@byline/core 3.9.0 → 3.10.1

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.
@@ -203,10 +203,20 @@ export interface IDbAdapter {
203
203
  collections: ICollectionCommands;
204
204
  documents: IDocumentCommands;
205
205
  counters: ICounterCommands;
206
+ /**
207
+ * Append-only audit-log writes (docs/AUDIT.md — Workstream 2). Optional
208
+ * capability, paired with `withTransaction`: a consumer that records audit
209
+ * entries asserts both are present and throws otherwise (it must never
210
+ * silently skip the audit row). Adapters that model the audit log
211
+ * implement it; others omit it.
212
+ */
213
+ audit?: IAuditCommands;
206
214
  };
207
215
  queries: {
208
216
  collections: ICollectionQueries;
209
217
  documents: IDocumentQueries;
218
+ /** Audit-log reads — per-document history, system-wide report. See docs/AUDIT.md. */
219
+ audit?: IAuditQueries;
210
220
  };
211
221
  /**
212
222
  * Optional capability: run `fn` inside a single database transaction so the
@@ -239,6 +249,81 @@ export interface IDbAdapter {
239
249
  rowsUpdated: number;
240
250
  }>;
241
251
  }
252
+ /**
253
+ * The realm of the actor that performed an audited change. `'admin'` for
254
+ * admin-user actions, `'user'` reserved for the end-user realm, `'system'`
255
+ * for deliberate internal-tooling writes. See docs/AUDIT.md.
256
+ */
257
+ export type AuditActorRealm = 'admin' | 'user' | 'system';
258
+ /** Input to `IAuditCommands.append` — one audit-log row. */
259
+ export interface AuditLogAppendInput {
260
+ /** The document the change concerns; NULL for admin-realm (non-document) events. */
261
+ documentId?: string | null;
262
+ collectionId?: string | null;
263
+ /** The acting user id, only when it is a real persisted user (a UUID); NULL otherwise. */
264
+ actorId?: string | null;
265
+ actorRealm: AuditActorRealm;
266
+ /** Namespaced action, e.g. `document.path.changed`. */
267
+ action: string;
268
+ /** The changed field where meaningful (e.g. `path`); NULL for whole-entity events. */
269
+ field?: string | null;
270
+ /** Prior value (JSON-serialisable); NULL where not applicable. */
271
+ before?: unknown;
272
+ /** New value (JSON-serialisable); NULL where not applicable. */
273
+ after?: unknown;
274
+ }
275
+ /** A materialised audit-log row. */
276
+ export interface AuditLogEntry {
277
+ id: string;
278
+ documentId: string | null;
279
+ collectionId: string | null;
280
+ actorId: string | null;
281
+ actorRealm: string;
282
+ action: string;
283
+ field: string | null;
284
+ before: unknown;
285
+ after: unknown;
286
+ occurredAt: Date;
287
+ }
288
+ /** A page of audit-log entries with pagination metadata. */
289
+ export interface AuditLogPage {
290
+ entries: AuditLogEntry[];
291
+ meta: {
292
+ total: number;
293
+ page: number;
294
+ pageSize: number;
295
+ totalPages: number;
296
+ };
297
+ }
298
+ /**
299
+ * Append-only audit-log writes. The companion read interface is
300
+ * `IAuditQueries`. See docs/AUDIT.md — Workstream 2.
301
+ */
302
+ export interface IAuditCommands {
303
+ /**
304
+ * Append one immutable audit-log row. The adapter generates the row id
305
+ * (UUIDv7) and `occurred_at`. Runs on the ambient executor, so when called
306
+ * inside `withTransaction` it commits atomically with the mutation it
307
+ * records — the load-bearing guarantee of the audit log.
308
+ */
309
+ append(input: AuditLogAppendInput): Promise<{
310
+ id: string;
311
+ }>;
312
+ }
313
+ /** Audit-log reads. See docs/AUDIT.md — Workstreams 3 & 4. */
314
+ export interface IAuditQueries {
315
+ /**
316
+ * The audit history for one document, newest first, paged. Backs the
317
+ * document-history view. The caller is responsible for the access gate
318
+ * (the document's own read pipeline) before reaching this — it does no
319
+ * scoping of its own.
320
+ */
321
+ getDocumentAuditLog(params: {
322
+ document_id: string;
323
+ page?: number;
324
+ page_size?: number;
325
+ }): Promise<AuditLogPage>;
326
+ }
242
327
  /**
243
328
  * Adapter capability for the shared-pool counter mechanism backing the
244
329
  * `counter` field type. See `packages/core/src/@types/field-types.ts`
@@ -86,6 +86,7 @@ export declare const ErrorCodes: {
86
86
  readonly STORAGE: "ERR_STORAGE";
87
87
  readonly READ_BUDGET_EXCEEDED: "ERR_READ_BUDGET_EXCEEDED";
88
88
  readonly PATH_CONFLICT: "ERR_PATH_CONFLICT";
89
+ readonly AUDIT_UNSUPPORTED: "ERR_AUDIT_UNSUPPORTED";
89
90
  };
90
91
  export declare const ERR_UNHANDLED: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
91
92
  export declare const ERR_NOT_FOUND: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
@@ -103,3 +104,13 @@ export declare const ERR_READ_BUDGET_EXCEEDED: (opts: BylineErrorOptions, errorC
103
104
  * `byline_document_paths(collection_id, locale, path)`.
104
105
  */
