@byline/db-postgres 3.8.0 → 3.10.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.
@@ -3312,4 +3312,187 @@ export declare const jsonStoreRelations: import("drizzle-orm").Relations<"byline
3312
3312
  document: import("drizzle-orm").One<"byline_document_versions", true>;
3313
3313
  collection: import("drizzle-orm").One<"byline_collections", true>;
3314
3314
  }>;
3315
+ export declare const auditLog: import("drizzle-orm/pg-core").PgTableWithColumns<{
3316
+ name: "byline_audit_log";
3317
+ schema: undefined;
3318
+ columns: {
3319
+ id: import("drizzle-orm/pg-core").PgColumn<{
3320
+ name: "id";
3321
+ tableName: "byline_audit_log";
3322
+ dataType: "string";
3323
+ columnType: "PgUUID";
3324
+ data: string;
3325
+ driverParam: string;
3326
+ notNull: true;
3327
+ hasDefault: false;
3328
+ isPrimaryKey: true;
3329
+ isAutoincrement: false;
3330
+ hasRuntimeDefault: false;
3331
+ enumValues: undefined;
3332
+ baseColumn: never;
3333
+ identity: undefined;
3334
+ generated: undefined;
3335
+ }, {}, {}>;
3336
+ document_id: import("drizzle-orm/pg-core").PgColumn<{
3337
+ name: "document_id";
3338
+ tableName: "byline_audit_log";
3339
+ dataType: "string";
3340
+ columnType: "PgUUID";
3341
+ data: string;
3342
+ driverParam: string;
3343
+ notNull: false;
3344
+ hasDefault: false;
3345
+ isPrimaryKey: false;
3346
+ isAutoincrement: false;
3347
+ hasRuntimeDefault: false;
3348
+ enumValues: undefined;
3349
+ baseColumn: never;
3350
+ identity: undefined;
3351
+ generated: undefined;
3352
+ }, {}, {}>;
3353
+ collection_id: import("drizzle-orm/pg-core").PgColumn<{
3354
+ name: "collection_id";
3355
+ tableName: "byline_audit_log";
3356
+ dataType: "string";
3357
+ columnType: "PgUUID";
3358
+ data: string;
3359
+ driverParam: string;
3360
+ notNull: false;
3361
+ hasDefault: false;
3362
+ isPrimaryKey: false;
3363
+ isAutoincrement: false;
3364
+ hasRuntimeDefault: false;
3365
+ enumValues: undefined;
3366
+ baseColumn: never;
3367
+ identity: undefined;
3368
+ generated: undefined;
3369
+ }, {}, {}>;
3370
+ actor_id: import("drizzle-orm/pg-core").PgColumn<{
3371
+ name: "actor_id";
3372
+ tableName: "byline_audit_log";
3373
+ dataType: "string";
3374
+ columnType: "PgUUID";
3375
+ data: string;
3376
+ driverParam: string;
3377
+ notNull: false;
3378
+ hasDefault: false;
3379
+ isPrimaryKey: false;
3380
+ isAutoincrement: false;
3381
+ hasRuntimeDefault: false;
3382
+ enumValues: undefined;
3383
+ baseColumn: never;
3384
+ identity: undefined;
3385
+ generated: undefined;
3386
+ }, {}, {}>;
3387
+ actor_realm: import("drizzle-orm/pg-core").PgColumn<{
3388
+ name: "actor_realm";
3389
+ tableName: "byline_audit_log";
3390
+ dataType: "string";
3391
+ columnType: "PgVarchar";
3392
+ data: string;
3393
+ driverParam: string;
3394
+ notNull: true;
3395
+ hasDefault: false;
3396
+ isPrimaryKey: false;
3397
+ isAutoincrement: false;
3398
+ hasRuntimeDefault: false;
3399
+ enumValues: [string, ...string[]];
3400
+ baseColumn: never;
3401
+ identity: undefined;
3402
+ generated: undefined;
3403
+ }, {}, {
3404
+ length: 16;
3405
+ }>;
3406
+ action: import("drizzle-orm/pg-core").PgColumn<{
3407
+ name: "action";
3408
+ tableName: "byline_audit_log";
3409
+ dataType: "string";
3410
+ columnType: "PgVarchar";
3411
+ data: string;
3412
+ driverParam: string;
3413
+ notNull: true;
3414
+ hasDefault: false;
3415
+ isPrimaryKey: false;
3416
+ isAutoincrement: false;
3417
+ hasRuntimeDefault: false;
3418
+ enumValues: [string, ...string[]];
3419
+ baseColumn: never;
3420
+ identity: undefined;
3421
+ generated: undefined;
3422
+ }, {}, {
3423
+ length: 64;
3424
+ }>;
3425
+ field: import("drizzle-orm/pg-core").PgColumn<{
3426
+ name: "field";
3427
+ tableName: "byline_audit_log";
3428
+ dataType: "string";
3429
+ columnType: "PgVarchar";
3430
+ data: string;
3431
+ driverParam: string;
3432
+ notNull: false;
3433
+ hasDefault: false;
3434
+ isPrimaryKey: false;
3435
+ isAutoincrement: false;
3436
+ hasRuntimeDefault: false;
3437
+ enumValues: [string, ...string[]];
3438
+ baseColumn: never;
3439
+ identity: undefined;
3440
+ generated: undefined;
3441
+ }, {}, {
3442
+ length: 128;
3443
+ }>;
3444
+ before: import("drizzle-orm/pg-core").PgColumn<{
3445
+ name: "before";
3446
+ tableName: "byline_audit_log";
3447
+ dataType: "json";
3448
+ columnType: "PgJsonb";
3449
+ data: unknown;
3450
+ driverParam: unknown;
3451
+ notNull: false;
3452
+ hasDefault: false;
3453
+ isPrimaryKey: false;
3454
+ isAutoincrement: false;
3455
+ hasRuntimeDefault: false;
3456
+ enumValues: undefined;
3457
+ baseColumn: never;
3458
+ identity: undefined;
3459
+ generated: undefined;
3460
+ }, {}, {}>;
3461
+ after: import("drizzle-orm/pg-core").PgColumn<{
3462
+ name: "after";
3463
+ tableName: "byline_audit_log";
3464
+ dataType: "json";
3465
+ columnType: "PgJsonb";
3466
+ data: unknown;
3467
+ driverParam: unknown;
3468
+ notNull: false;
3469
+ hasDefault: false;
3470
+ isPrimaryKey: false;
3471
+ isAutoincrement: false;
3472
+ hasRuntimeDefault: false;
3473
+ enumValues: undefined;
3474
+ baseColumn: never;
3475
+ identity: undefined;
3476
+ generated: undefined;
3477
+ }, {}, {}>;
3478
+ occurred_at: import("drizzle-orm/pg-core").PgColumn<{
3479
+ name: "occurred_at";
3480
+ tableName: "byline_audit_log";
3481
+ dataType: "date";
3482
+ columnType: "PgTimestamp";
3483
+ data: Date;
3484
+ driverParam: string;
3485
+ notNull: true;
3486
+ hasDefault: true;
3487
+ isPrimaryKey: false;
3488
+ isAutoincrement: false;
3489
+ hasRuntimeDefault: false;
3490
+ enumValues: undefined;
3491
+ baseColumn: never;
3492
+ identity: undefined;
3493
+ generated: undefined;
3494
+ }, {}, {}>;
3495
+ };
3496
+ dialect: "pg";
3497
+ }>;
3315
3498
  export { adminPermissions, adminPermissionsRelations, adminRefreshTokens, adminRefreshTokensRelations, adminRoleAdminUser, adminRoleAdminUserRelations, adminRoles, adminRolesRelations, adminUsers, adminUsersRelations, } from './auth.js';
