@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
package/src/testing.ts CHANGED
@@ -16,17 +16,42 @@ import { type ReservedSQL, SQL } from 'bun'
16
16
  import { createHash } from 'node:crypto'
17
17
  import { fileURLToPath } from 'node:url'
18
18
  import { z } from 'zod/v4'
19
- import { inSet, namesOf } from './builtins'
20
19
  import { type CatalogColumn, introspectSchema } from './codegen'
21
20
  import { applicationSchema, Database, openFirstConnection } from './database'
22
21
  import type { Primitive } from './description'
23
22
  import { FILES_TABLE, FRAMEWORK_MIGRATION_BUNDLE, FRAMEWORK_MIGRATIONS } from './migrations'
24
- import { type Container, type ForeignKey, modelOf, type TableModel, type TableModels, tableModels, type Tables } from './policy'
23
+ import { openPostgres } from './postgres'
25
24
  import { refuseInProduction } from './production'
26
- import { frameworkSideOf } from './registry'
27
25
  import type { TableProbe } from './store'
26
+ import {
27
+ type CheckConstraint,
28
+ checkLimit,
29
+ foreignKeyLimit,
30
+ type InsertedRow,
31
+ MAX_REMOVED_ROWS,
32
+ probeTable,
33
+ randomHex,
34
+ removalLimit,
35
+ type SeedColumn,
36
+ type SeedingBackend,
37
+ type SeedLimitError,
38
+ type TestDatabase,
39
+ testSeeding,
40
+ typeLimit,
41
+ } from './testing-shared'
28
42
 
29
43
  export { ChildTable, defineChildStore, defineStore, DimensionTable, type TableProbe } from './store'
44
+ export {
45
+ type ApplicationRow,
46
+ type IsolationWorlds,
47
+ type SampleValue,
48
+ SEEDED_OBJECT_VERSION,
49
+ type SeededApplicationRows,
50
+ type SeededRow,
51
+ SeedLimitError,
52
+ type TestDatabase,
53
+ type WorldRow,
54
+ } from './testing-shared'
30
55
 
31
56
  const MODULE = '@fabrika/db/testing'
32
57
  refuseInProduction(MODULE)
