clone

a self-hosted sync node for lofi apps: jazz sync over iroh, ticket-gated, with no static ip or cloud needed

feat/browser-cors-sync-tickets
lofi-node / src / verdict.ts
verdict.ts 4.7K
1// Write-verdict taxonomy: classify mutation-error codes so consumers act on2// the code, never on the human-readable reason string.3//4// A Jazz write that reaches the sync server settles into a per-batch fate;5// when that fate is a rejection it carries a machine-readable `code` plus a6// free-text `reason`. The reason is diagnostics only — its wording changes7// between Jazz versions. The code is the contract, and this module is the one8// place that maps codes onto the two actions a consumer can take:9//10// - `permanent`: the store has adjudicated the write and will never accept it.11// The write's verdict is `rejected`; compensation may run.12// - `transient`: the outcome is not adjudicated (store unreachable, timeout,13// ticket hiccup). The write stays pending; keep waiting and retrying.14//15// Codes not in the registry classify as `transient` by design: an16// unrecognized code must never trigger irreversible compensation. Waiting is17// recoverable; a wrong `rejected` verdict is not.1819/** How a consumer must treat a mutation-error code. */20export type MutationErrorClass = "permanent" | "transient";2122/**23 * The registry of known mutation-error codes.24 *25 * The pinned Jazz alpha's server can emit exactly four rejection codes26 * through the batch-settlement path (source: `jazz-tools` crate at the27 * alpha.53 publish commit; the code space is the set of string literals fed28 * into `BatchFate::Rejected`, not a closed enum):29 *30 * - `permission_denied` — the store adjudicated the write and refused it.31 * Despite the name this is the server's general write-denial code: policy32 * denial under a stale client policy, content that fails the server's33 * column validation, and schema-resolution failures all surface under it34 * (the `reason` differs; the classification does not). Permanent.35 * - `transaction_conflict` — a transactional batch whose staged row's36 * visible parent frontier moved before the authority validated the seal.37 * Observed in the harness: the fate is final and deterministic across38 * reconnects; the engine does not rebase or retry. A retry is a new batch.39 * Permanent.40 * - `permissions_head_missing` — the store enforces permissions but has no41 * published permissions head (schema deployed without permissions).42 * Observed in the harness: a persisted, batch-correlated rejection on both43 * verdict surfaces. Publishing permissions later does not revisit the44 * fate. Permanent.45 * - `invalid_batch_submission` — the sealed submission itself is malformed46 * (empty member list, digest mismatch, rows off the declared branch,47 * invalid row states, mixed direct/transactional rows). Every trigger is48 * an envelope invariant the client runtime itself computes, and the one49 * author-reachable shape (committing an empty transaction) throws50 * synchronously at the call site — so the code is unreachable from the51 * public API by construction. It stays out of the registry (classifying52 * as transient) until a rejection can actually be observed: an unprovoked53 * permanent entry could authorize compensation on a fate whose semantics54 * were never demonstrated.55 *56 * Writes that violate the CURRENT locally-known policy or schema never57 * produce a code at all — the client runtime refuses them synchronously at58 * the call site, before any batch exists.59 *60 * `expired` (the store refused the write because it arrived past its declared61 * deadline) is registered permanent: late arrival is final, since waiting or62 * re-sending only arrives later still. No store emits it yet, so the entry is63 * inert until one does. The pinned alpha does use the string `expired` on a64 * different surface — the transport's unauthenticated response, for a65 * rejected JWT — but that surface never reaches this classifier.66 */67export const MUTATION_ERROR_CLASSES: Readonly<Record<string, MutationErrorClass>> = {68 permission_denied: "permanent",69 transaction_conflict: "permanent",70 permissions_head_missing: "permanent",71 expired: "permanent",72};7374/**75 * Classify a mutation-error code from a rejected batch fate — the `code` on76 * `MutationErrorEvent`, on `PersistedWriteRejectedError`, or on a77 * `BatchFate` of kind `rejected`.78 *79 * Unknown codes classify as `transient`: the consumer keeps the write80 * pending rather than compensating on a code it cannot interpret.81 */82export function classifyMutationError(code: string): MutationErrorClass {83 return MUTATION_ERROR_CLASSES[code] ?? "transient";84}8586/** True when {@link classifyMutationError} classifies `code` as permanent —87 * the write is `rejected` and compensation may run. */88export function isPermanentMutationError(code: string): boolean {89 return classifyMutationError(code) === "permanent";90}