@@ -632,6 +632,39 @@ export const jsonStoreRelations = relations(jsonStore, ({ one }) => ({
632
632
  }),
633
633
  }));
634
634
  // ---------------------------------------------------------------------------
635
+ // Audit log — byline_audit_log
636
+ // ---------------------------------------------------------------------------
637
+ // Document-grain audit log. Records the changes the immutable version stream
638
+ // does NOT capture an actor for: non-versioned system-field writes (path,
639
+ // availableLocales), in-place status transitions, and deletions — plus, later,
640
+ // admin-module events. One generic table (nullable `document_id`, namespaced
641
+ // `action`) so the system-wide activity report and future admin-realm auditing
642
+ // fit without a second migration. Append-only and deliberately **FK-free**: an
643
+ // audit row is an immutable historical fact that must outlive the document,
644
+ // collection, or actor it references — a `document.deleted` row cannot be
645
+ // allowed to cascade-delete itself. See docs/AUDIT.md — Workstream 2.
646
+ export const auditLog = pgTable('byline_audit_log', {
647
+ id: uuid('id').primaryKey(), // UUIDv7 — time-ordered, so id ordering ≈ time ordering
648
+ document_id: uuid('document_id'), // NULL for admin-realm (non-document) events; no FK
649
+ collection_id: uuid('collection_id'), // no FK — outlives the collection
650
+ actor_id: uuid('actor_id'), // NULL = system / internal tooling / non-UUID synthetic actor
651
+ actor_realm: varchar('actor_realm', { length: 16 }).notNull(), // 'admin' | 'user' | 'system'
652
+ action: varchar('action', { length: 64 }).notNull(), // namespaced, e.g. 'document.path.changed'
653
+ field: varchar('field', { length: 128 }), // the changed field where meaningful (e.g. 'path'), else NULL
654
+ before: jsonb('before'),
655
+ after: jsonb('after'),
656
+ occurred_at: timestamp('occurred_at', { precision: 6, withTimezone: true })
657
+ .notNull()
658
+ .defaultNow(),
659
+ }, (table) => [
660
+ // Per-document history, time-ordered (id is UUIDv7).
661
+ index('idx_audit_log_document_id').on(table.document_id, table.id),
662
+ // Per-actor activity — the system-wide report's actor filter.
663
+ index('idx_audit_log_actor_id').on(table.actor_id, table.id),
664
+ // Action-type filter for the activity report.
665
+ index('idx_audit_log_action').on(table.action, table.id),
666
+ ]);
667
+ // ---------------------------------------------------------------------------
635
668
  // Auth schema — byline_admin_users, byline_admin_roles, etc.
