diff --git a/.changeset/recover-reserved-creates.md b/.changeset/recover-reserved-creates.md new file mode 100644 index 0000000..5484199 --- /dev/null +++ b/.changeset/recover-reserved-creates.md @@ -0,0 +1,5 @@ +--- +"@schemaforge/client": minor +--- + +Add typed create reservation, immutable request snapshots and explicit receipt recovery without automatic replacement creates. diff --git a/packages/client/CREATE-RECOVERY.md b/packages/client/CREATE-RECOVERY.md new file mode 100644 index 0000000..bce9de7 --- /dev/null +++ b/packages/client/CREATE-RECOVERY.md @@ -0,0 +1,32 @@ +# Recovering uncertain creates + +`createForgeClient` provides an additive `CreateRecoveryClient` alongside the existing client and revision interfaces. It requires the SchemaForge `create-intent-v1` backend contract. The initial backend supports authenticated PostgreSQL operations on schemas without configured hooks or enabled global webhooks. A capability advertisement alone does not establish support for an individual schema. + +```ts +const prepared = await client.prepareCreate('Example', { name: 'My record' }) +const result = await client.commitPreparedCreate(prepared) +// If the result was lost, retain prepared and recover the same operation: +const receipt = await client.getCreateReceipt(prepared.schema, prepared.receipt.id) +``` + +Preparation reserves an operation without creating an entity. The returned object includes a deep-frozen JSON snapshot of the submitted fields and the server receipt. It contains application input, which may be private, but no credentials. Caller changes to the original input do not alter the reserved snapshot. The client performs no storage, polling, automatic mutation retries or replacement reservation. The host owns draft retention and must bind the prepared object to its original account, selected program and workspace. Do not automatically carry a draft into another account context. + +Commitment first reads the current receipt with current authentication. Changes to token or program providers during an awaited operation reject the result; a context change during the receipt preflight prevents the commit request. A host that renews sessions must re-establish the original identity before deliberately retrying. Hosts must also prevent their own retry wrappers from automatically replaying mutations. + +First commitment returns `outcome: 'created'`; an explicit replay returns `outcome: 'reconciled'`. Both include the current authorized entity, including any later edits. A `revision` is included only when the backend returns valid prepared revision support. Its absence is not permission to perform an unconditional overwrite. Receipt reads return `pending`, `committed`, or `committed_unavailable`; only `committed` includes a currently readable entity ID. A deleted result is not recreated. + +## Recovery decisions + +The receipt's `expires_at` and `recover_until` are server deadlines. Backend v1 admits a first commit for 15 minutes and permits receipt recovery for 24 hours from reservation. An admitted transaction may finish after its admission deadline. The SDK leaves deadline enforcement to the server rather than treating the browser clock as authoritative. + +A pending receipt is a snapshot, not proof that an in-flight commit will fail. After a lost response, reconcile or explicitly submit the same prepared operation. An unavailable or expired receipt, an authorization denial, a malformed successful response, a timeout or a network failure does not justify automatically creating a replacement. The user or operator must resolve uncertainty within the supported recovery policy. + +Validation or business-name conflicts leave the backend reservation pending until admission expires. A later identical submission can succeed if relevant state changes; changing fields requires a separate explicit create decision. Schema changes may invalidate an uncommitted reservation. Matching a live name does not prove that a particular create operation succeeded. Business-name uniqueness and conditional edits remain separate concerns. + +## Transport and errors + +Requests use encoded identities, current token/tenant providers, `cache: 'no-store'`, redirect refusal and the optional caller abort signal. Reservation and commit POST requests are never automatically refreshed or retried, including on HTTP 401. Receipt GET can use the existing host renewal callback, but a changed provider value prevents a subsequent commit in the same operation. + +`ForgeCreateRecoveryError` distinguishes malformed input/response, context changes and deleted committed results. HTTP failures retain `ForgeApiError.status` and `.body`; authentication failures use `ForgeUnauthorizedError`. Classify documented structured error codes, not human messages. Ordinary create can use `error: "unique_violation"`; conditional and intent paths can use `error: "conflict"` with `reason: "unique_violation"` to avoid disclosing hidden constraint details. A request already sent can commit even if its response is rejected or cancelled. Cancelling a request does not roll back persistence. + +The backend scopes receipts to the normalized principal, effective selected tenant and schema identity, rechecks current authorization, and atomically persists the entity, initial revision and receipt. Those server guarantees cannot be supplied by a client preflight. Direct database changes, partial restores and external side effects remain outside this protocol. This document describes development support and does not assert an upstream release or production acceptance. diff --git a/packages/client/package.json b/packages/client/package.json index a1fdbeb..aa6fea3 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -9,7 +9,8 @@ "files": [ "dist", "FILE-ACCESS.md", - "RECORD-REVISIONS.md" + "RECORD-REVISIONS.md", + "CREATE-RECOVERY.md" ], "main": "./dist/index.cjs", "module": "./dist/index.js", diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 524d83b..8b265db 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -89,13 +89,47 @@ export interface RecordRevisionClient { replaceEntityIfRevision(schema: string, id: string, body: Record, revision: string, options?: RecordRequestOptions): Promise deleteEntityIfRevision(schema: string, id: string, revision: string, options?: RecordRequestOptions): Promise } +export type CreateReceipt = { + id: string + state: "pending" | "committed" | "committed_unavailable" + expires_at: string + recover_until: string + entity_id: string | null +} +/** An immutable submitted snapshot. Contains application input, never credentials. */ +export type PreparedCreate = Readonly<{ schema: string; receipt: Readonly; fields: Readonly> }> +export type CreateCommitResult = { outcome: "created" | "reconciled"; row: EntityRow; revision?: string } +export class ForgeCreateRecoveryError extends Error { + constructor(readonly code: "invalid_request" | "invalid_response" | "context_changed" | "result_unavailable", message: string) { + super(message) + this.name = "ForgeCreateRecoveryError" + } +} +export interface CreateRecoveryClient { + prepareCreate(schema: string, fields: Record, options?: RecordRequestOptions): Promise + getCreateReceipt(schema: string, intent: string, options?: RecordRequestOptions): Promise + commitPreparedCreate(prepared: PreparedCreate, options?: RecordRequestOptions): Promise +} +function validIntent(value: unknown): value is string { + return typeof value === "string" && /^createintent_[0-9a-hjkmnp-tv-z]{26}$/.test(value) +} +function freezeJson(value: T): T { + if (value && typeof value === "object") { + for (const child of Object.values(value)) freezeJson(child) + Object.freeze(value) + } + return value +} +function createResponseError(): ForgeCreateRecoveryError { + return new ForgeCreateRecoveryError("invalid_response", "Invalid create recovery response. Reconcile the same operation before creating a replacement.") +} 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 { +export function createForgeClient(config: ForgeClientConfig): ForgeClient & RecordRevisionClient & CreateRecoveryClient { const base = config.baseUrl ?? "" function buildHeaders(extra?: Record): Record { @@ -188,7 +222,76 @@ export function createForgeClient(config: ForgeClientConfig): ForgeClient & Reco return requestResponse(entityPath(schema, id), { ...revisionInit(options), method, body: encoded, headers: { Accept: "application/json", [EXPECTED_REVISION_HEADER]: revision } }, false) } + function pinCreateContext(): () => void { + const token = config.getToken(), tenant = config.getActiveTenant?.() ?? null + return () => { + if (token !== config.getToken() || tenant !== (config.getActiveTenant?.() ?? null)) throw new ForgeCreateRecoveryError("context_changed", "The account context changed. Recover this operation in its original context.") + } + } + function intentPath(schema: string, intent?: string): string { + if (typeof schema !== "string" || !schema || schema.length > 240) throw new ForgeCreateRecoveryError("invalid_request", "A schema identity is required.") + if (intent !== undefined && !validIntent(intent)) throw new ForgeCreateRecoveryError("invalid_request", "A valid reserved create intent is required.") + return `${FORGE_PREFIX}/schemas/${encodeURIComponent(schema)}/create-intents${intent === undefined ? "" : `/${encodeURIComponent(intent)}`}` + } + async function createJson(response: Response, statuses: number[]): Promise { + if (!statuses.includes(response.status) || response.headers.get("content-type")?.split(";")[0].trim().toLowerCase() !== "application/json") { + await response.body?.cancel() + throw createResponseError() + } + try { return await response.json() } + catch (error) { if (error instanceof SyntaxError) throw createResponseError(); throw error } + } + function decodeReceipt(value: unknown, expected?: string): CreateReceipt { + if (!value || typeof value !== "object") throw createResponseError() + const row = value as Partial + if (!validIntent(row.id) || (expected !== undefined && row.id !== expected) || !["pending", "committed", "committed_unavailable"].includes(row.state ?? "") || typeof row.expires_at !== "string" || typeof row.recover_until !== "string" || !Number.isFinite(Date.parse(row.expires_at)) || !Number.isFinite(Date.parse(row.recover_until)) || Date.parse(row.recover_until) <= Date.parse(row.expires_at)) throw createResponseError() + if (row.state === "committed" ? !validEntityIdentity(row.entity_id) : row.entity_id !== null) throw createResponseError() + return { id: row.id, state: row.state!, expires_at: row.expires_at, recover_until: row.recover_until, entity_id: row.entity_id! } + } + function validEntityIdentity(value: unknown): value is string { + return typeof value === "string" && value.length > 0 && value.length <= 240 && !/[\u0000-\u0020\u007f]/.test(value) + } + async function getCreateReceipt(schema: string, intent: string, options: RecordRequestOptions = {}): Promise { + options.signal?.throwIfAborted() + const path = intentPath(schema, intent), assertContext = pinCreateContext() + const value = await createJson(await requestResponse(path, revisionInit(options)), [200]) + options.signal?.throwIfAborted(); assertContext() + return decodeReceipt(value, intent) + } + return { + getCreateReceipt, + async prepareCreate(schema, fields, options = {}) { + options.signal?.throwIfAborted() + const path = intentPath(schema), assertContext = pinCreateContext() + if (!fields || typeof fields !== "object" || Array.isArray(fields)) throw new ForgeCreateRecoveryError("invalid_request", "Create fields must be an object.") + const body = JSON.stringify(wrap(fields)) + const snapshot = freezeJson(JSON.parse(body).fields as Record) + const value = await createJson(await requestResponse(path, { ...revisionInit(options), method: "POST", body }, false), [201]) + options.signal?.throwIfAborted(); assertContext() + const receipt = decodeReceipt(value) + if (receipt.state !== "pending") throw createResponseError() + return Object.freeze({ schema, receipt: Object.freeze(receipt), fields: snapshot }) + }, + async commitPreparedCreate(prepared, options = {}) { + options.signal?.throwIfAborted() + if (!prepared || !prepared.receipt || !validIntent(prepared.receipt.id) || !prepared.fields || typeof prepared.fields !== "object" || Array.isArray(prepared.fields)) throw new ForgeCreateRecoveryError("invalid_request", "An immutable prepared create operation is required.") + const { schema } = prepared, intent = prepared.receipt.id + const body = JSON.stringify({ fields: prepared.fields }), assertContext = pinCreateContext() + const receipt = await getCreateReceipt(schema, intent, options) + options.signal?.throwIfAborted(); assertContext() + if (receipt.state === "committed_unavailable") throw new ForgeCreateRecoveryError("result_unavailable", "This operation committed a record that is no longer available. It will not be recreated.") + const response = await requestResponse(`${FORGE_PREFIX}/schemas/${encodeURIComponent(schema)}/entities`, { ...revisionInit(options), method: "POST", body, headers: { Accept: "application/json", "Create-Intent": intent } }, false) + if (response.headers.get("Create-Intent") !== intent) { await response.body?.cancel(); throw createResponseError() } + const revision = response.headers.get(RECORD_REVISION_HEADER) + if (revision !== null && !validRevision(revision)) { await response.body?.cancel(); throw createResponseError() } + const value = await createJson(response, [200, 201]) + options.signal?.throwIfAborted(); assertContext() + if (!value || typeof value !== "object") throw createResponseError() + const envelope = value as EntityEnvelope + if (!validEntityIdentity(envelope.id) || !envelope.fields || typeof envelope.fields !== "object" || Array.isArray(envelope.fields) || (receipt.entity_id !== null && envelope.id !== receipt.entity_id)) throw createResponseError() + return { outcome: response.status === 201 ? "created" : "reconciled", row: { ...flatten(envelope), id: envelope.id }, ...(revision === null ? {} : { revision }) } + }, getVersionedEntity, async updateEntityIfRevision(schema, id, body, revision, options = {}) { return decodeVersioned(await conditionalResponse(schema, id, revision, "PATCH", body, options), id) diff --git a/packages/client/tests/create-recovery.test.mjs b/packages/client/tests/create-recovery.test.mjs new file mode 100644 index 0000000..dc07caf --- /dev/null +++ b/packages/client/tests/create-recovery.test.mjs @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createForgeClient, ForgeApiError, ForgeUnauthorizedError, ForgeCreateRecoveryError } from '../dist/index.js' +const intent = 'createintent_' + '0'.repeat(25) + '1' +const revision = 'revision_' + '0'.repeat(25) + '1' +const receipt = (state = 'pending') => ({ id: intent, state, expires_at: '2026-09-06T01:15:00Z', recover_until: '2026-09-07T01:00:00Z', entity_id: state === 'committed' ? 'example_one' : null }) +const prepared = () => Object.freeze({ schema: 'Example', receipt: Object.freeze(receipt()), fields: Object.freeze({ name: 'Synthetic' }) }) +const json = (value, status = 200, headers = {}) => new Response(JSON.stringify(value), { status, headers: { 'Content-Type': 'application/json', ...headers } }) +const committed = (status = 201, id = 'example_one') => json({ id, fields: { name: 'Current authorized value' } }, status, { 'Create-Intent': intent, 'Entity-Revision': revision }) +const configured = extras => createForgeClient({ baseUrl: 'https://api.example.test', getToken: () => 'synthetic', getActiveTenant: () => 'Organization:alpha', ...extras }) + +test('preparation freezes an independent JSON snapshot and commitment sends those exact fields', async t => { + const fields = { id: 'ignored', name: 'Synthetic', nested: { choices: ['original'] } }, calls = [] + t.mock.method(globalThis, 'fetch', async (url, init) => { + calls.push({ url, init }) + if (url.endsWith('/create-intents')) { fields.nested.choices[0] = 'changed externally'; return json(receipt(), 201) } + return init.method === 'POST' ? committed() : json(receipt()) + }) + const client = configured(), signal = new AbortController().signal + const operation = await client.prepareCreate('Example/Archive', fields, { signal }) + assert.equal(operation.fields.nested.choices[0], 'original') + assert.throws(() => { operation.fields.nested.choices[0] = 'mutation' }, TypeError) + assert.ok(Object.isFrozen(operation.receipt)) + const result = await client.commitPreparedCreate(operation, { signal }) + assert.equal(result.outcome, 'created'); assert.equal(result.revision, revision) + assert.equal(calls[0].init.body, calls[2].init.body) + assert.ok(calls[0].url.includes('Example%2FArchive')) + for (const { init } of calls) { + assert.equal(init.cache, 'no-store'); assert.equal(init.redirect, 'error'); assert.equal(init.signal, signal) + assert.equal(init.headers.Authorization, 'Bearer synthetic'); assert.equal(init.headers['X-Active-Tenant'], 'Organization:alpha') + } + assert.equal(calls[2].init.headers['Create-Intent'], intent) + assert.ok(!JSON.stringify(operation).includes('synthetic')) +}) + +test('a lost commit response never causes an automatic replacement and explicit recovery reuses identity', async t => { + let committedOnce = false, posts = 0 + t.mock.method(globalThis, 'fetch', async (_url, init) => { + if (init.method !== 'POST') return json(receipt(committedOnce ? 'committed' : 'pending')) + posts++ + if (!committedOnce) { committedOnce = true; throw new TypeError('Synthetic response loss') } + return committed(200) + }) + const client = configured(), operation = prepared() + await assert.rejects(client.commitPreparedCreate(operation), TypeError) + assert.equal(posts, 1) + assert.equal((await client.getCreateReceipt('Example', intent)).state, 'committed') + assert.equal((await client.commitPreparedCreate(operation)).outcome, 'reconciled') + assert.equal(posts, 2) +}) + +test('reservation and commit authorization failures do not refresh or retry', async t => { + let posts = 0, renewals = 0 + t.mock.method(globalThis, 'fetch', async (_url, init) => { + if (init.method === 'POST') { posts++; return json({}, 401) } + return json(receipt()) + }) + const client = configured({ onUnauthorized: async () => { renewals++; return 'renewed' } }) + await assert.rejects(client.prepareCreate('Example', {}), ForgeUnauthorizedError) + await assert.rejects(client.commitPreparedCreate(prepared()), ForgeUnauthorizedError) + assert.equal(posts, 2); assert.equal(renewals, 0) +}) + +test('context changes during preparation discard the reservation response and during preflight prevent commitment', async t => { + let tenant = 'Organization:alpha', calls = 0, mode = 'reserve' + t.mock.method(globalThis, 'fetch', async () => { calls++; tenant = 'Organization:beta'; return json(receipt(), mode === 'reserve' ? 201 : 200) }) + const client = configured({ getActiveTenant: () => tenant }) + await assert.rejects(client.prepareCreate('Example', {}), error => error.code === 'context_changed') + tenant = 'Organization:alpha'; mode = 'commit' + await assert.rejects(client.commitPreparedCreate(prepared()), error => error.code === 'context_changed') + assert.equal(calls, 2) +}) + +test('renewal during receipt preflight cannot silently retarget a commitment', async t => { + let token = 'old', calls = 0 + t.mock.method(globalThis, 'fetch', async () => ++calls === 1 ? json({}, 401) : json(receipt())) + const client = configured({ getToken: () => token, onUnauthorized: async () => { token = 'renewed'; return token } }) + await assert.rejects(client.commitPreparedCreate(prepared()), error => error.code === 'context_changed') + assert.equal(calls, 2) +}) + +test('deleted committed results and expired or denied receipts never dispatch a commit', async t => { + let response, calls = 0 + t.mock.method(globalThis, 'fetch', async (_url, init) => { calls++; assert.notEqual(init.method, 'POST'); return response }) + const client = configured() + response = json(receipt('committed_unavailable')) + await assert.rejects(client.commitPreparedCreate(prepared()), error => error.code === 'result_unavailable') + for (const status of [403, 404, 409, 503]) { + response = json({ error: 'conflict', reason: 'create_intent_unavailable' }, status) + await assert.rejects(client.commitPreparedCreate(prepared()), error => error instanceof ForgeApiError && error.status === status) + } + assert.equal(calls, 5) +}) + +test('invalid intent input never reaches transport and malformed receipts never become prepared operations', async t => { + let calls = 0, response + t.mock.method(globalThis, 'fetch', async () => { calls++; return response }) + const client = configured() + for (const id of ['', 'private-invalid', intent + ', ' + intent]) await assert.rejects(client.getCreateReceipt('Example', id), error => error.code === 'invalid_request') + assert.equal(calls, 0) + for (const value of [null, {}, { ...receipt(), id: 'private-invalid' }, { ...receipt(), state: 'other' }, { ...receipt(), entity_id: 'leaked' }, { ...receipt(), recover_until: receipt().expires_at }, { ...receipt('committed'), entity_id: null }]) { + response = json(value, 201) + await assert.rejects(client.prepareCreate('Example', {}), error => error instanceof ForgeCreateRecoveryError && error.code === 'invalid_response' && !error.message.includes('private-invalid')) + } +}) + +test('malformed commit responses remain uncertain and do not cause another request', async t => { + let result, calls = 0 + t.mock.method(globalThis, 'fetch', async (_url, init) => { calls++; return init.method === 'POST' ? result : json(receipt('committed')) }) + for (result of [json({ id: 'example_one', fields: {} }), committed(200, 'different'), json({ id: 'example_one', fields: [] }, 200, { 'Create-Intent': intent }), new Response('private-invalid-json', { status: 201, headers: { 'Content-Type': 'application/json', 'Create-Intent': intent } })]) { + await assert.rejects(configured().commitPreparedCreate(prepared()), error => error.code === 'invalid_response' && !error.message.includes('private-invalid-json')) + } + assert.equal(calls, 8) +}) + +test('cancellation during receipt preflight prevents commitment', async t => { + const controller = new AbortController(); let calls = 0 + t.mock.method(globalThis, 'fetch', async () => { calls++; controller.abort(new Error('Synthetic cancellation')); return json(receipt()) }) + await assert.rejects(configured().commitPreparedCreate(prepared(), { signal: controller.signal }), { message: 'Synthetic cancellation' }) + assert.equal(calls, 1) +}) + +test('a conflict after preflight is preserved without retry or interpreting its message', async t => { + let calls = 0 + t.mock.method(globalThis, 'fetch', async (_url, init) => { calls++; return init.method === 'POST' ? json({ error: 'conflict', reason: 'create_intent_content_conflict' }, 409) : json(receipt()) }) + await assert.rejects(configured().commitPreparedCreate(prepared()), error => error instanceof ForgeApiError && error.status === 409 && JSON.parse(error.body).reason === 'create_intent_content_conflict') + assert.equal(calls, 2) +}) + +test('unprepared revision support is explicit and body cancellation stays cancellation', async t => { + let result, mode = 'commit' + t.mock.method(globalThis, 'fetch', async (_url, init) => mode === 'read' || init.method === 'POST' ? result : json(receipt())) + result = json({ id: 'example_one', fields: { name: 'Synthetic' } }, 201, { 'Create-Intent': intent }) + const committed = await configured().commitPreparedCreate(prepared()) + assert.equal(committed.revision, undefined) + mode = 'read' + result = new Response(new ReadableStream({ start(controller) { controller.error(new DOMException('Synthetic cancellation', 'AbortError')) } }), { headers: { 'Content-Type': 'application/json' } }) + await assert.rejects(configured().getCreateReceipt('Example', intent), { name: 'AbortError' }) +})