@byline/core 2.7.0 → 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.
@@ -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,6 +53,22 @@ 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[];
58
74
  /**
@@ -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
- throw new Error(`Collection "${collection.path}" declares a field named "${field.name}", which is a reserved system attribute. Use \`useAsPath: '<sourceField>'\` on the collection definition instead.`);
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, defaultLocale, documentId, logger } = args;
132
- if (requestLocale === defaultLocale) {
133
- // Default-locale write: pass path through when supplied; otherwise
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-default-locale write: reject any path change with a warn so the
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
- defaultLocale,
146
+ sourceLocale,
144
147
  suppliedPath: explicitPath,
145
148
  currentPath,
146
- }, 'path changes apply only on default-locale writes; ignored on translation save');
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
- defaultLocale,
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
- defaultLocale,
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-default-locale (translation) saves', async () => {
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
- defaultLocale: 'en',
404
+ sourceLocale: 'en',
404
405
  suppliedPath: 'a-propos',
405
406
  currentPath: 'about',
406
- }), expect.stringContaining('path changes apply only on default-locale writes'));
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": "2.7.0",
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": "2.7.0"
82
+ "@byline/auth": "3.0.0"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@biomejs/biome": "2.4.15",