@ultimat3/entity 2.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/seed.ts CHANGED
@@ -1,17 +1,28 @@
1
- // A seed is the fixture graph, written once and replayed anywhere. `id('post:tenancy')` is a
2
- // UUID v5 of the label, so the same row gets the same id on every machine and a bug reproduced
3
- // locally reproduces in CI. Rows go through `entity.$parse` and the invariants, which makes a
4
- // seed a test of the schema as well as data for one.
1
+ // A seed is the fixture graph, written once and REPLAYED anywhere: a second run writes nothing new
2
+ // and raises nothing. Two write verbs, because only the author knows which key identifies a row
3
+ // `insert` where the seed owns the id (`id('post:tenancy')` is a v5 uuid of the label, the same on
4
+ // every machine), `upsert` where the table owns it and a natural key is all there is.
5
5
 
6
6
  import { createHash } from 'node:crypto';
7
+ import { type Environment, resolveEnvironment } from '@ultimat3/core';
8
+ import { entityNow } from './clock';
7
9
  import type { Driver } from './database';
8
10
  import { memoryDriver } from './database';
9
- import type { EntityCore } from './entity';
11
+ import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
12
+ import { EntityError } from './errors';
13
+ import type { Predicate } from './tenancy';
10
14
  import type { ColumnMap, Insertable } from './types';
11
15
 
12
16
  /** Framework namespace for seed labels. Fixed forever: changing it moves every seeded id. */
13
17
  const NAMESPACE = 'a3c1f0d6-5c2b-4a3e-9f1b-6d4e7c8a9b02';
14
18
 
19
+ /**
20
+ * What a replay never overwrites, unless `preserve` says otherwise. Spelled here as a constant for
21
+ * the reason `SOFT_DELETE_COLUMN` is: the timestamp convention is the framework's, so the column a
22
+ * seed must not reset is decided once.
23
+ */
24
+ const CREATED_AT_COLUMN = 'createdAt';
25
+
15
26
  const bytesOf = (uuid: string): Uint8Array =>
16
27
  Uint8Array.from((uuid.replaceAll('-', '').match(/../g) ?? []).map((pair) => parseInt(pair, 16)));
17
28
 
@@ -35,35 +46,294 @@ export const seedId = (label: string): string => {
35
46
  ].join('-');
36
47
  };
37
48
 
