@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
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 {
|
|
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
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
|
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
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
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
|
|
456
|
-
const
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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 (
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
]
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
}
|