@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/db",
3
- "version": "0.4.6",
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": "^0.4.6",
65
- "@fabrika/platform-node": "^0.4.6",
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 { applicationRuntimeUrl, definePostgresApplicationMigrations, dropApplicationDatabase } from '@fabrika/platform-node'
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, admin: SQL, url: string): Promise<void> {
305
+ async function reclaimAbandonedSchemas(lock: ReservedSQL, url: string): Promise<void> {
290
306
  const found = nameRows.parse(
291
- await admin.unsafe(
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
- const appId = `test-${randomHex()}${randomHex()}`
350
- const schema = applicationSchema(appId)
351
- // The framework's tables first, as `fabrika db migrate` applies them: a job's lease and a webhook's bindings live there.
352
- const migrations = definePostgresApplicationMigrations({
353
- appId,
354
- directory: migrationsDirectory(options.migrations),
355
- framework: { bundle: FRAMEWORK_MIGRATION_BUNDLE, migrations: FRAMEWORK_MIGRATIONS },
356
- })
357
- const runtimeUrl = applicationRuntimeUrl(url, appId)
358
- const admin = new SQL({ url, adapter: 'postgres', max: 1, prepare: false })
359
- const holder = new SQL({ url, adapter: 'postgres', max: 1, prepare: false })
360
- const seeder = new SQL({ url: runtimeUrl, adapter: 'postgres', max: 1, prepare: false })
361
- const lock = await holder.reserve()
362
- const releaseLock = async (): Promise<void> => {
363
- try {
364
- await lock.unsafe('SELECT pg_advisory_unlock($1::bigint)', [schemaLockKey(schema)])
365
- } finally {
366
- lock.release()
367
- }
368
- }
369
- const cleanup = (): Promise<void> =>
370
- settleAll([() => seeder.close(), () => dropApplicationDatabase(url, appId), releaseLock, () => holder.close(), () => admin.close()])
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 reclaimAbandonedSchemas(lock, admin, url)
373
- // Held for the schema's whole life, before the schema and its roles exist: a schema whose lock is free is abandoned.
374
- await lock.unsafe('SELECT pg_advisory_lock($1::bigint)', [schemaLockKey(schema)])
375
- await migrations.migrate(url)
369
+ opened = await withConnectionCapacity(() => openTestDatabase(url, migrationsDirectory(options.migrations)), { waitMs })
376
370
  } catch (error) {
377
- // The roles, the schema and its ledger exist before a failing migration, so a failed start leaves them behind.
378
- await cleanup()
379
- throw error
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 database = Database.postgres({ url: runtimeUrl, schema, max: 2 })
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 ??= admin.unsafe(CHECK_QUERY, [schema]).then((rows: unknown) =>
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
- catalog ??= introspectSchema(url, schema, migrations.objects())
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
- close: () => settleAll([() => database.close(), cleanup]),
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
  /**