49
+ /**
50
+ * Which deploys a seed belongs to, declared as DATA on the seed and never inferred from its name.
51
+ *
52
+ * `reference` is data the app is wrong without — currencies, plans, service tiers, locations — and
53
+ * it ships to production through this same mechanism. `dev` is fixture data, and production is the
54
+ * one environment it must not reach by accident. The word is the seed's; the refusal is the
55
+ * caller's (`x db seed`), because an app that seeds its own database from its boot code has
56
+ * DECIDED to (axiom 8) and a library that overruled that would break it.
57
+ */
58
+ export const SEED_TIERS = ['reference', 'dev'] as const;
59
+
60
+ export type SeedTier = (typeof SEED_TIERS)[number];
61
+
62
+ /**
63
+ * The tiers a run takes when nothing asked for one: everything, except that production leaves
64
+ * `dev` out. `requested` is both the selection AND the consent, one word doing one job — a cluster
65
+ * that sets `ULTIMATE_ENV=production` on every box (staging included) still loads its dev seeds by
66
+ * naming the tier, instead of by lying about the environment.
67
+ */
68
+ export const seedTiersFor = (
69
+ environment: Environment,
70
+ requested?: SeedTier | undefined,
71
+ ): readonly SeedTier[] => {
72
+ if (requested !== undefined) return [requested];
73
+ return environment === 'production' ? ['reference'] : [...SEED_TIERS];
74
+ };
75
+
76
+ /** What one `upsert` did. `skipped` is a row already stored with these values — no statement. */
77
+ export type SeedWrite = 'inserted' | 'updated' | 'skipped';
78
+
79
+ /** One run's tally, in the three words every seed report is built from. */
80
+ export interface SeedMetrics {
81
+ inserted: number;
82
+ updated: number;
83
+ skipped: number;
84
+ }
85
+
86
+ export interface SeedKey<Row> {
87
+ /**
88
+ * The columns of the unique constraint this row is identified by — its NATURAL key, which is the
89
+ * only key a seed writing into an existing table can know. A target no declared constraint
90
+ * matches is refused by `upsertPlan` before a statement is sent (`42P10` otherwise).
91
+ */
92
+ readonly by: readonly (keyof Row & string)[];
93
+ /**
94
+ * Columns a collision leaves alone. `createdAt` by default, and that default is the point: a
95
+ * replay must not reset when a row first arrived. The conflict target, the primary key and the
96
+ * soft-delete stamp are spared by `upsertPlan` already.
97
+ */
98
+ readonly preserve?: readonly (keyof Row & string)[];
99
+ }
100
+
38
101
  export interface SeedContext {
102
+ /**
103
+ * Rows whose ids the SEED chose, written in one statement and replayable by primary key: a row
104
+ * already stored is left exactly as it is (`on conflict … do nothing`). The bulk verb — one
105
+ * statement per call, not one per row.
106
+ */
39
107
  insert<Row, C extends ColumnMap>(
40
108
  entity: EntityCore<Row, C>,
41
109
  rows: readonly Insertable<C>[],
42
110
  ): Promise<void>;
111
+ /**
112
+ * One row whose id the TABLE owns, matched on the natural key `by` names. Reads first so the
113
+ * answer can be `'skipped'`, then writes with a single `on conflict … do update`, which is what
114
+ * settles the race between two containers booting at once — the read is for the report, never
115
+ * for the decision.
116
+ */
117
+ upsert<Row, C extends ColumnMap>(
118
+ entity: EntityCore<Row, C>,
119
+ key: SeedKey<Row>,
120
+ values: Insertable<C>,
121
+ ): Promise<SeedWrite>;
122
+ /**
123
+ * The other unit of idempotency: the FILE. Bulk volume data has no natural key worth upserting
124
+ * ten thousand rows against, so the guard is a sentinel — `if (await exists(reports)) return;`
125
+ * at the top of the seed.
126
+ */
127
+ exists<Row, C extends ColumnMap>(
128
+ entity: EntityCore<Row, C>,
129
+ where?: Partial<Row>,
130
+ ): Promise<boolean>;
131
+ count<Row, C extends ColumnMap>(
132
+ entity: EntityCore<Row, C>,
133
+ where?: Partial<Row>,
134
+ ): Promise<number>;
135
+ /** Scoped wipe before a regenerate. Refused on a soft-deleting entity — see `softDeleteWipe`. */
136
+ deleteWhere<Row, C extends ColumnMap>(
137
+ entity: EntityCore<Row, C>,
138
+ where: Partial<Row>,
139
+ ): Promise<number>;
43
140
  /** Deterministic id for a label. Same label, same uuid, every run. */
44
141
  id(label: string): string;
142
+ /** One instant for the whole run, so every row a bulk pass stamps carries the same timestamp. */
143
+ readonly now: Date;
144
+ readonly environment: Environment;
145
+ readonly tier: SeedTier;
146
+ /** Reads still run; every write short-circuits and is counted as what it WOULD have written. */
147
+ readonly dryRun: boolean;
148
+ readonly metrics: SeedMetrics;
45
149
  }
46
150
 
47
151
  export interface SeedOptions {
48
152
  /** Defaults to a fresh in-memory driver, so a seed runs with no database at all. */
49
153
  readonly driver?: Driver;
154
+ readonly dryRun?: boolean;
155
+ /** Injected for a test; `process.env` otherwise. Read once, by `resolveEnvironment`. */
156
+ readonly env?: Readonly<Record<string, string | undefined>> | undefined;
157
+ }
158
+
159
+ export interface SeedRun {
160
+ readonly name: string;
161
+ readonly tier: SeedTier;
162
+ readonly metrics: SeedMetrics;
50
163
  }
51
164
 
52
165
  export interface Seed {
53
166
  readonly name: string;
54
- run(options?: SeedOptions): Promise<void>;
167
+ readonly tier: SeedTier;
168
+ run(options?: SeedOptions): Promise<SeedRun>;
55
169
  }
56
170
 
