@fabrika/db 0.4.6 → 0.4.8
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 +3 -3
- package/src/database.ts +13 -0
- package/src/testing.ts +121 -40
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/db",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -61,8 +61,8 @@
|
|
|
61
61
|
"test": "bun test"
|
|
62
62
|
},
|
|
63
63
|
"dependencies": {
|
|
64
|
-
"@fabrika/auth-core": "
|
|
65
|
-
"@fabrika/platform-node": "
|
|
64
|
+
"@fabrika/auth-core": "0.4.8",
|
|
65
|
+
"@fabrika/platform-node": "0.4.8",
|
|
66
66
|
"kysely": "0.29.6",
|
|
67
67
|
"kysely-postgres-js": "5.0.1",
|
|
68
68
|
"zod": "^3.25.76"
|
package/src/database.ts
CHANGED
|
@@ -65,6 +65,7 @@ export interface PostgresOptions {
|
|
|
65
65
|
|
|
66
66
|
let openSession: (database: Database, readOnly: boolean) => Session
|
|
67
67
|
let transactionBuilder: (database: Database) => TransactionBuilder<LooseDB>
|
|
68
|
+
let firstConnection: (database: Database) => Promise<void>
|
|
68
69
|
|
|
69
70
|
/** The connection the data layer runs on. It has no public query method: everything goes through a store. */
|
|
70
71
|
export class Database {
|
|
@@ -80,6 +81,9 @@ export class Database {
|
|
|
80
81
|
static {
|
|
81
82
|
openSession = (database, readOnly) => new Session(database.#kysely, database.#kysely, readOnly)
|
|
82
83
|
transactionBuilder = (database) => database.#kysely.transaction()
|
|
84
|
+
firstConnection = async (database) => {
|
|
85
|
+
await database.#kysely.executeQuery(CompiledQuery.raw('SELECT 1'))
|
|
86
|
+
}
|
|
83
87
|
}
|
|
84
88
|
|
|
85
89
|
/**
|
|
@@ -112,6 +116,15 @@ export class Database {
|
|
|
112
116
|
Object.freeze(Database)
|
|
113
117
|
Object.freeze(Database.prototype)
|
|
114
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Opens the pool's first connection, which stays open. A pool with one open connection makes a statement wait for it
|
|
121
|
+
* when the database refuses another (Bun 1.3.14), so only a pool with none fails on a database at its limit: the test
|
|
122
|
+
* kit opens it before a file's first case, where a refusal is waited out instead of failing a case.
|
|
123
|
+
*/
|
|
124
|
+
export function openFirstConnection(database: Database): Promise<void> {
|
|
125
|
+
return firstConnection(database)
|
|
126
|
+
}
|
|
127
|
+
|
|
115
128
|
/** The connection a session runs on, and whether the request running now may only read. */
|
|
116
129
|
export interface SessionSource {
|
|
117
130
|
readonly database: Database
|
package/src/testing.ts
CHANGED
|
@@ -3,14 +3,22 @@
|
|
|
3
3
|
// its runtime role; the isolation fixtures seed rows in two disjoint containment chains, and the table probe reaches a
|
|
4
4
|
// table by name with every runtime check of a store.
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
applicationRuntimeUrl,
|
|
8
|
+
ConnectionCapacityError,
|
|
9
|
+
DEFAULT_CONNECTION_WAIT_MS,
|
|
10
|
+
definePostgresApplicationMigrations,
|
|
11
|
+
dropApplicationDatabase,
|
|
12
|
+
isConnectionCapacityError,
|
|
13
|
+
withConnectionCapacity,
|
|
14
|
+
} from '@fabrika/platform-node'
|
|
7
15
|
import { type ReservedSQL, SQL } from 'bun'
|
|
8
16
|
import { createHash } from 'node:crypto'
|
|
9
17
|
import { fileURLToPath } from 'node:url'
|
|
10
18
|
import { z } from 'zod/v4'
|
|
11
19
|
import { inSet, namesOf } from './builtins'
|
|
12
20
|
import { type CatalogColumn, introspectSchema } from './codegen'
|
|
13
|
-
import { applicationSchema, Database } from './database'
|
|
21
|
+
import { applicationSchema, Database, openFirstConnection } from './database'
|
|
14
22
|
import type { Primitive } from './description'
|
|
15
23
|
import { FRAMEWORK_MIGRATION_BUNDLE, FRAMEWORK_MIGRATIONS } from './migrations'
|
|
16
24
|
import { type Container, type ForeignKey, modelOf, type TableModel, type TableModels, tableModels, type Tables } from './policy'
|
|
@@ -37,8 +45,16 @@ export interface TestDatabaseOptions {
|
|
|
37
45
|
readonly migrations: string | URL
|
|
38
46
|
/** The test file, for the skip message. */
|
|
39
47
|
readonly label: string
|
|
48
|
+
/**
|
|
49
|
+
* How long to wait for free connection slots, in ms, before the file fails saying so; `DEFAULT_CONNECTION_WAIT_MS` by
|
|
50
|
+
* default. Once one file of a process gave up, every later one fails at once.
|
|
51
|
+
*/
|
|
52
|
+
readonly connectionWaitMs?: number
|
|
40
53
|
}
|
|
41
54
|
|
|
55
|
+
/** Set once a test database of this process gave up waiting for a connection slot. */
|
|
56
|
+
let capacityExhausted: ConnectionCapacityError | undefined
|
|
57
|
+
|
|
42
58
|
/** A value a test row holds in a column; `null` for a nullable column the fixture left empty. */
|
|
43
59
|
export type SampleValue = Primitive | null
|
|
44
60
|
|
|
@@ -286,9 +302,9 @@ const lockRows = z.array(z.object({ locked: z.boolean() }))
|
|
|
286
302
|
* the cluster's, so a family whose schema lives in another database, or that another provisioner created, is left to
|
|
287
303
|
* whoever can drop it.
|
|
288
304
|
*/
|
|
289
|
-
async function reclaimAbandonedSchemas(lock: ReservedSQL,
|
|
305
|
+
async function reclaimAbandonedSchemas(lock: ReservedSQL, url: string): Promise<void> {
|
|
290
306
|
const found = nameRows.parse(
|
|
291
|
-
await
|
|
307
|
+
await lock.unsafe(
|
|
292
308
|
`SELECT nspname AS name FROM pg_namespace WHERE nspname LIKE 'app\\_test\\_%' UNION SELECT rolname FROM pg_roles WHERE rolname LIKE 'app\\_test\\_%' OR rolname LIKE 'run\\_test\\_%'`,
|
|
293
309
|
),
|
|
294
310
|
)
|
|
@@ -346,45 +362,23 @@ export async function testDatabase(options: TestDatabaseOptions): Promise<TestDa
|
|
|
346
362
|
console.warn(`${options.label} skipped: set ${TEST_POSTGRES_URL} to run the Postgres-backed tests`)
|
|
347
363
|
return null
|
|
348
364
|
}
|
|
349
|
-
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
const migrations = definePostgresApplicationMigrations({
|
|
353
|
-
appId,
|
|
354
|
-
directory: migrationsDirectory(options.migrations),
|
|
355
|
-
framework: { bundle: FRAMEWORK_MIGRATION_BUNDLE, migrations: FRAMEWORK_MIGRATIONS },
|
|
356
|
-
})
|
|
357
|
-
const runtimeUrl = applicationRuntimeUrl(url, appId)
|
|
358
|
-
const admin = new SQL({ url, adapter: 'postgres', max: 1, prepare: false })
|
|
359
|
-
const holder = new SQL({ url, adapter: 'postgres', max: 1, prepare: false })
|
|
360
|
-
const seeder = new SQL({ url: runtimeUrl, adapter: 'postgres', max: 1, prepare: false })
|
|
361
|
-
const lock = await holder.reserve()
|
|
362
|
-
const releaseLock = async (): Promise<void> => {
|
|
363
|
-
try {
|
|
364
|
-
await lock.unsafe('SELECT pg_advisory_unlock($1::bigint)', [schemaLockKey(schema)])
|
|
365
|
-
} finally {
|
|
366
|
-
lock.release()
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
const cleanup = (): Promise<void> =>
|
|
370
|
-
settleAll([() => seeder.close(), () => dropApplicationDatabase(url, appId), releaseLock, () => holder.close(), () => admin.close()])
|
|
365
|
+
if (capacityExhausted !== undefined) throw new Error(`${options.label}: ${capacityExhausted.message}; its cases did not run`)
|
|
366
|
+
const waitMs = options.connectionWaitMs ?? DEFAULT_CONNECTION_WAIT_MS
|
|
367
|
+
let opened: OpenedTestDatabase
|
|
371
368
|
try {
|
|
372
|
-
await
|
|
373
|
-
// Held for the schema's whole life, before the schema and its roles exist: a schema whose lock is free is abandoned.
|
|
374
|
-
await lock.unsafe('SELECT pg_advisory_lock($1::bigint)', [schemaLockKey(schema)])
|
|
375
|
-
await migrations.migrate(url)
|
|
369
|
+
opened = await withConnectionCapacity(() => openTestDatabase(url, migrationsDirectory(options.migrations)), { waitMs })
|
|
376
370
|
} catch (error) {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
371
|
+
if (!(error instanceof ConnectionCapacityError)) throw error
|
|
372
|
+
// The next file of this run would wait as long again for the same full database.
|
|
373
|
+
capacityExhausted = error
|
|
374
|
+
throw new Error(`${options.label}: ${error.message}; its cases did not run`)
|
|
380
375
|
}
|
|
381
|
-
const
|
|
382
|
-
let catalog: Promise<readonly CatalogColumn[]> | undefined
|
|
376
|
+
const { schema, lock, seeder, catalog } = opened
|
|
383
377
|
let checks: Promise<readonly CheckConstraint[]> | undefined
|
|
384
378
|
|
|
385
379
|
/** The CHECK constraints that name `column` of `table`. */
|
|
386
380
|
async function checksOn(table: string, column: string): Promise<readonly CheckConstraint[]> {
|
|
387
|
-
checks ??=
|
|
381
|
+
checks ??= lock.unsafe(CHECK_QUERY, [schema]).then((rows: unknown) =>
|
|
388
382
|
checkRows.parse(rows).map((row) => ({
|
|
389
383
|
name: row.name,
|
|
390
384
|
table: row.table_name,
|
|
@@ -413,8 +407,7 @@ export async function testDatabase(options: TestDatabaseOptions): Promise<TestDa
|
|
|
413
407
|
}
|
|
414
408
|
|
|
415
409
|
async function columnsOf(table: string): Promise<readonly CatalogColumn[]> {
|
|
416
|
-
|
|
417
|
-
const columns = (await catalog).filter((column) => column.table_name === table)
|
|
410
|
+
const columns = catalog.filter((column) => column.table_name === table)
|
|
418
411
|
if (columns.length === 0) {
|
|
419
412
|
throw new Error(`${table} is not a table of the migrated schema; write its migration first`)
|
|
420
413
|
}
|
|
@@ -625,7 +618,7 @@ export async function testDatabase(options: TestDatabaseOptions): Promise<TestDa
|
|
|
625
618
|
}
|
|
626
619
|
|
|
627
620
|
return {
|
|
628
|
-
database,
|
|
621
|
+
database: opened.database,
|
|
629
622
|
schema,
|
|
630
623
|
async seedWorlds(tables, table, values = {}) {
|
|
631
624
|
const { models, model } = scopedModel(tables, table)
|
|
@@ -677,8 +670,96 @@ export async function testDatabase(options: TestDatabaseOptions): Promise<TestDa
|
|
|
677
670
|
}
|
|
678
671
|
return values
|
|
679
672
|
},
|
|
680
|
-
|
|
673
|
+
// Its own connections end first, so the drop finds a slot even in a database at its limit.
|
|
674
|
+
close: () => closeTestDatabase(url, opened, (drop) => withConnectionCapacity(drop, { waitMs })),
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** The pools of one test database; `lock` is absent until its connection is reserved. */
|
|
679
|
+
interface TestConnections {
|
|
680
|
+
readonly appId: string
|
|
681
|
+
readonly schema: string
|
|
682
|
+
readonly admin: SQL
|
|
683
|
+
readonly lock: ReservedSQL | undefined
|
|
684
|
+
readonly seeder: SQL
|
|
685
|
+
readonly database: Database
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** What `openTestDatabase` holds open for one test file. */
|
|
689
|
+
interface OpenedTestDatabase extends TestConnections {
|
|
690
|
+
/** The provisioning credential's one connection: it holds the schema's lock and reads the CHECK constraints. */
|
|
691
|
+
readonly lock: ReservedSQL
|
|
692
|
+
readonly catalog: readonly CatalogColumn[]
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** The runtime role's connections of a test database: a job's fenced write and its cancellation each need one. */
|
|
696
|
+
const RUNTIME_CONNECTIONS = 2
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* The connections a test database holds once open: the provisioning connection, the seeder and the runtime role's.
|
|
700
|
+
* Migrating and reading the catalog each borrow one more for a moment, and so does the drop once the others ended.
|
|
701
|
+
*/
|
|
702
|
+
export const TEST_DATABASE_CONNECTIONS = 2 + RUNTIME_CONNECTIONS
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Creates, locks and migrates a fresh schema and opens the first connection of every pool the test file uses, so no case
|
|
706
|
+
* meets a pool that cannot connect: a refused connection there would read as a failed case. Whatever fails, nothing it
|
|
707
|
+
* opened stays open.
|
|
708
|
+
*/
|
|
709
|
+
async function openTestDatabase(url: string, directory: string): Promise<OpenedTestDatabase> {
|
|
710
|
+
const appId = `test-${randomHex()}${randomHex()}`
|
|
711
|
+
const schema = applicationSchema(appId)
|
|
712
|
+
// The framework's tables first, as `fabrika db migrate` applies them: a job's lease and a webhook's bindings live there.
|
|
713
|
+
const migrations = definePostgresApplicationMigrations({
|
|
714
|
+
appId,
|
|
715
|
+
directory,
|
|
716
|
+
framework: { bundle: FRAMEWORK_MIGRATION_BUNDLE, migrations: FRAMEWORK_MIGRATIONS },
|
|
717
|
+
})
|
|
718
|
+
const runtimeUrl = applicationRuntimeUrl(url, appId)
|
|
719
|
+
const admin = new SQL({ url, adapter: 'postgres', max: 1, prepare: false })
|
|
720
|
+
const seeder = new SQL({ url: runtimeUrl, adapter: 'postgres', max: 1, prepare: false })
|
|
721
|
+
const database = Database.postgres({ url: runtimeUrl, schema, max: RUNTIME_CONNECTIONS })
|
|
722
|
+
let lock: ReservedSQL | undefined
|
|
723
|
+
try {
|
|
724
|
+
lock = await admin.reserve()
|
|
725
|
+
await reclaimAbandonedSchemas(lock, url)
|
|
726
|
+
// Held for the schema's whole life, before the schema and its roles exist: a schema whose lock is free is abandoned.
|
|
727
|
+
await lock.unsafe('SELECT pg_advisory_lock($1::bigint)', [schemaLockKey(schema)])
|
|
728
|
+
await migrations.migrate(url)
|
|
729
|
+
const catalog = await introspectSchema(url, schema, migrations.objects())
|
|
730
|
+
await seeder.unsafe('SELECT 1')
|
|
731
|
+
await openFirstConnection(database)
|
|
732
|
+
return { appId, schema, admin, lock, seeder, database, catalog }
|
|
733
|
+
} catch (error) {
|
|
734
|
+
// The roles, the schema and its ledger exist before a failing migration, so a failed start leaves them behind.
|
|
735
|
+
try {
|
|
736
|
+
await closeTestDatabase(url, { appId, schema, admin, lock, seeder, database }, (drop) => drop())
|
|
737
|
+
} catch (cleanupError) {
|
|
738
|
+
// A refused connection is retried with a fresh schema; the next run reclaims this one once its lock is free.
|
|
739
|
+
if (!isConnectionCapacityError(error)) throw cleanupError
|
|
740
|
+
}
|
|
741
|
+
throw error
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** Ends every connection of a test database and drops its schema and roles, running each step even when one throws. */
|
|
746
|
+
function closeTestDatabase(url: string, connections: TestConnections, dropping: (drop: () => Promise<void>) => Promise<void>): Promise<void> {
|
|
747
|
+
const { appId, schema, admin, lock, seeder, database } = connections
|
|
748
|
+
const releaseLock = async (): Promise<void> => {
|
|
749
|
+
if (lock === undefined) return
|
|
750
|
+
try {
|
|
751
|
+
await lock.unsafe('SELECT pg_advisory_unlock($1::bigint)', [schemaLockKey(schema)])
|
|
752
|
+
} finally {
|
|
753
|
+
lock.release()
|
|
754
|
+
}
|
|
681
755
|
}
|
|
756
|
+
return settleAll([
|
|
757
|
+
() => database.close(),
|
|
758
|
+
() => seeder.close(),
|
|
759
|
+
() => dropping(() => dropApplicationDatabase(url, appId)),
|
|
760
|
+
releaseLock,
|
|
761
|
+
() => admin.close(),
|
|
762
|
+
])
|
|
682
763
|
}
|
|
683
764
|
|
|
684
765
|
/**
|