636
669
  // See ./auth.ts for definitions and rationale.
637
670
  // ---------------------------------------------------------------------------
package/dist/index.js CHANGED
@@ -8,6 +8,9 @@
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';
12
+ import { createAuditCommands } from './modules/audit/audit-commands.js';
13
+ import { createAuditQueries } from './modules/audit/audit-queries.js';
11
14
  import { createCounterCommands } from './modules/counters/counters-commands.js';
12
15
  import { createCommandBuilders, } from './modules/storage/storage-commands.js';
13
16
  import { createQueryBuilders } from './modules/storage/storage-queries.js';
@@ -19,15 +22,31 @@ export const pgAdapter = ({ connectionString, collections, defaultContentLocale,
19
22
  connectionTimeoutMillis,
20
23
  });
21
24
  const db = drizzle(pool, { schema });
22
- const commandBuilders = createCommandBuilders(db, defaultContentLocale);
25
+ // Request-scoped transaction propagation (docs/TRANSACTIONS.md). The command
26
+ // builders run on the DBManager — each `this.db` access resolves to the
27
+ // ambient transaction when a `withTransaction` boundary is open, else the
28
+ // pool. Queries and counters stay on the raw `db` for now (reads don't need
29
+ // to join the audit transaction); they migrate opportunistically.
30
+ const dbManager = new DBManagerImpl({ dbPool: db });
31
+ const txManager = new TXManagerImpl({ db: dbManager });
32
+ const commandBuilders = createCommandBuilders(dbManager, defaultContentLocale);
23
33
  const queryBuilders = createQueryBuilders(db, collections, defaultContentLocale);