57
- export const defineSeed = (name: string, build: (context: SeedContext) => Promise<void>): Seed => ({
58
- name,
59
- run: async (options = {}) => {
60
- const driver = options.driver ?? memoryDriver();
61
- await build({
62
- insert: async (entity, rows) => {
63
- const repo = driver.repo(entity);
64
- for (const row of rows) await repo.insert(entity.$parse(row));
65
- },
66
- id: seedId,
67
- });
68
- },
69
- });
171
+ export interface SeedInit {
172
+ /** Defaults to `dev`: fixture data is what a seed is until its author says otherwise. */
173
+ readonly tier?: SeedTier;
174
+ }
175
+
176
+ /** What `x db seed` picks out of a module. Same shape rule as `isRouteConfig`. */
177
+ export const isSeed = (value: unknown): value is Seed =>
178
+ typeof value === 'object' &&
179
+ value !== null &&
180
+ typeof (value as { name?: unknown }).name === 'string' &&
181
+ typeof (value as { run?: unknown }).run === 'function' &&
182
+ (SEED_TIERS as readonly unknown[]).includes((value as { tier?: unknown }).tier);
183
+
184
+ /** Property access on a parsed row without `any`: `$parse` fills every declared column. */
185
+ const cellOf = (row: unknown, property: string): unknown =>
186
+ (row as Readonly<Record<string, unknown>>)[property];
187
+
188
+ /**
189
+ * Two cells, as a replay has to compare them: a `Date` is not `===` a `Date` and money is an
190
+ * object, so both are compared by value and everything else by identity.
191
+ */
192
+ const sameCell = (left: unknown, right: unknown): boolean => {
193
+ if (left instanceof Date || right instanceof Date) {
194
+ return left instanceof Date && right instanceof Date && left.getTime() === right.getTime();
195
+ }
196
+ if (typeof left === 'object' && left !== null && typeof right === 'object' && right !== null) {
197
+ return JSON.stringify(left) === JSON.stringify(right);
198
+ }
199
+ return left === right;
200
+ };
201
+
202
+ const equalityPredicates = <Row>(where: Partial<Row>): readonly Predicate[] =>
203
+ Object.entries(where).map(([column, value]): Predicate => ({ column, op: 'eq', value }));
204
+
205
+ /**
206
+ * The entity's own key as a conflict target. `$primaryKey` is `readonly string[]` because an
207
+ * entity does not know its row type at that field, and `onConflict` is typed by the row.
208
+ */
209
+ const primaryKeyTarget = <Row>(entity: EntityCore<Row>): readonly (keyof Row & string)[] =>
210
+ entity.$primaryKey as readonly (keyof Row & string)[];
211
+
212
+ /**
213
+ * A primary key the row leaves to a GENERATED default is a different id on every run, so the
214
+ * conflict target finds nothing and each replay inserts one more copy. `$parse` refuses a key with
215
+ * no value at all; this is the half it cannot see, because filling that column is what it does.
216
+ */
217
+ const generatedKey = (entity: EntityCore, missing: string, position: number): EntityError =>
218
+ new EntityError({
219
+ code: 'X_INVARIANT_VIOLATED',
220
+ cause: `${entity.$name} seed row ${position + 1} leaves "${missing}" to a generated default, and a primary key generated fresh on every run is a row every replay inserts a second copy of`,
221
+ fix: `insert(${entity.$name}, rows.map((row, index) => ({ ...row, ${missing}: id(\`${entity.$name}:\${index}\`) }))) # id() is a uuid v5 of the label: same row, same id, every run`,
222
+ });
223
+
224
+ /**
225
+ * Deleting from a soft-deleting entity inside a seed, refused rather than documented. The stamp is
226
+ * what makes it unrecoverable: `upsertPlan` spares the soft-delete column on purpose and the stored
227
+ * row still occupies its unique key, so the replay that was supposed to bring the rows back writes
228
+ * nothing at all and the fixture is gone until the database is.
229
+ */
230
+ const softDeleteWipe = (entity: EntityCore): EntityError =>
231
+ new EntityError({
232
+ code: 'X_INVARIANT_VIOLATED',
233
+ cause: `${entity.$name} declares ${SOFT_DELETE_COLUMN}, so deleteWhere() would stamp its seeded rows rather than remove them — the stamped row keeps its unique key, and no replay of this seed can clear it`,
234
+ fix: 'x db reset --json # the only wipe a soft-deleting entity has; drop the deleteWhere() call from the seed',
235
+ });
236
+
237
+ /** The row an update may write: everything the caller named, less what a match must not move. */
238
+ const withoutPreserved = <Row>(row: Row, preserve: readonly string[]): Row => {
239
+ const copy: Record<string, unknown> = { ...(row as Record<string, unknown>) };
240
+ for (const property of preserve) delete copy[property];
241
+ // `Repo` is typed for whole rows, and a partial one is exactly what keeps a column OUT of the
242
+ // update set — `namedProperties` reads what the row owns. Same assertion the bulk live test takes.
243
+ return copy as Row;
244
+ };
245
+
246
+ export const defineSeed = (
247
+ name: string,
248
+ build: (context: SeedContext) => Promise<void>,
249
+ init: SeedInit = {},
250
+ ): Seed => {
251
+ const tier = init.tier ?? 'dev';
252
+ return {
253
+ name,
254
+ tier,
255
+ run: async (options = {}) => {
256
+ const driver = options.driver ?? memoryDriver();
257
+ const dryRun = options.dryRun ?? false;
258
+ const metrics: SeedMetrics = { inserted: 0, updated: 0, skipped: 0 };
259
+ const context: SeedContext = {
260
+ insert: async (entity, rows) => {
261
+ // Judged on the row as WRITTEN, before `$parse` fills the column that would hide it.
262
+ for (const [position, row] of rows.entries()) {
263
+ const missing = entity.$primaryKey.find(
264
+ (property) =>
265
+ entity.$columns[property]?.$meta.default?.kind === 'generated' &&
266
+ !Object.hasOwn(row, property),
267
+ );
268
+ if (missing !== undefined) throw generatedKey(entity, missing, position);
269
+ }
270
+ const parsed = rows.map((row) => entity.$parse(row));
271
+ if (dryRun) {
272
+ metrics.inserted += parsed.length;
273
+ return;
274
+ }
275
+ const written = await driver.repo(entity).upsertAll(parsed, {
276
+ onConflict: primaryKeyTarget(entity),
277
+ // Never `'update'`: a do-nothing conflict needs no tenant column in the target, so this
278
+ // is the one form that replays on a tenant-scoped entity whose unique keys are global.
279
+ onMatch: 'nothing',
280
+ });
281
+ metrics.inserted += written.length;
282
+ metrics.skipped += parsed.length - written.length;
283
+ },
284
+
285
+ upsert: async (entity, key, values) => {
286
+ const row = entity.$parse(values);
287
+ const repo = driver.repo(entity);
288
+ const where = Object.fromEntries(
289
+ key.by.map((property) => [property, cellOf(row, property)]),
290
+ );
291
+ const found = await repo.findMany({ where: equalityPredicates(where), limit: 1 });
292
+ const stored = found.rows[0];
293
+ const preserve: readonly string[] = key.preserve ?? [CREATED_AT_COLUMN];
294
+ const compared = Object.keys(row as Record<string, unknown>).filter(
295
+ (property) => !preserve.includes(property),
296
+ );
297
+ if (
298
+ stored !== undefined &&
299
+ compared.every((property) => sameCell(cellOf(stored, property), cellOf(row, property)))
300
+ ) {
301
+ metrics.skipped += 1;
302
+ return 'skipped';
303
+ }
304
+ const write: SeedWrite = stored === undefined ? 'inserted' : 'updated';
305
+ if (!dryRun) {
306
+ await repo.upsertAll([stored === undefined ? row : withoutPreserved(row, preserve)], {
307
+ onConflict: key.by,
308
+ onMatch: 'update',
309
+ });
310
+ }
311
+ metrics[write === 'inserted' ? 'inserted' : 'updated'] += 1;
312
+ return write;
313
+ },
314
+
315
+ count: async (entity, where) =>
316
+ driver
317
+ .repo(entity)
318
+ .count(where === undefined ? {} : { where: equalityPredicates(where) }),
319
+
320
+ exists: async (entity, where) => (await context.count(entity, where)) > 0,
321
+
322
+ deleteWhere: async (entity, where) => {
323
+ if (entity.$softDelete) throw softDeleteWipe(entity);
324
+ if (dryRun) return context.count(entity, where);
325
+ return driver.repo(entity).deleteWhere(where);
326
+ },
327
+
328
+ id: seedId,
329
+ now: entityNow(),
330
+ environment: resolveEnvironment({ env: options.env }),
331
+ tier,
332
+ dryRun,
333
+ metrics,
334
+ };
335
+ await build(context);
336
+ return { name, tier, metrics };
337
+ },
338
+ };
339
+ };
package/src/types.ts CHANGED
@@ -6,17 +6,39 @@
6
6
  // A column carries its TypeScript type in `$parse`, which is what lets the row type be derived
