@byline/core 2.6.1 → 3.0.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/collection-types.d.ts +19 -0
- package/dist/@types/db-types.d.ts +57 -0
- package/dist/@types/site-config.d.ts +39 -0
- package/dist/config/validate-collections.d.ts +5 -0
- package/dist/config/validate-collections.js +33 -2
- package/dist/config/validate-collections.test.node.js +69 -0
- package/dist/core.js +15 -0
- package/dist/schemas/zod/builder.d.ts +21 -0
- package/dist/schemas/zod/builder.js +4 -0
- package/dist/services/document-lifecycle.d.ts +22 -0
- package/dist/services/document-lifecycle.js +24 -10
- package/dist/services/document-lifecycle.test.node.js +25 -3
- package/dist/storage/collection-fingerprint.js +2 -0
- package/package.json +2 -2
|
@@ -713,6 +713,25 @@ export interface CollectionDefinition {
|
|
|
713
713
|
* without `useAsPath` receive a UUID `path` instead.
|
|
714
714
|
*/
|
|
715
715
|
useAsPath?: string;
|
|
716
|
+
/**
|
|
717
|
+
* Opts this collection into the `availableLocales` editorial advertising
|
|
718
|
+
* control — the deliberate "advertise these content locales" set an editor
|
|
719
|
+
* curates per document, stored document-grain in
|
|
720
|
+
* `byline_document_available_locales` (mirrors `byline_document_paths`) and
|
|
721
|
+
* surfaced on reads as `availableLocales`. It is the editorial counterpart
|
|
722
|
+
* to the derived, ledger-backed `_availableVersionLocales` (path-coverage
|
|
723
|
+
* fact); the public advertised set is their intersection
|
|
724
|
+
* (`availableLocales ∩ _availableVersionLocales`).
|
|
725
|
+
*
|
|
726
|
+
* Like `useAsPath`, the value is system metadata edited via a non-field
|
|
727
|
+
* sidebar widget — `availableLocales` is a reserved name and cannot be
|
|
728
|
+
* declared as a field. Advertising locales is only meaningful when the
|
|
729
|
+
* collection has at least one `localized` field, so the validator rejects
|
|
730
|
+
* `advertiseLocales: true` on a collection with none.
|
|
731
|
+
*
|
|
732
|
+
* See `docs/AVAILABLE-LOCALES.md`.
|
|
733
|
+
*/
|
|
734
|
+
advertiseLocales?: boolean;
|
|
716
735
|
/**
|
|
717
736
|
* Optional host-defined function that composes a renderable root-relative
|
|
718
737
|
* path for a document in this collection. Called server-side by the
|
|
@@ -16,6 +16,32 @@ import type { QueryPredicate } from './query-predicate.js';
|
|
|
16
16
|
* defaults to this).
|
|
17
17
|
*/
|
|
18
18
|
export type ReadMode = 'any' | 'published';
|
|
19
|
+
/**
|
|
20
|
+
* What a read does when the requested content locale is missing (the value of
|
|
21
|
+
* the `onMissingLocale` read option).
|
|
22
|
+
*
|
|
23
|
+
* - `'empty'` — no field-level fallback. Restore the requested locale
|
|
24
|
+
* exactly, leaving untranslated localized fields empty. The
|
|
25
|
+
* raw per-locale view the admin editor needs (empty =
|
|
26
|
+
* "not translated yet"). The document is always returned.
|
|
27
|
+
* - `'fallback'` — fall back through the locale chain to the default content
|
|
28
|
+
* locale, restoring the whole document in one effective
|
|
29
|
+
* locale (never mixing). A detail read still returns the
|
|
30
|
+
* document; a list read still includes it. The
|
|
31
|
+
* `@byline/client` default — public consumers "just work".
|
|
32
|
+
* - `'omit'` — only surface documents available in the requested locale.
|
|
33
|
+
* A detail read returns `null` (→ 404) when the requested
|
|
34
|
+
* locale is unavailable; a list read excludes such documents.
|
|
35
|
+
* Available documents restore the requested locale exactly.
|
|
36
|
+
* Backed by the `byline_document_version_locales` ledger.
|
|
37
|
+
*
|
|
38
|
+
* At the adapter layer an omitted value behaves as `'empty'` (exact-match, the
|
|
39
|
+
* safe default for internal/direct reads); `@byline/client` defaults it to
|
|
40
|
+
* `'fallback'` for application reads. Availability follows path-coverage against
|
|
41
|
+
* the default content locale; a document with no localized content is available
|
|
42
|
+
* in every locale. See `docs/CONTENT-LOCALE-RESOLUTION.md`.
|
|
43
|
+
*/
|
|
44
|
+
export type MissingLocalePolicy = 'empty' | 'fallback' | 'omit';
|
|
19
45
|
/**
|
|
20
46
|
* Request-scoped context shared across every read and populate walk in one
|
|
21
47
|
* logical request. Threaded through populate, `afterRead` hooks, and any
|
|
@@ -182,6 +208,18 @@ export interface IDbAdapter {
|
|
|
182
208
|
collections: ICollectionQueries;
|
|
183
209
|
documents: IDocumentQueries;
|
|
184
210
|
};
|
|
211
|
+
/**
|
|
212
|
+
* Optional maintenance: stamp `source_locale` (the per-document content
|
|
213
|
+
* anchor) on documents created before the column existed, setting NULL rows
|
|
214
|
+
* to the adapter's configured default content locale. Called idempotently at
|
|
215
|
+
* boot by `initBylineCore` so in-place upgrades self-heal without a manual
|
|
216
|
+
* step or a migrate-ordering constraint — a no-op (zero rows) once every
|
|
217
|
+
* document is stamped. Optional so adapters that don't model `source_locale`
|
|
218
|
+
* need not implement it. See docs/DEFAULT-LOCALE-SWITCHING.md.
|
|
219
|
+
*/
|
|
220
|
+
backfillSourceLocales?: () => Promise<{
|
|
221
|
+
rowsUpdated: number;
|
|
222
|
+
}>;
|
|
185
223
|
}
|
|
186
224
|
/**
|
|
187
225
|
* Adapter capability for the shared-pool counter mechanism backing the
|
|
@@ -271,6 +309,15 @@ export interface IDocumentCommands {
|
|
|
271
309
|
* surface as `ERR_PATH_CONFLICT` from the lifecycle layer.
|
|
272
310
|
*/
|
|
273
311
|
path?: string;
|
|
312
|
+
/**
|
|
313
|
+
* Optional. When provided, the adapter replaces the document's rows in
|
|
314
|
+
* `byline_document_available_locales` (document-grain) wholesale — the
|
|
315
|
+
* editorial advertised-locale set. `undefined` leaves the existing set
|
|
316
|
+
* untouched (sticky across versions, like `path`); `[]` clears it. The
|
|
317
|
+
* locale values are the advertised content locales themselves, not the
|
|
318
|
+
* write locale. See `docs/AVAILABLE-LOCALES.md`.
|
|
319
|
+
*/
|
|
320
|
+
availableLocales?: string[];
|
|
274
321
|
locale?: string;
|
|
275
322
|
status?: string;
|
|
276
323
|
createdBy?: string;
|
|
@@ -375,6 +422,9 @@ export interface IDocumentQueries {
|
|
|
375
422
|
* never silently leaks into a live site.
|
|
376
423
|
*/
|
|
377
424
|
lenient?: boolean;
|
|
425
|
+
/** See `MissingLocalePolicy`. `'omit'` returns `null` when the document
|
|
426
|
+
* is not available in the requested locale. Omitted ⇒ `'empty'`. */
|
|
427
|
+
onMissingLocale?: MissingLocalePolicy;
|
|
378
428
|
}): Promise<any | null>;
|
|
379
429
|
/**
|
|
380
430
|
* Fetch only the current version's metadata row (no field reconstruction).
|
|
@@ -411,6 +461,9 @@ export interface IDocumentQueries {
|
|
|
411
461
|
filters?: DocumentFilter[];
|
|
412
462
|
/** See `getDocumentById.requestContext`. */
|
|
413
463
|
requestContext?: RequestContext;
|
|
464
|
+
/** See `MissingLocalePolicy`. `'omit'` returns `null` when the document
|
|
465
|
+
* is not available in the requested locale. Omitted ⇒ `'empty'`. */
|
|
466
|
+
onMissingLocale?: MissingLocalePolicy;
|
|
414
467
|
}): Promise<any | null>;
|
|
415
468
|
getDocumentByVersion(params: {
|
|
416
469
|
document_version_id: string;
|
|
@@ -549,6 +602,10 @@ export interface IDocumentQueries {
|
|
|
549
602
|
readMode?: ReadMode;
|
|
550
603
|
/** See `getDocumentById.requestContext`. */
|
|
551
604
|
requestContext?: RequestContext;
|
|
605
|
+
/** See `MissingLocalePolicy`. `'omit'` excludes documents not available
|
|
606
|
+
* in the requested locale (filtered at the SQL layer so pagination stays
|
|
607
|
+
* correct). Omitted ⇒ `'empty'`. */
|
|
608
|
+
onMissingLocale?: MissingLocalePolicy;
|
|
552
609
|
}): Promise<{
|
|
553
610
|
documents: any[];
|
|
554
611
|
total: number;
|
|
@@ -53,8 +53,47 @@ export interface BaseConfig {
|
|
|
53
53
|
}>;
|
|
54
54
|
};
|
|
55
55
|
content: {
|
|
56
|
+
/**
|
|
57
|
+
* The default **content** locale: the locale new documents are authored
|
|
58
|
+
* in, and the locale served for a request that doesn't specify one.
|
|
59
|
+
*
|
|
60
|
+
* As of the source_locale work this is **no longer the per-document data
|
|
61
|
+
* anchor.** Each document records its own `source_locale` at creation
|
|
62
|
+
* (= this value at that moment) and rides it for the read fallback floor,
|
|
63
|
+
* its path locale, and the completeness ledger — so changing this value
|
|
64
|
+
* is safe for existing data: they keep reading against the locale they
|
|
65
|
+
* were authored in. New documents created after the change anchor to the
|
|
66
|
+
* new value. See docs/DEFAULT-LOCALE-SWITCHING.md.
|
|
67
|
+
*
|
|
68
|
+
* (Switching this on a live system still needs the one-time
|
|
69
|
+
* `backfillSourceLocales()` maintenance step to have stamped any rows
|
|
70
|
+
* that predate the `source_locale` column.)
|
|
71
|
+
*/
|
|
56
72
|
defaultLocale: string;
|
|
57
73
|
locales: string[];
|
|
74
|
+
/**
|
|
75
|
+
* Optional display names for the content locales a document can be
|
|
76
|
+
* published in. Mirrors `interface.localeDefinitions`, but for the
|
|
77
|
+
* *content* dimension rather than the admin chrome.
|
|
78
|
+
*
|
|
79
|
+
* Byline itself does not render these — the content-locale set has
|
|
80
|
+
* no admin switcher. They exist so a host frontend can advertise
|
|
81
|
+
* content languages (hreflang clusters, "read this in…" affordances,
|
|
82
|
+
* sitemap alternates) with author-controlled labels — `Français`
|
|
83
|
+
* rather than the lowercase `français` that CLDR's
|
|
84
|
+
* `Intl.DisplayNames` returns for romance languages — read straight
|
|
85
|
+
* off `getServerConfig().i18n.content.localeDefinitions` instead of
|
|
86
|
+
* maintaining a parallel label map.
|
|
87
|
+
*
|
|
88
|
+
* Hosts that omit this can resolve labels per-code via
|
|
89
|
+
* `Intl.DisplayNames`; hosts that provide it for some codes still
|
|
90
|
+
* fall back for the rest. Entries for codes outside `locales` are
|
|
91
|
+
* silently ignored.
|
|
92
|
+
*/
|
|
93
|
+
localeDefinitions?: ReadonlyArray<{
|
|
94
|
+
code: string;
|
|
95
|
+
nativeName: string;
|
|
96
|
+
}>;
|
|
58
97
|
};
|
|
59
98
|
/**
|
|
60
99
|
* Admin interface translation registry — a `TranslationBundle`
|
|
@@ -24,6 +24,11 @@ export declare const RESERVED_FIELD_NAMES: ReadonlySet<string>;
|
|
|
24
24
|
* - When `useAsPath` is set, the referenced field must exist at the
|
|
25
25
|
* top level of the collection and be of a type the slugifier can
|
|
26
26
|
* sensibly consume (text-like or date-like).
|
|
27
|
+
* - No field may be named `availableLocales`; collections opt into the
|
|
28
|
+
* editorial available-locales control via `advertiseLocales: true`.
|
|
29
|
+
* - When `advertiseLocales` is `true`, the collection must have at least
|
|
30
|
+
* one `localized` field — advertising content locales is meaningless
|
|
31
|
+
* otherwise.
|
|
27
32
|
*
|
|
28
33
|
* Throws a plain `Error` (not a `BylineError`) because configuration
|
|
29
34
|
* validation runs at startup, before the logger and error registry are
|
|
@@ -12,7 +12,15 @@
|
|
|
12
12
|
* store tables from installations whose schemas declared these names as
|
|
13
13
|
* user fields before they were promoted to system attributes.
|
|
14
14
|
*/
|
|
15
|
-
export const RESERVED_FIELD_NAMES = new Set(['path']);
|
|
15
|
+
export const RESERVED_FIELD_NAMES = new Set(['path', 'availableLocales']);
|
|
16
|
+
/**
|
|
17
|
+
* Per-reserved-name hint pointing the user at the collection-level directive
|
|
18
|
+
* that replaces declaring the name as a field.
|
|
19
|
+
*/
|
|
20
|
+
const RESERVED_FIELD_HINTS = {
|
|
21
|
+
path: "Use `useAsPath: '<sourceField>'` on the collection definition instead.",
|
|
22
|
+
availableLocales: 'Use `advertiseLocales: true` on the collection definition instead.',
|
|
23
|
+
};
|
|
16
24
|
const USE_AS_PATH_SOURCE_TYPES = new Set([
|
|
17
25
|
'text',
|
|
18
26
|
'textArea',
|
|
@@ -21,6 +29,20 @@ const USE_AS_PATH_SOURCE_TYPES = new Set([
|
|
|
21
29
|
'datetime',
|
|
22
30
|
'time',
|
|
23
31
|
]);
|
|
32
|
+
/**
|
|
33
|
+
* True when any field in the tree (at any nesting depth) is `localized`.
|
|
34
|
+
* Used to gate `advertiseLocales` — advertising content locales is only
|
|
35
|
+
* meaningful when the collection has locale-varying content.
|
|
36
|
+
*/
|
|
37
|
+
function hasLocalizedField(fields) {
|
|
38
|
+
let found = false;
|
|
39
|
+
walkFields(fields, (field) => {
|
|
40
|
+
if ('localized' in field && field.localized === true) {
|
|
41
|
+
found = true;
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
return found;
|
|
45
|
+
}
|
|
24
46
|
function walkFields(fields, visit) {
|
|
25
47
|
for (const field of fields) {
|
|
26
48
|
visit(field);
|
|
@@ -44,6 +66,11 @@ function walkFields(fields, visit) {
|
|
|
44
66
|
* - When `useAsPath` is set, the referenced field must exist at the
|
|
45
67
|
* top level of the collection and be of a type the slugifier can
|
|
46
68
|
* sensibly consume (text-like or date-like).
|
|
69
|
+
* - No field may be named `availableLocales`; collections opt into the
|
|
70
|
+
* editorial available-locales control via `advertiseLocales: true`.
|
|
71
|
+
* - When `advertiseLocales` is `true`, the collection must have at least
|
|
72
|
+
* one `localized` field — advertising content locales is meaningless
|
|
73
|
+
* otherwise.
|
|
47
74
|
*
|
|
48
75
|
* Throws a plain `Error` (not a `BylineError`) because configuration
|
|
49
76
|
* validation runs at startup, before the logger and error registry are
|
|
@@ -53,7 +80,8 @@ export function validateCollections(collections) {
|
|
|
53
80
|
for (const collection of collections) {
|
|
54
81
|
walkFields(collection.fields, (field) => {
|
|
55
82
|
if ('name' in field && RESERVED_FIELD_NAMES.has(field.name)) {
|
|
56
|
-
|
|
83
|
+
const hint = RESERVED_FIELD_HINTS[field.name] ?? '';
|
|
84
|
+
throw new Error(`Collection "${collection.path}" declares a field named "${field.name}", which is a reserved system attribute.${hint ? ` ${hint}` : ''}`);
|
|
57
85
|
}
|
|
58
86
|
});
|
|
59
87
|
if (collection.useAsPath != null) {
|
|
@@ -65,5 +93,8 @@ export function validateCollections(collections) {
|
|
|
65
93
|
throw new Error(`Collection "${collection.path}" sets \`useAsPath: '${collection.useAsPath}'\` but field "${collection.useAsPath}" has type "${source.type}". Supported source types: ${[...USE_AS_PATH_SOURCE_TYPES].join(', ')}.`);
|
|
66
94
|
}
|
|
67
95
|
}
|
|
96
|
+
if (collection.advertiseLocales === true && !hasLocalizedField(collection.fields)) {
|
|
97
|
+
throw new Error(`Collection "${collection.path}" sets \`advertiseLocales: true\` but has no localized fields. The available-locales control advertises content locales, which is only meaningful when at least one field is \`localized\`.`);
|
|
98
|
+
}
|
|
68
99
|
}
|
|
69
100
|
}
|
|
@@ -145,4 +145,73 @@ describe('validateCollections', () => {
|
|
|
145
145
|
};
|
|
146
146
|
expect(() => validateCollections([collection])).toThrow(/no top-level field with that name/);
|
|
147
147
|
});
|
|
148
|
+
it('rejects a top-level field named "availableLocales"', () => {
|
|
149
|
+
const collection = {
|
|
150
|
+
...baseCollection,
|
|
151
|
+
fields: [
|
|
152
|
+
{ name: 'title', label: 'Title', type: 'text' },
|
|
153
|
+
{ name: 'availableLocales', label: 'Available Locales', type: 'text' },
|
|
154
|
+
],
|
|
155
|
+
};
|
|
156
|
+
expect(() => validateCollections([collection])).toThrow(/reserved system attribute/);
|
|
157
|
+
});
|
|
158
|
+
it('points the user at advertiseLocales when "availableLocales" is declared as a field', () => {
|
|
159
|
+
const collection = {
|
|
160
|
+
...baseCollection,
|
|
161
|
+
fields: [
|
|
162
|
+
{ name: 'title', label: 'Title', type: 'text' },
|
|
163
|
+
{ name: 'availableLocales', label: 'Available Locales', type: 'text' },
|
|
164
|
+
],
|
|
165
|
+
};
|
|
166
|
+
expect(() => validateCollections([collection])).toThrow(/advertiseLocales: true/);
|
|
167
|
+
});
|
|
168
|
+
it('rejects a nested "availableLocales" field inside a group', () => {
|
|
169
|
+
const collection = {
|
|
170
|
+
...baseCollection,
|
|
171
|
+
fields: [
|
|
172
|
+
{
|
|
173
|
+
name: 'meta',
|
|
174
|
+
label: 'Meta',
|
|
175
|
+
type: 'group',
|
|
176
|
+
fields: [{ name: 'availableLocales', label: 'Available Locales', type: 'text' }],
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
};
|
|
180
|
+
expect(() => validateCollections([collection])).toThrow(/reserved system attribute/);
|
|
181
|
+
});
|
|
182
|
+
it('accepts advertiseLocales: true when the collection has a localized field', () => {
|
|
183
|
+
const collection = {
|
|
184
|
+
...baseCollection,
|
|
185
|
+
fields: [{ name: 'title', label: 'Title', type: 'text', localized: true }],
|
|
186
|
+
advertiseLocales: true,
|
|
187
|
+
};
|
|
188
|
+
expect(() => validateCollections([collection])).not.toThrow();
|
|
189
|
+
});
|
|
190
|
+
it('accepts advertiseLocales: true with a nested localized field', () => {
|
|
191
|
+
const collection = {
|
|
192
|
+
...baseCollection,
|
|
193
|
+
fields: [
|
|
194
|
+
{ name: 'title', label: 'Title', type: 'text' },
|
|
195
|
+
{
|
|
196
|
+
name: 'meta',
|
|
197
|
+
label: 'Meta',
|
|
198
|
+
type: 'group',
|
|
199
|
+
fields: [{ name: 'summary', label: 'Summary', type: 'textArea', localized: true }],
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
advertiseLocales: true,
|
|
203
|
+
};
|
|
204
|
+
expect(() => validateCollections([collection])).not.toThrow();
|
|
205
|
+
});
|
|
206
|
+
it('rejects advertiseLocales: true when the collection has no localized fields', () => {
|
|
207
|
+
const collection = {
|
|
208
|
+
...baseCollection,
|
|
209
|
+
fields: [{ name: 'title', label: 'Title', type: 'text' }],
|
|
210
|
+
advertiseLocales: true,
|
|
211
|
+
};
|
|
212
|
+
expect(() => validateCollections([collection])).toThrow(/no localized fields/);
|
|
213
|
+
});
|
|
214
|
+
it('accepts advertiseLocales omitted regardless of localized fields', () => {
|
|
215
|
+
expect(() => validateCollections([baseCollection])).not.toThrow();
|
|
216
|
+
});
|
|
148
217
|
});
|
package/dist/core.js
CHANGED
|
@@ -88,6 +88,21 @@ export const initBylineCore = async (config, pinoLogger) => {
|
|
|
88
88
|
db: composed.db,
|
|
89
89
|
logger: composed.logger,
|
|
90
90
|
});
|
|
91
|
+
// Idempotently stamp `source_locale` on any documents created before that
|
|
92
|
+
// column existed (it ships nullable and is backfilled here rather than via a
|
|
93
|
+
// NOT NULL migration, so a vanilla `drizzle:migrate` on a populated database
|
|
94
|
+
// never fails on the constraint). Uses the adapter's configured default
|
|
95
|
+
// content locale — the anchor those rows were implicitly authored against —
|
|
96
|
+
// and is a no-op (zero rows) once every document is stamped. Self-heals
|
|
97
|
+
// in-place upgrades without a manual maintenance step. The write path stamps
|
|
98
|
+
// new documents directly, so steady-state boots touch nothing. See
|
|
99
|
+
// docs/DEFAULT-LOCALE-SWITCHING.md.
|
|
100
|
+
if (typeof composed.db.backfillSourceLocales === 'function') {
|
|
101
|
+
const { rowsUpdated } = await composed.db.backfillSourceLocales();
|
|
102
|
+
if (rowsUpdated > 0) {
|
|
103
|
+
composed.logger.info({ rowsUpdated }, `[i18n] stamped source_locale on ${rowsUpdated} pre-existing document(s)`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
91
106
|
const getCollectionRecord = (path) => {
|
|
92
107
|
const record = collectionRecords.get(path);
|
|
93
108
|
if (!record) {
|
|
@@ -5,6 +5,7 @@ export declare const createBaseSchema: (collection?: CollectionDefinition) => z.
|
|
|
5
5
|
id: z.ZodUUID;
|
|
6
6
|
versionId: z.ZodOptional<z.ZodUUID>;
|
|
7
7
|
path: z.ZodOptional<z.ZodString>;
|
|
8
|
+
sourceLocale: z.ZodOptional<z.ZodString>;
|
|
8
9
|
status: z.ZodEnum<{
|
|
9
10
|
[x: string]: string;
|
|
10
11
|
}>;
|
|
@@ -36,6 +37,7 @@ export declare const createCollectionSchemasForPath: (path: string) => {
|
|
|
36
37
|
id: z.ZodUUID;
|
|
37
38
|
versionId: z.ZodOptional<z.ZodUUID>;
|
|
38
39
|
path: z.ZodOptional<z.ZodString>;
|
|
40
|
+
sourceLocale: z.ZodOptional<z.ZodString>;
|
|
39
41
|
status: z.ZodEnum<{
|
|
40
42
|
[x: string]: string;
|
|
41
43
|
}>;
|
|
@@ -53,6 +55,7 @@ export declare const createCollectionSchemasForPath: (path: string) => {
|
|
|
53
55
|
id: z.ZodUUID;
|
|
54
56
|
versionId: z.ZodOptional<z.ZodUUID>;
|
|
55
57
|
path: z.ZodOptional<z.ZodString>;
|
|
58
|
+
sourceLocale: z.ZodOptional<z.ZodString>;
|
|
56
59
|
status: z.ZodEnum<{
|
|
57
60
|
[x: string]: string;
|
|
58
61
|
}>;
|
|
@@ -68,6 +71,7 @@ export declare const createCollectionSchemasForPath: (path: string) => {
|
|
|
68
71
|
id: z.ZodUUID;
|
|
69
72
|
versionId: z.ZodOptional<z.ZodUUID>;
|
|
70
73
|
path: z.ZodOptional<z.ZodString>;
|
|
74
|
+
sourceLocale: z.ZodOptional<z.ZodString>;
|
|
71
75
|
status: z.ZodEnum<{
|
|
72
76
|
[x: string]: string;
|
|
73
77
|
}>;
|
|
@@ -102,6 +106,7 @@ export declare const createCollectionSchemasForPath: (path: string) => {
|
|
|
102
106
|
id: z.ZodUUID;
|
|
103
107
|
versionId: z.ZodOptional<z.ZodUUID>;
|
|
104
108
|
path: z.ZodOptional<z.ZodString>;
|
|
109
|
+
sourceLocale: z.ZodOptional<z.ZodString>;
|
|
105
110
|
status: z.ZodEnum<{
|
|
106
111
|
[x: string]: string;
|
|
107
112
|
}>;
|
|
@@ -128,6 +133,7 @@ export declare const createCollectionSchemasForPath: (path: string) => {
|
|
|
128
133
|
id: z.ZodUUID;
|
|
129
134
|
versionId: z.ZodOptional<z.ZodUUID>;
|
|
130
135
|
path: z.ZodOptional<z.ZodString>;
|
|
136
|
+
sourceLocale: z.ZodOptional<z.ZodString>;
|
|
131
137
|
status: z.ZodEnum<{
|
|
132
138
|
[x: string]: string;
|
|
133
139
|
}>;
|
|
@@ -144,6 +150,7 @@ export declare const createCollectionSchemas: (collection: CollectionDefinition)
|
|
|
144
150
|
id: z.ZodUUID;
|
|
145
151
|
versionId: z.ZodOptional<z.ZodUUID>;
|
|
146
152
|
path: z.ZodOptional<z.ZodString>;
|
|
153
|
+
sourceLocale: z.ZodOptional<z.ZodString>;
|
|
147
154
|
status: z.ZodEnum<{
|
|
148
155
|
[x: string]: string;
|
|
149
156
|
}>;
|
|
@@ -161,6 +168,7 @@ export declare const createCollectionSchemas: (collection: CollectionDefinition)
|
|
|
161
168
|
id: z.ZodUUID;
|
|
162
169
|
versionId: z.ZodOptional<z.ZodUUID>;
|
|
163
170
|
path: z.ZodOptional<z.ZodString>;
|
|
171
|
+
sourceLocale: z.ZodOptional<z.ZodString>;
|
|
164
172
|
status: z.ZodEnum<{
|
|
165
173
|
[x: string]: string;
|
|
166
174
|
}>;
|
|
@@ -176,6 +184,7 @@ export declare const createCollectionSchemas: (collection: CollectionDefinition)
|
|
|
176
184
|
id: z.ZodUUID;
|
|
177
185
|
versionId: z.ZodOptional<z.ZodUUID>;
|
|
178
186
|
path: z.ZodOptional<z.ZodString>;
|
|
187
|
+
sourceLocale: z.ZodOptional<z.ZodString>;
|
|
179
188
|
status: z.ZodEnum<{
|
|
180
189
|
[x: string]: string;
|
|
181
190
|
}>;
|
|
@@ -210,6 +219,7 @@ export declare const createCollectionSchemas: (collection: CollectionDefinition)
|
|
|
210
219
|
id: z.ZodUUID;
|
|
211
220
|
versionId: z.ZodOptional<z.ZodUUID>;
|
|
212
221
|
path: z.ZodOptional<z.ZodString>;
|
|
222
|
+
sourceLocale: z.ZodOptional<z.ZodString>;
|
|
213
223
|
status: z.ZodEnum<{
|
|
214
224
|
[x: string]: string;
|
|
215
225
|
}>;
|
|
@@ -236,6 +246,7 @@ export declare const createCollectionSchemas: (collection: CollectionDefinition)
|
|
|
236
246
|
id: z.ZodUUID;
|
|
237
247
|
versionId: z.ZodOptional<z.ZodUUID>;
|
|
238
248
|
path: z.ZodOptional<z.ZodString>;
|
|
249
|
+
sourceLocale: z.ZodOptional<z.ZodString>;
|
|
239
250
|
status: z.ZodEnum<{
|
|
240
251
|
[x: string]: string;
|
|
241
252
|
}>;
|
|
@@ -252,6 +263,7 @@ export declare const createTypedCollectionSchemas: (collection: CollectionDefini
|
|
|
252
263
|
id: z.ZodUUID;
|
|
253
264
|
versionId: z.ZodOptional<z.ZodUUID>;
|
|
254
265
|
path: z.ZodOptional<z.ZodString>;
|
|
266
|
+
sourceLocale: z.ZodOptional<z.ZodString>;
|
|
255
267
|
status: z.ZodEnum<{
|
|
256
268
|
[x: string]: string;
|
|
257
269
|
}>;
|
|
@@ -269,6 +281,7 @@ export declare const createTypedCollectionSchemas: (collection: CollectionDefini
|
|
|
269
281
|
id: z.ZodUUID;
|
|
270
282
|
versionId: z.ZodOptional<z.ZodUUID>;
|
|
271
283
|
path: z.ZodOptional<z.ZodString>;
|
|
284
|
+
sourceLocale: z.ZodOptional<z.ZodString>;
|
|
272
285
|
status: z.ZodEnum<{
|
|
273
286
|
[x: string]: string;
|
|
274
287
|
}>;
|
|
@@ -284,6 +297,7 @@ export declare const createTypedCollectionSchemas: (collection: CollectionDefini
|
|
|
284
297
|
id: z.ZodUUID;
|
|
285
298
|
versionId: z.ZodOptional<z.ZodUUID>;
|
|
286
299
|
path: z.ZodOptional<z.ZodString>;
|
|
300
|
+
sourceLocale: z.ZodOptional<z.ZodString>;
|
|
287
301
|
status: z.ZodEnum<{
|
|
288
302
|
[x: string]: string;
|
|
289
303
|
}>;
|
|
@@ -318,6 +332,7 @@ export declare const createTypedCollectionSchemas: (collection: CollectionDefini
|
|
|
318
332
|
id: z.ZodUUID;
|
|
319
333
|
versionId: z.ZodOptional<z.ZodUUID>;
|
|
320
334
|
path: z.ZodOptional<z.ZodString>;
|
|
335
|
+
sourceLocale: z.ZodOptional<z.ZodString>;
|
|
321
336
|
status: z.ZodEnum<{
|
|
322
337
|
[x: string]: string;
|
|
323
338
|
}>;
|
|
@@ -344,6 +359,7 @@ export declare const createTypedCollectionSchemas: (collection: CollectionDefini
|
|
|
344
359
|
id: z.ZodUUID;
|
|
345
360
|
versionId: z.ZodOptional<z.ZodUUID>;
|
|
346
361
|
path: z.ZodOptional<z.ZodString>;
|
|
362
|
+
sourceLocale: z.ZodOptional<z.ZodString>;
|
|
347
363
|
status: z.ZodEnum<{
|
|
348
364
|
[x: string]: string;
|
|
349
365
|
}>;
|
|
@@ -360,6 +376,7 @@ export declare const createTypedCollectionSchemasForPath: (path: string) => {
|
|
|
360
376
|
id: z.ZodUUID;
|
|
361
377
|
versionId: z.ZodOptional<z.ZodUUID>;
|
|
362
378
|
path: z.ZodOptional<z.ZodString>;
|
|
379
|
+
sourceLocale: z.ZodOptional<z.ZodString>;
|
|
363
380
|
status: z.ZodEnum<{
|
|
364
381
|
[x: string]: string;
|
|
365
382
|
}>;
|
|
@@ -377,6 +394,7 @@ export declare const createTypedCollectionSchemasForPath: (path: string) => {
|
|
|
377
394
|
id: z.ZodUUID;
|
|
378
395
|
versionId: z.ZodOptional<z.ZodUUID>;
|
|
379
396
|
path: z.ZodOptional<z.ZodString>;
|
|
397
|
+
sourceLocale: z.ZodOptional<z.ZodString>;
|
|
380
398
|
status: z.ZodEnum<{
|
|
381
399
|
[x: string]: string;
|
|
382
400
|
}>;
|
|
@@ -392,6 +410,7 @@ export declare const createTypedCollectionSchemasForPath: (path: string) => {
|
|
|
392
410
|
id: z.ZodUUID;
|
|
393
411
|
versionId: z.ZodOptional<z.ZodUUID>;
|
|
394
412
|
path: z.ZodOptional<z.ZodString>;
|
|
413
|
+
sourceLocale: z.ZodOptional<z.ZodString>;
|
|
395
414
|
status: z.ZodEnum<{
|
|
396
415
|
[x: string]: string;
|
|
397
416
|
}>;
|
|
@@ -426,6 +445,7 @@ export declare const createTypedCollectionSchemasForPath: (path: string) => {
|
|
|
426
445
|
id: z.ZodUUID;
|
|
427
446
|
versionId: z.ZodOptional<z.ZodUUID>;
|
|
428
447
|
path: z.ZodOptional<z.ZodString>;
|
|
448
|
+
sourceLocale: z.ZodOptional<z.ZodString>;
|
|
429
449
|
status: z.ZodEnum<{
|
|
430
450
|
[x: string]: string;
|
|
431
451
|
}>;
|
|
@@ -452,6 +472,7 @@ export declare const createTypedCollectionSchemasForPath: (path: string) => {
|
|
|
452
472
|
id: z.ZodUUID;
|
|
453
473
|
versionId: z.ZodOptional<z.ZodUUID>;
|
|
454
474
|
path: z.ZodOptional<z.ZodString>;
|
|
475
|
+
sourceLocale: z.ZodOptional<z.ZodString>;
|
|
455
476
|
status: z.ZodEnum<{
|
|
456
477
|
[x: string]: string;
|
|
457
478
|
}>;
|
|
@@ -197,6 +197,10 @@ export const createBaseSchema = (collection) => {
|
|
|
197
197
|
id: z.uuid(),
|
|
198
198
|
versionId: z.uuid().optional(),
|
|
199
199
|
path: z.string().optional(),
|
|
200
|
+
// The document's content source-locale anchor (see docs/DEFAULT-LOCALE-SWITCHING.md).
|
|
201
|
+
// Carried through list/get responses so the admin can badge it; Zod would
|
|
202
|
+
// otherwise strip it as an undeclared key.
|
|
203
|
+
sourceLocale: z.string().optional(),
|
|
200
204
|
status: statusEnum,
|
|
201
205
|
hasPublishedVersion: z.boolean().optional(),
|
|
202
206
|
createdAt: z.iso.datetime(),
|
|
@@ -180,6 +180,14 @@ export declare function createDocument(ctx: DocumentLifecycleContext, params: {
|
|
|
180
180
|
* lifecycle derives the value from `definition.useAsPath`.
|
|
181
181
|
*/
|
|
182
182
|
path?: string;
|
|
183
|
+
/**
|
|
184
|
+
* The editorial advertised-locale set (from the admin available-locales
|
|
185
|
+
* sidebar widget). Document-grain and sticky like `path`: passed straight
|
|
186
|
+
* to the storage primitive, which replaces the document's rows wholesale.
|
|
187
|
+
* `undefined` writes nothing (a new document starts with an empty set —
|
|
188
|
+
* the safe opt-in default); `[]` clears it. See docs/AVAILABLE-LOCALES.md.
|
|
189
|
+
*/
|
|
190
|
+
availableLocales?: string[];
|
|
183
191
|
}): Promise<CreateDocumentResult>;
|
|
184
192
|
/**
|
|
185
193
|
* Update a document via full replacement (PUT semantics).
|
|
@@ -205,6 +213,13 @@ export declare function updateDocument(ctx: DocumentLifecycleContext, params: {
|
|
|
205
213
|
* action driven by the admin path widget.
|
|
206
214
|
*/
|
|
207
215
|
path?: string;
|
|
216
|
+
/**
|
|
217
|
+
* The editorial advertised-locale set. `undefined` leaves the existing
|
|
218
|
+
* set untouched (sticky — document-grain, like `path`); an explicit array
|
|
219
|
+
* (empty included) replaces it wholesale. Driven by the admin
|
|
220
|
+
* available-locales sidebar widget. See docs/AVAILABLE-LOCALES.md.
|
|
221
|
+
*/
|
|
222
|
+
availableLocales?: string[];
|
|
208
223
|
}): Promise<UpdateDocumentResult>;
|
|
209
224
|
/**
|
|
210
225
|
* Update a document via patch application.
|
|
@@ -233,6 +248,13 @@ export declare function updateDocumentWithPatches(ctx: DocumentLifecycleContext,
|
|
|
233
248
|
* the previous version.
|
|
234
249
|
*/
|
|
235
250
|
path?: string;
|
|
251
|
+
/**
|
|
252
|
+
* The editorial advertised-locale set (typically supplied alongside
|
|
253
|
+
* patches when the admin available-locales widget has been edited).
|
|
254
|
+
* `undefined` leaves the existing set untouched (sticky); an explicit
|
|
255
|
+
* array replaces it wholesale. See docs/AVAILABLE-LOCALES.md.
|
|
256
|
+
*/
|
|
257
|
+
availableLocales?: string[];
|
|
236
258
|
}): Promise<UpdateDocumentWithPatchesResult>;
|
|
237
259
|
/**
|
|
238
260
|
* Change a document's workflow status.
|
|
@@ -128,22 +128,25 @@ function rethrowPathConflict(err, path, locale) {
|
|
|
128
128
|
* path write entirely (no upsert).
|
|
129
129
|
*/
|
|
130
130
|
function resolvePathForUpdate(args) {
|
|
131
|
-
const { explicitPath, currentPath, requestLocale,
|
|
132
|
-
if (requestLocale ===
|
|
133
|
-
//
|
|
134
|
-
// skip the write (existing path row stays as-is — sticky).
|
|
131
|
+
const { explicitPath, currentPath, requestLocale, sourceLocale, documentId, logger } = args;
|
|
132
|
+
if (requestLocale === sourceLocale) {
|
|
133
|
+
// Source-locale write: pass path through when supplied; otherwise
|
|
134
|
+
// skip the write (existing path row stays as-is — sticky). The path row
|
|
135
|
+
// lives under the document's source_locale (its anchor), not the mutable
|
|
136
|
+
// global default — so this stays correct after the global default is
|
|
137
|
+
// switched. See docs/DEFAULT-LOCALE-SWITCHING.md.
|
|
135
138
|
return explicitPath ?? undefined;
|
|
136
139
|
}
|
|
137
|
-
// Non-
|
|
138
|
-
// operation succeeds but the editor / API caller is informed.
|
|
140
|
+
// Non-source-locale (translation) write: reject any path change with a warn
|
|
141
|
+
// so the operation succeeds but the editor / API caller is informed.
|
|
139
142
|
if (explicitPath !== null && explicitPath !== currentPath) {
|
|
140
143
|
logger?.warn({
|
|
141
144
|
documentId,
|
|
142
145
|
requestedLocale: requestLocale,
|
|
143
|
-
|
|
146
|
+
sourceLocale,
|
|
144
147
|
suppliedPath: explicitPath,
|
|
145
148
|
currentPath,
|
|
146
|
-
}, 'path changes apply only on
|
|
149
|
+
}, 'path changes apply only on source-locale writes; ignored on translation save');
|
|
147
150
|
}
|
|
148
151
|
return undefined;
|
|
149
152
|
}
|
|
@@ -239,6 +242,7 @@ export async function createDocument(ctx, params) {
|
|
|
239
242
|
action: 'create',
|
|
240
243
|
documentData: data,
|
|
241
244
|
path: resolvedPath,
|
|
245
|
+
availableLocales: params.availableLocales,
|
|
242
246
|
status: params.status ?? data.status ?? getDefaultStatus(definition),
|
|
243
247
|
locale: params.locale ?? defaultLocale,
|
|
244
248
|
orderKey,
|
|
@@ -300,11 +304,15 @@ export async function updateDocument(ctx, params) {
|
|
|
300
304
|
const defaultStatus = getDefaultStatus(definition);
|
|
301
305
|
const explicitPath = typeof params.path === 'string' && params.path.length > 0 ? params.path : null;
|
|
302
306
|
const requestLocale = params.locale ?? defaultLocale;
|
|
307
|
+
// The document's own content-locale anchor governs which save writes the
|
|
308
|
+
// path row — not the mutable global default. Falls back to the global
|
|
309
|
+
// default for rows predating source_locale (not yet backfilled).
|
|
310
|
+
const sourceLocale = originalData.source_locale ?? defaultLocale;
|
|
303
311
|
const pathForCommand = resolvePathForUpdate({
|
|
304
312
|
explicitPath,
|
|
305
313
|
currentPath: originalData.path,
|
|
306
314
|
requestLocale,
|
|
307
|
-
|
|
315
|
+
sourceLocale,
|
|
308
316
|
documentId: params.documentId,
|
|
309
317
|
logger: ctx.logger,
|
|
310
318
|
});
|
|
@@ -318,6 +326,7 @@ export async function updateDocument(ctx, params) {
|
|
|
318
326
|
action: 'update',
|
|
319
327
|
documentData: data,
|
|
320
328
|
path: pathForCommand,
|
|
329
|
+
availableLocales: params.availableLocales,
|
|
321
330
|
status: defaultStatus,
|
|
322
331
|
locale: requestLocale,
|
|
323
332
|
previousVersionId: originalData.document_version_id,
|
|
@@ -407,11 +416,15 @@ export async function updateDocumentWithPatches(ctx, params) {
|
|
|
407
416
|
const defaultStatus = getDefaultStatus(definition);
|
|
408
417
|
const explicitPath = typeof params.path === 'string' && params.path.length > 0 ? params.path : null;
|
|
409
418
|
const requestLocale = params.locale ?? defaultLocale;
|
|
419
|
+
// The document's own content-locale anchor governs which save writes the
|
|
420
|
+
// path row — not the mutable global default. Falls back to the global
|
|
421
|
+
// default for rows predating source_locale (not yet backfilled).
|
|
422
|
+
const sourceLocale = originalData.source_locale ?? defaultLocale;
|
|
410
423
|
const pathForCommand = resolvePathForUpdate({
|
|
411
424
|
explicitPath,
|
|
412
425
|
currentPath: originalData.path,
|
|
413
426
|
requestLocale,
|
|
414
|
-
|
|
427
|
+
sourceLocale,
|
|
415
428
|
documentId: params.documentId,
|
|
416
429
|
logger: ctx.logger,
|
|
417
430
|
});
|
|
@@ -425,6 +438,7 @@ export async function updateDocumentWithPatches(ctx, params) {
|
|
|
425
438
|
action: 'update',
|
|
426
439
|
documentData: nextData,
|
|
427
440
|
path: pathForCommand,
|
|
441
|
+
availableLocales: params.availableLocales,
|
|
428
442
|
status: defaultStatus,
|
|
429
443
|
locale: requestLocale,
|
|
430
444
|
previousVersionId: originalData.document_version_id,
|
|
@@ -374,11 +374,12 @@ describe('Document lifecycle service', () => {
|
|
|
374
374
|
});
|
|
375
375
|
expect(createDocumentVersion.mock.calls[0]?.[0].status).toBe('draft');
|
|
376
376
|
});
|
|
377
|
-
it('drops path changes silently with a logger.warn on non-
|
|
377
|
+
it('drops path changes silently with a logger.warn on non-source-locale (translation) saves', async () => {
|
|
378
378
|
const { db, getDocumentById, createDocumentVersion } = createMockDb();
|
|
379
379
|
getDocumentById.mockResolvedValue({
|
|
380
380
|
document_version_id: 'prev-ver',
|
|
381
381
|
path: 'about',
|
|
382
|
+
source_locale: 'en',
|
|
382
383
|
status: 'draft',
|
|
383
384
|
fields: { title: 'About' },
|
|
384
385
|
});
|
|
@@ -400,10 +401,31 @@ describe('Document lifecycle service', () => {
|
|
|
400
401
|
expect(warn).toHaveBeenCalledWith(expect.objectContaining({
|
|
401
402
|
documentId: 'doc-1',
|
|
402
403
|
requestedLocale: 'fr',
|
|
403
|
-
|
|
404
|
+
sourceLocale: 'en',
|
|
404
405
|
suppliedPath: 'a-propos',
|
|
405
406
|
currentPath: 'about',
|
|
406
|
-
}), expect.stringContaining('path changes apply only on
|
|
407
|
+
}), expect.stringContaining('path changes apply only on source-locale writes'));
|
|
408
|
+
});
|
|
409
|
+
it('writes the path on a source-locale save even when it differs from the global default', async () => {
|
|
410
|
+
// Document anchored to de (re-anchored, or authored before a global
|
|
411
|
+
// default switch); the global default is en. A de save is the
|
|
412
|
+
// source-locale write, so the supplied path must flow through.
|
|
413
|
+
const { db, getDocumentById, createDocumentVersion } = createMockDb();
|
|
414
|
+
getDocumentById.mockResolvedValue({
|
|
415
|
+
document_version_id: 'prev-ver',
|
|
416
|
+
path: 'ueber-uns',
|
|
417
|
+
source_locale: 'de',
|
|
418
|
+
status: 'draft',
|
|
419
|
+
fields: { title: 'Über uns' },
|
|
420
|
+
});
|
|
421
|
+
const ctx = buildCtx(db);
|
|
422
|
+
await updateDocument(ctx, {
|
|
423
|
+
documentId: 'doc-1',
|
|
424
|
+
data: { title: 'Über uns — neu' },
|
|
425
|
+
locale: 'de',
|
|
426
|
+
path: 'ueber-uns-neu',
|
|
427
|
+
});
|
|
428
|
+
expect(createDocumentVersion.mock.calls[0]?.[0].path).toBe('ueber-uns-neu');
|
|
407
429
|
});
|
|
408
430
|
it('does not warn when a translation save supplies the same path as current', async () => {
|
|
409
431
|
const { db, getDocumentById } = createMockDb();
|
|
@@ -115,6 +115,8 @@ function canonicalCollection(def) {
|
|
|
115
115
|
out.useAsPath = def.useAsPath;
|
|
116
116
|
if (def.useAsTitle !== undefined)
|
|
117
117
|
out.useAsTitle = def.useAsTitle;
|
|
118
|
+
if (def.advertiseLocales !== undefined)
|
|
119
|
+
out.advertiseLocales = def.advertiseLocales;
|
|
118
120
|
return out;
|
|
119
121
|
}
|
|
120
122
|
// ---------------------------------------------------------------------------
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@byline/core",
|
|
3
3
|
"private": false,
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
|
-
"version": "
|
|
5
|
+
"version": "3.0.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
"sharp": "^0.34.5",
|
|
80
80
|
"uuid": "^14.0.0",
|
|
81
81
|
"zod": "^4.4.3",
|
|
82
|
-
"@byline/auth": "
|
|
82
|
+
"@byline/auth": "3.0.0"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
85
|
"@biomejs/biome": "2.4.15",
|