@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
@@ -665,4 +665,26 @@ export interface RichTextEmbedContext {
665
665
  * each field's `embedRelationsOnSave` flag.
666
666
  */
667
667
  export type RichTextEmbedFn = (ctx: RichTextEmbedContext) => Promise<void>;
668
+ /**
669
+ * Context passed to the richtext markdown serializer for one rich-text
670
+ * field value. Read-only and synchronous — the export surface walks the
671
+ * stored editor JSON directly (no editor instantiation, no DB reads).
672
+ */
673
+ export interface RichTextToMarkdownContext {
674
+ /** The richText field's value (raw editor JSON, possibly stringified). */
675
+ value: unknown;
676
+ /** Field path within the document — e.g. `'body'` or `'content.0.caption'`. */
677
+ fieldPath: string;
678
+ /** Collection path the document belongs to. */
679
+ collectionPath: string;
680
+ }
681
+ /**
682
+ * Server-side markdown serializer contract for the agent-readable export
683
+ * surface (`documentToMarkdown`, `.md` routes, `llms.txt`). Editor
684
+ * adapters export an implementation (e.g. `lexicalToMarkdown` from
685
+ * `@byline/richtext-lexical/server`); installations register one via
686
+ * `ServerConfig.fields.richText.toMarkdown`. One-way and lossy-tolerant
687
+ * by contract — output is never re-imported.
688
+ */
689
+ export type RichTextToMarkdownFn = (ctx: RichTextToMarkdownContext) => string;
668
690
  export {};
@@ -10,7 +10,7 @@ import type { SlugifierFn } from '../utils/slugify.js';
10
10
  import type { CollectionAdminConfig } from './admin-types.js';
11
11
  import type { CollectionDefinition } from './collection-types.js';
12
12
  import type { IDbAdapter } from './db-types.js';
13
- import type { RichTextEditorComponent, RichTextEmbedFn, RichTextPopulateFn } from './field-types.js';
13
+ import type { RichTextEditorComponent, RichTextEmbedFn, RichTextPopulateFn, RichTextToMarkdownFn } from './field-types.js';
14
14
  import type { IStorageProvider } from './storage-types.js';
15
15
  export type DbAdapterFn = (args: {
16
16
  connectionString: string;
@@ -307,6 +307,13 @@ export interface ServerConfig<TAdminStore = unknown> extends BaseConfig {
307
307
  richText?: {
308
308
  populate?: RichTextPopulateFn;
309
309
  embed?: RichTextEmbedFn;
310
+ /**
311
+ * `toMarkdown` — one-way markdown serializer for the agent-readable
312
+ * export surface (`documentToMarkdown`, `.md` routes, `llms.txt`).
313
+ * Optional: only installations that expose a markdown surface need
314
+ * it. Synchronous and read-only — it walks stored editor JSON.
315
+ */
316
+ toMarkdown?: RichTextToMarkdownFn;
310
317
  };
311
318
  };
312
319
  }
@@ -0,0 +1,79 @@
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 { RequestContext } from '@byline/auth';
9
+ import type { CollectionDefinition, IDbAdapter, IStorageProvider } from '../../@types/index.js';
10
+ import type { BylineLogger } from '../../lib/logger.js';
11
+ import type { SlugifierFn } from '../../utils/slugify.js';
12
+ /**
13
+ * The shared context every lifecycle function requires. Built once per
14
+ * request by the API route layer and passed through.
15
+ */
16
+ export interface DocumentLifecycleContext {
17
+ /** The database adapter returned by `getServerConfig().db`. */
18
+ db: IDbAdapter;
19
+ /** The resolved `CollectionDefinition` (includes `hooks`). */
20
+ definition: CollectionDefinition;
21
+ /** The database-level collection row ID. */
22
+ collectionId: string;
23
+ /**
24
+ * The collection's current schema version. Stamped onto every
25
+ * `documentVersions` row written during the lifecycle call so that
26
+ * Phase-2 in-memory migration can later resolve each document against
27
+ * the shape it was authored under. Callers resolve this from the core
28
+ * registry (`core.getCollectionRecord(path).version`).
29
+ */
30
+ collectionVersion: number;
31
+ /** The collection `path` string (e.g. `'docs'`, `'news'`). */
32
+ collectionPath: string;
33
+ /**
34
+ * Storage provider for this collection. Required when the collection
35
+ * has any upload-capable image/file field, so that the original files
36
+ * and their persisted variants can be cleaned up on document deletion.
37
+ *
38
+ * Resolved by the route layer as:
39
+ * `field.upload?.storage ?? serverConfig.storage`
40
+ *
41
+ * Optional — callers whose collections have no upload-capable fields
42
+ * are unaffected.
43
+ */
44
+ storage?: IStorageProvider;
45
+ /** Structured logger instance. Provided via the DI registry. */
46
+ logger: BylineLogger;
47
+ /**
48
+ * The default content locale (e.g. `'en'`). Used to anchor `path`
49
+ * derivation: the slugifier always runs against the default-locale
50
+ * source value, and creating a brand-new document in any other locale
51
+ * is rejected.
52
+ *
53
+ * Sourced by callers from `ServerConfig.i18n.content.defaultLocale`.
54
+ */
55
+ defaultLocale: string;
56
+ /**
57
+ * Installation slugifier. When omitted, the lifecycle falls back to
58
+ * the default `slugify` exported from `@byline/core`.
59
+ */
60
+ slugifier?: SlugifierFn;
61
+ /**
62
+ * Request-scoped context carrying the authenticated actor, request id,
63
+ * and related per-request metadata.
64
+ *
65
+ * Plumbing only in Phase 0 of the auth roadmap — present on the context
66
+ * so every lifecycle service can accept and forward it, but no ability
67
+ * assertions are performed yet. Phase 4 turns enforcement on: lifecycle
68
+ * entry points will call `context.requestContext?.actor?.assertAbility(...)`
69
+ * before any storage mutation.
70
+ *
71
+ * Optional so that internal-tooling callers (seed scripts, migration
72
+ * tools) continue to compile. Production write paths always supply it
73
+ * — `assertActorCanPerform` runs at every lifecycle entry and rejects
74
+ * a missing context.
75
+ *
76
+ * See docs/AUTHN-AUTHZ.md.
77
+ */
78
+ requestContext?: RequestContext;
79
+ }
@@ -0,0 +1,8 @@
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
+ export {};
@@ -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 CopyToLocaleResult {
10
+ documentId: string;
11
+ documentVersionId: string;
12
+ /** Source locale read for the copy. */
13
+ sourceLocale: string;
14
+ /** Target locale into which the source's localized leaves were written. */
15
+ targetLocale: string;
16
+ /**
17
+ * Number of localized field values copied from source to target. Useful
18
+ * for UI toasts ("Copied 4 fields from EN to FR"). A zero result means
19
+ * the source had no localized content to copy into the target under the
20
+ * chosen merge rule (e.g. `overwrite: false` and target was already
21
+ * fully populated).
22
+ */
23
+ fieldsUpdated: number;
24
+ }
25
+ /**
26
+ * Copy a document's content from one locale into another, in place on
27
+ * the same document.
28
+ *
29
+ * Reads the source and target locales separately (the storage layer
30
+ * resolves localized fields to flat single-locale shapes when given a
31
+ * specific `resolveLocale`). A schema-aware merge walker decides, leaf
32
+ * by leaf, whether to take the source's value or keep the target's,
33
+ * driven by the `overwrite` flag. The merged tree is written via
34
+ * `createDocumentVersion({ action: 'copy_to_locale', locale: target })`
35
+ * — the existing cross-locale carry-forward in the storage primitive
36
+ * preserves every *other* locale's rows untouched.
37
+ *
38
+ * Non-localized fields are never altered by this operation: they live
39
+ * on `locale: 'all'` rows and the merge walker passes the target's
40
+ * value through so the write does not blank them.
41
+ *
42
+ * Path is sticky and lives on default-locale only; this operation never
43
+ * touches `byline_document_paths`. Status resets to the workflow
44
+ * default — translations land as drafts.
45
+ *
46
+ * Flow:
47
+ * 1. `assertActorCanPerform('update')` — same gate as a translation save.
48
+ * 2. Reject if `sourceLocale === targetLocale`.
49
+ * 3. Fetch source via `getDocumentById({ locale: sourceLocale })`.
50
+ * 4. Fetch target via `getDocumentById({ locale: targetLocale })`.
51
+ * 5. `mergeLocaleData(definition.fields, source.fields, target.fields, overwrite)`.
52
+ * 6. `hooks.beforeUpdate({ data, originalData, collectionPath, copyToLocale })`.
53
+ * 7. `createDocumentVersion({ documentId, action: 'copy_to_locale',
54
+ * locale: targetLocale, documentData, previousVersionId, status })`.
55
+ * 8. `hooks.afterUpdate({ ..., copyToLocale })`.
56
+ */
57
+ export declare function copyToLocale(ctx: DocumentLifecycleContext, params: {
58
+ documentId: string;
59
+ sourceLocale: string;
60
+ targetLocale: string;
61
+ overwrite: boolean;
62
+ }): Promise<CopyToLocaleResult>;
@@ -0,0 +1,157 @@
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_NOT_FOUND, ERR_VALIDATION } from '../../lib/errors.js';
11
+ import { withLogContext } from '../../lib/logger.js';
12
+ import { getDefaultStatus } from '../../workflow/workflow.js';
13
+ import { applyRichTextEmbed, extractVersionId, invokeHook } from './internals.js';
14
+ import { mergeLocaleData } from './merge-locale-data.js';
15
+ /**
16
+ * Copy a document's content from one locale into another, in place on
17
+ * the same document.
18
+ *
19
+ * Reads the source and target locales separately (the storage layer
20
+ * resolves localized fields to flat single-locale shapes when given a
21
+ * specific `resolveLocale`). A schema-aware merge walker decides, leaf
22
+ * by leaf, whether to take the source's value or keep the target's,
23
+ * driven by the `overwrite` flag. The merged tree is written via
24
+ * `createDocumentVersion({ action: 'copy_to_locale', locale: target })`
25
+ * — the existing cross-locale carry-forward in the storage primitive
26
+ * preserves every *other* locale's rows untouched.
27
+ *
28
+ * Non-localized fields are never altered by this operation: they live
29
+ * on `locale: 'all'` rows and the merge walker passes the target's
30
+ * value through so the write does not blank them.
31
+ *
32
+ * Path is sticky and lives on default-locale only; this operation never
33
+ * touches `byline_document_paths`. Status resets to the workflow
34
+ * default — translations land as drafts.
35
+ *
36
+ * Flow:
37
+ * 1. `assertActorCanPerform('update')` — same gate as a translation save.
38
+ * 2. Reject if `sourceLocale === targetLocale`.
39
+ * 3. Fetch source via `getDocumentById({ locale: sourceLocale })`.
40
+ * 4. Fetch target via `getDocumentById({ locale: targetLocale })`.
41
+ * 5. `mergeLocaleData(definition.fields, source.fields, target.fields, overwrite)`.
42
+ * 6. `hooks.beforeUpdate({ data, originalData, collectionPath, copyToLocale })`.
43
+ * 7. `createDocumentVersion({ documentId, action: 'copy_to_locale',
44
+ * locale: targetLocale, documentData, previousVersionId, status })`.
45
+ * 8. `hooks.afterUpdate({ ..., copyToLocale })`.
46
+ */
47
+ export async function copyToLocale(ctx, params) {
48
+ return withLogContext({ domain: 'services', module: 'lifecycle', function: 'copyToLocale' }, async () => {
49
+ const { db, definition, collectionId, collectionPath } = ctx;
50
+ assertActorCanPerform(ctx.requestContext, collectionPath, 'update');
51
+ if (params.sourceLocale === params.targetLocale) {
52
+ throw ERR_VALIDATION({
53
+ message: 'sourceLocale and targetLocale must differ',
54
+ details: {
55
+ documentId: params.documentId,
56
+ sourceLocale: params.sourceLocale,
57
+ targetLocale: params.targetLocale,
58
+ },
59
+ }).log(ctx.logger);
60
+ }
61
+ // 1. Source read.
62
+ const source = await db.queries.documents.getDocumentById({
63
+ collection_id: collectionId,
64
+ document_id: params.documentId,
65
+ locale: params.sourceLocale,
66
+ reconstruct: true,
67
+ lenient: true,
68
+ requestContext: ctx.requestContext,
69
+ });
70
+ if (source == null) {
71
+ throw ERR_NOT_FOUND({
72
+ message: 'document not found in source locale',
73
+ details: {
74
+ documentId: params.documentId,
75
+ sourceLocale: params.sourceLocale,
76
+ collectionPath,
77
+ },
78
+ }).log(ctx.logger);
79
+ }
80
+ // 2. Target read — needed for both originalData (hooks) and to
81
+ // preserve non-localized values + structural shape.
82
+ const target = await db.queries.documents.getDocumentById({
83
+ collection_id: collectionId,
84
+ document_id: params.documentId,
85
+ locale: params.targetLocale,
86
+ reconstruct: true,
87
+ lenient: true,
88
+ requestContext: ctx.requestContext,
89
+ });
90
+ if (target == null) {
91
+ throw ERR_NOT_FOUND({
92
+ message: 'document not found in target locale',
93
+ details: {
94
+ documentId: params.documentId,
95
+ targetLocale: params.targetLocale,
96
+ collectionPath,
97
+ },
98
+ }).log(ctx.logger);
99
+ }
100
+ const sourceRecord = source;
101
+ const targetRecord = target;
102
+ const sourceFields = sourceRecord.fields ?? {};
103
+ const targetFields = targetRecord.fields ?? {};
104
+ // 3. Merge.
105
+ const merged = mergeLocaleData(definition.fields, sourceFields, targetFields, params.overwrite);
106
+ // 4. Hooks see the target-locale view as originalData (consistent
107
+ // with how updateDocument scopes originalData to the active
108
+ // locale) and the merged payload as the next `data`.
109
+ const hooks = await resolveHooks(definition);
110
+ const copyToLocaleMarker = {
111
+ sourceLocale: params.sourceLocale,
112
+ targetLocale: params.targetLocale,
113
+ };
114
+ await invokeHook(hooks?.beforeUpdate, {
115
+ data: merged.data,
116
+ originalData: targetFields,
117
+ collectionPath,
118
+ copyToLocale: copyToLocaleMarker,
119
+ });
120
+ // 5. Write. previousVersionId threads the current version id so the
121
+ // storage primitive's cross-locale carry-forward fires for every
122
+ // *other* locale (not source, not target — those rows are
123
+ // rewritten by this call).
124
+ const previousVersionId = targetRecord.document_version_id ?? undefined;
125
+ await applyRichTextEmbed(ctx, merged.data);
126
+ const writeResult = await db.commands.documents.createDocumentVersion({
127
+ documentId: params.documentId,
128
+ collectionId,
129
+ collectionVersion: ctx.collectionVersion,
130
+ collectionConfig: definition,
131
+ action: 'copy_to_locale',
132
+ documentData: merged.data,
133
+ status: getDefaultStatus(definition),
134
+ locale: params.targetLocale,
135
+ previousVersionId,
136
+ });
137
+ const documentVersionId = extractVersionId(writeResult.document);
138
+ await invokeHook(hooks?.afterUpdate, {
139
+ data: merged.data,
140
+ originalData: targetFields,
141
+ collectionPath,
142
+ documentId: params.documentId,
143
+ documentVersionId,
144
+ // Path is sticky and source-locale-anchored; copy-to-locale never
145
+ // touches it. Read it off the target envelope.
146
+ path: targetRecord.path ?? '',
147
+ copyToLocale: copyToLocaleMarker,
148
+ });
149
+ return {
150
+ documentId: params.documentId,
151
+ documentVersionId,
152
+ sourceLocale: params.sourceLocale,
153
+ targetLocale: params.targetLocale,
154
+ fieldsUpdated: merged.fieldsUpdated,
155
+ };
156
+ });
157
+ }
@@ -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 { DocumentLifecycleContext } from './context.js';
9
+ export interface CreateDocumentResult {
10
+ documentId: string;
11
+ documentVersionId: string;
12
+ }
13
+ /**
14
+ * Create a new document.
15
+ *
16
+ * Flow:
17
+ * 1. Default-locale enforcement: reject if `params.locale` is anything
18
+ * other than the configured default content locale (a brand-new
19
+ * document's canonical `path` lives in the default locale).
20
+ * 2. `normaliseDateFields(data)`
21
+ * 3. `hooks.beforeCreate({ data, collectionPath })`
22
+ * 4. Resolve `path` — explicit `params.path` → derive via `useAsPath`
23
+ * → UUID fallback.
24
+ * 5. `db.commands.documents.createDocumentVersion(...)` (action = 'create')
25
+ * 6. `hooks.afterCreate({ data, collectionPath, documentId, documentVersionId })`
26
+ */
27
+ export declare function createDocument(ctx: DocumentLifecycleContext, params: {
28
+ data: Record<string, any>;
29
+ locale?: string;
30
+ status?: string;
31
+ /**
32
+ * Explicit, user-supplied path (e.g. from the admin sidebar widget
33
+ * or an SDK caller importing legacy content). When omitted, the
34
+ * lifecycle derives the value from `definition.useAsPath`.
35
+ */
36
+ path?: string;
37
+ /**
38
+ * The editorial advertised-locale set (from the admin available-locales
39
+ * sidebar widget). Document-grain and sticky like `path`: passed straight
40
+ * to the storage primitive, which replaces the document's rows wholesale.
41
+ * `undefined` writes nothing (a new document starts with an empty set —
42
+ * the safe opt-in default); `[]` clears it. See docs/I18N.md.
43
+ */
44
+ availableLocales?: string[];
45
+ }): Promise<CreateDocumentResult>;
@@ -0,0 +1,91 @@
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_VALIDATION } from '../../lib/errors.js';
11
+ import { withLogContext } from '../../lib/logger.js';
12
+ import { normaliseDateFields } from '../../utils/normalise-dates.js';
13
+ import { slugify } from '../../utils/slugify.js';
14
+ import { getDefaultStatus } from '../../workflow/workflow.js';
15
+ import { assignCounterValues } from '../assign-counter-values.js';
16
+ import { applyRichTextEmbed, derivePath, extractDocumentId, extractVersionId, invokeHook, maybeAppendOrderKey, rethrowPathConflict, } from './internals.js';
17
+ /**
18
+ * Create a new document.
19
+ *
20
+ * Flow:
21
+ * 1. Default-locale enforcement: reject if `params.locale` is anything
22
+ * other than the configured default content locale (a brand-new
23
+ * document's canonical `path` lives in the default locale).
24
+ * 2. `normaliseDateFields(data)`
25
+ * 3. `hooks.beforeCreate({ data, collectionPath })`
26
+ * 4. Resolve `path` — explicit `params.path` → derive via `useAsPath`
27
+ * → UUID fallback.
28
+ * 5. `db.commands.documents.createDocumentVersion(...)` (action = 'create')
29
+ * 6. `hooks.afterCreate({ data, collectionPath, documentId, documentVersionId })`
30
+ */
31
+ export async function createDocument(ctx, params) {
32
+ return withLogContext({ domain: 'services', module: 'lifecycle', function: 'createDocument' }, async () => {
33
+ const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
34
+ assertActorCanPerform(ctx.requestContext, collectionPath, 'create');
35
+ const slugifier = ctx.slugifier ?? slugify;
36
+ const hooks = await resolveHooks(definition);
37
+ const data = params.data;
38
+ if (params.locale != null && params.locale !== defaultLocale) {
39
+ throw ERR_VALIDATION({
40
+ message: `documents must be created in the default content locale ('${defaultLocale}'); received '${params.locale}'. Create the default-locale version first, then add localised versions via update.`,
41
+ details: { defaultLocale, providedLocale: params.locale, collectionPath },
42
+ }).log(ctx.logger);
43
+ }
44
+ normaliseDateFields(data);
45
+ await invokeHook(hooks?.beforeCreate, { data, collectionPath });
46
+ // Allocate counter-field values after beforeCreate so user-land hooks
47
+ // can run their own logic on the raw payload, but before the flatten/
48
+ // insert pass so the assigned values are persisted on the same write.
49
+ // Caller-supplied counter values are overwritten — counters are
50
+ // allocator-assigned, never user-set.
51
+ await assignCounterValues({
52
+ fields: definition.fields,
53
+ data,
54
+ counters: db.commands.counters,
55
+ });
56
+ const explicitPath = typeof params.path === 'string' && params.path.length > 0 ? params.path : null;
57
+ const resolvedPath = explicitPath ?? derivePath(definition, data, defaultLocale, slugifier);
58
+ // Append-at-end order_key for `orderable: true` collections.
59
+ // Computed before the insert so the single createDocumentVersion call
60
+ // carries the key into the byline_documents row. No effect when the
61
+ // admin config opts out or isn't registered.
62
+ const orderKey = await maybeAppendOrderKey(ctx, collectionPath);
63
+ // Refresh embedded relation envelopes inside rich-text fields
64
+ // (internal-link / inline-image nodes) before flatten-and-persist.
65
+ await applyRichTextEmbed(ctx, data);
66
+ const result = await db.commands.documents
67
+ .createDocumentVersion({
68
+ collectionId,
69
+ collectionVersion: ctx.collectionVersion,
70
+ collectionConfig: definition,
71
+ action: 'create',
72
+ documentData: data,
73
+ path: resolvedPath,
74
+ availableLocales: params.availableLocales,
75
+ status: params.status ?? data.status ?? getDefaultStatus(definition),
76
+ locale: params.locale ?? defaultLocale,
77
+ orderKey,
78
+ })
79
+ .catch((err) => rethrowPathConflict(err, resolvedPath, defaultLocale));
80
+ const documentId = extractDocumentId(result.document);
81
+ const documentVersionId = extractVersionId(result.document);
82
+ await invokeHook(hooks?.afterCreate, {
83
+ data,
84
+ collectionPath,
85
+ documentId,
86
+ documentVersionId,
87
+ path: resolvedPath,
88
+ });
89
+ return { documentId, documentVersionId };
90
+ });
91
+ }
@@ -0,0 +1,44 @@
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 DeleteLocaleResult {
10
+ documentId: string;
11
+ /** The newly-created version that omits the deleted locale's content. */
12
+ documentVersionId: string;
13
+ /** The content locale that was removed. */
14
+ locale: string;
15
+ }
16
+ /**
17
+ * Remove one content locale's data from a document, in place on the same
18
+ * document, by writing a new immutable version that omits that locale's
19
+ * store rows (every other locale and all non-localized `'all'` rows are
20
+ * carried forward by the storage primitive).
21
+ *
22
+ * The default content locale is the document's anchor (path + source_locale)
23
+ * and can never be removed — rejected up front. The new version lands as the
24
+ * workflow's default status (a fresh draft), exactly like `copyToLocale`: the
25
+ * previously-published version keeps serving — including the locale being
26
+ * removed — until the new version is reviewed and published. The deletion is
27
+ * recoverable: the prior version still holds the locale, so restoring it
28
+ * brings the content back.
29
+ *
30
+ * Flow:
31
+ * 1. `assertActorCanPerform('update')` — removing a translation is an edit.
32
+ * 2. Reject `locale === defaultLocale`.
33
+ * 3. Read the document in the target locale (validates existence; supplies
34
+ * `originalData` for hooks and the availability set for the presence
35
+ * check).
36
+ * 4. Reject when the locale has no content to delete.
37
+ * 5. `hooks.beforeUpdate({ …, deleteLocale: { locale } })`.
38
+ * 6. `db.commands.documents.deleteDocumentLocale({ …, status: default })`.
39
+ * 7. `hooks.afterUpdate({ …, deleteLocale: { locale } })`.
40
+ */
41
+ export declare function deleteLocale(ctx: DocumentLifecycleContext, params: {
42
+ documentId: string;
43
+ locale: string;
44
+ }): Promise<DeleteLocaleResult>;
@@ -0,0 +1,117 @@
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_NOT_FOUND, ERR_VALIDATION } from '../../lib/errors.js';
11
+ import { withLogContext } from '../../lib/logger.js';
12
+ import { getDefaultStatus } from '../../workflow/workflow.js';
13
+ import { invokeHook } from './internals.js';
14
+ /**
15
+ * Remove one content locale's data from a document, in place on the same
16
+ * document, by writing a new immutable version that omits that locale's
17
+ * store rows (every other locale and all non-localized `'all'` rows are
18
+ * carried forward by the storage primitive).
19
+ *
20
+ * The default content locale is the document's anchor (path + source_locale)
21
+ * and can never be removed — rejected up front. The new version lands as the
22
+ * workflow's default status (a fresh draft), exactly like `copyToLocale`: the
23
+ * previously-published version keeps serving — including the locale being
24
+ * removed — until the new version is reviewed and published. The deletion is
25
+ * recoverable: the prior version still holds the locale, so restoring it
26
+ * brings the content back.
27
+ *
28
+ * Flow:
29
+ * 1. `assertActorCanPerform('update')` — removing a translation is an edit.
30
+ * 2. Reject `locale === defaultLocale`.
31
+ * 3. Read the document in the target locale (validates existence; supplies
32
+ * `originalData` for hooks and the availability set for the presence
33
+ * check).
34
+ * 4. Reject when the locale has no content to delete.
35
+ * 5. `hooks.beforeUpdate({ …, deleteLocale: { locale } })`.
36
+ * 6. `db.commands.documents.deleteDocumentLocale({ …, status: default })`.
37
+ * 7. `hooks.afterUpdate({ …, deleteLocale: { locale } })`.
38
+ */
39
+ export async function deleteLocale(ctx, params) {
40
+ return withLogContext({ domain: 'services', module: 'lifecycle', function: 'deleteLocale' }, async () => {
41
+ const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
42
+ assertActorCanPerform(ctx.requestContext, collectionPath, 'update');
43
+ // The default locale anchors the document's path and source_locale —
44
+ // it cannot be deleted (the other locales fall back to it).
45
+ if (params.locale === defaultLocale) {
46
+ throw ERR_VALIDATION({
47
+ message: `cannot delete the default content locale ('${defaultLocale}')`,
48
+ details: { documentId: params.documentId, locale: params.locale, collectionPath },
49
+ }).log(ctx.logger);
50
+ }
51
+ // Read the document in the locale being removed — validates existence,
52
+ // supplies originalData for hooks, and the availability set for the
53
+ // content-presence check below.
54
+ const target = await db.queries.documents.getDocumentById({
55
+ collection_id: collectionId,
56
+ document_id: params.documentId,
57
+ locale: params.locale,
58
+ reconstruct: true,
59
+ lenient: true,
60
+ requestContext: ctx.requestContext,
61
+ });
62
+ if (target == null) {
63
+ throw ERR_NOT_FOUND({
64
+ message: 'document not found',
65
+ details: { documentId: params.documentId, collectionPath },
66
+ }).log(ctx.logger);
67
+ }
68
+ const targetRecord = target;
69
+ // `_availableVersionLocales` is the derived (path-coverage) set; it is
70
+ // the same source the editor's Delete-Locale picker is built from, so a
71
+ // locale offered in the UI resolves here. A partially-translated locale
72
+ // that never reached full coverage is not deletable through this path.
73
+ const available = targetRecord._availableVersionLocales ?? [];
74
+ if (!available.includes(params.locale)) {
75
+ throw ERR_NOT_FOUND({
76
+ message: `locale '${params.locale}' has no content to delete`,
77
+ details: { documentId: params.documentId, locale: params.locale, collectionPath },
78
+ }).log(ctx.logger);
79
+ }
80
+ const hooks = await resolveHooks(definition);
81
+ const deleteLocaleMarker = { locale: params.locale };
82
+ const originalData = targetRecord.fields ?? {};
83
+ await invokeHook(hooks?.beforeUpdate, {
84
+ data: originalData,
85
+ originalData,
86
+ collectionPath,
87
+ deleteLocale: deleteLocaleMarker,
88
+ });
89
+ const result = await db.commands.documents.deleteDocumentLocale({
90
+ documentId: params.documentId,
91
+ locale: params.locale,
92
+ status: getDefaultStatus(definition),
93
+ });
94
+ if (result == null) {
95
+ throw ERR_NOT_FOUND({
96
+ message: 'document not found',
97
+ details: { documentId: params.documentId, collectionPath },
98
+ }).log(ctx.logger);
99
+ }
100
+ await invokeHook(hooks?.afterUpdate, {
101
+ data: originalData,
102
+ originalData,
103
+ collectionPath,
104
+ documentId: params.documentId,
105
+ documentVersionId: result.newVersionId,
106
+ // Path is sticky and source-locale-anchored; deleting a translation
107
+ // never touches it. Read it off the target envelope.
108
+ path: targetRecord.path ?? '',
109
+ deleteLocale: deleteLocaleMarker,
110
+ });
111
+ return {
112
+ documentId: params.documentId,
113
+ documentVersionId: result.newVersionId,
114
+ locale: params.locale,
115
+ };
116
+ });
117
+ }