@fabrika/db 0.0.0-stage → 0.0.1

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/testing.ts ADDED
@@ -0,0 +1,695 @@
1
+ // @fabrika/db/testing — the database test kit of an application, for test files only (`@fabrika/check` refuses it
2
+ // anywhere else). A test database is a fresh schema with its own role family, migrated by the real runner and used as
3
+ // its runtime role; the isolation fixtures seed rows in two disjoint containment chains, and the table probe reaches a
4
+ // table by name with every runtime check of a store.
5
+
6
+ import { applicationRuntimeUrl, definePostgresApplicationMigrations, dropApplicationDatabase } from '@fabrika/platform-node'
7
+ import { type ReservedSQL, SQL } from 'bun'
8
+ import { createHash } from 'node:crypto'
9
+ import { fileURLToPath } from 'node:url'
10
+ import { z } from 'zod/v4'
11
+ import { inSet, namesOf } from './builtins'
12
+ import { type CatalogColumn, introspectSchema } from './codegen'
13
+ import { applicationSchema, Database } from './database'
14
+ import type { Primitive } from './description'
15
+ import { FRAMEWORK_MIGRATION_BUNDLE, FRAMEWORK_MIGRATIONS } from './migrations'
16
+ import { type Container, type ForeignKey, modelOf, type TableModel, type TableModels, tableModels, type Tables } from './policy'
17
+ import { refuseInProduction } from './production'
18
+ import { frameworkSideOf } from './registry'
19
+ import type { TableProbe } from './store'
20
+
21
+ export { withoutInboxes } from './policy'
22
+ export { ChildTable, defineChildStore, defineStore, DimensionTable, type TableProbe } from './store'
23
+
24
+ const MODULE = '@fabrika/db/testing'
25
+ refuseInProduction(MODULE)
26
+ const release = process.env['FABRIKA_RELEASE']
27
+ if (release !== undefined && release !== '') {
28
+ throw new Error(`${MODULE} is a test helper and does not load in a release (FABRIKA_RELEASE is set)`)
29
+ }
30
+
31
+ export const TEST_POSTGRES_URL = 'FABRIKA_TEST_POSTGRES_URL'
32
+ /** Set to `1` by the platform build: a missing test database is then a failure, not a skip. */
33
+ export const TEST_POSTGRES_REQUIRED = 'FABRIKA_TEST_POSTGRES_REQUIRED'
34
+
35
+ export interface TestDatabaseOptions {
36
+ /** The application's `db/migrations/` directory. */
37
+ readonly migrations: string | URL
38
+ /** The test file, for the skip message. */
39
+ readonly label: string
40
+ }
41
+
42
+ /** A value a test row holds in a column; `null` for a nullable column the fixture left empty. */
43
+ export type SampleValue = Primitive | null
44
+
45
+ /** One seeded row and where it lies. */
46
+ export interface WorldRow {
47
+ /** The row's key: the dimension value of a dimension table's row, a child's own key. */
48
+ readonly key: string
49
+ /** A child row's parent key; `null` for a dimension table. */
50
+ readonly parent: string | null
51
+ /** The value of the grant dimension the row lies in (`IsolationWorlds.dimension`). */
52
+ readonly scope: string
53
+ /**
54
+ * Every dimension value the row lies in: a dimension table's own first, then each container, innermost first. A
55
+ * share link's chain is this list.
56
+ */
57
+ readonly chain: readonly { readonly type: string; readonly value: string }[]
58
+ /** The row's values of the policy's update columns, as seeded. */
59
+ readonly updates: Readonly<Record<string, SampleValue>>
60
+ /**
61
+ * The distinct value written into every text column the fixture filled itself, in this row and in its containers.
62
+ * Each occurs in no other fixture, so one found in an answer to another scope is a leaked row.
63
+ */
64
+ readonly markers: readonly string[]
65
+ }
66
+
67
+ /** One seeded row of an application table, which lies in no dimension. */
68
+ export interface ApplicationRow {
69
+ readonly key: string
70
+ /** The row's values of the policy's update columns, as seeded. */
71
+ readonly updates: Readonly<Record<string, SampleValue>>
72
+ /** The distinct value written into every text column the fixture filled itself. */
73
+ readonly markers: readonly string[]
74
+ }
75
+
76
+ /** A row seeded under another: its key, and the distinct value written into every text column the fixture filled. */
77
+ export interface SeededRow {
78
+ readonly key: string
79
+ readonly markers: readonly string[]
80
+ }
81
+
82
+ /**
83
+ * What the isolation fixture cannot seed for a schema: a CHECK its values fail, a required foreign key to a scoped table
84
+ * outside the containment chain. It is a limit of the generator, named with the constraint or the column, never a
85
+ * failure of the handler or the policy a case is about.
86
+ */
87
+ export class SeedLimitError extends Error {
88
+ override readonly name = 'SeedLimitError'
89
+ }
90
+
91
+ /** One row of a table in each of two containment chains that share no value. */
92
+ export interface IsolationWorlds {
93
+ /**
94
+ * The dimension a caller's grants are scoped by: a dimension table's container, or its own dimension when it has
95
+ * none; a child table's parent's.
96
+ */
97
+ readonly dimension: string
98
+ readonly own: WorldRow
99
+ readonly other: WorldRow
100
+ }
101
+
102
+ export interface TestDatabase {
103
+ /** The connection `boot` binds the data layer to: the runtime role, on the migrated schema. */
104
+ readonly database: Database
105
+ readonly schema: string
106
+ /**
107
+ * Seeds one row of `table` in each of two fresh chains, with every container row a table owns. `values` (column →
108
+ * value) are written into both rows of `table`: the state an endpoint's precondition needs. A text column keeps its
109
+ * marker, so a seed of one, or of a column that places the row, throws.
110
+ */
111
+ seedWorlds<DB, P>(tables: Tables<DB, P>, table: string, values?: Readonly<Record<string, SampleValue>>): Promise<IsolationWorlds>
112
+ /** Seeds one row of the application table `table`: its key, version and creator stamped, every other column sampled. */
113
+ seedRow<DB, P>(tables: Tables<DB, P>, table: string): Promise<ApplicationRow>
114
+ /**
115
+ * Seeds one row of `table`, a child or dimension table that lies in the object of `under` (a row `seedWorlds`
116
+ * seeded), placed in that row's chain: the row a fixture names by id, such as a note's comment. Answers its key and
117
+ * the markers it holds.
118
+ */
119
+ seedUnder<DB, P>(tables: Tables<DB, P>, table: string, under: WorldRow): Promise<SeededRow>
120
+ /** Values for the policy's insert columns of `table` that its schema accepts: every required one, sampled. */
121
+ sampleInsert<DB, P>(tables: Tables<DB, P>, table: string): Promise<Readonly<Record<string, SampleValue>>>
122
+ /** Closes the connections and drops the schema and its roles. */
123
+ close(): Promise<void>
124
+ }
125
+
126
+ const TEXT_TYPES: ReadonlySet<string> = new Set(['text', 'varchar', 'bpchar'])
127
+
128
+ function randomHex(): string {
129
+ return crypto.randomUUID().replaceAll('-', '').slice(0, 8)
130
+ }
131
+
132
+ function quoted(identifier: string): string {
133
+ return `"${identifier}"`
134
+ }
135
+
136
+ /** A sample for a required column the test does not care about, by the types `@fabrika/db/codegen` accepts. */
137
+ function sampleOf(table: string, column: CatalogColumn): Primitive {
138
+ if (column.type_type === 'e') {
139
+ const [first] = column.enum_labels
140
+ if (first !== undefined) {
141
+ return first
142
+ }
143
+ }
144
+ const type = column.type_name
145
+ if (type === 'uuid') return crypto.randomUUID()
146
+ if (TEXT_TYPES.has(type)) return `s${randomHex()}`
147
+ if (type === 'int2' || type === 'int4' || type === 'float4' || type === 'float8') return 1
148
+ if (type === 'int8' || type === 'numeric') return '1'
149
+ if (type === 'bool') return false
150
+ // ISO text, not a Date: the seed binds past the layer's compiler, where Bun.SQL would send Date#toString(). A day is
151
+ // `YYYY-MM-DD`, since the probe's insert sends it through the layer, which takes a `date` only so.
152
+ if (type === 'date') return new Date().toISOString().slice(0, 10)
153
+ if (type === 'timestamp' || type === 'timestamptz') return new Date().toISOString()
154
+ if (type === 'time' || type === 'timetz') return '12:00:00'
155
+ if (type === 'interval') return '1 hour'
156
+ throw new SeedLimitError(`the isolation fixture cannot fill ${table}.${column.column_name} (${type}); give the column a default or make it nullable`)
157
+ }
158
+
159
+ /** A text column the fixture writes a marker into, nullable or not, so that a leaked value is recognised anywhere. */
160
+ function isMarkable(column: CatalogColumn): boolean {
161
+ return column.type_type !== 'e' && TEXT_TYPES.has(column.type_name) && column.generated === 'no'
162
+ }
163
+
164
+ function isRequired(column: CatalogColumn): boolean {
165
+ return !column.nullable && column.generated === 'no'
166
+ }
167
+
168
+ /** A CHECK constraint of the schema, by the columns it names. */
169
+ interface CheckConstraint {
170
+ readonly name: string
171
+ readonly table: string
172
+ readonly columns: readonly string[]
173
+ /** The values of a one-column `IN (…)` list the fixture writes in its place, first one first; empty for any other CHECK. */
174
+ readonly values: readonly string[]
175
+ }
176
+
177
+ const checkRows = z.array(z.object({ name: z.string(), table_name: z.string(), definition: z.string(), columns: z.array(z.string()) }))
178
+
179
+ /** Every CHECK constraint of the schema's tables, with its definition as Postgres prints it. */
180
+ const CHECK_QUERY = `
181
+ SELECT c.conname::text AS name, r.relname::text AS table_name, pg_get_constraintdef(c.oid) AS definition,
182
+ ARRAY(SELECT a.attname::text FROM pg_attribute a WHERE a.attrelid = c.conrelid AND a.attnum = ANY (c.conkey) ORDER BY a.attnum) AS columns
183
+ FROM pg_constraint c
184
+ JOIN pg_class r ON r.oid = c.conrelid
185
+ JOIN pg_namespace n ON n.oid = r.relnamespace
186
+ WHERE n.nspname = $1 AND c.contype = 'c'
187
+ ORDER BY r.relname, c.conname
188
+ `
189
+
190
+ /** `CHECK ((status = ANY (ARRAY['open'::text, 'done'::text])))`, a one-column `IN` list as Postgres prints it. */
191
+ const IN_LIST = /^CHECK \(\(\(?"?([^"()\s]+)"?\)?(?:::[a-z ]+)? = ANY \(\(?ARRAY\[(.+)\]\)?(?:::[a-z ]+\[\])?\)\)\)$/
192
+ const LITERAL = /'((?:[^']|'')*)'::/g
193
+
194
+ /** The values of a one-column `IN` list, or none for any other CHECK. */
195
+ function listedValues(definition: string, columns: readonly string[]): string[] {
196
+ const listed = IN_LIST.exec(definition)
197
+ if (listed === null || columns.length !== 1 || listed[1] !== columns[0]) return []
198
+ return [...(listed[2] ?? '').matchAll(LITERAL)].map((match) => (match[1] ?? '').replaceAll("''", "'"))
199
+ }
200
+
201
+ /** A key value as the database returned it, for the foreign key that references it. */
202
+ function keyValue(value: unknown, where: string): SampleValue {
203
+ if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
204
+ return value
205
+ }
206
+ throw new SeedLimitError(`the isolation fixture cannot reference ${where}: its key is no text, number or boolean`)
207
+ }
208
+
209
+ /** The dimension a caller's grants are scoped by, as `IsolationWorlds.dimension` describes. */
210
+ function grantDimension(models: TableModels, model: TableModel): string {
211
+ const scope = model.scope
212
+ if (scope.mode === 'dimension') {
213
+ return scope.container?.dimension ?? scope.dimension
214
+ }
215
+ if (scope.mode === 'child') {
216
+ const parent = modelOf(models, scope.parent)
217
+ if (parent?.scope.mode === 'dimension') {
218
+ return parent.scope.container?.dimension ?? parent.scope.dimension
219
+ }
220
+ }
221
+ throw new Error(`${model.name} is not a dimension or child table; no store reaches it, so it has no isolation tests`)
222
+ }
223
+
224
+ function scopedModel<DB, P>(tables: Tables<DB, P>, table: string): { readonly models: TableModels; readonly model: TableModel } {
225
+ const models = tableModels(tables)
226
+ const model = modelOf(models, table)
227
+ if (model === undefined) {
228
+ throw new Error(`${table} is not a table of the policies`)
229
+ }
230
+ return { models, model }
231
+ }
232
+
233
+ function ownerOf(models: TableModels, dimension: string): TableModel | undefined {
234
+ for (const name of Object.keys(models)) {
235
+ const model = modelOf(models, name)
236
+ if (model?.scope.mode === 'dimension' && model.scope.dimension === dimension) {
237
+ return model
238
+ }
239
+ }
240
+ return undefined
241
+ }
242
+
243
+ /** How many referenced rows deep the fixture follows required foreign keys outside the chain before it gives up. */
244
+ const MAX_REFERENCE_DEPTH = 4
245
+
246
+ /** A CHECK or foreign key violation of a seeded row, as the generator limit it is; `undefined` for any other failure. */
247
+ function seedLimit(error: unknown, table: string): SeedLimitError | undefined {
248
+ if (!(error instanceof SQL.PostgresError)) return undefined
249
+ const constraint = error.constraint ?? 'unnamed'
250
+ if (error.errno === '23514') {
251
+ return new SeedLimitError(
252
+ `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`,
253
+ )
254
+ }
255
+ if (error.errno === '23503') {
256
+ return new SeedLimitError(`the isolation fixture's row of ${table} breaks the foreign key ${constraint}`)
257
+ }
258
+ return undefined
259
+ }
260
+
261
+ /** The throwaway schemas, `app_test_<16 hex>`, and their roles `app_test_<16 hex>` and `run_test_<16 hex>`; nothing else is ever reclaimed. */
262
+ const TEST_SCHEMA = /^app_test_[0-9a-f]{16}$/
263
+ const TEST_ROLE = /^(?:app|run)_(test_[0-9a-f]{16})$/
264
+ /**
265
+ * Why an abandoned family is left alone: `DROP ROLE` of a role that still owns something in another database of the
266
+ * cluster, and a family another provisioner created (a `test-isolation` run's), which only it administers.
267
+ */
268
+ const LEFT_TO_ITS_OWNER: ReadonlySet<unknown> = new Set(['2BP01', '42501'])
269
+
270
+ /** The session-level advisory lock that holds a throwaway schema: the first 48 bits of SHA-256 of its name, plus one. */
271
+ function schemaLockKey(schema: string): string {
272
+ return String(createHash('sha256').update(`fabrika-test-schema:${schema}`, 'utf8').digest().readUIntBE(0, 6) + 1)
273
+ }
274
+
275
+ /** `test-<16 hex>`, the application id whose schema is `app_test_<16 hex>`. */
276
+ function testAppId(schema: string): string {
277
+ return `test-${schema.slice('app_test_'.length)}`
278
+ }
279
+
280
+ const nameRows = z.array(z.object({ name: z.string() }))
281
+ const lockRows = z.array(z.object({ locked: z.boolean() }))
282
+
283
+ /**
284
+ * Drops every throwaway schema and role family whose lock can be taken: its run ended without `close` (a crash, a
285
+ * kill). A schema whose lock is held belongs to a live run, perhaps in another process, and is never touched. Roles are
286
+ * the cluster's, so a family whose schema lives in another database, or that another provisioner created, is left to
287
+ * whoever can drop it.
288
+ */
289
+ async function reclaimAbandonedSchemas(lock: ReservedSQL, admin: SQL, url: string): Promise<void> {
290
+ const found = nameRows.parse(
291
+ await admin.unsafe(
292
+ `SELECT nspname AS name FROM pg_namespace WHERE nspname LIKE 'app\\_test\\_%' UNION SELECT rolname FROM pg_roles WHERE rolname LIKE 'app\\_test\\_%' OR rolname LIKE 'run\\_test\\_%'`,
293
+ ),
294
+ )
295
+ const schemas = new Set<string>()
296
+ for (const { name } of found) {
297
+ const role = TEST_ROLE.exec(name)
298
+ const schema = role === null ? name : `app_${role[1]}`
299
+ if (TEST_SCHEMA.test(schema)) schemas.add(schema)
300
+ }
301
+ for (const schema of schemas) {
302
+ const key = schemaLockKey(schema)
303
+ const [taken] = lockRows.parse(await lock.unsafe('SELECT pg_try_advisory_lock($1::bigint) AS locked', [key]))
304
+ if (taken?.locked !== true) continue
305
+ try {
306
+ await dropApplicationDatabase(url, testAppId(schema))
307
+ } catch (error) {
308
+ if (!(error instanceof SQL.PostgresError && LEFT_TO_ITS_OWNER.has(error.errno))) throw error
309
+ } finally {
310
+ await lock.unsafe('SELECT pg_advisory_unlock($1::bigint)', [key])
311
+ }
312
+ }
313
+ }
314
+
315
+ /** Runs every step even when an earlier one throws, then throws what failed. */
316
+ async function settleAll(steps: readonly (() => Promise<void>)[]): Promise<void> {
317
+ const failures: unknown[] = []
318
+ for (const step of steps) {
319
+ try {
320
+ await step()
321
+ } catch (error) {
322
+ failures.push(error)
323
+ }
324
+ }
325
+ if (failures.length === 1) throw failures[0]
326
+ if (failures.length > 1) throw new AggregateError(failures, 'several cleanup steps failed')
327
+ }
328
+
329
+ function migrationsDirectory(migrations: string | URL): string {
330
+ return migrations instanceof URL ? fileURLToPath(migrations) : migrations
331
+ }
332
+
333
+ /**
334
+ * A fresh schema with the framework's and the application's migrations applied by the real runner (`@fabrika/platform-node`) as its
335
+ * migration role, connected and seeded as its runtime role, or `null` after the standard skip message when
336
+ * `FABRIKA_TEST_POSTGRES_URL` is unset. That URL is the provisioning credential, which must be able to create roles.
337
+ * With `FABRIKA_TEST_POSTGRES_REQUIRED=1` a missing URL throws instead. The URL is a credential: no message names it.
338
+ */
339
+ export async function testDatabase(options: TestDatabaseOptions): Promise<TestDatabase | null> {
340
+ refuseInProduction(MODULE)
341
+ const url = process.env[TEST_POSTGRES_URL] ?? ''
342
+ if (url === '') {
343
+ if (process.env[TEST_POSTGRES_REQUIRED] === '1') {
344
+ throw new Error(`${options.label}: ${TEST_POSTGRES_REQUIRED}=1, but ${TEST_POSTGRES_URL} is unset`)
345
+ }
346
+ console.warn(`${options.label} skipped: set ${TEST_POSTGRES_URL} to run the Postgres-backed tests`)
347
+ return null
348
+ }
349
+ const appId = `test-${randomHex()}${randomHex()}`
350
+ const schema = applicationSchema(appId)
351
+ // The framework's tables first, as `fabrika db migrate` applies them: a job's lease and a webhook's bindings live there.
352
+ const migrations = definePostgresApplicationMigrations({
353
+ appId,
354
+ directory: migrationsDirectory(options.migrations),
355
+ framework: { bundle: FRAMEWORK_MIGRATION_BUNDLE, migrations: FRAMEWORK_MIGRATIONS },
356
+ })
357
+ const runtimeUrl = applicationRuntimeUrl(url, appId)
358
+ const admin = new SQL({ url, adapter: 'postgres', max: 1, prepare: false })
359
+ const holder = new SQL({ url, adapter: 'postgres', max: 1, prepare: false })
360
+ const seeder = new SQL({ url: runtimeUrl, adapter: 'postgres', max: 1, prepare: false })
361
+ const lock = await holder.reserve()
362
+ const releaseLock = async (): Promise<void> => {
363
+ try {
364
+ await lock.unsafe('SELECT pg_advisory_unlock($1::bigint)', [schemaLockKey(schema)])
365
+ } finally {
366
+ lock.release()
367
+ }
368
+ }
369
+ const cleanup = (): Promise<void> =>
370
+ settleAll([() => seeder.close(), () => dropApplicationDatabase(url, appId), releaseLock, () => holder.close(), () => admin.close()])
371
+ try {
372
+ await reclaimAbandonedSchemas(lock, admin, url)
373
+ // Held for the schema's whole life, before the schema and its roles exist: a schema whose lock is free is abandoned.
374
+ await lock.unsafe('SELECT pg_advisory_lock($1::bigint)', [schemaLockKey(schema)])
375
+ await migrations.migrate(url)
376
+ } catch (error) {
377
+ // The roles, the schema and its ledger exist before a failing migration, so a failed start leaves them behind.
378
+ await cleanup()
379
+ throw error
380
+ }
381
+ const database = Database.postgres({ url: runtimeUrl, schema, max: 2 })
382
+ let catalog: Promise<readonly CatalogColumn[]> | undefined
383
+ let checks: Promise<readonly CheckConstraint[]> | undefined
384
+
385
+ /** The CHECK constraints that name `column` of `table`. */
386
+ async function checksOn(table: string, column: string): Promise<readonly CheckConstraint[]> {
387
+ checks ??= admin.unsafe(CHECK_QUERY, [schema]).then((rows: unknown) =>
388
+ checkRows.parse(rows).map((row) => ({
389
+ name: row.name,
390
+ table: row.table_name,
391
+ columns: row.columns,
392
+ values: listedValues(row.definition, row.columns),
393
+ }))
394
+ )
395
+ return (await checks).filter((check) => check.table === table && check.columns.includes(column))
396
+ }
397
+
398
+ /**
399
+ * What the fixture writes into a column a CHECK names: the first value of a one-column `IN` list, `null` where the
400
+ * column admits it (a CHECK passes on null); `undefined` when no CHECK names it or none of those applies.
401
+ */
402
+ async function checkedValue(table: string, column: CatalogColumn): Promise<SampleValue | undefined> {
403
+ const named = await checksOn(table, column.column_name)
404
+ if (named.length === 0) return undefined
405
+ const [listed] = named.flatMap((check) => check.values)
406
+ if (listed !== undefined && named.every((check) => check.values.includes(listed))) return listed
407
+ return column.nullable ? null : undefined
408
+ }
409
+
410
+ /** A required column's sample: an `IN` list's first value where a CHECK names one, else a sample of its type. */
411
+ async function requiredSample(table: string, column: CatalogColumn): Promise<SampleValue> {
412
+ return (await checkedValue(table, column)) ?? sampleOf(table, column)
413
+ }
414
+
415
+ async function columnsOf(table: string): Promise<readonly CatalogColumn[]> {
416
+ catalog ??= introspectSchema(url, schema, migrations.objects())
417
+ const columns = (await catalog).filter((column) => column.table_name === table)
418
+ if (columns.length === 0) {
419
+ throw new Error(`${table} is not a table of the migrated schema; write its migration first`)
420
+ }
421
+ return columns
422
+ }
423
+
424
+ async function columnOf(table: string, name: string): Promise<CatalogColumn> {
425
+ const column = (await columnsOf(table)).find((candidate) => candidate.column_name === name)
426
+ if (column === undefined) {
427
+ throw new Error(`${table}.${name} is not a column of the migrated schema`)
428
+ }
429
+ return column
430
+ }
431
+
432
+ /** A column the seeded row left to its default: null only where the column admits it. */
433
+ async function updateSample(table: string, name: string): Promise<SampleValue> {
434
+ const column = await columnOf(table, name)
435
+ return column.nullable ? null : requiredSample(table, column)
436
+ }
437
+
438
+ /** Whether the fixture writes a marker into `column`: text that holds no foreign key and that no CHECK names. */
439
+ async function holdsMarker(model: TableModel | undefined, table: string, column: CatalogColumn): Promise<boolean> {
440
+ const keyed = (model?.foreignKeys ?? []).some((key) => key.columns.includes(column.column_name))
441
+ return isMarkable(column) && !keyed && (await checksOn(table, column.column_name)).length === 0
442
+ }
443
+
444
+ /**
445
+ * The values of a foreign key outside the containment chain: `null` where every open column admits it, else the
446
+ * key of a row of the referenced table, an existing one (a reference table's rows come from a migration) or one the
447
+ * fixture inserts. A required key to a dimension or child table is refused: its row would lie in another chain.
448
+ */
449
+ async function referencedKey(models: TableModels, table: string, key: ForeignKey, open: readonly string[], depth: number) {
450
+ const columns = await columnsOf(table)
451
+ const values = new Map<string, SampleValue>()
452
+ if (open.every((name) => columns.find((column) => column.column_name === name)?.nullable === true)) {
453
+ for (const name of open) values.set(name, null)
454
+ return values
455
+ }
456
+ const target = modelOf(models, key.table)
457
+ const scoped = target?.scope.mode === 'dimension' || target?.scope.mode === 'child'
458
+ if (scoped || open.length !== key.columns.length || key.table === table || depth >= MAX_REFERENCE_DEPTH) {
459
+ throw new SeedLimitError(
460
+ `the isolation fixture cannot fill ${table}.${
461
+ open.join(', ')
462
+ }: a required foreign key to ${key.table} outside the containment chain; give the column a default or make it nullable`,
463
+ )
464
+ }
465
+ const selected: unknown = await seeder.unsafe(
466
+ `SELECT ${key.references.map(quoted).join(', ')} FROM ${quoted(schema)}.${quoted(key.table)} LIMIT 1`,
467
+ )
468
+ const existing = z.array(z.record(z.string(), z.unknown())).parse(selected)[0]
469
+ const row = existing ?? (await insert(models, key.table, new Map(), [], depth + 1)).stored
470
+ key.columns.forEach((name, index) => {
471
+ const referenced = key.references[index] ?? ''
472
+ values.set(name, keyValue(row[referenced], `${key.table}.${referenced}`))
473
+ })
474
+ return values
475
+ }
476
+
477
+ /** A value of `dimension` for the column that carries it: a uuid, or text that names the world it belongs to. */
478
+ async function scopeValue(table: string, column: string, dimension: string, world: string): Promise<string> {
479
+ const type = (await columnOf(table, column)).type_name
480
+ if (type === 'uuid') return crypto.randomUUID()
481
+ if (TEXT_TYPES.has(type)) return `${dimension}-${world}-${randomHex()}`
482
+ throw new Error(`${table}.${column} holds a ${dimension} as ${type}; a key or containment column holds its scope value as text or uuid`)
483
+ }
484
+
485
+ /**
486
+ * Inserts one row: `assigned` as given, a foreign key outside the chain `null` or referencing a row, a column a CHECK
487
+ * names its `IN` list's first value or `null`, every other text column a marker and every other required column a
488
+ * sample. Answers what the fixture wrote, and the row as stored (its database defaults filled). A CHECK or a foreign
489
+ * key the values still fail is a `SeedLimitError` that names the constraint.
490
+ */
491
+ async function insert(
492
+ models: TableModels,
493
+ table: string,
494
+ assigned: ReadonlyMap<string, SampleValue>,
495
+ markers: string[],
496
+ depth = 0,
497
+ ): Promise<{ readonly row: ReadonlyMap<string, SampleValue>; readonly stored: Readonly<Record<string, unknown>> }> {
498
+ const model = modelOf(models, table)
499
+ const columns = await columnsOf(table)
500
+ // A column with a database default keeps it unless the fixture assigned it, a foreign key's included.
501
+ const defaulted = new Set(columns.filter((column) => column.generated !== 'no').map((column) => column.column_name))
502
+ const keyed = new Map<string, SampleValue>()
503
+ for (const key of model?.foreignKeys ?? []) {
504
+ const open = key.columns.filter((column) => !assigned.has(column) && !defaulted.has(column))
505
+ if (open.length === 0) continue
506
+ for (const [name, value] of await referencedKey(models, table, key, open, depth)) keyed.set(name, value)
507
+ }
508
+ const row = new Map<string, SampleValue>()
509
+ for (const column of columns) {
510
+ const name = column.column_name
511
+ // An assigned `null` is the fixture's value too, never a gap to fill.
512
+ const given = assigned.has(name) ? assigned : keyed.has(name) ? keyed : undefined
513
+ if (given !== undefined) {
514
+ row.set(name, given.get(name) ?? null)
515
+ continue
516
+ }
517
+ if (column.generated !== 'no') continue
518
+ const checked = await checkedValue(table, column)
519
+ if (checked !== undefined) {
520
+ row.set(name, checked)
521
+ } else if (await holdsMarker(model, table, column)) {
522
+ const marker = `m${randomHex()}${randomHex()}`
523
+ markers.push(marker)
524
+ row.set(name, marker)
525
+ } else {
526
+ row.set(name, column.nullable ? null : sampleOf(table, column))
527
+ }
528
+ }
529
+ const names = [...row.keys()]
530
+ const placeholders = names.map((_, index) => `$${index + 1}`)
531
+ const target = `${quoted(schema)}.${quoted(table)}`
532
+ const statement = names.length === 0
533
+ ? `INSERT INTO ${target} DEFAULT VALUES RETURNING *`
534
+ : `INSERT INTO ${target} (${names.map(quoted).join(', ')}) VALUES (${placeholders.join(', ')}) RETURNING *`
535
+ let stored: unknown
536
+ try {
537
+ stored = await seeder.unsafe(statement, names.map((name) => row.get(name) ?? null))
538
+ } catch (error) {
539
+ throw seedLimit(error, table) ?? error
540
+ }
541
+ const [returned] = z.array(z.record(z.string(), z.unknown())).parse(stored)
542
+ return { row, stored: returned ?? {} }
543
+ }
544
+
545
+ /** The columns a model's row is placed by: its own key when it owns a dimension, and each link of its chain. */
546
+ function placement(model: TableModel, values: ReadonlyMap<string, string>, key: string): Map<string, SampleValue> {
547
+ const assigned = new Map<string, SampleValue>()
548
+ const scope = model.scope
549
+ if (scope.mode === 'dimension' || scope.mode === 'child' || scope.mode === 'app') {
550
+ assigned.set(scope.key, key)
551
+ }
552
+ for (const link of model.chain) {
553
+ const value = values.get(link.dimension)
554
+ if (value !== undefined) {
555
+ assigned.set(link.column, value)
556
+ }
557
+ }
558
+ if (model.version !== undefined) {
559
+ assigned.set(model.version, 1)
560
+ }
561
+ if (model.createdBy !== undefined) {
562
+ assigned.set(model.createdBy, 'isolation-fixture')
563
+ }
564
+ return assigned
565
+ }
566
+
567
+ async function seedWorld(models: TableModels, model: TableModel, world: string, seed: Readonly<Record<string, SampleValue>>): Promise<WorldRow> {
568
+ const markers: string[] = []
569
+ const values = new Map<string, string>()
570
+ const links: readonly Container[] = model.chain
571
+ for (const link of links) {
572
+ values.set(link.dimension, await scopeValue(model.name, link.column, link.dimension, world))
573
+ }
574
+ const scope = model.scope
575
+ if (scope.mode !== 'dimension' && scope.mode !== 'child') {
576
+ throw new Error(`${model.name} is not a dimension or child table`)
577
+ }
578
+ const key = await scopeValue(model.name, scope.key, scope.mode === 'dimension' ? scope.dimension : model.name, world)
579
+ if (scope.mode === 'dimension') {
580
+ values.set(scope.dimension, key)
581
+ }
582
+ // Containers first, outermost first: a composite foreign key needs its target row.
583
+ for (let index = links.length - 1; index >= 0; index--) {
584
+ const link = links[index]
585
+ const owner = link === undefined ? undefined : ownerOf(models, link.dimension)
586
+ const value = link === undefined ? undefined : values.get(link.dimension)
587
+ if (owner !== undefined && owner.name !== model.name && value !== undefined) {
588
+ await insert(models, owner.name, placement(owner, values, value), markers)
589
+ }
590
+ }
591
+ const assigned = placement(model, values, key)
592
+ for (const [name, value] of Object.entries(seed)) {
593
+ const column = await columnOf(model.name, name)
594
+ if (assigned.has(name)) {
595
+ throw new Error(`${model.name}.${name} places the row in its scope; a seed sets only the row's own state`)
596
+ }
597
+ if (await holdsMarker(model, model.name, column)) {
598
+ throw new Error(
599
+ `${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`,
600
+ )
601
+ }
602
+ assigned.set(name, value)
603
+ }
604
+ const { row } = await insert(models, model.name, assigned, markers)
605
+ const updates: Record<string, SampleValue> = {}
606
+ for (const name of namesOf(model.update)) {
607
+ updates[name] = row.get(name) ?? await updateSample(model.name, name)
608
+ }
609
+ const dimension = grantDimension(models, model)
610
+ const chain = [
611
+ ...(scope.mode === 'dimension' ? [{ type: scope.dimension, value: key }] : []),
612
+ ...links.flatMap((link) => {
613
+ const value = values.get(link.dimension)
614
+ return value === undefined ? [] : [{ type: link.dimension, value }]
615
+ }),
616
+ ]
617
+ return {
618
+ key,
619
+ parent: scope.mode === 'child' ? (values.get(links[0]?.dimension ?? '') ?? null) : null,
620
+ scope: values.get(dimension) ?? key,
621
+ chain,
622
+ updates,
623
+ markers,
624
+ }
625
+ }
626
+
627
+ return {
628
+ database,
629
+ schema,
630
+ async seedWorlds(tables, table, values = {}) {
631
+ const { models, model } = scopedModel(tables, table)
632
+ const dimension = grantDimension(models, model)
633
+ return { dimension, own: await seedWorld(models, model, 'own', values), other: await seedWorld(models, model, 'other', values) }
634
+ },
635
+ async seedRow(tables, table) {
636
+ const { models, model } = scopedModel(tables, table)
637
+ if (model.scope.mode !== 'app') {
638
+ throw new Error(`${table} is a ${model.scope.mode} table; seedWorlds seeds a dimension or child table`)
639
+ }
640
+ const markers: string[] = []
641
+ const key = await scopeValue(table, model.scope.key, table, 'app')
642
+ const { row } = await insert(models, table, placement(model, new Map(), key), markers)
643
+ const updates: Record<string, SampleValue> = {}
644
+ for (const name of namesOf(model.update)) {
645
+ updates[name] = row.get(name) ?? await updateSample(table, name)
646
+ }
647
+ return { key, updates, markers }
648
+ },
649
+ async seedUnder(tables, table, under) {
650
+ const { models, model } = scopedModel(tables, table)
651
+ const scope = model.scope
652
+ const [object] = under.chain
653
+ if ((scope.mode !== 'dimension' && scope.mode !== 'child') || object === undefined) {
654
+ throw new Error(`${table} is a ${scope.mode} table; a row seeded under another lies in its chain`)
655
+ }
656
+ const values = new Map(under.chain.map((link) => [link.type, link.value]))
657
+ if (!model.chain.some((link) => link.dimension === object.type) || model.chain.some((link) => !values.has(link.dimension))) {
658
+ throw new Error(`${table} does not lie in a ${object.type}, so no row of it is seeded under one`)
659
+ }
660
+ const key = await scopeValue(table, scope.key, scope.mode === 'dimension' ? scope.dimension : table, 'under')
661
+ const markers: string[] = []
662
+ await insert(models, table, placement(model, values, key), markers)
663
+ return { key, markers }
664
+ },
665
+ async sampleInsert(tables, table) {
666
+ const { models, model } = scopedModel(tables, table)
667
+ const values: Record<string, SampleValue> = {}
668
+ const columns = await columnsOf(table)
669
+ const required = columns.filter((column) => inSet(model.insert, column.column_name) && isRequired(column))
670
+ for (const key of model.foreignKeys) {
671
+ const open = key.columns.filter((name) => required.some((column) => column.column_name === name))
672
+ if (open.length === 0) continue
673
+ for (const [name, value] of await referencedKey(models, table, key, open, 0)) values[name] = value
674
+ }
675
+ for (const column of required) {
676
+ if (!(column.column_name in values)) values[column.column_name] = await requiredSample(table, column)
677
+ }
678
+ return values
679
+ },
680
+ close: () => settleAll([() => database.close(), cleanup]),
681
+ }
682
+ }
683
+
684
+ /**
685
+ * The untyped handle over one table of the data layer `scoped(tables)` created; it reaches the database once `boot`
686
+ * has bound that layer. Every runtime check of a store applies: kind, dimension, action and the request's authority.
687
+ */
688
+ export function tableProbe(layer: unknown, table: string): TableProbe {
689
+ refuseInProduction(MODULE)
690
+ const side = frameworkSideOf(layer)
691
+ if (side === undefined) {
692
+ throw new TypeError('not a data layer scoped(tables) created')
693
+ }
694
+ return side.probe(table)
695
+ }