7
7
  // from the column set instead of being written a second time as a hand-maintained schema.
8
8
 
9
- /** Postgres types the blessed builders emit. `money` expands to `bigint` + `char(3)`. */
10
- export type ColumnKind =
11
- | 'uuid'
12
- | 'text'
13
- | 'char'
14
- | 'boolean'
15
- | 'integer'
16
- | 'bigint'
17
- | 'timestamptz'
18
- | 'jsonb'
19
- | 'money';
9
+ /**
10
+ * Postgres types the builders emit. `money` expands to `bigint` + `char(3)` (+ a nullable
11
+ * `integer` scale); `array` expands to its element's type with `[]` after it.
12
+ *
13
+ * The four beyond the blessed set exist for ONE reason: a schema Ultimate did not generate already
14
+ * has them. A table with a `numeric(18,8)` rate, a `date` a rate takes effect on, a `jsonb` payload
15
+ * and a `bytea` blob cannot be declared at all without them, and an entity that cannot be declared
16
+ * is a rewrite instead of an adoption.
17
+ */
18
+ export const COLUMN_KINDS = [
19
+ 'uuid',
20
+ 'text',
21
+ 'char',
22
+ 'boolean',
23
+ 'integer',
24
+ 'bigint',
25
+ 'numeric',
26
+ 'timestamptz',
27
+ 'date',
28
+ 'jsonb',
29
+ 'bytea',
30
+ 'array',
31
+ 'money',
32
+ ] as const;
33
+
34
+ /**
35
+ * Derived from the array and never written twice — the same shape `PRIMITIVE_KINDS` in
36
+ * `@ultimat3/core`'s `registrar.ts` uses, and for the same reason: a package that has to answer
37
+ * "one case per kind" needs a RUNTIME list, and a list written beside the type is one that drifts
38
+ * from it. `@ultimat3/query`'s `shape-order.test.ts` spelled its own out and counted nine against
39
+ * thirteen — `9 === 9`, a test that could not fail.
40
+ */
41
+ export type ColumnKind = (typeof COLUMN_KINDS)[number];
20
42
 
