@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,513 @@
|
|
|
1
|
+
// @fabrika/db/testing-sqlite — the database test kit of an application on SQLite (Cloudflare, ADR-0065), for test files
|
|
2
|
+
// only. A test database is a fresh in-memory bun:sqlite database shaped like a Durable Object's storage, migrated by
|
|
3
|
+
// the object's own runner and opened with the object's engine; the isolation fixtures and the table probe are
|
|
4
|
+
// `@fabrika/db/testing`'s. No backend is needed, so it never skips. There is no role separation on SQLite: the fixture
|
|
5
|
+
// seeds through the same storage the application writes.
|
|
6
|
+
|
|
7
|
+
import { APPLICATION_MIGRATION_BUNDLE } from '@fabrika/platform-node'
|
|
8
|
+
import { Database as BunDatabase } from 'bun:sqlite'
|
|
9
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
import { z } from 'zod/v4'
|
|
13
|
+
import type { Primitive } from './description'
|
|
14
|
+
import { FILES_TABLE, FRAMEWORK_MIGRATION_BUNDLE } from './migrations'
|
|
15
|
+
import { SQLITE_FRAMEWORK_MIGRATIONS, SQLITE_NOW_FORMAT } from './migrations-sqlite'
|
|
16
|
+
import { refuseInProduction } from './production'
|
|
17
|
+
import { openSqlite, type SqliteValue } from './sqlite'
|
|
18
|
+
import { bunSqliteStorage } from './sqlite-bun'
|
|
19
|
+
import { migrateSqlite, type SqliteMigrationBundle } from './sqlite-migrate'
|
|
20
|
+
import type { TableProbe } from './store'
|
|
21
|
+
import {
|
|
22
|
+
type CheckConstraint,
|
|
23
|
+
checkLimit,
|
|
24
|
+
foreignKeyLimit,
|
|
25
|
+
type InsertedRow,
|
|
26
|
+
MAX_REMOVED_ROWS,
|
|
27
|
+
probeTable,
|
|
28
|
+
randomHex,
|
|
29
|
+
removalLimit,
|
|
30
|
+
type SampleValue,
|
|
31
|
+
type SeedColumn,
|
|
32
|
+
type SeedingBackend,
|
|
33
|
+
SeedLimitError,
|
|
34
|
+
type TestDatabase,
|
|
35
|
+
testSeeding,
|
|
36
|
+
typeLimit,
|
|
37
|
+
} from './testing-shared'
|
|
38
|
+
|
|
39
|
+
export { ChildTable, defineChildStore, defineStore, DimensionTable, type TableProbe } from './store'
|
|
40
|
+
export {
|
|
41
|
+
type ApplicationRow,
|
|
42
|
+
type IsolationWorlds,
|
|
43
|
+
type SampleValue,
|
|
44
|
+
SEEDED_OBJECT_VERSION,
|
|
45
|
+
type SeededApplicationRows,
|
|
46
|
+
type SeededRow,
|
|
47
|
+
SeedLimitError,
|
|
48
|
+
type TestDatabase,
|
|
49
|
+
type WorldRow,
|
|
50
|
+
} from './testing-shared'
|
|
51
|
+
|
|
52
|
+
const MODULE = '@fabrika/db/testing-sqlite'
|
|
53
|
+
refuseInProduction(MODULE)
|
|
54
|
+
const release = process.env['FABRIKA_RELEASE']
|
|
55
|
+
if (release !== undefined && release !== '') {
|
|
56
|
+
throw new Error(`${MODULE} is a test helper and does not load in a release (FABRIKA_RELEASE is set)`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface TestDatabaseOptions {
|
|
60
|
+
/** The application's `db/migrations/` directory. */
|
|
61
|
+
readonly migrations: string | URL
|
|
62
|
+
/** The test file. `@fabrika/db/testing` names it when it skips; this kit never skips. */
|
|
63
|
+
readonly label: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type Exec = (query: string, ...bindings: SqliteValue[]) => Record<string, SqliteValue>[]
|
|
67
|
+
|
|
68
|
+
const TEXT_TYPES: ReadonlySet<string> = new Set(['TEXT', 'VARCHAR', 'CHAR'])
|
|
69
|
+
const NUMBER_TYPES: ReadonlySet<string> = new Set(['INTEGER', 'INT', 'BIGINT', 'SMALLINT', 'REAL', 'DOUBLE', 'FLOAT'])
|
|
70
|
+
const TIMESTAMP_TYPES: ReadonlySet<string> = new Set(['TIMESTAMP', 'TIMESTAMPTZ', 'DATETIME'])
|
|
71
|
+
|
|
72
|
+
/** A declared type without its length or precision (`VARCHAR(80)` → `VARCHAR`), as `@fabrika/db/codegen-sqlite` reads it. */
|
|
73
|
+
function baseType(declared: string): string {
|
|
74
|
+
return declared.trim().toUpperCase().replace(/\s*\(.*\)$/s, '')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A sample for a required column the test does not care about, by the declared types `@fabrika/db/codegen-sqlite` accepts. */
|
|
78
|
+
function sampleOf(table: string, column: string, declared: string): Primitive {
|
|
79
|
+
const type = baseType(declared)
|
|
80
|
+
if (type === 'UUID') return crypto.randomUUID()
|
|
81
|
+
if (TEXT_TYPES.has(type)) return `s${randomHex()}`
|
|
82
|
+
if (NUMBER_TYPES.has(type)) return 1
|
|
83
|
+
if (type === 'BOOLEAN') return false
|
|
84
|
+
// A day is `YYYY-MM-DD` and a timestamp ISO text in UTC: the forms the layer writes and reads on SQLite.
|
|
85
|
+
if (type === 'DATE') return new Date().toISOString().slice(0, 10)
|
|
86
|
+
if (TIMESTAMP_TYPES.has(type)) return new Date().toISOString()
|
|
87
|
+
throw typeLimit(table, column, declared)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const tableRows = z.array(z.object({ name: z.string(), wr: z.number() }))
|
|
91
|
+
const columnRows = z.array(
|
|
92
|
+
z.object({ name: z.string(), type: z.string(), notnull: z.number(), dflt_value: z.string().nullable(), pk: z.number(), hidden: z.number() }),
|
|
93
|
+
)
|
|
94
|
+
const schemaRows = z.array(z.object({ name: z.string(), sql: z.string() }))
|
|
95
|
+
|
|
96
|
+
/** Every column of the database's ordinary tables. */
|
|
97
|
+
function readColumns(exec: Exec): SeedColumn[] {
|
|
98
|
+
const tables = tableRows.parse(exec(`SELECT name, wr FROM pragma_table_list WHERE schema = 'main' AND type = 'table' ORDER BY name`))
|
|
99
|
+
.filter((table) => !table.name.startsWith('sqlite_'))
|
|
100
|
+
const columns: SeedColumn[] = []
|
|
101
|
+
for (const table of tables) {
|
|
102
|
+
const declared = columnRows.parse(exec('SELECT name, type, "notnull", dflt_value, pk, hidden FROM pragma_table_xinfo(?) ORDER BY cid', table.name))
|
|
103
|
+
const keyColumns = declared.filter((column) => column.pk > 0)
|
|
104
|
+
for (const column of declared) {
|
|
105
|
+
const type = baseType(column.type)
|
|
106
|
+
// An INTEGER PRIMARY KEY of a rowid table is the rowid: never null, assigned when an insert omits it.
|
|
107
|
+
const rowid = table.wr === 0 && keyColumns.length === 1 && column.pk === 1 && type === 'INTEGER'
|
|
108
|
+
columns.push({
|
|
109
|
+
table: table.name,
|
|
110
|
+
name: column.name,
|
|
111
|
+
type: column.type,
|
|
112
|
+
// SQLite lets a primary key other than the rowid hold NULL unless it says NOT NULL; a seeded row's key never does.
|
|
113
|
+
nullable: !rowid && column.notnull === 0 && column.pk === 0,
|
|
114
|
+
defaulted: rowid || column.dflt_value !== null || column.hidden === 2 || column.hidden === 3,
|
|
115
|
+
text: TEXT_TYPES.has(type),
|
|
116
|
+
uuid: type === 'UUID',
|
|
117
|
+
sample: () => sampleOf(table.name, column.name, column.type),
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return columns
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** One token of a `CREATE TABLE` text: a bare word, a quoted name, a string literal's value, or anything else. */
|
|
125
|
+
interface Token {
|
|
126
|
+
readonly kind: 'word' | 'name' | 'string' | 'other'
|
|
127
|
+
readonly text: string
|
|
128
|
+
readonly start: number
|
|
129
|
+
readonly end: number
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const QUOTE_ENDS: ReadonlyMap<string, string> = new Map([
|
|
133
|
+
["'", "'"],
|
|
134
|
+
['"', '"'],
|
|
135
|
+
['`', '`'],
|
|
136
|
+
['[', ']'],
|
|
137
|
+
])
|
|
138
|
+
const WORD = /[A-Za-z_\u0080-][A-Za-z0-9_$\u0080-]*/y
|
|
139
|
+
const NUMBER = /(?:[0-9]|\.[0-9])[0-9A-Za-z_.]*/y
|
|
140
|
+
const SPACE = /\s+/y
|
|
141
|
+
|
|
142
|
+
/** The tokens of a statement as SQLite reads it, comments and white space dropped. */
|
|
143
|
+
function tokenize(sql: string): Token[] {
|
|
144
|
+
const tokens: Token[] = []
|
|
145
|
+
let index = 0
|
|
146
|
+
const sticky = (pattern: RegExp): string | undefined => {
|
|
147
|
+
pattern.lastIndex = index
|
|
148
|
+
return pattern.exec(sql)?.[0]
|
|
149
|
+
}
|
|
150
|
+
while (index < sql.length) {
|
|
151
|
+
const start = index
|
|
152
|
+
const char = sql.charAt(index)
|
|
153
|
+
const space = sticky(SPACE)
|
|
154
|
+
if (space !== undefined) {
|
|
155
|
+
index += space.length
|
|
156
|
+
} else if (sql.startsWith('--', index)) {
|
|
157
|
+
const end = sql.indexOf('\n', index)
|
|
158
|
+
index = end === -1 ? sql.length : end + 1
|
|
159
|
+
} else if (sql.startsWith('/*', index)) {
|
|
160
|
+
const end = sql.indexOf('*/', index + 2)
|
|
161
|
+
index = end === -1 ? sql.length : end + 2
|
|
162
|
+
} else if (QUOTE_ENDS.has(char)) {
|
|
163
|
+
const close = QUOTE_ENDS.get(char) ?? char
|
|
164
|
+
let text = ''
|
|
165
|
+
index += 1
|
|
166
|
+
for (;;) {
|
|
167
|
+
const end = sql.indexOf(close, index)
|
|
168
|
+
if (end === -1) throw new Error('an unterminated quote in the schema; SQLite stored a statement it could not have parsed')
|
|
169
|
+
text += sql.slice(index, end)
|
|
170
|
+
index = end + 1
|
|
171
|
+
if (close === ']' || sql.charAt(index) !== close) break
|
|
172
|
+
text += close
|
|
173
|
+
index += 1
|
|
174
|
+
}
|
|
175
|
+
tokens.push({ kind: char === "'" ? 'string' : 'name', text, start, end: index })
|
|
176
|
+
} else {
|
|
177
|
+
const word = sticky(WORD)
|
|
178
|
+
const number = word === undefined ? sticky(NUMBER) : undefined
|
|
179
|
+
const text = word ?? number ?? char
|
|
180
|
+
index += text.length
|
|
181
|
+
// `X'00'` is a blob literal, not the column x.
|
|
182
|
+
const blob = word !== undefined && word.toUpperCase() === 'X' && sql.charAt(index) === "'"
|
|
183
|
+
tokens.push({ kind: word !== undefined && !blob ? 'word' : 'other', text, start, end: index })
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return tokens
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function isWord(token: Token | undefined, keyword: string): boolean {
|
|
190
|
+
return token?.kind === 'word' && token.text.toUpperCase() === keyword
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isPunctuation(token: Token | undefined, text: string): boolean {
|
|
194
|
+
return token?.kind === 'other' && token.text === text
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The index of the parenthesis that closes the one at `open`, or -1. */
|
|
198
|
+
function closing(tokens: readonly Token[], open: number): number {
|
|
199
|
+
let depth = 0
|
|
200
|
+
for (let index = open; index < tokens.length; index++) {
|
|
201
|
+
if (isPunctuation(tokens[index], '(')) depth++
|
|
202
|
+
if (isPunctuation(tokens[index], ')') && --depth === 0) return index
|
|
203
|
+
}
|
|
204
|
+
return -1
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The definitions of a `CREATE TABLE` body: each column and each table constraint, as its tokens. */
|
|
208
|
+
function definitions(tokens: readonly Token[]): Token[][] {
|
|
209
|
+
const open = tokens.findIndex((token) => isPunctuation(token, '('))
|
|
210
|
+
const close = open === -1 ? -1 : closing(tokens, open)
|
|
211
|
+
if (close === -1) return []
|
|
212
|
+
const parts: Token[][] = [[]]
|
|
213
|
+
let depth = 0
|
|
214
|
+
for (const token of tokens.slice(open + 1, close)) {
|
|
215
|
+
if (isPunctuation(token, '(')) depth++
|
|
216
|
+
if (isPunctuation(token, ')')) depth--
|
|
217
|
+
if (depth === 0 && isPunctuation(token, ',')) {
|
|
218
|
+
parts.push([])
|
|
219
|
+
} else {
|
|
220
|
+
parts.at(-1)?.push(token)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return parts.filter((part) => part.length > 0)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const TABLE_CONSTRAINTS: ReadonlySet<string> = new Set(['CONSTRAINT', 'CHECK', 'PRIMARY', 'UNIQUE', 'FOREIGN'])
|
|
227
|
+
|
|
228
|
+
/** The values of `<column> IN ('a', 'b', …)`, parenthesized or not, or none for any other expression. */
|
|
229
|
+
function listedValues(expression: readonly Token[], column: string): string[] {
|
|
230
|
+
let tokens = expression
|
|
231
|
+
while (isPunctuation(tokens[0], '(') && closing(tokens, 0) === tokens.length - 1) {
|
|
232
|
+
tokens = tokens.slice(1, -1)
|
|
233
|
+
}
|
|
234
|
+
// The subject may be parenthesized too: `(("status")) IN (…)`.
|
|
235
|
+
let depth = 0
|
|
236
|
+
while (isPunctuation(tokens[depth], '(')) depth++
|
|
237
|
+
const subject = tokens[depth]
|
|
238
|
+
for (let index = 1; index <= depth; index++) {
|
|
239
|
+
if (!isPunctuation(tokens[depth + index], ')')) return []
|
|
240
|
+
}
|
|
241
|
+
const list = depth * 2 + 1
|
|
242
|
+
const named = subject?.kind === 'word' || subject?.kind === 'name'
|
|
243
|
+
if (!named || subject.text.toLowerCase() !== column.toLowerCase() || !isWord(tokens[list], 'IN') || !isPunctuation(tokens[list + 1], '(')) return []
|
|
244
|
+
if (!isPunctuation(tokens.at(-1), ')') || closing(tokens, list + 1) !== tokens.length - 1) return []
|
|
245
|
+
const items = tokens.slice(list + 2, -1)
|
|
246
|
+
const values: string[] = []
|
|
247
|
+
for (let index = 0; index < items.length; index++) {
|
|
248
|
+
const item = items[index]
|
|
249
|
+
if (index % 2 === 1) {
|
|
250
|
+
if (!isPunctuation(item, ',')) return []
|
|
251
|
+
} else if (item?.kind === 'string') {
|
|
252
|
+
values.push(item.text)
|
|
253
|
+
} else {
|
|
254
|
+
return []
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return items.length % 2 === 1 ? values : []
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Every CHECK of a table as `sqlite_schema` stores its `CREATE TABLE`: a column's and the table's, by the columns it names. */
|
|
261
|
+
function tableChecks(table: string, sql: string, columns: readonly string[]): CheckConstraint[] {
|
|
262
|
+
const byName = new Map(columns.map((name) => [name.toLowerCase(), name]))
|
|
263
|
+
const checks: CheckConstraint[] = []
|
|
264
|
+
for (const definition of definitions(tokenize(sql))) {
|
|
265
|
+
// A column definition starts with its name, which no CHECK is.
|
|
266
|
+
const first = definition[0]
|
|
267
|
+
const start = first?.kind === 'word' && TABLE_CONSTRAINTS.has(first.text.toUpperCase()) ? 0 : 1
|
|
268
|
+
let depth = 0
|
|
269
|
+
for (let index = start; index < definition.length; index++) {
|
|
270
|
+
const token = definition[index]
|
|
271
|
+
if (isPunctuation(token, '(')) depth++
|
|
272
|
+
if (isPunctuation(token, ')')) depth--
|
|
273
|
+
if (depth !== 0 || !isWord(token, 'CHECK') || !isPunctuation(definition[index + 1], '(')) continue
|
|
274
|
+
const close = closing(definition, index + 1)
|
|
275
|
+
if (close === -1) break
|
|
276
|
+
const expression = definition.slice(index + 2, close)
|
|
277
|
+
const named = new Set<string>()
|
|
278
|
+
expression.forEach((part, at) => {
|
|
279
|
+
const column = part.kind === 'name' || (part.kind === 'word' && !isPunctuation(expression[at + 1], '('))
|
|
280
|
+
? byName.get(part.text.toLowerCase())
|
|
281
|
+
: undefined
|
|
282
|
+
if (column !== undefined) named.add(column)
|
|
283
|
+
})
|
|
284
|
+
const constraint = isWord(definition[index - 2], 'CONSTRAINT') ? definition[index - 1]?.text : undefined
|
|
285
|
+
const [only] = named
|
|
286
|
+
const from = expression[0]?.start ?? 0
|
|
287
|
+
const to = expression.at(-1)?.end ?? 0
|
|
288
|
+
checks.push({
|
|
289
|
+
// SQLite names an unnamed CHECK by its expression's text in the error it fails with.
|
|
290
|
+
name: constraint ?? sql.slice(from, to),
|
|
291
|
+
table,
|
|
292
|
+
columns: [...named],
|
|
293
|
+
values: named.size === 1 && only !== undefined ? listedValues(expression, only) : [],
|
|
294
|
+
})
|
|
295
|
+
index = close
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return checks
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const foreignKeyRows = z.array(z.object({ id: z.number(), seq: z.number(), table: z.string(), from: z.string(), to: z.string().nullable() }))
|
|
302
|
+
const keyRows = z.array(z.object({ name: z.string(), pk: z.number() }))
|
|
303
|
+
const rowidRows = z.array(z.object({ id: z.number() }))
|
|
304
|
+
|
|
305
|
+
/** A foreign key of `table` and the columns of the table it references, in order. */
|
|
306
|
+
interface Referrer {
|
|
307
|
+
readonly table: string
|
|
308
|
+
readonly columns: readonly string[]
|
|
309
|
+
readonly refs: readonly string[]
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Every foreign key of the database's tables, by the table it references; a key that names no columns references its primary key. */
|
|
313
|
+
function readReferrers(exec: Exec): ReadonlyMap<string, readonly Referrer[]> {
|
|
314
|
+
const tables = tableRows.parse(exec(`SELECT name, wr FROM pragma_table_list WHERE schema = 'main' AND type = 'table' ORDER BY name`))
|
|
315
|
+
.filter((table) => !table.name.startsWith('sqlite_'))
|
|
316
|
+
const referrers = new Map<string, Referrer[]>()
|
|
317
|
+
for (const table of tables) {
|
|
318
|
+
const keys = new Map<number, { table: string; columns: string[]; refs: (string | null)[] }>()
|
|
319
|
+
for (
|
|
320
|
+
const row of foreignKeyRows.parse(exec('SELECT id, seq, "table", "from", "to" FROM pragma_foreign_key_list(?) ORDER BY id, seq', table.name))
|
|
321
|
+
) {
|
|
322
|
+
const key = keys.get(row.id) ?? { table: row.table, columns: [], refs: [] }
|
|
323
|
+
key.columns.push(row.from)
|
|
324
|
+
key.refs.push(row.to)
|
|
325
|
+
keys.set(row.id, key)
|
|
326
|
+
}
|
|
327
|
+
for (const key of keys.values()) {
|
|
328
|
+
const primary = keyRows.parse(exec('SELECT name, pk FROM pragma_table_info(?) WHERE pk > 0 ORDER BY pk', key.table)).map((column) => column.name)
|
|
329
|
+
const refs = key.refs.map((ref, index) => ref ?? primary[index] ?? '')
|
|
330
|
+
referrers.set(key.table, [...(referrers.get(key.table) ?? []), { table: table.name, columns: key.columns, refs }])
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return referrers
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Every CHECK constraint of the database's tables. */
|
|
337
|
+
function readChecks(exec: Exec, columns: readonly SeedColumn[]): CheckConstraint[] {
|
|
338
|
+
const tables = schemaRows.parse(exec(`SELECT name, sql FROM sqlite_schema WHERE type = 'table' AND sql IS NOT NULL ORDER BY name`))
|
|
339
|
+
return tables
|
|
340
|
+
.filter((table) => !table.name.startsWith('sqlite_'))
|
|
341
|
+
.flatMap((table) => tableChecks(table.name, table.sql, columns.filter((column) => column.table === table.name).map((column) => column.name)))
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** A value the seeding writes, as a Durable Object binds it: a boolean 0 or 1, a `Date` its ISO text. */
|
|
345
|
+
function binding(value: SampleValue): SqliteValue {
|
|
346
|
+
if (typeof value === 'boolean') return value ? 1 : 0
|
|
347
|
+
if (value instanceof Date) {
|
|
348
|
+
if (Number.isNaN(value.getTime())) throw new SeedLimitError('the isolation fixture cannot write an invalid Date')
|
|
349
|
+
return value.toISOString()
|
|
350
|
+
}
|
|
351
|
+
if (typeof value === 'bigint') {
|
|
352
|
+
const number = Number(value)
|
|
353
|
+
if (!Number.isSafeInteger(number)) throw new SeedLimitError('the isolation fixture cannot write a bigint outside the safe integer range on SQLite')
|
|
354
|
+
return number
|
|
355
|
+
}
|
|
356
|
+
return value
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** A CHECK or foreign key violation of a seeded row, as the generator limit it is; `undefined` for any other failure. */
|
|
360
|
+
function seedLimit(error: unknown, table: string): SeedLimitError | undefined {
|
|
361
|
+
const message = error instanceof Error ? error.message : ''
|
|
362
|
+
const check = /^CHECK constraint failed: (.*)$/s.exec(message)
|
|
363
|
+
if (check !== null) return checkLimit(check[1] ?? 'unnamed', table)
|
|
364
|
+
// SQLite names no foreign key in its error.
|
|
365
|
+
if (message.startsWith('FOREIGN KEY constraint failed')) return foreignKeyLimit('unnamed', table)
|
|
366
|
+
return undefined
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function quoted(identifier: string): string {
|
|
370
|
+
return `"${identifier.replaceAll('"', '""')}"`
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function migrationsDirectory(migrations: string | URL): string {
|
|
374
|
+
return migrations instanceof URL ? fileURLToPath(migrations) : migrations
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** The application's migrations: every `.sql` file of the directory by name, as `fabrika app build` bundles them for the object. */
|
|
378
|
+
function applicationMigrations(directory: string): SqliteMigrationBundle['migrations'] {
|
|
379
|
+
if (!existsSync(directory)) {
|
|
380
|
+
throw new Error(`No migrations directory at ${directory}; create the first migration with fabrika db new <slug>`)
|
|
381
|
+
}
|
|
382
|
+
const names = readdirSync(directory).filter((name) => name.endsWith('.sql')).sort((left, right) => left < right ? -1 : left > right ? 1 : 0)
|
|
383
|
+
return names.map((name) => ({ name, sql: readFileSync(join(directory, name), 'utf8') }))
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* A fresh in-memory database with the framework's and the application's migrations applied by the runner a Durable
|
|
388
|
+
* Object boots with (`@fabrika/db/sqlite-migrate`), opened with its engine (`@fabrika/db/sqlite`). Never `null`: it
|
|
389
|
+
* needs no backend, so it never skips; its type is still assignable to `@fabrika/db/testing`'s, so a generated test
|
|
390
|
+
* that checks for `null` compiles against both kits.
|
|
391
|
+
*/
|
|
392
|
+
export async function testDatabase(options: TestDatabaseOptions): Promise<TestDatabase> {
|
|
393
|
+
refuseInProduction(MODULE)
|
|
394
|
+
const migrations = applicationMigrations(migrationsDirectory(options.migrations))
|
|
395
|
+
const sqlite = new BunDatabase(':memory:')
|
|
396
|
+
const storage = bunSqliteStorage(sqlite)
|
|
397
|
+
try {
|
|
398
|
+
await migrateSqlite({ exec: storage.sql.exec, transactionSync: (fn) => storage.transactionSync(fn) }, [
|
|
399
|
+
{ bundle: FRAMEWORK_MIGRATION_BUNDLE, migrations: SQLITE_FRAMEWORK_MIGRATIONS },
|
|
400
|
+
{ bundle: APPLICATION_MIGRATION_BUNDLE, migrations },
|
|
401
|
+
])
|
|
402
|
+
} catch (error) {
|
|
403
|
+
sqlite.close()
|
|
404
|
+
throw error
|
|
405
|
+
}
|
|
406
|
+
const exec: Exec = (query, ...bindings) => storage.sql.exec(query, ...bindings).toArray()
|
|
407
|
+
const columns = readColumns(exec)
|
|
408
|
+
const withoutRowid = new Set(
|
|
409
|
+
tableRows.parse(exec(`SELECT name, wr FROM pragma_table_list WHERE schema = 'main' AND type = 'table' AND wr = 1`)).map((table) => table.name),
|
|
410
|
+
)
|
|
411
|
+
const referrers = readReferrers(exec)
|
|
412
|
+
/** A removal finds a table's rows again by their rowid; a table without one is a limit of the fixture. */
|
|
413
|
+
const requireRowid = (table: string): void => {
|
|
414
|
+
if (withoutRowid.has(table)) {
|
|
415
|
+
throw new SeedLimitError(`the isolation fixture cannot remove the rows of ${table}, a WITHOUT ROWID table; declare it with a rowid`)
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
const backend: SeedingBackend = {
|
|
419
|
+
columns,
|
|
420
|
+
checks: async () => readChecks(exec, columns),
|
|
421
|
+
async insert(table, row) {
|
|
422
|
+
const names = [...row.keys()]
|
|
423
|
+
const target = quoted(table)
|
|
424
|
+
const statement = names.length === 0
|
|
425
|
+
? `INSERT INTO ${target} DEFAULT VALUES RETURNING *`
|
|
426
|
+
: `INSERT INTO ${target} (${names.map(quoted).join(', ')}) VALUES (${names.map(() => '?').join(', ')}) RETURNING *`
|
|
427
|
+
try {
|
|
428
|
+
return exec(statement, ...names.map((name) => binding(row.get(name) ?? null)))[0] ?? {}
|
|
429
|
+
} catch (error) {
|
|
430
|
+
throw seedLimit(error, table) ?? error
|
|
431
|
+
}
|
|
432
|
+
},
|
|
433
|
+
async firstRow(table, selected) {
|
|
434
|
+
return exec(`SELECT ${selected.map(quoted).join(', ')} FROM ${quoted(table)} LIMIT 1`)[0]
|
|
435
|
+
},
|
|
436
|
+
async insertFile(file) {
|
|
437
|
+
exec(
|
|
438
|
+
`INSERT INTO ${quoted(FILES_TABLE)} (id, kind, status, object_key, size, sha256, content_type, name, created_at, object_version)
|
|
439
|
+
VALUES (?, ?, 'live', ?, 0, ?, 'application/octet-stream', 'seeded', strftime('${SQLITE_NOW_FORMAT}', 'now'), ?)`,
|
|
440
|
+
file.id,
|
|
441
|
+
file.kind,
|
|
442
|
+
file.objectKey,
|
|
443
|
+
file.sha256,
|
|
444
|
+
file.objectVersion,
|
|
445
|
+
)
|
|
446
|
+
},
|
|
447
|
+
async remove(rows: readonly InsertedRow[]) {
|
|
448
|
+
// One object, one writer: nothing writes between finding the rows and deleting them. The foreign keys are checked
|
|
449
|
+
// at the commit, so rows that refer to each other go together.
|
|
450
|
+
storage.transactionSync(() => {
|
|
451
|
+
exec('PRAGMA defer_foreign_keys = ON')
|
|
452
|
+
const found = new Map<string, Set<number>>()
|
|
453
|
+
let count = 0
|
|
454
|
+
let level = new Map<string, number[]>()
|
|
455
|
+
const add = (table: string, rowid: number): void => {
|
|
456
|
+
let rowids = found.get(table)
|
|
457
|
+
if (rowids === undefined) {
|
|
458
|
+
rowids = new Set()
|
|
459
|
+
found.set(table, rowids)
|
|
460
|
+
}
|
|
461
|
+
if (rowids.has(rowid)) return
|
|
462
|
+
if (++count > MAX_REMOVED_ROWS) throw removalLimit()
|
|
463
|
+
rowids.add(rowid)
|
|
464
|
+
level.set(table, [...(level.get(table) ?? []), rowid])
|
|
465
|
+
}
|
|
466
|
+
for (const row of rows) {
|
|
467
|
+
const where = [...row.match.keys()].map((column) => `${quoted(column)} = ?`).join(' AND ')
|
|
468
|
+
requireRowid(row.table)
|
|
469
|
+
const located = rowidRows.parse(exec(`SELECT rowid AS id FROM ${quoted(row.table)} WHERE ${where}`, ...[...row.match.values()].map(binding)))
|
|
470
|
+
for (const { id } of located) add(row.table, id)
|
|
471
|
+
}
|
|
472
|
+
while (level.size > 0) {
|
|
473
|
+
const current = level
|
|
474
|
+
level = new Map()
|
|
475
|
+
for (const [table, rowids] of current) {
|
|
476
|
+
for (const key of referrers.get(table) ?? []) {
|
|
477
|
+
requireRowid(key.table)
|
|
478
|
+
const on = key.columns.map((column, position) => `r.${quoted(column)} = t.${quoted(key.refs[position] ?? '')}`).join(' AND ')
|
|
479
|
+
const referring = rowidRows.parse(
|
|
480
|
+
exec(
|
|
481
|
+
`SELECT r.rowid AS id FROM ${quoted(key.table)} r JOIN ${quoted(table)} t ON ${on} WHERE t.rowid IN (SELECT value FROM json_each(?))`,
|
|
482
|
+
JSON.stringify(rowids),
|
|
483
|
+
),
|
|
484
|
+
)
|
|
485
|
+
for (const referrer of referring) add(key.table, referrer.id)
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
for (const [table, rowids] of found) {
|
|
490
|
+
exec(`DELETE FROM ${quoted(table)} WHERE rowid IN (SELECT value FROM json_each(?))`, JSON.stringify([...rowids]))
|
|
491
|
+
}
|
|
492
|
+
})
|
|
493
|
+
},
|
|
494
|
+
}
|
|
495
|
+
const database = openSqlite(storage)
|
|
496
|
+
return {
|
|
497
|
+
...testSeeding(backend),
|
|
498
|
+
database,
|
|
499
|
+
schema: 'main',
|
|
500
|
+
async close() {
|
|
501
|
+
await database.close()
|
|
502
|
+
sqlite.close()
|
|
503
|
+
},
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* The untyped handle over one table of the data layer `scoped(tables)` created; it reaches the database once `boot`
|
|
509
|
+
* has bound that layer. Every runtime check of a store applies: kind, dimension, action and the request's authority.
|
|
510
|
+
*/
|
|
511
|
+
export function tableProbe(layer: unknown, table: string): TableProbe {
|
|
512
|
+
return probeTable(MODULE, layer, table)
|
|
513
|
+
}
|