@byline/db-postgres 2.7.0 → 3.0.1

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.
@@ -11,10 +11,18 @@
11
11
  // either deferring adapter construction or accepting a lazy logger parameter.
12
12
  import { ERR_DATABASE, ERR_NOT_FOUND, getLogger } from '@byline/core';
13
13
  import { and, desc, eq, inArray, isNotNull, sql } from 'drizzle-orm';
14
- import { collections, currentDocumentsView, currentPublishedDocumentsView, documentPaths, documents, documentVersions, metaStore, } from '../../database/schema/index.js';
14
+ import { collections, currentDocumentsView, currentPublishedDocumentsView, documentAvailableLocales, documentPaths, documents, documentVersionLocales, documentVersions, metaStore, } from '../../database/schema/index.js';
15
15
  import { extractFlattenedFieldValue, restoreFieldSetData } from './storage-restore.js';
16
16
  import { allStoreTypes, storeSelectList, storeTableNames, } from './storage-store-manifest.js';
17
17
  import { resolveStoreTypes } from './storage-utils.js';
18
+ /** True when `a` contains every member of `b`. */
19
+ function isSuperset(a, b) {
20
+ for (const item of b) {
21
+ if (!a.has(item))
22
+ return false;
23
+ }
24
+ return true;
25
+ }
18
26
  /**
19
27
  * CollectionQueries
20
28
  */
@@ -91,20 +99,102 @@ export class DocumentQueries {
91
99
  return readMode === 'published' ? currentPublishedDocumentsView : currentDocumentsView;
92
100
  }
93
101
  /**
94
- * Build the locale priority chain for path resolution:
95
- * `[requested, default]`, deduplicated when both are the same.
96
- *
97
- * Used by every read path that touches `byline_document_paths`. In
98
- * phase 1 only the default-locale row is ever populated, so a non-
99
- * default `requested` locale always falls through to the default —
100
- * but the chain shape is correct for phase 2 when per-locale rows
101
- * begin to exist.
102
+ * Build the locale priority chain for fallback resolution:
103
+ * `[requested, floor]`, deduplicated when both are the same. The floor is
104
+ * the document's own `source_locale` anchor when known (so a re-anchored
105
+ * document, or any document read after the global default is switched, falls
106
+ * back to the locale it was actually authored in) otherwise the configured
107
+ * global default, which is correct for not-yet-anchored rows and for
108
+ * row-less lookups (findByPath). See docs/I18N.md.
109
+ */
110
+ buildLocaleChain(requestedLocale, sourceLocale) {
111
+ const floor = sourceLocale ?? this.defaultContentLocale;
112
+ const requested = requestedLocale ?? floor;
113
+ return requested === floor ? [requested] : [requested, floor];
114
+ }
115
+ /**
116
+ * Build the `onMissingLocale: 'omit'` availability gate — an EXISTS against
117
+ * the version-locale ledger (`byline_document_version_locales`) that keeps
118
+ * only documents available in the requested locale. The `'all'` sentinel row
119
+ * covers locale-agnostic documents (no localized content). Returns `null`
120
+ * when the gate does not apply — a non-`'omit'` policy (`'empty'` /
121
+ * `'fallback'` / unset), or the admin sentinel `'all'` read — so callers can
122
+ * conditionally push it into a WHERE.
123
+ */
124
+ localeAvailabilityExists(versionId, locale, onMissingLocale) {
125
+ if (onMissingLocale !== 'omit' || locale === 'all')
126
+ return null;
127
+ return sql `EXISTS (
128
+ SELECT 1 FROM byline_document_version_locales dvl
129
+ WHERE dvl.document_version_id = ${versionId}
130
+ AND (dvl.locale = ${locale} OR dvl.locale = 'all')
131
+ )`;
132
+ }
133
+ /**
134
+ * Batch-fetch the version-locale availability sets from the
135
+ * `byline_document_version_locales` ledger. For each version returns the
136
+ * concrete locales its content is complete in (`availableLocales`, sorted),
137
+ * or `localeAgnostic: true` when the version carries only the `'all'`
138
+ * sentinel (no localized content → renders identically in every locale).
139
+ * Drives the `_availableVersionLocales` read metadata. One indexed query per call.
102
140
  */