24
34
  const counterCommands = createCounterCommands(db);
35
+ // Audit writes run on the DBManager so they join an ambient `withTransaction`
36
+ // (atomic with the mutation they record); audit reads run on the pool.
37
+ const auditCommands = createAuditCommands(dbManager);
38
+ const auditQueries = createAuditQueries(db);
25
39
  return {
26
40
  commands: {
27
41
  ...commandBuilders,
28
42
  counters: counterCommands,
43
+ audit: auditCommands,
29
44
  },
30
- queries: queryBuilders,
45
+ queries: {
46
+ ...queryBuilders,
47
+ audit: auditQueries,
48
+ },
49
+ withTransaction: (fn) => txManager.withTransaction(fn),
31
50
  drizzle: db,
32
51
  pool,
33
52
  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
  }
@@ -0,0 +1,27 @@
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
+ * Append-only audit-log writes (docs/AUDIT.md — Workstream 2). A deliberately
10
+ * dumb command: it inserts one row and knows nothing about *which* changes
11
+ * warrant an audit entry — that policy lives in the lifecycle services, which
12
+ * wrap the mutation + this append in `withTransaction` so they commit
13
+ * atomically. Runs on the `DBManager` executor, so an `append` issued inside a
14
+ * `withTransaction` boundary joins the ambient transaction.
15
+ */
16
+ import type { AuditLogAppendInput, IAuditCommands } from '@byline/core';
17
+ import type { DBManager } from '../../lib/db-manager.js';
18
+ export declare class AuditCommands implements IAuditCommands {
19
+ private dbManager;
20
+ constructor(dbManager: DBManager);
21
+ /** Ambient transaction when a `withTransaction` boundary is open, else the pool. */
22
+ private get db();
23
+ append(input: AuditLogAppendInput): Promise<{
24
+ id: string;
25
+ }>;
26
+ }
27
+ export declare function createAuditCommands(dbManager: DBManager): AuditCommands;
@@ -0,0 +1,40 @@
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
+ import { v7 as uuidv7 } from 'uuid';
9
+ import { auditLog } from '../../database/schema/index.js';
10
+ export class AuditCommands {
11
+ dbManager;
12
+ constructor(dbManager) {
13
+ this.dbManager = dbManager;
14
+ }
15
+ /** Ambient transaction when a `withTransaction` boundary is open, else the pool. */
16
+ get db() {
17
+ return this.dbManager.get();
18
+ }
19
+ async append(input) {
20
+ const id = uuidv7(); // time-ordered, so id ordering ≈ occurred_at ordering
21
+ await this.db.insert(auditLog).values({
22
+ id,
23
+ document_id: input.documentId ?? null,
24
+ collection_id: input.collectionId ?? null,
25
+ actor_id: input.actorId ?? null,
26
+ actor_realm: input.actorRealm,
27
+ action: input.action,
28
+ field: input.field ?? null,
29
+ // `?? null` only coerces undefined → SQL NULL; a real `false`/`0`/`''`
30
+ // before/after value is preserved.
31
+ before: input.before ?? null,
32
+ after: input.after ?? null,
33
+ // occurred_at defaults to now() at the DB.
34
+ });
35
+ return { id };
36
+ }
37
+ }
38
+ export function createAuditCommands(dbManager) {
39
+ return new AuditCommands(dbManager);
40
+ }
@@ -0,0 +1,29 @@
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
+ * Audit-log reads (docs/AUDIT.md — Workstreams 3 & 4). Reads run on the pool
10
+ * directly — they never need to join an audit write's transaction — so this
11
+ * takes the raw connection rather than the `DBManager`. Access scoping is the
12
+ * caller's responsibility (the document's own read gate); these queries do no
13
+ * scoping of their own.
14
+ */
15
+ import type { AuditLogPage, IAuditQueries } from '@byline/core';
16
+ import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
17
+ import type * as schema from '../../database/schema/index.js';
18
+ type DatabaseConnection = NodePgDatabase<typeof schema>;
19
+ export declare class AuditQueries implements IAuditQueries {
20
+ private db;
21
+ constructor(db: DatabaseConnection);
22
+ getDocumentAuditLog(params: {
23
+ document_id: string;
24
+ page?: number;
25
+ page_size?: number;
26
+ }): Promise<AuditLogPage>;
27
+ }
28
+ export declare function createAuditQueries(db: DatabaseConnection): AuditQueries;
29
+ export {};
@@ -0,0 +1,55 @@
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
+ import { desc, eq, sql } from 'drizzle-orm';
9
+ import { auditLog } from '../../database/schema/index.js';
10
+ function toEntry(row) {
11
+ return {
12
+ id: row.id,
13
+ documentId: row.document_id,
14
+ collectionId: row.collection_id,
15
+ actorId: row.actor_id,
16
+ actorRealm: row.actor_realm,
17
+ action: row.action,
18
+ field: row.field,
19
+ before: row.before,
20
+ after: row.after,
21
+ occurredAt: row.occurred_at,
22
+ };
23
+ }
24
+ export class AuditQueries {
25
+ db;
26
+ constructor(db) {
27
+ this.db = db;
28
+ }
29
+ async getDocumentAuditLog(params) {
30
+ const page = params.page ?? 1;
31
+ const pageSize = params.page_size ?? 20;
32
+ const offset = (page - 1) * pageSize;
33
+ const totalResult = await this.db
34
+ .select({ count: sql `count(*)` })
35
+ .from(auditLog)
36
+ .where(eq(auditLog.document_id, params.document_id));
37
+ const total = Number(totalResult[0]?.count) || 0;
38
+ const totalPages = Math.ceil(total / pageSize);
39
+ const rows = await this.db
40
+ .select()
41
+ .from(auditLog)
42
+ .where(eq(auditLog.document_id, params.document_id))
43
+ // id is UUIDv7 — DESC is newest-first without a separate sort column.
44
+ .orderBy(desc(auditLog.id))
45
+ .limit(pageSize)
46
+ .offset(offset);
47
+ return {
48
+ entries: rows.map(toEntry),
49
+ meta: { total, page, pageSize, totalPages },
50
+ };
51
+ }
52
+ }
53
+ export function createAuditQueries(db) {
54
+ return new AuditQueries(db);
55
+ }
@@ -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,117 @@
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 the audit-log storage layer (docs/AUDIT.md — W2):
10
+ * `audit.append` (write) + `audit.getDocumentAuditLog` (read), and the
11
+ * load-bearing property that an `append` issued inside `withTransaction`
12
+ * commits or rolls back **with** the transaction it is part of.
13
+ */
14
+ import { v7 as uuidv7 } from 'uuid';
15
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
16
+ import { setupTestDB, teardownTestDB } from '../../../lib/test-helper.js';
17
+ import { createAuditCommands } from '../audit-commands.js';
18
+ import { createAuditQueries } from '../audit-queries.js';
19
+ let auditCommands;
20
+ let auditQueries;
21
+ let txManager;
22
+ describe('audit-log storage', () => {
23
+ beforeAll(() => {
24
+ const testDB = setupTestDB([]);
25
+ txManager = testDB.txManager;
26
+ auditCommands = createAuditCommands(testDB.dbManager);
27
+ auditQueries = createAuditQueries(testDB.db);
28
+ });
29
+ afterAll(async () => {
30
+ await teardownTestDB();
31
+ });
32
+ it('appends an entry and reads it back with full field fidelity', async () => {
33
+ const documentId = uuidv7();
34
+ const collectionId = uuidv7();
35
+ const actorId = uuidv7();
36
+ await auditCommands.append({
37
+ documentId,
38
+ collectionId,
39
+ actorId,
40
+ actorRealm: 'admin',
41
+ action: 'document.path.changed',
42
+ field: 'path',
43
+ before: 'old-slug',
44
+ after: 'new-slug',
45
+ });
46
+ const page = await auditQueries.getDocumentAuditLog({ document_id: documentId });
47
+ expect(page.meta.total).toBe(1);
48
+ const entry = page.entries[0];
49
+ expect(entry.documentId).toBe(documentId);
50
+ expect(entry.collectionId).toBe(collectionId);
51
+ expect(entry.actorId).toBe(actorId);
52
+ expect(entry.actorRealm).toBe('admin');
53
+ expect(entry.action).toBe('document.path.changed');
54
+ expect(entry.field).toBe('path');
55
+ expect(entry.before).toBe('old-slug');
56
+ expect(entry.after).toBe('new-slug');
57
+ expect(entry.occurredAt).toBeInstanceOf(Date);
58
+ });
59
+ it('persists NULL actor + structured before/after, and returns newest-first', async () => {
60
+ const documentId = uuidv7();
61
+ // A system write (no actor) with object-shaped before/after.
62
+ await auditCommands.append({
63
+ documentId,
64
+ actorId: null,
65
+ actorRealm: 'system',
66
+ action: 'document.locales.changed',
67
+ field: 'availableLocales',
68
+ before: ['en'],
69
+ after: ['en', 'fr'],
70
+ });
71
+ await auditCommands.append({
72
+ documentId,
73
+ actorRealm: 'admin',
74
+ action: 'document.status.changed',
75
+ field: 'status',
76
+ before: 'draft',
77
+ after: 'published',
78
+ });
79
+ const page = await auditQueries.getDocumentAuditLog({ document_id: documentId });
80
+ expect(page.meta.total).toBe(2);
81
+ // UUIDv7 ids → newest first: the status change was appended last.
82
+ expect(page.entries[0]?.action).toBe('document.status.changed');
83
+ expect(page.entries[1]?.action).toBe('document.locales.changed');
84
+ const localesEntry = page.entries[1];
85
+ expect(localesEntry.actorId).toBeNull();
86
+ expect(localesEntry.before).toEqual(['en']);
87
+ expect(localesEntry.after).toEqual(['en', 'fr']);
88
+ });
89
+ it('rolls back the audit row when its enclosing transaction throws', async () => {
90
+ const documentId = uuidv7();
91
+ await expect(txManager.withTransaction(async () => {
92
+ await auditCommands.append({
93
+ documentId,
94
+ actorRealm: 'admin',
95
+ action: 'document.deleted',
96
+ });
97
+ // The unit of work fails after the audit append. If the append had run
98
+ // on the pool rather than the ambient tx, the row would survive.
99
+ throw new Error('boom');
100
+ })).rejects.toThrow('boom');
101
+ const page = await auditQueries.getDocumentAuditLog({ document_id: documentId });
102
+ expect(page.meta.total).toBe(0);
103
+ });
104
+ it('commits the audit row with its enclosing transaction', async () => {
105
+ const documentId = uuidv7();
106
+ await txManager.withTransaction(async () => {
107
+ await auditCommands.append({
108
+ documentId,
109
+ actorRealm: 'admin',
110
+ action: 'document.deleted',
111
+ });
112
+ });
113
+ const page = await auditQueries.getDocumentAuditLog({ document_id: documentId });
114
+ expect(page.meta.total).toBe(1);
115
+ expect(page.entries[0]?.action).toBe('document.deleted');
116
+ });
117
+ });
@@ -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.10.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.10.0",
61
+ "@byline/admin": "3.10.0",
62
+ "@byline/core": "3.10.0"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@biomejs/biome": "2.4.15",