@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.
- package/dist/@types/field-types.d.ts +22 -0
- package/dist/@types/site-config.d.ts +8 -1
- package/dist/services/document-lifecycle/context.d.ts +79 -0
- package/dist/services/document-lifecycle/context.js +8 -0
- package/dist/services/document-lifecycle/copy-to-locale.d.ts +62 -0
- package/dist/services/document-lifecycle/copy-to-locale.js +157 -0
- package/dist/services/document-lifecycle/create.d.ts +45 -0
- package/dist/services/document-lifecycle/create.js +91 -0
- package/dist/services/document-lifecycle/delete-locale.d.ts +44 -0
- package/dist/services/document-lifecycle/delete-locale.js +117 -0
- package/dist/services/document-lifecycle/delete.d.ts +37 -0
- package/dist/services/document-lifecycle/delete.js +113 -0
- package/dist/services/document-lifecycle/duplicate.d.ts +60 -0
- package/dist/services/document-lifecycle/duplicate.js +219 -0
- package/dist/services/document-lifecycle/index.d.ts +43 -0
- package/dist/services/document-lifecycle/index.js +33 -0
- package/dist/services/document-lifecycle/internals.d.ts +113 -0
- package/dist/services/document-lifecycle/internals.js +218 -0
- package/dist/services/document-lifecycle/merge-locale-data.d.ts +48 -0
- package/dist/services/document-lifecycle/merge-locale-data.js +147 -0
- package/dist/services/document-lifecycle/restore.d.ts +57 -0
- package/dist/services/document-lifecycle/restore.js +162 -0
- package/dist/services/document-lifecycle/status.d.ts +41 -0
- package/dist/services/document-lifecycle/status.js +150 -0
- package/dist/services/document-lifecycle/system-fields.d.ts +62 -0
- package/dist/services/document-lifecycle/system-fields.js +100 -0
- package/dist/services/document-lifecycle/update.d.ts +84 -0
- package/dist/services/document-lifecycle/update.js +216 -0
- package/dist/services/document-lifecycle.test.node.js +1 -1
- package/dist/services/document-to-markdown.d.ts +79 -0
- package/dist/services/document-to-markdown.js +267 -0
- package/dist/services/document-to-markdown.test.node.d.ts +8 -0
- package/dist/services/document-to-markdown.test.node.js +161 -0
- package/dist/services/field-upload.js +1 -1
- package/dist/services/index.d.ts +2 -1
- package/dist/services/index.js +2 -1
- 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
|
+
}
|