@@ -54,101 +79,8 @@ export interface TestDatabaseOptions {
54
79
  /** Set once a test database of this process gave up waiting for a connection slot. */
55
80
  let capacityExhausted: ConnectionCapacityError | undefined
56
81
 
57
- /** A value a test row holds in a column; `null` for a nullable column the fixture left empty. */
58
- export type SampleValue = Primitive | null
59
-
60
- /** One seeded row and where it lies. */
61
- export interface WorldRow {
62
- /** The row's key: the dimension value of a dimension table's row, a child's own key. */
63
- readonly key: string
64
- /** A child row's parent key; `null` for a dimension table. */
65
- readonly parent: string | null
66
- /** The value of the grant dimension the row lies in (`IsolationWorlds.dimension`). */
67
- readonly scope: string
68
- /**
69
- * Every dimension value the row lies in: a dimension table's own first, then each container, innermost first. A
70
- * share link's chain is this list.
71
- */
72
- readonly chain: readonly { readonly type: string; readonly value: string }[]
73
- /** The row's values of the policy's update columns, as seeded. */
74
- readonly updates: Readonly<Record<string, SampleValue>>
75
- /**
76
- * The distinct value written into every text column the fixture filled itself, in this row and in its containers.
77
- * Each occurs in no other fixture, so one found in an answer to another scope is a leaked row.
78
- */
79
- readonly markers: readonly string[]
80
- }
81
-
82
- /** One seeded row of an application table, which lies in no dimension. */
83
- export interface ApplicationRow {
84
- readonly key: string
85
- /** The row's values of the policy's update columns, as seeded. */
86
- readonly updates: Readonly<Record<string, SampleValue>>
87
- /** The distinct value written into every text column the fixture filled itself. */
88
- readonly markers: readonly string[]
89
- }
90
-
91
- /** A row seeded under another: its key, and the distinct value written into every text column the fixture filled. */
92
- export interface SeededRow {
93
- readonly key: string
94
- readonly markers: readonly string[]
95
- }
96
-
97
- /**
98
- * What the isolation fixture cannot seed for a schema: a CHECK its values fail, a required foreign key to a scoped table
99
- * outside the containment chain. It is a limit of the generator, named with the constraint or the column, never a
100
- * failure of the handler or the policy a case is about.
101
- */
102
- export class SeedLimitError extends Error {
103
- override readonly name = 'SeedLimitError'
104
- }
105
-
106
- /** One row of a table in each of two containment chains that share no value. */
107
- export interface IsolationWorlds {
108
- /**
109
- * The dimension a caller's grants are scoped by: a dimension table's container, or its own dimension when it has
110
- * none; a child table's parent's.
111
- */
112
- readonly dimension: string
113
- readonly own: WorldRow
114
- readonly other: WorldRow
115
- }
116
-
117
- export interface TestDatabase {
118
- /** The connection `boot` binds the data layer to: the runtime role, on the migrated schema. */
119
- readonly database: Database
120
- readonly schema: string
121
- /**
122
- * Seeds one row of `table` in each of two fresh chains, with every container row a table owns. `values` (column →
123
- * value) are written into both rows of `table`: the state an endpoint's precondition needs. A text column keeps its
124
- * marker, so a seed of one, or of a column that places the row, throws.
125
- */
126
- seedWorlds<DB, P>(tables: Tables<DB, P>, table: string, values?: Readonly<Record<string, SampleValue>>): Promise<IsolationWorlds>
127
- /** Seeds one row of the application table `table`: its key, version and creator stamped, every other column sampled. */
128
- seedRow<DB, P>(tables: Tables<DB, P>, table: string): Promise<ApplicationRow>
129
- /**
130
- * Seeds one row of `table`, a child or dimension table that lies in the object of `under` (a row `seedWorlds`
131
- * seeded), placed in that row's chain: the row a fixture names by id, such as a note's comment. Answers its key and
132
- * the markers it holds.
133
- */
134
- seedUnder<DB, P>(tables: Tables<DB, P>, table: string, under: WorldRow): Promise<SeededRow>
135
- /** Values for the policy's insert columns of `table` that its schema accepts: every required one, sampled; a file column a seeded file. */
136
- sampleInsert<DB, P>(tables: Tables<DB, P>, table: string): Promise<Readonly<Record<string, SampleValue>>>
137
- /**
138
- * The object key of every file the fixture seeded into a file column, oldest first. Each stands for an empty object,
139
- * which a test's object store holds when a handler serves or opens the file.
140
- */
141
- seededObjects(): readonly string[]
142
- /** Closes the connections and drops the schema and its roles. */
143
- close(): Promise<void>
144
- }
145
-
146
82
  const TEXT_TYPES: ReadonlySet<string> = new Set(['text', 'varchar', 'bpchar'])
147
83
 
148
- function randomHex(): string {
149
- return crypto.randomUUID().replaceAll('-', '').slice(0, 8)
150
- }
151
-
152
84
  function quoted(identifier: string): string {
153
85
  return `"${identifier}"`
154
86
  }
@@ -173,25 +105,21 @@ function sampleOf(table: string, column: CatalogColumn): Primitive {
173
105
  if (type === 'timestamp' || type === 'timestamptz') return new Date().toISOString()
174
106
  if (type === 'time' || type === 'timetz') return '12:00:00'
175
107
  if (type === 'interval') return '1 hour'
176
- throw new SeedLimitError(`the isolation fixture cannot fill ${table}.${column.column_name} (${type}); give the column a default or make it nullable`)
177
- }
178
-
179
- /** A text column the fixture writes a marker into, nullable or not, so that a leaked value is recognised anywhere. */
180
- function isMarkable(column: CatalogColumn): boolean {
181
- return column.type_type !== 'e' && TEXT_TYPES.has(column.type_name) && column.generated === 'no'
108
+ throw typeLimit(table, column.column_name, type)
182
109
  }
183
110
 
184
- function isRequired(column: CatalogColumn): boolean {
185
- return !column.nullable && column.generated === 'no'
186
- }
187
-
188
- /** A CHECK constraint of the schema, by the columns it names. */
189
- interface CheckConstraint {
190
- readonly name: string
191
- readonly table: string
192
- readonly columns: readonly string[]
193
- /** The values of a one-column `IN (…)` list the fixture writes in its place, first one first; empty for any other CHECK. */
194
- readonly values: readonly string[]
111
+ /** A catalog column as the seeding reads it. */
112
+ function seedColumn(column: CatalogColumn): SeedColumn {
113
+ return {
114
+ table: column.table_name,
115
+ name: column.column_name,
116
+ type: column.type_name,
117
+ nullable: column.nullable,
118
+ defaulted: column.generated !== 'no',
119
+ text: column.type_type !== 'e' && TEXT_TYPES.has(column.type_name),
120
+ uuid: column.type_name === 'uuid',
121
+ sample: () => sampleOf(column.table_name, column),
122
+ }
195
123
  }
196
124
 
197
125
  const checkRows = z.array(z.object({ name: z.string(), table_name: z.string(), definition: z.string(), columns: z.array(z.string()) }))
@@ -207,6 +135,27 @@ WHERE n.nspname = $1 AND c.contype = 'c'
207
135
  ORDER BY r.relname, c.conname
208
136
  `
209
137
 
138
+ const referrerRows = z.array(z.object({ table_name: z.string(), columns: z.array(z.string()), refs: z.array(z.string()) }))
139
+
140
+ /** Every foreign key of the schema's tables that references the table `$2`, with its columns and the ones it references, in order. */
141
+ const REFERRER_QUERY = `
142
+ SELECT r.relname::text AS table_name,
143
+ ARRAY(SELECT a.attname::text FROM unnest(c.conkey) WITH ORDINALITY k(attnum, ord) JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum ORDER BY k.ord) AS columns,
144
+ ARRAY(SELECT a.attname::text FROM unnest(c.confkey) WITH ORDINALITY k(attnum, ord) JOIN pg_attribute a ON a.attrelid = c.confrelid AND a.attnum = k.attnum ORDER BY k.ord) AS refs
145
+ FROM pg_constraint c
146
+ JOIN pg_class r ON r.oid = c.conrelid
147
+ JOIN pg_namespace n ON n.oid = r.relnamespace
148
+ JOIN pg_class t ON t.oid = c.confrelid
149
+ WHERE n.nspname = $1 AND t.relnamespace = n.oid AND t.relname = $2 AND c.contype = 'f'
150
+ ORDER BY r.relname, c.conname
151
+ `
152
+
153
+ // A row's ctid is its place, unique in a table: an application migration creates no partitioned table (ADR-0017).
154
+ const ctidRows = z.array(z.object({ ctid: z.string() }))
155
+
156
+ /** Ctids as one array literal parameter, `{"(0,1)","(0,2)"}`: the driver mis-binds a JS array. */
157
+ const tidArray = (ctids: Iterable<string>): string => `{${[...ctids].map((ctid) => `"${ctid}"`).join(',')}}`
158
+
210
159
  /** `CHECK ((status = ANY (ARRAY['open'::text, 'done'::text])))`, a one-column `IN` list as Postgres prints it. */
211
160
  const IN_LIST = /^CHECK \(\(\(?"?([^"()\s]+)"?\)?(?:::[a-z ]+)? = ANY \(\(?ARRAY\[(.+)\]\)?(?:::[a-z ]+\[\])?\)\)\)$/
212
161
  const LITERAL = /'((?:[^']|'')*)'::/g
@@ -218,71 +167,12 @@ function listedValues(definition: string, columns: readonly string[]): string[]
218
167
  return [...(listed[2] ?? '').matchAll(LITERAL)].map((match) => (match[1] ?? '').replaceAll("''", "'"))
219
168
  }
220
169
 
221
- /** A key value as the database returned it, for the foreign key that references it. */
222
- function keyValue(value: unknown, where: string): SampleValue {
223
- if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
224
- return value
225
- }
226
- throw new SeedLimitError(`the isolation fixture cannot reference ${where}: its key is no text, number or boolean`)
227
- }
228
-
229
- /** The dimension a caller's grants are scoped by, as `IsolationWorlds.dimension` describes. */
230
- function grantDimension(models: TableModels, model: TableModel): string {
231
- const scope = model.scope
232
- if (scope.mode === 'dimension') {
233
- return scope.container?.dimension ?? scope.dimension
234
- }
235
- if (scope.mode === 'child') {
236
- const parent = modelOf(models, scope.parent)
237
- if (parent?.scope.mode === 'dimension') {
238
- return parent.scope.container?.dimension ?? parent.scope.dimension
239
- }
240
- }
241
- throw new Error(`${model.name} is not a dimension or child table; no store reaches it, so it has no isolation tests`)
242
- }
243
-
244
- function scopedModel<DB, P>(tables: Tables<DB, P>, table: string): { readonly models: TableModels; readonly model: TableModel } {
245
- const models = tableModels(tables)
246
- const model = modelOf(models, table)
247
- if (model === undefined) {
248
- throw new Error(`${table} is not a table of the policies`)
249
- }
250
- return { models, model }
251
- }
252
-
253
- function ownerOf(models: TableModels, dimension: string): TableModel | undefined {
254
- for (const name of Object.keys(models)) {
255
- const model = modelOf(models, name)
256
- if (model?.scope.mode === 'dimension' && model.scope.dimension === dimension) {
257
- return model
258
- }
259
- }
260
- return undefined
261
- }
262
-
263
- /** How many referenced rows deep the fixture follows required foreign keys outside the chain before it gives up. */
264
- const MAX_REFERENCE_DEPTH = 4
265
- /** The digest of the empty object a seeded file stands for. */
266
- const EMPTY_SHA256 = createHash('sha256').digest('hex')
267
-
268
- /**
269
- * The object version a seeded file was scanned at: whoever stocks its empty object (`seededObjects`) stores it under
270
- * this version, so a file of a scanning kind is served as a live one is, by its scanned version.
271
- */
272
- export const SEEDED_OBJECT_VERSION = '"seeded"'
273
-
274
170
  /** A CHECK or foreign key violation of a seeded row, as the generator limit it is; `undefined` for any other failure. */
275
171
  function seedLimit(error: unknown, table: string): SeedLimitError | undefined {
276
172
  if (!(error instanceof SQL.PostgresError)) return undefined
277
173
  const constraint = error.constraint ?? 'unnamed'
278
- if (error.errno === '23514') {
279
- return new SeedLimitError(
280
- `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`,
281
- )
282
- }
283
- if (error.errno === '23503') {
284
- return new SeedLimitError(`the isolation fixture's row of ${table} breaks the foreign key ${constraint}`)
285
- }
174
+ if (error.errno === '23514') return checkLimit(constraint, table)
175
+ if (error.errno === '23503') return foreignKeyLimit(constraint, table)
286
176
  return undefined
287
177
  }
