@byline/db-postgres 3.8.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,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
  *
@@ -379,8 +391,7 @@ export declare class DocumentCommands implements IDocumentCommands {
379
391
  order_key: string;
380
392
  }): Promise<void>;
381
393
  }
382
- export declare function createCommandBuilders(db: DatabaseConnection, defaultContentLocale: string): {
394
+ export declare function createCommandBuilders(dbManager: DBManager, defaultContentLocale: string): {
383
395
  collections: CollectionCommands;
384
396
  documents: DocumentCommands;
385
397
  };
386
- 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
  *
@@ -836,9 +854,9 @@ export class DocumentCommands {
836
854
  .where(eq(documents.id, params.document_id));
837
855
  }
838
856
  }
839
- export function createCommandBuilders(db, defaultContentLocale) {
857
+ export function createCommandBuilders(dbManager, defaultContentLocale) {
840
858
  return {
841
- collections: new CollectionCommands(db),
842
- documents: new DocumentCommands(db, defaultContentLocale),
859
+ collections: new CollectionCommands(dbManager),
860
+ documents: new DocumentCommands(dbManager, defaultContentLocale),
843
861
  };
844
862
  }
@@ -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.8.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.8.0",
61
- "@byline/auth": "3.8.0",
62
- "@byline/core": "3.8.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",