@fabrika/db 0.8.2 → 0.9.0-cloudflare.2

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.
Files changed (71) hide show
  1. package/package.json +44 -12
  2. package/src/admissions.ts +78 -12
  3. package/src/application-rows.ts +108 -0
  4. package/src/capability.ts +5 -0
  5. package/src/codegen-sqlite.ts +154 -0
  6. package/src/codegen.ts +59 -9
  7. package/src/database.ts +208 -56
  8. package/src/errors.ts +1 -0
  9. package/src/file-columns.ts +4 -1
  10. package/src/framework-tables.ts +28 -8
  11. package/src/framework.ts +10 -2
  12. package/src/guard.ts +8 -0
  13. package/src/index.ts +9 -1
  14. package/src/job-leases.ts +157 -37
  15. package/src/links.ts +8 -1
  16. package/src/migrations-sqlite.ts +132 -0
  17. package/src/nodes.ts +45 -9
  18. package/src/orm.ts +25 -15
  19. package/src/policy.ts +8 -2
  20. package/src/postgres.ts +26 -0
  21. package/src/registry.ts +8 -2
  22. package/src/sql.ts +15 -7
  23. package/src/sqlite-bun.ts +122 -0
  24. package/src/sqlite-migrate.ts +242 -0
  25. package/src/sqlite.ts +224 -0
  26. package/src/store.ts +336 -62
  27. package/src/stored-files.ts +149 -46
  28. package/src/tables.ts +16 -0
  29. package/src/temporal.ts +115 -9
  30. package/src/testing-shared.ts +733 -0
  31. package/src/testing-sqlite.ts +513 -0
  32. package/src/testing.ts +170 -482
  33. package/types/src/admissions.d.ts +21 -0
  34. package/types/src/application-rows.d.ts +58 -0
  35. package/types/src/builtins.d.ts +42 -0
  36. package/types/src/capability.d.ts +119 -0
  37. package/types/src/codegen-sqlite.d.ts +6 -0
  38. package/types/src/codegen.d.ts +82 -0
  39. package/types/src/database.d.ts +179 -0
  40. package/types/src/description.d.ts +109 -0
  41. package/types/src/errors.d.ts +98 -0
  42. package/types/src/file-columns.d.ts +37 -0
  43. package/types/src/file-references.d.ts +27 -0
  44. package/types/src/framework-tables.d.ts +59 -0
  45. package/types/src/framework.d.ts +37 -0
  46. package/types/src/guard.d.ts +23 -0
  47. package/types/src/identifier.d.ts +4 -0
  48. package/types/src/index.d.ts +10 -0
  49. package/types/src/job-leases.d.ts +93 -0
  50. package/types/src/links.d.ts +13 -0
  51. package/types/src/migrations-sqlite.d.ts +4 -0
  52. package/types/src/migrations.d.ts +25 -0
  53. package/types/src/nodes.d.ts +63 -0
  54. package/types/src/orm-types.d.ts +638 -0
  55. package/types/src/orm.d.ts +77 -0
  56. package/types/src/policy.d.ts +354 -0
  57. package/types/src/postgres.d.ts +12 -0
  58. package/types/src/production.d.ts +5 -0
  59. package/types/src/registry.d.ts +40 -0
  60. package/types/src/sql.d.ts +59 -0
  61. package/types/src/sqlite-bun.d.ts +13 -0
  62. package/types/src/sqlite-migrate.d.ts +28 -0
  63. package/types/src/sqlite.d.ts +24 -0
  64. package/types/src/store.d.ts +345 -0
  65. package/types/src/stored-files.d.ts +98 -0
  66. package/types/src/tables.d.ts +21 -0
  67. package/types/src/temporal.d.ts +33 -0
  68. package/types/src/testing-shared.d.ts +201 -0
  69. package/types/src/testing-sqlite.d.ts +22 -0
  70. package/types/src/testing.d.ts +35 -0
  71. package/types/src/vocabulary-tables.d.ts +225 -0