288
178
 
@@ -371,6 +261,7 @@ export async function testDatabase(options: TestDatabaseOptions): Promise<TestDa
371
261
  if (process.env[TEST_POSTGRES_REQUIRED] === '1') {
372
262
  throw new Error(`${options.label}: ${TEST_POSTGRES_REQUIRED}=1, but ${TEST_POSTGRES_URL} is unset`)
373
263
  }
264
+ // The Zerops build counts this line's text in every framework release's output: never reword it.
374
265
  console.warn(`${options.label} skipped: set ${TEST_POSTGRES_URL} to run the Postgres-backed tests`)
375
266
  return null
376
267
  }
@@ -386,322 +277,124 @@ export async function testDatabase(options: TestDatabaseOptions): Promise<TestDa
386
277
  throw new Error(`${options.label}: ${error.message}; its cases did not run`)
387
278
  }
388
279
  const { schema, lock, seeder, catalog } = opened
389
- let checks: Promise<readonly CheckConstraint[]> | undefined
390
- const seededObjects: string[] = []
391
-
392
- /** The CHECK constraints that name `column` of `table`. */
393
- async function checksOn(table: string, column: string): Promise<readonly CheckConstraint[]> {
394
- checks ??= lock.unsafe(CHECK_QUERY, [schema]).then((rows: unknown) =>
395
- checkRows.parse(rows).map((row) => ({
396
- name: row.name,
397
- table: row.table_name,
398
- columns: row.columns,
399
- values: listedValues(row.definition, row.columns),
400
- }))
401
- )
402
- return (await checks).filter((check) => check.table === table && check.columns.includes(column))
403
- }
404
-
405
- /**
406
- * What the fixture writes into a column a CHECK names: the first value of a one-column `IN` list, `null` where the
407
- * column admits it (a CHECK passes on null); `undefined` when no CHECK names it or none of those applies.
408
- */
409
- async function checkedValue(table: string, column: CatalogColumn): Promise<SampleValue | undefined> {
410
- const named = await checksOn(table, column.column_name)
411
- if (named.length === 0) return undefined
412
- const [listed] = named.flatMap((check) => check.values)
413
- if (listed !== undefined && named.every((check) => check.values.includes(listed))) return listed
414
- return column.nullable ? null : undefined
415
- }
416
-
417
- /** A required column's sample: an `IN` list's first value where a CHECK names one, else a sample of its type. */
418
- async function requiredSample(table: string, column: CatalogColumn): Promise<SampleValue> {
419
- return (await checkedValue(table, column)) ?? sampleOf(table, column)
420
- }
421
-
422
- async function columnsOf(table: string): Promise<readonly CatalogColumn[]> {
423
- const columns = catalog.filter((column) => column.table_name === table)
424
- if (columns.length === 0) {
425
- throw new Error(`${table} is not a table of the migrated schema; write its migration first`)
280
+ const referrers = new Map<string, Promise<z.infer<typeof referrerRows>>>()
281
+ /** The foreign keys that reference `table`, read from the catalog once. */
282
+ const referrersOf = (table: string): Promise<z.infer<typeof referrerRows>> => {
283
+ let known = referrers.get(table)
284
+ if (known === undefined) {
285
+ known = lock.unsafe(REFERRER_QUERY, [schema, table]).then((rows: unknown) => referrerRows.parse(rows))
286
+ referrers.set(table, known)
287
+ // A failed read is not kept: the next removal reads the catalog again.
288
+ known.catch(() => referrers.delete(table))
426
289
  }
427
- return columns
290
+ return known
428
291
  }
429
-
430
- async function columnOf(table: string, name: string): Promise<CatalogColumn> {
431
- const column = (await columnsOf(table)).find((candidate) => candidate.column_name === name)
432
- if (column === undefined) {
433
- throw new Error(`${table}.${name} is not a column of the migrated schema`)
434
- }
435
- return column
436
- }
437
-
438
- /** A column the seeded row left to its default: null only where the column admits it. */
439
- async function updateSample(table: string, name: string): Promise<SampleValue> {
440
- const column = await columnOf(table, name)
441
- return column.nullable ? null : requiredSample(table, column)
442
- }
443
-
444
- /** Whether the fixture writes a marker into `column`: text that holds no foreign key and that no CHECK names. */
445
- async function holdsMarker(model: TableModel | undefined, table: string, column: CatalogColumn): Promise<boolean> {
446
- const keyed = (model?.foreignKeys ?? []).some((key) => key.columns.includes(column.column_name))
447
- return isMarkable(column) && !keyed && (await checksOn(table, column.column_name)).length === 0
448
- }
449
-
450
292
  /**
451
- * The values of a foreign key outside the containment chain: `null` where every open column admits it, else the
452
- * key of a row of the referenced table, an existing one (a reference table's rows come from a migration) or one the
453
- * fixture inserts. A required key to a dimension or child table is refused: its row would lie in another chain.
293
+ * Deletes the rows `start` names (table → ctids) and every row that refers to one of them, however deep. Rows are
294
+ * found a level at a time, one statement per table and foreign key, by joining on the key's columns, so no value
295
+ * leaves the database and comes back as a parameter. Each found row is locked (`FOR UPDATE`) until the transaction
296
+ * ends: no write moves it to another ctid, and no new row refers to it, before the delete. All go in one statement,
297
+ * so rows that refer to each other (a reply its notice pins) go together: a foreign key is checked at its end.
454
298
  */
455
- async function referencedKey(models: TableModels, table: string, key: ForeignKey, open: readonly string[], depth: number) {
456
- const columns = await columnsOf(table)
457
- const values = new Map<string, SampleValue>()
458
- if (open.every((name) => columns.find((column) => column.column_name === name)?.nullable === true)) {
459
- for (const name of open) values.set(name, null)
460
- return values
461
- }
462
- const target = modelOf(models, key.table)
463
- const scoped = target?.scope.mode === 'dimension' || target?.scope.mode === 'child'
464
- if (scoped || open.length !== key.columns.length || key.table === table || depth >= MAX_REFERENCE_DEPTH) {
465
- throw new SeedLimitError(
466
- `the isolation fixture cannot fill ${table}.${
467
- open.join(', ')
468
- }: a required foreign key to ${key.table} outside the containment chain; give the column a default or make it nullable`,
469
- )
470
- }
471
- const selected: unknown = await seeder.unsafe(
472
- `SELECT ${key.references.map(quoted).join(', ')} FROM ${quoted(schema)}.${quoted(key.table)} LIMIT 1`,
473
- )
474
- const existing = z.array(z.record(z.string(), z.unknown())).parse(selected)[0]
475
- const row = existing ?? (await insert(models, key.table, new Map(), [], depth + 1)).stored
476
- key.columns.forEach((name, index) => {
477
- const referenced = key.references[index] ?? ''
478
- values.set(name, keyValue(row[referenced], `${key.table}.${referenced}`))
479
- })
480
- return values
481
- }
482
-
483
- /** A value of `dimension` for the column that carries it: a uuid, or text that names the world it belongs to. */
484
- async function scopeValue(table: string, column: string, dimension: string, world: string): Promise<string> {
485
- const type = (await columnOf(table, column)).type_name
486
- if (type === 'uuid') return crypto.randomUUID()
487
- if (TEXT_TYPES.has(type)) return `${dimension}-${world}-${randomHex()}`
488
- throw new Error(`${table}.${column} holds a ${dimension} as ${type}; a key or containment column holds its scope value as text or uuid`)
489
- }
490
-
491
- /** A live, empty file of `kind`, as the framework's put leaves one once a reference claims it: what a seeded file column holds. */
492
- async function seedFile(kind: string): Promise<string> {
493
- const id = crypto.randomUUID()
494
- const objectKey = `${kind}/${id}`
495
- seededObjects.push(objectKey)
496
- await seeder.unsafe(
497
- `INSERT INTO ${quoted(schema)}.${quoted(FILES_TABLE)} (id, kind, status, object_key, size, sha256, content_type, name, created_at, object_version)
498
- VALUES ($1, $2, 'live', $3, 0, $4, 'application/octet-stream', 'seeded', now(), $5)`,
499
- [id, kind, objectKey, EMPTY_SHA256, SEEDED_OBJECT_VERSION],
500
- )
501
- return id
502
- }
503
-
504
- /**
505
- * 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
506
- * names its `IN` list's first value or `null`, every other text column a marker and every other required column a
507
- * sample. Answers what the fixture wrote, and the row as stored (its database defaults filled). A CHECK or a foreign
508
- * key the values still fail is a `SeedLimitError` that names the constraint.
509
- */
510
- async function insert(
511
- models: TableModels,
512
- table: string,
513
- assigned: ReadonlyMap<string, SampleValue>,
514
- markers: string[],
515
- depth = 0,
516
- ): Promise<{ readonly row: ReadonlyMap<string, SampleValue>; readonly stored: Readonly<Record<string, unknown>> }> {
517
- const model = modelOf(models, table)
518
- const columns = await columnsOf(table)
519
- // A column with a database default keeps it unless the fixture assigned it, a foreign key's included.
520
- const defaulted = new Set(columns.filter((column) => column.generated !== 'no').map((column) => column.column_name))
521
- const keyed = new Map<string, SampleValue>()
522
- for (const key of model?.foreignKeys ?? []) {
523
- const open = key.columns.filter((column) => !assigned.has(column) && !defaulted.has(column))
524
- if (open.length === 0) continue
525
- for (const [name, value] of await referencedKey(models, table, key, open, depth)) keyed.set(name, value)
526
- }
527
- const row = new Map<string, SampleValue>()
528
- for (const column of columns) {
529
- const name = column.column_name
530
- // An assigned `null` is the fixture's value too, never a gap to fill.
531
- const given = assigned.has(name) ? assigned : keyed.has(name) ? keyed : undefined
532
- if (given !== undefined) {
533
- row.set(name, given.get(name) ?? null)
534
- continue
535
- }
536
- if (column.generated !== 'no') continue
537
- const kind = model !== undefined && Object.hasOwn(model.files, name) ? model.files[name] : undefined
538
- const checked = kind === undefined ? await checkedValue(table, column) : undefined
539
- if (kind !== undefined) {
540
- row.set(name, await seedFile(kind))
541
- } else if (checked !== undefined) {
542
- row.set(name, checked)
543
- } else if (await holdsMarker(model, table, column)) {
544
- const marker = `m${randomHex()}${randomHex()}`
545
- markers.push(marker)
546
- row.set(name, marker)
547
- } else {
548
- row.set(name, column.nullable ? null : sampleOf(table, column))
299
+ const removeRows = async (tx: SQL, start: ReadonlyMap<string, readonly string[]>): Promise<void> => {
300
+ const found = new Map<string, Set<string>>()
301
+ let count = 0
302
+ let level = new Map<string, string[]>()
303
+ const add = (table: string, ctid: string): void => {
304
+ let ctids = found.get(table)
305
+ if (ctids === undefined) {
306
+ ctids = new Set()
307
+ found.set(table, ctids)
549
308
  }
309
+ if (ctids.has(ctid)) return
310
+ if (++count > MAX_REMOVED_ROWS) throw removalLimit()
311
+ ctids.add(ctid)
312
+ level.set(table, [...(level.get(table) ?? []), ctid])
550
313
  }
551
- const names = [...row.keys()]
552
- const placeholders = names.map((_, index) => `$${index + 1}`)
553
- const target = `${quoted(schema)}.${quoted(table)}`
554
- const statement = names.length === 0
555
- ? `INSERT INTO ${target} DEFAULT VALUES RETURNING *`
556
- : `INSERT INTO ${target} (${names.map(quoted).join(', ')}) VALUES (${placeholders.join(', ')}) RETURNING *`
557
- let stored: unknown
558
- try {
559
- stored = await seeder.unsafe(statement, names.map((name) => row.get(name) ?? null))
560
- } catch (error) {
561
- throw seedLimit(error, table) ?? error
562
- }
563
- const [returned] = z.array(z.record(z.string(), z.unknown())).parse(stored)
564
- return { row, stored: returned ?? {} }
565
- }
566
-
567
- /** The columns a model's row is placed by: its own key when it owns a dimension, and each link of its chain. */
568
- function placement(model: TableModel, values: ReadonlyMap<string, string>, key: string): Map<string, SampleValue> {
569
- const assigned = new Map<string, SampleValue>()
570
- const scope = model.scope
571
- if (scope.mode === 'dimension' || scope.mode === 'child' || scope.mode === 'app') {
572
- assigned.set(scope.key, key)
573
- }
574
- for (const link of model.chain) {
575
- const value = values.get(link.dimension)
576
- if (value !== undefined) {
577
- assigned.set(link.column, value)
314
+ for (const [table, ctids] of start) for (const ctid of ctids) add(table, ctid)
315
+ while (level.size > 0) {
316
+ const current = level
317
+ level = new Map()
318
+ for (const [table, ctids] of current) {
319
+ const target = `${quoted(schema)}.${quoted(table)}`
320
+ for (const key of await referrersOf(table)) {
321
+ const on = key.columns.map((column, position) => `r.${quoted(column)} = t.${quoted(key.refs[position] ?? '')}`).join(' AND ')
322
+ const referring = ctidRows.parse(
323
+ await tx.unsafe(
324
+ `SELECT r.ctid::text AS ctid FROM ${quoted(schema)}.${
325
+ quoted(key.table_name)
326
+ } r JOIN ${target} t ON ${on} WHERE t.ctid = ANY($1::tid[]) FOR UPDATE OF r`,
327
+ [tidArray(ctids)],
328
+ ),
329
+ )
330
+ for (const referrer of referring) add(key.table_name, referrer.ctid)
331
+ }
578
332
  }
579
333
  }
580
- if (model.version !== undefined) {
581
- assigned.set(model.version, 1)
582
- }
583
- if (model.createdBy !== undefined) {
584
- assigned.set(model.createdBy, 'isolation-fixture')
585
- }
586
- return assigned
334
+ if (found.size === 0) return
335
+ const tables = [...found]
336
+ const deletes = tables.map(([table], index) =>
337
+ `d${index} AS (DELETE FROM ${quoted(schema)}.${quoted(table)} WHERE ctid = ANY($${index + 1}::tid[]))`
338
+ )
339
+ await tx.unsafe(`WITH ${deletes.join(', ')} SELECT 1`, tables.map(([, ctids]) => tidArray(ctids)))
587
340
  }
588
-
589
- async function seedWorld(models: TableModels, model: TableModel, world: string, seed: Readonly<Record<string, SampleValue>>): Promise<WorldRow> {
590
- const markers: string[] = []
591
- const values = new Map<string, string>()
592
- const links: readonly Container[] = model.chain
593
- for (const link of links) {
594
- values.set(link.dimension, await scopeValue(model.name, link.column, link.dimension, world))
595
- }
596
- const scope = model.scope
597
- if (scope.mode !== 'dimension' && scope.mode !== 'child') {
598
- throw new Error(`${model.name} is not a dimension or child table`)
599
- }
600
- const key = await scopeValue(model.name, scope.key, scope.mode === 'dimension' ? scope.dimension : model.name, world)
601
- if (scope.mode === 'dimension') {
602
- values.set(scope.dimension, key)
603
- }
604
- // Containers first, outermost first: a composite foreign key needs its target row.
605
- for (let index = links.length - 1; index >= 0; index--) {
606
- const link = links[index]
607
- const owner = link === undefined ? undefined : ownerOf(models, link.dimension)
608
- const value = link === undefined ? undefined : values.get(link.dimension)
609
- if (owner !== undefined && owner.name !== model.name && value !== undefined) {
610
- await insert(models, owner.name, placement(owner, values, value), markers)
611
- }
612
- }
613
- const assigned = placement(model, values, key)
614
- for (const [name, value] of Object.entries(seed)) {
615
- const column = await columnOf(model.name, name)
616
- if (assigned.has(name)) {
617
- throw new Error(`${model.name}.${name} places the row in its scope; a seed sets only the row's own state`)
618
- }
619
- if (await holdsMarker(model, model.name, column)) {
620
- throw new Error(
621
- `${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`,
622
- )
341
+ const backend: SeedingBackend = {
342
+ columns: catalog.map(seedColumn),
343
+ checks: async () =>
344
+ checkRows.parse(await lock.unsafe(CHECK_QUERY, [schema])).map((row): CheckConstraint => ({
345
+ name: row.name,
346
+ table: row.table_name,
347
+ columns: row.columns,
348
+ values: listedValues(row.definition, row.columns),
349
+ })),
350
+ async insert(table, row) {
351
+ const names = [...row.keys()]
352
+ const placeholders = names.map((_, index) => `$${index + 1}`)
353
+ const target = `${quoted(schema)}.${quoted(table)}`
354
+ const statement = names.length === 0
355
+ ? `INSERT INTO ${target} DEFAULT VALUES RETURNING *`
356
+ : `INSERT INTO ${target} (${names.map(quoted).join(', ')}) VALUES (${placeholders.join(', ')}) RETURNING *`
357
+ let stored: unknown
358
+ try {
359
+ stored = await seeder.unsafe(statement, names.map((name) => row.get(name) ?? null))
360
+ } catch (error) {
361
+ throw seedLimit(error, table) ?? error
623
362
  }
624
- assigned.set(name, value)
625
- }
626
- const { row } = await insert(models, model.name, assigned, markers)
627
- const updates: Record<string, SampleValue> = {}
628
- for (const name of namesOf(model.update)) {
629
- updates[name] = row.get(name) ?? await updateSample(model.name, name)
630
- }
631
- const dimension = grantDimension(models, model)
632
- const chain = [
633
- ...(scope.mode === 'dimension' ? [{ type: scope.dimension, value: key }] : []),
634
- ...links.flatMap((link) => {
635
- const value = values.get(link.dimension)
636
- return value === undefined ? [] : [{ type: link.dimension, value }]
637
- }),
638
- ]
639
- return {
640
- key,
641
- parent: scope.mode === 'child' ? (values.get(links[0]?.dimension ?? '') ?? null) : null,
642
- scope: values.get(dimension) ?? key,
643
- chain,
644
- updates,
645
- markers,
646
- }
363
+ const [returned] = z.array(z.record(z.string(), z.unknown())).parse(stored)
364
+ return returned ?? {}
365
+ },
366
+ async firstRow(table, columns) {
367
+ const selected: unknown = await seeder.unsafe(`SELECT ${columns.map(quoted).join(', ')} FROM ${quoted(schema)}.${quoted(table)} LIMIT 1`)
368
+ return z.array(z.record(z.string(), z.unknown())).parse(selected)[0]
369
+ },
370
+ async insertFile(file) {
371
+ await seeder.unsafe(
372
+ `INSERT INTO ${quoted(schema)}.${quoted(FILES_TABLE)} (id, kind, status, object_key, size, sha256, content_type, name, created_at, object_version)
373
+ VALUES ($1, $2, 'live', $3, 0, $4, 'application/octet-stream', 'seeded', now(), $5)`,
374
+ [file.id, file.kind, file.objectKey, file.sha256, file.objectVersion],
375
+ )
376
+ },
377
+ async remove(rows: readonly InsertedRow[]) {
378
+ await seeder.begin(async (tx) => {
379
+ const start = new Map<string, string[]>()
380
+ for (const row of rows) {
381
+ const where = [...row.match.keys()].map((column, index) => `${quoted(column)} = $${index + 1}`).join(' AND ')
382
+ const located = ctidRows.parse(
383
+ await tx.unsafe(`SELECT ctid::text AS ctid FROM ${quoted(schema)}.${quoted(row.table)} WHERE ${where} FOR UPDATE`, [
384
+ ...row.match.values(),
385
+ ]),
386
+ )
387
+ start.set(row.table, [...(start.get(row.table) ?? []), ...located.map((found) => found.ctid)])
388
+ }
389
+ await removeRows(tx, start)
390
+ })
391
+ },
647
392
  }
648
393
 
649
394
  return {
395
+ ...testSeeding(backend),
650
396
  database: opened.database,
651
397
  schema,
652
- async seedWorlds(tables, table, values = {}) {
653
- const { models, model } = scopedModel(tables, table)
654
- const dimension = grantDimension(models, model)
655
- return { dimension, own: await seedWorld(models, model, 'own', values), other: await seedWorld(models, model, 'other', values) }
656
- },
657
- async seedRow(tables, table) {
658
- const { models, model } = scopedModel(tables, table)
659
- if (model.scope.mode !== 'app') {
660
- throw new Error(`${table} is a ${model.scope.mode} table; seedWorlds seeds a dimension or child table`)
661
- }
662
- const markers: string[] = []
663
- const key = await scopeValue(table, model.scope.key, table, 'app')
664
- const { row } = await insert(models, table, placement(model, new Map(), key), markers)
665
- const updates: Record<string, SampleValue> = {}
666
- for (const name of namesOf(model.update)) {
667
- updates[name] = row.get(name) ?? await updateSample(table, name)
668
- }
669
- return { key, updates, markers }
670
- },
671
- async seedUnder(tables, table, under) {
672
- const { models, model } = scopedModel(tables, table)
673
- const scope = model.scope
674
- const [object] = under.chain
675
- if ((scope.mode !== 'dimension' && scope.mode !== 'child') || object === undefined) {
676
- throw new Error(`${table} is a ${scope.mode} table; a row seeded under another lies in its chain`)
677
- }
678
- const values = new Map(under.chain.map((link) => [link.type, link.value]))
679
- if (!model.chain.some((link) => link.dimension === object.type) || model.chain.some((link) => !values.has(link.dimension))) {
680
- throw new Error(`${table} does not lie in a ${object.type}, so no row of it is seeded under one`)
681
- }
682
- const key = await scopeValue(table, scope.key, scope.mode === 'dimension' ? scope.dimension : table, 'under')
683
- const markers: string[] = []
684
- await insert(models, table, placement(model, values, key), markers)
685
- return { key, markers }
686
- },
687
- async sampleInsert(tables, table) {
688
- const { models, model } = scopedModel(tables, table)
689
- const values: Record<string, SampleValue> = {}
690
- const columns = await columnsOf(table)
691
- const required = columns.filter((column) => inSet(model.insert, column.column_name) && isRequired(column))
692
- for (const key of model.foreignKeys) {
693
- const open = key.columns.filter((name) => required.some((column) => column.column_name === name))
694
- if (open.length === 0) continue
695
- for (const [name, value] of await referencedKey(models, table, key, open, 0)) values[name] = value
696
- }
697
- for (const column of required) {
698
- const name = column.column_name
699
- const kind = Object.hasOwn(model.files, name) ? model.files[name] : undefined
700
- if (!(name in values)) values[name] = kind === undefined ? await requiredSample(table, column) : await seedFile(kind)
701
- }
702
- return values
703
- },
704
- seededObjects: () => [...seededObjects],
705
398
  // Its own connections end first, so the drop finds a slot even in a database at its limit.
706
399
  close: () => closeTestDatabase(url, opened, (drop) => withConnectionCapacity(drop, { waitMs })),
707
400
  }
@@ -750,7 +443,7 @@ async function openTestDatabase(url: string, directory: string): Promise<OpenedT
750
443
  const runtimeUrl = applicationRuntimeUrl(url, appId)
751
444
  const admin = new SQL({ url, adapter: 'postgres', max: 1, prepare: false })
752
445
  const seeder = new SQL({ url: runtimeUrl, adapter: 'postgres', max: 1, prepare: false })
753
- const database = Database.postgres({ url: runtimeUrl, schema, max: RUNTIME_CONNECTIONS })
446
+ const database = openPostgres({ url: runtimeUrl, schema, max: RUNTIME_CONNECTIONS })
754
447
  let lock: ReservedSQL | undefined
755
448
  try {
756
449
  lock = await admin.reserve()
@@ -799,10 +492,5 @@ function closeTestDatabase(url: string, connections: TestConnections, dropping:
799
492
  * has bound that layer. Every runtime check of a store applies: kind, dimension, action and the request's authority.
800
493
  */
801
494
  export function tableProbe(layer: unknown, table: string): TableProbe {
802
- refuseInProduction(MODULE)
803
- const side = frameworkSideOf(layer)
804
- if (side === undefined) {
805
- throw new TypeError('not a data layer scoped(tables) created')
806
- }
807
- return side.probe(table)
495
+ return probeTable(MODULE, layer, table)
808
496
  }