/** * The client→server graph-mutation channel — the missing return leg. * * LiteShip's stream is server→client (SSE). This is the other direction: a client * proposes a change to the graph (a sort, a filter, an edit — expressed as a * {@link GraphPatch}), sends it back to the server, and the server VALIDATES it * against its own current truth before applying. It is the AI-cast refuse-seam * ({@link validateGraphPatchProposal} → {@link applyValidatedPatch}) turned into a * transport-agnostic request/response: * * client: GraphPatch.propose(base, ops) → sendGraphMutation(url, patch) * server: handleGraphMutation(request, store) * → GraphPatch.decode → validateGraphPatchProposal → applyValidatedPatch * * The SAME validation an AI proposal passes governs a human client's edit: a patch * cast against a stale base (its `base` no longer matches the server's `graph.id`) * is REFUSED — optimistic concurrency for free — as is a dangling edge or a * malformed envelope. Nothing mutates the server graph except a validated patch. * * This module is transport-agnostic on purpose: `handleGraphMutation` takes an * already-parsed request and a host-owned {@link GraphStore}, and returns a plain * result — no `Request`/`Response`, no framework. `@czap/astro` wraps it into an * Astro API route; any host with a POST endpoint can wrap it the same way. The * host owns the graph store and thus the authority (ADR-0015): LiteShip provides * the channel and the gate, never the persistence. * * @module */ import type { DocumentGraph } from './document-graph.js'; import { GraphPatch } from './graph-patch.js'; /** * A client's mutation request: the proposed patch as it arrived over the wire * (untrusted `unknown` — a serialized {@link GraphPatch} envelope). It is decoded * and validated on the server; the client never mutates the graph directly. */ export interface GraphMutationRequest { /** The raw, untrusted GraphPatch envelope the client proposed (e.g. parsed JSON). */ readonly patch: unknown; } /** * The server's response. Three outcomes, one shape to consume: * - `applied` — the new sealed graph (the client swaps its view to it); * - `refused` — the patch did not validate (base mismatch, dangling edge, version skew, * malformed envelope, or a lost-update CAS miss); the graph is byte-identical. * `staleBase` is present (and `true`) exactly when the refusal is base-staleness / * lost-update: reload the base and re-propose. It is absent for invalid proposals, * where retrying the same patch cannot succeed; * - `error` — a SERVER-side failure (store I/O, an unexpected throw), distinct from a * refusal: the proposal may be fine, so a retry can succeed. */ export type GraphMutationResponse = { readonly status: 'applied'; readonly graph: DocumentGraph; } | { readonly status: 'refused'; readonly errors: readonly string[]; readonly staleBase?: true; } | { readonly status: 'error'; readonly message: string; }; /** * Outcome of {@link verifyAppliedGraph}: the re-sealed canonical graph on success, or the * reason the wire value is not a graph the server's own pipeline would emit. */ export type AppliedGraphVerification = { readonly ok: true; readonly graph: DocumentGraph; } | { readonly ok: false; readonly message: string; }; /** * The host's graph store — the authority boundary. LiteShip reads the current * truth and hands back the applied truth; the host decides where it lives (memory, * KV, DB) and persists it. `loadGraph` MUST return the current server-side graph * the client's patch will be validated against. */ export interface GraphStore { readonly loadGraph: () => DocumentGraph | Promise; /** * Compare-and-swap the graph: commit `next` ONLY if the store's current graph is still * `expected` — the base the patch was validated against, compared by its content * address (`id`). Return `false` if the store moved since `loadGraph` (a concurrent * commit won); the channel then REFUSES so the client reloads and retries. * * This is where the optimistic-concurrency guarantee is actually enforced. The * base-match validation stops a client that proposed against a STALE base; the CAS * stops two clients that both loaded the SAME base from clobbering each other (the * lost-update race). In-memory, compare the ids and swap only on a match; a DB/KV host * does a version-conditional UPDATE. */ readonly saveGraph: (next: DocumentGraph, expected: DocumentGraph) => boolean | Promise; } /** * Process one client mutation against the host's current graph. Pure of transport: * decode → load → validate → apply → save. NEVER throws — every failure maps to a * response shape, so the caller has exactly one thing to serialize: * - a bad proposal (malformed envelope, validation rejection, CAS miss) → `refused`; * - a store I/O failure (loadGraph / saveGraph reject) → `error` (not the client's * fault; a raw persistence error must not escape as an unstructured 500). * * The `error.message` is surfaced to the caller (and, via `graphMutationRoute`, to the * HTTP client) — deliberately: a blanket "internal error" would strand a host debugging a * failed mutation, the silent degradation LiteShip refuses to ship. LiteShip surfaces what * the host's store throws; it does not redact it. A `GraphStore` whose errors could carry * secrets (connection strings, internal paths) MUST therefore catch and re-throw a redacted * message inside the store — the store is the host's authority boundary (ADR-0015). */ export declare function handleGraphMutation(request: GraphMutationRequest, store: GraphStore): Promise; /** * The applied-graph adopt guard — proves a wire graph is a normalized, * self-addressed base the server's own pipeline would emit. Shared by * `sendGraphMutation` and `@czap/astro`'s `adoptAppliedGraph`. */ export declare function verifyAppliedGraph(value: unknown): AppliedGraphVerification; /** * Client-side sender: POST a proposed {@link GraphPatch} to the host's mutation * endpoint and resolve the server's {@link GraphMutationResponse}. A thin `fetch` * wrapper — the host wires the endpoint with {@link handleGraphMutation}. `fetchImpl` * is injectable for tests / non-browser hosts; it defaults to the global `fetch`. */ export declare function sendGraphMutation(url: string, patch: GraphPatch, fetchImpl?: typeof fetch): Promise; //# sourceMappingURL=graph-mutation.d.ts.map