Rindle

API index and search · Build metadata

Source snapshot

packages/client/src/mutation-ops.ts

Source revision 05d0bf2c2e56 · build details
Source revision: 05d0bf2c2e56.
TypeScript input SHA-256: aabe6cfcc4172b870d5e272142958e9ea8d8784c2aa23133156e5a7ee633318e
Generated 2026-09-04T23:58:25.590Z with TypeScript 6.0.3. Public TypeScript checks and declaration emit passed. Package runtime tests are separate.
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