@byline/core 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.
@@ -208,6 +208,24 @@ export interface IDbAdapter {
208
208
  collections: ICollectionQueries;
209
209
  documents: IDocumentQueries;
210
210
  };
211
+ /**
212
+ * Optional capability: run `fn` inside a single database transaction so the
213
+ * writes it performs commit or roll back atomically. The adapter propagates
214
+ * the transaction to every `commands.*` call made within `fn` (see
215
+ * docs/TRANSACTIONS.md — AsyncLocalStorage propagation), so a service can
216
+ * compose multiple commands into one unit of work without threading a
217
+ * transaction handle through their signatures.
218
+ *
219
+ * **Loud-failure contract.** Optional because not every adapter can provide
220
+ * interactive transactions — a pure HTTP-gateway serverless driver (Neon
221
+ * HTTP, Cloudflare D1, …) cannot. An adapter that cannot **must omit this
222
+ * method** (or implement it to throw); a consumer that requires atomicity
223
+ * (e.g. the audit log) MUST assert its presence and throw — never silently
224
+ * run non-atomically, which would defeat the very guarantee it provides. See
225
+ * docs/TRANSACTIONS.md ("Serverless / HTTP-gateway databases — the contract
226
+ * seam").
227
+ */
228
+ withTransaction?: <T>(fn: () => Promise<T>) => Promise<T>;
211
229
  /**
212
230
  * Optional maintenance: stamp `source_locale` (the per-document content
213
231
  * anchor) on documents created before the column existed, setting NULL rows
@@ -415,6 +433,8 @@ export interface IDocumentCommands {
415
433
  documentId: string;
416
434
  locale: string;
417
435
  status?: string;
436
+ /** Acting user id for the version audit trail (`created_by`). See docs/AUDIT.md. */
437
+ createdBy?: string;
418
438
  }): Promise<{
419
439
  newVersionId: string;
420
440
  previousVersionId: string;
@@ -12,6 +12,8 @@ export declare const createBaseSchema: (collection?: CollectionDefinition) => z.
12
12
  hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
13
13
  createdAt: z.ZodISODateTime;
14
14
  updatedAt: z.ZodISODateTime;
15
+ createdBy: z.ZodOptional<z.ZodUUID>;
16
+ eventType: z.ZodOptional<z.ZodString>;
15
17
  }, z.core.$strip>;
