API index and search · Build metadata
Source snapshot
packages/client/src/schema.ts
1// The typed schema: `table("issue").columns({...}).primaryKey("id")` + `createSchema`.2//3// A table definition doubles as a field→condition factory (a runtime Proxy):4// `issue.priority(gt(8))` produces a `Cond<RowOf<issue>>`. THAT is where the table type is5// bound — which is why `or`/`exists` are plain top-level functions, no closure needed6// (WASM-CLIENT-DESIGN.md §6). Schema metadata is stored under a `unique symbol` key so it7// never collides with a column name.89import type { Arg, Cond } from "./operators.ts";10import { fieldCondition } from "./operators.ts";11import type { ColType, WireValue } from "./types.ts";1213/** A column descriptor. `type` drives the comparator + JSON parsing; `__t` is a phantom. A14 * nullable column is a `Col<T | null>` — that is the ONLY thing nullability changes at the type15 * level, so `RowOf` (and the field-condition factory) widen automatically.16 *17 * `optional` is the runtime companion of that phantom: set by {@link ColBuilder.nullable}, it is18 * what the write funnels read to make a nullable column omittable from an insert (filled with19 * `null`, design 206 §6.2). It mirrors the engine's `ColumnDef.optional`20 * (`pragma_table_info.notnull == 0`); absent ⇒ `NOT NULL` / required. */21export interface Col<T> {22 readonly type: ColType;23 readonly optional?: boolean;24 readonly __t?: T;25}26export type ColT<X> = X extends Col<infer T> ? T : never;27export type AnyCols = Record<string, Col<unknown>>;28export type RowOf<C extends AnyCols> = { [K in keyof C]: ColT<C[K]> };2930/** The chainable form returned by the column factories: a {@link Col} plus `.nullable()`.31 *32 * `.nullable()` widens the column's value type to `T | null` — its `Row<…>` field becomes33 * `T | null` — and sets the runtime {@link Col.optional} marker so it may be omitted from an insert34 * (design 206 §6.2). `rindle schema gen` emits `.nullable()` for every nullable (non-`NOT NULL`) SQL35 * column; you can also call it by hand on a local-only table's columns. It is idempotent and stays36 * chainable. */37export interface ColBuilder<T> extends Col<T> {38 nullable(): ColBuilder<T | null>;39}40const makeCol = <T>(type: ColType, optional = false): ColBuilder<T> => ({41 type,42 ...(optional ? { optional: true } : null),43 nullable: () => makeCol<T | null>(type, true),44});4546export const string = <T extends string = string>(): ColBuilder<T> => makeCol<T>("string");47export const number = <T extends number = number>(): ColBuilder<T> => makeCol<T>("number");48export const boolean = (): ColBuilder<boolean> => makeCol<boolean>("boolean");49export const json = <T = unknown>(): ColBuilder<T> => makeCol<T>("json");50/** The exact-i64 column plane (design 226, `BIGINT`/`INT8` decltype): typed `bigint` in51 * application code. The vocabulary exists from Stage C4 so generated schemas can name it;52 * no exact integer cell enters the browser IVM until Stage E — until then the daemon53 * refuses IVM queries whose footprint touches the column, and the SQL plane carries it. */54export const int64 = <T extends bigint = bigint>(): ColBuilder<T> => makeCol<T>("int64");5556/** Metadata key on a {@link TableDef} (a `unique symbol`, so no column name collides). */57export const SCHEMA: unique symbol = Symbol("rindle.schema");5859export interface TableMeta<N extends string = string, C extends AnyCols = AnyCols, PK extends string = string> {60 readonly name: N;61 readonly columns: C;62 // `PK` is an INDEPENDENT param (a plain string union), NOT `keyof C`: `keyof C` would be63 // contravariant and make `TableMeta` non-covariant in `C`, breaking `TableDef<concrete>` →64 // `AnyTable`. Key validity is enforced on the `primaryKey()` builder (`K extends keyof C`); PK is65 // captured there so the mutator tx can type `update`/`delete` pk args (`PkOf`/`UpdateOf`). The66 // default `string` erases it (older 2-arg `TableMeta<N, C>` refs still resolve).67 readonly primaryKey: readonly PK[];68 /** A **local-only** table (`201-LOCAL-ONLY-TABLES-DESIGN.md`): client-authoritative,69 * never synced/tracked/rebased. The single marker the whole design keys off (§4) — it is70 * immutable for the table's lifetime (N2) and never crosses the wire (C2). Absent ⇒ an71 * ordinary synced table.72 *73 * `true` ⇒ eligible for the local-persistence plane (durable + cross-tab when the client74 * enables `persistLocal`, `207-LOCAL-TABLE-PERSISTENCE-DESIGN.md`). `"session"` ⇒ local but75 * EPHEMERAL: outside the plane entirely — never persisted, never replicated across tabs,76 * per-client-instance state that empties on reload (201's original behavior) even when77 * `persistLocal` is on. Every OTHER locality rule (untracked source, M1/M2 guards, E3/Q178 * wire exclusion) treats both variants identically. */79 readonly local?: boolean | "session";80}8182/** Options for {@link table}. */83export interface TableOptions {84 /** Declare a {@link TableMeta.local local-only} table (selection state, draft text, view85 * prefs, scratch rows): client-authoritative, never synced or rebased. See86 * `201-LOCAL-ONLY-TABLES-DESIGN.md`. Pass `"session"` for a local table that must stay87 * EPHEMERAL and per-tab even when the client enables `persistLocal` — e.g. selection state88 * that should not follow the user across tabs or reloads (207 §5.4). */89 local?: boolean | "session";90}9192export type TableDef<N extends string, C extends AnyCols, PK extends string = string> = {93 readonly [SCHEMA]: TableMeta<N, C, PK>;94} & {95 readonly [K in keyof C]: (arg: Arg<ColT<C[K]>>) => Cond<RowOf<C>>;96};9798/** Any table, for positions that only read its metadata. The field-factory part of a99 * `TableDef` is invariant in `C`, so callers constrain on the `[SCHEMA]` meta only — to100 * which every concrete `TableDef<N, C>` is assignable. */101export type TableLike<C extends AnyCols> = { readonly [SCHEMA]: TableMeta<string, C> };102export type AnyTable = TableLike<AnyCols>;103104/** The row type of a table definition: `Row<typeof issue>` → `{ id: string; … }`. The105 * whole-table ergonomic form of {@link RowOf}, so app code derives its row interfaces from106 * the schema instead of hand-maintaining a parallel twin. */107export type Row<T extends AnyTable> = RowOf<T[typeof SCHEMA]["columns"]>;108109/** Flatten an intersection of mapped types into a single object type (preserving `?`/`readonly`) so110 * {@link InsertOf} reads as one clean shape in editor hovers, not `A & B`. */111type Simplify<T> = { [K in keyof T]: T[K] };112113/** True for the top types `unknown`/`any` (`unknown extends any` too), where `[null] extends [T]` is114 * vacuously true and so can't tell a `.nullable()` apart. */115type IsTop<T> = unknown extends T ? true : false;116117/** Whether column `X` may be OMITTED from an insert: it admits `null` at the type level — EXCLUDING118 * the top types. A bare `json()` is `json<unknown>()`, and `unknown | null` collapses back to119 * `unknown`, erasing whether `.nullable()` was applied; treating it as required is the safe120 * direction (a `NOT NULL` json column is never wrongly made optional). Declare `json<T>()` to make a121 * nullable json column omittable. The runtime companion is `Col.optional` (design 206 §6.2/§7). */122type InsertOptional<X> = IsTop<ColT<X>> extends true ? false : [null] extends [ColT<X>] ? true : false;123124/** The INSERT shape of a column map: {@link RowOf} with every NULLABLE column made OPTIONAL (`?`) —125 * it may be omitted and is filled with `null` by both write funnels (design 206 §6.2/§7). `NOT NULL`126 * columns stay required. The insert-side twin of {@link RowOf}, mirroring Drizzle's `$inferInsert`127 * vs `$inferSelect` split. */128export type InsertOf<C extends AnyCols> = Simplify<129 { [K in keyof C as InsertOptional<C[K]> extends true ? never : K]: ColT<C[K]> } & {130 [K in keyof C as InsertOptional<C[K]> extends true ? K : never]?: ColT<C[K]>;131 }132>;133134/** The insert type of a table def: `Insert<typeof issue>` → `{ id: string; … assignee?: string | null }`.135 * The whole-table ergonomic form of {@link InsertOf} (the insert-side twin of {@link Row}). */136export type Insert<T extends AnyTable> = InsertOf<T[typeof SCHEMA]["columns"]>;137138/** The exact PRIMARY-KEY columns of a table (each typed), required — the identity a `delete`/`row`,139 * and the WHERE half of an `update`, take. `PK` is the table's pk-column union (the schema's `__pk`). */140export type PkOf<C extends AnyCols, PK extends string> = { [K in PK & keyof C]: ColT<C[K & keyof C]> };141142/** The UPDATE shape of a table: its {@link PkOf primary-key columns} REQUIRED (they identify the row)143 * plus every non-pk column OPTIONAL (`?`) and typed — a nullable one stays `T | null` (settable to144 * null), a `NOT NULL` one is `T`. Omitted non-pk columns are left unchanged. */145export type UpdateOf<C extends AnyCols, PK extends string> = Simplify<146 PkOf<C, PK> & { [K in Exclude<keyof C, PK>]?: ColT<C[K]> }147>;148149/** The primary-key column union for table `N` of a schema, read from its `__pk` map `P` and narrowed150 * to `N`'s actual columns (falling back to all columns when `P` doesn't name `N` — e.g. the loose151 * default schema). Feeds {@link PkOf}/{@link UpdateOf} in the typed mutator tx. */152export type PkColsOf<S extends ColsMap, P extends Record<string, string>, N extends keyof S> = (N extends keyof P153 ? P[N]154 : keyof S[N] & string) &155 keyof S[N] &156 string;157158/** `table("issue").columns({ id: string(), … }).primaryKey("id")`. Pass `{ local: true }` for a159 * {@link TableMeta.local local-only} table (`201-LOCAL-ONLY-TABLES-DESIGN.md`). */160export function table<N extends string>(name: N, opts?: TableOptions) {161 return {162 columns<C extends AnyCols>(cols: C) {163 return {164 primaryKey<K extends keyof C & string>(...keys: K[]): TableDef<N, C, K> {165 return makeTableDef<N, C, K>({ name, columns: cols, primaryKey: keys, local: opts?.local });166 },167 };168 },169 };170}171172function makeTableDef<N extends string, C extends AnyCols, PK extends string>(meta: TableMeta<N, C, PK>): TableDef<N, C, PK> {173 return new Proxy({} as Record<string | symbol, unknown>, {174 get(_target, prop) {175 if (prop === SCHEMA) return meta;176 if (typeof prop === "string") return (arg: unknown) => fieldCondition(prop, arg);177 return undefined;178 },179 }) as unknown as TableDef<N, C, PK>;180}181182/** Read a table's metadata (columns / PK / name). */183export function tableMeta(t: AnyTable): TableMeta {184 return t[SCHEMA];185}186187/** name → columns, derived from the tables array (for typing `store.query.<table>`). */188export type SchemaOf<T extends readonly AnyTable[]> = {189 [E in T[number] as E[typeof SCHEMA]["name"]]: E[typeof SCHEMA]["columns"];190};191192/** name → its primary-key column union, derived from the tables array (the PK captured by the193 * `primaryKey(...)` builder). Powers the mutator tx's typed `update`/`delete`/`row` pk args. */194export type PkMapOf<T extends readonly AnyTable[]> = {195 [E in T[number] as E[typeof SCHEMA]["name"]]: E[typeof SCHEMA]["primaryKey"][number];196};197198/** name → columns, the resolved schema map carried in the {@link Schema} type. */199export type ColsMap = Record<string, AnyCols>;200201/** name → (some subset of its column names): the loose shape a {@link Schema}'s pk-map satisfies. The202 * fallback when a schema wasn't built through {@link createSchema} — every column could be the pk. */203export type PkMap<S extends ColsMap> = { [N in keyof S]: keyof S[N] & string };204205export interface Schema<S extends ColsMap = ColsMap, P extends Record<string, string> = PkMap<S>> {206 readonly tables: Readonly<Record<string, TableMeta>>;207 /** Phantom carrying name→columns for query-root inference (never read at runtime). */208 readonly __cols: S;209 /** Phantom carrying name→pk-column-union for the typed mutator tx (never read at runtime). */210 readonly __pk: P;211}212213/** Table-name prefixes reserved by the engine for SYNTHETIC tables — `__agg_<fnv>` aggregate214 * bases (`AGGREGATE-SYNC-DESIGN.md` §3.3) and `_rindle_*` system tables (e.g. the lmid table).215 * A user table (synced OR local) under one of these would shadow a synthetic source — and since216 * `registerTable` is idempotent-on-name and base tables register before synthetics, the later217 * synthetic registration would silently no-op and the agg join would read the user table as the218 * count (silent corruption, `201-LOCAL-ONLY-TABLES-DESIGN.md` N1). The ban makes that219 * structurally impossible — checked once, statically, at {@link createSchema}. */220export const RESERVED_TABLE_PREFIXES = ["__agg_", "_rindle_"] as const;221222/** Whether `name` collides with an engine-reserved synthetic prefix ({@link RESERVED_TABLE_PREFIXES}). */223export function isReservedTableName(name: string): boolean {224 return RESERVED_TABLE_PREFIXES.some((p) => name.startsWith(p));225}226227export function createSchema<const T extends readonly AnyTable[]>(opts: {228 tables: T;229}): Schema<SchemaOf<T>, PkMapOf<T>> {230 const tables: Record<string, TableMeta> = {};231 for (const t of opts.tables) {232 const m = t[SCHEMA];233 addTableMeta(tables, m, "createSchema");234 }235 return { tables } as unknown as Schema<SchemaOf<T>, PkMapOf<T>>;236}237238/** Extend a generated/synced schema with client-authoritative local-only tables.239 *240 * This is the ergonomic path for SQL-first apps: keep `schema.gen.ts` fully generated, define241 * private UI tables in a tiny hand-written file, then hand the combined schema to the browser242 * client. The added tables MUST be `table(name, { local: true })`: `extendSchema` deliberately243 * refuses to append ordinary synced tables, because those need to come from daemon introspection244 * (`rindle schema gen`) so the server and client cannot drift. */245export function extendSchema<S extends ColsMap, P extends Record<string, string>, const T extends readonly AnyTable[]>(246 base: Schema<S, P>,247 opts: { tables: T },248): Schema<S & SchemaOf<T>, P & PkMapOf<T>> {249 const tables: Record<string, TableMeta> = { ...base.tables };250 for (const t of opts.tables) {251 const m = t[SCHEMA];252 if (!m.local) {253 throw new Error(254 `extendSchema: table "${m.name}" is not local-only — synced tables must be generated from the daemon schema.`,255 );256 }257 addTableMeta(tables, m, "extendSchema");258 }259 return { tables } as unknown as Schema<S & SchemaOf<T>, P & PkMapOf<T>>;260}261262function addTableMeta(tables: Record<string, TableMeta>, m: TableMeta, caller: "createSchema" | "extendSchema") {263 // N1: ban reserved synthetic prefixes for ANY user table (synced or local). One source per264 // name, statically disjoint from the dynamic `__agg_*`/`_rindle_*` namespace.265 if (isReservedTableName(m.name)) {266 throw new Error(267 `${caller}: table "${m.name}" uses a reserved prefix (${RESERVED_TABLE_PREFIXES.join(", ")}) — ` +268 `these namespaces are owned by the engine's synthetic tables (201-LOCAL-ONLY-TABLES-DESIGN.md N1).`,269 );270 }271 // Same N1 rule for the room-table namespace (302 §2): a room's engine twin is `table@sourceKey`,272 // so a user table containing `@` could alias a twin and let a room channel's deltas fold into273 // user-authoritative rows. `roomEngineTable`'s no-collision claim is enforced HERE.274 if (m.name.includes("@")) {275 throw new Error(276 `${caller}: table "${m.name}" contains "@" — reserved for the room-table namespace ` +277 `(302-ROOM-STORE-SEPARATION: a room's engine twin is "table@sourceKey").`,278 );279 }280 // N1/N2: one name → one source. A duplicate (incl. a local/synced name clash, since both281 // register from this one array) is a genuine collision, not a silent overwrite.282 if (Object.prototype.hasOwnProperty.call(tables, m.name)) {283 throw new Error(`${caller}: duplicate table "${m.name}" — table names must be unique.`);284 }285 tables[m.name] = m;286}287288// ----------------------------- refinement (narrowing generated column types) ---------------------289//290// SQL carries a column's KIND (string/number/boolean/json) but not a refinement WITHIN a kind — the291// element type of `json<T>()`, or a string/number literal union. `refineTable` re-types those292// columns on a generated `TableDef` (so its field conditions, `rel(...)`s, and `Row<typeof t>` all293// narrow), and `refineSchema` swaps the refined defs into the generated schema (so query roots and294// result rows narrow too). Both are runtime-validated IDENTITIES: a refinement narrows the phantom295// TS type but can never change a column's runtime kind, so the def still matches the daemon's wire296// schema exactly. Keep refinements in a hand-written module (beside `extendSchema`'s local tables);297// `schema.gen.ts` stays a pure, wholesale-regenerated artifact.298299/** Per-column narrowings for {@link refineTable}: each entry must keep the column's kind and narrow300 * its TS type (`Col<T2>` with `T2 extends T`) — `json<Meta>()` on a json column, a literal-union301 * `string<"a" | "b">()` on a string column. Kind changes are rejected (cross-kind at compile time,302 * same-phantom kind flips like `string()` on a json column at runtime). */303export type ColRefinements<C extends AnyCols> = {304 readonly [K in keyof C]?: Col<ColT<C[K]>>;305};306307/** `C` with the columns named in `R` re-typed to their refined `Col`s. */308export type RefinedCols<C extends AnyCols, R extends ColRefinements<C>> = {309 [K in keyof C]: K extends keyof R ? (R[K] extends Col<unknown> ? R[K] : C[K]) : C[K];310};311312/** Narrow a generated table's column TYPES without touching its runtime shape.313 *314 * Returns the SAME def, re-typed (identity, after validating that every refined column exists and315 * keeps its kind) — so conditions built from it, `rel(...)`s anchored on it, and `Row<typeof t>`316 * all see the narrowed types. Pass the result to {@link refineSchema} so query roots narrow too. */317export function refineTable<N extends string, C extends AnyCols, R extends ColRefinements<C>>(318 base: TableDef<N, C>,319 cols: R,320): TableDef<N, RefinedCols<C, R>> {321 const meta = base[SCHEMA];322 for (const [name, col] of Object.entries(cols) as [string, Col<unknown> | undefined][]) {323 if (col === undefined) continue;324 const baseCol = meta.columns[name];325 if (baseCol === undefined) {326 throw new Error(`refineTable: table "${meta.name}" has no column "${name}".`);327 }328 if (col.type !== baseCol.type) {329 throw new Error(330 `refineTable: column "${meta.name}.${name}" is ${baseCol.type}, not ${col.type} — a refinement ` +331 `may only narrow the TS type WITHIN a column's kind, never change the kind itself.`,332 );333 }334 }335 return base as unknown as TableDef<N, RefinedCols<C, R>>;336}337338/** A table def acceptable to {@link refineSchema} over `Schema<S>`: its NAME must be one of `S`'s339 * tables (a def for an unknown table is a compile error at the call site). */340export type RefinableTable<S extends ColsMap> = {341 readonly [SCHEMA]: TableMeta<Extract<keyof S, string>, AnyCols>;342};343344/** `S` with each table named in `T` re-typed to that def's (refined) columns. (The inner345 * `extends AnyCols` guard is how the checker proves the remapped `SchemaOf<T>[K]` is a column346 * map while `T` is still generic; it always holds for a concrete `T`.) */347export type RefinedColsMap<S extends ColsMap, T extends readonly AnyTable[]> = {348 [K in keyof S]: K extends keyof SchemaOf<T> ? (SchemaOf<T>[K] extends AnyCols ? SchemaOf<T>[K] : S[K]) : S[K];349};350351/** Swap {@link refineTable}-narrowed table defs into a generated schema, re-typing those tables for352 * everything downstream of the schema (`newQueryBuilder`/`queries` roots, store row types).353 *354 * Runtime-validated identity: each def must name a table already in the schema and match its355 * runtime shape exactly (same columns, kinds, primary key, and locality) — refinement narrows TS356 * types, never what's on the wire. Composes with {@link extendSchema} in either order. */357export function refineSchema<S extends ColsMap, P extends Record<string, string>, const T extends readonly RefinableTable<S>[]>(358 base: Schema<S, P>,359 opts: { tables: T },360): Schema<RefinedColsMap<S, T>, P> {361 const seen = new Set<string>();362 for (const t of opts.tables) {363 const m = t[SCHEMA];364 const baseMeta = base.tables[m.name];365 if (baseMeta === undefined) {366 throw new Error(367 `refineSchema: schema has no table "${m.name}" — refinement re-types existing tables only ` +368 `(add local-only tables with extendSchema).`,369 );370 }371 if (seen.has(m.name)) {372 throw new Error(`refineSchema: table "${m.name}" is refined twice.`);373 }374 seen.add(m.name);375 assertRefinementMatches(m, baseMeta);376 }377 return base as unknown as Schema<RefinedColsMap<S, T>, P>;378}379380/** The refined def must be the same table the schema already carries — identical column set, kinds,381 * primary key, and locality — so swapping its TYPE in cannot change any runtime behavior. */382function assertRefinementMatches(m: TableMeta, baseMeta: TableMeta): void {383 const cols = Object.keys(m.columns);384 const baseCols = Object.keys(baseMeta.columns);385 const matches =386 cols.length === baseCols.length &&387 cols.every((c) => baseMeta.columns[c] !== undefined && m.columns[c].type === baseMeta.columns[c].type) &&388 m.primaryKey.length === baseMeta.primaryKey.length &&389 m.primaryKey.every((k, i) => baseMeta.primaryKey[i] === k) &&390 // Locality must match EXACTLY, including the persisted-vs-session variant (a refinement391 // flipping `true` ↔ `"session"` would silently change the table's durability).392 (m.local ?? false) === (baseMeta.local ?? false);393 if (!matches) {394 throw new Error(395 `refineSchema: table "${m.name}" does not match the schema's table of that name — pass the ` +396 `output of refineTable over the SAME generated def (identical columns, kinds, primary key, ` +397 `and locality).`,398 );399 }400}401402/** Whether `table` is a {@link TableMeta.local local-only} table in `schema` (an unknown table403 * reads as non-local). The single locality predicate the backends key off. BOTH variants —404 * `true` and `"session"` — are local here; the persisted/ephemeral split matters only to the405 * persistence plane ({@link persistedLocalTableNames}). */406export function isLocalTable<S extends ColsMap>(schema: Schema<S>, table: string): boolean {407 return Boolean(schema.tables[table]?.local);408}409410/** The set of local-only table names in `schema` (`201-LOCAL-ONLY-TABLES-DESIGN.md` §4) — BOTH411 * variants (`true` and `"session"`); every locality rule except persistence keys off this set. */412export function localTableNames<S extends ColsMap>(schema: Schema<S>): Set<string> {413 return new Set(Object.keys(schema.tables).filter((n) => schema.tables[n].local));414}415416/** The subset of {@link localTableNames} eligible for the persistence plane — `local: true` only.417 * A `local: "session"` table stays outside it: never persisted, never replicated across tabs418 * (`207-LOCAL-TABLE-PERSISTENCE-DESIGN.md` §5.4). */419export function persistedLocalTableNames<S extends ColsMap>(schema: Schema<S>): Set<string> {420 return new Set(Object.keys(schema.tables).filter((n) => schema.tables[n].local === true));421}422423/** A stable fingerprint of the schema's PERSISTED local tables only — the persistence gate's424 * `schemaHash` (`207-LOCAL-TABLE-PERSISTENCE-DESIGN.md` §3.3 / P7). Per `local: true` table:425 * `(name, ordered column names + types + optionality, pk columns)`. Column order is kept (rows are426 * positional); tables are sorted by name so registration order can't skew it; synced-table AND427 * `local: "session"` changes never move it (reshaping an ephemeral table must not wipe durable428 * data). The value is the canonical descriptor itself, not a digest — local-table sets are tiny,429 * and exactness (no collision can ever skip a P7 clear) beats compactness here. */430export function localSchemaHash<S extends ColsMap>(schema: Schema<S>): string {431 // Derived from persistedLocalTableNames — the hash's contract is "fingerprints exactly the432 // tables the plane persists", so the two must share one predicate, not two copies of it.433 const tables = [...persistedLocalTableNames(schema)]434 .sort()435 .map((n) => {436 const meta = schema.tables[n];437 const cols = Object.keys(meta.columns).map((c) => [c, meta.columns[c].type, meta.columns[c].optional === true]);438 return [n, cols, meta.primaryKey];439 });440 return `v1:${JSON.stringify(tables)}`;441}442443// ----------------------------- relationships (FRAGMENT-COMPOSITION-DESIGN §4.2, named edges) -----444//445// A relationship is the correlation (`parent.col → child.col`) declared ONCE as a value, so `sub`,446// `countAs`, and `exists` don't restate `{ parent, child }` keys at every spread/filter site. It is a447// plain typed value (not registered on the schema), passed where `(child, corr)` used to go.448449/** Brand on a {@link Relationship} value (a `unique symbol`, distinct from a {@link TableDef}). */450const RELATIONSHIP_BRAND: unique symbol = Symbol("rindle.relationship");451452/**453 * A reusable, typed JOIN between two tables — the correlation declared once (design §4). Built with454 * {@link rel}; parameterized by the parent columns `PC` (so a `sub` checks the relationship belongs to455 * the query's table) and the child columns `CC` (which flow into the nested result type). Pass it to456 * `sub`/`countAs`/`exists` in place of an explicit `child` + `{ parent, child }` correlation.457 */458export interface Relationship<PC extends AnyCols, CC extends AnyCols> {459 /** The child table the relationship points at. */460 readonly child: TableLike<CC>;461 /** Correlation keys: `parent[i]` (a parent column) joins to `child[i]` (a child column). */462 readonly correlation: { readonly parent: readonly string[]; readonly child: readonly string[] };463 /** Phantom binding the parent columns so `Query<C>.sub(alias, rel)` rejects a rel for another table. */464 readonly __parent?: PC;465 readonly [RELATIONSHIP_BRAND]: true;466}467468/** Any relationship, for positions that only read its correlation / child table. */469export type AnyRelationship = Relationship<AnyCols, AnyCols>;470471/**472 * Declare a relationship once: `rel(issue, user, { ownerId: "id" })` means `issue.ownerId → user.id`.473 * `mapping` is `{ [parentColumn]: childColumn }` (a composite join is multiple entries). The `parent`474 * table is used only to type-check the keys; pass the result to `sub`/`countAs`/`exists`.475 */476export function rel<PC extends AnyCols, CC extends AnyCols>(477 _parent: TableLike<PC>,478 child: TableLike<CC>,479 mapping: Partial<Record<keyof PC & string, keyof CC & string>>,480): Relationship<PC, CC> {481 const parent = Object.keys(mapping);482 const childKeys = parent.map((k) => mapping[k as keyof PC & string] as string);483 return { child, correlation: { parent, child: childKeys }, [RELATIONSHIP_BRAND]: true };484}485486/** A typed registry of named {@link Relationship}s — `defineRelationships({ issueOwner: rel(...) })`.487 * A thin identity helper that names the bag and constrains its values; the keys are yours to choose. */488export function defineRelationships<R extends Record<string, AnyRelationship>>(rels: R): R {489 return rels;490}491492/** Runtime guard: is `v` a {@link Relationship} value (not a table or a plain object)? */493export function isRelationship(v: unknown): v is AnyRelationship {494 return typeof v === "object" && v !== null && (v as Partial<AnyRelationship>)[RELATIONSHIP_BRAND] === true;495}496497/** The `SchemaSpec` (`columns` + `primaryKey` indices) the wasm `Db.registerTable` wants. */498export function tableSpec(meta: TableMeta): { columns: string[]; primaryKey: number[] } {499 const columns = Object.keys(meta.columns);500 const primaryKey = meta.primaryKey.map((k) => columns.indexOf(k));501 return { columns, primaryKey };502}503504/** A table's insert-completeness plan, derived once from its `Col` markers and shared by BOTH write505 * funnels — the client `trackingTx` and the server `renderOp` — so their required-sets can't drift506 * (design 206 §6.1/§6.2). A `NOT NULL` column is `required`; a nullable column (`.nullable()` set507 * `Col.optional`) may be omitted and is filled with `null` (see {@link insertCell}). PK columns are508 * never nullable (introspection forces them non-null), so they are always required. */509export interface InsertPlan {510 /** Every column, in wire order. */511 readonly columns: string[];512 /** Columns that MUST be present on a full insert — the non-nullable ones. */513 readonly required: string[];514 /** The nullable (omittable-to-null) columns, for a fast membership test in the fill. */515 readonly nullable: ReadonlySet<string>;516}517518/** Derive a table's {@link InsertPlan} from its column markers. */519export function insertPlan(meta: TableMeta): InsertPlan {520 const columns = Object.keys(meta.columns);521 const nullable = new Set(columns.filter((c) => meta.columns[c].optional === true));522 return { columns, required: columns.filter((c) => !nullable.has(c)), nullable };523}524525/** The cell a full insert writes for column `c`: the given value, or `null` when a nullable column526 * is omitted (design 206 §6.2). The caller's completeness check ({@link InsertPlan.required})527 * guarantees a non-nullable column is present, so its omission never reaches here. */528export function insertCell(row: Record<string, WireValue>, c: string): WireValue {529 return c in row ? row[c] : null;530}531532/** Encode a keyed-row cell to its wire {@link WireValue} for a column KIND — the one place both write533 * funnels stringify a `json<T>` object (the typed mutator surface / design 206 §7). An534 * already-stringified json value (a `string`) or any non-json cell passes through unchanged, so a535 * mutator may pass EITHER a parsed object OR a JSON string. Mirrors `store.positionalize`. */536export function toCell(v: WireValue | object, type: ColType): WireValue {537 if (type === "json" && v !== null && typeof v === "object") return JSON.stringify(v);538 return v as WireValue;539}540541/** The client's per-table flat schema (name + column order + PK indices), the shape a542 * normalized `hello` advertises (NORMALIZED-CHANGES-DESIGN.md §3). Used to validate a543 * server hello against the CLIENT's own typed schema so a column-order / PK skew is caught544 * instead of silently transposing positional cells (CRIT#4). Sorted by name for stable545 * ordering.546 *547 * Local-only tables are **omitted** (`201-LOCAL-ONLY-TABLES-DESIGN.md` E1): the client never548 * claims them to the server, the server never expects/ships them, and they stay out of the schema549 * fingerprint (`normalizedFp`) so a local table can't skew it vs. the server's. */550export function normalizedTableSchemas<S extends ColsMap>(551 schema: Schema<S>,552): { name: string; columns: string[]; primaryKey: number[] }[] {553 return Object.keys(schema.tables)554 .filter((name) => !schema.tables[name].local)555 .sort()556 .map((name) => ({ name, ...tableSpec(schema.tables[name]) }));557}558