diff --git a/.changeset/fresh-record-revisions.md b/.changeset/fresh-record-revisions.md new file mode 100644 index 0000000..0e9cca3 --- /dev/null +++ b/.changeset/fresh-record-revisions.md @@ -0,0 +1,5 @@ +--- +"@schemaforge/client": minor +--- + +Add an optional record-revision client facet with fresh versioned reads and conditional PATCH, PUT and DELETE. Reject unsupported preflight responses and changed account/program context, and surface conflicts without automatic mutation retries. diff --git a/packages/client/RECORD-REVISIONS.md b/packages/client/RECORD-REVISIONS.md new file mode 100644 index 0000000..604d413 --- /dev/null +++ b/packages/client/RECORD-REVISIONS.md @@ -0,0 +1,24 @@ +# Conditional record mutations + +`createForgeClient` now returns `ForgeClient & RecordRevisionClient`. Existing hosts that implement or accept the original `ForgeClient` interface remain compatible. The additional facet provides fresh versioned reads, conditional PATCH/PUT, and conditional DELETE for SchemaForge's `record-revision-v1` protocol. + +```ts +const baseline = await client.getVersionedEntity("Example", id, { signal }) +const updated = await client.updateEntityIfRevision( + "Example", id, { name: "Updated label" }, baseline.revision, { signal }, +) +``` + +`updateEntityIfRevision` sends PATCH, matching the existing `updateEntity` method. `replaceEntityIfRevision` sends PUT with a complete field payload. Both return `{ row, revision }`; `deleteEntityIfRevision` returns void after a 204 response. The returned row uses the existing flattened entity and permission representation. + +Each conditional mutation first performs a fresh authorized GET. It requires an `Entity-Revision` response header and compares that baseline with the supplied opaque revision before sending `If-Entity-Revision`. An older or unprepared backend therefore receives no mutation from these methods. This preflight is an availability and context check; the backend's atomic comparison remains the protection against a record changing between GET and mutation. + +The current upstream implementation supports prepared PostgreSQL schemas. Its metadata capability describes adapter support; the detail response header establishes schema readiness. Other or unprepared adapters must refuse conditional writes. Mixed old/new writers, direct database writes that do not maintain revisions, and schema/policy changes concurrent with a request are outside the protocol's bounded guarantee. Do not substitute HTTP `If-Match` or treat the token as a timestamp, audit history or strong representation ETag. + +Reads and mutations use `cache: "no-store"`, reject redirects, and accept an optional AbortSignal. Inputs are serialized before the preflight, so later caller edits do not change the submitted fields. A token or active-program change during the preflight stops the mutation. Already-started requests cannot be revoked merely by switching context or aborting browser transport; the host must discard late feedback using its own session boundary. + +The preflight read can use the existing one-time unauthorized callback. A context change during that renewal stops the pending mutation. The mutation request itself is never automatically retried, including after 401. The host may renew its session and deliberately reload/review current state. It must not blindly resubmit a write after a network failure, cancellation, malformed success response or missing success revision: the mutation may already have committed. + +`ForgeRecordRevisionError.code` identifies local `invalid_revision`, `unavailable`, `invalid_response`, `conflict` and `context_changed` failures. HTTP denials and conflicts retain `ForgeApiError`/`ForgeUnauthorizedError`; the upstream conflict body distinguishes `revision_conflict` and `conditional_mutation_unsupported`. No conflict causes automatic overwriting, fallback to unconditional operations, or record deletion. Preserve unsaved input and offer current-state recovery. + +The SDK does not persist revisions or record contents. It does not infer authorization from a revision, a successful past read, or a permission flag. Current backend authorization remains authoritative for every request. Host-owned deadlines and UI decisions remain outside this transport facet. diff --git a/packages/client/package.json b/packages/client/package.json index 5fe95f5..a1fdbeb 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -8,7 +8,8 @@ "sideEffects": false, "files": [ "dist", - "FILE-ACCESS.md" + "FILE-ACCESS.md", + "RECORD-REVISIONS.md" ], "main": "./dist/index.cjs", "module": "./dist/index.js", diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index e72bf10..524d83b 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -73,7 +73,29 @@ export interface ForgeClient { me(): Promise } -export function createForgeClient(config: ForgeClientConfig): ForgeClient { +export type VersionedEntityResult = { row: EntityRow; revision: string } +export type RecordRequestOptions = { signal?: AbortSignal } +export type RecordRevisionErrorCode = "invalid_revision" | "unavailable" | "invalid_response" | "conflict" | "context_changed" +export class ForgeRecordRevisionError extends Error { + constructor(readonly code: RecordRevisionErrorCode, message: string) { + super(message) + this.name = "ForgeRecordRevisionError" + } +} +/** Additive facet; existing hosts implementing only ForgeClient remain compatible. */ +export interface RecordRevisionClient { + getVersionedEntity(schema: string, id: string, options?: RecordRequestOptions): Promise + updateEntityIfRevision(schema: string, id: string, body: Record, revision: string, options?: RecordRequestOptions): Promise + replaceEntityIfRevision(schema: string, id: string, body: Record, revision: string, options?: RecordRequestOptions): Promise + deleteEntityIfRevision(schema: string, id: string, revision: string, options?: RecordRequestOptions): Promise +} +const RECORD_REVISION_HEADER = "Entity-Revision" +const EXPECTED_REVISION_HEADER = "If-Entity-Revision" +function validRevision(value: string | null): value is string { + return value !== null && /^revision_[0-9a-hjkmnp-tv-z]{26}$/.test(value) +} + +export function createForgeClient(config: ForgeClientConfig): ForgeClient & RecordRevisionClient { const base = config.baseUrl ?? "" function buildHeaders(extra?: Record): Record { @@ -89,14 +111,19 @@ export function createForgeClient(config: ForgeClientConfig): ForgeClient { return fetch(`${base}${path}`, { ...init, headers: buildHeaders(init?.headers as Record) }) } - async function request(path: string, init?: RequestInit, requireJson = false): Promise { + async function requestResponse(path: string, init?: RequestInit, allowRefresh = true): Promise { let res = await send(path, init) - if (res.status === 401 && config.onUnauthorized) { + if (res.status === 401 && allowRefresh && config.onUnauthorized) { const refreshed = await config.onUnauthorized() if (refreshed) res = await send(path, init) } if (res.status === 401) throw new ForgeUnauthorizedError() if (!res.ok) throw new ForgeApiError(res.status, await res.text()) + return res + } + + async function request(path: string, init?: RequestInit, requireJson = false): Promise { + const res = await requestResponse(path, init) if (requireJson && res.headers.get("content-type")?.split(";")[0].trim().toLowerCase() !== "application/json") { await res.body?.cancel() throw new Error("Expected a presigned file URL response") @@ -120,7 +147,62 @@ export function createForgeClient(config: ForgeClientConfig): ForgeClient { return { fields } } + function entityPath(schema: string, id: string): string { + return `${FORGE_PREFIX}/schemas/${encodeURIComponent(schema)}/entities/${encodeURIComponent(id)}` + } + function revisionInit(options: RecordRequestOptions): RequestInit { + return { signal: options.signal, cache: "no-store", redirect: "error", headers: { Accept: "application/json" } } + } + async function decodeVersioned(res: Response, id: string): Promise { + const revision = res.headers.get(RECORD_REVISION_HEADER) + if (revision === null) { + await res.body?.cancel() + throw new ForgeRecordRevisionError("unavailable", "Record revision support is unavailable. No unconditional fallback is provided.") + } + if (!validRevision(revision) || res.headers.get("content-type")?.split(";")[0].trim().toLowerCase() !== "application/json") { + await res.body?.cancel() + throw new ForgeRecordRevisionError("invalid_response", "Invalid record revision response.") + } + let value: unknown + try { value = await res.json() } + catch (error) { + if (error instanceof SyntaxError) throw new ForgeRecordRevisionError("invalid_response", "Invalid record revision response.") + throw error + } + if (!value || typeof value !== "object" || !("id" in value) || value.id !== id || !("fields" in value) || !value.fields || typeof value.fields !== "object" || Array.isArray(value.fields)) throw new ForgeRecordRevisionError("invalid_response", "Invalid record revision response.") + return { row: { ...flatten(value as EntityEnvelope), id }, revision } + } + async function getVersionedEntity(schema: string, id: string, options: RecordRequestOptions = {}): Promise { + options.signal?.throwIfAborted() + return decodeVersioned(await requestResponse(entityPath(schema, id), revisionInit(options)), id) + } + async function conditionalResponse(schema: string, id: string, revision: string, method: "PATCH" | "PUT" | "DELETE", body: Record | undefined, options: RecordRequestOptions): Promise { + if (!validRevision(revision)) throw new ForgeRecordRevisionError("invalid_revision", "A valid observed record revision is required.") + const encoded = body === undefined ? undefined : JSON.stringify(wrap(body)) + const token = config.getToken(), tenant = config.getActiveTenant?.() ?? null + const baseline = await getVersionedEntity(schema, id, options) + options.signal?.throwIfAborted() + if (token !== config.getToken() || tenant !== (config.getActiveTenant?.() ?? null)) throw new ForgeRecordRevisionError("context_changed", "The account context changed. Reload the record before editing.") + if (baseline.revision !== revision) throw new ForgeRecordRevisionError("conflict", "The record changed. Reload it before editing.") + // A mutation is never automatically retried, including after a 401. + return requestResponse(entityPath(schema, id), { ...revisionInit(options), method, body: encoded, headers: { Accept: "application/json", [EXPECTED_REVISION_HEADER]: revision } }, false) + } + return { + getVersionedEntity, + async updateEntityIfRevision(schema, id, body, revision, options = {}) { + return decodeVersioned(await conditionalResponse(schema, id, revision, "PATCH", body, options), id) + }, + async replaceEntityIfRevision(schema, id, body, revision, options = {}) { + return decodeVersioned(await conditionalResponse(schema, id, revision, "PUT", body, options), id) + }, + async deleteEntityIfRevision(schema, id, revision, options = {}) { + const response = await conditionalResponse(schema, id, revision, "DELETE", undefined, options) + if (response.status !== 204) { + await response.body?.cancel() + throw new ForgeRecordRevisionError("invalid_response", "Unexpected conditional delete response. Reload current state before trying again.") + } + }, async listSchemas() { const res = await request(`${FORGE_PREFIX}/schemas`) return res.schemas diff --git a/packages/client/tests/record-revisions.test.mjs b/packages/client/tests/record-revisions.test.mjs new file mode 100644 index 0000000..5ae323e --- /dev/null +++ b/packages/client/tests/record-revisions.test.mjs @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createForgeClient, ForgeApiError, ForgeUnauthorizedError, ForgeRecordRevisionError } from '../dist/index.js' + +const revision = 'revision_' + '0'.repeat(25) + '1' +const newer = 'revision_' + '0'.repeat(25) + '2' +const envelope = (id = 'one', fields = { name: 'Synthetic record' }) => ({ id, fields, permissions: { update: true } }) +const json = (body, status = 200, token = revision) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json', ...(token ? { 'Entity-Revision': token } : {}) } }) +const configured = extras => createForgeClient({ baseUrl: 'https://api.example.test', getToken: () => 'synthetic', getActiveTenant: () => 'Organization:alpha', ...extras }) + +test('reads an opaque fresh baseline with encoded identity, current auth and cancellation', async t => { + let call + t.mock.method(globalThis, 'fetch', async (url, init) => { call = { url, init }; return json(envelope('id?#')) }) + const signal = new AbortController().signal + const result = await configured().getVersionedEntity('Example/Archive', 'id?#', { signal }) + assert.deepEqual(result, { row: { id: 'id?#', name: 'Synthetic record', __permissions: { update: true } }, revision }) + assert.equal(call.url, 'https://api.example.test/api/v1/forge/schemas/Example%2FArchive/entities/id%3F%23') + assert.equal(call.init.headers.Authorization, 'Bearer synthetic') + assert.equal(call.init.headers['X-Active-Tenant'], 'Organization:alpha') + assert.equal(call.init.cache, 'no-store'); assert.equal(call.init.redirect, 'error'); assert.equal(call.init.signal, signal) +}) + +test('PATCH and PUT snapshot fields and use the observed condition after a fresh preflight', async t => { + let calls = [], body = { id: 'ignored', name: 'Original input' } + t.mock.method(globalThis, 'fetch', async (url, init) => { + calls.push({ url, init }) + if (!init.method) { body.name = 'Changed after submission'; return json(envelope()) } + return json(envelope('one', { name: 'Original input' }), 200, newer) + }) + const client = configured() + for (const [method, verb] of [['updateEntityIfRevision', 'PATCH'], ['replaceEntityIfRevision', 'PUT']]) { + calls = []; body = { id: 'ignored', name: 'Original input' } + const result = await client[method]('Example', 'one', body, revision) + assert.equal(result.revision, newer); assert.equal(result.row.name, 'Original input') + assert.equal(calls.length, 2); assert.equal(calls[1].init.method, verb) + assert.equal(calls[1].init.headers['If-Entity-Revision'], revision) + assert.deepEqual(JSON.parse(calls[1].init.body), { fields: { name: 'Original input' } }) + assert.equal(calls[1].init.cache, 'no-store'); assert.equal(calls[1].init.redirect, 'error') + } +}) + +test('conditional delete requires a fresh supported baseline and preserves 204 behavior', async t => { + const calls = [] + t.mock.method(globalThis, 'fetch', async (_url, init) => { calls.push(init); return init.method === 'DELETE' ? new Response(null, { status: 204 }) : json(envelope()) }) + assert.equal(await configured().deleteEntityIfRevision('Example', 'one', revision), undefined) + assert.equal(calls.length, 2); assert.equal(calls[1].headers['If-Entity-Revision'], revision) +}) + +test('an older or unprepared backend cannot receive a conditional mutation', async t => { + const calls = [] + t.mock.method(globalThis, 'fetch', async (_url, init) => { calls.push(init); return json(envelope(), 200, null) }) + await assert.rejects(configured().updateEntityIfRevision('Example', 'one', {}, revision), error => error instanceof ForgeRecordRevisionError && error.code === 'unavailable') + assert.equal(calls.length, 1); assert.equal(calls[0].method, undefined) +}) + +test('invalid input or a changed baseline causes no mutation', async t => { + const calls = [] + t.mock.method(globalThis, 'fetch', async (_url, init) => { calls.push(init); return json(envelope(), 200, newer) }) + const client = configured() + for (const invalid of ['', 'private-token', revision + ', ' + newer]) await assert.rejects(client.deleteEntityIfRevision('Example', 'one', invalid), error => error.code === 'invalid_revision') + assert.equal(calls.length, 0) + await assert.rejects(client.deleteEntityIfRevision('Example', 'one', revision), error => error.code === 'conflict') + assert.equal(calls.length, 1); assert.equal(calls[0].method, undefined) +}) + +test('server conflicts are returned once without renewal or a mutation retry', async t => { + let calls = 0, refreshes = 0 + t.mock.method(globalThis, 'fetch', async (_url, init) => { calls++; return init.method ? json({ error: 'conflict', reason: 'revision_conflict' }, 409, null) : json(envelope()) }) + await assert.rejects(configured({ onUnauthorized: async () => { refreshes++; return 'renewed' } }).updateEntityIfRevision('Example', 'one', {}, revision), error => error instanceof ForgeApiError && error.status === 409) + assert.equal(calls, 2); assert.equal(refreshes, 0) +}) + +test('a mutation 401 is not automatically retried under renewed credentials', async t => { + let calls = 0, refreshes = 0 + t.mock.method(globalThis, 'fetch', async (_url, init) => { calls++; return init.method ? json({}, 401, null) : json(envelope()) }) + await assert.rejects(configured({ onUnauthorized: async () => { refreshes++; return 'renewed' } }).deleteEntityIfRevision('Example', 'one', revision), ForgeUnauthorizedError) + assert.equal(calls, 2); assert.equal(refreshes, 0) +}) + +test('account or selected-program changes during preflight prevent the write', async t => { + let token = 'first', tenant = 'Organization:alpha', calls = 0, change + t.mock.method(globalThis, 'fetch', async () => { calls++; change(); return json(envelope()) }) + const client = configured({ getToken: () => token, getActiveTenant: () => tenant }) + for (change of [() => { token = 'second' }, () => { tenant = 'Organization:beta' }]) { + await assert.rejects(client.updateEntityIfRevision('Example', 'one', {}, revision), error => error.code === 'context_changed') + } + assert.equal(calls, 2) +}) + +test('malformed versioned metadata is rejected without echoing private content', async t => { + let response + t.mock.method(globalThis, 'fetch', async () => response) + const client = configured() + for (response of [json(envelope(), 200, 'private-malformed-revision'), json(envelope('different')), json({ id: 'one', fields: [] }), new Response('private-json-error', { headers: { 'Entity-Revision': revision, 'Content-Type': 'application/json' } })]) { + await assert.rejects(client.getVersionedEntity('Example', 'one'), error => error.code === 'invalid_response' && !error.message.includes('private')) + } +}) + +test('non-JSON revision responses are cancelled before body buffering', async t => { + let cancelled = false + t.mock.method(globalThis, 'fetch', async () => new Response(new ReadableStream({ cancel() { cancelled = true } }), { headers: { 'Entity-Revision': revision, 'Content-Type': 'text/plain' } })) + await assert.rejects(configured().getVersionedEntity('Example', 'one'), error => error.code === 'invalid_response') + assert.equal(cancelled, true) +}) + +test('cancellation during preflight prevents the mutation', async t => { + const controller = new AbortController(); let calls = 0 + t.mock.method(globalThis, 'fetch', async () => { calls++; controller.abort(new Error('Synthetic cancellation')); return json(envelope()) }) + await assert.rejects(configured().deleteEntityIfRevision('Example', 'one', revision, { signal: controller.signal }), { message: 'Synthetic cancellation' }) + assert.equal(calls, 1) +}) + +test('a failed mutation response does not initiate another write', async t => { + let calls = 0 + t.mock.method(globalThis, 'fetch', async (_url, init) => { calls++; if (init.method) throw new Error('Synthetic response lost'); return json(envelope()) }) + await assert.rejects(configured().updateEntityIfRevision('Example', 'one', {}, revision), { message: 'Synthetic response lost' }) + assert.equal(calls, 2) +}) + + +test('preserves cancellation while reading the versioned response body', async t => { + const controller = new AbortController() + let ready + const received = new Promise(resolve => { ready = resolve }) + t.mock.method(globalThis, 'fetch', async (_url, init) => new Response(new ReadableStream({ + start(stream) { + init.signal.addEventListener('abort', () => stream.error(init.signal.reason), { once: true }) + ready() + }, + }), { headers: { 'Entity-Revision': revision, 'Content-Type': 'application/json' } })) + const pending = configured().getVersionedEntity('Example', 'one', { signal: controller.signal }) + await received + controller.abort(new Error('Synthetic body cancellation')) + await assert.rejects(pending, { message: 'Synthetic body cancellation' }) +})