diff --git a/docs/spec.md b/docs/spec.md index 5fd0bcc..ea155f2 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -690,7 +690,7 @@ Standard HTTP status codes are used throughout: | **413** | Request entity too large | Attachment upload exceeds the server's size limit | | **422** | Unprocessable entity | `StackValidationError` — request is syntactically valid but content fails schema validation (e.g. a required field has the wrong type) | -The distinction between **400** and **422** matters for write endpoints (`POST /records`, `PATCH /records/:id`, `POST /types`): a 400 means the request couldn't be parsed at all; a 422 means the server understood the request but the content didn't satisfy the type schema. +The distinction between **400** and **422** matters for write endpoints (`POST /records`, `PATCH /records/:id`, `POST /records/:id/migrate`, `POST /types`): a 400 means the request couldn't be parsed at all; a 422 means the server understood the request but the content didn't satisfy the type schema. ### Records @@ -703,6 +703,7 @@ PATCH /records/:id — update content only (partial merge, null = dele DELETE /records/:id — soft delete DELETE /records/:id?hard=true — hard delete POST /records/:id/undelete — undelete (reverse a soft delete; idempotent) +POST /records/:id/migrate — commit a migration (change typeId + content together) ``` **`GET /records` query params:** @@ -734,6 +735,8 @@ POST /records/:id/undelete — undelete (reverse a soft delete; idempotent) `POST /records` accepts a full record body, including an optional client-supplied `id` — see [Record IDs](#record-ids) for the validation and duplicate-conflict rules the server applies. +`POST /records/:id/migrate` is the only way a record's `typeId` changes after creation. Body: `{ "toTypeId": "...", "content": {...} }` — the full post-migration content, computed client-side by the type's owning app (migration functions are app code, not server code) and validated by the server against `toTypeId`'s schema before writing. This is what `stack.update()` uses to commit a pending lazy migration alongside a content patch (a content-only `PATCH` can't carry a `typeId` change), and what `stack.migrateAll()` uses for each record in a batch pass. + ### Permissions ``` @@ -741,6 +744,8 @@ GET /records/:id/permissions — get current permissions PUT /records/:id/permissions — replace all permissions (empty array = private) ``` +Both endpoints use the envelope `{ "permissions": [...] }` as the request/response body. + **Response envelope:** ```json diff --git a/packages/adapter-api/package.json b/packages/adapter-api/package.json index f7bb108..1cd687a 100644 --- a/packages/adapter-api/package.json +++ b/packages/adapter-api/package.json @@ -26,6 +26,7 @@ "@haverstack/wire-types": "workspace:*" }, "devDependencies": { + "@haverstack/conformance-fixtures": "workspace:*", "@types/node": "^22.0.0", "typescript": "^5.5.0", "vitest": "^2.0.0" diff --git a/packages/adapter-api/src/index.ts b/packages/adapter-api/src/index.ts index 5e074ad..71b222b 100644 --- a/packages/adapter-api/src/index.ts +++ b/packages/adapter-api/src/index.ts @@ -23,6 +23,7 @@ import type { StackQuery, QueryResult, Association, + Permission, AdapterCapabilities, RecordId, FileId, @@ -315,8 +316,24 @@ export class APIAdapter implements StackAdapter { return raw ? parseRecord(raw) : null; } - async updateRecord(id: RecordId, changes: Partial): Promise { - const raw = await this.request('PATCH', `/records/${id}`, changes); + async patchContent(id: RecordId, patch: Record): Promise { + // Content-only RFC 7396 merge patch — no record fields (typeId, version, + // updatedAt) travel in this body. The server merges against its own + // current state and assigns the new version/updatedAt; the response is + // authoritative. + const raw = await this.request('PATCH', `/records/${id}`, patch); + return parseRecord(raw); + } + + async commitMigration( + id: RecordId, + toTypeId: TypeId, + content: Record, + ): Promise { + const raw = await this.request('POST', `/records/${id}/migrate`, { + toTypeId, + content, + }); return parseRecord(raw); } @@ -367,6 +384,14 @@ export class APIAdapter implements StackAdapter { await this.request('DELETE', `/records/${id}/associations`, association); } + // ------------------------------------------------------- + // Permissions + // ------------------------------------------------------- + + async setPermissions(id: RecordId, permissions: Permission[]): Promise { + await this.request('PUT', `/records/${id}/permissions`, { permissions }); + } + // ------------------------------------------------------- // Versions // ------------------------------------------------------- @@ -387,8 +412,14 @@ export class APIAdapter implements StackAdapter { } async saveVersion(_id: RecordId, _version: RecordVersion): Promise { - // The server snapshots versions automatically as a side effect of updateRecord. - // There is no client-initiated saveVersion endpoint in the wire protocol. + // The server snapshots versions automatically as a side effect of every + // mutating endpoint. There is no client-initiated saveVersion endpoint + // in the wire protocol. + } + + async restoreVersion(id: RecordId, version: number): Promise { + const raw = await this.request('POST', `/records/${id}/restore/${version}`); + return parseRecord(raw); } // ------------------------------------------------------- diff --git a/packages/adapter-api/tests/api.test.ts b/packages/adapter-api/tests/api.test.ts index 0bff88c..5f392ee 100644 --- a/packages/adapter-api/tests/api.test.ts +++ b/packages/adapter-api/tests/api.test.ts @@ -264,31 +264,88 @@ describe('getRecord', () => { }); // ------------------------------------------------------- -// updateRecord +// patchContent // ------------------------------------------------------- -describe('updateRecord', () => { +describe('patchContent', () => { test('sends PATCH /records/:id', async () => { const adapter = await openAdapter(); const updated = { ...RECORD_RAW, content: { text: 'Updated' }, version: 2 }; mockFetch.mockResolvedValueOnce(jsonResponse(updated)); - await adapter.updateRecord('rec-abc123', { content: { text: 'Updated' }, version: 2 }); + await adapter.patchContent('rec-abc123', { text: 'Updated' }); expect(mockFetch).toHaveBeenLastCalledWith( `${BASE_URL}/records/rec-abc123`, expect.objectContaining({ method: 'PATCH' }), ); }); + test('sends only the raw patch — no record fields — as the body', async () => { + const adapter = await openAdapter(); + const updated = { ...RECORD_RAW, content: { text: 'Updated' }, version: 2 }; + mockFetch.mockResolvedValueOnce(jsonResponse(updated)); + await adapter.patchContent('rec-abc123', { text: 'Updated', removedField: null }); + const [, init] = mockFetch.mock.lastCall as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toEqual({ text: 'Updated', removedField: null }); + }); + test('returns updated record with parsed dates', async () => { const adapter = await openAdapter(); const updated = { ...RECORD_RAW, content: { text: 'Updated' }, version: 2 }; mockFetch.mockResolvedValueOnce(jsonResponse(updated)); - const result = await adapter.updateRecord('rec-abc123', { content: { text: 'Updated' } }); + const result = await adapter.patchContent('rec-abc123', { text: 'Updated' }); expect(result.content).toEqual({ text: 'Updated' }); expect(result.updatedAt).toBeInstanceOf(Date); }); }); +// ------------------------------------------------------- +// commitMigration +// ------------------------------------------------------- + +describe('commitMigration', () => { + test('sends POST /records/:id/migrate with toTypeId and content', async () => { + const adapter = await openAdapter(); + const migrated = { ...RECORD_RAW, typeId: 'com.example/note@2', version: 2 }; + mockFetch.mockResolvedValueOnce(jsonResponse(migrated)); + await adapter.commitMigration('rec-abc123', 'com.example/note@2', { text: 'Hello world' }); + expect(mockFetch).toHaveBeenLastCalledWith( + `${BASE_URL}/records/rec-abc123/migrate`, + expect.objectContaining({ method: 'POST' }), + ); + const [, init] = mockFetch.mock.lastCall as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toEqual({ + toTypeId: 'com.example/note@2', + content: { text: 'Hello world' }, + }); + }); + + test('returns the migrated record', async () => { + const adapter = await openAdapter(); + const migrated = { ...RECORD_RAW, typeId: 'com.example/note@2', version: 2 }; + mockFetch.mockResolvedValueOnce(jsonResponse(migrated)); + const result = await adapter.commitMigration('rec-abc123', 'com.example/note@2', {}); + expect(result.typeId).toBe('com.example/note@2'); + }); +}); + +// ------------------------------------------------------- +// setPermissions +// ------------------------------------------------------- + +describe('setPermissions', () => { + test('sends PUT /records/:id/permissions with a permissions envelope', async () => { + const adapter = await openAdapter(); + mockFetch.mockResolvedValueOnce(noContent()); + await adapter.setPermissions('rec-abc123', [{ access: 'public' }]); + expect(mockFetch).toHaveBeenLastCalledWith( + `${BASE_URL}/records/rec-abc123/permissions`, + expect.objectContaining({ method: 'PUT' }), + ); + const [, init] = mockFetch.mock.lastCall as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toEqual({ permissions: [{ access: 'public' }] }); + }); +}); + // ------------------------------------------------------- // deleteRecord // ------------------------------------------------------- @@ -503,6 +560,28 @@ describe('getVersion', () => { }); }); +describe('restoreVersion', () => { + test('sends POST /records/:id/restore/:version', async () => { + const adapter = await openAdapter(); + const restored = { ...RECORD_RAW, content: { text: 'original' }, version: 2 }; + mockFetch.mockResolvedValueOnce(jsonResponse(restored)); + await adapter.restoreVersion('rec-abc123', 1); + expect(mockFetch).toHaveBeenLastCalledWith( + `${BASE_URL}/records/rec-abc123/restore/1`, + expect.objectContaining({ method: 'POST' }), + ); + }); + + test('returns the restored record with parsed dates', async () => { + const adapter = await openAdapter(); + const restored = { ...RECORD_RAW, content: { text: 'original' }, version: 2 }; + mockFetch.mockResolvedValueOnce(jsonResponse(restored)); + const result = await adapter.restoreVersion('rec-abc123', 1); + expect(result.content).toEqual({ text: 'original' }); + expect(result.createdAt).toBeInstanceOf(Date); + }); +}); + describe('saveVersion', () => { test('is a no-op — does not make any HTTP requests', async () => { const adapter = await openAdapter(); diff --git a/packages/adapter-api/tests/conformance.test.ts b/packages/adapter-api/tests/conformance.test.ts new file mode 100644 index 0000000..17b7301 --- /dev/null +++ b/packages/adapter-api/tests/conformance.test.ts @@ -0,0 +1,219 @@ +/** + * Drives APIAdapter through the shared wire-protocol fixtures from + * @haverstack/conformance-fixtures, asserting it produces the documented + * request for each fixture and parses the documented response. The same + * fixtures are importable by a server implementation to assert the mirror + * image: that its HTTP handlers accept the documented request and produce + * the documented response. See that package for the fixture data itself. + */ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import { APIAdapter } from '../src/index.js'; +import { + createRecordFixtures, + patchContentFixtures, + deleteRecordFixtures, + undeleteRecordFixtures, + associateFixtures, + dissociateFixtures, + setPermissionsFixtures, + restoreVersionFixtures, + commitMigrationFixtures, +} from '@haverstack/conformance-fixtures'; +import type { Association } from '@haverstack/core'; + +const BASE_URL = 'https://stack.example.com'; + +const DISCOVERY = { + version: '1.0', + entityId: 'entity-owner-123', + timezone: 'UTC', + capabilities: { + fullTextSearch: true, + contentFieldQuery: true, + sortableFields: ['createdAt', 'updatedAt', 'version'], + }, +}; + +const jsonResponse = (body: unknown, status = 200): Response => + new Response(body === undefined ? null : JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + +let mockFetch: ReturnType; + +beforeEach(() => { + mockFetch = vi.fn(); + vi.stubGlobal('fetch', mockFetch); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const openAdapter = async (): Promise => { + mockFetch.mockResolvedValueOnce(jsonResponse(DISCOVERY)); + return APIAdapter.open({ url: BASE_URL }); +}; + +/** Record id embedded in a fixture path like "/records/rec-1" or ".../rec-1/permissions". */ +const idFromPath = (path: string): string => path.split('/')[2].split('?')[0]; + +describe('createRecord fixtures', () => { + for (const fixture of createRecordFixtures) { + test(fixture.name, async () => { + const adapter = await openAdapter(); + mockFetch.mockResolvedValueOnce(jsonResponse(fixture.responseBody, fixture.responseStatus)); + + const { createdAt, updatedAt, deletedAt, ...req } = fixture.requestBody!; + await adapter.createRecord({ + ...req, + createdAt: new Date(createdAt), + updatedAt: new Date(updatedAt), + ...(deletedAt !== undefined && { deletedAt: new Date(deletedAt) }), + }); + + const [url, init] = mockFetch.mock.lastCall as [string, RequestInit]; + expect(url).toBe(`${BASE_URL}${fixture.path}`); + expect(init.method).toBe(fixture.method); + expect(JSON.parse(init.body as string)).toEqual(fixture.requestBody); + }); + } +}); + +describe('patchContent fixtures', () => { + for (const fixture of patchContentFixtures) { + test(fixture.name, async () => { + const adapter = await openAdapter(); + mockFetch.mockResolvedValueOnce(jsonResponse(fixture.responseBody, fixture.responseStatus)); + + const result = await adapter.patchContent(idFromPath(fixture.path), fixture.requestBody!); + + const [url, init] = mockFetch.mock.lastCall as [string, RequestInit]; + expect(url).toBe(`${BASE_URL}${fixture.path}`); + expect(init.method).toBe(fixture.method); + // The wire body is the raw patch only — never typeId, version, or updatedAt. + expect(JSON.parse(init.body as string)).toEqual(fixture.requestBody); + expect(result.content).toEqual(fixture.responseBody!.content); + expect(result.version).toBe(fixture.responseBody!.version); + }); + } +}); + +describe('deleteRecord fixtures', () => { + for (const fixture of deleteRecordFixtures) { + test(fixture.name, async () => { + const adapter = await openAdapter(); + mockFetch.mockResolvedValueOnce(new Response(null, { status: fixture.responseStatus })); + + const hard = fixture.path.includes('hard=true'); + await adapter.deleteRecord(idFromPath(fixture.path), { hard }); + + const [url, init] = mockFetch.mock.lastCall as [string, RequestInit]; + expect(url).toBe(`${BASE_URL}${fixture.path}`); + expect(init.method).toBe(fixture.method); + }); + } +}); + +describe('undeleteRecord fixtures', () => { + for (const fixture of undeleteRecordFixtures) { + test(fixture.name, async () => { + const adapter = await openAdapter(); + mockFetch.mockResolvedValueOnce(jsonResponse(fixture.responseBody, fixture.responseStatus)); + + const result = await adapter.undeleteRecord(idFromPath(fixture.path)); + + const [url, init] = mockFetch.mock.lastCall as [string, RequestInit]; + expect(url).toBe(`${BASE_URL}${fixture.path}`); + expect(init.method).toBe(fixture.method); + expect(result.deletedAt).toBeUndefined(); + }); + } +}); + +describe('associate fixtures', () => { + for (const fixture of associateFixtures) { + test(fixture.name, async () => { + const adapter = await openAdapter(); + mockFetch.mockResolvedValueOnce(new Response(null, { status: fixture.responseStatus })); + + await adapter.associate(idFromPath(fixture.path), fixture.requestBody as Association); + + const [url, init] = mockFetch.mock.lastCall as [string, RequestInit]; + expect(url).toBe(`${BASE_URL}${fixture.path}`); + expect(init.method).toBe(fixture.method); + expect(JSON.parse(init.body as string)).toEqual(fixture.requestBody); + }); + } +}); + +describe('dissociate fixtures', () => { + for (const fixture of dissociateFixtures) { + test(fixture.name, async () => { + const adapter = await openAdapter(); + mockFetch.mockResolvedValueOnce(new Response(null, { status: fixture.responseStatus })); + + await adapter.dissociate(idFromPath(fixture.path), fixture.requestBody as Association); + + const [url, init] = mockFetch.mock.lastCall as [string, RequestInit]; + expect(url).toBe(`${BASE_URL}${fixture.path}`); + expect(init.method).toBe(fixture.method); + expect(JSON.parse(init.body as string)).toEqual(fixture.requestBody); + }); + } +}); + +describe('setPermissions fixtures', () => { + for (const fixture of setPermissionsFixtures) { + test(fixture.name, async () => { + const adapter = await openAdapter(); + mockFetch.mockResolvedValueOnce(new Response(null, { status: fixture.responseStatus })); + + await adapter.setPermissions( + idFromPath(fixture.path), + fixture.requestBody!.permissions as never, + ); + + const [url, init] = mockFetch.mock.lastCall as [string, RequestInit]; + expect(url).toBe(`${BASE_URL}${fixture.path}`); + expect(init.method).toBe(fixture.method); + expect(JSON.parse(init.body as string)).toEqual(fixture.requestBody); + }); + } +}); + +describe('restoreVersion fixtures', () => { + for (const fixture of restoreVersionFixtures) { + test(fixture.name, async () => { + const adapter = await openAdapter(); + mockFetch.mockResolvedValueOnce(jsonResponse(fixture.responseBody, fixture.responseStatus)); + + const version = Number(fixture.path.split('/').pop()); + const result = await adapter.restoreVersion(idFromPath(fixture.path), version); + + const [url, init] = mockFetch.mock.lastCall as [string, RequestInit]; + expect(url).toBe(`${BASE_URL}${fixture.path}`); + expect(init.method).toBe(fixture.method); + expect(result.content).toEqual(fixture.responseBody!.content); + }); + } +}); + +describe('commitMigration fixtures', () => { + for (const fixture of commitMigrationFixtures) { + test(fixture.name, async () => { + const adapter = await openAdapter(); + mockFetch.mockResolvedValueOnce(jsonResponse(fixture.responseBody, fixture.responseStatus)); + + const { toTypeId, content } = fixture.requestBody!; + const result = await adapter.commitMigration(idFromPath(fixture.path), toTypeId, content); + + const [url, init] = mockFetch.mock.lastCall as [string, RequestInit]; + expect(url).toBe(`${BASE_URL}${fixture.path}`); + expect(init.method).toBe(fixture.method); + expect(JSON.parse(init.body as string)).toEqual(fixture.requestBody); + expect(result.typeId).toBe(fixture.responseBody!.typeId); + }); + } +}); diff --git a/packages/adapter-api/vitest.config.ts b/packages/adapter-api/vitest.config.ts index e0ec2fb..19d747f 100644 --- a/packages/adapter-api/vitest.config.ts +++ b/packages/adapter-api/vitest.config.ts @@ -5,6 +5,10 @@ export default defineConfig({ resolve: { alias: { '@haverstack/core': resolve(__dirname, '../core/src/index.ts'), + '@haverstack/conformance-fixtures': resolve( + __dirname, + '../conformance-fixtures/src/index.ts', + ), }, }, test: { diff --git a/packages/adapter-local/src/index.ts b/packages/adapter-local/src/index.ts index b10e335..47b9c66 100644 --- a/packages/adapter-local/src/index.ts +++ b/packages/adapter-local/src/index.ts @@ -21,6 +21,7 @@ import type { StackQuery, QueryResult, Association, + Permission, AdapterCapabilities, RecordId, FileId, @@ -117,8 +118,8 @@ export class LocalAdapter implements StackAdapter { return this.record.getRecord(id); } - async updateRecord(id: RecordId, changes: Partial): Promise { - return this.record.updateRecord(id, changes); + async patchContent(id: RecordId, patch: Record): Promise { + return this.record.patchContent(id, patch); } async deleteRecord(id: RecordId, opts?: { hard?: boolean }): Promise { @@ -141,6 +142,10 @@ export class LocalAdapter implements StackAdapter { return this.record.dissociate(id, association); } + async setPermissions(id: RecordId, permissions: Permission[]): Promise { + return this.record.setPermissions(id, permissions); + } + async getVersions(id: RecordId): Promise { return this.record.getVersions(id); } @@ -153,6 +158,18 @@ export class LocalAdapter implements StackAdapter { return this.record.saveVersion(id, version); } + async restoreVersion(id: RecordId, version: number): Promise { + return this.record.restoreVersion(id, version); + } + + async commitMigration( + id: RecordId, + toTypeId: TypeId, + content: Record, + ): Promise { + return this.record.commitMigration(id, toTypeId, content); + } + async saveType(type: StackType): Promise { return this.record.saveType(type); } diff --git a/packages/conformance-fixtures/package.json b/packages/conformance-fixtures/package.json new file mode 100644 index 0000000..e51b45c --- /dev/null +++ b/packages/conformance-fixtures/package.json @@ -0,0 +1,42 @@ +{ + "name": "@haverstack/conformance-fixtures", + "version": "0.1.0", + "description": "Request/response fixtures for the Stack API wire protocol, shared between adapter-api and server implementations", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "https://github.com/haverstack/core", + "directory": "packages/conformance-fixtures" + }, + "license": "CC0-1.0", + "keywords": [ + "haverstack", + "conformance", + "fixtures", + "wire protocol" + ], + "scripts": { + "prepublishOnly": "pnpm run build", + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc --noEmit", + "lint": "eslint src" + }, + "dependencies": { + "@haverstack/wire-types": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.5.0" + } +} diff --git a/packages/conformance-fixtures/src/index.ts b/packages/conformance-fixtures/src/index.ts new file mode 100644 index 0000000..b47f9e9 --- /dev/null +++ b/packages/conformance-fixtures/src/index.ts @@ -0,0 +1,280 @@ +/** + * Stack — API Conformance Fixtures + * ------------------------------------------------------- + * Request/response pairs for every record-mutation endpoint in the Stack + * API wire format (docs/spec.md § API Adapter Wire Format). These pin down + * the exact wire shape each endpoint accepts and returns — in particular, + * that PATCH /records/:id carries a content-only merge patch, never + * record fields like typeId/version/updatedAt (see #52). + * + * This package is pure data: no test framework, no adapter, no server. + * Two independent consumers exercise the same fixtures against their own + * implementation: + * + * - @haverstack/adapter-api tests that APIAdapter produces the documented + * request for each method call and parses the documented response. + * - haverstack/server (or any other server implementation) tests that its + * HTTP handlers accept the documented request and produce the documented + * response. + * + * A fixture is self-contained: `responseBody` reflects the state that + * results from applying `requestBody` to whatever prior state the fixture's + * description assumes. Fixtures don't prescribe how a server seeds that + * prior state — that's the consumer's test setup. + */ + +import type { WireRecord } from '@haverstack/wire-types'; + +export type WireMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; + +export type ConformanceFixture = { + /** Unique, stable name — usable as a test-case id. */ + name: string; + /** What this fixture pins down, and why. */ + description: string; + method: WireMethod; + /** Request path, e.g. "/records/rec-1/restore/1". */ + path: string; + /** JSON request body. Absent for bodiless requests. */ + requestBody?: Req; + /** Expected HTTP status code. */ + responseStatus: number; + /** Expected JSON response body. Absent for empty (e.g. 204) responses. */ + responseBody?: Res; +}; + +// ------------------------------------------------------- +// Records: create +// ------------------------------------------------------- + +export const createRecordFixtures: ConformanceFixture[] = [ + { + name: 'create-record', + description: 'POST /records accepts a full record body and echoes it back.', + method: 'POST', + path: '/records', + requestBody: { + id: 'rec-1', + typeId: 'com.example/note@1', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + content: { title: 'Hello', body: 'World' }, + version: 1, + }, + responseStatus: 200, + responseBody: { + id: 'rec-1', + typeId: 'com.example/note@1', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + content: { title: 'Hello', body: 'World' }, + version: 1, + }, + }, +]; + +// ------------------------------------------------------- +// Records: patchContent — PATCH /records/:id +// ------------------------------------------------------- + +export const patchContentFixtures: ConformanceFixture, WireRecord>[] = [ + { + name: 'patch-content-merges-and-adds-fields', + description: + 'The PATCH body carries only the content patch — never typeId, version, or updatedAt. ' + + 'The server merges it against current content and assigns the new version/updatedAt itself.', + method: 'PATCH', + path: '/records/rec-1', + requestBody: { title: 'Updated title', pinned: true }, + responseStatus: 200, + responseBody: { + id: 'rec-1', + typeId: 'com.example/note@1', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + content: { title: 'Updated title', pinned: true, body: 'original body' }, + version: 2, + }, + }, + { + name: 'patch-content-null-deletes-a-field', + description: + 'A field set to null is removed from stored content (RFC 7396 merge-patch delete). ' + + 'Fields omitted from the patch are left untouched.', + method: 'PATCH', + path: '/records/rec-2', + requestBody: { title: null }, + responseStatus: 200, + responseBody: { + id: 'rec-2', + typeId: 'com.example/note@1', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + content: { body: 'kept' }, + version: 2, + }, + }, +]; + +// ------------------------------------------------------- +// Records: delete / undelete +// ------------------------------------------------------- + +export const deleteRecordFixtures: ConformanceFixture[] = [ + { + name: 'delete-record-soft', + description: 'DELETE /records/:id soft-deletes — record and version history are retained.', + method: 'DELETE', + path: '/records/rec-1', + responseStatus: 204, + }, + { + name: 'delete-record-hard', + description: 'DELETE /records/:id?hard=true permanently removes the record and its history.', + method: 'DELETE', + path: '/records/rec-1?hard=true', + responseStatus: 204, + }, +]; + +export const undeleteRecordFixtures: ConformanceFixture[] = [ + { + name: 'undelete-record', + description: + 'POST /records/:id/undelete reverses a soft delete and returns the record as it now ' + + 'stands (deletedAt absent). Idempotent — a second call returns the same result.', + method: 'POST', + path: '/records/rec-1/undelete', + responseStatus: 200, + responseBody: { + id: 'rec-1', + typeId: 'com.example/note@1', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-03T00:00:00.000Z', + content: { title: 'Hello' }, + version: 3, + }, + }, +]; + +// ------------------------------------------------------- +// Associations +// ------------------------------------------------------- + +export const associateFixtures: ConformanceFixture, undefined>[] = [ + { + name: 'associate-tag', + description: 'POST /records/:id/associations adds an association and bumps version.', + method: 'POST', + path: '/records/rec-1/associations', + requestBody: { kind: 'tag', label: 'starred' }, + responseStatus: 204, + }, +]; + +export const dissociateFixtures: ConformanceFixture, undefined>[] = [ + { + name: 'dissociate-tag', + description: 'DELETE /records/:id/associations removes an association and bumps version.', + method: 'DELETE', + path: '/records/rec-1/associations', + requestBody: { kind: 'tag', label: 'starred' }, + responseStatus: 204, + }, +]; + +// ------------------------------------------------------- +// Permissions +// ------------------------------------------------------- + +export const setPermissionsFixtures: ConformanceFixture<{ permissions: unknown[] }, undefined>[] = [ + { + name: 'set-permissions-public', + description: + 'PUT /records/:id/permissions replaces all permissions. Body and (if returned) response ' + + 'both use the { "permissions": [...] } envelope.', + method: 'PUT', + path: '/records/rec-1/permissions', + requestBody: { permissions: [{ access: 'public' }] }, + responseStatus: 204, + }, + { + name: 'set-permissions-empty-is-private', + description: 'An empty permissions array makes the record private (owner-only).', + method: 'PUT', + path: '/records/rec-1/permissions', + requestBody: { permissions: [] }, + responseStatus: 204, + }, +]; + +// ------------------------------------------------------- +// Versions: restore +// ------------------------------------------------------- + +export const restoreVersionFixtures: ConformanceFixture[] = [ + { + name: 'restore-version', + description: + "POST /records/:id/restore/:version creates a new version from an old snapshot's " + + 'content (and associations, if present) — never permissions. No request body: the ' + + 'server holds the snapshot already.', + method: 'POST', + path: '/records/rec-1/restore/1', + responseStatus: 200, + responseBody: { + id: 'rec-1', + typeId: 'com.example/note@1', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-04T00:00:00.000Z', + content: { title: 'original title' }, + version: 4, + }, + }, +]; + +// ------------------------------------------------------- +// Migration commit +// ------------------------------------------------------- + +export const commitMigrationFixtures: ConformanceFixture< + { toTypeId: string; content: Record }, + WireRecord +>[] = [ + { + name: 'commit-migration', + description: + 'POST /records/:id/migrate is the only way typeId changes after creation. Body carries ' + + "the full post-migration content (computed client-side by the type's owning app); the " + + "server validates it against toTypeId's schema before writing.", + method: 'POST', + path: '/records/rec-1/migrate', + requestBody: { toTypeId: 'com.example/note@2', content: { title: 'Hello', pinned: false } }, + responseStatus: 200, + responseBody: { + id: 'rec-1', + typeId: 'com.example/note@2', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-05T00:00:00.000Z', + content: { title: 'Hello', pinned: false }, + version: 5, + }, + }, +]; + +// ------------------------------------------------------- +// All fixtures +// ------------------------------------------------------- + +/** Every fixture across every endpoint, for consumers that want to iterate uniformly. */ +export const allConformanceFixtures: ConformanceFixture[] = [ + ...createRecordFixtures, + ...patchContentFixtures, + ...deleteRecordFixtures, + ...undeleteRecordFixtures, + ...associateFixtures, + ...dissociateFixtures, + ...setPermissionsFixtures, + ...restoreVersionFixtures, + ...commitMigrationFixtures, +]; diff --git a/packages/conformance-fixtures/tsconfig.build.json b/packages/conformance-fixtures/tsconfig.build.json new file mode 100644 index 0000000..299c632 --- /dev/null +++ b/packages/conformance-fixtures/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "noEmit": false + } +} diff --git a/packages/conformance-fixtures/tsconfig.json b/packages/conformance-fixtures/tsconfig.json new file mode 100644 index 0000000..fda526d --- /dev/null +++ b/packages/conformance-fixtures/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/core/src/combine.ts b/packages/core/src/combine.ts index 1673d95..585eb58 100644 --- a/packages/core/src/combine.ts +++ b/packages/core/src/combine.ts @@ -24,17 +24,20 @@ export function combineAdapters(parts: { createRecord: (r) => parts.record.createRecord(r), getRecord: (id) => parts.record.getRecord(id), - updateRecord: (id, changes) => parts.record.updateRecord(id, changes), + patchContent: (id, patch) => parts.record.patchContent(id, patch), 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), dissociate: (id, assoc) => parts.record.dissociate(id, assoc), + setPermissions: (id, permissions) => parts.record.setPermissions(id, permissions), getVersions: (id) => parts.record.getVersions(id), getVersion: (id, v) => parts.record.getVersion(id, v), saveVersion: (id, v) => parts.record.saveVersion(id, v), + restoreVersion: (id, v) => parts.record.restoreVersion(id, v), + commitMigration: (id, toTypeId, content) => parts.record.commitMigration(id, toTypeId, content), saveType: (t) => parts.record.saveType(t), getType: (id) => parts.record.getType(id), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bd31906..0660d33 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -86,3 +86,4 @@ export { export { hashSchema, isCompatible, parseTypeId, buildTypeId } from './schema.js'; export { validateContent, isValid } from './validate.js'; export type { ValidationError } from './validate.js'; +export { applyMergePatch } from './merge.js'; diff --git a/packages/core/src/merge.ts b/packages/core/src/merge.ts new file mode 100644 index 0000000..1a5afde --- /dev/null +++ b/packages/core/src/merge.ts @@ -0,0 +1,24 @@ +/** + * Stack — RFC 7396 JSON Merge Patch + * ------------------------------------------------------- + * Shallow merge-patch semantics for record content: a field set to `null` + * removes it, any other value replaces it, and omitted fields are retained. + * Shared by Stack.update() (for client-side validation) and every + * StackRecordAdapter's patchContent() implementation, so a patch produces + * the same merged content everywhere it's applied. + */ + +export function applyMergePatch( + content: Record, + patch: Record, +): Record { + const merged: Record = { ...content }; + for (const [key, value] of Object.entries(patch)) { + if (value === null) { + delete merged[key]; + } else { + merged[key] = value; + } + } + return merged; +} diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index 261ad72..f220c6f 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -17,6 +17,7 @@ import { generateId, isValidIdFormat, idTimestamp } from './id.js'; import { hashSchema, isCompatible, parseTypeId } from './schema.js'; import { validateContent } from './validate.js'; +import { applyMergePatch } from './merge.js'; import { checkAccess } from './access.js'; import { SYSTEM_TYPES } from './types.js'; import type { ValidationError } from './validate.js'; @@ -419,12 +420,7 @@ export class Stack implements StackClient { for (const record of result.records) { await this.saveVersion(record); const migratedContent = migrateFn(record.content); - await this.adapter.updateRecord(record.id, { - typeId: latestId, - content: migratedContent, - updatedAt: new Date(), - version: record.version + 1, - }); + await this.adapter.commitMigration(record.id, latestId, migratedContent); migrated++; } @@ -554,16 +550,8 @@ export class Stack implements StackClient { throw new Error(`Unknown type: "${latestTypeId}"`); } - // Shallow merge: start with (migrated) existing content, apply changes. - // null values mean "delete this field" (RFC 7396 / JSON Merge Patch). - const merged: Record = { ...existingContent }; - for (const [key, value] of Object.entries(content)) { - if (value === null) { - delete merged[key]; - } else { - merged[key] = value; - } - } + // Merge (RFC 7396 / JSON Merge Patch): null values delete a field. + const merged = applyMergePatch(existingContent, content); const errors = validateContent(merged, type.schema); if (errors.length > 0) { @@ -573,12 +561,13 @@ export class Stack implements StackClient { // Snapshot the raw stored state before overwriting await this.saveVersion(existing); - return this.adapter.updateRecord(id, { - typeId: latestTypeId, - content: merged, - updatedAt: new Date(), - version: existing.version + 1, - }); + // If a pending lazy migration is being committed alongside this patch, + // the typeId change has to travel through commitMigration() — a + // content-only patch has no way to carry it. + if (latestTypeId !== existing.typeId) { + return this.adapter.commitMigration(id, latestTypeId, merged); + } + return this.adapter.patchContent(id, content); } /** @@ -596,7 +585,6 @@ export class Stack implements StackClient { await this.saveVersion(existing); await this.adapter.associate(id, association); - await this.adapter.updateRecord(id, { version: existing.version + 1, updatedAt: new Date() }); } /** @@ -612,7 +600,6 @@ export class Stack implements StackClient { await this.saveVersion(existing); await this.adapter.dissociate(id, association); - await this.adapter.updateRecord(id, { version: existing.version + 1, updatedAt: new Date() }); } /** @@ -628,11 +615,7 @@ export class Stack implements StackClient { if (permissionsEqual(existing.permissions ?? [], permissions)) return; await this.saveVersion(existing); - await this.adapter.updateRecord(id, { - permissions, - version: existing.version + 1, - updatedAt: new Date(), - }); + await this.adapter.setPermissions(id, permissions); } /** @@ -658,7 +641,6 @@ export class Stack implements StackClient { await this.saveVersion(existing); await this.adapter.deleteRecord(id, opts); - await this.adapter.updateRecord(id, { version: existing.version + 1, updatedAt: new Date() }); } /** @@ -675,8 +657,7 @@ export class Stack implements StackClient { if (!existing.deletedAt) return existing; await this.saveVersion(existing); - await this.adapter.undeleteRecord(id); - return this.adapter.updateRecord(id, { version: existing.version + 1, updatedAt: new Date() }); + return this.adapter.undeleteRecord(id); } /** @@ -723,12 +704,7 @@ export class Stack implements StackClient { // Snapshot current state before restoring await this.saveVersion(existing); - return this.adapter.updateRecord(id, { - content: target.content, - updatedAt: new Date(), - version: existing.version + 1, - ...(target.associations !== undefined && { associations: target.associations }), - }); + return this.adapter.restoreVersion(id, version); } // ------------------------------------------------------- diff --git a/packages/core/src/testing.ts b/packages/core/src/testing.ts index d4178fa..9090a3c 100644 --- a/packages/core/src/testing.ts +++ b/packages/core/src/testing.ts @@ -7,8 +7,10 @@ import type { StackQuery, QueryResult, Association, + Permission, AdapterCapabilities, } from './types.js'; +import { applyMergePatch } from './merge.js'; /** * Fully functional in-memory StackAdapter with offset-based cursor pagination. @@ -47,10 +49,14 @@ export class MemoryAdapter implements StackAdapter { return this.records.get(id) ?? null; } - async updateRecord(id: string, changes: Partial) { + private bump(record: StackRecord): StackRecord { + return { ...record, version: record.version + 1, updatedAt: new Date() }; + } + + async patchContent(id: string, patch: Record) { const existing = this.records.get(id); if (!existing) throw new Error(`Not found: ${id}`); - const updated = { ...existing, ...changes }; + const updated = this.bump({ ...existing, content: applyMergePatch(existing.content, patch) }); this.records.set(id, updated); return updated; } @@ -61,7 +67,7 @@ export class MemoryAdapter implements StackAdapter { this.order.splice(this.order.indexOf(id), 1); } else { const record = this.records.get(id); - if (record) this.records.set(id, { ...record, deletedAt: new Date() }); + if (record) this.records.set(id, this.bump({ ...record, deletedAt: new Date() })); } } @@ -69,7 +75,7 @@ export class MemoryAdapter implements StackAdapter { const record = this.records.get(id); if (!record) throw new Error(`Not found: ${id}`); const { deletedAt: _deletedAt, ...rest } = record; - const updated = rest as StackRecord; + const updated = this.bump(rest as StackRecord); this.records.set(id, updated); return updated; } @@ -107,7 +113,7 @@ export class MemoryAdapter implements StackAdapter { const record = this.records.get(id); if (!record) throw new Error(`Not found: ${id}`); const assocs = record.associations ?? []; - this.records.set(id, { ...record, associations: [...assocs, association] }); + this.records.set(id, this.bump({ ...record, associations: [...assocs, association] })); } async dissociate(id: string, association: Association) { @@ -116,7 +122,13 @@ export class MemoryAdapter implements StackAdapter { const assocs = (record.associations ?? []).filter( (a) => !(a.kind === association.kind && a.label === association.label), ); - this.records.set(id, { ...record, associations: assocs }); + this.records.set(id, this.bump({ ...record, associations: assocs })); + } + + async setPermissions(id: string, permissions: Permission[]) { + const record = this.records.get(id); + if (!record) throw new Error(`Not found: ${id}`); + this.records.set(id, this.bump({ ...record, permissions })); } async getVersions(id: string) { @@ -130,6 +142,28 @@ export class MemoryAdapter implements StackAdapter { this.versions.set(id, [...existing, version]); } + async restoreVersion(id: string, version: number) { + const record = this.records.get(id); + if (!record) throw new Error(`Not found: ${id}`); + const target = (this.versions.get(id) ?? []).find((v) => v.version === version); + if (!target) throw new Error(`Version not found: ${id}@${version}`); + const updated = this.bump({ + ...record, + content: target.content, + ...(target.associations !== undefined && { associations: target.associations }), + }); + this.records.set(id, updated); + return updated; + } + + async commitMigration(id: string, toTypeId: TypeId, content: Record) { + const record = this.records.get(id); + if (!record) throw new Error(`Not found: ${id}`); + const updated = this.bump({ ...record, typeId: toTypeId, content }); + this.records.set(id, updated); + return updated; + } + async saveType(type: StackType) { this.types.set(type.id, type); } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 4b543bd..33a3608 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -339,7 +339,15 @@ export interface StackRecordAdapter { // Records createRecord(record: StackRecord): Promise; getRecord(id: RecordId): Promise; - updateRecord(id: RecordId, changes: Partial): Promise; + /** + * Apply a content-only RFC 7396 merge patch. `null` removes a field; + * other values replace it; omitted fields are retained. Bumps `version` + * and `updatedAt` as part of the same write. Does not touch `typeId` — + * a patch that also commits a pending migration goes through + * commitMigration() instead, since a content-only patch has no way to + * carry a type change. + */ + patchContent(id: RecordId, patch: Record): Promise; deleteRecord(id: RecordId, opts?: { hard?: boolean }): Promise; /** Reverse a soft delete. Returns the record as it now stands. */ undeleteRecord(id: RecordId): Promise; @@ -349,10 +357,31 @@ export interface StackRecordAdapter { associate(id: RecordId, association: Association): Promise; dissociate(id: RecordId, association: Association): Promise; + /** Replace all permissions on a record. Bumps version internally. */ + setPermissions(id: RecordId, permissions: Permission[]): Promise; + // Versions getVersions(id: RecordId): Promise; getVersion(id: RecordId, version: number): Promise; saveVersion(id: RecordId, version: RecordVersion): Promise; + /** + * Restore a record to a previous version's content (and associations, + * when the snapshot has them). Never restores permissions. Bumps version + * internally. Throws StackNotFoundError if the version doesn't exist. + */ + restoreVersion(id: RecordId, version: number): Promise; + + /** + * Commit a migration: write new content under a new typeId in one step. + * This is the only way a record's typeId changes after creation — used + * by Stack.update() when committing a pending lazy migration, and by + * Stack.migrateAll(). Bumps version internally. + */ + commitMigration( + id: RecordId, + toTypeId: TypeId, + content: Record, + ): Promise; // Types saveType(type: StackType): Promise; diff --git a/packages/core/tests/merge.test.ts b/packages/core/tests/merge.test.ts new file mode 100644 index 0000000..fcfc721 --- /dev/null +++ b/packages/core/tests/merge.test.ts @@ -0,0 +1,35 @@ +import { describe, test, expect } from 'vitest'; +import { applyMergePatch } from '../src/merge.js'; + +describe('applyMergePatch', () => { + test('adds new fields', () => { + const result = applyMergePatch({ title: 'Hello' }, { pinned: true }); + expect(result).toEqual({ title: 'Hello', pinned: true }); + }); + + test('replaces existing fields', () => { + const result = applyMergePatch({ title: 'Hello' }, { title: 'Updated' }); + expect(result).toEqual({ title: 'Updated' }); + }); + + test('null removes a field', () => { + const result = applyMergePatch({ title: 'Hello', pinned: true }, { pinned: null }); + expect(result).toEqual({ title: 'Hello' }); + }); + + test('omitted fields are retained', () => { + const result = applyMergePatch({ title: 'Hello', pinned: true }, { title: 'Updated' }); + expect(result).toEqual({ title: 'Updated', pinned: true }); + }); + + test('does not mutate the original content object', () => { + const original = { title: 'Hello' }; + applyMergePatch(original, { title: 'Updated' }); + expect(original).toEqual({ title: 'Hello' }); + }); + + test('empty patch returns an equivalent copy', () => { + const result = applyMergePatch({ title: 'Hello' }, {}); + expect(result).toEqual({ title: 'Hello' }); + }); +}); diff --git a/packages/record-adapter-sqljs/src/index.ts b/packages/record-adapter-sqljs/src/index.ts index b54abf5..f362a5c 100644 --- a/packages/record-adapter-sqljs/src/index.ts +++ b/packages/record-adapter-sqljs/src/index.ts @@ -28,8 +28,10 @@ import type { StackQuery, QueryResult, Association, + Permission, AdapterCapabilities, } from '@haverstack/core'; +import { applyMergePatch } from '@haverstack/core'; // ------------------------------------------------------- // Types @@ -569,59 +571,20 @@ export class SQLiteRecordAdapter implements StackRecordAdapter { return rowToRecord(row, associations); } - async updateRecord(id: string, changes: Partial): Promise { - const setClauses: string[] = []; - const params: unknown[] = []; - - if (changes.content !== undefined) { - setClauses.push('content = ?'); - params.push(JSON.stringify(changes.content)); - } - if (changes.typeId !== undefined) { - setClauses.push('type_id = ?'); - params.push(changes.typeId); - } - if (changes.updatedAt !== undefined) { - setClauses.push('updated_at = ?'); - params.push(toMs(changes.updatedAt)); - } - if (changes.version !== undefined) { - setClauses.push('version = ?'); - params.push(changes.version); - } - if (changes.deletedAt !== undefined) { - setClauses.push('deleted_at = ?'); - params.push(toMs(changes.deletedAt)); - } - if (changes.permissions !== undefined) { - setClauses.push('permissions = ?'); - params.push(changes.permissions.length ? JSON.stringify(changes.permissions) : null); - } - - if (setClauses.length) { - params.push(id); - this.db.run( - `UPDATE records SET ${setClauses.join(', ')} WHERE id = ?`, - params as import('sql.js').BindParams, - ); - } - - // Replace associations if provided - if (changes.associations !== undefined) { - this.db.run('DELETE FROM associations WHERE record_id = ?', [id]); - if (changes.associations.length) { - this.insertAssociations(id, changes.associations); - } - } - - if (changes.content !== undefined) { - this.updateFts(id, JSON.stringify(changes.content)); - } + async patchContent(id: string, patch: Record): Promise { + const existing = await this.getRecord(id); + if (!existing) throw new Error(`Record not found: "${id}"`); + const merged = applyMergePatch(existing.content, patch); + this.db.run( + 'UPDATE records SET content = ?, version = version + 1, updated_at = ? WHERE id = ?', + [JSON.stringify(merged), toMs(new Date()), id], + ); + this.updateFts(id, JSON.stringify(merged)); this.persist(); const updated = await this.getRecord(id); - if (!updated) throw new Error(`Record not found after update: "${id}"`); + if (!updated) throw new Error(`Record not found after patchContent: "${id}"`); return updated; } @@ -635,13 +598,19 @@ export class SQLiteRecordAdapter implements StackRecordAdapter { ); this.db.run('DELETE FROM records WHERE id = ?', [id]); } else { - this.db.run('UPDATE records SET deleted_at = ? WHERE id = ?', [toMs(new Date()), id]); + this.db.run( + 'UPDATE records SET deleted_at = ?, version = version + 1, updated_at = ? WHERE id = ?', + [toMs(new Date()), toMs(new Date()), id], + ); } this.persist(); } async undeleteRecord(id: string): Promise { - this.db.run('UPDATE records SET deleted_at = NULL WHERE id = ?', [id]); + this.db.run( + 'UPDATE records SET deleted_at = NULL, version = version + 1, updated_at = ? WHERE id = ?', + [toMs(new Date()), id], + ); this.persist(); const updated = await this.getRecord(id); @@ -649,6 +618,51 @@ export class SQLiteRecordAdapter implements StackRecordAdapter { return updated; } + async setPermissions(id: string, permissions: Permission[]): Promise { + this.db.run( + 'UPDATE records SET permissions = ?, version = version + 1, updated_at = ? WHERE id = ?', + [permissions.length ? JSON.stringify(permissions) : null, toMs(new Date()), id], + ); + this.persist(); + } + + async restoreVersion(id: string, version: number): Promise { + const target = await this.getVersion(id, version); + if (!target) throw new Error(`Version not found: ${id}@${version}`); + + this.db.run( + 'UPDATE records SET content = ?, version = version + 1, updated_at = ? WHERE id = ?', + [JSON.stringify(target.content), toMs(new Date()), id], + ); + if (target.associations !== undefined) { + this.db.run('DELETE FROM associations WHERE record_id = ?', [id]); + if (target.associations.length) this.insertAssociations(id, target.associations); + } + this.updateFts(id, JSON.stringify(target.content)); + this.persist(); + + const updated = await this.getRecord(id); + if (!updated) throw new Error(`Record not found after restoreVersion: "${id}"`); + return updated; + } + + async commitMigration( + id: string, + toTypeId: TypeId, + content: Record, + ): Promise { + this.db.run( + 'UPDATE records SET type_id = ?, content = ?, version = version + 1, updated_at = ? WHERE id = ?', + [toTypeId, JSON.stringify(content), toMs(new Date()), id], + ); + this.updateFts(id, JSON.stringify(content)); + this.persist(); + + const updated = await this.getRecord(id); + if (!updated) throw new Error(`Record not found after commitMigration: "${id}"`); + return updated; + } + async queryRecords(query: StackQuery): Promise { const { sql: where, params } = buildWhereClause(query); const order = buildOrderClause(query); @@ -820,6 +834,7 @@ export class SQLiteRecordAdapter implements StackRecordAdapter { async associate(recordId: string, association: Association): Promise { this.insertAssociations(recordId, [association]); + this.bumpVersion(recordId); this.persist(); } @@ -839,9 +854,17 @@ export class SQLiteRecordAdapter implements StackRecordAdapter { association.kind === 'relationship' ? association.recordId : '', ], ); + this.bumpVersion(recordId); this.persist(); } + private bumpVersion(id: string): void { + this.db.run('UPDATE records SET version = version + 1, updated_at = ? WHERE id = ?', [ + toMs(new Date()), + id, + ]); + } + private insertAssociations(recordId: string, associations: Association[]): void { for (const assoc of associations) { this.db.run( diff --git a/packages/record-adapter-sqljs/tests/record.test.ts b/packages/record-adapter-sqljs/tests/record.test.ts index c271fd0..97127d1 100644 --- a/packages/record-adapter-sqljs/tests/record.test.ts +++ b/packages/record-adapter-sqljs/tests/record.test.ts @@ -197,25 +197,20 @@ describe('records — CRUD', () => { expect(retrieved?.appId).toBe('app-123'); }); - test('updateRecord changes specified fields', async () => { + test('patchContent changes content and bumps version', async () => { const adapter = await initAdapter(); const record = makeRecord(); await adapter.createRecord(record); - const now = new Date(); - const updated = await adapter.updateRecord(record.id, { - content: { text: 'Updated' }, - version: 2, - updatedAt: now, - }); + const updated = await adapter.patchContent(record.id, { text: 'Updated' }); expect(updated.content).toEqual({ text: 'Updated' }); expect(updated.version).toBe(2); }); - test('updateRecord preserves unchanged fields', async () => { + test('patchContent preserves unchanged fields', async () => { const adapter = await initAdapter(); const record = makeRecord({ parentId: 'parent-abc' }); await adapter.createRecord(record); - await adapter.updateRecord(record.id, { version: 2, updatedAt: new Date() }); + await adapter.patchContent(record.id, { text: 'Updated' }); const retrieved = await adapter.getRecord(record.id); expect(retrieved?.parentId).toBe('parent-abc'); }); @@ -714,6 +709,50 @@ describe('associations', () => { const retrieved = await adapter.getRecord(record.id); expect(retrieved?.associations?.length).toBe(3); }); + + test('associate bumps version', async () => { + const adapter = await initAdapter(); + const record = makeRecord(); + await adapter.createRecord(record); + await adapter.associate(record.id, { kind: 'tag', label: 'starred' }); + const retrieved = await adapter.getRecord(record.id); + expect(retrieved?.version).toBe(2); + }); + + test('dissociate bumps version', async () => { + const adapter = await initAdapter(); + const record = makeRecord(); + await adapter.createRecord(record); + await adapter.associate(record.id, { kind: 'tag', label: 'starred' }); + await adapter.dissociate(record.id, { kind: 'tag', label: 'starred' }); + const retrieved = await adapter.getRecord(record.id); + expect(retrieved?.version).toBe(3); + }); +}); + +// ------------------------------------------------------- +// Permissions +// ------------------------------------------------------- + +describe('setPermissions', () => { + test('replaces permissions and bumps version', async () => { + const adapter = await initAdapter(); + const record = makeRecord(); + await adapter.createRecord(record); + await adapter.setPermissions(record.id, [{ access: 'public' }]); + const retrieved = await adapter.getRecord(record.id); + expect(retrieved?.permissions).toEqual([{ access: 'public' }]); + expect(retrieved?.version).toBe(2); + }); + + test('empty array clears permissions', async () => { + const adapter = await initAdapter(); + const record = makeRecord({ permissions: [{ access: 'public' }] }); + await adapter.createRecord(record); + await adapter.setPermissions(record.id, []); + const retrieved = await adapter.getRecord(record.id); + expect(retrieved?.permissions).toBeUndefined(); + }); }); // ------------------------------------------------------- @@ -807,6 +846,63 @@ describe('versions', () => { }); }); +describe('restoreVersion', () => { + test('restores content and bumps version', async () => { + const adapter = await initAdapter(); + const record = makeRecord(); + await adapter.createRecord(record); + await adapter.saveVersion(record.id, { + version: 1, + content: { text: 'original' }, + updatedAt: new Date(), + }); + await adapter.patchContent(record.id, { text: 'changed' }); + + const restored = await adapter.restoreVersion(record.id, 1); + expect(restored.content).toEqual({ text: 'original' }); + expect(restored.version).toBe(3); + }); + + test('restores associations when the snapshot has them', async () => { + const adapter = await initAdapter(); + const record = makeRecord(); + await adapter.createRecord(record); + await adapter.saveVersion(record.id, { + version: 1, + content: { text: 'original' }, + updatedAt: new Date(), + associations: [{ kind: 'tag', label: 'starred' }], + }); + await adapter.associate(record.id, { kind: 'tag', label: 'pinned' }); + + const restored = await adapter.restoreVersion(record.id, 1); + expect(restored.associations).toEqual([{ kind: 'tag', label: 'starred' }]); + }); + + test('throws for an unknown version', async () => { + const adapter = await initAdapter(); + const record = makeRecord(); + await adapter.createRecord(record); + await expect(adapter.restoreVersion(record.id, 99)).rejects.toThrow(); + }); +}); + +describe('commitMigration', () => { + test('changes typeId and content together, and bumps version', async () => { + const adapter = await initAdapter(); + const record = makeRecord({ typeId: 'com.example.test/note@1' }); + await adapter.createRecord(record); + + const migrated = await adapter.commitMigration(record.id, 'com.example.test/note@2', { + text: 'Hello world', + pinned: false, + }); + expect(migrated.typeId).toBe('com.example.test/note@2'); + expect(migrated.content).toEqual({ text: 'Hello world', pinned: false }); + expect(migrated.version).toBe(2); + }); +}); + // ------------------------------------------------------- // Tokens // ------------------------------------------------------- diff --git a/packages/record-adapter-sqljs/vitest.config.ts b/packages/record-adapter-sqljs/vitest.config.ts new file mode 100644 index 0000000..e0ec2fb --- /dev/null +++ b/packages/record-adapter-sqljs/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'path'; + +export default defineConfig({ + resolve: { + alias: { + '@haverstack/core': resolve(__dirname, '../core/src/index.ts'), + }, + }, + test: { + environment: 'node', + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2fd54d4..fd24460 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: specifier: workspace:* version: link:../wire-types devDependencies: + '@haverstack/conformance-fixtures': + specifier: workspace:* + version: link:../conformance-fixtures '@types/node': specifier: ^22.0.0 version: 22.19.17 @@ -90,6 +93,19 @@ importers: specifier: ^2.0.0 version: 2.1.9(@types/node@22.19.17) + packages/conformance-fixtures: + dependencies: + '@haverstack/wire-types': + specifier: workspace:* + version: link:../wire-types + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.17 + typescript: + specifier: ^5.5.0 + version: 5.9.3 + packages/core: devDependencies: '@types/node':