From 17df0a84056402818e97ebd5b46dce23844417ec Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 18:08:14 +0000 Subject: [PATCH] feat(core): add undelete API to reverse soft deletes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #60. The spec promises soft-delete recovery, but nothing implemented it — Stack/ScopedStack only exposed delete(). Adds: - StackRecordAdapter.undeleteRecord(id): Promise, a dedicated adapter method (not routed through the generic updateRecord Partial merge, which can't express "clear deletedAt" — every adapter treats an undefined field as "leave untouched," so there's no way to signal removal through it). Implemented in MemoryAdapter, record-adapter-sqljs, adapter-api (POST /records/:id/undelete), and wired through combineAdapters() and adapter-local's LocalAdapter. - Stack.undelete(id): idempotent (a non-deleted record returns unchanged), StackNotFoundError for missing/hard-deleted records. - ScopedStack.undelete(id): gated by requireDeletable, the same write-bit-or-delete-grant check as delete() — undelete is delete's inverse, so the same capability governs both directions. Hard delete's owner-only carve-out (#59) is untouched; it has no inverse. - migrateAll() now sweeps soft-deleted records too, so a record migrated while deleted comes back current on undelete instead of stranded on a stale schema. - Spec: §Deletion documents the API and idempotency contract; wire table gains the endpoint. Out of scope, per #60's own framing: version-bump-on-delete/undelete (tracked in #61, which hasn't landed — delete() doesn't bump version either yet, so undelete() stays symmetric with it) and the shared conformance-fixture framework (#52, doesn't exist in this repo yet; covered here with regular adapter-level tests instead). --- docs/spec.md | 13 +++- packages/adapter-api/src/index.ts | 5 ++ packages/adapter-api/tests/api.test.ts | 25 +++++++ packages/adapter-local/src/index.ts | 4 ++ packages/core/src/combine.ts | 1 + packages/core/src/stack.ts | 27 +++++++- packages/core/src/testing.ts | 9 +++ packages/core/src/types.ts | 2 + packages/core/tests/scoped-stack.test.ts | 35 ++++++++++ packages/core/tests/stack.test.ts | 65 ++++++++++++++++++- packages/record-adapter-sqljs/src/index.ts | 9 +++ .../record-adapter-sqljs/tests/record.test.ts | 13 ++++ 12 files changed, 205 insertions(+), 3 deletions(-) diff --git a/docs/spec.md b/docs/spec.md index 06c05fe..3cf5bb1 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -33,7 +33,7 @@ stack.timezone; // from adapter.timezone `LocalAdapter.initialize()` fails if the file already exists. `LocalAdapter.open()` fails if the file does not exist. This makes the distinction explicit and prevents silent config divergence. -Plugin and extension code that doesn't need to know the underlying backend should accept `StackClient` rather than the concrete `Stack` or `ScopedStack`. `StackClient` is the passable interface covering the full record API (`create`, `get`, `query`, `update`, `delete`, `associate`, `dissociate`, `setPermissions`, `getVersions`, `getVersion`, `restoreVersion`, `getAttachment`, `putAttachment`, `deleteAttachment`) plus a `features` getter. Both `Stack` and `ScopedStack` implement it. +Plugin and extension code that doesn't need to know the underlying backend should accept `StackClient` rather than the concrete `Stack` or `ScopedStack`. `StackClient` is the passable interface covering the full record API (`create`, `get`, `query`, `update`, `delete`, `undelete`, `associate`, `dissociate`, `setPermissions`, `getVersions`, `getVersion`, `restoreVersion`, `getAttachment`, `putAttachment`, `deleteAttachment`) plus a `features` getter. Both `Stack` and `ScopedStack` implement it. **Stack identity** (`ownerEntityId`, `timezone`) is stored as a singleton `_config@1` record in the records table. Adapters expose these values as typed readonly properties (`adapter.ownerEntityId`, `adapter.timezone`) rather than as a generic key/value store. @@ -579,6 +579,16 @@ Queries exclude soft-deleted Records by default. Opt in with: stack.query({ filter: { includeDeleted: true } }); ``` +**Undelete** reverses a soft delete. It's idempotent — calling it on a Record that isn't deleted succeeds and returns the Record unchanged, so a retried call after a network blip never fails. A missing Record throws `StackNotFoundError`; a hard-deleted Record is simply missing, so it throws the same way. + +```ts +const record = await stack.undelete(recordId); // clears deletedAt, returns the record +``` + +Under `ScopedStack`, `undelete()` is gated the same way as `delete()` — the `write` bit or a `delete-own`/`delete-any` grant. Undelete is the inverse of soft delete, so the same capability governs both directions; granting one without the other would be backwards. (Hard delete's owner-only carve-out above is unaffected — it has no inverse.) + +Undelete does not re-run migrations. If a soft-deleted Record's schema fell behind while it was deleted, it comes back stale — a legal state, self-healing the same way any other stale Record is, the next time it's written or `migrateAll()` sweeps it. `migrateAll()` includes soft-deleted Records in its sweep, so a Record can be migrated while deleted and come back current on undelete. + **Restore** always creates a new version with the old content — it never rewrites history. The act of restoring is itself part of the version history. ```ts @@ -648,6 +658,7 @@ GET /records/:id — get one PATCH /records/:id — update content only (partial merge, null = delete field) DELETE /records/:id — soft delete DELETE /records/:id?hard=true — hard delete +POST /records/:id/undelete — undelete (reverse a soft delete; idempotent) ``` **`GET /records` query params:** diff --git a/packages/adapter-api/src/index.ts b/packages/adapter-api/src/index.ts index d64caa1..78722c9 100644 --- a/packages/adapter-api/src/index.ts +++ b/packages/adapter-api/src/index.ts @@ -323,6 +323,11 @@ export class APIAdapter implements StackAdapter { await this.request('DELETE', path); } + async undeleteRecord(id: RecordId): Promise { + const raw = await this.request('POST', `/records/${id}/undelete`); + return parseRecord(raw); + } + async queryRecords(query: StackQuery): Promise { type Envelope = { records: WireRecord[]; diff --git a/packages/adapter-api/tests/api.test.ts b/packages/adapter-api/tests/api.test.ts index b70f286..9815f32 100644 --- a/packages/adapter-api/tests/api.test.ts +++ b/packages/adapter-api/tests/api.test.ts @@ -315,6 +315,31 @@ describe('deleteRecord', () => { }); }); +// ------------------------------------------------------- +// undeleteRecord +// ------------------------------------------------------- + +describe('undeleteRecord', () => { + test('sends POST /records/:id/undelete', async () => { + const adapter = await openAdapter(); + mockFetch.mockResolvedValueOnce(jsonResponse(RECORD_RAW)); + await adapter.undeleteRecord('rec-abc123'); + expect(mockFetch).toHaveBeenLastCalledWith( + `${BASE_URL}/records/rec-abc123/undelete`, + expect.objectContaining({ method: 'POST' }), + ); + }); + + test('returns the record with parsed dates', async () => { + const adapter = await openAdapter(); + mockFetch.mockResolvedValueOnce(jsonResponse(RECORD_RAW)); + const result = await adapter.undeleteRecord('rec-abc123'); + expect(result.id).toBe('rec-abc123'); + expect(result.createdAt).toBeInstanceOf(Date); + expect(result.deletedAt).toBeUndefined(); + }); +}); + // ------------------------------------------------------- // queryRecords // ------------------------------------------------------- diff --git a/packages/adapter-local/src/index.ts b/packages/adapter-local/src/index.ts index 32a25cb..b10e335 100644 --- a/packages/adapter-local/src/index.ts +++ b/packages/adapter-local/src/index.ts @@ -125,6 +125,10 @@ export class LocalAdapter implements StackAdapter { return this.record.deleteRecord(id, opts); } + async undeleteRecord(id: RecordId): Promise { + return this.record.undeleteRecord(id); + } + async queryRecords(query: StackQuery): Promise { return this.record.queryRecords(query); } diff --git a/packages/core/src/combine.ts b/packages/core/src/combine.ts index 4e86305..1673d95 100644 --- a/packages/core/src/combine.ts +++ b/packages/core/src/combine.ts @@ -26,6 +26,7 @@ export function combineAdapters(parts: { getRecord: (id) => parts.record.getRecord(id), updateRecord: (id, changes) => parts.record.updateRecord(id, changes), deleteRecord: (id, opts) => parts.record.deleteRecord(id, opts), + undeleteRecord: (id) => parts.record.undeleteRecord(id), queryRecords: (q) => parts.record.queryRecords(q), associate: (id, assoc) => parts.record.associate(id, assoc), diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index 41aa2ce..246987f 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -128,6 +128,7 @@ export interface StackClient { dissociate(id: string, association: Association): Promise; setPermissions(id: string, permissions: Permission[]): Promise; delete(id: string, opts?: DeleteRecordOptions): Promise; + undelete(id: string): Promise; getVersions(id: string): Promise; getVersion(id: string, version: number): Promise; restoreVersion(id: string, version: number): Promise; @@ -339,7 +340,7 @@ export class Stack implements StackClient { let cursor: string | undefined; do { const result: QueryResult = await this.adapter.queryRecords({ - filter: { typeId }, + filter: { typeId, includeDeleted: true }, limit: 100, cursor, }); @@ -539,6 +540,20 @@ export class Stack implements StackClient { return this.adapter.deleteRecord(id, opts); } + /** + * Reverse a soft delete. Idempotent — undeleting a record that isn't + * deleted returns it unchanged. Hard-deleted records are gone, so this + * throws StackNotFoundError for them just like any other missing record. + */ + async undelete(id: string): Promise { + const existing = await this.adapter.getRecord(id); + if (!existing) { + throw new StackNotFoundError(`Record not found: "${id}"`); + } + if (!existing.deletedAt) return existing; + return this.adapter.undeleteRecord(id); + } + /** * Query records. See StackQuery for filter, sort, and pagination options. */ @@ -975,6 +990,16 @@ export class ScopedStack implements StackClient { return this.stack.delete(id, opts); } + /** + * Reverse a soft delete. Gated the same as delete() — undelete is the + * inverse of soft delete, so granting one direction without the other + * would be backwards. Idempotent, per Stack.undelete(). + */ + async undelete(id: string): Promise { + await this.requireDeletable(id); + return this.stack.undelete(id); + } + async getVersions(id: string): Promise { const existing = await this.stack.get(id, { migrate: false }); if (!existing) throw new StackNotFoundError(`Record not found: "${id}"`); diff --git a/packages/core/src/testing.ts b/packages/core/src/testing.ts index a757486..d4178fa 100644 --- a/packages/core/src/testing.ts +++ b/packages/core/src/testing.ts @@ -65,6 +65,15 @@ export class MemoryAdapter implements StackAdapter { } } + async undeleteRecord(id: string) { + const record = this.records.get(id); + if (!record) throw new Error(`Not found: ${id}`); + const { deletedAt: _deletedAt, ...rest } = record; + const updated = rest as StackRecord; + this.records.set(id, updated); + return updated; + } + /** Cursor is a stringified offset into insertion order. */ async queryRecords(query: StackQuery): Promise { const f = query.filter ?? {}; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 3e4d46d..2e92d6e 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -339,6 +339,8 @@ export interface StackRecordAdapter { getRecord(id: RecordId): Promise; updateRecord(id: RecordId, changes: Partial): Promise; deleteRecord(id: RecordId, opts?: { hard?: boolean }): Promise; + /** Reverse a soft delete. Returns the record as it now stands. */ + undeleteRecord(id: RecordId): Promise; queryRecords(query: StackQuery): Promise; // Associations diff --git a/packages/core/tests/scoped-stack.test.ts b/packages/core/tests/scoped-stack.test.ts index 4ded0c1..6e82ba4 100644 --- a/packages/core/tests/scoped-stack.test.ts +++ b/packages/core/tests/scoped-stack.test.ts @@ -186,6 +186,26 @@ describe('ScopedStack — write access', () => { expect(await adapter.getRecord(record.id)).toBeNull(); }); + test('undelete enforces write access', async () => { + const record = await adapter.createRecord(makeRecord({ deletedAt: new Date() })); + await expect(stack.asEntity(STRANGER).undelete(record.id)).rejects.toThrow( + StackPermissionError, + ); + const undeleted = await stack.asEntity(OWNER).undelete(record.id); + expect(undeleted.deletedAt).toBeUndefined(); + }); + + test('write:true holder can undelete', async () => { + const record = await adapter.createRecord( + makeRecord({ + deletedAt: new Date(), + permissions: [{ access: 'entity', entityId: MEMBER, read: true, write: true }], + }), + ); + const undeleted = await stack.asEntity(MEMBER).undelete(record.id); + expect(undeleted.deletedAt).toBeUndefined(); + }); + test('associate/dissociate/setPermissions enforce write access', async () => { const record = await adapter.createRecord(makeRecord()); const tag: Association = { kind: 'tag', label: 'starred' }; @@ -537,6 +557,21 @@ describe('ScopedStack — grant-based update/delete', () => { ); }); + test('delete-any grant also allows undelete', async () => { + await stack.grant(MEMBER, [{ actions: ['delete-any'], typeId: COMMENT }]); + const record = await stack.create(COMMENT, { text: 'hello' }, { entityId: STRANGER }); + await stack.asEntity(MEMBER).delete(record.id); + const undeleted = await stack.asEntity(MEMBER).undelete(record.id); + expect(undeleted.deletedAt).toBeUndefined(); + }); + + test('update grant does not allow undelete', async () => { + await stack.grant(MEMBER, [{ actions: ['update-any'], typeId: COMMENT }]); + const record = await stack.create(COMMENT, { text: 'hello' }, { entityId: MEMBER }); + await adapter.deleteRecord(record.id); + await expect(stack.asEntity(MEMBER).undelete(record.id)).rejects.toThrow(StackPermissionError); + }); + test('default grant (null entityId) applies update-own to any authenticated entity', async () => { await stack.grant(null, [{ actions: ['update-own'], typeId: COMMENT }]); const record = await stack.create(COMMENT, { text: 'original' }, { entityId: STRANGER }); diff --git a/packages/core/tests/stack.test.ts b/packages/core/tests/stack.test.ts index 3e10128..d7b645a 100644 --- a/packages/core/tests/stack.test.ts +++ b/packages/core/tests/stack.test.ts @@ -1,5 +1,10 @@ import { describe, test, expect, beforeEach } from 'vitest'; -import { Stack, StackValidationError, StackMigrationError } from '../src/stack.js'; +import { + Stack, + StackValidationError, + StackMigrationError, + StackNotFoundError, +} from '../src/stack.js'; import { MemoryAdapter } from '../src/testing.js'; // ------------------------------------------------------- @@ -341,6 +346,18 @@ describe('migrateAll', () => { expect(result.migrated).toBe(0); }); + test('sweeps soft-deleted records too, so undelete returns them healed', async () => { + const record = await stack.create(NOTE_V1, { text: 'stale' }); + await stack.delete(record.id); + + const result = await stack.migrateAll('com.example.test/note'); + + expect(result.migrated).toBe(1); + const undeleted = await stack.undelete(record.id); + expect(undeleted.typeId).toBe(NOTE_V2); + expect(undeleted.content).toEqual({ text: 'stale', title: '' }); + }); + test('snapshots previous content to version history before migrating', async () => { const record = await stack.create(NOTE_V1, { text: 'original' }); await stack.migrateAll('com.example.test/note'); @@ -423,6 +440,52 @@ describe('delete', () => { }); }); +// ------------------------------------------------------- +// undelete +// ------------------------------------------------------- + +describe('undelete', () => { + test('reverses a soft delete', async () => { + const record = await stack.create(NOTE_V1, { text: 'hello' }); + await stack.delete(record.id); + const undeleted = await stack.undelete(record.id); + expect(undeleted.deletedAt).toBeUndefined(); + expect((await adapter.getRecord(record.id))?.deletedAt).toBeUndefined(); + }); + + test('is idempotent — undeleting a non-deleted record returns it unchanged', async () => { + const record = await stack.create(NOTE_V1, { text: 'hello' }); + const result = await stack.undelete(record.id); + expect(result).toEqual(record); + }); + + test('a second undelete call is also a no-op success', async () => { + const record = await stack.create(NOTE_V1, { text: 'hello' }); + await stack.delete(record.id); + await stack.undelete(record.id); + const result = await stack.undelete(record.id); + expect(result.deletedAt).toBeUndefined(); + }); + + test('throws StackNotFoundError for a hard-deleted (missing) record', async () => { + const record = await stack.create(NOTE_V1, { text: 'hello' }); + await stack.delete(record.id, { hard: true }); + await expect(stack.undelete(record.id)).rejects.toThrow(StackNotFoundError); + }); + + test('throws StackNotFoundError for a record that never existed', async () => { + await expect(stack.undelete('nonexistent')).rejects.toThrow(StackNotFoundError); + }); + + test('undeleted record is included in default queries again', async () => { + const record = await stack.create(NOTE_V1, { text: 'hello' }); + await stack.delete(record.id); + await stack.undelete(record.id); + const result = await stack.query({ filter: { typeId: NOTE_V1 } }); + expect(result.records.find((r) => r.id === record.id)).toBeDefined(); + }); +}); + // ------------------------------------------------------- // flush / close lifecycle // ------------------------------------------------------- diff --git a/packages/record-adapter-sqljs/src/index.ts b/packages/record-adapter-sqljs/src/index.ts index 78ed22d..0d94c08 100644 --- a/packages/record-adapter-sqljs/src/index.ts +++ b/packages/record-adapter-sqljs/src/index.ts @@ -636,6 +636,15 @@ export class SQLiteRecordAdapter implements StackRecordAdapter { this.persist(); } + async undeleteRecord(id: string): Promise { + this.db.run('UPDATE records SET deleted_at = NULL WHERE id = ?', [id]); + this.persist(); + + const updated = await this.getRecord(id); + if (!updated) throw new Error(`Record not found after undelete: "${id}"`); + return updated; + } + async queryRecords(query: StackQuery): Promise { const { sql: where, params } = buildWhereClause(query); const order = buildOrderClause(query); diff --git a/packages/record-adapter-sqljs/tests/record.test.ts b/packages/record-adapter-sqljs/tests/record.test.ts index 7afba38..516fca0 100644 --- a/packages/record-adapter-sqljs/tests/record.test.ts +++ b/packages/record-adapter-sqljs/tests/record.test.ts @@ -250,6 +250,19 @@ describe('records — CRUD', () => { expect(await adapter.getVersions(record.id)).toEqual([]); }); + test('undeleteRecord clears deletedAt and returns the record', async () => { + const adapter = await initAdapter(); + const record = makeRecord(); + await adapter.createRecord(record); + await adapter.deleteRecord(record.id); + + const undeleted = await adapter.undeleteRecord(record.id); + expect(undeleted.deletedAt).toBeUndefined(); + + const retrieved = await adapter.getRecord(record.id); + expect(retrieved?.deletedAt).toBeUndefined(); + }); + test('stored dates roundtrip correctly', async () => { const adapter = await initAdapter(); const createdAt = new Date('2024-06-15T12:00:00.000Z');