@byline/db-postgres 3.7.0 → 3.9.0

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/dist/index.js CHANGED
@@ -8,6 +8,7 @@
8
8
  import { drizzle } from 'drizzle-orm/node-postgres';
9
9
  import pg from 'pg';
10
10
  import * as schema from './database/schema/index.js';
11
+ import { DBManagerImpl, TXManagerImpl } from './lib/db-manager.js';
11
12
  import { createCounterCommands } from './modules/counters/counters-commands.js';
12
13
  import { createCommandBuilders, } from './modules/storage/storage-commands.js';
13
14
  import { createQueryBuilders } from './modules/storage/storage-queries.js';
@@ -19,7 +20,14 @@ export const pgAdapter = ({ connectionString, collections, defaultContentLocale,
19
20
  connectionTimeoutMillis,
20
21
  });
21
22
  const db = drizzle(pool, { schema });
22
- const commandBuilders = createCommandBuilders(db, defaultContentLocale);
23
+ // Request-scoped transaction propagation (docs/TRANSACTIONS.md). The command
24
+ // builders run on the DBManager — each `this.db` access resolves to the
25
+ // ambient transaction when a `withTransaction` boundary is open, else the
26
+ // pool. Queries and counters stay on the raw `db` for now (reads don't need
27
+ // to join the audit transaction); they migrate opportunistically.
28
+ const dbManager = new DBManagerImpl({ dbPool: db });
29
+ const txManager = new TXManagerImpl({ db: dbManager });
30
+ const commandBuilders = createCommandBuilders(dbManager, defaultContentLocale);
23
31
  const queryBuilders = createQueryBuilders(db, collections, defaultContentLocale);
24
32
  const counterCommands = createCounterCommands(db);