16
18
  export declare const createFieldsSchema: (fields: Field[], strict?: boolean) => z.ZodObject<{
17
19
  [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
@@ -44,6 +46,8 @@ export declare const createCollectionSchemasForPath: (path: string) => {
44
46
  hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
45
47
  createdAt: z.ZodISODateTime;
46
48
  updatedAt: z.ZodISODateTime;
49
+ createdBy: z.ZodOptional<z.ZodUUID>;
50
+ eventType: z.ZodOptional<z.ZodString>;
47
51
  }, z.core.$strip>;
48
52
  fields: z.ZodObject<{
49
53
  [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
@@ -62,6 +66,8 @@ export declare const createCollectionSchemasForPath: (path: string) => {
62
66
  hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
63
67
  createdAt: z.ZodISODateTime;
64
68
  updatedAt: z.ZodISODateTime;
69
+ createdBy: z.ZodOptional<z.ZodUUID>;
70
+ eventType: z.ZodOptional<z.ZodString>;
65
71
  }, z.core.$strip>;
66
72
  list: z.ZodObject<{
67
73
  docs: z.ZodArray<z.ZodObject<{
@@ -78,6 +84,8 @@ export declare const createCollectionSchemasForPath: (path: string) => {
78
84
  hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
79
85
  createdAt: z.ZodISODateTime;
80
86
  updatedAt: z.ZodISODateTime;
87
+ createdBy: z.ZodOptional<z.ZodUUID>;
88
+ eventType: z.ZodOptional<z.ZodString>;
81
89
  }, z.core.$strip>>;
82
90
  meta: z.ZodObject<{
83
91
  page: z.ZodNumber;
@@ -113,6 +121,8 @@ export declare const createCollectionSchemasForPath: (path: string) => {
113
121
  hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
114
122
  createdAt: z.ZodISODateTime;
115
123
  updatedAt: z.ZodISODateTime;
124
+ createdBy: z.ZodOptional<z.ZodUUID>;
125
+ eventType: z.ZodOptional<z.ZodString>;
116
126
  }, z.core.$strip>>;
117
127
  meta: z.ZodObject<{
118
128
  page: z.ZodNumber;
@@ -140,6 +150,8 @@ export declare const createCollectionSchemasForPath: (path: string) => {
140
150
  hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
141
151
  createdAt: z.ZodISODateTime;
142
152
  updatedAt: z.ZodISODateTime;
153
+ createdBy: z.ZodOptional<z.ZodUUID>;
154
+ eventType: z.ZodOptional<z.ZodString>;
143
155
  }, z.core.$strip>;
144
156
  update: z.ZodObject<{
145
157
  [x: string]: z.ZodOptional<z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>>;
@@ -157,6 +169,8 @@ export declare const createCollectionSchemas: (collection: CollectionDefinition)
157
169
  hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
158
170
  createdAt: z.ZodISODateTime;
159
171
  updatedAt: z.ZodISODateTime;
172
+ createdBy: z.ZodOptional<z.ZodUUID>;
173
+ eventType: z.ZodOptional<z.ZodString>;
160
174
  }, z.core.$strip>;
161
175
  fields: z.ZodObject<{
162
176
  [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
@@ -175,6 +189,8 @@ export declare const createCollectionSchemas: (collection: CollectionDefinition)
175
189
  hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
176
190
  createdAt: z.ZodISODateTime;
177
191
  updatedAt: z.ZodISODateTime;
192
+ createdBy: z.ZodOptional<z.ZodUUID>;
193
+ eventType: z.ZodOptional<z.ZodString>;
178
194
  }, z.core.$strip>;
179
195
  list: z.ZodObject<{
180
196
  docs: z.ZodArray<z.ZodObject<{
@@ -191,6 +207,8 @@ export declare const createCollectionSchemas: (collection: CollectionDefinition)
191
207
  hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
192
208
  createdAt: z.ZodISODateTime;
193
209
  updatedAt: z.ZodISODateTime;
210
+ createdBy: z.ZodOptional<z.ZodUUID>;
211
+ eventType: z.ZodOptional<z.ZodString>;
194
212
  }, z.core.$strip>>;
195
213
  meta: z.ZodObject<{
196
214
  page: z.ZodNumber;
@@ -226,6 +244,8 @@ export declare const createCollectionSchemas: (collection: CollectionDefinition)
226
244
  hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
227
245
  createdAt: z.ZodISODateTime;
228
246
  updatedAt: z.ZodISODateTime;
247
+ createdBy: z.ZodOptional<z.ZodUUID>;
248
+ eventType: z.ZodOptional<z.ZodString>;
229
249
  }, z.core.$strip>>;
230
250
  meta: z.ZodObject<{
231
251
  page: z.ZodNumber;
@@ -253,6 +273,8 @@ export declare const createCollectionSchemas: (collection: CollectionDefinition)
253
273
  hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
254
274
  createdAt: z.ZodISODateTime;
255
275
  updatedAt: z.ZodISODateTime;
276
+ createdBy: z.ZodOptional<z.ZodUUID>;
277
+ eventType: z.ZodOptional<z.ZodString>;
256
278
  }, z.core.$strip>;
257
279
  update: z.ZodObject<{
258
280
  [x: string]: z.ZodOptional<z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>>;
@@ -270,6 +292,8 @@ export declare const createTypedCollectionSchemas: (collection: CollectionDefini
270
292
  hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
271
293
  createdAt: z.ZodISODateTime;
272
294
  updatedAt: z.ZodISODateTime;
295
+ createdBy: z.ZodOptional<z.ZodUUID>;
296
+ eventType: z.ZodOptional<z.ZodString>;
273
297
  }, z.core.$strip>;
274
298
  fields: z.ZodObject<{
275
299
  [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
@@ -288,6 +312,8 @@ export declare const createTypedCollectionSchemas: (collection: CollectionDefini
288
312
  hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
289
313
  createdAt: z.ZodISODateTime;
290
314
  updatedAt: z.ZodISODateTime;
315
+ createdBy: z.ZodOptional<z.ZodUUID>;
316
+ eventType: z.ZodOptional<z.ZodString>;
291
317
  }, z.core.$strip>;
292
318
  list: z.ZodObject<{
293
319
  docs: z.ZodArray<z.ZodObject<{
@@ -304,6 +330,8 @@ export declare const createTypedCollectionSchemas: (collection: CollectionDefini
304
330
  hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
305
331
  createdAt: z.ZodISODateTime;
306
332
  updatedAt: z.ZodISODateTime;
333
+ createdBy: z.ZodOptional<z.ZodUUID>;
334
+ eventType: z.ZodOptional<z.ZodString>;
307
335
  }, z.core.$strip>>;
308
336
  meta: z.ZodObject<{
309
337
  page: z.ZodNumber;
@@ -339,6 +367,8 @@ export declare const createTypedCollectionSchemas: (collection: CollectionDefini
339
367
  hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
340
368
  createdAt: z.ZodISODateTime;
341
369
  updatedAt: z.ZodISODateTime;
370
+ createdBy: z.ZodOptional<z.ZodUUID>;
371
+ eventType: z.ZodOptional<z.ZodString>;
342
372
  }, z.core.$strip>>;
343
373
  meta: z.ZodObject<{
344
374
  page: z.ZodNumber;
@@ -366,6 +396,8 @@ export declare const createTypedCollectionSchemas: (collection: CollectionDefini
366
396
  hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
367
397
  createdAt: z.ZodISODateTime;
368
398
  updatedAt: z.ZodISODateTime;
399
+ createdBy: z.ZodOptional<z.ZodUUID>;
400
+ eventType: z.ZodOptional<z.ZodString>;
369
401
  }, z.core.$strip>;
370
402
  update: z.ZodObject<{
371
403
  [x: string]: z.ZodOptional<z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>>;
@@ -383,6 +415,8 @@ export declare const createTypedCollectionSchemasForPath: (path: string) => {
383
415
  hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
384
416
  createdAt: z.ZodISODateTime;
385
417
  updatedAt: z.ZodISODateTime;
418
+ createdBy: z.ZodOptional<z.ZodUUID>;
419
+ eventType: z.ZodOptional<z.ZodString>;
386
420
  }, z.core.$strip>;
387
421
  fields: z.ZodObject<{
388
422
  [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
@@ -401,6 +435,8 @@ export declare const createTypedCollectionSchemasForPath: (path: string) => {
401
435
  hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
402
436
  createdAt: z.ZodISODateTime;
403
437
  updatedAt: z.ZodISODateTime;
438
+ createdBy: z.ZodOptional<z.ZodUUID>;
439
+ eventType: z.ZodOptional<z.ZodString>;
404
440
  }, z.core.$strip>;
405
441
  list: z.ZodObject<{
406
442
  docs: z.ZodArray<z.ZodObject<{
@@ -417,6 +453,8 @@ export declare const createTypedCollectionSchemasForPath: (path: string) => {
417
453
  hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
418
454
  createdAt: z.ZodISODateTime;
419
455
  updatedAt: z.ZodISODateTime;
456
+ createdBy: z.ZodOptional<z.ZodUUID>;
457
+ eventType: z.ZodOptional<z.ZodString>;
420
458
  }, z.core.$strip>>;
421
459
  meta: z.ZodObject<{
422
460
  page: z.ZodNumber;
@@ -452,6 +490,8 @@ export declare const createTypedCollectionSchemasForPath: (path: string) => {
452
490
  hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
453
491
  createdAt: z.ZodISODateTime;
454
492
  updatedAt: z.ZodISODateTime;
493
+ createdBy: z.ZodOptional<z.ZodUUID>;
494
+ eventType: z.ZodOptional<z.ZodString>;
455
495
  }, z.core.$strip>>;
456
496
  meta: z.ZodObject<{
457
497
  page: z.ZodNumber;
@@ -479,6 +519,8 @@ export declare const createTypedCollectionSchemasForPath: (path: string) => {
479
519
  hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
480
520
  createdAt: z.ZodISODateTime;
481
521
  updatedAt: z.ZodISODateTime;
522
+ createdBy: z.ZodOptional<z.ZodUUID>;
523
+ eventType: z.ZodOptional<z.ZodString>;
482
524
  }, z.core.$strip>;
483
525
  update: z.ZodObject<{
484
526
  [x: string]: z.ZodOptional<z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>>;
@@ -205,6 +205,11 @@ export const createBaseSchema = (collection) => {
205
205
  hasPublishedVersion: z.boolean().optional(),
206
206
  createdAt: z.iso.datetime(),
207
207
  updatedAt: z.iso.datetime(),
208
+ // Version audit metadata — acting user + action (see docs/AUDIT.md — Workstream 1).
209
+ // Declared so list/get/history responses carry them through the
210
+ // server-fn parse; Zod would otherwise strip them as undeclared keys.
211
+ createdBy: z.uuid().optional(),
212
+ eventType: z.string().optional(),
208
213
  });
209
214
  };
210
215
  // Create field schemas for a collection.
@@ -10,7 +10,7 @@ import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
10
10
  import { ERR_NOT_FOUND, ERR_VALIDATION } from '../../lib/errors.js';
11
11
  import { withLogContext } from '../../lib/logger.js';
12
12
  import { getDefaultStatus } from '../../workflow/workflow.js';
13
- import { applyRichTextEmbed, extractVersionId, invokeHook } from './internals.js';
13
+ import { actorId, applyRichTextEmbed, extractVersionId, invokeHook } from './internals.js';
14
14
  import { mergeLocaleData } from './merge-locale-data.js';
15
15
  /**
16
16
  * Copy a document's content from one locale into another, in place on
@@ -133,6 +133,7 @@ export async function copyToLocale(ctx, params) {
133
133
  status: getDefaultStatus(definition),
134
134
  locale: params.targetLocale,
135
135
  previousVersionId,
136
+ createdBy: actorId(ctx),
136
137
  });
137
138
  const documentVersionId = extractVersionId(writeResult.document);
138
139
  await invokeHook(hooks?.afterUpdate, {
@@ -13,7 +13,7 @@ import { normaliseDateFields } from '../../utils/normalise-dates.js';
13
13
  import { slugify } from '../../utils/slugify.js';
14
14
  import { getDefaultStatus } from '../../workflow/workflow.js';
15
15
  import { assignCounterValues } from '../assign-counter-values.js';
16
- import { applyRichTextEmbed, derivePath, extractDocumentId, extractVersionId, invokeHook, maybeAppendOrderKey, rethrowPathConflict, } from './internals.js';
16
+ import { actorId, applyRichTextEmbed, derivePath, extractDocumentId, extractVersionId, invokeHook, maybeAppendOrderKey, rethrowPathConflict, } from './internals.js';
17
17
  /**
18
18
  * Create a new document.
19
19
  *
@@ -75,6 +75,7 @@ export async function createDocument(ctx, params) {
75
75
  status: params.status ?? data.status ?? getDefaultStatus(definition),
76
76
  locale: params.locale ?? defaultLocale,
77
77
  orderKey,
78
+ createdBy: actorId(ctx),
78
79
  })
79
80
  .catch((err) => rethrowPathConflict(err, resolvedPath, defaultLocale));
80
81
  const documentId = extractDocumentId(result.document);
@@ -10,7 +10,7 @@ import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
10
10
  import { ERR_NOT_FOUND, ERR_VALIDATION } from '../../lib/errors.js';
11
11
  import { withLogContext } from '../../lib/logger.js';
12
12
  import { getDefaultStatus } from '../../workflow/workflow.js';
13
- import { invokeHook } from './internals.js';
13
+ import { actorId, invokeHook } from './internals.js';
14
14
  /**
15
15
  * Remove one content locale's data from a document, in place on the same
16
16
  * document, by writing a new immutable version that omits that locale's
@@ -90,6 +90,7 @@ export async function deleteLocale(ctx, params) {
90
90
  documentId: params.documentId,
91
91
  locale: params.locale,
92
92
  status: getDefaultStatus(definition),
93
+ createdBy: actorId(ctx),
93
94
  });
94
95
  if (result == null) {
95
96
  throw ERR_NOT_FOUND({
@@ -12,7 +12,7 @@ import { withLogContext } from '../../lib/logger.js';
12
12
  import { slugify } from '../../utils/slugify.js';
13
13
  import { getDefaultStatus } from '../../workflow/workflow.js';
14
14
  import { assignCounterValues } from '../assign-counter-values.js';
15
- import { applyRichTextEmbed, derivePath, extractDocumentId, extractVersionId, invokeHook, isPathConflictError, maybeAppendOrderKey, rethrowPathConflict, stripMetaIdsInPlace, } from './internals.js';
15
+ import { actorId, applyRichTextEmbed, derivePath, extractDocumentId, extractVersionId, invokeHook, isPathConflictError, maybeAppendOrderKey, rethrowPathConflict, stripMetaIdsInPlace, } from './internals.js';
16
16
  /**
17
17
  * Apply the `" (copy)"` suffix to the configured `useAsTitle` field on a
18
18
  * duplicate's data tree. Handles both shapes:
@@ -170,6 +170,7 @@ export async function duplicateDocument(ctx, params) {
170
170
  status: defaultStatus,
171
171
  locale: 'all',
172
172
  orderKey,
173
+ createdBy: actorId(ctx),
173
174
  })
174
175
  .catch((err) => rethrowPathConflict(err, finalPath, defaultLocale));
175
176
  }
@@ -194,6 +195,7 @@ export async function duplicateDocument(ctx, params) {
194
195
  status: defaultStatus,
195
196
  locale: 'all',
196
197
  orderKey,
198
+ createdBy: actorId(ctx),
197
199
  })
198
200
  .catch((retryErr) => rethrowPathConflict(retryErr, finalPath, defaultLocale));
199
201
  }
@@ -15,6 +15,22 @@ import { type CollectionDefinition, type CollectionHookSlot } from '../../@types
15
15
  import type { BylineLogger } from '../../lib/logger.js';
16
16
  import type { SlugifierFn } from '../../utils/slugify.js';
17
17
  import type { DocumentLifecycleContext } from './context.js';
18
+ /**
19
+ * The acting user's id for the version audit trail (`created_by` on
20
+ * `byline_document_versions`).
21
+ *
22
+ * Returns the id only when it is a real **persisted user id** — i.e. a UUID.
23
+ * Synthetic actors used by scripts, seeds, and tests (e.g.
24
+ * `createSuperAdminContext({ id: 'import-docs-script' })`, or the default
25
+ * `'super-admin'`) are **not** users: their non-UUID ids would be rejected by
26
+ * the `uuid` column outright, and the correct audit value for a system/tooling
27
+ * write is NULL regardless. So a non-UUID id — and a missing `requestContext`
28
+ * (the seeds/migrations escape hatch) — both yield `undefined` → NULL
29
+ * `created_by`, which the history strip renders as "unknown". Real
30
+ * `AdminAuth` / `UserAuth` actors always carry UUID ids, so their attribution
31
+ * is unaffected. See docs/AUDIT.md — Workstream 1.
32
+ */
33
+ export declare function actorId(ctx: DocumentLifecycleContext): string | undefined;
18
34
  /**
19
35
  * Safely invoke an optional hook slot, awaiting the result if it returns a
20
36
  * Promise. When the slot is an array of functions they are executed
@@ -17,6 +17,30 @@ import { ERR_PATH_CONFLICT, ErrorCodes } from '../../lib/errors.js';
17
17
  import { generateKeyBetween } from '../../lib/fractional-index.js';
18
18
  import { createReadContext } from '../populate.js';
19
19
  import { embedRichTextFields } from '../richtext-embed.js';
20
+ /**
21
+ * Matches a canonical UUID (any version). Real admin / end-user actors carry
22
+ * UUID ids (`uuidv7`); synthetic actors do not.
23
+ */
24
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
25
+ /**
26
+ * The acting user's id for the version audit trail (`created_by` on
27
+ * `byline_document_versions`).
28
+ *
29
+ * Returns the id only when it is a real **persisted user id** — i.e. a UUID.
30
+ * Synthetic actors used by scripts, seeds, and tests (e.g.
31
+ * `createSuperAdminContext({ id: 'import-docs-script' })`, or the default
32
+ * `'super-admin'`) are **not** users: their non-UUID ids would be rejected by
33
+ * the `uuid` column outright, and the correct audit value for a system/tooling
34
+ * write is NULL regardless. So a non-UUID id — and a missing `requestContext`
35
+ * (the seeds/migrations escape hatch) — both yield `undefined` → NULL
36
+ * `created_by`, which the history strip renders as "unknown". Real
37
+ * `AdminAuth` / `UserAuth` actors always carry UUID ids, so their attribution
38
+ * is unaffected. See docs/AUDIT.md — Workstream 1.
39
+ */
40
+ export function actorId(ctx) {
41
+ const id = ctx.requestContext?.actor?.id;
42
+ return id != null && UUID_RE.test(id) ? id : undefined;
43
+ }
20
44
  /**
21
45
  * Safely invoke an optional hook slot, awaiting the result if it returns a
22
46
  * Promise. When the slot is an array of functions they are executed
@@ -10,7 +10,7 @@ import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
10
10
  import { ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_VALIDATION } from '../../lib/errors.js';
11
11
  import { withLogContext } from '../../lib/logger.js';
12
12
  import { getDefaultStatus } from '../../workflow/workflow.js';
13
- import { applyRichTextEmbed, extractDocumentId, extractVersionId, invokeHook } from './internals.js';
13
+ import { actorId, applyRichTextEmbed, extractDocumentId, extractVersionId, invokeHook, } from './internals.js';
14
14
  /**
15
15
  * Restore a historical document version as the new current version.
16
16
  *
@@ -139,6 +139,7 @@ export async function restoreDocumentVersion(ctx, params) {
139
139
  status: getDefaultStatus(definition),
140
140
  locale: 'all',
141
141
  previousVersionId: currentMeta.document_version_id,
142
+ createdBy: actorId(ctx),
142
143
  });
143
144
  const documentId = extractDocumentId(result.document) || params.documentId;
144
145
  const documentVersionId = extractVersionId(result.document);
@@ -13,7 +13,7 @@ import { applyPatches } from '../../patches/index.js';
13
13
  import { normaliseDateFields } from '../../utils/normalise-dates.js';
14
14
  import { getDefaultStatus } from '../../workflow/workflow.js';
15
15
  import { assignCounterValues } from '../assign-counter-values.js';
16
- import { applyRichTextEmbed, extractDocumentId, extractVersionId, invokeHook, resolvePathForUpdate, rethrowPathConflict, } from './internals.js';
16
+ import { actorId, applyRichTextEmbed, extractDocumentId, extractVersionId, invokeHook, resolvePathForUpdate, rethrowPathConflict, } from './internals.js';
17
17
  /**
18
18
  * Update a document via full replacement (PUT semantics).
19
19
  *
@@ -85,6 +85,7 @@ export async function updateDocument(ctx, params) {
85
85
  status: defaultStatus,
86
86
  locale: requestLocale,
87
87
  previousVersionId: originalData.document_version_id,
88
+ createdBy: actorId(ctx),
88
89
  })
89
90
  .catch((err) => rethrowPathConflict(err, pathForCommand ?? '', defaultLocale));
90
91
  const documentId = extractDocumentId(result.document) || params.documentId;
@@ -198,6 +199,7 @@ export async function updateDocumentWithPatches(ctx, params) {
198
199
  status: defaultStatus,
199
200
  locale: requestLocale,
200
201
  previousVersionId: originalData.document_version_id,
202
+ createdBy: actorId(ctx),
201
203
  })
202
204
  .catch((err) => rethrowPathConflict(err, pathForCommand ?? '', defaultLocale));
203
205
  const documentId = extractDocumentId(result.document) || params.documentId;
@@ -104,6 +104,10 @@ const noopLogger = {
104
104
  trace: vi.fn(),
105
105
  silent: vi.fn(),
106
106
  };
107
+ // A real persisted-user id is a UUID; `actorId()` only attributes UUIDs (see
108
+ // the regression note below). Use a valid UUID so the default context's
109
+ // writes are attributed.
110
+ const TEST_ACTOR_ID = '01901234-0000-7000-8000-000000000001';
107
111
  function buildCtx(db, definition = minimalCollection) {
108
112
  return {
109
113
  db,
@@ -117,7 +121,7 @@ function buildCtx(db, definition = minimalCollection) {
117
121
  // tests do not have to care about ability enforcement. The dedicated
118
122
  // "enforcement" block below covers the missing-context / missing-ability
119
123
  // negative cases.
120
- requestContext: createSuperAdminContext({ id: 'test-super-admin' }),
124
+ requestContext: createSuperAdminContext({ id: TEST_ACTOR_ID }),
121
125
  };
122
126
  }
123
127
  // ---------------------------------------------------------------------------
@@ -139,6 +143,28 @@ describe('Document lifecycle service', () => {
139
143
  expect(result.documentId).toBe('doc-1');
140
144
  expect(result.documentVersionId).toBe('ver-1');
141
145
  });
146
+ it('passes the acting user id as createdBy for the audit trail', async () => {
147
+ const { db, createDocumentVersion } = createMockDb();
148
+ const ctx = buildCtx(db);
149
+ await createDocument(ctx, {
150
+ data: { title: 'Hello' },
151
+ locale: 'en',
152
+ });
153
+ // Audit contract (docs/AUDIT.md — W1): every version row
154
+ // records the actor that created it.
155
+ expect(createDocumentVersion.mock.calls[0]?.[0].createdBy).toBe(TEST_ACTOR_ID);
156
+ });
157
+ it('writes NULL createdBy for a synthetic (non-UUID) script/seed actor', async () => {
158
+ // Regression guard (v3.8.0): a synthetic super-admin id such as
159
+ // `import-docs-script` is not a real user and is not a UUID — writing
160
+ // it into the `created_by` UUID column crashed every import/seed. Such
161
+ // system/tooling writes must attribute to NULL, not the synthetic id.
162
+ const { db, createDocumentVersion } = createMockDb();
163
+ const ctx = buildCtx(db);
164
+ ctx.requestContext = createSuperAdminContext({ id: 'import-docs-script' });
165
+ await createDocument(ctx, { data: { title: 'Hello' }, locale: 'en' });
166
+ expect(createDocumentVersion.mock.calls[0]?.[0].createdBy).toBeUndefined();
167
+ });
142
168
  it('invokes beforeCreate and afterCreate hooks in order', async () => {
143
169
  const callOrder = [];
144
170
  const hooks = {
@@ -307,6 +333,16 @@ describe('Document lifecycle service', () => {
307
333
  // updateDocument (PUT)
308
334
  // -----------------------------------------------------------------------
309
335
  describe('updateDocument', () => {
336
+ it('passes the acting user id as createdBy for the audit trail', async () => {
337
+ const { db, getDocumentById, createDocumentVersion } = createMockDb();
338
+ getDocumentById.mockResolvedValue({ status: 'draft', fields: { title: 'Old' } });
339
+ const ctx = buildCtx(db);
340
+ await updateDocument(ctx, {
341
+ documentId: 'doc-1',
342
+ data: { title: 'New' },
343
+ });
344
+ expect(createDocumentVersion.mock.calls[0]?.[0].createdBy).toBe(TEST_ACTOR_ID);
345
+ });
310
346
  it('fetches the original before calling hooks', async () => {
311
347
  const { db, getDocumentById, createDocumentVersion } = createMockDb();
312
348
  getDocumentById.mockResolvedValue({ status: 'draft', fields: { title: 'Old' } });
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.7.0",
5
+ "version": "3.9.0",
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.7.0"
82
+ "@byline/auth": "3.9.0"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@biomejs/biome": "2.4.15",