@byline/db-postgres 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/database/schema/index.d.ts +190 -0
- package/dist/database/schema/index.js +60 -1
- package/dist/index.d.ts +55 -4
- package/dist/index.js +5 -1
- package/dist/modules/storage/storage-commands.d.ts +141 -3
- package/dist/modules/storage/storage-commands.js +388 -10
- package/dist/modules/storage/storage-queries.d.ts +90 -12
- package/dist/modules/storage/storage-queries.js +285 -48
- package/dist/modules/storage/tests/storage-document-available-locales.test.d.ts +8 -0
- package/dist/modules/storage/tests/storage-document-available-locales.test.js +198 -0
- package/dist/modules/storage/tests/storage-locale-fallback.test.d.ts +8 -0
- package/dist/modules/storage/tests/storage-locale-fallback.test.js +615 -0
- package/package.json +4 -4
|
@@ -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
|
|
95
|
-
* `[requested,
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
* default
|
|
100
|
-
*
|
|
101
|
-
|
|
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/DEFAULT-LOCALE-SWITCHING.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
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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/AVAILABLE-LOCALES.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
|
-
|
|
124
|
-
//
|
|
125
|
-
//
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {};
|