25
33
  return {
@@ -28,6 +36,7 @@ export const pgAdapter = ({ connectionString, collections, defaultContentLocale,
28
36
  counters: counterCommands,
29
37
  },
30
38
  queries: queryBuilders,
39
+ withTransaction: (fn) => txManager.withTransaction(fn),
31
40
  drizzle: db,
32
41
  pool,
33
42
  backfillVersionLocales: () => commandBuilders.documents.backfillVersionLocales(),
@@ -0,0 +1,61 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ *
8
+ * Request-scoped transaction propagation via AsyncLocalStorage.
9
+ *
10
+ * Ported from the Modulus project (`db-manager.ts`) — the same ALS mechanism
11
+ * Byline's logger already uses (`packages/core/src/lib/logger.ts`,
12
+ * `withLogContext`). The full design — the service-owned `withTransaction`
13
+ * boundary, the DB↔DB vs DB↔external distinction, the incremental-adoption
14
+ * caveat, and the serverless db-contract-seam decisions — lives in
15
+ * `docs/TRANSACTIONS.md`. This machinery is deliberately adapter-internal:
16
+ * transactions are driver-specific, so `@byline/core` only declares the
17
+ * `withTransaction` capability on `IDbAdapter`, never the implementation.
18
+ */
19
+ import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
20
+ import type * as schema from '../database/schema/index.js';
21
+ /**
22
+ * The executor every storage command runs on: either the connection pool
23
+ * (autonomous, statement-at-a-time) or — when a `withTransaction` boundary is
24
+ * open in the current async context — that transaction. Commands obtain it via
25
+ * `DBManager.get()` and never thread a transaction handle through their
26
+ * signatures.
27
+ */
28
+ export type DBExecutor = NodePgDatabase<typeof schema>;
29
+ export interface DBManager {
30
+ /**
31
+ * The current executor: the ambient transaction when a `withTransaction`
32
+ * boundary is open in this async context, otherwise the pool.
33
+ */
34
+ get(): DBExecutor;
35
+ }
36
+ export declare class DBManagerImpl implements DBManager {
37
+ private readonly dbPool;
38
+ constructor(deps: {
39
+ dbPool: DBExecutor;
40
+ });
41
+ get(): DBExecutor;
42
+ }
43
+ export interface TXManager {
44
+ /**
45
+ * Run `fn` inside a single database transaction. Every `DBManager.get()`
46
+ * call made during `fn` (transitively, across `await`s) returns that
47
+ * transaction, so the commands `fn` invokes commit or roll back together.
48
+ *
49
+ * Nesting: when already inside a `withTransaction`, the inner call opens a
50
+ * SAVEPOINT (Drizzle nested transaction) — an inner throw rolls back to the
51
+ * savepoint, an outer throw rolls back everything.
52
+ */
53
+ withTransaction<T>(fn: () => Promise<T>): Promise<T>;
54
+ }
55
+ export declare class TXManagerImpl implements TXManager {
56
+ private readonly db;
57
+ constructor(deps: {
58
+ db: DBManager;
59
+ });
60
+ withTransaction<T>(fn: () => Promise<T>): Promise<T>;
61
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ *
8
+ * Request-scoped transaction propagation via AsyncLocalStorage.
9
+ *
10
+ * Ported from the Modulus project (`db-manager.ts`) — the same ALS mechanism
11
+ * Byline's logger already uses (`packages/core/src/lib/logger.ts`,
12
+ * `withLogContext`). The full design — the service-owned `withTransaction`
13
+ * boundary, the DB↔DB vs DB↔external distinction, the incremental-adoption
14
+ * caveat, and the serverless db-contract-seam decisions — lives in
15
+ * `docs/TRANSACTIONS.md`. This machinery is deliberately adapter-internal:
16
+ * transactions are driver-specific, so `@byline/core` only declares the
17
+ * `withTransaction` capability on `IDbAdapter`, never the implementation.
18
+ */
19
+ import { AsyncLocalStorage } from 'node:async_hooks';
20
+ const transactionALS = new AsyncLocalStorage();
21
+ export class DBManagerImpl {
22
+ dbPool;
23
+ constructor(deps) {
24
+ this.dbPool = deps.dbPool;
25
+ }
26
+ get() {
27
+ return transactionALS.getStore() ?? this.dbPool;
28
+ }
29
+ }
30
+ export class TXManagerImpl {
31
+ db;
32
+ constructor(deps) {
33
+ this.db = deps.db;
34
+ }
35
+ withTransaction(fn) {
36
+ return this.db.get().transaction((tx) =>
37
+ // `tx` is Drizzle's PgTransaction; it carries the full query-builder
38
+ // surface every command uses. The cast bridges the one structural gap
39
+ // to NodePgDatabase — the transaction lacks `$client`, which no command
40
+ // touches. See docs/TRANSACTIONS.md.
41
+ transactionALS.run(tx, fn));
42
+ }
43
+ }
@@ -1,9 +1,12 @@
1
1
  import type { CollectionDefinition } from '@byline/core';
2
2
  import { type NodePgDatabase } from 'drizzle-orm/node-postgres';
3
3
  import * as schema from '../database/schema/index.js';
4
+ import { DBManagerImpl, TXManagerImpl } from './db-manager.js';
4
5
  export declare function setupTestDB(collections?: CollectionDefinition[]): {
5
6
  pool: import("pg").Pool;
6
7
  db: NodePgDatabase<typeof schema>;
8
+ dbManager: DBManagerImpl;
9
+ txManager: TXManagerImpl;
7
10
  commandBuilders: {
8
11
  collections: import("../modules/storage/storage-commands.js").CollectionCommands;
9
12
  documents: import("../modules/storage/storage-commands.js").DocumentCommands;
@@ -3,9 +3,12 @@ import pg from 'pg';
3
3
  import * as schema from '../database/schema/index.js';
4
4
  import { createCommandBuilders } from '../modules/storage/storage-commands.js';
5
5
  import { createQueryBuilders } from '../modules/storage/storage-queries.js';
6
+ import { DBManagerImpl, TXManagerImpl } from './db-manager.js';
6
7
  import { assertTestDatabase } from './test-db.js';
7
8
  let pool;
8
9
  let db;
10
+ let dbManager;
11
+ let txManager;
9
12
  let commandBuilders;
10
13
  let queryBuilders;
11
14
  export function setupTestDB(collections = []) {
@@ -26,19 +29,25 @@ export function setupTestDB(collections = []) {
26
29
  if (!db) {
27
30
  db = drizzle(pool, { schema });
28
31
  }
32
+ if (!dbManager) {
33
+ dbManager = new DBManagerImpl({ dbPool: db });
34
+ txManager = new TXManagerImpl({ db: dbManager });
35
+ }
29
36
  if (!commandBuilders) {
30
- commandBuilders = createCommandBuilders(db, 'en');
37
+ commandBuilders = createCommandBuilders(dbManager, 'en');
31
38
  }
32
39
  // Recreate queryBuilders when collections are provided so that
33
40
  // DocumentQueries can resolve collection definitions by path.
34
41
  queryBuilders = createQueryBuilders(db, collections, 'en');
35
- return { pool, db, commandBuilders, queryBuilders };
42
+ return { pool, db, dbManager, txManager, commandBuilders, queryBuilders };
36
43
  }
37
44
  export async function teardownTestDB() {
38
45
  if (pool) {
39
46
  await pool.end();
40
47
  pool = undefined;
41
48
  db = undefined;
49
+ dbManager = undefined;
50
+ txManager = undefined;
42
51
  commandBuilders = undefined;
43
52
  queryBuilders = undefined;
44
53
  }
@@ -6,7 +6,7 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
  import { ERR_ADMIN_USER_VERSION_CONFLICT, } from '@byline/admin/admin-users';
9
- import { and, asc, desc, eq, ilike, or, sql } from 'drizzle-orm';
9
+ import { and, asc, desc, eq, ilike, inArray, or, sql } from 'drizzle-orm';
10
10
  import { v7 as uuidv7 } from 'uuid';
11
11
  import { adminUsers } from '../../database/schema/auth.js';
12
12
  /**
@@ -78,6 +78,11 @@ export function createAdminUsersRepository(db) {
78
78
  const [row] = await db.select(PUBLIC_COLUMNS).from(adminUsers).where(eq(adminUsers.id, id));
79
79
  return row ?? null;
80
80
  },
81
+ async getByIds(ids) {
82
+ if (ids.length === 0)
83
+ return [];
84
+ return db.select(PUBLIC_COLUMNS).from(adminUsers).where(inArray(adminUsers.id, ids));
85
+ },
81
86
  async getByEmail(email) {
82
87
  const [row] = await db
83
88
  .select(PUBLIC_COLUMNS)
@@ -6,9 +6,7 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
  import type { CollectionDefinition, ICollectionCommands, IDocumentCommands } from '@byline/core';
9
- import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
10
- import type * as schema from '../../database/schema/index.js';
11
- type DatabaseConnection = NodePgDatabase<typeof schema>;
9
+ import type { DBManager } from '../../lib/db-manager.js';
12
10
  /** Outcome of re-anchoring a single document's content source locale. */
13
11
  export type ReAnchorStatus = 'reanchored' | 'skipped-incomplete' | 'already-anchored' | 'not-found';
14
12
  export interface ReAnchorResult {
@@ -36,8 +34,15 @@ export interface ReAnchorReport {
36
34
  * CollectionCommands
37
35
  */
38
36
  export declare class CollectionCommands implements ICollectionCommands {
39
- private db;
40
- constructor(db: DatabaseConnection);
37
+ private dbManager;
38
+ constructor(dbManager: DBManager);
39
+ /**
40
+ * The executor for this call — the ambient transaction when a
41
+ * `withTransaction` boundary is open, otherwise the pool. Resolved per
42
+ * access so every `this.db.*` below transparently joins an enclosing
43
+ * transaction with no call-site change. See docs/TRANSACTIONS.md.
44
+ */
45
+ private get db();
41
46
  create(path: string, config: CollectionDefinition, opts?: {
42
47
  version?: number;
43
48
  schemaHash?: string;
@@ -73,9 +78,16 @@ export declare class CollectionCommands implements ICollectionCommands {
73
78
  * DocumentCommands
74
79
  */
75
80
  export declare class DocumentCommands implements IDocumentCommands {
76
- private db;
81
+ private dbManager;
77
82
  private defaultContentLocale;
78
- constructor(db: DatabaseConnection, defaultContentLocale: string);
83
+ constructor(dbManager: DBManager, defaultContentLocale: string);
84
+ /**
85
+ * The executor for this call — the ambient transaction when a
86
+ * `withTransaction` boundary is open, otherwise the pool. Resolved per
87
+ * access so every `this.db.*` below transparently joins an enclosing
88
+ * transaction with no call-site change. See docs/TRANSACTIONS.md.
89
+ */
90
+ private get db();
79
91
  /**
80
92
  * createDocumentVersion
81
93
  *
@@ -245,6 +257,7 @@ export declare class DocumentCommands implements IDocumentCommands {
245
257
  documentId: string;
246
258
  locale: string;
247
259
  status?: string;
260
+ createdBy?: string;
248
261
  }): Promise<{
249
262
  newVersionId: string;
250
263
  previousVersionId: string;
@@ -268,6 +281,7 @@ export declare class DocumentCommands implements IDocumentCommands {
268
281
  documentId: string;
269
282
  targetLocale: string;
270
283
  dryRun?: boolean;
284
+ createdBy?: string;
271
285
  }): Promise<ReAnchorResult>;
272
286
  /**
273
287
  * reAnchorDocuments
@@ -377,8 +391,7 @@ export declare class DocumentCommands implements IDocumentCommands {
377
391
  order_key: string;
378
392
  }): Promise<void>;
379
393
  }
380
- export declare function createCommandBuilders(db: DatabaseConnection, defaultContentLocale: string): {
394
+ export declare function createCommandBuilders(dbManager: DBManager, defaultContentLocale: string): {
381
395
  collections: CollectionCommands;
382
396
  documents: DocumentCommands;
383
397
  };
384
- export {};
@@ -15,9 +15,18 @@ import { getFirstOrThrow } from './storage-utils.js';
15
15
  * CollectionCommands
16
16
  */
17
17
  export class CollectionCommands {
18
- db;
19
- constructor(db) {
20
- this.db = db;
18
+ dbManager;
19
+ constructor(dbManager) {
20
+ this.dbManager = dbManager;
21
+ }
22
+ /**
23
+ * The executor for this call — the ambient transaction when a
24
+ * `withTransaction` boundary is open, otherwise the pool. Resolved per
25
+ * access so every `this.db.*` below transparently joins an enclosing
26
+ * transaction with no call-site change. See docs/TRANSACTIONS.md.
27
+ */
28
+ get db() {
29
+ return this.dbManager.get();
21
30
  }
22
31
  async create(path, config, opts) {
23
32
  return await this.db
@@ -51,12 +60,21 @@ export class CollectionCommands {
51
60
  * DocumentCommands
52
61
  */
53
62
  export class DocumentCommands {
54
- db;
63
+ dbManager;
55
64
  defaultContentLocale;
56
- constructor(db, defaultContentLocale) {
57
- this.db = db;
65
+ constructor(dbManager, defaultContentLocale) {
66
+ this.dbManager = dbManager;
58
67
  this.defaultContentLocale = defaultContentLocale;
59
68
  }
69
+ /**
70
+ * The executor for this call — the ambient transaction when a
71
+ * `withTransaction` boundary is open, otherwise the pool. Resolved per
72
+ * access so every `this.db.*` below transparently joins an enclosing
73
+ * transaction with no call-site change. See docs/TRANSACTIONS.md.
74
+ */
75
+ get db() {
76
+ return this.dbManager.get();
77
+ }
60
78
  /**
61
79
  * createDocumentVersion
62
80
  *
@@ -111,6 +129,7 @@ export class DocumentCommands {
111
129
  collection_version: params.collectionVersion,
112
130
  event_type: params.action ?? 'create',
113
131
  status: params.status ?? 'draft',
132
+ created_by: params.createdBy ?? null,
114
133
  })
115
134
  .returning()
116
135
  .then(getFirstOrThrow('Failed to create document version'));
@@ -498,7 +517,7 @@ export class DocumentCommands {
498
517
  * existence first, so this is a guard).
499
518
  */
500
519
  async deleteDocumentLocale(params) {
501
- const { documentId, locale, status } = params;
520
+ const { documentId, locale, status, createdBy } = params;
502
521
  return this.db.transaction(async (tx) => {
503
522
  // 1. Current (latest, non-deleted) version + the document's anchor.
504
523
  const current = await tx
@@ -529,6 +548,7 @@ export class DocumentCommands {
529
548
  event_type: 'delete_locale',
530
549
  status: status ?? 'draft',
531
550
  change_summary: `deleted content locale ${locale}`,
551
+ created_by: createdBy ?? null,
532
552
  });
533
553
  await this.copyAllVersionStoreRows(tx, current.versionId, newVersionId, locale);
534
554
  // 3. Recompute the new version's availability ledger against the source
@@ -553,7 +573,7 @@ export class DocumentCommands {
553
573
  * docs/I18N.md.
554
574
  */
555
575
  async reAnchorDocument(params) {
556
- const { documentId, targetLocale, dryRun = false } = params;
576
+ const { documentId, targetLocale, dryRun = false, createdBy } = params;
557
577
  return this.db.transaction(async (tx) => {
558
578
  // 1. Current (latest, non-deleted) version + the document's anchor.
559
579
  const current = await tx
@@ -613,6 +633,7 @@ export class DocumentCommands {
613
633
  event_type: 'update',
614
634
  status: current.status ?? 'draft',
615
635
  change_summary: `re-anchored content source locale ${fromLocale} → ${targetLocale}`,
636
+ created_by: createdBy ?? null,
616
637
  });
617
638
  await this.copyAllVersionStoreRows(tx, current.versionId, newVersionId);
618
639
  // 6. Ledger for the new version, computed against the new source locale.
@@ -833,9 +854,9 @@ export class DocumentCommands {
833
854
  .where(eq(documents.id, params.document_id));
834
855
  }
835
856
  }
836
- export function createCommandBuilders(db, defaultContentLocale) {
857
+ export function createCommandBuilders(dbManager, defaultContentLocale) {
837
858
  return {
838
- collections: new CollectionCommands(db),
839
- documents: new DocumentCommands(db, defaultContentLocale),
859
+ collections: new CollectionCommands(dbManager),
860
+ documents: new DocumentCommands(dbManager, defaultContentLocale),
840
861
  };
841
862
  }
@@ -256,8 +256,10 @@ export declare class DocumentQueries implements IDocumentQueries {
256
256
  path: string;
257
257
  source_locale: string;
258
258
  status: string | null;
259
+ event_type: string;
259
260
  created_at: Date;
260
261
  updated_at: Date;
262
+ created_by: string | null;
261
263
  fields: any;
262
264
  availableLocales: string[];
263
265
  _availableVersionLocales: string[];
@@ -268,8 +270,10 @@ export declare class DocumentQueries implements IDocumentQueries {
268
270
  path: string;
269
271
  source_locale: string;
270
272
  status: string | null;
273
+ event_type: string;
271
274
  created_at: Date;
272
275
  updated_at: Date;
276
+ created_by: string | null;
273
277
  fields: FlattenedStore[];
274
278
  } | null>;
275
279
  getDocumentByPath({ collection_id, path, locale, reconstruct, readMode, filters, onMissingLocale, }: {
@@ -286,8 +290,10 @@ export declare class DocumentQueries implements IDocumentQueries {
286
290
  path: string;
287
291
  source_locale: string;
288
292
  status: string | null;
293
+ event_type: string;
289
294
  created_at: Date;
290
295
  updated_at: Date;
296
+ created_by: string | null;
291
297
  fields: any;
292
298
  availableLocales: string[];
293
299
  _availableVersionLocales: string[];
@@ -298,8 +304,10 @@ export declare class DocumentQueries implements IDocumentQueries {
298
304
  path: string;
299
305
  source_locale: string;
300
306
  status: string | null;
307
+ event_type: string;
301
308
  created_at: Date;
302
309
  updated_at: Date;
310
+ created_by: string | null;
303
311
  fields: FlattenedStore[];
304
312
  availableLocales?: undefined;
305
313
  _availableVersionLocales?: undefined;
@@ -531,8 +531,10 @@ export class DocumentQueries {
531
531
  path: document.path ?? '',
532
532
  source_locale: document.source_locale ?? null,
533
533
  status: document.status,
534
+ event_type: document.event_type,
534
535
  created_at: document.created_at,
535
536
  updated_at: document.updated_at,
537
+ created_by: document.created_by ?? null,
536
538
  fields,
537
539
  availableLocales: advertised ?? [],
538
540
  _availableVersionLocales: availability?.availableLocales ?? [],
@@ -548,8 +550,10 @@ export class DocumentQueries {
548
550
  path: document.path ?? '',
549
551
  source_locale: document.source_locale ?? null,
550
552
  status: document.status,
553
+ event_type: document.event_type,
551
554
  created_at: document.created_at,
552
555
  updated_at: document.updated_at,
556
+ created_by: document.created_by ?? null,
553
557
  fields: fieldValues,
554
558
  };
555
559
  }
@@ -612,8 +616,10 @@ export class DocumentQueries {
612
616
  path: document.path ?? '',
613
617
  source_locale: document.source_locale ?? null,
614
618
  status: document.status,
619
+ event_type: document.event_type,
615
620
  created_at: document.created_at,
616
621
  updated_at: document.updated_at,
622
+ created_by: document.created_by ?? null,
617
623
  fields,
618
624
  availableLocales: advertised ?? [],
619
625
  _availableVersionLocales: availability?.availableLocales ?? [],
@@ -628,8 +634,10 @@ export class DocumentQueries {
628
634
  path: document.path ?? '',
629
635
  source_locale: document.source_locale ?? null,
630
636
  status: document.status,
637
+ event_type: document.event_type,
631
638
  created_at: document.created_at,
632
639
  updated_at: document.updated_at,
640
+ created_by: document.created_by ?? null,
633
641
  fields: fieldValues,
634
642
  };
635
643
  }
@@ -999,8 +1007,10 @@ export class DocumentQueries {
999
1007
  path: doc.path ?? '',
1000
1008
  source_locale: doc.source_locale ?? null,
1001
1009
  status: doc.status,
1010
+ event_type: doc.event_type,
1002
1011
  created_at: doc.created_at,
1003
1012
  updated_at: doc.updated_at,
1013
+ created_by: doc.created_by ?? null,
1004
1014
  fields: trimmedFields,
1005
1015
  };
1006
1016
  result.push(documentWithFields);
@@ -0,0 +1,8 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ export {};
@@ -0,0 +1,108 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ /**
9
+ * Integration tests for request-scoped transaction propagation
10
+ * (`withTransaction` / DBManager / TXManager — see docs/TRANSACTIONS.md).
11
+ *
12
+ * Proves the load-bearing guarantee the audit log depends on: multiple
13
+ * `commands.*` calls wrapped in one `withTransaction` commit or roll back
14
+ * **together**, with no transaction handle threaded through their signatures.
15
+ * The commands join the ambient transaction purely via the AsyncLocalStorage
16
+ * propagation in DBManager.get().
17
+ */
18
+ import { eq } from 'drizzle-orm';
19
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
20
+ import { collections } from '../../../database/schema/index.js';
21
+ import { setupTestDB, teardownTestDB } from '../../../lib/test-helper.js';
22
+ let db;
23
+ let dbManager;
24
+ let txManager;
25
+ let commandBuilders;
26
+ const ts = Date.now();
27
+ const createdPaths = [];
28
+ async function collectionExists(path) {
29
+ const rows = await db
30
+ .select({ id: collections.id })
31
+ .from(collections)
32
+ .where(eq(collections.path, path));
33
+ return rows.length > 0;
34
+ }
35
+ describe('withTransaction propagation + atomicity', () => {
36
+ beforeAll(() => {
37
+ const testDB = setupTestDB([]);
38
+ db = testDB.db;
39
+ dbManager = testDB.dbManager;
40
+ txManager = testDB.txManager;
41
+ commandBuilders = testDB.commandBuilders;
42
+ });
43
+ afterAll(async () => {
44
+ for (const path of createdPaths) {
45
+ try {
46
+ const rows = await db
47
+ .select({ id: collections.id })
48
+ .from(collections)
49
+ .where(eq(collections.path, path));
50
+ if (rows[0])
51
+ await commandBuilders.collections.delete(rows[0].id);
52
+ }
53
+ catch (error) {
54
+ console.error('cleanup failed for', path, error);
55
+ }
56
+ }
57
+ await teardownTestDB();
58
+ });
59
+ it('get() returns the pool outside a transaction and the tx inside it', async () => {
60
+ // Outside any boundary, the executor is the pool itself.
61
+ expect(dbManager.get()).toBe(db);
62
+ let insideExecutor;
63
+ await txManager.withTransaction(async () => {
64
+ insideExecutor = dbManager.get();
65
+ });
66
+ // Inside the boundary it is the ambient transaction — a different handle.
67
+ expect(insideExecutor).not.toBe(db);
68
+ });
69
+ it('commits every command in the boundary together', async () => {
70
+ const a = `tx-commit-a-${ts}`;
71
+ const b = `tx-commit-b-${ts}`;
72
+ createdPaths.push(a, b);
73
+ await txManager.withTransaction(async () => {
74
+ await commandBuilders.collections.create(a, {
75
+ path: a,
76
+ labels: { singular: 'A', plural: 'As' },
77
+ fields: [{ name: 'title', type: 'text' }],
78
+ });
79
+ await commandBuilders.collections.create(b, {
80
+ path: b,
81
+ labels: { singular: 'B', plural: 'Bs' },
82
+ fields: [{ name: 'title', type: 'text' }],
83
+ });
84
+ });
85
+ expect(await collectionExists(a)).toBe(true);
86
+ expect(await collectionExists(b)).toBe(true);
87
+ });
88
+ it('rolls back every command in the boundary when it throws', async () => {
89
+ const a = `tx-rollback-a-${ts}`;
90
+ const b = `tx-rollback-b-${ts}`;
91
+ const boom = new Error('boom');
92
+ await expect(txManager.withTransaction(async () => {
93
+ // First write succeeds...
94
+ await commandBuilders.collections.create(a, {
95
+ path: a,
96
+ labels: { singular: 'A', plural: 'As' },
97
+ fields: [{ name: 'title', type: 'text' }],
98
+ });
99
+ // ...then the unit of work fails after it. If the first write ran on
100
+ // the pool rather than the ambient tx, it would survive this throw.
101
+ throw boom;
102
+ })).rejects.toThrow('boom');
103
+ // Atomicity: the successful-looking first write was rolled back with the
104
+ // failing transaction. Neither collection exists.
105
+ expect(await collectionExists(a)).toBe(false);
106
+ expect(await collectionExists(b)).toBe(false);
107
+ });
108
+ });
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/db-postgres",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "3.7.0",
5
+ "version": "3.9.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -57,9 +57,9 @@
57
57
  "pg": "^8.21.0",
58
58
  "uuid": "^14.0.0",
59
59
  "zod": "^4.4.3",
60
- "@byline/admin": "3.7.0",
61
- "@byline/auth": "3.7.0",
62
- "@byline/core": "3.7.0"
60
+ "@byline/auth": "3.9.0",
61
+ "@byline/admin": "3.9.0",
62
+ "@byline/core": "3.9.0"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@biomejs/biome": "2.4.15",