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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fresh-record-revisions.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 24 additions & 0 deletions packages/client/RECORD-REVISIONS.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
88 changes: 85 additions & 3 deletions packages/client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,29 @@ export interface ForgeClient {
me(): Promise<MeResponse>
}

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<VersionedEntityResult>
updateEntityIfRevision(schema: string, id: string, body: Record<string, unknown>, revision: string, options?: RecordRequestOptions): Promise<VersionedEntityResult>
replaceEntityIfRevision(schema: string, id: string, body: Record<string, unknown>, revision: string, options?: RecordRequestOptions): Promise<VersionedEntityResult>
deleteEntityIfRevision(schema: string, id: string, revision: string, options?: RecordRequestOptions): Promise<void>
}
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<string, string>): Record<string, string> {
Expand All @@ -89,14 +111,19 @@ export function createForgeClient(config: ForgeClientConfig): ForgeClient {
return fetch(`${base}${path}`, { ...init, headers: buildHeaders(init?.headers as Record<string, string>) })
}

async function request<T>(path: string, init?: RequestInit, requireJson = false): Promise<T> {
async function requestResponse(path: string, init?: RequestInit, allowRefresh = true): Promise<Response> {
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<T>(path: string, init?: RequestInit, requireJson = false): Promise<T> {
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")
Expand All @@ -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<VersionedEntityResult> {
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<VersionedEntityResult> {
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<string, unknown> | undefined, options: RecordRequestOptions): Promise<Response> {
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<ListSchemasResponse>(`${FORGE_PREFIX}/schemas`)
return res.schemas
Expand Down
135 changes: 135 additions & 0 deletions packages/client/tests/record-revisions.test.mjs
Original file line number Diff line number Diff line change
@@ -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' })
})
Loading