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
12 changes: 8 additions & 4 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,27 +364,31 @@ Plain `Stack` methods remain unscoped and perform no permission checks — corre

## Versions

Version history is managed by the library as a side channel — apps do not manage it directly. On every `update()`, the library snapshots the previous state.
Version history is managed by the library as a side channel — apps do not manage it directly.

**One rule, no special cases:** every mutation of a Record — content (`update`), associations (`associate`/`dissociate`), permissions (`setPermissions`), soft delete, undelete — snapshots the Record's prior full state and bumps `version`. A mutation that changes nothing (re-adding an association that's already present, removing one that isn't there, setting a deep-equal permission set, deleting an already-deleted Record) is a no-op: no bump, no snapshot. Hard delete is the one exception — it destroys the Record and its version history outright, so there's nothing to snapshot.

```ts
type RecordVersion = {
version: number;
content: object;
updatedAt: Date;
entityId?: string; // Who made this change
associations?: Association[]; // Present if the record had associations at snapshot time
permissions?: Permission[]; // Present if the record had permissions at snapshot time
};
```

**API surface:**

- `stack.getVersions(recordId)` — retrieve version history
- `stack.restoreVersion(recordId, version)` — revert to a prior version
- `stack.restoreVersion(recordId, version)` — revert to a prior version. Restores `content` and `associations` when the target snapshot has them, but **never restores `permissions`** — those are owner/creator territory (see [Permissions](#permissions)), and silently reverting an ACL as a side effect of a content rollback would be a surprise nobody wants. Permissions in a snapshot are for audit and deliberate owner action, not automatic restore.

**Storage per adapter:**

- JSON: sibling file `{id}.versions.json`
- SQLite: `versions` table
- API: server snapshots automatically on every `PATCH /records/:id`; history is read via `/records/:id/versions`
- API: server snapshots automatically on every mutating endpoint (`PATCH /records/:id`, associations, permissions, delete, undelete); history is read via `/records/:id/versions`

---

Expand Down Expand Up @@ -707,7 +711,7 @@ PUT /records/:id/permissions — replace all permissions (empty array =

### Versions

The server snapshots a record's state automatically on every `PATCH /records/:id` — there is no client-initiated endpoint to write a version directly.
The server snapshots a record's state automatically on every mutating endpoint — content, associations, permissions, delete, undelete (see [Versions](#versions) for the full rule) — there is no client-initiated endpoint to write a version directly.

```
GET /records/:id/versions — list all versions (newest first)
Expand Down
2 changes: 2 additions & 0 deletions packages/adapter-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ const parseVersion = (raw: WireVersion): RecordVersion => {
updatedAt: new Date(raw.updatedAt),
};
if (raw.entityId != null) v.entityId = raw.entityId;
if (raw.associations != null) v.associations = raw.associations;
if (raw.permissions != null) v.permissions = raw.permissions;
return v;
};

Expand Down
13 changes: 13 additions & 0 deletions packages/adapter-api/tests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,19 @@ describe('getVersion', () => {
mockFetch.mockResolvedValueOnce(new Response(null, { status: 404 }));
expect(await adapter.getVersion('rec-abc123', 99)).toBeNull();
});

test('parses associations and permissions when present', async () => {
const adapter = await openAdapter();
const withOptionals = {
...VERSION_RAW,
associations: [{ kind: 'tag', label: 'starred' }],
permissions: [{ access: 'public' }],
};
mockFetch.mockResolvedValueOnce(jsonResponse(withOptionals));
const version = await adapter.getVersion('rec-abc123', 1);
expect(version?.associations).toEqual([{ kind: 'tag', label: 'starred' }]);
expect(version?.permissions).toEqual([{ access: 'public' }]);
});
});

describe('saveVersion', () => {
Expand Down
108 changes: 98 additions & 10 deletions packages/core/src/stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,54 +504,101 @@ export class Stack implements StackClient {
}

/**
* Add an association to a record.
* Add an association to a record. Snapshots the record's prior state and
* bumps version, same as update() — associations are covered by the same
* versioning rule as content.
* If the association already exists (same kind, label, and payload), this is a no-op.
*/
async associate(id: string, association: Association): Promise<void> {
return this.adapter.associate(id, association);
const existing = await this.adapter.getRecord(id);
if (!existing) {
throw new StackNotFoundError(`Record not found: "${id}"`);
}
if ((existing.associations ?? []).some((a) => associationEqual(a, association))) return;

await this.saveVersion(existing);
await this.adapter.associate(id, association);
await this.adapter.updateRecord(id, { version: existing.version + 1, updatedAt: new Date() });
}

/**
* Remove an association from a record.
* Matched by kind, label, and payload. No-op if not found.
* Remove an association from a record. Snapshots and bumps version, same
* as associate(). Matched by kind, label, and payload. No-op if not found.
*/
async dissociate(id: string, association: Association): Promise<void> {
return this.adapter.dissociate(id, association);
const existing = await this.adapter.getRecord(id);
if (!existing) {
throw new StackNotFoundError(`Record not found: "${id}"`);
}
if (!(existing.associations ?? []).some((a) => associationEqual(a, association))) return;

await this.saveVersion(existing);
await this.adapter.dissociate(id, association);
await this.adapter.updateRecord(id, { version: existing.version + 1, updatedAt: new Date() });
}

/**
* Replace all permissions on a record.
* Pass an empty array to make the record private (the default).
* Replace all permissions on a record. Snapshots and bumps version, same
* as associate(). Pass an empty array to make the record private (the
* default). No-op if the new set is deep-equal to the current one.
*/
async setPermissions(id: string, permissions: Permission[]): Promise<void> {
const existing = await this.adapter.getRecord(id);
if (!existing) {
throw new StackNotFoundError(`Record not found: "${id}"`);
}
await this.adapter.updateRecord(id, { permissions });
if (permissionsEqual(existing.permissions ?? [], permissions)) return;

await this.saveVersion(existing);
await this.adapter.updateRecord(id, {
permissions,
version: existing.version + 1,
updatedAt: new Date(),
});
}

/**
* Soft-delete a record (default) or hard-delete it permanently.
* Soft-deleted records are excluded from queries unless includeDeleted is set.
* Hard-deleted records and all their version history are permanently removed.
*
* Soft delete snapshots the record's prior state and bumps version, same
* as update(); it's a no-op if the record is already deleted. Hard delete
* destroys the record (and its version history) outright — there's
* nothing to snapshot.
*/
async delete(id: string, opts: DeleteRecordOptions = {}): Promise<void> {
return this.adapter.deleteRecord(id, opts);
if (opts.hard) {
return this.adapter.deleteRecord(id, opts);
}

const existing = await this.adapter.getRecord(id);
if (!existing) {
throw new StackNotFoundError(`Record not found: "${id}"`);
}
if (existing.deletedAt) return;

await this.saveVersion(existing);
await this.adapter.deleteRecord(id, opts);
await this.adapter.updateRecord(id, { version: existing.version + 1, updatedAt: new Date() });
}

/**
* 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.
* Snapshots and bumps version, same as delete().
*/
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);

await this.saveVersion(existing);
await this.adapter.undeleteRecord(id);
return this.adapter.updateRecord(id, { version: existing.version + 1, updatedAt: new Date() });
}

/**
Expand All @@ -577,6 +624,12 @@ export class Stack implements StackClient {
/**
* Restore a record to a previous version by creating a new version
* with the old content. Never rewrites history.
*
* Restores associations too, when the target snapshot has them. Never
* restores permissions — those are owner/creator territory (see
* setPermissions()), and silently reverting an ACL as a side effect of a
* content rollback would be a surprise nobody wants. Permissions in a
* snapshot are for audit and deliberate owner action, not automatic restore.
*/
async restoreVersion(id: string, version: number): Promise<StackRecord> {
const existing = await this.adapter.getRecord(id);
Expand All @@ -596,6 +649,7 @@ export class Stack implements StackClient {
content: target.content,
updatedAt: new Date(),
version: existing.version + 1,
...(target.associations !== undefined && { associations: target.associations }),
});
}

Expand Down Expand Up @@ -760,11 +814,45 @@ export class Stack implements StackClient {
content: record.content,
updatedAt: record.updatedAt,
...(record.entityId && { entityId: record.entityId }),
...(record.associations && { associations: record.associations }),
...(record.permissions && { permissions: record.permissions }),
};
await this.adapter.saveVersion(record.id, version);
}
}

// -------------------------------------------------------
// Equality helpers
// -------------------------------------------------------

/**
* Matches the SQLite adapter's association primary key (kind, label,
* file_id, related_id) — mimeType is not part of an attachment
* association's identity.
*/
function associationEqual(a: Association, b: Association): boolean {
if (a.kind !== b.kind || a.label !== b.label) return false;
if (a.kind === 'attachment' && b.kind === 'attachment') return a.fileId === b.fileId;
if (a.kind === 'relationship' && b.kind === 'relationship') return a.recordId === b.recordId;
return true;
}

function permissionEqual(a: Permission, b: Permission): boolean {
if (a.access !== b.access) return false;
if (a.access === 'public') return true;
if (a.access === 'entity' && b.access === 'entity') {
return a.entityId === b.entityId && a.read === b.read && a.write === b.write;
}
if (a.access === 'group' && b.access === 'group') {
return a.groupId === b.groupId && a.read === b.read && a.write === b.write;
}
return false;
}

function permissionsEqual(a: Permission[], b: Permission[]): boolean {
return a.length === b.length && a.every((p, i) => permissionEqual(p, b[i]));
}

// -------------------------------------------------------
// ScopedStack
// -------------------------------------------------------
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 @@ -92,6 +92,8 @@ export type RecordVersion = {
content: Record<string, unknown>;
updatedAt: Date;
entityId?: RecordId; // Who made this change
associations?: Association[];
permissions?: Permission[];
};

// -------------------------------------------------------
Expand Down
Loading
Loading