@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`).
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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`).
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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(
|
|
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.
|
|
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.
|
|
82
|
+
"@byline/auth": "3.9.0"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
85
|
"@biomejs/biome": "2.4.15",
|