@byline/core 3.8.0 → 3.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -17,10 +17,18 @@ import type { SlugifierFn } from '../../utils/slugify.js';
17
17
  import type { DocumentLifecycleContext } from './context.js';
18
18
  /**
19
19
  * The acting user's id for the version audit trail (`created_by` on
20
- * `byline_document_versions`). `undefined` for internal-tooling callers
21
- * without a `requestContext` (seeds, migrations — the documented escape
22
- * hatch), which persists as NULL. Both actor realms (`AdminAuth`,
23
- * `UserAuth`) carry `id`. See docs/AUDIT.md Workstream 1.
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.
24
32
  */
25
33
  export declare function actorId(ctx: DocumentLifecycleContext): string | undefined;
26
34
  /**
@@ -17,15 +17,29 @@ 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;
20
25
  /**
21
26
  * The acting user's id for the version audit trail (`created_by` on
22
- * `byline_document_versions`). `undefined` for internal-tooling callers
23
- * without a `requestContext` (seeds, migrations — the documented escape
24
- * hatch), which persists as NULL. Both actor realms (`AdminAuth`,
25
- * `UserAuth`) carry `id`. See docs/AUDIT.md Workstream 1.
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.
26
39
  */
27
40
  export function actorId(ctx) {
28
- return ctx.requestContext?.actor?.id;
41
+ const id = ctx.requestContext?.actor?.id;
42
+ return id != null && UUID_RE.test(id) ? id : undefined;
29
43
  }
30
44
  /**
31
45
  * Safely invoke an optional hook slot, awaiting the result if it returns a
@@ -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
  // ---------------------------------------------------------------------------
@@ -148,7 +152,18 @@ describe('Document lifecycle service', () => {
148
152
  });
149
153
  // Audit contract (docs/AUDIT.md — W1): every version row
150
154
  // records the actor that created it.
151
- expect(createDocumentVersion.mock.calls[0]?.[0].createdBy).toBe('test-super-admin');
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();
152
167
  });
153
168
  it('invokes beforeCreate and afterCreate hooks in order', async () => {
154
169
  const callOrder = [];
@@ -326,7 +341,7 @@ describe('Document lifecycle service', () => {
326
341
  documentId: 'doc-1',
327
342
  data: { title: 'New' },
328
343
  });
329
- expect(createDocumentVersion.mock.calls[0]?.[0].createdBy).toBe('test-super-admin');
344
+ expect(createDocumentVersion.mock.calls[0]?.[0].createdBy).toBe(TEST_ACTOR_ID);
330
345
  });
331
346
  it('fetches the original before calling hooks', async () => {
332
347
  const { db, getDocumentById, createDocumentVersion } = createMockDb();
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.8.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.8.0"
82
+ "@byline/auth": "3.9.0"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@biomejs/biome": "2.4.15",