@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/package.json +69 -5
- package/src/builtins.ts +94 -0
- package/src/capability.ts +93 -0
- package/src/codegen.ts +302 -0
- package/src/database.ts +413 -0
- package/src/description.ts +309 -0
- package/src/errors.ts +124 -0
- package/src/file-table-sql.ts +133 -0
- package/src/file-tables.ts +577 -0
- package/src/form-admissions.ts +82 -0
- package/src/framework-tables.ts +147 -0
- package/src/framework.ts +89 -0
- package/src/guard.ts +439 -0
- package/src/identifier.ts +43 -0
- package/src/index.ts +129 -0
- package/src/job-leases.ts +373 -0
- package/src/links.ts +61 -0
- package/src/migrations.ts +97 -0
- package/src/nodes.ts +344 -0
- package/src/orm-types.ts +596 -0
- package/src/orm.ts +891 -0
- package/src/policy.ts +1194 -0
- package/src/production.ts +26 -0
- package/src/registry.ts +57 -0
- package/src/sql.ts +732 -0
- package/src/store.ts +1693 -0
- package/src/tables.ts +167 -0
- package/src/temporal.ts +91 -0
- package/src/testing.ts +695 -0
- package/src/vocabulary-tables.ts +206 -0
- package/README.md +0 -3
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
|
+
}
|