@@ -0,0 +1,733 @@
1
+ // The dialect-neutral half of the database test kits (`@fabrika/db/testing` on Postgres, `@fabrika/db/testing-sqlite`
2
+ // on SQLite): the isolation fixtures' shapes, the seeding plan over a migrated schema a kit describes, and the table
3
+ // probe. Not a subpath: each kit refuses a production process before it imports this module.
4
+
5
+ import { createHash } from 'node:crypto'
6
+ import { type ApplicationRowRefusal, applicationRowRefusalText, type ApplicationRowsPlan, planApplicationRows } from './application-rows'
7
+ import { inSet, namesOf } from './builtins'
8
+ import type { Database } from './database'
9
+ import type { Primitive } from './description'
10
+ import { FILES_TABLE } from './migrations'
11
+ import { type Container, type ForeignKey, modelOf, type TableModel, type TableModels, tableModels, type Tables } from './policy'
12
+ import { refuseInProduction } from './production'
13
+ import { frameworkSideOf } from './registry'
14
+ import type { TableProbe } from './store'
15
+
16
+ /** A value a test row holds in a column; `null` for a nullable column the fixture left empty. */
17
+ export type SampleValue = Primitive | null
18
+
19
+ /** One seeded row and where it lies. */
20
+ export interface WorldRow {
21
+ /** The row's key: the dimension value of a dimension table's row, a child's own key. */
22
+ readonly key: string
23
+ /** A child row's parent key; `null` for a dimension table. */
24
+ readonly parent: string | null
25
+ /** The value of the grant dimension the row lies in (`IsolationWorlds.dimension`). */
26
+ readonly scope: string
27
+ /**
28
+ * Every dimension value the row lies in: a dimension table's own first, then each container, innermost first. A
29
+ * share link's chain is this list.
30
+ */
31
+ readonly chain: readonly { readonly type: string; readonly value: string }[]
32
+ /** The row's values of the policy's update columns, as seeded. */
33
+ readonly updates: Readonly<Record<string, SampleValue>>
34
+ /**
35
+ * The distinct value written into every text column the fixture filled itself, in this row and in its containers.
36
+ * Each occurs in no other fixture, so one found in an answer to another scope is a leaked row.
37
+ */
38
+ readonly markers: readonly string[]
39
+ }
40
+
41
+ /** One seeded row of an application table, which lies in no dimension. */
42
+ export interface ApplicationRow {
43
+ readonly key: string
44
+ /** The row's values of the policy's update columns, as seeded. */
45
+ readonly updates: Readonly<Record<string, SampleValue>>
46
+ /** The distinct value written into every text column the fixture filled itself. */
47
+ readonly markers: readonly string[]
48
+ }
49
+
50
+ /** Rows of application tables seeded together for one case, and their removal once the case is over. */
51
+ export interface SeededApplicationRows {
52
+ /** Each table's row key, by table. */
53
+ readonly keys: Readonly<Record<string, string>>
54
+ /**
55
+ * Deletes the rows, the rows their required foreign keys and file columns made the fixture insert, and every row
56
+ * that refers to one of them, however deep (what the case's handler wrote about them), in one transaction, so rows
57
+ * that refer to each other go together. A row already gone is skipped.
58
+ */
59
+ remove(): Promise<void>
60
+ }
61
+
62
+ /** A row seeded under another: its key, and the distinct value written into every text column the fixture filled. */
63
+ export interface SeededRow {
64
+ readonly key: string
65
+ readonly markers: readonly string[]
66
+ }
67
+
68
+ /**
69
+ * What the isolation fixture cannot seed for a schema: a CHECK its values fail, a required foreign key to a scoped table
70
+ * outside the containment chain. It is a limit of the generator, named with the constraint or the column, never a
71
+ * failure of the handler or the policy a case is about.
72
+ */
73
+ export class SeedLimitError extends Error {
74
+ override readonly name = 'SeedLimitError'
75
+ }
76
+
77
+ /** One row of a table in each of two containment chains that share no value. */
78
+ export interface IsolationWorlds {
79
+ /**
80
+ * The dimension a caller's grants are scoped by: a dimension table's container, or its own dimension when it has
81
+ * none; a child table's parent's.
82
+ */
83
+ readonly dimension: string
84
+ readonly own: WorldRow
85
+ readonly other: WorldRow
86
+ /**
87
+ * Deletes both rows of the table and every row that refers to them, however deep; the containers stay. A case whose
88
+ * rows hold pinned values removes them, so the next case can pin the same values again under a unique index.
89
+ */
90
+ readonly remove: () => Promise<void>
91
+ }
92
+
93
+ /** What a test kit seeds through, over its migrated schema. */
94
+ export interface TestSeeding {
95
+ /**
96
+ * Seeds one row of `table` in each of two fresh chains, with every container row a table owns. `values` (column →
97
+ * value) are written into both rows of `table`: the state an endpoint's precondition needs. A text column keeps its
98
+ * marker, so a seed of one, or of a column that places the row, throws. `pins` (column → value) give a text column of
99
+ * `table` a known value instead of its marker: the value in the own row, and `<value>-other` in the other row, which
100
+ * counts among the other row's markers, so a leak of it is still found. A pin of a column that places the row, or of
101
+ * no text column, throws.
102
+ */
103
+ seedWorlds<DB, P>(
104
+ tables: Tables<DB, P>,
105
+ table: string,
106
+ values?: Readonly<Record<string, SampleValue>>,
107
+ pins?: Readonly<Record<string, string>>,
108
+ ): Promise<IsolationWorlds>
109
+ /**
110
+ * Seeds one row of the application table `table`: its key, version and creator stamped, `values` (column → value)
111
+ * as given, a text column too, every other column sampled. A value for a stamped column or a file column throws.
112
+ */
113
+ seedRow<DB, P>(tables: Tables<DB, P>, table: string, values?: Readonly<Record<string, SampleValue>>): Promise<ApplicationRow>
114
+ /**
115
+ * Seeds one row of each application table `rows` names (table → column → value), as `seedRow` does, in foreign-key
116
+ * order: a foreign key from one of them to another holds that row's key unless the values give it. Every table's
117
+ * values are checked before the first insert, and a table that fails removes the rows seeded before it. `remove`
118
+ * deletes them, the rows a required foreign key of theirs made it insert, and every row that refers to any of them,
119
+ * so the next case starts without them.
120
+ */
121
+ seedApplicationRows<DB, P>(
122
+ tables: Tables<DB, P>,
123
+ rows: Readonly<Record<string, Readonly<Record<string, SampleValue>>>>,
124
+ ): Promise<SeededApplicationRows>
125
+ /**
126
+ * Seeds one row of `table`, a child or dimension table that lies in the object of `under` (a row `seedWorlds`
127
+ * seeded), placed in that row's chain: the row a fixture names by id, such as a note's comment. Answers its key and
128
+ * the markers it holds.
129
+ */
130
+ seedUnder<DB, P>(tables: Tables<DB, P>, table: string, under: WorldRow): Promise<SeededRow>
131
+ /** Values for the policy's insert columns of `table` that its schema accepts: every required one, sampled; a file column a seeded file. */
132
+ sampleInsert<DB, P>(tables: Tables<DB, P>, table: string): Promise<Readonly<Record<string, SampleValue>>>
133
+ /**
134
+ * The object key of every file the fixture seeded into a file column, oldest first. Each stands for an empty object,
135
+ * which a test's object store holds when a handler serves or opens the file.
136
+ */
137
+ seededObjects(): readonly string[]
138
+ }
139
+
140
+ export interface TestDatabase extends TestSeeding {
141
+ /** The connection `boot` binds the data layer to: on Postgres the runtime role on the migrated schema; on SQLite the database itself. */
142
+ readonly database: Database
143
+ /** The migrated schema: `app_test_<hex>` on Postgres, `main` on SQLite. */
144
+ readonly schema: string
145
+ /** Closes the connections and drops the schema and its roles (on SQLite: closes the in-memory database). */
146
+ close(): Promise<void>
147
+ }
148
+
149
+ /** A column of the migrated schema, as the seeding reads it. */
150
+ export interface SeedColumn {
151
+ readonly table: string
152
+ readonly name: string
153
+ /** The type as the database names it, for a message. */
154
+ readonly type: string
155
+ readonly nullable: boolean
156
+ /** The database fills it when an insert leaves it out: a default, an identity, a generated column. */
157
+ readonly defaulted: boolean
158
+ /** A text type that is no enum: where nothing else fills it, the fixture writes a marker into it. */
159
+ readonly text: boolean
160
+ /** A uuid type: a key or containment column of it holds a random uuid as its scope value. */
161
+ readonly uuid: boolean
162
+ /** A sample of the column's type for a required column the test does not care about; a `SeedLimitError` for a type it cannot fill. */
163
+ readonly sample: () => Primitive
164
+ }
165
+
166
+ /** A CHECK constraint of the schema, by the columns it names. */
167
+ export interface CheckConstraint {
168
+ readonly name: string
169
+ readonly table: string
170
+ readonly columns: readonly string[]
171
+ /** The values of a one-column `IN (…)` list the fixture writes in its place, first one first; empty for any other CHECK. */
172
+ readonly values: readonly string[]
173
+ }
174
+
175
+ /** A live, empty file of `kind`, as the framework's put leaves one once a reference claims it. */
176
+ export interface SeededFile {
177
+ readonly id: string
178
+ readonly kind: string
179
+ readonly objectKey: string
180
+ readonly sha256: string
181
+ readonly objectVersion: string
182
+ }
183
+
184
+ /** A row the fixture inserted, found again by `match` (column → value): what a removal deletes. */
185
+ export interface InsertedRow {
186
+ readonly table: string
187
+ readonly match: ReadonlyMap<string, SampleValue>
188
+ }
189
+
190
+ /** How many rows one removal deletes, referring rows included, before it gives up. */
191
+ export const MAX_REMOVED_ROWS = 10_000
192
+
193
+ /** The seed limit of a removal that reaches more than `MAX_REMOVED_ROWS` rows. */
194
+ export function removalLimit(): SeedLimitError {
195
+ return new SeedLimitError(`the isolation fixture cannot remove the application rows: more than ${MAX_REMOVED_ROWS} rows refer to them`)
196
+ }
197
+
198
+ /** What a kit gives the seeding: its schema's columns and CHECK constraints, and the statements it runs. */
199
+ export interface SeedingBackend {
200
+ readonly columns: readonly SeedColumn[]
201
+ /** Every CHECK constraint of the schema's tables; read once, on the first column that needs them. */
202
+ checks(): Promise<readonly CheckConstraint[]>
203
+ /** Inserts one row and answers it as stored; a CHECK or foreign key it fails is a `SeedLimitError` (`checkLimit`, `foreignKeyLimit`). */
204
+ insert(table: string, values: ReadonlyMap<string, SampleValue>): Promise<Readonly<Record<string, unknown>>>
205
+ /** One row of `table`, of `columns`, or `undefined` when it has none. */
206
+ firstRow(table: string, columns: readonly string[]): Promise<Readonly<Record<string, unknown>> | undefined>
207
+ insertFile(file: SeededFile): Promise<void>
208
+ /**
209
+ * Deletes `rows` (each found by its `match`; one already gone is skipped) and every row that refers to one of them,
210
+ * however deep, in one transaction whose foreign keys are checked once all are gone, so rows that refer to each other
211
+ * go together. No write moves or adds a referring row between finding the rows and deleting them.
212
+ */
213
+ remove(rows: readonly InsertedRow[]): Promise<void>
214
+ }
215
+
216
+ /** The seed limit of a CHECK a seeded row fails. */
217
+ export function checkLimit(constraint: string, table: string): SeedLimitError {
218
+ return new SeedLimitError(
219
+ `the isolation fixture cannot satisfy the CHECK ${constraint} of ${table}: it writes a text marker or a sample of the type there; give the column an IN list, a default, or make it nullable`,
220
+ )
221
+ }
222
+
223
+ /** The seed limit of a foreign key a seeded row breaks. */
224
+ export function foreignKeyLimit(constraint: string, table: string): SeedLimitError {
225
+ return new SeedLimitError(`the isolation fixture's row of ${table} breaks the foreign key ${constraint}`)
226
+ }
227
+
228
+ /** The seed limit of a required column whose type the fixture has no sample of. */
229
+ export function typeLimit(table: string, column: string, type: string): SeedLimitError {
230
+ return new SeedLimitError(`the isolation fixture cannot fill ${table}.${column} (${type}); give the column a default or make it nullable`)
231
+ }
232
+
233
+ export function randomHex(): string {
234
+ return crypto.randomUUID().replaceAll('-', '').slice(0, 8)
235
+ }
236
+
237
+ /** The error a refusal of `planApplicationRows` throws when a test seeds the rows anyway. */
238
+ function applicationRowError(refusal: ApplicationRowRefusal): Error {
239
+ const text = applicationRowRefusalText(refusal)
240
+ return refusal.kind === 'cycle' ? new SeedLimitError(`the isolation fixture cannot order the application rows: ${text}`) : new Error(text)
241
+ }
242
+
243
+ /** A key value as the database returned it, for the foreign key that references it. */
244
+ function keyValue(value: unknown, where: string): SampleValue {
245
+ if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
246
+ return value
247
+ }
248
+ throw new SeedLimitError(`the isolation fixture cannot reference ${where}: its key is no text, number or boolean`)
249
+ }
250
+
251
+ function isRequired(column: SeedColumn): boolean {
252
+ return !column.nullable && !column.defaulted
253
+ }
254
+
255
+ /** A text column the fixture writes a marker into, nullable or not, so that a leaked value is recognised anywhere. */
256
+ function isMarkable(column: SeedColumn): boolean {
257
+ return column.text && !column.defaulted
258
+ }
259
+
260
+ /** The dimension a caller's grants are scoped by, as `IsolationWorlds.dimension` describes. */
261
+ function grantDimension(models: TableModels, model: TableModel): string {
262
+ const scope = model.scope
263
+ if (scope.mode === 'dimension') {
264
+ return scope.container?.dimension ?? scope.dimension
265
+ }
266
+ if (scope.mode === 'child') {
267
+ const parent = modelOf(models, scope.parent)
268
+ if (parent?.scope.mode === 'dimension') {
269
+ return parent.scope.container?.dimension ?? parent.scope.dimension
270
+ }
271
+ }
272
+ throw new Error(`${model.name} is not a dimension or child table; no store reaches it, so it has no isolation tests`)
273
+ }
274
+
275
+ function scopedModel<DB, P>(tables: Tables<DB, P>, table: string): { readonly models: TableModels; readonly model: TableModel } {
276
+ const models = tableModels(tables)
277
+ const model = modelOf(models, table)
278
+ if (model === undefined) {
279
+ throw new Error(`${table} is not a table of the policies`)
280
+ }
281
+ return { models, model }
282
+ }
283
+
284
+ function ownerOf(models: TableModels, dimension: string): TableModel | undefined {
285
+ for (const name of Object.keys(models)) {
286
+ const model = modelOf(models, name)
287
+ if (model?.scope.mode === 'dimension' && model.scope.dimension === dimension) {
288
+ return model
289
+ }
290
+ }
291
+ return undefined
292
+ }
293
+
294
+ /** How many referenced rows deep the fixture follows required foreign keys outside the chain before it gives up. */
295
+ const MAX_REFERENCE_DEPTH = 4
296
+ /** The digest of the empty object a seeded file stands for. */
297
+ const EMPTY_SHA256 = createHash('sha256').digest('hex')
298
+
299
+ /**
300
+ * The object version a seeded file was scanned at: whoever stocks its empty object (`seededObjects`) stores it under
301
+ * this version, so a file of a scanning kind is served as a live one is, by its scanned version.
302
+ */
303
+ export const SEEDED_OBJECT_VERSION = '"seeded"'
304
+
305
+ /** The isolation fixtures over the schema `backend` describes. */
306
+ export function testSeeding(backend: SeedingBackend): TestSeeding {
307
+ let checks: Promise<readonly CheckConstraint[]> | undefined
308
+ const seededObjects: string[] = []
309
+
310
+ /** The CHECK constraints that name `column` of `table`. */
311
+ async function checksOn(table: string, column: string): Promise<readonly CheckConstraint[]> {
312
+ checks ??= backend.checks()
313
+ return (await checks).filter((check) => check.table === table && check.columns.includes(column))
314
+ }
315
+
316
+ /**
317
+ * What the fixture writes into a column a CHECK names: the first value of a one-column `IN` list, `null` where the
318
+ * column admits it (a CHECK passes on null); `undefined` when no CHECK names it or none of those applies.
319
+ */
320
+ async function checkedValue(table: string, column: SeedColumn): Promise<SampleValue | undefined> {
321
+ const named = await checksOn(table, column.name)
322
+ if (named.length === 0) return undefined
323
+ const [listed] = named.flatMap((check) => check.values)
324
+ if (listed !== undefined && named.every((check) => check.values.includes(listed))) return listed
325
+ return column.nullable ? null : undefined
326
+ }
327
+
328
+ /** A required column's sample: an `IN` list's first value where a CHECK names one, else a sample of its type. */
329
+ async function requiredSample(table: string, column: SeedColumn): Promise<SampleValue> {
330
+ return (await checkedValue(table, column)) ?? column.sample()
331
+ }
332
+
333
+ function columnsOf(table: string): readonly SeedColumn[] {
334
+ const columns = backend.columns.filter((column) => column.table === table)
335
+ if (columns.length === 0) {
336
+ throw new Error(`${table} is not a table of the migrated schema; write its migration first`)
337
+ }
338
+ return columns
339
+ }
340
+
341
+ function columnOf(table: string, name: string): SeedColumn {
342
+ const column = columnsOf(table).find((candidate) => candidate.name === name)
343
+ if (column === undefined) {
344
+ throw new Error(`${table}.${name} is not a column of the migrated schema`)
345
+ }
346
+ return column
347
+ }
348
+
349
+ /** A column the seeded row left to its default: null only where the column admits it. */
350
+ async function updateSample(table: string, name: string): Promise<SampleValue> {
351
+ const column = columnOf(table, name)
352
+ return column.nullable ? null : requiredSample(table, column)
353
+ }
354
+
355
+ /** Whether the fixture writes a marker into `column`: text that holds no foreign key and that no CHECK names. */
356
+ async function holdsMarker(model: TableModel | undefined, table: string, column: SeedColumn): Promise<boolean> {
357
+ const keyed = (model?.foreignKeys ?? []).some((key) => key.columns.includes(column.name))
358
+ return isMarkable(column) && !keyed && (await checksOn(table, column.name)).length === 0
359
+ }
360
+
361
+ /**
362
+ * The values of a foreign key outside the containment chain: `null` where every open column admits it, else the
363
+ * key of a row of the referenced table, an existing one (a reference table's rows come from a migration) or one the
364
+ * fixture inserts, which `inserted` records when given. A required key to a dimension or child table is refused: its
365
+ * row would lie in another chain.
366
+ */
367
+ async function referencedKey(
368
+ models: TableModels,
369
+ table: string,
370
+ key: ForeignKey,
371
+ open: readonly string[],
372
+ depth: number,
373
+ inserted?: InsertedRow[],
374
+ ) {
375
+ const columns = columnsOf(table)
376
+ const values = new Map<string, SampleValue>()
377
+ if (open.every((name) => columns.find((column) => column.name === name)?.nullable === true)) {
378
+ for (const name of open) values.set(name, null)
379
+ return values
380
+ }
381
+ const target = modelOf(models, key.table)
382
+ const scoped = target?.scope.mode === 'dimension' || target?.scope.mode === 'child'
383
+ if (scoped || open.length !== key.columns.length || key.table === table || depth >= MAX_REFERENCE_DEPTH) {
384
+ throw new SeedLimitError(
385
+ `the isolation fixture cannot fill ${table}.${
386
+ open.join(', ')
387
+ }: a required foreign key to ${key.table} outside the containment chain; give the column a default or make it nullable`,
388
+ )
389
+ }
390
+ const existing = await backend.firstRow(key.table, key.references)
391
+ const row = existing ?? (await insert(models, key.table, new Map(), [], depth + 1, inserted)).stored
392
+ const match = new Map<string, SampleValue>()
393
+ key.columns.forEach((name, index) => {
394
+ const referenced = key.references[index] ?? ''
395
+ const value = keyValue(row[referenced], `${key.table}.${referenced}`)
396
+ values.set(name, value)
397
+ match.set(referenced, value)
398
+ })
399
+ if (existing === undefined) inserted?.push({ table: key.table, match })
400
+ return values
401
+ }
402
+
403
+ /** A value of `dimension` for the column that carries it: a uuid, or text that names the world it belongs to. */
404
+ function scopeValue(table: string, name: string, dimension: string, world: string): string {
405
+ const column = columnOf(table, name)
406
+ if (column.uuid) return crypto.randomUUID()
407
+ if (column.text) return `${dimension}-${world}-${randomHex()}`
408
+ throw new Error(`${table}.${name} holds a ${dimension} as ${column.type}; a key or containment column holds its scope value as text or uuid`)
409
+ }
410
+
411
+ /**
412
+ * A live, empty file of `kind`, as the framework's put leaves one once a reference claims it: what a seeded file column
413
+ * holds. `inserted` records it when given.
414
+ */
415
+ async function seedFile(kind: string, inserted?: InsertedRow[]): Promise<string> {
416
+ const id = crypto.randomUUID()
417
+ const objectKey = `${kind}/${id}`
418
+ seededObjects.push(objectKey)
419
+ await backend.insertFile({ id, kind, objectKey, sha256: EMPTY_SHA256, objectVersion: SEEDED_OBJECT_VERSION })
420
+ inserted?.push({ table: FILES_TABLE, match: new Map([['id', id]]) })
421
+ return id
422
+ }
423
+
424
+ /**
425
+ * Inserts one row: `assigned` as given, a file column a seeded file, a foreign key outside the chain `null` or referencing a row, a column a CHECK
426
+ * names its `IN` list's first value or `null`, every other text column a marker and every other required column a
427
+ * sample. Answers what the fixture wrote, and the row as stored (its database defaults filled). A CHECK or a foreign
428
+ * key the values still fail is a `SeedLimitError` that names the constraint. `inserted` records every row a foreign
429
+ * key or a file column made it insert, not the row itself.
430
+ */
431
+ async function insert(
432
+ models: TableModels,
433
+ table: string,
434
+ assigned: ReadonlyMap<string, SampleValue>,
435
+ markers: string[],
436
+ depth = 0,
437
+ inserted?: InsertedRow[],
438
+ ): Promise<{ readonly row: ReadonlyMap<string, SampleValue>; readonly stored: Readonly<Record<string, unknown>> }> {
439
+ const model = modelOf(models, table)
440
+ const columns = columnsOf(table)
441
+ // A column with a database default keeps it unless the fixture assigned it, a foreign key's included.
442
+ const defaulted = new Set(columns.filter((column) => column.defaulted).map((column) => column.name))
443
+ const keyed = new Map<string, SampleValue>()
444
+ for (const key of model?.foreignKeys ?? []) {
445
+ const open = key.columns.filter((column) => !assigned.has(column) && !defaulted.has(column))
446
+ if (open.length === 0) continue
447
+ for (const [name, value] of await referencedKey(models, table, key, open, depth, inserted)) keyed.set(name, value)
448
+ }
449
+ const row = new Map<string, SampleValue>()
450
+ for (const column of columns) {
451
+ const name = column.name
452
+ // An assigned `null` is the fixture's value too, never a gap to fill.
453
+ const given = assigned.has(name) ? assigned : keyed.has(name) ? keyed : undefined
454
+ if (given !== undefined) {
455
+ row.set(name, given.get(name) ?? null)
456
+ continue
457
+ }
458
+ if (column.defaulted) continue
459
+ const kind = model !== undefined && Object.hasOwn(model.files, name) ? model.files[name] : undefined
460
+ const checked = kind === undefined ? await checkedValue(table, column) : undefined
461
+ if (kind !== undefined) {
462
+ row.set(name, await seedFile(kind, inserted))
463
+ } else if (checked !== undefined) {
464
+ row.set(name, checked)
465
+ } else if (await holdsMarker(model, table, column)) {
466
+ const marker = `m${randomHex()}${randomHex()}`
467
+ markers.push(marker)
468
+ row.set(name, marker)
469
+ } else {
470
+ row.set(name, column.nullable ? null : column.sample())
471
+ }
472
+ }
473
+ return { row, stored: await backend.insert(table, row) }
474
+ }
475
+
476
+ /** The columns a model's row is placed by: its own key when it owns a dimension, and each link of its chain. */
477
+ function placement(model: TableModel, values: ReadonlyMap<string, string>, key: string): Map<string, SampleValue> {
478
+ const assigned = new Map<string, SampleValue>()
479
+ const scope = model.scope
480
+ if (scope.mode === 'dimension' || scope.mode === 'child' || scope.mode === 'app') {
481
+ assigned.set(scope.key, key)
482
+ }
483
+ for (const link of model.chain) {
484
+ const value = values.get(link.dimension)
485
+ if (value !== undefined) {
486
+ assigned.set(link.column, value)
487
+ }
488
+ }
489
+ if (model.version !== undefined) {
490
+ assigned.set(model.version, 1)
491
+ }
492
+ if (model.createdBy !== undefined) {
493
+ assigned.set(model.createdBy, 'isolation-fixture')
494
+ }
495
+ return assigned
496
+ }
497
+
498
+ /**
499
+ * The plan of seeding `rows` (table → column → value), after `planApplicationRows`, the rule `@fabrika/check` reports
500
+ * from source, and the migrated schema passed it: each given column is a column of the database too. Throws the first
501
+ * refusal; inserts nothing, so a seed of several tables checks them all first.
502
+ */
503
+ function checkApplicationValues(models: TableModels, rows: Readonly<Record<string, Readonly<Record<string, SampleValue>>>>): ApplicationRowsPlan {
504
+ const plan = planApplicationRows(models, rows)
505
+ const [refusal] = plan.refusals
506
+ if (refusal !== undefined) throw applicationRowError(refusal)
507
+ for (const table of plan.order) {
508
+ for (const name of Object.keys(rows[table] ?? {})) columnOf(table, name)
509
+ }
510
+ return plan
511
+ }
512
+
513
+ /**
514
+ * One row of the application table `model`, whose values `checkApplicationValues` passed: its key, version and
515
+ * creator stamped, `linked` (a foreign key to a row seeded beside it) and `values` as given, a text column too, every
516
+ * other column sampled. A key `linked` gives (a table that shares its referenced row's key) is the row's key.
517
+ * `inserted` records the row and each row a foreign key or a file column made it insert, referenced rows first.
518
+ */
519
+ async function seedApplicationRow(
520
+ models: TableModels,
521
+ model: TableModel,
522
+ values: Readonly<Record<string, SampleValue>>,
523
+ linked: ReadonlyMap<string, SampleValue>,
524
+ markers: string[],
525
+ inserted?: InsertedRow[],
526
+ ) {
527
+ const table = model.name
528
+ const scope = model.scope
529
+ if (scope.mode !== 'app') throw new Error(`${table} is a ${scope.mode} table, not an application table`)
530
+ const shared = linked.get(scope.key)
531
+ const key = shared === undefined || shared === null ? scopeValue(table, scope.key, table, 'app') : String(shared)
532
+ const assigned = placement(model, new Map(), key)
533
+ for (const [name, value] of linked) assigned.set(name, value)
534
+ // A given text column holds no marker: no leak case counts an application row's values as another scope's.
535
+ for (const [name, value] of Object.entries(values)) assigned.set(name, value)
536
+ const seeded = await insert(models, table, assigned, markers, 0, inserted)
537
+ inserted?.push({ table, match: new Map([[scope.key, key]]) })
538
+ return { key, ...seeded }
539
+ }
540
+
541
+ /** The value a pin gives the row of `world`: as written in the own row, distinct in the other. */
542
+ const pinnedValue = (value: string, world: string): string => (world === 'own' ? value : `${value}-${world}`)
543
+
544
+ async function seedWorld(
545
+ models: TableModels,
546
+ model: TableModel,
547
+ world: string,
548
+ seed: Readonly<Record<string, SampleValue>>,
549
+ pins: Readonly<Record<string, string>>,
550
+ inserted: InsertedRow[],
551
+ ): Promise<WorldRow> {
552
+ const markers: string[] = []
553
+ const values = new Map<string, string>()
554
+ const links: readonly Container[] = model.chain
555
+ for (const link of links) {
556
+ values.set(link.dimension, scopeValue(model.name, link.column, link.dimension, world))
557
+ }
558
+ const scope = model.scope
559
+ if (scope.mode !== 'dimension' && scope.mode !== 'child') {
560
+ throw new Error(`${model.name} is not a dimension or child table`)
561
+ }
562
+ const key = scopeValue(model.name, scope.key, scope.mode === 'dimension' ? scope.dimension : model.name, world)
563
+ if (scope.mode === 'dimension') {
564
+ values.set(scope.dimension, key)
565
+ }
566
+ // Containers first, outermost first: a composite foreign key needs its target row.
567
+ for (let index = links.length - 1; index >= 0; index--) {
568
+ const link = links[index]
569
+ const owner = link === undefined ? undefined : ownerOf(models, link.dimension)
570
+ const value = link === undefined ? undefined : values.get(link.dimension)
571
+ if (owner !== undefined && owner.name !== model.name && value !== undefined) {
572
+ await insert(models, owner.name, placement(owner, values, value), markers)
573
+ }
574
+ }
575
+ const assigned = placement(model, values, key)
576
+ for (const [name, value] of Object.entries(seed)) {
577
+ const column = columnOf(model.name, name)
578
+ if (assigned.has(name)) {
579
+ throw new Error(`${model.name}.${name} places the row in its scope; a seed sets only the row's own state`)
580
+ }
581
+ if (await holdsMarker(model, model.name, column)) {
582
+ throw new Error(
583
+ `${model.name}.${name} holds a text marker the leak cases look for; a seed sets a column of another type (an enum, a boolean, a number), a text column a CHECK lists the values of, or a foreign key`,
584
+ )
585
+ }
586
+ assigned.set(name, value)
587
+ }
588
+ for (const [name, value] of Object.entries(pins)) {
589
+ const column = columnOf(model.name, name)
590
+ if (assigned.has(name)) {
591
+ throw new Error(`${model.name}.${name} places the row in its scope or is seeded; a pin gives a text column of the row's own state a known value`)
592
+ }
593
+ if (!(await holdsMarker(model, model.name, column))) {
594
+ throw new Error(`${model.name}.${name} holds no text marker; a pin gives a text column a known value, a seed every other column`)
595
+ }
596
+ const pinned = pinnedValue(value, world)
597
+ assigned.set(name, pinned)
598
+ markers.push(pinned)
599
+ }
600
+ const { row } = await insert(models, model.name, assigned, markers)
601
+ inserted.push({ table: model.name, match: new Map([[scope.key, key]]) })
602
+ const updates: Record<string, SampleValue> = {}
603
+ for (const name of namesOf(model.update)) {
604
+ updates[name] = row.get(name) ?? await updateSample(model.name, name)
605
+ }
606
+ const dimension = grantDimension(models, model)
607
+ const chain = [
608
+ ...(scope.mode === 'dimension' ? [{ type: scope.dimension, value: key }] : []),
609
+ ...links.flatMap((link) => {
610
+ const value = values.get(link.dimension)
611
+ return value === undefined ? [] : [{ type: link.dimension, value }]
612
+ }),
613
+ ]
614
+ return {
615
+ key,
616
+ parent: scope.mode === 'child' ? (values.get(links[0]?.dimension ?? '') ?? null) : null,
617
+ scope: values.get(dimension) ?? key,
618
+ chain,
619
+ updates,
620
+ markers,
621
+ }
622
+ }
623
+
624
+ return {
625
+ async seedWorlds(tables, table, values = {}, pins = {}) {
626
+ const { models, model } = scopedModel(tables, table)
627
+ const dimension = grantDimension(models, model)
628
+ const inserted: InsertedRow[] = []
629
+ return {
630
+ dimension,
631
+ own: await seedWorld(models, model, 'own', values, pins, inserted),
632
+ other: await seedWorld(models, model, 'other', values, pins, inserted),
633
+ remove: () => backend.remove(inserted),
634
+ }
635
+ },
636
+ async seedRow(tables, table, values = {}) {
637
+ const { models, model } = scopedModel(tables, table)
638
+ checkApplicationValues(models, { [table]: values })
639
+ const markers: string[] = []
640
+ const { key, row } = await seedApplicationRow(models, model, values, new Map(), markers)
641
+ const updates: Record<string, SampleValue> = {}
642
+ for (const name of namesOf(model.update)) {
643
+ updates[name] = row.get(name) ?? await updateSample(table, name)
644
+ }
645
+ return { key, updates, markers }
646
+ },
647
+ async seedApplicationRows(tables, rows) {
648
+ const models = tableModels(tables)
649
+ const plan = checkApplicationValues(models, rows)
650
+ const order = plan.order.flatMap((table) => modelOf(models, table) ?? [])
651
+ const inserted: InsertedRow[] = []
652
+ // Every row the fixture inserted: the given rows, the rows a foreign key or a file column of theirs made it
653
+ // insert, and every row that refers to one of them.
654
+ const remove = (): Promise<void> => backend.remove(inserted)
655
+ const stored = new Map<string, Readonly<Record<string, unknown>>>()
656
+ const keys: Record<string, string> = {}
657
+ try {
658
+ for (const model of order) {
659
+ const values = rows[model.name] ?? {}
660
+ const linked = new Map<string, SampleValue>()
661
+ for (const key of plan.links[model.name] ?? []) {
662
+ const referenced = stored.get(key.table)
663
+ if (referenced === undefined) continue
664
+ key.columns.forEach((column, index) => {
665
+ const name = key.references[index] ?? ''
666
+ linked.set(column, keyValue(referenced[name], `${key.table}.${name}`))
667
+ })
668
+ }
669
+ const seeded = await seedApplicationRow(models, model, values, linked, [], inserted)
670
+ stored.set(model.name, seeded.stored)
671
+ keys[model.name] = seeded.key
672
+ }
673
+ } catch (error) {
674
+ // A table that fails leaves none of the rows seeded before it for the next case.
675
+ try {
676
+ await remove()
677
+ } catch (cleanup) {
678
+ throw new AggregateError([error, cleanup], 'the isolation fixture could not seed the application rows, nor remove those it had seeded')
679
+ }
680
+ throw error
681
+ }
682
+ return { keys, remove }
683
+ },
684
+ async seedUnder(tables, table, under) {
685
+ const { models, model } = scopedModel(tables, table)
686
+ const scope = model.scope
687
+ const [object] = under.chain
688
+ if ((scope.mode !== 'dimension' && scope.mode !== 'child') || object === undefined) {
689
+ throw new Error(`${table} is a ${scope.mode} table; a row seeded under another lies in its chain`)
690
+ }
691
+ const values = new Map(under.chain.map((link) => [link.type, link.value]))
692
+ if (!model.chain.some((link) => link.dimension === object.type) || model.chain.some((link) => !values.has(link.dimension))) {
693
+ throw new Error(`${table} does not lie in a ${object.type}, so no row of it is seeded under one`)
694
+ }
695
+ const key = scopeValue(table, scope.key, scope.mode === 'dimension' ? scope.dimension : table, 'under')
696
+ const markers: string[] = []
697
+ await insert(models, table, placement(model, values, key), markers)
698
+ return { key, markers }
699
+ },
700
+ async sampleInsert(tables, table) {
701
+ const { models, model } = scopedModel(tables, table)
702
+ const values: Record<string, SampleValue> = {}
703
+ const columns = columnsOf(table)
704
+ const required = columns.filter((column) => inSet(model.insert, column.name) && isRequired(column))
705
+ for (const key of model.foreignKeys) {
706
+ const open = key.columns.filter((name) => required.some((column) => column.name === name))
707
+ if (open.length === 0) continue
708
+ for (const [name, value] of await referencedKey(models, table, key, open, 0)) values[name] = value
709
+ }
710
+ for (const column of required) {
711
+ const name = column.name
712
+ const kind = Object.hasOwn(model.files, name) ? model.files[name] : undefined
713
+ if (!(name in values)) values[name] = kind === undefined ? await requiredSample(table, column) : await seedFile(kind)
714
+ }
715
+ return values
716
+ },
717
+ seededObjects: () => [...seededObjects],
718
+ }
719
+ }
720
+
721
+ /**
722
+ * The untyped handle over one table of the data layer `scoped(tables)` created; it reaches the database once `boot`
723
+ * has bound that layer. Every runtime check of a store applies: kind, dimension, action and the request's authority.
724
+ * `module` is the kit's, which refuses a production process.
725
+ */
726
+ export function probeTable(module: string, layer: unknown, table: string): TableProbe {
727
+ refuseInProduction(module)
728
+ const side = frameworkSideOf(layer)
729
+ if (side === undefined) {
730
+ throw new TypeError('not a data layer scoped(tables) created')
731
+ }
732
+ return side.probe(table)
733
+ }