103
- buildLocaleChain(requestedLocale) {
104
- const requested = requestedLocale ?? this.defaultContentLocale;
105
- return requested === this.defaultContentLocale
106
- ? [requested]
107
- : [requested, this.defaultContentLocale];
141
+ async getAvailableLocalesByVersion(versionIds) {
142
+ const result = new Map();
143
+ if (versionIds.length === 0)
144
+ return result;
145
+ const rows = await this.db
146
+ .select({
147
+ vid: documentVersionLocales.document_version_id,
148
+ locale: documentVersionLocales.locale,
149
+ })
150
+ .from(documentVersionLocales)
151
+ .where(inArray(documentVersionLocales.document_version_id, versionIds));
152
+ for (const row of rows) {
153
+ let entry = result.get(row.vid);
154
+ if (entry == null) {
155
+ entry = { availableLocales: [], localeAgnostic: false };
156
+ result.set(row.vid, entry);
157
+ }
158
+ if (row.locale === 'all')
159
+ entry.localeAgnostic = true;
160
+ else
161
+ entry.availableLocales.push(row.locale);
162
+ }
163
+ for (const entry of result.values())
164
+ entry.availableLocales.sort();
165
+ return result;
166
+ }
167
+ /**
168
+ * Batch-fetch the editorial advertised-locale sets from
169
+ * `byline_document_available_locales` (document-grain). For each logical
170
+ * document returns the sorted set of locales the editor has elected to
171
+ * advertise. Surfaced on reads as `availableLocales` — the deliberate
172
+ * counterpart to the version-grain `_availableVersionLocales` ledger fact;
173
+ * the public advertised set is their intersection. One indexed query per
174
+ * call. See docs/I18N.md.
175
+ */
176
+ async getAdvertisedLocalesByDocument(documentIds) {
177
+ const result = new Map();
178
+ if (documentIds.length === 0)
179
+ return result;
180
+ const rows = await this.db
181
+ .select({
182
+ did: documentAvailableLocales.document_id,
183
+ locale: documentAvailableLocales.locale,
184
+ })
185
+ .from(documentAvailableLocales)
186
+ .where(inArray(documentAvailableLocales.document_id, documentIds));
187
+ for (const row of rows) {
188
+ let arr = result.get(row.did);
189
+ if (arr == null) {
190
+ arr = [];
191
+ result.set(row.did, arr);
192
+ }
193
+ arr.push(row.locale);
194
+ }
195
+ for (const arr of result.values())
196
+ arr.sort();
197
+ return result;
108
198
  }
109
199
  /**
110
200
  * Emit a SQL fragment that resolves the path string for a document via
@@ -119,13 +209,21 @@ export class DocumentQueries {
119
209
  * LIMIT 1)
120
210
  * ```
121
211
  */
122
- pathProjection(documentIdCol, requestedLocale) {
123
- const chain = this.buildLocaleChain(requestedLocale);
124
- // Build a `ARRAY[$1, $2, ...]::text[]` literal so each locale is its
125
- // own parameter. Passing a JS array as a single `${chain}` placeholder
212
+ pathProjection(documentIdCol, requestedLocale, sourceLocaleCol) {
213
+ // The fallback floor: the row's `source_locale` column when supplied
214
+ // (COALESCE-guarded for not-yet-anchored NULL rows), otherwise the
215
+ // configured global default. The chain is `[requested, floor]`; a runtime
216
+ // duplicate (requested === floor) is harmless — `array_position` picks the
217
+ // first match and `LIMIT 1` collapses it.
218
+ const floorSql = sourceLocaleCol
219
+ ? sql `COALESCE(${sourceLocaleCol}, ${this.defaultContentLocale})`
220
+ : sql `${this.defaultContentLocale}`;
221
+ const requestedSql = requestedLocale != null ? sql `${requestedLocale}` : floorSql;
222
+ // Build a `ARRAY[$1, $2]::text[]` literal so each locale is its own
223
+ // parameter. Passing a JS array as a single `${chain}` placeholder
126
224
  // serialises as a scalar string (`'en'`), which Postgres rejects when
127
225
  // cast to `text[]` ("malformed array literal").
128
- const chainSql = sql.join(chain.map((l) => sql `${l}`), sql `, `);
226
+ const chainSql = sql.join([requestedSql, floorSql], sql `, `);
129
227
  return sql `(
130
228
  SELECT ${documentPaths.path} FROM ${documentPaths}
131
229
  WHERE ${documentPaths.document_id} = ${documentIdCol}
@@ -155,7 +253,8 @@ export class DocumentQueries {
155
253
  updated_at: view.updated_at,
156
254
  created_by: view.created_by,
157
255
  change_summary: view.change_summary,
158
- path: this.pathProjection(sql `${view.document_id}`, requestedLocale),
256
+ source_locale: view.source_locale,
257
+ path: this.pathProjection(sql `${view.document_id}`, requestedLocale, sql `${view.source_locale}`),
159
258
  };
160
259
  }
161
260
  /**
@@ -165,6 +264,11 @@ export class DocumentQueries {
165
264
  * the locale priority chain, since it no longer lives on the version row.
166
265
  */
167
266
  documentVersionsProjection(requestedLocale) {
267
+ // `source_locale` lives on `byline_documents` (document-grain), not the
268
+ // version row — resolve it via a correlated subquery so point-in-time
269
+ // history reads re-base their fallback floor onto the document's anchor,
270
+ // consistent with the current-documents views.
271
+ const sourceLocaleSql = sql `(SELECT source_locale FROM byline_documents WHERE id = ${documentVersions.document_id})`;
168
272
  return {
169
273
  id: documentVersions.id,
170
274
  document_id: documentVersions.document_id,
@@ -177,7 +281,8 @@ export class DocumentQueries {
177
281
  updated_at: documentVersions.updated_at,
178
282
  created_by: documentVersions.created_by,
179
283
  change_summary: documentVersions.change_summary,
180
- path: this.pathProjection(sql `${documentVersions.document_id}`, requestedLocale),
284
+ source_locale: sourceLocaleSql,
285
+ path: this.pathProjection(sql `${documentVersions.document_id}`, requestedLocale, sourceLocaleSql),
181
286
  };
182
287
  }
183
288
  /**
@@ -210,6 +315,52 @@ export class DocumentQueries {
210
315
  LIMIT 1
211
316
  )`;
212
317
  }
318
+ /**
319
+ * Resolve the single effective content locale a version should be restored
320
+ * in, walking the fallback chain (`[requested, default]`) and returning the
321
+ * first locale the version is *available* in.
322
+ *
323
+ * Phase-1 availability rule — **path-coverage against the default locale**:
324
+ * the default (terminal) locale defines the canonical set of localized field
325
+ * paths; a candidate locale `L` is available iff it covers every one of them.
326
+ * This needs only the rows already in hand (no schema walk) and is correct
327
+ * because Byline shares document structure across locales (meta rows are
328
+ * `'all'`) — only leaf values vary per locale.
329
+ *
330
+ * Edge cases: an empty canonical set (the version has no localized content)
331
+ * means any requested locale is trivially available, so the requested locale
332
+ * is returned and the (non-localized, `'all'`) values render identically. The
333
+ * chain always terminates at the default locale, guaranteeing a return value.
334
+ */
335
+ resolveEffectiveLocale(flattenedData, chain) {
336
+ // biome-ignore lint/style/noNonNullAssertion: chain is non-empty by construction
337
+ const defaultLocale = chain[chain.length - 1];
338
+ // Localized field paths present, grouped by locale. Skip `'all'` rows
339
+ // (non-localized values + meta) — they don't participate in coverage.
340
+ const pathsByLocale = new Map();
341
+ for (const row of flattenedData) {
342
+ if (row.locale === 'all' || row.field_type === 'meta')
343
+ continue;
344
+ let set = pathsByLocale.get(row.locale);
345
+ if (set == null) {
346
+ set = new Set();
347
+ pathsByLocale.set(row.locale, set);
348
+ }
349
+ set.add(row.field_path.join('.'));
350
+ }
351
+ const canonical = pathsByLocale.get(defaultLocale) ?? new Set();
352
+ for (const candidate of chain) {
353
+ if (candidate === defaultLocale)
354
+ break; // terminal — return default below
355
+ // No canonical localized content → any locale is trivially available.
356
+ if (canonical.size === 0)
357
+ return candidate;
358
+ const covered = pathsByLocale.get(candidate);
359
+ if (covered != null && isSuperset(covered, canonical))
360
+ return candidate;
361
+ }
362
+ return defaultLocale;
363
+ }
213
364
  /**
214
365
  * Reconstruct document fields from unified row values using schema-aware
215
366
  * restoration. Meta rows (from store_meta) are converted to
@@ -223,7 +374,7 @@ export class DocumentQueries {
223
374
  * how to surface them (the admin edit path uses this to render a
224
375
  * "best-effort load" banner against an out-of-date document).
225
376
  */
226
- reconstructFromUnifiedRows(unifiedFieldValues, definition, locale, metaRows, lenient = false) {
377
+ reconstructFromUnifiedRows(unifiedFieldValues, definition, locale, metaRows, lenient = false, onMissingLocale, sourceLocale) {
227
378
  const flattenedData = unifiedFieldValues.map((row) => extractFlattenedFieldValue(row));
228
379
  if (metaRows) {
229
380
  for (const meta of metaRows) {
@@ -236,7 +387,17 @@ export class DocumentQueries {
236
387
  });
237
388
  }
238
389
  }
239
- const resolveLocale = locale !== 'all' ? locale : undefined;
390
+ // Concrete locale: with `onMissingLocale: 'fallback'`, restore the whole
391
+ // document in a single effective locale chosen from the fallback chain
392
+ // (never mixing locales across fields). Otherwise restore the requested
393
+ // locale exactly — empty where untranslated, the raw per-locale view the
394
+ // admin editor needs (`'empty'`/`'omit'`/unset). `'all'` keeps the
395
+ // per-locale map shape (admin multi-locale read).
396
+ const resolveLocale = locale === 'all'
397
+ ? undefined
398
+ : onMissingLocale === 'fallback'
399
+ ? this.resolveEffectiveLocale(flattenedData, this.buildLocaleChain(locale, sourceLocale))
400
+ : locale;
240
401
  const { data, warnings } = restoreFieldSetData(definition.fields, flattenedData, resolveLocale);
241
402
  if (!lenient && warnings.length > 0) {
242
403
  throw ERR_DATABASE({
@@ -286,7 +447,7 @@ export class DocumentQueries {
286
447
  * rather than thrown. This is the admin edit path's "best-effort load"
287
448
  * mode for documents written under a previous collection schema.
288
449
  */
289
- async getDocumentById({ collection_id, document_id, locale = 'en', reconstruct = true, readMode, filters, lenient = false, }) {
450
+ async getDocumentById({ collection_id, document_id, locale = 'en', reconstruct = true, readMode, filters, lenient = false, onMissingLocale, }) {
290
451
  const view = this.pickCurrentView(readMode);
291
452
  // 1. Get current version (or current published version, per readMode)
292
453
  const baseConditions = [
@@ -298,12 +459,18 @@ export class DocumentQueries {
298
459
  docVersionId: sql `${view.id}`,
299
460
  documentId: sql `${view.document_id}`,
300
461
  status: sql `${view.status}`,
301
- path: this.pathProjection(sql `${view.document_id}`, locale),
462
+ path: this.pathProjection(sql `${view.document_id}`, locale, sql `${view.source_locale}`),
302
463
  };
303
464
  for (const f of filters) {
304
465
  baseConditions.push(this.buildFilterExists(f, locale, outerScope, readMode, 0));
305
466
  }
306
467
  }
468
+ // `onMissingLocale: 'omit'` — resolve to null when the document is not
469
+ // available in the requested locale (no version-locale ledger row).
470
+ const strictGate = this.localeAvailabilityExists(sql `${view.id}`, locale, onMissingLocale);
471
+ if (strictGate) {
472
+ baseConditions.push(strictGate);
473
+ }
307
474
  const [document] = await this.db
308
475
  .select(this.viewProjection(view, locale))
309
476
  .from(view)
@@ -312,7 +479,7 @@ export class DocumentQueries {
312
479
  return null;
313
480
  }
314
481
  // 2. Get all field values for this document
315
- const unifiedFieldValues = await this.getAllFieldValues(document.id, locale);
482
+ const unifiedFieldValues = await this.getAllFieldValues(document.id, locale, document.source_locale);
316
483
  // 3. If reconstruct is true, reconstruct the fields and attach meta
317
484
  if (reconstruct === true) {
318
485
  const definition = await this.getDefinitionForCollection(collection_id);
@@ -325,15 +492,21 @@ export class DocumentQueries {
325
492
  })
326
493
  .from(metaStore)
327
494
  .where(eq(metaStore.document_version_id, document.id));
328
- const { fields, warnings } = this.reconstructFromUnifiedRows(unifiedFieldValues, definition, locale, metaRows, lenient);
495
+ const { fields, warnings } = this.reconstructFromUnifiedRows(unifiedFieldValues, definition, locale, metaRows, lenient, onMissingLocale, document.source_locale);
496
+ const availability = (await this.getAvailableLocalesByVersion([document.id])).get(document.id);
497
+ const advertised = (await this.getAdvertisedLocalesByDocument([document.document_id])).get(document.document_id);
329
498
  return {
330
499
  document_version_id: document.id,
331
500
  document_id: document.document_id,
332
501
  path: document.path ?? '',
502
+ source_locale: document.source_locale ?? null,
333
503
  status: document.status,
334
504
  created_at: document.created_at,
335
505
  updated_at: document.updated_at,
336
506
  fields,
507
+ availableLocales: advertised ?? [],
508
+ _availableVersionLocales: availability?.availableLocales ?? [],
509
+ _localeAgnostic: availability?.localeAgnostic ?? false,
337
510
  ...(lenient && warnings.length > 0 ? { restoreWarnings: warnings } : {}),
338
511
  };
339
512
  }
@@ -343,13 +516,14 @@ export class DocumentQueries {
343
516
  document_version_id: document.id,
344
517
  document_id: document.document_id,
345
518
  path: document.path ?? '',
519
+ source_locale: document.source_locale ?? null,
346
520
  status: document.status,
347
521
  created_at: document.created_at,
348
522
  updated_at: document.updated_at,
349
523
  fields: fieldValues,
350
524
  };
351
525
  }
352
- async getDocumentByPath({ collection_id, path, locale = 'en', reconstruct = true, readMode, filters, }) {
526
+ async getDocumentByPath({ collection_id, path, locale = 'en', reconstruct = true, readMode, filters, onMissingLocale, }) {
353
527
  const view = this.pickCurrentView(readMode);
354
528
  // 1. Get current version (or current published version, per readMode)
355
529
  //
@@ -366,12 +540,18 @@ export class DocumentQueries {
366
540
  docVersionId: sql `${view.id}`,
367
541
  documentId: sql `${view.document_id}`,
368
542
  status: sql `${view.status}`,
369
- path: this.pathProjection(sql `${view.document_id}`, locale),
543
+ path: this.pathProjection(sql `${view.document_id}`, locale, sql `${view.source_locale}`),
370
544
  };
371
545
  for (const f of filters) {
372
546
  baseConditions.push(this.buildFilterExists(f, locale, outerScope, readMode, 0));
373
547
  }
374
548
  }
549
+ // `onMissingLocale: 'omit'` — resolve to null when the document is not
550
+ // available in the requested locale (no version-locale ledger row).
551
+ const strictGate = this.localeAvailabilityExists(sql `${view.id}`, locale, onMissingLocale);
552
+ if (strictGate) {
553
+ baseConditions.push(strictGate);
554
+ }
375
555
  const [document] = await this.db
376
556
  .select(this.viewProjection(view, locale))
377
557
  .from(view)
@@ -380,7 +560,7 @@ export class DocumentQueries {
380
560
  return null;
381
561
  }
382
562
  // 2. Get all field values for this document
383
- const unifiedFieldValues = await this.getAllFieldValues(document.id, locale);
563
+ const unifiedFieldValues = await this.getAllFieldValues(document.id, locale, document.source_locale);
384
564
  // 3. If reconstruct is true, reconstruct the fields and attach meta
385
565
  if (reconstruct === true) {
386
566
  const definition = await this.getDefinitionForCollection(collection_id);
@@ -393,15 +573,21 @@ export class DocumentQueries {
393
573
  })
394
574
  .from(metaStore)
395
575
  .where(eq(metaStore.document_version_id, document.id));
396
- const { fields } = this.reconstructFromUnifiedRows(unifiedFieldValues, definition, locale, metaRows);
576
+ const { fields } = this.reconstructFromUnifiedRows(unifiedFieldValues, definition, locale, metaRows, false, onMissingLocale, document.source_locale);
577
+ const availability = (await this.getAvailableLocalesByVersion([document.id])).get(document.id);
578
+ const advertised = (await this.getAdvertisedLocalesByDocument([document.document_id])).get(document.document_id);
397
579
  return {
398
580
  document_version_id: document.id,
399
581
  document_id: document.document_id,
400
582
  path: document.path ?? '',
583
+ source_locale: document.source_locale ?? null,
401
584
  status: document.status,
402
585
  created_at: document.created_at,
403
586
  updated_at: document.updated_at,
404
587
  fields,
588
+ availableLocales: advertised ?? [],
589
+ _availableVersionLocales: availability?.availableLocales ?? [],
590
+ _localeAgnostic: availability?.localeAgnostic ?? false,
405
591
  };
406
592
  }
407
593
  // Non-reconstructed: return raw flattened values
@@ -410,6 +596,7 @@ export class DocumentQueries {
410
596
  document_version_id: document.id,
411
597
  document_id: document.document_id,
412
598
  path: document.path ?? '',
599
+ source_locale: document.source_locale ?? null,
413
600
  status: document.status,
414
601
  created_at: document.created_at,
415
602
  updated_at: document.updated_at,
@@ -431,7 +618,7 @@ export class DocumentQueries {
431
618
  details: { documentVersionId: document_version_id },
432
619
  }).log(getLogger());
433
620
  }
434
- const unifiedFieldValues = await this.getAllFieldValues(document.id, locale);
621
+ const unifiedFieldValues = await this.getAllFieldValues(document.id, locale, document.source_locale);
435
622
  const definition = await this.getDefinitionForCollection(document.collection_id);
436
623
  const metaRows = await this.db
437
624
  .select({
@@ -442,11 +629,12 @@ export class DocumentQueries {
442
629
  })
443
630
  .from(metaStore)
444
631
  .where(eq(metaStore.document_version_id, document.id));
445
- const { fields } = this.reconstructFromUnifiedRows(unifiedFieldValues, definition, locale, metaRows);
632
+ const { fields } = this.reconstructFromUnifiedRows(unifiedFieldValues, definition, locale, metaRows, false, undefined, document.source_locale);
446
633
  const documentWithFields = {
447
634
  document_version_id: document.id,
448
635
  document_id: document.document_id,
449
636
  path: document.path ?? '',
637
+ source_locale: document.source_locale ?? null,
450
638
  status: document.status,
451
639
  created_at: document.created_at,
452
640
  updated_at: document.updated_at,
@@ -500,7 +688,7 @@ export class DocumentQueries {
500
688
  docVersionId: sql `${view.id}`,
501
689
  documentId: sql `${view.document_id}`,
502
690
  status: sql `${view.status}`,
503
- path: this.pathProjection(sql `${view.document_id}`, filterLocale),
691
+ path: this.pathProjection(sql `${view.document_id}`, filterLocale, sql `${view.source_locale}`),
504
692
  };
505
693
  for (const f of filters) {
506
694
  baseConditions.push(this.buildFilterExists(f, filterLocale, outerScope, readMode, 0));
@@ -510,7 +698,15 @@ export class DocumentQueries {
510
698
  .select(this.viewProjection(view, filterLocale))
511
699
  .from(view)
512
700
  .where(and(...baseConditions));
513
- return this.reconstructDocuments({ documents: docs, locale, fields });
701
+ // Populated relation targets always fall back through the locale chain so
702
+ // a populated tree never has holes — independent of the outer read's
703
+ // `onMissingLocale`. (A no-op when `locale === 'all'`, which keeps the map.)
704
+ return this.reconstructDocuments({
705
+ documents: docs,
706
+ locale,
707
+ fields,
708
+ onMissingLocale: 'fallback',
709
+ });
514
710
  }
515
711
  /**
516
712
  * getDocumentHistory — paginated version history for a document,
@@ -702,7 +898,7 @@ export class DocumentQueries {
702
898
  * reconstructDocuments — retrieve field values and reconstruct multiple documents.
703
899
  * Supports selective field loading via the `fields` parameter.
704
900
  */
705
- async reconstructDocuments({ documents, locale = 'all', fields: requestedFields, }) {
901
+ async reconstructDocuments({ documents, locale = 'all', fields: requestedFields, onMissingLocale, }) {
706
902
  if (documents.length === 0)
707
903
  return [];
708
904
  const versionIds = documents.map((v) => v.id);
@@ -714,8 +910,14 @@ export class DocumentQueries {
714
910
  const storeTypes = requestedFields?.length
715
911
  ? resolveStoreTypes(definition.fields, requestedFields)
716
912
  : undefined;
913
+ // The distinct fallback floors for the batch — each document's own
914
+ // `source_locale` anchor — so the field fetch pulls every locale a row in
915
+ // this page might fall back to, not just the global default.
916
+ const floorLocales = [
917
+ ...new Set(documents.map((d) => d.source_locale).filter((l) => l != null)),
918
+ ];
717
919
  // Get field values for all versions in one query
718
- const allFieldValues = await this.getAllFieldValuesForMultipleVersions(versionIds, locale, storeTypes);
920
+ const allFieldValues = await this.getAllFieldValuesForMultipleVersions(versionIds, locale, storeTypes, floorLocales);
719
921
  // Group field values by document version
720
922
  const fieldValuesByVersion = new Map();
721
923
  for (const fieldValue of allFieldValues) {
@@ -753,7 +955,7 @@ export class DocumentQueries {
753
955
  for (const doc of documents) {
754
956
  const versionFieldValues = fieldValuesByVersion.get(doc.id) || [];
755
957
  const docMetaRows = (metaByVersion.get(doc.id) ?? []);
756
- const { fields } = this.reconstructFromUnifiedRows(versionFieldValues, definition, locale, docMetaRows);
958
+ const { fields } = this.reconstructFromUnifiedRows(versionFieldValues, definition, locale, docMetaRows, false, onMissingLocale, doc.source_locale);
757
959
  // When specific fields were requested, trim the reconstructed object
758
960
  // to only those fields. Store-level filtering avoids querying unused
759
961
  // tables, but fields sharing a store (e.g. price + views in numeric)
@@ -765,6 +967,7 @@ export class DocumentQueries {
765
967
  document_version_id: doc.id,
766
968
  document_id: doc.document_id,
767
969
  path: doc.path ?? '',
970
+ source_locale: doc.source_locale ?? null,
768
971
  status: doc.status,
769
972
  created_at: doc.created_at,
770
973
  updated_at: doc.updated_at,
@@ -778,8 +981,8 @@ export class DocumentQueries {
778
981
  * Gets all field values for a single document version.
779
982
  * Delegates to the multi-version dynamic UNION ALL builder.
780
983
  */
781
- async getAllFieldValues(documentVersionId, locale = 'all') {
782
- return this.getAllFieldValuesForMultipleVersions([documentVersionId], locale);
984
+ async getAllFieldValues(documentVersionId, locale = 'all', sourceLocale) {
985
+ return this.getAllFieldValuesForMultipleVersions([documentVersionId], locale, undefined, sourceLocale ? [sourceLocale] : undefined);
783
986
  }
784
987
  /**
785
988
  * Gets field values for multiple versions in a single query.
@@ -788,10 +991,24 @@ export class DocumentQueries {
788
991
  * the UNION ALL — this is the selective field loading optimisation for
789
992
  * list views that only need a subset of fields.
790
993
  */
791
- async getAllFieldValuesForMultipleVersions(documentVersionIds, locale = 'all', storeTypes) {
994
+ async getAllFieldValuesForMultipleVersions(documentVersionIds, locale = 'all', storeTypes, floorLocales) {
792
995
  if (documentVersionIds.length === 0)
793
996
  return [];
794
- const localeCondition = locale === 'all' ? sql `` : sql `AND (locale = ${locale} OR locale = 'all')`;
997
+ // For a concrete locale, fetch the requested locale plus every fallback
998
+ // floor in the batch, plus non-localized `'all'` rows. The floors are the
999
+ // documents' own `source_locale` anchors (passed by the caller, which has
1000
+ // them on the row) so a document authored in a non-default locale still
1001
+ // has its fallback rows fetched; they default to the global default when
1002
+ // unknown. Per-version effective-locale resolution (see
1003
+ // `resolveEffectiveLocale`) then picks one locale to restore from. `'all'`
1004
+ // skips the filter (admin multi-locale read).
1005
+ let localeCondition = sql ``;
1006
+ if (locale !== 'all') {
1007
+ const floors = floorLocales?.length ? floorLocales : [this.defaultContentLocale];
1008
+ const chain = [...new Set([locale, ...floors])];
1009
+ const chainSql = sql.join(chain.map((l) => sql `${l}`), sql `, `);
1010
+ localeCondition = sql `AND (locale = ANY(ARRAY[${chainSql}]::text[]) OR locale = 'all')`;
1011
+ }
795
1012
  const documentCondition = sql `document_version_id = ANY(ARRAY[${sql.join(documentVersionIds.map((id) => sql `${id}::uuid`), sql `, `)}])`;
796
1013
  const typesToQuery = storeTypes ?? new Set(allStoreTypes);
797
1014
  // Build UNION ALL from only the required store tables.
@@ -824,7 +1041,7 @@ export class DocumentQueries {
824
1041
  * Document-level conditions (status, path) are applied directly on the
825
1042
  * current_documents view.
826
1043
  */
827
- async findDocuments({ collection_id, filters = [], status, pathFilter, query, sort, orderBy = 'created_at', orderDirection = 'desc', locale = 'en', page = 1, pageSize = 20, fields: requestedFields, readMode, }) {
1044
+ async findDocuments({ collection_id, filters = [], status, pathFilter, query, sort, orderBy = 'created_at', orderDirection = 'desc', locale = 'en', page = 1, pageSize = 20, fields: requestedFields, readMode, onMissingLocale, }) {
828
1045
  const offset = (page - 1) * pageSize;
829
1046
  const sourceTable = readMode === 'published'
830
1047
  ? sql.raw('byline_current_published_documents')
@@ -834,8 +1051,14 @@ export class DocumentQueries {
834
1051
  if (status) {
835
1052
  conditions.push(sql `d.status = ${status}`);
836
1053
  }
1054
+ // `onMissingLocale: 'omit'` — exclude documents not available in the
1055
+ // requested locale (filtered at the SQL layer so pagination stays correct).
1056
+ const strictGate = this.localeAvailabilityExists(sql `d.id`, locale, onMissingLocale);
1057
+ if (strictGate) {
1058
+ conditions.push(strictGate);
1059
+ }
837
1060
  if (pathFilter) {
838
- conditions.push(this.buildFilterCondition(this.pathProjection(sql `d.document_id`, locale), pathFilter.operator, pathFilter.value));
1061
+ conditions.push(this.buildFilterCondition(this.pathProjection(sql `d.document_id`, locale, sql `d.source_locale`), pathFilter.operator, pathFilter.value));
839
1062
  }
840
1063
  // Text search across configured search fields via EXISTS on store_text.
841
1064
  if (query) {
@@ -857,7 +1080,7 @@ export class DocumentQueries {
857
1080
  docVersionId: sql `d.id`,
858
1081
  documentId: sql `d.document_id`,
859
1082
  status: sql `d.status`,
860
- path: this.pathProjection(sql `d.document_id`, locale),
1083
+ path: this.pathProjection(sql `d.document_id`, locale, sql `d.source_locale`),
861
1084
  }, readMode, 0));
862
1085
  }
863
1086
  const whereClause = sql.join(conditions, sql ` AND `);
@@ -907,7 +1130,7 @@ export class DocumentQueries {
907
1130
  // keyed by document_id + locale). Project it via the locale-aware
908
1131
  // subquery so the result rows still carry `path` for the in-memory
909
1132
  // Document shape.
910
- const pathProjectionSql = this.pathProjection(sql `d.document_id`, locale);
1133
+ const pathProjectionSql = this.pathProjection(sql `d.document_id`, locale, sql `d.source_locale`);
911
1134
  const mainQuery = sql `
912
1135
  SELECT d.*, ${pathProjectionSql} AS path
913
1136
  FROM ${sourceTable} d
@@ -931,12 +1154,25 @@ export class DocumentQueries {
931
1154
  updated_at: new Date(row.updated_at),
932
1155
  created_by: row.created_by,
933
1156
  change_summary: row.change_summary,
1157
+ source_locale: row.source_locale ?? null,
934
1158
  }));
935
1159
  const documents = await this.reconstructDocuments({
936
1160
  documents: currentDocuments,
937
1161
  locale,
938
1162
  fields: requestedFields,
1163
+ onMissingLocale,
939
1164
  });
1165
+ // Attach the version-locale availability metadata per row (one batched
1166
+ // indexed query for the whole page) so list consumers can render
1167
+ // language affordances / hreflang without a follow-up fetch.
1168
+ const availability = await this.getAvailableLocalesByVersion(documents.map((d) => d.document_version_id));
1169
+ const advertised = await this.getAdvertisedLocalesByDocument(documents.map((d) => d.document_id));
1170
+ for (const doc of documents) {
1171
+ const a = availability.get(doc.document_version_id);
1172
+ doc.availableLocales = advertised.get(doc.document_id) ?? [];
1173
+ doc._availableVersionLocales = a?.availableLocales ?? [];
1174
+ doc._localeAgnostic = a?.localeAgnostic ?? false;
1175
+ }
940
1176
  return { documents, total };
941
1177
  }
942
1178
  /**
@@ -1047,8 +1283,9 @@ export class DocumentQueries {
1047
1283
  documentId: sql.raw(`td${depth}.document_id`),
1048
1284
  status: sql.raw(`td${depth}.status`),
1049
1285
  // `td${depth}.path` no longer exists on the view; resolve via the
1050
- // locale priority chain against byline_document_paths instead.
1051
- path: this.pathProjection(sql.raw(`td${depth}.document_id`), locale),
1286
+ // locale priority chain against byline_document_paths instead, anchored
1287
+ // to the target document's own `source_locale`.
1288
+ path: this.pathProjection(sql.raw(`td${depth}.document_id`), locale, sql.raw(`td${depth}.source_locale`)),
1052
1289
  };
1053
1290
  const nestedConditions = filter.nested.map((nested) => this.buildFilterExists(nested, locale, innerScope, readMode, depth + 1));
1054
1291
  const nestedAnd = nestedConditions.length > 0 ? sql ` AND ${sql.join(nestedConditions, sql ` AND `)}` : sql ``;
@@ -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 {};