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