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/recover-reserved-creates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@schemaforge/client": minor
---

Add typed create reservation, immutable request snapshots and explicit receipt recovery without automatic replacement creates.
32 changes: 32 additions & 0 deletions packages/client/CREATE-RECOVERY.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
105 changes: 104 additions & 1 deletion packages/client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,47 @@ export interface RecordRevisionClient {
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>
}
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<CreateReceipt>; fields: Readonly<Record<string, unknown>> }>
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<string, unknown>, options?: RecordRequestOptions): Promise<PreparedCreate>
getCreateReceipt(schema: string, intent: string, options?: RecordRequestOptions): Promise<CreateReceipt>
commitPreparedCreate(prepared: PreparedCreate, options?: RecordRequestOptions): Promise<CreateCommitResult>
}
function validIntent(value: unknown): value is string {
return typeof value === "string" && /^createintent_[0-9a-hjkmnp-tv-z]{26}$/.test(value)
}
function freezeJson<T>(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<string, string>): Record<string, string> {
Expand Down Expand Up @@ -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<unknown> {
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<CreateReceipt>
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<CreateReceipt> {
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<string, unknown>)
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)
Expand Down
Loading
Loading