Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:**
Expand Down
5 changes: 5 additions & 0 deletions packages/adapter-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,11 @@ export class APIAdapter implements StackAdapter {
await this.request<void>('DELETE', path);
}

async undeleteRecord(id: RecordId): Promise<StackRecord> {
const raw = await this.request<WireRecord>('POST', `/records/${id}/undelete`);
return parseRecord(raw);
}

async queryRecords(query: StackQuery): Promise<QueryResult> {
type Envelope = {
records: WireRecord[];
Expand Down
25 changes: 25 additions & 0 deletions packages/adapter-api/tests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// -------------------------------------------------------
Expand Down
4 changes: 4 additions & 0 deletions packages/adapter-local/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@ export class LocalAdapter implements StackAdapter {
return this.record.deleteRecord(id, opts);
}

async undeleteRecord(id: RecordId): Promise<StackRecord> {
return this.record.undeleteRecord(id);
}

async queryRecords(query: StackQuery): Promise<QueryResult> {
return this.record.queryRecords(query);
}
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/combine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
27 changes: 26 additions & 1 deletion packages/core/src/stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export interface StackClient {
dissociate(id: string, association: Association): Promise<void>;
setPermissions(id: string, permissions: Permission[]): Promise<void>;
delete(id: string, opts?: DeleteRecordOptions): Promise<void>;
undelete(id: string): Promise<StackRecord>;
getVersions(id: string): Promise<RecordVersion[]>;
getVersion(id: string, version: number): Promise<RecordVersion | null>;
restoreVersion(id: string, version: number): Promise<StackRecord>;
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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<StackRecord> {
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.
*/
Expand Down Expand Up @@ -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<StackRecord> {
await this.requireDeletable(id);
return this.stack.undelete(id);
}

async getVersions(id: string): Promise<RecordVersion[]> {
const existing = await this.stack.get(id, { migrate: false });
if (!existing) throw new StackNotFoundError(`Record not found: "${id}"`);
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<QueryResult> {
const f = query.filter ?? {};
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,8 @@ export interface StackRecordAdapter {
getRecord(id: RecordId): Promise<StackRecord | null>;
updateRecord(id: RecordId, changes: Partial<StackRecord>): Promise<StackRecord>;
deleteRecord(id: RecordId, opts?: { hard?: boolean }): Promise<void>;
/** Reverse a soft delete. Returns the record as it now stands. */
undeleteRecord(id: RecordId): Promise<StackRecord>;
queryRecords(query: StackQuery): Promise<QueryResult>;

// Associations
Expand Down
35 changes: 35 additions & 0 deletions packages/core/tests/scoped-stack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
Expand Down Expand Up @@ -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 });
Expand Down
65 changes: 64 additions & 1 deletion packages/core/tests/stack.test.ts
Original file line number Diff line number Diff line change
@@ -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';

// -------------------------------------------------------
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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
// -------------------------------------------------------
Expand Down
9 changes: 9 additions & 0 deletions packages/record-adapter-sqljs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,15 @@ export class SQLiteRecordAdapter implements StackRecordAdapter {
this.persist();
}

async undeleteRecord(id: string): Promise<StackRecord> {
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<QueryResult> {
const { sql: where, params } = buildWhereClause(query);
const order = buildOrderClause(query);
Expand Down
13 changes: 13 additions & 0 deletions packages/record-adapter-sqljs/tests/record.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading