@byline/core 3.5.1 → 3.7.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.
Files changed (37) hide show
  1. package/dist/@types/field-types.d.ts +22 -0
  2. package/dist/@types/site-config.d.ts +8 -1
  3. package/dist/services/document-lifecycle/context.d.ts +79 -0
  4. package/dist/services/document-lifecycle/context.js +8 -0
  5. package/dist/services/document-lifecycle/copy-to-locale.d.ts +62 -0
  6. package/dist/services/document-lifecycle/copy-to-locale.js +157 -0
  7. package/dist/services/document-lifecycle/create.d.ts +45 -0
  8. package/dist/services/document-lifecycle/create.js +91 -0
  9. package/dist/services/document-lifecycle/delete-locale.d.ts +44 -0
  10. package/dist/services/document-lifecycle/delete-locale.js +117 -0
  11. package/dist/services/document-lifecycle/delete.d.ts +37 -0
  12. package/dist/services/document-lifecycle/delete.js +113 -0
  13. package/dist/services/document-lifecycle/duplicate.d.ts +60 -0
  14. package/dist/services/document-lifecycle/duplicate.js +219 -0
  15. package/dist/services/document-lifecycle/index.d.ts +43 -0
  16. package/dist/services/document-lifecycle/index.js +33 -0
  17. package/dist/services/document-lifecycle/internals.d.ts +113 -0
  18. package/dist/services/document-lifecycle/internals.js +218 -0
  19. package/dist/services/document-lifecycle/merge-locale-data.d.ts +48 -0
  20. package/dist/services/document-lifecycle/merge-locale-data.js +147 -0
  21. package/dist/services/document-lifecycle/restore.d.ts +57 -0
  22. package/dist/services/document-lifecycle/restore.js +162 -0
  23. package/dist/services/document-lifecycle/status.d.ts +41 -0
  24. package/dist/services/document-lifecycle/status.js +150 -0
  25. package/dist/services/document-lifecycle/system-fields.d.ts +62 -0
  26. package/dist/services/document-lifecycle/system-fields.js +100 -0
  27. package/dist/services/document-lifecycle/update.d.ts +84 -0
  28. package/dist/services/document-lifecycle/update.js +216 -0
  29. package/dist/services/document-lifecycle.test.node.js +1 -1
  30. package/dist/services/document-to-markdown.d.ts +79 -0
  31. package/dist/services/document-to-markdown.js +267 -0
  32. package/dist/services/document-to-markdown.test.node.d.ts +8 -0
  33. package/dist/services/document-to-markdown.test.node.js +161 -0
  34. package/dist/services/field-upload.js +1 -1
  35. package/dist/services/index.d.ts +2 -1
  36. package/dist/services/index.js +2 -1
  37. package/package.json +2 -2
