// A mutator's `local()` half, run in a unit test: a `LocalTx` over rows held in memory, with the // semantics of the page's record store. The twin under test is the app's; this is only the store // it writes into, so a test asserts on rows and holds no cast and no hand-rolled table. import type { LocalTable, LocalTx } from '@ultimat3/action'; /** `{ : { : } }` — what the store holds, as a test seeds it and reads it back. */ export type LocalRows = Readonly< Record>> >; export interface MemoryLocalTx { /** What `mutator.local(tx, input)` is handed. `tx.
` and `tx.table('')` are one table. */ readonly tx: LocalTx; /** What a table holds now, by key — after however many applications. `{}` for one never seen. */ rows(table: string): Readonly>; } /** `undefined` in a patch means "leave it alone", exactly as the record store reads it. */ const definedOf = (columns: object): Record => Object.fromEntries(Object.entries(columns).filter(([, value]) => value !== undefined)); /** * A client store for one test. Keyed, exactly like the real one: the browser holds no entity * schema, so a row is addressed by the key its server twin answers under, never by a column. * * | Call | Does | * |---|---| * | `insert(key, row)` | stores the row under the key | * | `upsert(key, row)` | merges over what is held; an `undefined` field leaves the column alone | * | `update(key, patch)` | changed fields, or a function of the held row. A NO-OP for a key the table does not hold — a twin must not invent a row | * | `delete(key)` | removes it | */ export function memoryLocalTx(seed: LocalRows = {}): MemoryLocalTx { const tables = new Map>(); const held = (name: string): Map => { const existing = tables.get(name); if (existing !== undefined) return existing; const created = new Map(); tables.set(name, created); return created; }; // Frozen on the way in, as the record store freezes: a twin that mutated a row in place would // pass here and corrupt the synced truth it was handed in a browser. const stored = (row: object): object => Object.freeze({ ...row }); for (const [name, rows] of Object.entries(seed)) { for (const [key, row] of Object.entries(rows)) held(name).set(key, stored(row)); } // The one widening in this file: the store holds rows of whatever shape a test seeded, and a // table is read back as the shape its caller names — the same door `tx.table()` is. const table = (name: string): LocalTable => { const rows = held(name) as Map; return { get: (key) => rows.get(key), all: () => [...rows.values()], insert: (key, row) => { rows.set(key, stored(row) as TRow); }, upsert: (key, row) => { rows.set(key, stored({ ...rows.get(key), ...definedOf(row) }) as TRow); }, update: (key, patch) => { const current = rows.get(key); if (current === undefined) return; const changed = typeof patch === 'function' ? patch(current) : patch; rows.set(key, stored({ ...current, ...definedOf(changed) }) as TRow); }, delete: (key) => { rows.delete(key); }, }; }; const tx = new Proxy({} as LocalTx, { get: (_target, property) => { if (typeof property !== 'string') return undefined; return property === 'table' ? table : table(property); }, }); return { tx, rows: (name: string): Readonly> => Object.fromEntries(tables.get(name) ?? []) as Record, }; }