21
43
  export type ColumnDefault =
22
44
  | { readonly kind: 'value'; readonly value: string | number | boolean | null }
@@ -48,6 +70,22 @@ export type { MoneyValue };
48
70
  * needs no conversion at the call site. A float throws, and so does a `bigint` past
49
71
  * `Number.MAX_SAFE_INTEGER` — see `parseMinor` in `columns.ts`.
50
72
  */
73
+ /**
74
+ * The physical columns behind a money property, when they are not `<name>_minor`,
75
+ * `<name>_currency` and `<name>_scale`. Named per part and merged over those defaults, so a table
76
+ * that renamed one column does not have to restate the other two.
77
+ */
78
+ export interface MoneyColumnNames {
79
+ readonly minor?: string;
80
+ readonly currency?: string;
81
+ /**
82
+ * `null` says this table has NO scale column — the ordinary shape of a money column written
83
+ * before scale existed. Every amount in it is then at the currency's own minor unit, which is
84
+ * what an absent scale already means, so nothing is lost but the ability to store a sub-cent one.
85
+ */
86
+ readonly scale?: string | null;
87
+ }
88
+
51
89
  export interface MoneyInput {
52
90
  readonly minor: bigint | number;
53
91
  readonly currency: string;
@@ -71,7 +109,20 @@ export interface ColumnMeta {
71
109
  readonly index: boolean;
72
110
  /** Presence of a tenant column is what turns tenancy on. See `tenancy.ts`. */
73
111
  readonly tenant: boolean;
112
+ /**
113
+ * The physical column, when it is not `snake(property)`. The whole of what makes an existing
114
+ * table addressable: `githubLogin` on a row, `gh_login` in every statement, decided once here
115
+ * and read through `columnName()` by every projection.
116
+ */
117
+ readonly name?: string;
74
118
  readonly length?: number;
119
+ /** `numeric(precision, scale)` — both, or neither. A bare `numeric` is unbounded on purpose. */
120
+ readonly precision?: number;
121
+ readonly numericScale?: number;
122
+ /** The element column of an `array`, which carries the element's own kind and `$parse`. */
123
+ readonly element?: AnyColumn;
124
+ /** Where money's three physical columns live, when the table already named them. */
125
+ readonly parts?: MoneyColumnNames;
75
126
  readonly values?: readonly string[];
76
127
  readonly default?: ColumnDefault;
77
128
  readonly onUpdate?: ColumnDefault;
@@ -99,6 +150,13 @@ export interface Column<T, Optional extends boolean = false> {
99
150
  tenant(): Column<T, Optional>;
100
151
  references(target: () => AnyColumn, options?: ReferenceOptions): Column<T, Optional>;
101
152
  default(value: T): Column<T, true>;
153
+ /**
154
+ * The physical column name, when the table does not spell it `snake(property)`. Name it LAST in
155
+ * a chain: this link returns the general column, so a builder's own methods (`defaultNow()`,
156
+ * a uuid key's narrowed `primaryKey()`) are declared before it — `uuid()` and `timestamp()`
157
+ * override it to keep theirs, and nothing else has any.
158
+ */
159
+ column(name: string): Column<T, Optional>;
102
160
  }
103
161
 
104
162
  /**
@@ -111,11 +169,13 @@ export interface Column<T, Optional extends boolean = false> {
111
169
  export interface UuidColumn<T extends string = string, Optional extends boolean = false>
112
170
  extends Column<T, Optional> {
113
171
  primaryKey(): Column<T, true>;
172
+ column(name: string): UuidColumn<T, Optional>;
114
173
  }
115
174
 
116
175
  export interface TimestampColumn<Optional extends boolean = false> extends Column<Date, Optional> {
117
176
  defaultNow(): TimestampColumn<true>;
118
177
  onUpdateNow(): TimestampColumn<Optional>;
178
+ column(name: string): TimestampColumn<Optional>;
119
179
  }
120
180
 
121
181
  export type AnyColumn = Column<unknown, boolean>;
@@ -169,6 +229,38 @@ export type Insertable<C extends ColumnMap> = {
169
229
  readonly [K in DefaultedKeys<C> | NullableKeys<C>]?: InputOf<TypeOf<C[K]>>;
170
230
  };
171
231
 
232
+ /**
233
+ * A patch or a filter: every property optional, **and every property allowed to be present and
234
+ * `undefined`**.
235
+ *
236
+ * `Partial<Row>` cannot say the second half. Under `exactOptionalPropertyTypes` — which this repo
237
+ * has on — `{ k?: T }` means "absent, or a `T`", so `{ postId: undefined }` is a type error, and
238
+ * that is the ONE value every filtered write in this package is built to refuse:
239
+ *
240
+ * - `plan.ts`'s `namedColumns` drops `value !== undefined`, so `deleteWhere({ postId: undefined })`
241
+ * yields no predicate and throws `X_WRITE_UNFILTERED` rather than deleting the table. That
242
+ * refusal exists for exactly one caller — a variable that came back `undefined` — and
243
+ * `Partial<Row>` made the caller unable to spell it and the test unable to prove it.
244
+ * - `updateWhere(filter, { createdAt: undefined })` is `X_PATCH_EMPTY`, one argument over.
245
+ * - `bulk-write.ts`'s `owns()` is `Object.hasOwn`, so for a batch a present-`undefined` property
246
+ * IS a named column — the opposite reading, deliberately, and equally unspellable.
247
+ *
248
+ * A type that forbids the value its own runtime is written to catch is a type that documents
249
+ * nothing: `bulk-write.test.ts` carried `as Partial<Item>` with the comment
250
+ * "`exactOptionalPropertyTypes` is why it takes a cast to say", which is the defect written down.
251
+ *
252
+ * Money goes through `InputOf`, for the reason `Insertable` does: a patch is a WRITE, and money's
253
+ * write shape is the wider one. `conflictKeyOf`'s own `cellKey` renders a `bigint` minor unit
254
+ * (`${part}n`), so refusing one here would have been a type refusing a value one function down
255
+ * spells out how to serialise. The row's own type stays in the union rather than being replaced by
256
+ * `InputOf<Row[K]>`: a conditional type over an unresolved `Row` never reduces, so `Row` itself
257
+ * stopped being assignable to its own patch and every internal caller reddened. Both spellings are
258
+ * accepted here, which is the honest statement anyway — a patch may carry either.
259
+ */
260
+ export type RowPatch<Row> = {
261
+ readonly [K in keyof Row]?: Row[K] | InputOf<Row[K]> | undefined;
262
+ };
263
+
172
264
  export interface IndexDef {
173
265
  readonly name: string;
174
266
  readonly columns: readonly string[];