@@ -0,0 +1,150 @@
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 { resolveHooks } from '../../@types/index.js';
9
+ import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
10
+ import { ERR_INVALID_TRANSITION, ERR_NOT_FOUND } from '../../lib/errors.js';
11
+ import { withLogContext } from '../../lib/logger.js';
12
+ import { getWorkflow, validateStatusTransition } from '../../workflow/workflow.js';
13
+ import { invokeHook } from './internals.js';
14
+ /**
15
+ * Change a document's workflow status.
16
+ *
17
+ * Flow:
18
+ * 1. Fetch current document metadata
19
+ * 2. Validate transition via `validateStatusTransition()`
20
+ * 3. `hooks.beforeStatusChange({ documentId, documentVersionId, collectionPath, previousStatus, nextStatus })`
21
+ * 4. `db.commands.documents.setDocumentStatus(...)` — in-place mutation
22
+ * 5. Auto-archive: if transitioning to `'published'`, archive other published versions
23
+ * 6. `hooks.afterStatusChange({ documentId, documentVersionId, collectionPath, previousStatus, nextStatus })`
24
+ */
25
+ export async function changeDocumentStatus(ctx, params) {
26
+ return withLogContext({ domain: 'services', module: 'lifecycle', function: 'changeDocumentStatus' }, async () => {
27
+ const { db, definition, collectionId, collectionPath } = ctx;
28
+ // Every transition requires the general changeStatus ability.
29
+ // Transitions that target the `published` status additionally
30
+ // require the narrower `publish` ability — so installations can
31
+ // grant "move things through the workflow" without also granting
32
+ // "flip the final publish switch".
33
+ assertActorCanPerform(ctx.requestContext, collectionPath, 'changeStatus');
34
+ if (params.nextStatus === 'published') {
35
+ assertActorCanPerform(ctx.requestContext, collectionPath, 'publish');
36
+ }
37
+ // Single-status workflows (e.g. SINGLE_STATUS_WORKFLOW for lookups)
38
+ // have no transitions to perform. Reject early with a clear message
39
+ // rather than relying on the generic ±1-step validator.
40
+ const workflow = getWorkflow(definition);
41
+ if (workflow.statuses.length <= 1) {
42
+ throw ERR_INVALID_TRANSITION({
43
+ message: `collection '${collectionPath}' has a single-status workflow; status transitions are not supported`,
44
+ details: { collectionPath, nextStatus: params.nextStatus },
45
+ }).log(ctx.logger);
46
+ }
47
+ const hooks = await resolveHooks(definition);
48
+ // 1. Fetch current version metadata. No field reconstruction needed —
49
+ // status transitions only touch the document_versions.status column.
50
+ const latest = await db.queries.documents.getCurrentVersionMetadata({
51
+ collection_id: collectionId,
52
+ document_id: params.documentId,
53
+ });
54
+ if (latest == null) {
55
+ throw ERR_NOT_FOUND({
56
+ message: 'document not found',
57
+ details: { documentId: params.documentId },
58
+ }).log(ctx.logger);
59
+ }
60
+ const currentStatus = latest.status ?? 'draft';
61
+ const documentVersionId = latest.document_version_id;
62
+ // 2. Validate transition.
63
+ const result = validateStatusTransition(workflow, currentStatus, params.nextStatus);
64
+ if (!result.valid) {
65
+ throw ERR_INVALID_TRANSITION({
66
+ message: result.reason ??
67
+ `invalid status transition from '${currentStatus}' to '${params.nextStatus}'`,
68
+ details: { currentStatus, nextStatus: params.nextStatus },
69
+ }).log(ctx.logger);
70
+ }
71
+ // Resolve the document's canonical path so the hooks can act on the
72
+ // specific document/URL (CDN purge, cache-key drop). Narrow lookup —
73
+ // getCurrentVersionMetadata deliberately omits the path subquery.
74
+ const path = (await ctx.db.queries.documents.getCurrentPath({
75
+ collection_id: collectionId,
76
+ document_id: params.documentId,
77
+ })) ?? '';
78
+ const hookCtx = {
79
+ documentId: params.documentId,
80
+ documentVersionId,
81
+ collectionPath,
82
+ path,
83
+ previousStatus: currentStatus,
84
+ nextStatus: params.nextStatus,
85
+ };
86
+ // 3. beforeStatusChange hook.
87
+ 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,
98
+ });
99
+ }
100
+ // 6. afterStatusChange hook.
101
+ await invokeHook(hooks?.afterStatusChange, hookCtx);
102
+ return { previousStatus: currentStatus, newStatus: params.nextStatus };
103
+ });
104
+ }
105
+ /**
106
+ * Unpublish a document by archiving its published version(s).
107
+ *
108
+ * Flow:
109
+ * 1. `hooks.beforeUnpublish({ documentId, collectionPath })`
110
+ * 2. `db.commands.documents.archivePublishedVersions(...)`
111
+ * 3. `hooks.afterUnpublish({ documentId, collectionPath, archivedCount })`
112
+ */
113
+ export async function unpublishDocument(ctx, params) {
114
+ return withLogContext({ domain: 'services', module: 'lifecycle', function: 'unpublishDocument' }, async () => {
115
+ const { db, collectionId, collectionPath, definition } = ctx;
116
+ // Unpublish is a workflow transition out of `published` — reuse the
117
+ // changeStatus gate rather than a separate ability.
118
+ assertActorCanPerform(ctx.requestContext, collectionPath, 'changeStatus');
119
+ // Single-status workflows have nothing to unpublish to.
120
+ const workflow = getWorkflow(definition);
121
+ if (workflow.statuses.length <= 1) {
122
+ throw ERR_INVALID_TRANSITION({
123
+ message: `collection '${collectionPath}' has a single-status workflow; unpublish is not supported`,
124
+ details: { collectionPath },
125
+ }).log(ctx.logger);
126
+ }
127
+ const hooks = await resolveHooks(definition);
128
+ // Resolve the document's canonical path so the hooks can target the
129
+ // specific document/URL (CDN purge, cache-key drop).
130
+ const path = (await db.queries.documents.getCurrentPath({
131
+ collection_id: collectionId,
132
+ document_id: params.documentId,
133
+ })) ?? '';
134
+ await invokeHook(hooks?.beforeUnpublish, {
135
+ documentId: params.documentId,
136
+ collectionPath,
137
+ path,
138
+ });
139
+ const archivedCount = await db.commands.documents.archivePublishedVersions({
140
+ document_id: params.documentId,
141
+ });
142
+ await invokeHook(hooks?.afterUnpublish, {
143
+ documentId: params.documentId,
144
+ collectionPath,
145
+ path,
146
+ archivedCount,
147
+ });
148
+ return { archivedCount };
149
+ });
150
+ }
@@ -0,0 +1,62 @@
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 { DocumentLifecycleContext } from './context.js';
9
+ export interface UpdateDocumentSystemFieldsResult {
10
+ documentId: string;
11
+ /** The path actually written, or `undefined` when no path write occurred. */
12
+ path?: string;
13
+ /** Whether the advertised-locale set was rewritten this call. */
14
+ availableLocalesWritten: boolean;
15
+ }
16
+ /**
17
+ * Write a document's system-managed, document-grain fields — `path` and the
18
+ * editorial `availableLocales` set — **without** minting a new version or
19
+ * touching workflow status.
20
+ *
21
+ * These fields are document-grain (they live in `byline_document_paths` and
22
+ * `byline_document_available_locales`, keyed by logical document, sticky across
23
+ * versions), so a workflow status change would falsely imply the edit is gated
24
+ * behind publish. It is not: the write is immediate and applies across every
25
+ * version. This service backs the admin path / available-locales widgets'
26
+ * direct-write Save (the `direct-write` and `both` dirty-reason cases). The
27
+ * public *advertised* set remains the intersection of `availableLocales` with
28
+ * the resolved version's completeness ledger. See docs/I18N.md.
29
+ *
30
+ * Flow:
31
+ * 1. `assertActorCanPerform('update')` — same auth gate as content writes.
32
+ * 2. Fetch the document to resolve its `source_locale` anchor + current path.
33
+ * 3. Path (when supplied): `resolvePathForUpdate` enforces the source-locale
34
+ * rule (translation-locale path edits are dropped with a warn); a real
35
+ * change is written via `updateDocumentPath`, mapping the unique-constraint
36
+ * violation to `ERR_PATH_CONFLICT`.
37
+ * 4. `availableLocales` (when supplied): rewritten wholesale via
38
+ * `setDocumentAvailableLocales`.
39
+ *
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.
42
+ *
43
+ * @throws {BylineError} ERR_NOT_FOUND if the document does not exist.
44
+ * @throws {BylineError} ERR_PATH_CONFLICT if the path is already in use.
45
+ */
46
+ export declare function updateDocumentSystemFields(ctx: DocumentLifecycleContext, params: {
47
+ documentId: string;
48
+ locale?: string;
49
+ /**
50
+ * Explicit path override from the path widget. `null` / empty / omitted
51
+ * means "no path write" (the existing row stays sticky). A non-empty
52
+ * string is written when the request locale is the document's source
53
+ * locale; on a translation locale it is dropped with a warn.
54
+ */
55
+ path?: string | null;
56
+ /**
57
+ * The editorial advertised-locale set from the available-locales widget.
58
+ * `undefined` means "no advertised-locale write"; an explicit array — `[]`
59
+ * included — replaces the set wholesale.
60
+ */
61
+ availableLocales?: string[];
62
+ }): Promise<UpdateDocumentSystemFieldsResult>;
@@ -0,0 +1,100 @@
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 { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
9
+ import { ERR_NOT_FOUND } from '../../lib/errors.js';
10
+ import { withLogContext } from '../../lib/logger.js';
11
+ import { resolvePathForUpdate, rethrowPathConflict } from './internals.js';
12
+ /**
13
+ * Write a document's system-managed, document-grain fields — `path` and the
14
+ * editorial `availableLocales` set — **without** minting a new version or
15
+ * touching workflow status.
16
+ *
17
+ * These fields are document-grain (they live in `byline_document_paths` and
18
+ * `byline_document_available_locales`, keyed by logical document, sticky across
19
+ * versions), so a workflow status change would falsely imply the edit is gated
20
+ * behind publish. It is not: the write is immediate and applies across every
21
+ * version. This service backs the admin path / available-locales widgets'
22
+ * direct-write Save (the `direct-write` and `both` dirty-reason cases). The
23
+ * public *advertised* set remains the intersection of `availableLocales` with
24
+ * the resolved version's completeness ledger. See docs/I18N.md.
25
+ *
26
+ * Flow:
27
+ * 1. `assertActorCanPerform('update')` — same auth gate as content writes.
28
+ * 2. Fetch the document to resolve its `source_locale` anchor + current path.
29
+ * 3. Path (when supplied): `resolvePathForUpdate` enforces the source-locale
30
+ * rule (translation-locale path edits are dropped with a warn); a real
31
+ * change is written via `updateDocumentPath`, mapping the unique-constraint
32
+ * violation to `ERR_PATH_CONFLICT`.
33
+ * 4. `availableLocales` (when supplied): rewritten wholesale via
34
+ * `setDocumentAvailableLocales`.
35
+ *
36
+ * 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
+ *
39
+ * @throws {BylineError} ERR_NOT_FOUND if the document does not exist.
40
+ * @throws {BylineError} ERR_PATH_CONFLICT if the path is already in use.
41
+ */
42
+ export async function updateDocumentSystemFields(ctx, params) {
43
+ return withLogContext({ domain: 'services', module: 'lifecycle', function: 'updateDocumentSystemFields' }, async () => {
44
+ const { db, collectionId, collectionPath, defaultLocale } = ctx;
45
+ assertActorCanPerform(ctx.requestContext, collectionPath, 'update');
46
+ const requestLocale = params.locale ?? defaultLocale;
47
+ // Resolve the document's source-locale anchor + current path. Both feed
48
+ // the path source-locale guard below; the fetch also asserts existence.
49
+ const latest = await db.queries.documents.getDocumentById({
50
+ collection_id: collectionId,
51
+ document_id: params.documentId,
52
+ locale: requestLocale,
53
+ reconstruct: true,
54
+ });
55
+ if (latest == null) {
56
+ throw ERR_NOT_FOUND({
57
+ message: 'document not found',
58
+ details: { documentId: params.documentId },
59
+ }).log(ctx.logger);
60
+ }
61
+ const originalData = latest;
62
+ const sourceLocale = originalData.source_locale ?? defaultLocale;
63
+ // Path: honour the same source-locale-only rule the versioned write
64
+ // uses. `resolvePathForUpdate` returns `undefined` to mean "skip the
65
+ // write" (null/empty override, or a translation-locale save).
66
+ const explicitPath = typeof params.path === 'string' && params.path.length > 0 ? params.path : null;
67
+ const pathForCommand = resolvePathForUpdate({
68
+ explicitPath,
69
+ currentPath: originalData.path,
70
+ requestLocale,
71
+ sourceLocale,
72
+ documentId: params.documentId,
73
+ logger: ctx.logger,
74
+ });
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.
86
+ 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
+ }
94
+ return {
95
+ documentId: params.documentId,
96
+ path: pathForCommand,
97
+ availableLocalesWritten,
98
+ };
99
+ });
100
+ }
@@ -0,0 +1,84 @@
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 { DocumentPatch } from '../../patches/index.js';
9
+ import type { DocumentLifecycleContext } from './context.js';
10
+ export interface UpdateDocumentResult {
11
+ documentId: string;
12
+ documentVersionId: string;
13
+ }
14
+ export interface UpdateDocumentWithPatchesResult {
15
+ documentId: string;
16
+ documentVersionId: string;
17
+ }
18
+ /**
19
+ * Update a document via full replacement (PUT semantics).
20
+ *
21
+ * Unlike the previous implementation, this now fetches the current version
22
+ * from storage to provide a real `originalData` to hooks.
23
+ *
24
+ * Flow:
25
+ * 1. Fetch current document via `getDocumentById({ reconstruct: true })`
26
+ * 2. `normaliseDateFields(data)`
27
+ * 3. `hooks.beforeUpdate({ data, originalData, collectionPath })`
28
+ * 4. `db.commands.documents.createDocumentVersion(...)` (action = 'update')
29
+ * 5. `hooks.afterUpdate({ data, originalData, collectionPath, documentId, documentVersionId })`
30
+ */
31
+ export declare function updateDocument(ctx: DocumentLifecycleContext, params: {
32
+ documentId: string;
33
+ data: Record<string, any>;
34
+ locale?: string;
35
+ /**
36
+ * Explicit path override. When omitted, the previous version's path
37
+ * carries forward unchanged (sticky). The lifecycle never re-derives
38
+ * `path` from the source field on update — that is an explicit user
39
+ * action driven by the admin path widget.
40
+ */
41
+ path?: string;
42
+ /**
43
+ * The editorial advertised-locale set. `undefined` leaves the existing
44
+ * set untouched (sticky — document-grain, like `path`); an explicit array
45
+ * (empty included) replaces it wholesale. Driven by the admin
46
+ * available-locales sidebar widget. See docs/I18N.md.
47
+ */
48
+ availableLocales?: string[];
49
+ }): Promise<UpdateDocumentResult>;
50
+ /**
51
+ * Update a document via patch application.
52
+ *
53
+ * Flow:
54
+ * 1. Fetch current document via `getDocumentById({ reconstruct: true })`
55
+ * 2. Optimistic concurrency check on `documentVersionId`
56
+ * 3. `applyPatches(definition, originalData, patches)` → `nextData`
57
+ * 4. `normaliseDateFields(nextData)`
58
+ * 5. `hooks.beforeUpdate({ data: nextData, originalData, collectionPath })`
59
+ * 6. `db.commands.documents.createDocumentVersion(...)` (action = 'update')
60
+ * 7. `hooks.afterUpdate({ data: nextData, originalData, collectionPath, documentId, documentVersionId })`
61
+ *
62
+ * @throws {BylineError} ERR_CONFLICT if the supplied `documentVersionId` does not match the current version.
63
+ * @throws {BylineError} ERR_PATCH_FAILED if `applyPatches` fails.
64
+ */
65
+ export declare function updateDocumentWithPatches(ctx: DocumentLifecycleContext, params: {
66
+ documentId: string;
67
+ patches: DocumentPatch[];
68
+ /** Client-supplied version ID for optimistic concurrency. */
69
+ documentVersionId?: string;
70
+ locale?: string;
71
+ /**
72
+ * Explicit path override (typically supplied alongside patches when
73
+ * the admin path widget has been edited). When omitted, sticky from
74
+ * the previous version.
75
+ */
76
+ path?: string;
77
+ /**
78
+ * The editorial advertised-locale set (typically supplied alongside
79
+ * patches when the admin available-locales widget has been edited).
80
+ * `undefined` leaves the existing set untouched (sticky); an explicit
81
+ * array replaces it wholesale. See docs/I18N.md.
82
+ */
83
+ availableLocales?: string[];
84
+ }): Promise<UpdateDocumentWithPatchesResult>;
@@ -0,0 +1,216 @@
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 { resolveHooks } from '../../@types/index.js';
9
+ import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
10
+ import { ERR_CONFLICT, ERR_NOT_FOUND, ERR_PATCH_FAILED } from '../../lib/errors.js';
11
+ import { withLogContext } from '../../lib/logger.js';
12
+ import { applyPatches } from '../../patches/index.js';
13
+ import { normaliseDateFields } from '../../utils/normalise-dates.js';
14
+ import { getDefaultStatus } from '../../workflow/workflow.js';
15
+ import { assignCounterValues } from '../assign-counter-values.js';
16
+ import { applyRichTextEmbed, extractDocumentId, extractVersionId, invokeHook, resolvePathForUpdate, rethrowPathConflict, } from './internals.js';
17
+ /**
18
+ * Update a document via full replacement (PUT semantics).
19
+ *
20
+ * Unlike the previous implementation, this now fetches the current version
21
+ * from storage to provide a real `originalData` to hooks.
22
+ *
23
+ * Flow:
24
+ * 1. Fetch current document via `getDocumentById({ reconstruct: true })`
25
+ * 2. `normaliseDateFields(data)`
26
+ * 3. `hooks.beforeUpdate({ data, originalData, collectionPath })`
27
+ * 4. `db.commands.documents.createDocumentVersion(...)` (action = 'update')
28
+ * 5. `hooks.afterUpdate({ data, originalData, collectionPath, documentId, documentVersionId })`
29
+ */
30
+ export async function updateDocument(ctx, params) {
31
+ return withLogContext({ domain: 'services', module: 'lifecycle', function: 'updateDocument' }, async () => {
32
+ const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
33
+ assertActorCanPerform(ctx.requestContext, collectionPath, 'update');
34
+ const hooks = await resolveHooks(definition);
35
+ const data = params.data;
36
+ // Fetch the real original so hooks get accurate originalData (fixes the
37
+ // PUT handler bug where originalData === data).
38
+ const latest = await db.queries.documents.getDocumentById({
39
+ collection_id: collectionId,
40
+ document_id: params.documentId,
41
+ locale: params.locale ?? defaultLocale,
42
+ reconstruct: true,
43
+ });
44
+ const originalData = latest ?? {};
45
+ normaliseDateFields(data);
46
+ await invokeHook(hooks?.beforeUpdate, { data, originalData, collectionPath });
47
+ // Counter fields are immutable: carry their values forward from the
48
+ // previous version rather than trusting whatever (or nothing) the
49
+ // caller sent. Lazy-allocates when a counter was added to the
50
+ // collection after this document was first created.
51
+ // originalData is the document envelope (with `.fields`, `.path`,
52
+ // `.document_version_id`); assignCounterValues expects field-shape.
53
+ await assignCounterValues({
54
+ fields: definition.fields,
55
+ data,
56
+ previousData: originalData.fields ?? originalData,
57
+ counters: db.commands.counters,
58
+ });
59
+ const defaultStatus = getDefaultStatus(definition);
60
+ const explicitPath = typeof params.path === 'string' && params.path.length > 0 ? params.path : null;
61
+ const requestLocale = params.locale ?? defaultLocale;
62
+ // The document's own content-locale anchor governs which save writes the
63
+ // path row — not the mutable global default. Falls back to the global
64
+ // default for rows predating source_locale (not yet backfilled).
65
+ const sourceLocale = originalData.source_locale ?? defaultLocale;
66
+ const pathForCommand = resolvePathForUpdate({
67
+ explicitPath,
68
+ currentPath: originalData.path,
69
+ requestLocale,
70
+ sourceLocale,
71
+ documentId: params.documentId,
72
+ logger: ctx.logger,
73
+ });
74
+ await applyRichTextEmbed(ctx, data);
75
+ const result = await db.commands.documents
76
+ .createDocumentVersion({
77
+ documentId: params.documentId,
78
+ collectionId,
79
+ collectionVersion: ctx.collectionVersion,
80
+ collectionConfig: definition,
81
+ action: 'update',
82
+ documentData: data,
83
+ path: pathForCommand,
84
+ availableLocales: params.availableLocales,
85
+ status: defaultStatus,
86
+ locale: requestLocale,
87
+ previousVersionId: originalData.document_version_id,
88
+ })
89
+ .catch((err) => rethrowPathConflict(err, pathForCommand ?? '', defaultLocale));
90
+ const documentId = extractDocumentId(result.document) || params.documentId;
91
+ const documentVersionId = extractVersionId(result.document);
92
+ await invokeHook(hooks?.afterUpdate, {
93
+ data,
94
+ originalData,
95
+ collectionPath,
96
+ documentId,
97
+ documentVersionId,
98
+ path: pathForCommand ?? originalData.path,
99
+ });
100
+ return { documentId, documentVersionId };
101
+ });
102
+ }
103
+ /**
104
+ * Update a document via patch application.
105
+ *
106
+ * Flow:
107
+ * 1. Fetch current document via `getDocumentById({ reconstruct: true })`
108
+ * 2. Optimistic concurrency check on `documentVersionId`
109
+ * 3. `applyPatches(definition, originalData, patches)` → `nextData`
110
+ * 4. `normaliseDateFields(nextData)`
111
+ * 5. `hooks.beforeUpdate({ data: nextData, originalData, collectionPath })`
112
+ * 6. `db.commands.documents.createDocumentVersion(...)` (action = 'update')
113
+ * 7. `hooks.afterUpdate({ data: nextData, originalData, collectionPath, documentId, documentVersionId })`
114
+ *
115
+ * @throws {BylineError} ERR_CONFLICT if the supplied `documentVersionId` does not match the current version.
116
+ * @throws {BylineError} ERR_PATCH_FAILED if `applyPatches` fails.
117
+ */
118
+ export async function updateDocumentWithPatches(ctx, params) {
119
+ return withLogContext({ domain: 'services', module: 'lifecycle', function: 'updateDocumentWithPatches' }, async () => {
120
+ const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
121
+ assertActorCanPerform(ctx.requestContext, collectionPath, 'update');
122
+ const hooks = await resolveHooks(definition);
123
+ // 1. Fetch current document.
124
+ const latest = await db.queries.documents.getDocumentById({
125
+ collection_id: collectionId,
126
+ document_id: params.documentId,
127
+ locale: params.locale ?? defaultLocale,
128
+ reconstruct: true,
129
+ });
130
+ if (latest == null) {
131
+ throw ERR_NOT_FOUND({
132
+ message: 'document not found',
133
+ details: { documentId: params.documentId },
134
+ }).log(ctx.logger);
135
+ }
136
+ const originalData = latest;
137
+ // 2. Optimistic concurrency check.
138
+ if (params.documentVersionId &&
139
+ params.documentVersionId !== originalData.document_version_id) {
140
+ throw ERR_CONFLICT({
141
+ message: 'document has been modified since you loaded it',
142
+ details: {
143
+ currentVersionId: originalData.document_version_id,
144
+ yourVersionId: params.documentVersionId,
145
+ },
146
+ }).log(ctx.logger);
147
+ }
148
+ // 3. Apply patches (patches operate on flat field data, not the full envelope).
149
+ const { doc: patchedDocument, errors } = applyPatches(definition, originalData.fields ?? {}, params.patches);
150
+ if (errors.length > 0) {
151
+ throw ERR_PATCH_FAILED({
152
+ message: `failed to apply patches: ${errors.map((e) => e.message).join('; ')}`,
153
+ details: { errors },
154
+ }).log(ctx.logger);
155
+ }
156
+ const nextData = patchedDocument;
157
+ // 4. Normalise dates.
158
+ normaliseDateFields(nextData);
159
+ // 5. beforeUpdate hook.
160
+ await invokeHook(hooks?.beforeUpdate, { data: nextData, originalData, collectionPath });
161
+ // 5b. Carry counter values forward from the previous version (or
162
+ // lazy-allocate if the previous version is missing a value). See
163
+ // updateDocument for the rationale — patch-based updates are
164
+ // subject to the same immutability contract.
165
+ await assignCounterValues({
166
+ fields: definition.fields,
167
+ data: nextData,
168
+ previousData: originalData.fields ?? {},
169
+ counters: db.commands.counters,
170
+ });
171
+ // 6. Persist.
172
+ const defaultStatus = getDefaultStatus(definition);
173
+ const explicitPath = typeof params.path === 'string' && params.path.length > 0 ? params.path : null;
174
+ const requestLocale = params.locale ?? defaultLocale;
175
+ // The document's own content-locale anchor governs which save writes the
176
+ // path row — not the mutable global default. Falls back to the global
177
+ // default for rows predating source_locale (not yet backfilled).
178
+ const sourceLocale = originalData.source_locale ?? defaultLocale;
179
+ const pathForCommand = resolvePathForUpdate({
180
+ explicitPath,
181
+ currentPath: originalData.path,
182
+ requestLocale,
183
+ sourceLocale,
184
+ documentId: params.documentId,
185
+ logger: ctx.logger,
186
+ });
187
+ await applyRichTextEmbed(ctx, nextData);
188
+ const result = await db.commands.documents
189
+ .createDocumentVersion({
190
+ documentId: params.documentId,
191
+ collectionId,
192
+ collectionVersion: ctx.collectionVersion,
193
+ collectionConfig: definition,
194
+ action: 'update',
195
+ documentData: nextData,
196
+ path: pathForCommand,
197
+ availableLocales: params.availableLocales,
198
+ status: defaultStatus,
199
+ locale: requestLocale,
200
+ previousVersionId: originalData.document_version_id,
201
+ })
202
+ .catch((err) => rethrowPathConflict(err, pathForCommand ?? '', defaultLocale));
203
+ const documentId = extractDocumentId(result.document) || params.documentId;
204
+ const documentVersionId = extractVersionId(result.document);
205
+ // 7. afterUpdate hook.
206
+ await invokeHook(hooks?.afterUpdate, {
207
+ data: nextData,
208
+ originalData,
209
+ collectionPath,
210
+ documentId,
211
+ documentVersionId,
212
+ path: pathForCommand ?? originalData.path,
213
+ });
214
+ return { documentId, documentVersionId };
215
+ });
216
+ }
@@ -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.js';
11
+ import { changeDocumentStatus, copyToLocale, createDocument, deleteDocument, duplicateDocument, restoreDocumentVersion, unpublishDocument, updateDocument, updateDocumentWithPatches, } from './document-lifecycle/index.js';
12
12
  // ---------------------------------------------------------------------------
13
13
  // Fixtures / Helpers
14
14
  // ---------------------------------------------------------------------------