105
106
  export declare const ERR_PATH_CONFLICT: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
107
+ /**
108
+ * Thrown when an audited write (a document-grain change that must be recorded
109
+ * atomically — path / available-locales / status / delete) runs against a db
110
+ * adapter that does not provide both the `withTransaction` capability and the
111
+ * `commands.audit` / `queries.audit` surfaces. A misconfiguration, not a
112
+ * user error: an auditability guarantee cannot be honoured non-atomically, so
113
+ * the write is refused loudly rather than recorded with a gap. See
114
+ * docs/TRANSACTIONS.md and docs/AUDIT.md.
115
+ */
116
+ export declare const ERR_AUDIT_UNSUPPORTED: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
@@ -119,6 +119,7 @@ export const ErrorCodes = {
119
119
  STORAGE: 'ERR_STORAGE',
120
120
  READ_BUDGET_EXCEEDED: 'ERR_READ_BUDGET_EXCEEDED',
121
121
  PATH_CONFLICT: 'ERR_PATH_CONFLICT',
122
+ AUDIT_UNSUPPORTED: 'ERR_AUDIT_UNSUPPORTED',
122
123
  };
123
124
  // ---------------------------------------------------------------------------
124
125
  // Pre-instantiated factories
@@ -139,3 +140,13 @@ export const ERR_READ_BUDGET_EXCEEDED = createErrorType(ErrorCodes.READ_BUDGET_E
139
140
  * `byline_document_paths(collection_id, locale, path)`.
140
141
  */
141
142
  export const ERR_PATH_CONFLICT = createErrorType(ErrorCodes.PATH_CONFLICT, 'warn');
143
+ /**
144
+ * Thrown when an audited write (a document-grain change that must be recorded
145
+ * atomically — path / available-locales / status / delete) runs against a db
146
+ * adapter that does not provide both the `withTransaction` capability and the
147
+ * `commands.audit` / `queries.audit` surfaces. A misconfiguration, not a
148
+ * user error: an auditability guarantee cannot be honoured non-atomically, so
149
+ * the write is refused loudly rather than recorded with a gap. See
150
+ * docs/TRANSACTIONS.md and docs/AUDIT.md.
151
+ */
152
+ export const ERR_AUDIT_UNSUPPORTED = createErrorType(ErrorCodes.AUDIT_UNSUPPORTED);
@@ -0,0 +1,45 @@
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 type { AuditActorRealm, AuditLogAppendInput, IDbAdapter } from '../../@types/index.js';
9
+ import type { DocumentLifecycleContext } from './context.js';
10
+ /** Namespaced audit actions for document-grain changes. */
11
+ export declare const AUDIT_ACTIONS: {
12
+ readonly pathChanged: "document.path.changed";
13
+ readonly localesChanged: "document.locales.changed";
14
+ readonly statusChanged: "document.status.changed";
15
+ readonly deleted: "document.deleted";
16
+ };
17
+ /**
18
+ * The actor id + realm for an audit-log row. Mirrors `actorId()`: a real
19
+ * persisted user carries a UUID id and is recorded with realm `'admin'`
20
+ * (these write-points are admin-gated, document-grain operations). A synthetic
21
+ * script/seed actor (non-UUID) or no actor is a system/tooling write — NULL id,
22
+ * realm `'system'`. (A future `UserAuth`-driven write-point would extend this
23
+ * to `'user'`.)
24
+ */
25
+ export declare function auditActor(ctx: DocumentLifecycleContext): {
26
+ actorId: string | undefined;
27
+ actorRealm: AuditActorRealm;
28
+ };
29
+ /** A non-null audit capability resolved from an adapter that supports it. */
30
+ export interface AuditCapability {
31
+ withTransaction: <T>(fn: () => Promise<T>) => Promise<T>;
32
+ append: (input: AuditLogAppendInput) => Promise<{
33
+ id: string;
34
+ }>;
35
+ }
36
+ /**
37
+ * Assert the adapter can record an audited write atomically — it must provide
38
+ * **both** `withTransaction` and `commands.audit`. Returns a non-null
39
+ * capability the caller composes; throws `ERR_AUDIT_UNSUPPORTED` otherwise,
40
+ * rather than silently skipping the audit row or running it non-atomically.
41
+ * See docs/TRANSACTIONS.md and docs/AUDIT.md.
42
+ */
43
+ export declare function requireAuditCapability(db: IDbAdapter): AuditCapability;
44
+ /** Order-insensitive equality for the advertised-locale set. */
45
+ export declare function sameLocaleSet(a: readonly string[], b: readonly string[]): boolean;
@@ -0,0 +1,67 @@
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 write helpers for the document-grain lifecycle write-points
10
+ * (docs/AUDIT.md — Workstream 2). The audit log records the changes the
11
+ * immutable version stream does NOT capture an actor for: non-versioned
12
+ * system-field writes (path, available-locales), in-place status transitions,
13
+ * and deletions. Each such mutation and its audit row commit atomically inside
14
+ * `withTransaction` — a silently-unwritten audit row is the one unacceptable
15
+ * outcome (see docs/TRANSACTIONS.md).
16
+ */
17
+ import { ERR_AUDIT_UNSUPPORTED } from '../../lib/errors.js';
18
+ import { actorId } from './internals.js';
19
+ /** Namespaced audit actions for document-grain changes. */
20
+ export const AUDIT_ACTIONS = {
21
+ pathChanged: 'document.path.changed',
22
+ localesChanged: 'document.locales.changed',
23
+ statusChanged: 'document.status.changed',
24
+ deleted: 'document.deleted',
25
+ };
26
+ /**
27
+ * The actor id + realm for an audit-log row. Mirrors `actorId()`: a real
28
+ * persisted user carries a UUID id and is recorded with realm `'admin'`
29
+ * (these write-points are admin-gated, document-grain operations). A synthetic
30
+ * script/seed actor (non-UUID) or no actor is a system/tooling write — NULL id,
31
+ * realm `'system'`. (A future `UserAuth`-driven write-point would extend this
32
+ * to `'user'`.)
33
+ */
34
+ export function auditActor(ctx) {
35
+ const id = actorId(ctx);
36
+ return id != null
37
+ ? { actorId: id, actorRealm: 'admin' }
38
+ : { actorId: undefined, actorRealm: 'system' };
39
+ }
40
+ /**
41
+ * Assert the adapter can record an audited write atomically — it must provide
42
+ * **both** `withTransaction` and `commands.audit`. Returns a non-null
43
+ * capability the caller composes; throws `ERR_AUDIT_UNSUPPORTED` otherwise,
44
+ * rather than silently skipping the audit row or running it non-atomically.
45
+ * See docs/TRANSACTIONS.md and docs/AUDIT.md.
46
+ */
47
+ export function requireAuditCapability(db) {
48
+ const withTransaction = db.withTransaction;
49
+ const audit = db.commands.audit;
50
+ if (withTransaction == null || audit == null) {
51
+ throw ERR_AUDIT_UNSUPPORTED({
52
+ message: 'audited write requires a db adapter with withTransaction + commands.audit support',
53
+ });
54
+ }
55
+ return {
56
+ withTransaction: (fn) => withTransaction(fn),
57
+ append: (input) => audit.append(input),
58
+ };
59
+ }
60
+ /** Order-insensitive equality for the advertised-locale set. */
61
+ export function sameLocaleSet(a, b) {
62
+ if (a.length !== b.length)
63
+ return false;
64
+ const sa = [...a].sort();
65
+ const sb = [...b].sort();
66
+ return sa.every((v, i) => v === sb[i]);
67
+ }
@@ -10,6 +10,7 @@ import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
10
10
  import { ERR_NOT_FOUND } from '../../lib/errors.js';
11
11
  import { withLogContext } from '../../lib/logger.js';
12
12
  import { getUploadFields } from '../../utils/storage-utils.js';
13
+ import { AUDIT_ACTIONS, auditActor, requireAuditCapability } from './audit.js';
13
14
  import { invokeHook } from './internals.js';
14
15
  /**
15
16
  * Soft-delete a document.
@@ -91,9 +92,26 @@ export async function deleteDocument(ctx, params) {
91
92
  };
92
93
  // 2. beforeDelete hook.
93
94
  await invokeHook(hooks?.beforeDelete, hookCtx);
94
- // 3. Soft-delete all versions.
95
- const deletedVersionCount = await db.commands.documents.softDeleteDocument({
96
- document_id: params.documentId,
95
+ // 3. Soft-delete all versions, atomically with the audit record. A
96
+ // whole-document delete mints no new version, so the version stream
97
+ // never records it — the audit log is the only place a deletion is
98
+ // accountable (docs/AUDIT.md). Storage-file cleanup (step 4) is a
99
+ // DB↔external side-effect and stays OUTSIDE the transaction — it is
100
+ // post-commit, best-effort compensation (docs/TRANSACTIONS.md).
101
+ const audit = requireAuditCapability(db);
102
+ const actor = auditActor(ctx);
103
+ let deletedVersionCount = 0;
104
+ await audit.withTransaction(async () => {
105
+ deletedVersionCount = await db.commands.documents.softDeleteDocument({
106
+ document_id: params.documentId,
107
+ });
108
+ await audit.append({
109
+ documentId: params.documentId,
110
+ collectionId: ctx.collectionId,
111
+ actorId: actor.actorId,
112
+ actorRealm: actor.actorRealm,
113
+ action: AUDIT_ACTIONS.deleted,
114
+ });
97
115
  });
98
116
  // 4. Clean up storage files. Non-fatal: logs errors but does not throw.
99
117
  if (ctx.storage && storagePathsToDelete.length > 0) {
@@ -10,6 +10,7 @@ import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
10
10
  import { ERR_INVALID_TRANSITION, ERR_NOT_FOUND } from '../../lib/errors.js';
11
11
  import { withLogContext } from '../../lib/logger.js';
12
12
  import { getWorkflow, validateStatusTransition } from '../../workflow/workflow.js';
13
+ import { AUDIT_ACTIONS, auditActor, requireAuditCapability } from './audit.js';
13
14
  import { invokeHook } from './internals.js';
14
15
  /**
15
16
  * Change a document's workflow status.
@@ -85,18 +86,34 @@ export async function changeDocumentStatus(ctx, params) {
85
86
  };
86
87
  // 3. beforeStatusChange hook.
87
88
  await invokeHook(hooks?.beforeStatusChange, hookCtx);
88
- // 4. Mutate status in-place.
89
- await db.commands.documents.setDocumentStatus({
90
- document_version_id: documentVersionId,
91
- status: params.nextStatus,
92
- });
93
- // 5. Auto-archive previous published versions.
94
- if (params.nextStatus === 'published') {
95
- await db.commands.documents.archivePublishedVersions({
96
- document_id: params.documentId,
97
- excludeVersionId: documentVersionId,
89
+ // 4–5. Mutate status in-place + auto-archive, atomically with the audit
90
+ // record. Status mutates the version row rather than minting a new
91
+ // version, so the version stream never captures *who* changed it —
92
+ // the audit log is its only accountability home (docs/AUDIT.md).
93
+ const audit = requireAuditCapability(db);
94
+ const actor = auditActor(ctx);
95
+ await audit.withTransaction(async () => {
96
+ await db.commands.documents.setDocumentStatus({
97
+ document_version_id: documentVersionId,
98
+ status: params.nextStatus,
98
99
  });
99
- }
100
+ if (params.nextStatus === 'published') {
101
+ await db.commands.documents.archivePublishedVersions({
102
+ document_id: params.documentId,
103
+ excludeVersionId: documentVersionId,
104
+ });
105
+ }
106
+ await audit.append({
107
+ documentId: params.documentId,
108
+ collectionId,
109
+ actorId: actor.actorId,
110
+ actorRealm: actor.actorRealm,
111
+ action: AUDIT_ACTIONS.statusChanged,
112
+ field: 'status',
113
+ before: currentStatus,
114
+ after: params.nextStatus,
115
+ });
116
+ });
100
117
  // 6. afterStatusChange hook.
101
118
  await invokeHook(hooks?.afterStatusChange, hookCtx);
102
119
  return { previousStatus: currentStatus, newStatus: params.nextStatus };
@@ -38,7 +38,9 @@ export interface UpdateDocumentSystemFieldsResult {
38
38
  * `setDocumentAvailableLocales`.
39
39
  *
40
40
  * No content hooks fire — these are not content writes. Accountability for
41
- * these mutations is the job of the (planned) document-grain audit log.
41
+ * these mutations is the document-grain audit log: each field that actually
42
+ * changes records a `document.path.changed` / `document.locales.changed` row
43
+ * atomically with the write (docs/AUDIT.md — Workstream 2).
42
44
  *
43
45
  * @throws {BylineError} ERR_NOT_FOUND if the document does not exist.
44
46
  * @throws {BylineError} ERR_PATH_CONFLICT if the path is already in use.
@@ -8,6 +8,7 @@
8
8
  import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
9
9
  import { ERR_NOT_FOUND } from '../../lib/errors.js';
10
10
  import { withLogContext } from '../../lib/logger.js';
11
+ import { AUDIT_ACTIONS, auditActor, requireAuditCapability, sameLocaleSet } from './audit.js';
11
12
  import { resolvePathForUpdate, rethrowPathConflict } from './internals.js';
12
13
  /**
13
14
  * Write a document's system-managed, document-grain fields — `path` and the
@@ -34,7 +35,9 @@ import { resolvePathForUpdate, rethrowPathConflict } from './internals.js';
34
35
  * `setDocumentAvailableLocales`.
35
36
  *
36
37
  * No content hooks fire — these are not content writes. Accountability for
37
- * these mutations is the job of the (planned) document-grain audit log.
38
+ * these mutations is the document-grain audit log: each field that actually
39
+ * changes records a `document.path.changed` / `document.locales.changed` row
40
+ * atomically with the write (docs/AUDIT.md — Workstream 2).
38
41
  *
39
42
  * @throws {BylineError} ERR_NOT_FOUND if the document does not exist.
40
43
  * @throws {BylineError} ERR_PATH_CONFLICT if the path is already in use.
@@ -72,25 +75,59 @@ export async function updateDocumentSystemFields(ctx, params) {
72
75
  documentId: params.documentId,
73
76
  logger: ctx.logger,
74
77
  });
75
- if (pathForCommand !== undefined) {
76
- await db.commands.documents
77
- .updateDocumentPath({
78
- documentId: params.documentId,
79
- collectionId,
80
- locale: sourceLocale,
81
- path: pathForCommand,
82
- })
83
- .catch((err) => rethrowPathConflict(err, pathForCommand, defaultLocale));
84
- }
85
- // Advertised locales: rewrite the document-grain set wholesale.
78
+ // Both document-grain writes and their audit rows commit atomically.
79
+ // These fields are non-versioned, so the version stream never records
80
+ // them — the audit log is their only accountability home. One audit row
81
+ // per field that actually changed (docs/AUDIT.md).
82
+ const currentPath = originalData.path;
83
+ const currentLocales = originalData.availableLocales ?? [];
86
84
  const availableLocalesWritten = params.availableLocales !== undefined;
87
- if (params.availableLocales !== undefined) {
88
- await db.commands.documents.setDocumentAvailableLocales({
89
- documentId: params.documentId,
90
- collectionId,
91
- availableLocales: params.availableLocales,
92
- });
93
- }
85
+ const audit = requireAuditCapability(db);
86
+ const actor = auditActor(ctx);
87
+ await audit.withTransaction(async () => {
88
+ if (pathForCommand !== undefined) {
89
+ await db.commands.documents
90
+ .updateDocumentPath({
91
+ documentId: params.documentId,
92
+ collectionId,
93
+ locale: sourceLocale,
94
+ path: pathForCommand,
95
+ })
96
+ .catch((err) => rethrowPathConflict(err, pathForCommand, defaultLocale));
97
+ if (pathForCommand !== currentPath) {
98
+ await audit.append({
99
+ documentId: params.documentId,
100
+ collectionId,
101
+ actorId: actor.actorId,
102
+ actorRealm: actor.actorRealm,
103
+ action: AUDIT_ACTIONS.pathChanged,
104
+ field: 'path',
105
+ before: currentPath ?? null,
106
+ after: pathForCommand,
107
+ });
108
+ }
109
+ }
110
+ // Advertised locales: rewrite the document-grain set wholesale.
111
+ if (params.availableLocales !== undefined) {
112
+ await db.commands.documents.setDocumentAvailableLocales({
113
+ documentId: params.documentId,
114
+ collectionId,
115
+ availableLocales: params.availableLocales,
116
+ });
117
+ if (!sameLocaleSet(currentLocales, params.availableLocales)) {
118
+ await audit.append({
119
+ documentId: params.documentId,
120
+ collectionId,
121
+ actorId: actor.actorId,
122
+ actorRealm: actor.actorRealm,
123
+ action: AUDIT_ACTIONS.localesChanged,
124
+ field: 'availableLocales',
125
+ before: currentLocales,
126
+ after: params.availableLocales,
127
+ });
128
+ }
129
+ }
130
+ });
94
131
  return {
95
132
  documentId: params.documentId,
96
133
  path: pathForCommand,
@@ -8,7 +8,7 @@
8
8
  import { AdminAuth, AuthError, AuthErrorCodes, createRequestContext, createSuperAdminContext, } from '@byline/auth';
9
9
  import { describe, expect, it, vi } from 'vitest';
10
10
  import { BylineError, ERR_PATH_CONFLICT, ErrorCodes } from '../lib/errors.js';
11
- import { changeDocumentStatus, copyToLocale, createDocument, deleteDocument, duplicateDocument, restoreDocumentVersion, unpublishDocument, updateDocument, updateDocumentWithPatches, } from './document-lifecycle/index.js';
11
+ import { changeDocumentStatus, copyToLocale, createDocument, deleteDocument, duplicateDocument, restoreDocumentVersion, unpublishDocument, updateDocument, updateDocumentSystemFields, updateDocumentWithPatches, } from './document-lifecycle/index.js';
12
12
  // ---------------------------------------------------------------------------
13
13
  // Fixtures / Helpers
14
14
  // ---------------------------------------------------------------------------
@@ -36,6 +36,11 @@ function createMockDb() {
36
36
  const getDocumentById = vi.fn().mockResolvedValue(null);
37
37
  const getCurrentVersionMetadata = vi.fn().mockResolvedValue(null);
38
38
  const getCurrentPath = vi.fn().mockResolvedValue('current-path');
39
+ // Audit capability (docs/AUDIT.md — W2). `withTransaction` is a passthrough
40
+ // in unit tests (runs the unit of work immediately, no real tx); `append`
41
+ // records the calls so write-point tests can assert the audit rows emitted.
42
+ const auditAppend = vi.fn().mockResolvedValue({ id: 'audit-1' });
43
+ const withTransaction = vi.fn(async (fn) => fn());
39
44
  const db = {
40
45
  commands: {
41
46
  collections: {
@@ -45,8 +50,8 @@ function createMockDb() {
45
50
  },
46
51
  documents: {
47
52
  createDocumentVersion,
48
- updateDocumentPath: vi.fn(),
49
- setDocumentAvailableLocales: vi.fn(),
53
+ updateDocumentPath: vi.fn().mockResolvedValue(undefined),
54
+ setDocumentAvailableLocales: vi.fn().mockResolvedValue(undefined),
50
55
  setDocumentStatus,
51
56
  archivePublishedVersions,
52
57
  softDeleteDocument,
@@ -57,7 +62,9 @@ function createMockDb() {
57
62
  ensureCounterGroup: vi.fn(),
58
63
  nextCounterValue: vi.fn(),
59
64
  },
65
+ audit: { append: auditAppend },
60
66
  },
67
+ withTransaction: withTransaction,
61
68
  queries: {
62
69
  collections: {
63
70
  getAllCollections: vi.fn(),
@@ -92,6 +99,8 @@ function createMockDb() {
92
99
  getDocumentById,
93
100
  getCurrentVersionMetadata,
94
101
  getCurrentPath,
102
+ auditAppend,
103
+ withTransaction,
95
104
  };
96
105
  }
97
106
  const noopLogger = {
@@ -683,6 +692,24 @@ describe('Document lifecycle service', () => {
683
692
  expect(result.previousStatus).toBe('draft');
684
693
  expect(result.newStatus).toBe('published');
685
694
  });
695
+ it('records a document.status.changed audit row atomically (from → to)', async () => {
696
+ const { db, getCurrentVersionMetadata, auditAppend, withTransaction } = createMockDb();
697
+ getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
698
+ const ctx = buildCtx(db);
699
+ await changeDocumentStatus(ctx, { documentId: 'doc-1', nextStatus: 'published' });
700
+ // The mutation + audit row run inside one withTransaction (docs/AUDIT.md).
701
+ expect(withTransaction).toHaveBeenCalledOnce();
702
+ expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({
703
+ documentId: 'doc-1',
704
+ collectionId: 'col-1',
705
+ actorId: TEST_ACTOR_ID,
706
+ actorRealm: 'admin',
707
+ action: 'document.status.changed',
708
+ field: 'status',
709
+ before: 'draft',
710
+ after: 'published',
711
+ }));
712
+ });
686
713
  it('throws ERR_NOT_FOUND when document is missing', async () => {
687
714
  const { db, getCurrentVersionMetadata } = createMockDb();
688
715
  getCurrentVersionMetadata.mockResolvedValue(null);
@@ -874,6 +901,76 @@ describe('Document lifecycle service', () => {
874
901
  expect(beforeDelete).toHaveBeenCalledWith(expect.objectContaining({ documentId: 'doc-1', path: 'doc-to-delete' }));
875
902
  expect(afterDelete).toHaveBeenCalledWith(expect.objectContaining({ documentId: 'doc-1', path: 'doc-to-delete' }));
876
903
  });
904
+ it('records a document.deleted audit row atomically with the soft-delete', async () => {
905
+ const { db, getDocumentById, softDeleteDocument, auditAppend, withTransaction } = createMockDb();
906
+ getDocumentById.mockResolvedValue({
907
+ document_version_id: 'ver-1',
908
+ document_id: 'doc-1',
909
+ path: 'doc-to-delete',
910
+ fields: {},
911
+ });
912
+ const ctx = buildCtx(db);
913
+ await deleteDocument(ctx, { documentId: 'doc-1' });
914
+ expect(withTransaction).toHaveBeenCalledOnce();
915
+ expect(softDeleteDocument).toHaveBeenCalledWith({ document_id: 'doc-1' });
916
+ expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({
917
+ documentId: 'doc-1',
918
+ collectionId: 'col-1',
919
+ actorRealm: 'admin',
920
+ action: 'document.deleted',
921
+ }));
922
+ });
923
+ });
924
+ // -----------------------------------------------------------------------
925
+ // updateDocumentSystemFields (audited, non-versioned)
926
+ // -----------------------------------------------------------------------
927
+ describe('updateDocumentSystemFields', () => {
928
+ function setupDoc(getDocumentById, overrides) {
929
+ getDocumentById.mockResolvedValue({
930
+ document_version_id: 'ver-1',
931
+ document_id: 'doc-1',
932
+ path: 'old-slug',
933
+ source_locale: 'en',
934
+ availableLocales: ['en'],
935
+ fields: {},
936
+ ...overrides,
937
+ });
938
+ }
939
+ it('records document.path.changed when the path actually changes', async () => {
940
+ const { db, getDocumentById, auditAppend, withTransaction } = createMockDb();
941
+ setupDoc(getDocumentById);
942
+ const ctx = buildCtx(db);
943
+ await updateDocumentSystemFields(ctx, { documentId: 'doc-1', path: 'new-slug' });
944
+ expect(withTransaction).toHaveBeenCalledOnce();
945
+ expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({
946
+ action: 'document.path.changed',
947
+ field: 'path',
948
+ before: 'old-slug',
949
+ after: 'new-slug',
950
+ }));
951
+ });
952
+ it('records no audit row when the path is unchanged', async () => {
953
+ const { db, getDocumentById, auditAppend } = createMockDb();
954
+ setupDoc(getDocumentById);
955
+ const ctx = buildCtx(db);
956
+ await updateDocumentSystemFields(ctx, { documentId: 'doc-1', path: 'old-slug' });
957
+ expect(auditAppend).not.toHaveBeenCalled();
958
+ });
959
+ it('records document.locales.changed with before/after sets', async () => {
960
+ const { db, getDocumentById, auditAppend } = createMockDb();
961
+ setupDoc(getDocumentById);
962
+ const ctx = buildCtx(db);
963
+ await updateDocumentSystemFields(ctx, {
964
+ documentId: 'doc-1',
965
+ availableLocales: ['en', 'fr'],
966
+ });
967
+ expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({
968
+ action: 'document.locales.changed',
969
+ field: 'availableLocales',
970
+ before: ['en'],
971
+ after: ['en', 'fr'],
972
+ }));
973
+ });
877
974
  });
878
975
  // -----------------------------------------------------------------------
879
976
  // restoreDocumentVersion
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/core",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "3.9.0",
5
+ "version": "3.10.1",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -79,7 +79,7 @@
79
79
  "sharp": "^0.34.5",
80
80
  "uuid": "^14.0.0",
81
81
  "zod": "^4.4.3",
82
- "@byline/auth": "3.9.0"
82
+ "@byline/auth": "3.10.1"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@biomejs/biome": "2.4.15",