API index and search · Build metadata
Source snapshot
packages/client/src/mutation-ops.ts
1// Isomorphic mutation ops — the backend-agnostic "rindle mutation" vocabulary2// (MUTATORS-ISOMORPHIC plan). A mutator names WHAT it writes (insert/update/upsert/3// insertIgnore/delete over a keyed row), not HOW — the server renders dialect SQL and the4// client applies to its local engine. This lives in `@rindle/client` (the leaf both tiers5// import); the SQL renderer that consumes it is server-only (`@rindle/api-server`).67import type { Ast } from "./ast.ts";8import type { ColsMap, InsertOf, PkColsOf, PkMap, PkOf, Schema, UpdateOf } from "./schema.ts";9import type { WireValue } from "./types.ts";1011/** A keyed row: column name → cell. The ergonomic write shape (validated against the schema at12 * runtime). JSON columns carry their raw JSON string (a {@link WireValue}), never a parsed object. */13export type KeyedRow = Record<string, WireValue>;1415/** One structured write intent — the discriminated union that mirrors the write half of the client16 * `MutationTx` 1:1. Keyed (column-name addressed), so it is independent of column order. */17export type MutationOp =18 | { kind: "insert"; table: string; row: KeyedRow } // required columns; omitted nullable cells become null19 | { kind: "upsert"; table: string; row: KeyedRow } // insert shape; replace non-pk cols on pk conflict20 | { kind: "insertIgnore"; table: string; row: KeyedRow } // insert shape; do nothing on pk conflict21 | { kind: "update"; table: string; row: KeyedRow } // pk cols + the non-pk cols to change22 | { kind: "delete"; table: string; pk: KeyedRow }; // pk cols only2324/** The ASYNC server-side write surface a mutator runs against. Semantically the twin of the client's25 * synchronous `MutationTx` write methods — same names, same keyed-row arguments — but every op is a26 * Promise. Reads see this transaction's own writes on both Postgres and the daemon; the daemon27 * opens an interactive transaction when a read is needed. Inserts require every non-nullable28 * column and fill omitted nullable columns with `null`. Updates and deletes require the PK. */29export interface ServerWriteTx {30 /** Insert a row. Missing required columns and unknown columns throw; omitted nullable31 * columns become `null`, rather than using database defaults. */32 insert(table: string, row: KeyedRow): Promise<void>;33 /** Update the row identified by the pk columns; only the named non-pk columns change. A missing34 * row is a NO-OP. */35 update(table: string, row: KeyedRow): Promise<void>;36 /** Insert, or replace non-PK columns on PK conflict, using the same insert shape. */37 upsert(table: string, row: KeyedRow): Promise<void>;38 /** Insert with the same nullable-column rules as `insert`, or do nothing on PK conflict. */39 insertIgnore(table: string, row: KeyedRow): Promise<void>;40 /** Delete the row identified by the pk columns. A missing row is a NO-OP. */41 delete(table: string, pk: KeyedRow): Promise<void>;42 /** Read one row by primary key, through the OPEN transaction on both backends43 * (read-your-writes: live on Postgres; via an interactive mutation session on the daemon,44 * DAEMON-INTERACTIVE-TXN-DESIGN.md §5.3). */45 row(table: string, pk: KeyedRow): Promise<KeyedRow | undefined>;46}4748// --------------------------------------------------------------------------- shared (generator) mutators49//50// The isomorphic mutator seam (MUTATORS-ISOMORPHIC plan §"one body, two tiers"): a mutator is a51// GENERATOR that `yield`s effects instead of an async/sync function. A generator is neither sync nor52// async — the tier's DRIVER decides — so ONE body runs synchronously against the browser wasm engine53// AND against a live async Postgres transaction on the server. Writes are `yield`ed and pipelined by54// the driver (never individually awaited by the body); a read is the thing that suspends. Three read55// shapes: `yield tx.row(...)` (a point pk read, evaluating to the row), `yield tx.query(builder)` (a56// full `where`/`orderBy`/`limit`/join query, evaluating to its rows), and `yield tx.all([...])` (a57// fan-out — Promise.all on the server, in-order on the client). All are order-preserving on both58// tiers, so the body stays deterministic. Every read is read-your-writes (sees this mutator's own59// writes-so-far). `tx.query` runs the SAME query engine live queries use (the wasm IVM on the client;60// `@rindle/query-compiler`'s SQLite SELECT through the open session on the daemon backend).6162/** A point read a generator mutator yields; the driver resolves it and feeds the row back through63 * `gen.next(row)`. Read-your-writes on every tier: the client reads its local engine, and both64 * server backends read the open transaction (the daemon via an interactive mutation session). */65export type ReadEffect = { kind: "row"; table: string; pk: KeyedRow };6667/** A fan-out a generator mutator yields: run several effects "together". The server driver resolves68 * them with `Promise.all`; the client driver runs them in array order (already synchronous). Results69 * return in the same order on both tiers. Each result has its effect's shape: a row, query rows,70 * `undefined` for a write, or an array for a nested batch. Cast the yielded result to that shape;71 * the generator's declared next-type covers only point reads. */72export type BatchEffect = { kind: "all"; effects: readonly YieldEffect[] };7374/** A row returned by a {@link QueryEffect}: column name → cell, plus each materialized relationship75 * name → its nested row(s) — an array (a plural relationship) or a single row / `null` (a `.one()`76 * relationship). Recursive. Presented identically on both tiers (a `view.data` row of the same77 * query). */78export type QueryResultRow = { [key: string]: WireValue | QueryResultRow | QueryResultRow[] };7980/** What {@link IsoTx.query} accepts: a query handle whose `.ast()` lowers to the wire {@link Ast} —81 * exactly what the typed query builder produces (`newQueryBuilder(schema).<table>…`, the tier-agnostic82 * server-scope builder both tiers can construct because it performs no I/O). Structural so the seam83 * need not carry the builder's heavy generics. */84export type QueryArg = { ast(): Ast };8586/** A full-shape read a generator mutator yields — a `where`/`orderBy`/`limit`/join query over the87 * state this mutator is mutating (read-your-writes, like {@link ReadEffect} but an arbitrary shape).88 * Evaluates to {@link QueryResultRow}`[]` — ALWAYS an array of the matching rows, in the query's89 * order, on BOTH tiers (a root `.one()` is not unwrapped here: take `[0]`). The single next-type is90 * a row, so cast the yield (`(yield tx.query(q)) as unknown as QueryResultRow[]`), same as `all`. */91export type QueryEffect = { kind: "query"; query: QueryArg };9293/** Everything a generator mutator may `yield`: a write {@link MutationOp}, a point {@link ReadEffect},94 * a full-query {@link QueryEffect}, or a {@link BatchEffect} fan-out. */95export type YieldEffect = MutationOp | ReadEffect | BatchEffect | QueryEffect;9697/** The tier-AGNOSTIC effect factory a generator mutator writes against. Every method just BUILDS an98 * effect to `yield` — it performs no I/O and holds no state, so the single {@link isoTx} instance is99 * shared by every mutator on both tiers; only the driver differs. `insert`/`upsert`/`insertIgnore`100 * require non-nullable columns and permit nullable omissions, which become `null`.101 * `update`/`delete` require the PK columns; `update` also names the columns to change. */102export interface IsoTx<S extends ColsMap = ColsMap, P extends Record<string, string> = PkMap<S>> {103 /** Insert a row. Omitted nullable columns become `null`; database defaults are not applied. */104 insert<N extends keyof S & string>(table: N, row: InsertOf<S[N]>): MutationOp;105 /** Update the row identified by its pk columns (REQUIRED); only the named non-pk columns change. */106 update<N extends keyof S & string>(table: N, row: UpdateOf<S[N], PkColsOf<S, P, N>>): MutationOp;107 /** Insert, or replace non-PK columns on PK conflict, with {@link IsoTx.insert}'s omission rules. */108 upsert<N extends keyof S & string>(table: N, row: InsertOf<S[N]>): MutationOp;109 /** Insert with {@link IsoTx.insert}'s omission rules, or do nothing on PK conflict. */110 insertIgnore<N extends keyof S & string>(table: N, row: InsertOf<S[N]>): MutationOp;111 /** Delete the row identified by its pk columns. */112 delete<N extends keyof S & string>(table: N, pk: PkOf<S[N], PkColsOf<S, P, N>>): MutationOp;113 /** Read one row by primary key (read-your-writes). */114 row<N extends keyof S & string>(table: N, pk: PkOf<S[N], PkColsOf<S, P, N>>): ReadEffect;115 /** Run a full query (`where`/`orderBy`/`limit`/join) over the state this mutator is mutating —116 * read-your-writes, like {@link row} but for arbitrary shapes. Pass a query from the tier-agnostic117 * builder, e.g. `tx.query(q.issue.where("ownerId", "=", ctx.user))` where `q = newQueryBuilder(schema)`.118 * The `yield` evaluates to {@link QueryResultRow}`[]` (cast it — the generator's single next-type is a row). */119 query(query: QueryArg): QueryEffect;120 all(effects: readonly YieldEffect[]): BatchEffect;121}122123/** The one shared effect factory (stateless — see {@link IsoTx}). Its methods just BUILD a124 * {@link MutationOp}, so the single instance serves every schema; the generic {@link IsoTx} view is125 * applied at the authoring site (a `json<T>` cell is a parsed object here and is stringified by the126 * funnels, {@link toCell}), hence the cast — the runtime shape is schema-agnostic. */127export const isoTx: IsoTx = {128 insert: (table: string, row: KeyedRow): MutationOp => ({ kind: "insert", table, row }),129 update: (table: string, row: KeyedRow): MutationOp => ({ kind: "update", table, row }),130 upsert: (table: string, row: KeyedRow): MutationOp => ({ kind: "upsert", table, row }),131 insertIgnore: (table: string, row: KeyedRow): MutationOp => ({ kind: "insertIgnore", table, row }),132 delete: (table: string, pk: KeyedRow): MutationOp => ({ kind: "delete", table, pk }),133 row: (table: string, pk: KeyedRow): ReadEffect => ({ kind: "row", table, pk }),134 query: (query: QueryArg): QueryEffect => ({ kind: "query", query }),135 all: (effects: readonly YieldEffect[]): BatchEffect => ({ kind: "all", effects }),136} as unknown as IsoTx;137138/** The minimal per-invocation context a shared mutator sees on BOTH tiers: the acting principal. The139 * server injects its AUTHENTICATED user; the client injects its local user. (The server's own140 * `MutationContext` is a superset of this.) */141export interface MutatorCtx {142 user: string;143}144145/** What a generator mutator IS: `yield tx.<op>()` on every side effect; a `yield tx.row()` expression146 * evaluates to the row. Neither sync nor async — the tier's driver decides, which is what lets one147 * body run synchronously on the client and against a live async transaction on the server. */148export type MutationGen = Generator<YieldEffect, void, KeyedRow | undefined>;149150/** A generator (isomorphic) mutator, shared verbatim by both tiers: the client trusts typed `args`,151 * the server parses untrusted args into `Args` before invoking. */152export type SharedMutator<Args, Ctx extends MutatorCtx = MutatorCtx> = (153 tx: IsoTx,154 args: Args,155 ctx: Ctx,156) => MutationGen;157158/** The minimal arg validator a shared mutator can carry so the SERVER can parse UNTRUSTED wire args159 * before driving it (the client trusts its typed callsites and never calls this). Structural on160 * purpose — a zod schema satisfies it as-is — so `@rindle/client` stays validator-library-agnostic. */161export interface ArgSchema<Args> {162 parse(raw: unknown): Args;163}164165/** A shared mutator that CARRIES its own arg validator, co-located at the def site (`shared(schema,166 * gen)`). The client registers it exactly like a bare generator mutator — the `.args` validator is167 * inert there (typed callsites skip the parse); the server ({@link runSharedMutation}, via the168 * api-server's `sharedApiMutators`) reads `.args` to parse untrusted wire args before driving the169 * SAME body. */170export type SharedMutatorWithArgs<Args, Ctx extends MutatorCtx = MutatorCtx> = SharedMutator<Args, Ctx> & {171 args: ArgSchema<Args>;172};173174/** Co-locate a shared (generator) mutator with the validator for its args — pairing the arg SHAPE175 * with the body that consumes it at ONE site, so neither tier restates it (the client derives its176 * callsite type from `Args`; the server parses untrusted args through `.args`). Returns the SAME177 * generator function with an `args` property attached (`Object.assign` mutates + returns it), so the178 * registered value is byte-for-byte what the client drove before: {@link isGeneratorMutator} still179 * detects it and it still `satisfies ClientRegistry`. */180export function shared<Args, Ctx extends MutatorCtx = MutatorCtx>(181 args: ArgSchema<Args>,182 run: SharedMutator<Args, Ctx>,183): SharedMutatorWithArgs<Args, Ctx> {184 return Object.assign(run, { args });185}186187/** Bind a schema to the mutator authoring surface: `const { shared } = defineMutators(schema)`.188 *189 * The returned `shared` is the schema-typed twin of the bare {@link shared} — its `tx` is an190 * {@link IsoTx} parameterized by THIS schema, so `tx.insert`/`update`/`upsert`/`insertIgnore`/`delete`191 * check the table name, every column name, each column's value type, nullable-omit on inserts, and192 * the exact primary-key columns on `update`/`delete` — all at compile time. It registers identically193 * to the bare form (same runtime value, still `satisfies ClientRegistry`, still an isomorphic194 * generator the server drives): only the AUTHORING types tighten. `schema` is read for its TYPE only195 * (never at runtime). Reads (`yield tx.row(...)`) stay loosely typed — a generator's single196 * next-type can't carry a per-table row. */197/** The typed {@link IsoTx} for a given schema — `IsoTxOf<typeof schema>`. Use it to annotate a helper198 * that a mutator body passes its `tx` to (e.g. `const ensureUser = (tx: IsoTxOf<typeof schema>, …)`),199 * so the helper gets the same table/column/pk typing the `defineMutators` `shared` callback does. */200export type IsoTxOf<Sch extends Schema> = Sch extends Schema<infer S, infer P> ? IsoTx<S, P> : never;201202export function defineMutators<S extends ColsMap, P extends Record<string, string>>(_schema: Schema<S, P>) {203 return {204 shared<Args, Ctx extends MutatorCtx = MutatorCtx>(205 args: ArgSchema<Args>,206 run: (tx: IsoTx<S, P>, args: Args, ctx: Ctx) => MutationGen,207 ): SharedMutatorWithArgs<Args, Ctx> {208 // The typed `tx: IsoTx<S, P>` is authoring-only; the driver invokes with the loose `isoTx`209 // singleton (cast at the call boundary, like the bare `shared`), so the value is registry-shaped.210 return Object.assign(run as unknown as SharedMutator<Args, Ctx>, { args });211 },212 };213}214215/** True iff `fn` is a generator function (a shared/isomorphic mutator) rather than a plain function —216 * the driver accepts both forms. Detected structurally (native `GeneratorFunction`). */217export function isGeneratorMutator(fn: unknown): fn is (...args: never[]) => MutationGen {218 return (219 typeof fn === "function" &&220 (fn as { constructor?: { name?: string } }).constructor?.name === "GeneratorFunction"221 );222}223224/** A tier's SYNCHRONOUS effect executor (the browser wasm engine): apply a write, resolve a read —225 * both immediate. */226export interface SyncEffectExec {227 apply(op: MutationOp): void;228 read(table: string, pk: KeyedRow): KeyedRow | undefined;229 query(q: QueryArg): QueryResultRow[];230}231232/** Drive a generator mutator SYNCHRONOUSLY (the client, inside the wasm write transaction). Each233 * yielded write applies immediately; each read (point or query) is resolved and fed back; a batch234 * runs in order. */235export function driveMutationSync(gen: MutationGen, exec: SyncEffectExec): void {236 const run = (eff: YieldEffect): KeyedRow | undefined => {237 if (eff.kind === "row") return exec.read(eff.table, eff.pk);238 if (eff.kind === "query") return exec.query(eff.query) as unknown as KeyedRow | undefined;239 if (eff.kind === "all") return eff.effects.map(run) as unknown as KeyedRow | undefined;240 exec.apply(eff);241 return undefined;242 };243 for (let step = gen.next(); !step.done; step = gen.next(run(step.value)));244}245246/** A tier's ASYNCHRONOUS effect executor (the server transaction): every op is a Promise. */247export interface AsyncEffectExec {248 apply(op: MutationOp): Promise<void>;249 read(table: string, pk: KeyedRow): Promise<KeyedRow | undefined>;250 query(q: QueryArg): Promise<QueryResultRow[]>;251}252253/** Drive a generator mutator ASYNCHRONOUSLY (the server, against the open transaction). Writes are254 * awaited (harmless: a single interactive Postgres connection serializes statements anyway, and the255 * daemon backend resolves them instantly), reads (point or query) suspend, and a batch fans out with256 * `Promise.all`. */257export async function driveMutationAsync(gen: MutationGen, exec: AsyncEffectExec): Promise<void> {258 const run = async (eff: YieldEffect): Promise<KeyedRow | undefined> => {259 if (eff.kind === "row") return exec.read(eff.table, eff.pk);260 if (eff.kind === "query") return (await exec.query(eff.query)) as unknown as KeyedRow | undefined;261 if (eff.kind === "all") return (await Promise.all(eff.effects.map(run))) as unknown as KeyedRow | undefined;262 await exec.apply(eff);263 return undefined;264 };265 for (let step = gen.next(); !step.done; step = gen.next(await run(step.value)));266}267