@byline/db-mysql 6.6.0 → 6.7.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.
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* `getDocumentSystemFieldsForUpdate`. `DocumentQueries` now declares
|
|
23
23
|
* `implements IDocumentQueries`.
|
|
24
24
|
*/
|
|
25
|
-
import type { CollectionDefinition, DocumentFilter, FieldFilterOperator, FieldSort, FlattenedStore, ICollectionQueries, IDocumentQueries, MissingLocalePolicy, ReadMode } from '@byline/core';
|
|
25
|
+
import type { CollectionDefinition, DocumentFilter, FieldFilterOperator, FieldSort, FlattenedStore, ICollectionQueries, IDocumentQueries, LocaleVisibility, MissingLocalePolicy, ReadMode } from '@byline/core';
|
|
26
26
|
import type { MySql2Database } from 'drizzle-orm/mysql2';
|
|
27
27
|
import { DocumentPublishScheduleQueries } from './publish-schedules.js';
|
|
28
28
|
import type * as schema from '../../database/schema/index.js';
|
|
@@ -138,16 +138,55 @@ export declare class DocumentQueries implements IDocumentQueries {
|
|
|
138
138
|
*/
|
|
139
139
|
private buildLocaleChain;
|
|
140
140
|
/**
|
|
141
|
-
* Build the `onMissingLocale: 'omit'` availability gate
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
141
|
+
* Build the `onMissingLocale: 'omit'` availability gate: keeps only
|
|
142
|
+
* documents whose selected version may be delivered in the requested
|
|
143
|
+
* locale, as an EXISTS-based predicate so counting and pagination stay
|
|
144
|
+
* correct. It mirrors `resolveReadLocale` in `@byline/core`:
|
|
145
|
+
*
|
|
146
|
+
* - the document's source locale is always admitted, with or without a
|
|
147
|
+
* ledger row (no checkbox needed);
|
|
148
|
+
* - a locale-agnostic version (the ledger's `'all'` sentinel) is admitted;
|
|
149
|
+
* - any other locale must be complete in the version's ledger, and under
|
|
150
|
+
* `'public'` visibility in a collection with `advertiseLocales` it must
|
|
151
|
+
* also be checked in `byline_document_available_locales`.
|
|
152
|
+
*
|
|
153
|
+
* Returns `null` when the gate does not apply (a non-`'omit'` policy, or the
|
|
154
|
+
* admin sentinel `'all'`), so callers can conditionally push it into a WHERE.
|
|
149
155
|
*/
|
|
150
156
|
private localeAvailabilityExists;
|
|
157
|
+
/**
|
|
158
|
+
* SQL predicate: may `locale` be delivered for this document version? The
|
|
159
|
+
* SQL twin of `isLocaleEligible` in `@byline/core` — the source is always
|
|
160
|
+
* eligible; a locale-agnostic version (`'all'` sentinel) is eligible; any
|
|
161
|
+
* other locale must be complete in the version's ledger and, under
|
|
162
|
+
* `'public'` visibility in a collection with `advertiseLocales`, checked in
|
|
163
|
+
* `byline_document_available_locales`.
|
|
164
|
+
*/
|
|
165
|
+
private localeEligibleSql;
|
|
166
|
+
/**
|
|
167
|
+
* SQL for the content locale a document's localized values are matched in,
|
|
168
|
+
* mirroring `resolveReadLocale`: under `'fallback'` the requested locale
|
|
169
|
+
* when eligible, otherwise the document's source; under `'omit'` the
|
|
170
|
+
* requested locale (the gate already guarantees eligibility); under a
|
|
171
|
+
* public exact read the requested locale when eligible, otherwise `NULL`
|
|
172
|
+
* (withheld values match nothing and are not replaced by the source); under
|
|
173
|
+
* an editorial exact read the requested locale, partial or not.
|
|
174
|
+
*/
|
|
175
|
+
private effectiveLocaleSql;
|
|
176
|
+
/**
|
|
177
|
+
* The filter policy for a public-capable tree structural read: only when the
|
|
178
|
+
* caller supplies `localeVisibility` (editing reads omit it and keep exact
|
|
179
|
+
* requested-locale matching) and there are predicates to evaluate. Tree
|
|
180
|
+
* edges stay within one collection, resolved from the anchor document when
|
|
181
|
+
* the caller has only a document id.
|
|
182
|
+
*/
|
|
183
|
+
private treeFilterPolicy;
|
|
184
|
+
/**
|
|
185
|
+
* Build the filter locale policy for a read: its missing-locale policy and
|
|
186
|
+
* visibility, plus the `advertiseLocales` flag of the read's collection and
|
|
187
|
+
* of every collection a relation filter hops into.
|
|
188
|
+
*/
|
|
189
|
+
private buildFilterLocalePolicy;
|
|
151
190
|
/**
|
|
152
191
|
* Batch-fetch the version-locale availability sets from the
|
|
153
192
|
* `byline_document_version_locales` ledger. For each version returns the
|
|
@@ -221,14 +260,6 @@ export declare class DocumentQueries implements IDocumentQueries {
|
|
|
221
260
|
* the locale priority chain, since it no longer lives on the version row.
|
|
222
261
|
*/
|
|
223
262
|
private documentVersionsProjection;
|
|
224
|
-
/**
|
|
225
|
-
* Resolve the single effective content locale a version should be restored
|
|
226
|
-
* in, walking the fallback chain (`[requested, default]`) and returning the
|
|
227
|
-
* first locale the version is *available* in. Pure JS/data-structure code
|
|
228
|
-
* — no SQL — ports unchanged from pg. See pg's docblock for the full
|
|
229
|
-
* phase-1 availability rule.
|
|
230
|
-
*/
|
|
231
|
-
private resolveEffectiveLocale;
|
|
232
263
|
/**
|
|
233
264
|
* Reconstruct document fields from unified row values using schema-aware
|
|
234
265
|
* restoration. Meta rows (from store_meta) are converted to
|
|
@@ -278,7 +309,7 @@ export declare class DocumentQueries implements IDocumentQueries {
|
|
|
278
309
|
* rather than thrown. This is the admin edit path's "best-effort load"
|
|
279
310
|
* mode for documents written under a previous collection schema.
|
|
280
311
|
*/
|
|
281
|
-
getDocumentById({ collection_id, document_id, locale, reconstruct, readMode, filters, lenient, onMissingLocale, }: {
|
|
312
|
+
getDocumentById({ collection_id, document_id, locale, reconstruct, readMode, filters, lenient, onMissingLocale, localeVisibility, }: {
|
|
282
313
|
collection_id: string;
|
|
283
314
|
document_id: string;
|
|
284
315
|
locale?: string;
|
|
@@ -287,6 +318,7 @@ export declare class DocumentQueries implements IDocumentQueries {
|
|
|
287
318
|
filters?: DocumentFilter[];
|
|
288
319
|
lenient?: boolean;
|
|
289
320
|
onMissingLocale?: MissingLocalePolicy;
|
|
321
|
+
localeVisibility?: LocaleVisibility;
|
|
290
322
|
}): Promise<{
|
|
291
323
|
document_version_id: string;
|
|
292
324
|
document_id: string;
|
|
@@ -298,6 +330,7 @@ export declare class DocumentQueries implements IDocumentQueries {
|
|
|
298
330
|
updated_at: Date;
|
|
299
331
|
created_by: string | null;
|
|
300
332
|
fields: any;
|
|
333
|
+
resolved_locale: string | null;
|
|
301
334
|
availableLocales: string[];
|
|
302
335
|
_availableVersionLocales: string[];
|
|
303
336
|
_localeAgnostic: boolean;
|
|
@@ -320,7 +353,7 @@ export declare class DocumentQueries implements IDocumentQueries {
|
|
|
320
353
|
* current version. See `resolveDocumentIdByPath` for the locale-chain
|
|
321
354
|
* SQL.
|
|
322
355
|
*/
|
|
323
|
-
getDocumentByPath({ collection_id, path, locale, reconstruct, readMode, filters, onMissingLocale, }: {
|
|
356
|
+
getDocumentByPath({ collection_id, path, locale, reconstruct, readMode, filters, onMissingLocale, localeVisibility, }: {
|
|
324
357
|
collection_id: string;
|
|
325
358
|
path: string;
|
|
326
359
|
locale?: string;
|
|
@@ -328,6 +361,7 @@ export declare class DocumentQueries implements IDocumentQueries {
|
|
|
328
361
|
readMode?: ReadMode;
|
|
329
362
|
filters?: DocumentFilter[];
|
|
330
363
|
onMissingLocale?: MissingLocalePolicy;
|
|
364
|
+
localeVisibility?: LocaleVisibility;
|
|
331
365
|
}): Promise<{
|
|
332
366
|
document_version_id: string;
|
|
333
367
|
document_id: string;
|
|
@@ -339,10 +373,12 @@ export declare class DocumentQueries implements IDocumentQueries {
|
|
|
339
373
|
updated_at: Date;
|
|
340
374
|
created_by: string | null;
|
|
341
375
|
fields: any;
|
|
376
|
+
resolved_locale: string | null;
|
|
342
377
|
availableLocales: string[];
|
|
343
378
|
_availableVersionLocales: string[];
|
|
344
379
|
_localeAgnostic: boolean;
|
|
345
380
|
} | {
|
|
381
|
+
resolved_locale?: undefined;
|
|
346
382
|
availableLocales?: undefined;
|
|
347
383
|
_availableVersionLocales?: undefined;
|
|
348
384
|
_localeAgnostic?: undefined;
|
|
@@ -389,13 +425,14 @@ export declare class DocumentQueries implements IDocumentQueries {
|
|
|
389
425
|
* `store_relation` rows carry `target_document_id` (not version ID), so
|
|
390
426
|
* populate collects those IDs and resolves them here in one round trip.
|
|
391
427
|
*/
|
|
392
|
-
getDocumentsByDocumentIds({ collection_id, document_ids, locale, fields, readMode, filters, }: {
|
|
428
|
+
getDocumentsByDocumentIds({ collection_id, document_ids, locale, fields, readMode, filters, localeVisibility, }: {
|
|
393
429
|
collection_id: string;
|
|
394
430
|
document_ids: string[];
|
|
395
431
|
locale?: string;
|
|
396
432
|
fields?: string[];
|
|
397
433
|
readMode?: ReadMode;
|
|
398
434
|
filters?: DocumentFilter[];
|
|
435
|
+
localeVisibility?: LocaleVisibility;
|
|
399
436
|
}): Promise<any[]>;
|
|
400
437
|
/**
|
|
401
438
|
* getDocumentHistory — paginated version history for a document,
|
|
@@ -519,12 +556,13 @@ export declare class DocumentQueries implements IDocumentQueries {
|
|
|
519
556
|
* MySQL has no cast syntax for a UUID-shaped `CHAR(36)` column, and the
|
|
520
557
|
* driver already binds the plain string correctly.
|
|
521
558
|
*/
|
|
522
|
-
getTreeAncestors({ document_id, maxDepth, readMode, locale, filters, }: {
|
|
559
|
+
getTreeAncestors({ document_id, maxDepth, readMode, locale, filters, localeVisibility, }: {
|
|
523
560
|
document_id: string;
|
|
524
561
|
maxDepth?: number;
|
|
525
562
|
readMode?: ReadMode;
|
|
526
563
|
locale?: string;
|
|
527
564
|
filters?: DocumentFilter[];
|
|
565
|
+
localeVisibility?: LocaleVisibility;
|
|
528
566
|
}): Promise<Array<{
|
|
529
567
|
document_id: string;
|
|
530
568
|
depth: number;
|
|
@@ -553,11 +591,12 @@ export declare class DocumentQueries implements IDocumentQueries {
|
|
|
553
591
|
* (which returns `[]` for both) conflates. `::uuid` cast dropped, same as
|
|
554
592
|
* `getTreeAncestors`.
|
|
555
593
|
*/
|
|
556
|
-
getTreeParent({ document_id, readMode, locale, filters, }: {
|
|
594
|
+
getTreeParent({ document_id, readMode, locale, filters, localeVisibility, }: {
|
|
557
595
|
document_id: string;
|
|
558
596
|
readMode?: ReadMode;
|
|
559
597
|
locale?: string;
|
|
560
598
|
filters?: DocumentFilter[];
|
|
599
|
+
localeVisibility?: LocaleVisibility;
|
|
561
600
|
}): Promise<{
|
|
562
601
|
placed: boolean;
|
|
563
602
|
parentDocumentId: string | null;
|
|
@@ -623,13 +662,14 @@ export declare class DocumentQueries implements IDocumentQueries {
|
|
|
623
662
|
* reverts to the *connection's* default collation, not the source
|
|
624
663
|
* column's, so both clauses are required, not just one).
|
|
625
664
|
*/
|
|
626
|
-
getTreeSubtree({ collectionId, rootDocumentId, maxDepth, readMode, locale, filters, }: {
|
|
665
|
+
getTreeSubtree({ collectionId, rootDocumentId, maxDepth, readMode, locale, filters, localeVisibility, }: {
|
|
627
666
|
collectionId: string;
|
|
628
667
|
rootDocumentId?: string | null;
|
|
629
668
|
maxDepth?: number;
|
|
630
669
|
readMode?: ReadMode;
|
|
631
670
|
locale?: string;
|
|
632
671
|
filters?: DocumentFilter[];
|
|
672
|
+
localeVisibility?: LocaleVisibility;
|
|
633
673
|
}): Promise<Array<{
|
|
634
674
|
document_id: string;
|
|
635
675
|
parent_document_id: string | null;
|
|
@@ -697,7 +737,7 @@ export declare class DocumentQueries implements IDocumentQueries {
|
|
|
697
737
|
* conditions (status, path) are applied directly on the current_documents
|
|
698
738
|
* view.
|
|
699
739
|
*/
|
|
700
|
-
findDocuments({ collection_id, filters, status, pathFilter, query, sort, orderBy, orderDirection, locale, page, pageSize, fields: requestedFields, readMode, onMissingLocale, }: {
|
|
740
|
+
findDocuments({ collection_id, filters, status, pathFilter, query, sort, orderBy, orderDirection, locale, page, pageSize, fields: requestedFields, readMode, onMissingLocale, localeVisibility, }: {
|
|
701
741
|
collection_id: string;
|
|
702
742
|
filters?: DocumentFilter[];
|
|
703
743
|
status?: string;
|
|
@@ -715,6 +755,7 @@ export declare class DocumentQueries implements IDocumentQueries {
|
|
|
715
755
|
fields?: string[];
|
|
716
756
|
readMode?: ReadMode;
|
|
717
757
|
onMissingLocale?: MissingLocalePolicy;
|
|
758
|
+
localeVisibility?: LocaleVisibility;
|
|
718
759
|
}): Promise<{
|
|
719
760
|
documents: any[];
|
|
720
761
|
total: number;
|
|
@@ -22,21 +22,13 @@
|
|
|
22
22
|
* `getDocumentSystemFieldsForUpdate`. `DocumentQueries` now declares
|
|
23
23
|
* `implements IDocumentQueries`.
|
|
24
24
|
*/
|
|
25
|
-
import { ERR_DATABASE, ERR_NOT_FOUND, extractFlattenedFieldValue, getLogger, orderByContentLocale, resolveIdentityField, resolveStoreTypes, restoreFieldSetData, } from '@byline/core';
|
|
25
|
+
import { deriveVersionLocaleLedger, ERR_DATABASE, ERR_NOT_FOUND, extractFlattenedFieldValue, getLogger, orderByContentLocale, resolveIdentityField, resolveReadLocale, resolveStoreTypes, restoreFieldSetData, } from '@byline/core';
|
|
26
26
|
import { and, desc, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm';
|
|
27
27
|
import { collections, currentDocumentsView, currentPublishedDocumentsView, documentAvailableLocales, documentPaths, documentRelationships, documents, documentVersionLocales, documentVersions, metaStore, } from '../../database/schema/index.js';
|
|
28
28
|
import { DocumentPublishScheduleQueries } from './publish-schedules.js';
|
|
29
29
|
import { normalizeRow } from './normalize-row.js';
|
|
30
30
|
import { allStoreTypes, storeSelectList, storeTableNames, } from './storage-store-manifest.js';
|
|
31
31
|
import { toDate } from './storage-utils.js';
|
|
32
|
-
/** True when `a` contains every member of `b`. */
|
|
33
|
-
function isSuperset(a, b) {
|
|
34
|
-
for (const item of b) {
|
|
35
|
-
if (!a.has(item))
|
|
36
|
-
return false;
|
|
37
|
-
}
|
|
38
|
-
return true;
|
|
39
|
-
}
|
|
40
32
|
/**
|
|
41
33
|
* CollectionQueries
|
|
42
34
|
*
|
|
@@ -180,24 +172,130 @@ export class DocumentQueries {
|
|
|
180
172
|
return requested === floor ? [requested] : [requested, floor];
|
|
181
173
|
}
|
|
182
174
|
/**
|
|
183
|
-
* Build the `onMissingLocale: 'omit'` availability gate
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
175
|
+
* Build the `onMissingLocale: 'omit'` availability gate: keeps only
|
|
176
|
+
* documents whose selected version may be delivered in the requested
|
|
177
|
+
* locale, as an EXISTS-based predicate so counting and pagination stay
|
|
178
|
+
* correct. It mirrors `resolveReadLocale` in `@byline/core`:
|
|
179
|
+
*
|
|
180
|
+
* - the document's source locale is always admitted, with or without a
|
|
181
|
+
* ledger row (no checkbox needed);
|
|
182
|
+
* - a locale-agnostic version (the ledger's `'all'` sentinel) is admitted;
|
|
183
|
+
* - any other locale must be complete in the version's ledger, and under
|
|
184
|
+
* `'public'` visibility in a collection with `advertiseLocales` it must
|
|
185
|
+
* also be checked in `byline_document_available_locales`.
|
|
186
|
+
*
|
|
187
|
+
* Returns `null` when the gate does not apply (a non-`'omit'` policy, or the
|
|
188
|
+
* admin sentinel `'all'`), so callers can conditionally push it into a WHERE.
|
|
191
189
|
*/
|
|
192
|
-
localeAvailabilityExists(versionId, locale, onMissingLocale) {
|
|
190
|
+
localeAvailabilityExists(versionId, locale, onMissingLocale, gate) {
|
|
193
191
|
if (onMissingLocale !== 'omit' || locale === 'all')
|
|
194
192
|
return null;
|
|
195
|
-
return
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
193
|
+
return this.localeEligibleSql({ versionId, documentId: gate.documentId, sourceLocale: gate.sourceLocale }, locale, gate.visibility, gate.advertiseLocales);
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* SQL predicate: may `locale` be delivered for this document version? The
|
|
197
|
+
* SQL twin of `isLocaleEligible` in `@byline/core` — the source is always
|
|
198
|
+
* eligible; a locale-agnostic version (`'all'` sentinel) is eligible; any
|
|
199
|
+
* other locale must be complete in the version's ledger and, under
|
|
200
|
+
* `'public'` visibility in a collection with `advertiseLocales`, checked in
|
|
201
|
+
* `byline_document_available_locales`.
|
|
202
|
+
*/
|
|
203
|
+
localeEligibleSql(row, locale, visibility, advertiseLocales) {
|
|
204
|
+
const checked = visibility === 'public' && advertiseLocales
|
|
205
|
+
? sql `AND EXISTS (
|
|
206
|
+
SELECT 1 FROM byline_document_available_locales dal
|
|
207
|
+
WHERE dal.document_id = ${row.documentId}
|
|
208
|
+
AND dal.locale = ${locale}
|
|
209
|
+
)`
|
|
210
|
+
: sql ``;
|
|
211
|
+
return sql `(
|
|
212
|
+
COALESCE(${row.sourceLocale}, ${this.defaultContentLocale}) = ${locale}
|
|
213
|
+
OR EXISTS (
|
|
214
|
+
SELECT 1 FROM byline_document_version_locales dvl
|
|
215
|
+
WHERE dvl.document_version_id = ${row.versionId}
|
|
216
|
+
AND dvl.locale = 'all'
|
|
217
|
+
)
|
|
218
|
+
OR (
|
|
219
|
+
EXISTS (
|
|
220
|
+
SELECT 1 FROM byline_document_version_locales dvl
|
|
221
|
+
WHERE dvl.document_version_id = ${row.versionId}
|
|
222
|
+
AND dvl.locale = ${locale}
|
|
223
|
+
)
|
|
224
|
+
${checked}
|
|
225
|
+
)
|
|
199
226
|
)`;
|
|
200
227
|
}
|
|
228
|
+
/**
|
|
229
|
+
* SQL for the content locale a document's localized values are matched in,
|
|
230
|
+
* mirroring `resolveReadLocale`: under `'fallback'` the requested locale
|
|
231
|
+
* when eligible, otherwise the document's source; under `'omit'` the
|
|
232
|
+
* requested locale (the gate already guarantees eligibility); under a
|
|
233
|
+
* public exact read the requested locale when eligible, otherwise `NULL`
|
|
234
|
+
* (withheld values match nothing and are not replaced by the source); under
|
|
235
|
+
* an editorial exact read the requested locale, partial or not.
|
|
236
|
+
*/
|
|
237
|
+
effectiveLocaleSql(row, locale, onMissingLocale, visibility, advertiseLocales) {
|
|
238
|
+
if (locale === 'all' || onMissingLocale === 'omit')
|
|
239
|
+
return sql `${locale}`;
|
|
240
|
+
const eligible = this.localeEligibleSql(row, locale, visibility, advertiseLocales);
|
|
241
|
+
if (onMissingLocale === 'fallback') {
|
|
242
|
+
return sql `(CASE WHEN ${eligible} THEN ${locale} ELSE COALESCE(${row.sourceLocale}, ${this.defaultContentLocale}) END)`;
|
|
243
|
+
}
|
|
244
|
+
if (visibility === 'public')
|
|
245
|
+
return sql `(CASE WHEN ${eligible} THEN ${locale} END)`;
|
|
246
|
+
return sql `${locale}`;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* The filter policy for a public-capable tree structural read: only when the
|
|
250
|
+
* caller supplies `localeVisibility` (editing reads omit it and keep exact
|
|
251
|
+
* requested-locale matching) and there are predicates to evaluate. Tree
|
|
252
|
+
* edges stay within one collection, resolved from the anchor document when
|
|
253
|
+
* the caller has only a document id.
|
|
254
|
+
*/
|
|
255
|
+
async treeFilterPolicy(filters, visibility, anchor) {
|
|
256
|
+
if (visibility == null || !filters?.length)
|
|
257
|
+
return undefined;
|
|
258
|
+
let collectionId = anchor.collectionId;
|
|
259
|
+
if (collectionId == null && anchor.documentId != null) {
|
|
260
|
+
const [row] = await this.db
|
|
261
|
+
.select({ collection_id: documents.collection_id })
|
|
262
|
+
.from(documents)
|
|
263
|
+
.where(eq(documents.id, anchor.documentId))
|
|
264
|
+
.limit(1);
|
|
265
|
+
collectionId = row?.collection_id;
|
|
266
|
+
}
|
|
267
|
+
if (collectionId == null)
|
|
268
|
+
return undefined;
|
|
269
|
+
return {
|
|
270
|
+
collectionId,
|
|
271
|
+
policy: await this.buildFilterLocalePolicy(collectionId, filters, 'fallback', visibility),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Build the filter locale policy for a read: its missing-locale policy and
|
|
276
|
+
* visibility, plus the `advertiseLocales` flag of the read's collection and
|
|
277
|
+
* of every collection a relation filter hops into.
|
|
278
|
+
*/
|
|
279
|
+
async buildFilterLocalePolicy(collectionId, filters, onMissingLocale, visibility) {
|
|
280
|
+
const ids = new Set([collectionId]);
|
|
281
|
+
const walk = (list) => {
|
|
282
|
+
for (const filter of list) {
|
|
283
|
+
if (filter.kind === 'relation') {
|
|
284
|
+
ids.add(filter.targetCollectionId);
|
|
285
|
+
walk(filter.nested);
|
|
286
|
+
}
|
|
287
|
+
else if (filter.kind === 'and' || filter.kind === 'or') {
|
|
288
|
+
walk(filter.children);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
walk(filters);
|
|
293
|
+
const advertiseByCollectionId = new Map();
|
|
294
|
+
for (const id of ids) {
|
|
295
|
+
advertiseByCollectionId.set(id, (await this.getDefinitionForCollection(id)).advertiseLocales === true);
|
|
296
|
+
}
|
|
297
|
+
return { onMissingLocale, visibility, advertiseByCollectionId };
|
|
298
|
+
}
|
|
201
299
|
/**
|
|
202
300
|
* Batch-fetch the version-locale availability sets from the
|
|
203
301
|
* `byline_document_version_locales` ledger. For each version returns the
|
|
@@ -317,15 +415,45 @@ export class DocumentQueries {
|
|
|
317
415
|
* predicate fail cleanly (no document found).
|
|
318
416
|
*/
|
|
319
417
|
resolveDocumentIdByPath(collection_id, path, requestedLocale) {
|
|
320
|
-
|
|
321
|
-
|
|
418
|
+
// Identity resolution for a slug, which is unique per (collection, locale):
|
|
419
|
+
//
|
|
420
|
+
// rank 0: the path row under the requested locale;
|
|
421
|
+
// rank 1: the path row under the installation default (the previous
|
|
422
|
+
// winner, so it is never displaced);
|
|
423
|
+
// rank 2: a row under *that document's* own source locale, so a document
|
|
424
|
+
// whose source differs from the current default stays reachable.
|
|
425
|
+
//
|
|
426
|
+
// Ranks 0 and 1 hold at most one row each (the unique constraint). Rank 2
|
|
427
|
+
// can hold several documents that share the slug under different sources;
|
|
428
|
+
// only a unique candidate is accepted, so an ambiguous lookup resolves to
|
|
429
|
+
// nothing (fail closed) instead of an arbitrary document. Content
|
|
430
|
+
// eligibility is decided afterwards, on reconstruction, so matching a path
|
|
431
|
+
// never grants a translation.
|
|
432
|
+
const requested = requestedLocale ?? this.defaultContentLocale;
|
|
433
|
+
const def = this.defaultContentLocale;
|
|
434
|
+
const rank = sql `CASE
|
|
435
|
+
WHEN ${documentPaths.locale} = ${requested} THEN 0
|
|
436
|
+
WHEN ${documentPaths.locale} = ${def} THEN 1
|
|
437
|
+
ELSE 2
|
|
438
|
+
END`;
|
|
322
439
|
return sql `(
|
|
323
|
-
SELECT
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
440
|
+
SELECT candidate.document_id FROM (
|
|
441
|
+
SELECT ${documentPaths.document_id} AS document_id,
|
|
442
|
+
${rank} AS path_rank,
|
|
443
|
+
COUNT(*) OVER (PARTITION BY ${rank}) AS rank_peers
|
|
444
|
+
FROM ${documentPaths}
|
|
445
|
+
INNER JOIN ${documents} ON ${documents.id} = ${documentPaths.document_id}
|
|
446
|
+
WHERE ${documentPaths.collection_id} = ${collection_id}
|
|
447
|
+
AND ${documentPaths.path} = ${path}
|
|
448
|
+
AND ${documentPaths.alive} = true
|
|
449
|
+
AND (
|
|
450
|
+
${documentPaths.locale} = ${requested}
|
|
451
|
+
OR ${documentPaths.locale} = ${def}
|
|
452
|
+
OR ${documentPaths.locale} = COALESCE(${documents.source_locale}, ${def})
|
|
453
|
+
)
|
|
454
|
+
) candidate
|
|
455
|
+
WHERE candidate.rank_peers = 1
|
|
456
|
+
ORDER BY candidate.path_rank
|
|
329
457
|
LIMIT 1
|
|
330
458
|
)`;
|
|
331
459
|
}
|
|
@@ -377,39 +505,6 @@ export class DocumentQueries {
|
|
|
377
505
|
path: this.pathProjection(sql `${documentVersions.document_id}`, requestedLocale, sourceLocaleSql),
|
|
378
506
|
};
|
|
379
507
|
}
|
|
380
|
-
/**
|
|
381
|
-
* Resolve the single effective content locale a version should be restored
|
|
382
|
-
* in, walking the fallback chain (`[requested, default]`) and returning the
|
|
383
|
-
* first locale the version is *available* in. Pure JS/data-structure code
|
|
384
|
-
* — no SQL — ports unchanged from pg. See pg's docblock for the full
|
|
385
|
-
* phase-1 availability rule.
|
|
386
|
-
*/
|
|
387
|
-
resolveEffectiveLocale(flattenedData, chain) {
|
|
388
|
-
// biome-ignore lint/style/noNonNullAssertion: chain is non-empty by construction
|
|
389
|
-
const defaultLocale = chain[chain.length - 1];
|
|
390
|
-
const pathsByLocale = new Map();
|
|
391
|
-
for (const row of flattenedData) {
|
|
392
|
-
if (row.locale === 'all' || row.field_type === 'meta')
|
|
393
|
-
continue;
|
|
394
|
-
let set = pathsByLocale.get(row.locale);
|
|
395
|
-
if (set == null) {
|
|
396
|
-
set = new Set();
|
|
397
|
-
pathsByLocale.set(row.locale, set);
|
|
398
|
-
}
|
|
399
|
-
set.add(row.field_path.join('.'));
|
|
400
|
-
}
|
|
401
|
-
const canonical = pathsByLocale.get(defaultLocale) ?? new Set();
|
|
402
|
-
for (const candidate of chain) {
|
|
403
|
-
if (candidate === defaultLocale)
|
|
404
|
-
break;
|
|
405
|
-
if (canonical.size === 0)
|
|
406
|
-
return candidate;
|
|
407
|
-
const covered = pathsByLocale.get(candidate);
|
|
408
|
-
if (covered != null && isSuperset(covered, canonical))
|
|
409
|
-
return candidate;
|
|
410
|
-
}
|
|
411
|
-
return defaultLocale;
|
|
412
|
-
}
|
|
413
508
|
/**
|
|
414
509
|
* Reconstruct document fields from unified row values using schema-aware
|
|
415
510
|
* restoration. Meta rows (from store_meta) are converted to
|
|
@@ -417,7 +512,7 @@ export class DocumentQueries {
|
|
|
417
512
|
* _id and _type for blocks and array items inline. Pure JS/core-delegate
|
|
418
513
|
* code — ports unchanged from pg.
|
|
419
514
|
*/
|
|
420
|
-
reconstructFromUnifiedRows(unifiedFieldValues, definition, locale, metaRows, lenient = false, onMissingLocale, sourceLocale) {
|
|
515
|
+
reconstructFromUnifiedRows(unifiedFieldValues, definition, locale, metaRows, lenient = false, onMissingLocale, sourceLocale, localeContext) {
|
|
421
516
|
const flattenedData = unifiedFieldValues.map((row) => extractFlattenedFieldValue(row));
|
|
422
517
|
if (metaRows) {
|
|
423
518
|
for (const meta of metaRows) {
|
|
@@ -430,19 +525,50 @@ export class DocumentQueries {
|
|
|
430
525
|
});
|
|
431
526
|
}
|
|
432
527
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
528
|
+
// One language decision per version, made from the whole version before
|
|
529
|
+
// any field projection (`resolveReadLocale` in `@byline/core`). The
|
|
530
|
+
// completeness ledger covers every store table, so a projected read decides
|
|
531
|
+
// exactly as a full read would; a version with no ledger rows (written
|
|
532
|
+
// before the ledger and not yet backfilled) derives one from the loaded
|
|
533
|
+
// rows. Omitted visibility is `'editorial'`: direct adapter reads are
|
|
534
|
+
// trusted primitives. Under `'public'`, an exact read of an ineligible
|
|
535
|
+
// locale withholds every localized value (only `'all'` rows and structural
|
|
536
|
+
// meta rows are restored). `'all'` keeps the per-locale map shape.
|
|
537
|
+
// The selected version's ledger, derived from the loaded rows only when
|
|
538
|
+
// the version has no ledger rows. Also the source of `localeAgnostic`,
|
|
539
|
+
// which every reconstructed result reports, multi-locale reads included.
|
|
540
|
+
const floor = sourceLocale ?? this.defaultContentLocale;
|
|
541
|
+
const ledger = localeContext?.ledger ?? deriveVersionLocaleLedger(flattenedData, floor);
|
|
542
|
+
let resolveLocale;
|
|
543
|
+
let resolvedLocale = null;
|
|
544
|
+
let rows = flattenedData;
|
|
545
|
+
if (locale !== 'all') {
|
|
546
|
+
const decision = resolveReadLocale({
|
|
547
|
+
locale,
|
|
548
|
+
chain: this.buildLocaleChain(locale, sourceLocale),
|
|
549
|
+
onMissingLocale,
|
|
550
|
+
eligibility: {
|
|
551
|
+
visibility: localeContext?.visibility ?? 'editorial',
|
|
552
|
+
sourceLocale: floor,
|
|
553
|
+
ledger,
|
|
554
|
+
advertiseLocales: definition.advertiseLocales === true,
|
|
555
|
+
availableLocales: localeContext?.availableLocales ?? [],
|
|
556
|
+
},
|
|
557
|
+
});
|
|
558
|
+
resolveLocale = decision.restoreLocale;
|
|
559
|
+
resolvedLocale = decision.resolvedLocale;
|
|
560
|
+
if (decision.withholdLocalized) {
|
|
561
|
+
rows = flattenedData.filter((row) => row.locale === 'all' || row.field_type === 'meta');
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
const { data, warnings } = restoreFieldSetData(definition.fields, rows, resolveLocale);
|
|
439
565
|
if (!lenient && warnings.length > 0) {
|
|
440
566
|
throw ERR_DATABASE({
|
|
441
567
|
message: `document reconstruction failed with ${warnings.length} warnings`,
|
|
442
568
|
details: { warnings },
|
|
443
569
|
}).log(getLogger());
|
|
444
570
|
}
|
|
445
|
-
return { fields: data, warnings };
|
|
571
|
+
return { fields: data, warnings, resolvedLocale, localeAgnostic: ledger.localeAgnostic };
|
|
446
572
|
}
|
|
447
573
|
/**
|
|
448
574
|
* getCurrentVersionMetadata — narrow metadata fetch for the current version.
|
|
@@ -504,24 +630,38 @@ export class DocumentQueries {
|
|
|
504
630
|
* rather than thrown. This is the admin edit path's "best-effort load"
|
|
505
631
|
* mode for documents written under a previous collection schema.
|
|
506
632
|
*/
|
|
507
|
-
async getDocumentById({ collection_id, document_id, locale = 'en', reconstruct = true, readMode, filters, lenient = false, onMissingLocale, }) {
|
|
633
|
+
async getDocumentById({ collection_id, document_id, locale = 'en', reconstruct = true, readMode, filters, lenient = false, onMissingLocale, localeVisibility, }) {
|
|
508
634
|
const view = this.pickCurrentView(readMode);
|
|
509
635
|
const baseConditions = [
|
|
510
636
|
eq(view.collection_id, collection_id),
|
|
511
637
|
eq(view.document_id, document_id),
|
|
512
638
|
];
|
|
513
639
|
if (filters?.length) {
|
|
640
|
+
const policy = await this.buildFilterLocalePolicy(collection_id, filters, onMissingLocale, localeVisibility);
|
|
641
|
+
const row = {
|
|
642
|
+
versionId: sql `${view.id}`,
|
|
643
|
+
documentId: sql `${view.document_id}`,
|
|
644
|
+
sourceLocale: sql `${view.source_locale}`,
|
|
645
|
+
};
|
|
514
646
|
const outerScope = {
|
|
515
647
|
docVersionId: sql `${view.id}`,
|
|
516
648
|
documentId: sql `${view.document_id}`,
|
|
517
649
|
status: sql `${view.status}`,
|
|
518
650
|
path: this.pathProjection(sql `${view.document_id}`, locale, sql `${view.source_locale}`),
|
|
651
|
+
effectiveLocale: this.effectiveLocaleSql(row, locale, onMissingLocale, localeVisibility, policy.advertiseByCollectionId.get(collection_id) ?? false),
|
|
652
|
+
policy,
|
|
519
653
|
};
|
|
520
654
|
for (const f of filters) {
|
|
521
655
|
baseConditions.push(this.buildFilterExists(f, locale, outerScope, readMode, 0));
|
|
522
656
|
}
|
|
523
657
|
}
|
|
524
|
-
const strictGate = this.localeAvailabilityExists(sql `${view.id}`, locale, onMissingLocale
|
|
658
|
+
const strictGate = this.localeAvailabilityExists(sql `${view.id}`, locale, onMissingLocale, {
|
|
659
|
+
documentId: sql `${view.document_id}`,
|
|
660
|
+
sourceLocale: sql `${view.source_locale}`,
|
|
661
|
+
visibility: localeVisibility,
|
|
662
|
+
advertiseLocales: onMissingLocale === 'omit' &&
|
|
663
|
+
(await this.getDefinitionForCollection(collection_id)).advertiseLocales === true,
|
|
664
|
+
});
|
|
525
665
|
if (strictGate) {
|
|
526
666
|
baseConditions.push(strictGate);
|
|
527
667
|
}
|
|
@@ -544,9 +684,9 @@ export class DocumentQueries {
|
|
|
544
684
|
})
|
|
545
685
|
.from(metaStore)
|
|
546
686
|
.where(eq(metaStore.document_version_id, document.id));
|
|
547
|
-
const { fields, warnings } = this.reconstructFromUnifiedRows(unifiedFieldValues, definition, locale, metaRows, lenient, onMissingLocale, document.source_locale);
|
|
548
687
|
const availability = (await this.getAvailableLocalesByVersion([document.id])).get(document.id);
|
|
549
688
|
const advertised = (await this.getAdvertisedLocalesByDocument([document.document_id])).get(document.document_id);
|
|
689
|
+
const { fields, warnings, resolvedLocale, localeAgnostic } = this.reconstructFromUnifiedRows(unifiedFieldValues, definition, locale, metaRows, lenient, onMissingLocale, document.source_locale, { visibility: localeVisibility, ledger: availability, availableLocales: advertised });
|
|
550
690
|
return {
|
|
551
691
|
document_version_id: document.id,
|
|
552
692
|
document_id: document.document_id,
|
|
@@ -558,9 +698,10 @@ export class DocumentQueries {
|
|
|
558
698
|
updated_at: document.updated_at,
|
|
559
699
|
created_by: document.created_by ?? null,
|
|
560
700
|
fields,
|
|
701
|
+
resolved_locale: resolvedLocale,
|
|
561
702
|
availableLocales: advertised ?? [],
|
|
562
703
|
_availableVersionLocales: availability?.availableLocales ?? [],
|
|
563
|
-
_localeAgnostic:
|
|
704
|
+
_localeAgnostic: localeAgnostic,
|
|
564
705
|
...(lenient && warnings.length > 0 ? { restoreWarnings: warnings } : {}),
|
|
565
706
|
};
|
|
566
707
|
}
|
|
@@ -584,24 +725,38 @@ export class DocumentQueries {
|
|
|
584
725
|
* current version. See `resolveDocumentIdByPath` for the locale-chain
|
|
585
726
|
* SQL.
|
|
586
727
|
*/
|
|
587
|
-
async getDocumentByPath({ collection_id, path, locale = 'en', reconstruct = true, readMode, filters, onMissingLocale, }) {
|
|
728
|
+
async getDocumentByPath({ collection_id, path, locale = 'en', reconstruct = true, readMode, filters, onMissingLocale, localeVisibility, }) {
|
|
588
729
|
const view = this.pickCurrentView(readMode);
|
|
589
730
|
const baseConditions = [
|
|
590
731
|
eq(view.collection_id, collection_id),
|
|
591
732
|
sql `${view.document_id} = ${this.resolveDocumentIdByPath(collection_id, path, locale)}`,
|
|
592
733
|
];
|
|
593
734
|
if (filters?.length) {
|
|
735
|
+
const policy = await this.buildFilterLocalePolicy(collection_id, filters, onMissingLocale, localeVisibility);
|
|
736
|
+
const row = {
|
|
737
|
+
versionId: sql `${view.id}`,
|
|
738
|
+
documentId: sql `${view.document_id}`,
|
|
739
|
+
sourceLocale: sql `${view.source_locale}`,
|
|
740
|
+
};
|
|
594
741
|
const outerScope = {
|
|
595
742
|
docVersionId: sql `${view.id}`,
|
|
596
743
|
documentId: sql `${view.document_id}`,
|
|
597
744
|
status: sql `${view.status}`,
|
|
598
745
|
path: this.pathProjection(sql `${view.document_id}`, locale, sql `${view.source_locale}`),
|
|
746
|
+
effectiveLocale: this.effectiveLocaleSql(row, locale, onMissingLocale, localeVisibility, policy.advertiseByCollectionId.get(collection_id) ?? false),
|
|
747
|
+
policy,
|
|
599
748
|
};
|
|
600
749
|
for (const f of filters) {
|
|
601
750
|
baseConditions.push(this.buildFilterExists(f, locale, outerScope, readMode, 0));
|
|
602
751
|
}
|
|
603
752
|
}
|
|
604
|
-
const strictGate = this.localeAvailabilityExists(sql `${view.id}`, locale, onMissingLocale
|
|
753
|
+
const strictGate = this.localeAvailabilityExists(sql `${view.id}`, locale, onMissingLocale, {
|
|
754
|
+
documentId: sql `${view.document_id}`,
|
|
755
|
+
sourceLocale: sql `${view.source_locale}`,
|
|
756
|
+
visibility: localeVisibility,
|
|
757
|
+
advertiseLocales: onMissingLocale === 'omit' &&
|
|
758
|
+
(await this.getDefinitionForCollection(collection_id)).advertiseLocales === true,
|
|
759
|
+
});
|
|
605
760
|
if (strictGate) {
|
|
606
761
|
baseConditions.push(strictGate);
|
|
607
762
|
}
|
|
@@ -624,9 +779,9 @@ export class DocumentQueries {
|
|
|
624
779
|
})
|
|
625
780
|
.from(metaStore)
|
|
626
781
|
.where(eq(metaStore.document_version_id, document.id));
|
|
627
|
-
const { fields } = this.reconstructFromUnifiedRows(unifiedFieldValues, definition, locale, metaRows, false, onMissingLocale, document.source_locale);
|
|
628
782
|
const availability = (await this.getAvailableLocalesByVersion([document.id])).get(document.id);
|
|
629
783
|
const advertised = (await this.getAdvertisedLocalesByDocument([document.document_id])).get(document.document_id);
|
|
784
|
+
const { fields, resolvedLocale, localeAgnostic } = this.reconstructFromUnifiedRows(unifiedFieldValues, definition, locale, metaRows, false, onMissingLocale, document.source_locale, { visibility: localeVisibility, ledger: availability, availableLocales: advertised });
|
|
630
785
|
return {
|
|
631
786
|
document_version_id: document.id,
|
|
632
787
|
document_id: document.document_id,
|
|
@@ -638,9 +793,10 @@ export class DocumentQueries {
|
|
|
638
793
|
updated_at: document.updated_at,
|
|
639
794
|
created_by: document.created_by ?? null,
|
|
640
795
|
fields,
|
|
796
|
+
resolved_locale: resolvedLocale,
|
|
641
797
|
availableLocales: advertised ?? [],
|
|
642
798
|
_availableVersionLocales: availability?.availableLocales ?? [],
|
|
643
|
-
_localeAgnostic:
|
|
799
|
+
_localeAgnostic: localeAgnostic,
|
|
644
800
|
};
|
|
645
801
|
}
|
|
646
802
|
const fieldValues = this.convertUnionRowToFlattenedStores(unifiedFieldValues);
|
|
@@ -696,7 +852,8 @@ export class DocumentQueries {
|
|
|
696
852
|
})
|
|
697
853
|
.from(metaStore)
|
|
698
854
|
.where(eq(metaStore.document_version_id, document.id));
|
|
699
|
-
const
|
|
855
|
+
const ledger = (await this.getAvailableLocalesByVersion([document.id])).get(document.id);
|
|
856
|
+
const { fields, resolvedLocale, localeAgnostic } = this.reconstructFromUnifiedRows(unifiedFieldValues, definition, locale, metaRows, false, undefined, document.source_locale, { ledger });
|
|
700
857
|
return {
|
|
701
858
|
document_version_id: document.id,
|
|
702
859
|
document_id: document.document_id,
|
|
@@ -706,6 +863,8 @@ export class DocumentQueries {
|
|
|
706
863
|
created_at: document.created_at,
|
|
707
864
|
updated_at: document.updated_at,
|
|
708
865
|
fields,
|
|
866
|
+
resolved_locale: resolvedLocale,
|
|
867
|
+
_localeAgnostic: localeAgnostic,
|
|
709
868
|
};
|
|
710
869
|
}
|
|
711
870
|
/**
|
|
@@ -735,7 +894,7 @@ export class DocumentQueries {
|
|
|
735
894
|
* `store_relation` rows carry `target_document_id` (not version ID), so
|
|
736
895
|
* populate collects those IDs and resolves them here in one round trip.
|
|
737
896
|
*/
|
|
738
|
-
async getDocumentsByDocumentIds({ collection_id, document_ids, locale = 'all', fields, readMode, filters, }) {
|
|
897
|
+
async getDocumentsByDocumentIds({ collection_id, document_ids, locale = 'all', fields, readMode, filters, localeVisibility, }) {
|
|
739
898
|
if (document_ids.length === 0)
|
|
740
899
|
return [];
|
|
741
900
|
const view = this.pickCurrentView(readMode);
|
|
@@ -750,11 +909,20 @@ export class DocumentQueries {
|
|
|
750
909
|
inArray(view.document_id, document_ids),
|
|
751
910
|
];
|
|
752
911
|
if (filters?.length) {
|
|
912
|
+
// Populate shows targets under fallback, so their predicates match in
|
|
913
|
+
// each target's effective fallback locale.
|
|
914
|
+
const policy = await this.buildFilterLocalePolicy(collection_id, filters, 'fallback', localeVisibility);
|
|
753
915
|
const outerScope = {
|
|
754
916
|
docVersionId: sql `${view.id}`,
|
|
755
917
|
documentId: sql `${view.document_id}`,
|
|
756
918
|
status: sql `${view.status}`,
|
|
757
919
|
path: this.pathProjection(sql `${view.document_id}`, filterLocale, sql `${view.source_locale}`),
|
|
920
|
+
effectiveLocale: this.effectiveLocaleSql({
|
|
921
|
+
versionId: sql `${view.id}`,
|
|
922
|
+
documentId: sql `${view.document_id}`,
|
|
923
|
+
sourceLocale: sql `${view.source_locale}`,
|
|
924
|
+
}, filterLocale, 'fallback', localeVisibility, policy.advertiseByCollectionId.get(collection_id) ?? false),
|
|
925
|
+
policy,
|
|
758
926
|
};
|
|
759
927
|
for (const f of filters) {
|
|
760
928
|
baseConditions.push(this.buildFilterExists(f, filterLocale, outerScope, readMode, 0));
|
|
@@ -772,6 +940,7 @@ export class DocumentQueries {
|
|
|
772
940
|
locale,
|
|
773
941
|
fields,
|
|
774
942
|
onMissingLocale: 'fallback',
|
|
943
|
+
localeVisibility,
|
|
775
944
|
});
|
|
776
945
|
}
|
|
777
946
|
/**
|
|
@@ -965,10 +1134,16 @@ export class DocumentQueries {
|
|
|
965
1134
|
* MySQL has no cast syntax for a UUID-shaped `CHAR(36)` column, and the
|
|
966
1135
|
* driver already binds the plain string correctly.
|
|
967
1136
|
*/
|
|
968
|
-
async getTreeAncestors({ document_id, maxDepth = 10_000, readMode = 'any', locale = this.defaultContentLocale, filters, }) {
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
1137
|
+
async getTreeAncestors({ document_id, maxDepth = 10_000, readMode = 'any', locale = this.defaultContentLocale, filters, localeVisibility, }) {
|
|
1138
|
+
// Public-capable structural reads evaluate `beforeRead` predicates in the
|
|
1139
|
+
// locale hydration will show (fallback, under the read's visibility), so a
|
|
1140
|
+
// node is neither dropped nor admitted on a withheld translation.
|
|
1141
|
+
const treePolicy = await this.treeFilterPolicy(filters, localeVisibility, {
|
|
1142
|
+
documentId: document_id,
|
|
1143
|
+
});
|
|
1144
|
+
const childGate = this.buildTreeVisibility(sql `child_document_id`, filters, locale, readMode, 'cv0', treePolicy);
|
|
1145
|
+
const anchorParentGate = this.buildTreeVisibility(sql `parent_document_id`, filters, locale, readMode, 'pv0', treePolicy);
|
|
1146
|
+
const recursiveParentGate = this.buildTreeVisibility(sql `r.parent_document_id`, filters, locale, readMode, 'pv1', treePolicy);
|
|
972
1147
|
const query = sql `
|
|
973
1148
|
WITH RECURSIVE ancestors AS (
|
|
974
1149
|
SELECT parent_document_id AS ancestor_id, child_document_id AS node_id, 1 AS depth
|
|
@@ -1022,9 +1197,15 @@ export class DocumentQueries {
|
|
|
1022
1197
|
* (which returns `[]` for both) conflates. `::uuid` cast dropped, same as
|
|
1023
1198
|
* `getTreeAncestors`.
|
|
1024
1199
|
*/
|
|
1025
|
-
async getTreeParent({ document_id, readMode = 'any', locale = this.defaultContentLocale, filters, }) {
|
|
1026
|
-
|
|
1027
|
-
|
|
1200
|
+
async getTreeParent({ document_id, readMode = 'any', locale = this.defaultContentLocale, filters, localeVisibility, }) {
|
|
1201
|
+
// Public-capable structural reads evaluate `beforeRead` predicates in the
|
|
1202
|
+
// locale hydration will show (fallback, under the read's visibility), so a
|
|
1203
|
+
// node is neither dropped nor admitted on a withheld translation.
|
|
1204
|
+
const treePolicy = await this.treeFilterPolicy(filters, localeVisibility, {
|
|
1205
|
+
documentId: document_id,
|
|
1206
|
+
});
|
|
1207
|
+
const childGate = this.buildTreeVisibility(sql `r.child_document_id`, filters, locale, readMode, 'cv0', treePolicy);
|
|
1208
|
+
const parentGate = this.buildTreeVisibility(sql `r.parent_document_id`, filters, locale, readMode, 'pv0', treePolicy);
|
|
1028
1209
|
const query = sql `
|
|
1029
1210
|
SELECT r.parent_document_id,
|
|
1030
1211
|
CASE WHEN r.parent_document_id IS NULL THEN TRUE ELSE ${parentGate} END AS parent_visible
|
|
@@ -1102,12 +1283,16 @@ export class DocumentQueries {
|
|
|
1102
1283
|
* reverts to the *connection's* default collation, not the source
|
|
1103
1284
|
* column's, so both clauses are required, not just one).
|
|
1104
1285
|
*/
|
|
1105
|
-
async getTreeSubtree({ collectionId, rootDocumentId = null, maxDepth = 10_000, readMode = 'any', locale = this.defaultContentLocale, filters, }) {
|
|
1286
|
+
async getTreeSubtree({ collectionId, rootDocumentId = null, maxDepth = 10_000, readMode = 'any', locale = this.defaultContentLocale, filters, localeVisibility, }) {
|
|
1287
|
+
// Public-capable structural reads evaluate `beforeRead` predicates in the
|
|
1288
|
+
// locale hydration will show (fallback, under the read's visibility), so a
|
|
1289
|
+
// node is neither dropped nor admitted on a withheld translation.
|
|
1290
|
+
const treePolicy = await this.treeFilterPolicy(filters, localeVisibility, { collectionId });
|
|
1106
1291
|
const rootCondition = rootDocumentId == null
|
|
1107
1292
|
? sql `r.parent_document_id IS NULL`
|
|
1108
1293
|
: sql `r.child_document_id = ${rootDocumentId}`;
|
|
1109
|
-
const anchorGate = this.buildTreeVisibility(sql `r.child_document_id`, filters, locale, readMode, 'sv0');
|
|
1110
|
-
const childGate = this.buildTreeVisibility(sql `r.child_document_id`, filters, locale, readMode, 'sv1');
|
|
1294
|
+
const anchorGate = this.buildTreeVisibility(sql `r.child_document_id`, filters, locale, readMode, 'sv0', treePolicy);
|
|
1295
|
+
const childGate = this.buildTreeVisibility(sql `r.child_document_id`, filters, locale, readMode, 'sv1', treePolicy);
|
|
1111
1296
|
const query = sql `
|
|
1112
1297
|
WITH RECURSIVE subtree AS (
|
|
1113
1298
|
SELECT r.child_document_id, r.parent_document_id, r.order_key,
|
|
@@ -1180,10 +1365,17 @@ export class DocumentQueries {
|
|
|
1180
1365
|
* dialect-specific work is one level down, in
|
|
1181
1366
|
* `getAllFieldValuesForMultipleVersions`).
|
|
1182
1367
|
*/
|
|
1183
|
-
async reconstructDocuments({ documents: docs, locale = 'all', fields: requestedFields, onMissingLocale, }) {
|
|
1368
|
+
async reconstructDocuments({ documents: docs, locale = 'all', fields: requestedFields, onMissingLocale, localeVisibility, ledgers, advertised, }) {
|
|
1184
1369
|
if (docs.length === 0)
|
|
1185
1370
|
return [];
|
|
1186
1371
|
const versionIds = docs.map((v) => v.id);
|
|
1372
|
+
const concrete = locale !== 'all';
|
|
1373
|
+
const versionLedgers = ledgers ?? (await this.getAvailableLocalesByVersion(versionIds));
|
|
1374
|
+
// The checkbox set only matters to a public decision.
|
|
1375
|
+
const documentAdvertised = advertised ??
|
|
1376
|
+
(concrete && localeVisibility === 'public'
|
|
1377
|
+
? await this.getAdvertisedLocalesByDocument(docs.map((d) => d.document_id))
|
|
1378
|
+
: undefined);
|
|
1187
1379
|
// Resolve definition once for the batch (safe — early return above guarantees length > 0)
|
|
1188
1380
|
const firstDoc = docs[0];
|
|
1189
1381
|
const definition = await this.getDefinitionForCollection(firstDoc.collection_id);
|
|
@@ -1237,7 +1429,11 @@ export class DocumentQueries {
|
|
|
1237
1429
|
for (const doc of docs) {
|
|
1238
1430
|
const versionFieldValues = fieldValuesByVersion.get(doc.id) || [];
|
|
1239
1431
|
const docMetaRows = (metaByVersion.get(doc.id) ?? []);
|
|
1240
|
-
const { fields } = this.reconstructFromUnifiedRows(versionFieldValues, definition, locale, docMetaRows, false, onMissingLocale, doc.source_locale
|
|
1432
|
+
const { fields, resolvedLocale, localeAgnostic } = this.reconstructFromUnifiedRows(versionFieldValues, definition, locale, docMetaRows, false, onMissingLocale, doc.source_locale, {
|
|
1433
|
+
visibility: localeVisibility,
|
|
1434
|
+
ledger: versionLedgers?.get(doc.id),
|
|
1435
|
+
availableLocales: documentAdvertised?.get(doc.document_id),
|
|
1436
|
+
});
|
|
1241
1437
|
// When specific fields were requested, trim the reconstructed object
|
|
1242
1438
|
// to only those fields. Store-level filtering avoids querying unused
|
|
1243
1439
|
// tables, but fields sharing a store (e.g. price + views in numeric)
|
|
@@ -1256,6 +1452,8 @@ export class DocumentQueries {
|
|
|
1256
1452
|
updated_at: doc.updated_at,
|
|
1257
1453
|
created_by: doc.created_by ?? null,
|
|
1258
1454
|
fields: trimmedFields,
|
|
1455
|
+
resolved_locale: resolvedLocale,
|
|
1456
|
+
_localeAgnostic: localeAgnostic,
|
|
1259
1457
|
};
|
|
1260
1458
|
result.push(documentWithFields);
|
|
1261
1459
|
}
|
|
@@ -1335,7 +1533,7 @@ export class DocumentQueries {
|
|
|
1335
1533
|
* conditions (status, path) are applied directly on the current_documents
|
|
1336
1534
|
* view.
|
|
1337
1535
|
*/
|
|
1338
|
-
async findDocuments({ collection_id, filters = [], status, pathFilter, query, sort, orderBy = 'created_at', orderDirection = 'desc', locale = 'en', page = 1, pageSize = 20, fields: requestedFields, readMode, onMissingLocale, }) {
|
|
1536
|
+
async findDocuments({ collection_id, filters = [], status, pathFilter, query, sort, orderBy = 'created_at', orderDirection = 'desc', locale = 'en', page = 1, pageSize = 20, fields: requestedFields, readMode, onMissingLocale, localeVisibility, }) {
|
|
1339
1537
|
const offset = (page - 1) * pageSize;
|
|
1340
1538
|
const sourceTable = readMode === 'published'
|
|
1341
1539
|
? sql.raw('byline_current_published_documents')
|
|
@@ -1345,9 +1543,21 @@ export class DocumentQueries {
|
|
|
1345
1543
|
if (status) {
|
|
1346
1544
|
conditions.push(sql `d.status = ${status}`);
|
|
1347
1545
|
}
|
|
1546
|
+
// The locale each row's localized values are matched in by filters, the
|
|
1547
|
+
// list text query and field sorts: exactly the locale reconstruction will
|
|
1548
|
+
// show for that row, so a withheld or incomplete translation can neither
|
|
1549
|
+
// match nor influence ordering while the response displays the source.
|
|
1550
|
+
const filterPolicy = await this.buildFilterLocalePolicy(collection_id, filters, onMissingLocale, localeVisibility);
|
|
1551
|
+
const effectiveLocale = this.effectiveLocaleSql({ versionId: sql `d.id`, documentId: sql `d.document_id`, sourceLocale: sql `d.source_locale` }, locale, onMissingLocale, localeVisibility, filterPolicy.advertiseByCollectionId.get(collection_id) ?? false);
|
|
1348
1552
|
// `onMissingLocale: 'omit'` — exclude documents not available in the
|
|
1349
1553
|
// requested locale (filtered at the SQL layer so pagination stays correct).
|
|
1350
|
-
const strictGate = this.localeAvailabilityExists(sql `d.id`, locale, onMissingLocale
|
|
1554
|
+
const strictGate = this.localeAvailabilityExists(sql `d.id`, locale, onMissingLocale, {
|
|
1555
|
+
documentId: sql `d.document_id`,
|
|
1556
|
+
sourceLocale: sql `d.source_locale`,
|
|
1557
|
+
visibility: localeVisibility,
|
|
1558
|
+
advertiseLocales: onMissingLocale === 'omit' &&
|
|
1559
|
+
(await this.getDefinitionForCollection(collection_id)).advertiseLocales === true,
|
|
1560
|
+
});
|
|
1351
1561
|
if (strictGate) {
|
|
1352
1562
|
conditions.push(strictGate);
|
|
1353
1563
|
}
|
|
@@ -1370,19 +1580,22 @@ export class DocumentQueries {
|
|
|
1370
1580
|
conditions.push(sql `EXISTS (
|
|
1371
1581
|
SELECT 1 FROM byline_store_text
|
|
1372
1582
|
WHERE document_version_id = d.id
|
|
1373
|
-
AND (locale = ${
|
|
1583
|
+
AND (locale = ${effectiveLocale} OR locale = 'all')
|
|
1374
1584
|
AND (${sql.join(searchConditions, sql ` OR `)})
|
|
1375
1585
|
)`);
|
|
1376
1586
|
}
|
|
1377
1587
|
// Field-level / relation-level EXISTS subqueries. Each relation hop
|
|
1378
1588
|
// introduces its own alias scope (`r${depth}`, `td${depth}`) so nested
|
|
1379
|
-
// EXISTS clauses don't shadow their outer relation's aliases.
|
|
1589
|
+
// EXISTS clauses don't shadow their outer relation's aliases. Localized
|
|
1590
|
+
// values are matched in each document's effective locale (`effectiveLocale`).
|
|
1380
1591
|
for (const filter of filters) {
|
|
1381
1592
|
conditions.push(this.buildFilterExists(filter, locale, {
|
|
1382
1593
|
docVersionId: sql `d.id`,
|
|
1383
1594
|
documentId: sql `d.document_id`,
|
|
1384
1595
|
status: sql `d.status`,
|
|
1385
1596
|
path: this.pathProjection(sql `d.document_id`, locale, sql `d.source_locale`),
|
|
1597
|
+
effectiveLocale,
|
|
1598
|
+
policy: filterPolicy,
|
|
1386
1599
|
}, readMode, 0));
|
|
1387
1600
|
}
|
|
1388
1601
|
const whereClause = sql.join(conditions, sql ` AND `);
|
|
@@ -1398,7 +1611,7 @@ export class DocumentQueries {
|
|
|
1398
1611
|
FROM ${sql.raw(storeTable)}
|
|
1399
1612
|
WHERE document_version_id = d.id
|
|
1400
1613
|
AND field_name = ${sort.fieldName}
|
|
1401
|
-
AND (locale = ${
|
|
1614
|
+
AND (locale = ${effectiveLocale} OR locale = 'all')
|
|
1402
1615
|
LIMIT 1
|
|
1403
1616
|
) _sort ON true`;
|
|
1404
1617
|
// MySQL has no `NULLS LAST`. `DESC` already sorts NULL last by
|
|
@@ -1481,22 +1694,25 @@ export class DocumentQueries {
|
|
|
1481
1694
|
change_summary: row.change_summary,
|
|
1482
1695
|
source_locale: row.source_locale ?? null,
|
|
1483
1696
|
}));
|
|
1697
|
+
// The version-locale availability for the whole page (one batched
|
|
1698
|
+
// indexed query). It selects each row's effective locale under
|
|
1699
|
+
// `'fallback'` and is attached per row below so list consumers can render
|
|
1700
|
+
// language affordances / hreflang without a follow-up fetch.
|
|
1701
|
+
const availability = await this.getAvailableLocalesByVersion(currentDocuments.map((d) => d.id));
|
|
1702
|
+
const advertised = await this.getAdvertisedLocalesByDocument(currentDocuments.map((d) => d.document_id));
|
|
1484
1703
|
const documents = await this.reconstructDocuments({
|
|
1485
1704
|
documents: currentDocuments,
|
|
1486
1705
|
locale,
|
|
1487
1706
|
fields: requestedFields,
|
|
1488
1707
|
onMissingLocale,
|
|
1708
|
+
localeVisibility,
|
|
1709
|
+
ledgers: availability,
|
|
1710
|
+
advertised,
|
|
1489
1711
|
});
|
|
1490
|
-
// Attach the version-locale availability metadata per row (one batched
|
|
1491
|
-
// indexed query for the whole page) so list consumers can render
|
|
1492
|
-
// language affordances / hreflang without a follow-up fetch.
|
|
1493
|
-
const availability = await this.getAvailableLocalesByVersion(documents.map((d) => d.document_version_id));
|
|
1494
|
-
const advertised = await this.getAdvertisedLocalesByDocument(documents.map((d) => d.document_id));
|
|
1495
1712
|
for (const doc of documents) {
|
|
1496
1713
|
const a = availability.get(doc.document_version_id);
|
|
1497
1714
|
doc.availableLocales = advertised.get(doc.document_id) ?? [];
|
|
1498
1715
|
doc._availableVersionLocales = a?.availableLocales ?? [];
|
|
1499
|
-
doc._localeAgnostic = a?.localeAgnostic ?? false;
|
|
1500
1716
|
}
|
|
1501
1717
|
return { documents, total };
|
|
1502
1718
|
}
|
|
@@ -1506,7 +1722,7 @@ export class DocumentQueries {
|
|
|
1506
1722
|
* SQL of its own (its nested `buildFilterExists` calls carry the
|
|
1507
1723
|
* dialect-specific bits).
|
|
1508
1724
|
*/
|
|
1509
|
-
buildTreeVisibility(documentId, filters, locale, readMode, aliasName) {
|
|
1725
|
+
buildTreeVisibility(documentId, filters, locale, readMode, aliasName, treePolicy) {
|
|
1510
1726
|
const view = readMode === 'published'
|
|
1511
1727
|
? sql.raw('byline_current_published_documents')
|
|
1512
1728
|
: sql.raw('byline_current_documents');
|
|
@@ -1516,6 +1732,14 @@ export class DocumentQueries {
|
|
|
1516
1732
|
documentId: sql `${alias}.document_id`,
|
|
1517
1733
|
status: sql `${alias}.status`,
|
|
1518
1734
|
path: this.pathProjection(sql `${alias}.document_id`, locale, sql `${alias}.source_locale`),
|
|
1735
|
+
effectiveLocale: treePolicy
|
|
1736
|
+
? this.effectiveLocaleSql({
|
|
1737
|
+
versionId: sql `${alias}.id`,
|
|
1738
|
+
documentId: sql `${alias}.document_id`,
|
|
1739
|
+
sourceLocale: sql `${alias}.source_locale`,
|
|
1740
|
+
}, locale, 'fallback', treePolicy.policy.visibility, treePolicy.policy.advertiseByCollectionId.get(treePolicy.collectionId) ?? false)
|
|
1741
|
+
: undefined,
|
|
1742
|
+
policy: treePolicy?.policy,
|
|
1519
1743
|
};
|
|
1520
1744
|
const filterSql = (filters ?? []).map((filter) => this.buildFilterExists(filter, locale, scope, readMode, 0));
|
|
1521
1745
|
const filterClause = filterSql.length > 0 ? sql ` AND ${sql.join(filterSql, sql ` AND `)}` : sql ``;
|
|
@@ -1537,7 +1761,7 @@ export class DocumentQueries {
|
|
|
1537
1761
|
buildFilterExists(filter, locale, outerScope, readMode, depth) {
|
|
1538
1762
|
switch (filter.kind) {
|
|
1539
1763
|
case 'field':
|
|
1540
|
-
return this.buildFieldExists(filter, locale
|
|
1764
|
+
return this.buildFieldExists(filter, outerScope.effectiveLocale ?? sql `${locale}`, outerScope.docVersionId);
|
|
1541
1765
|
case 'relation':
|
|
1542
1766
|
return this.buildRelationExists(filter, locale, outerScope, readMode, depth);
|
|
1543
1767
|
case 'and':
|
|
@@ -1628,6 +1852,17 @@ export class DocumentQueries {
|
|
|
1628
1852
|
// locale priority chain against byline_document_paths instead, anchored
|
|
1629
1853
|
// to the target document's own `source_locale`.
|
|
1630
1854
|
path: this.pathProjection(sql.raw(`td${depth}.document_id`), locale, sql.raw(`td${depth}.source_locale`)),
|
|
1855
|
+
// Each target is matched in its own effective locale (populate shows
|
|
1856
|
+
// targets under fallback), judged by its own source, ledger, checkboxes
|
|
1857
|
+
// and collection opt-in.
|
|
1858
|
+
effectiveLocale: outerScope.policy
|
|
1859
|
+
? this.effectiveLocaleSql({
|
|
1860
|
+
versionId: sql.raw(`td${depth}.id`),
|
|
1861
|
+
documentId: sql.raw(`td${depth}.document_id`),
|
|
1862
|
+
sourceLocale: sql.raw(`td${depth}.source_locale`),
|
|
1863
|
+
}, locale, 'fallback', outerScope.policy.visibility, outerScope.policy.advertiseByCollectionId.get(filter.targetCollectionId) ?? false)
|
|
1864
|
+
: undefined,
|
|
1865
|
+
policy: outerScope.policy,
|
|
1631
1866
|
};
|
|
1632
1867
|
const nestedConditions = filter.nested.map((nested) => this.buildFilterExists(nested, locale, innerScope, readMode, depth + 1));
|
|
1633
1868
|
const quantifier = filter.quantifier ?? 'some';
|
|
@@ -1661,7 +1896,7 @@ export class DocumentQueries {
|
|
|
1661
1896
|
WHERE ${rAlias}.document_version_id = ${outerScope.docVersionId}
|
|
1662
1897
|
AND ${fieldMatch}
|
|
1663
1898
|
AND ${rAlias}.target_collection_id = ${filter.targetCollectionId}
|
|
1664
|
-
AND (${rAlias}.locale = ${locale} OR ${rAlias}.locale = 'all')${nestedAnd}
|
|
1899
|
+
AND (${rAlias}.locale = ${outerScope.effectiveLocale ?? sql `${locale}`} OR ${rAlias}.locale = 'all')${nestedAnd}
|
|
1665
1900
|
)`;
|
|
1666
1901
|
return quantifier === 'some' ? existsSql : sql `NOT ${existsSql}`;
|
|
1667
1902
|
}
|
|
@@ -5,8 +5,11 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
+
import { sql } from 'drizzle-orm';
|
|
8
9
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
9
10
|
import { setupTestDB, teardownTestDB } from '../../../lib/test-helper.js';
|
|
11
|
+
import { createCommandBuilders } from '../storage-commands.js';
|
|
12
|
+
import { createQueryBuilders } from '../storage-queries.js';
|
|
10
13
|
const timestamp = Date.now();
|
|
11
14
|
function first(rows) {
|
|
12
15
|
const row = rows[0];
|
|
@@ -154,6 +157,210 @@ const DatesCollectionConfig = {
|
|
|
154
157
|
* existing suite exercised a `date`/`datetime` field through this path, so
|
|
155
158
|
* nothing caught the un-coerced string before this test.
|
|
156
159
|
*/
|
|
160
|
+
const ProjectedLocaleCollectionConfig = {
|
|
161
|
+
path: `queries-test-projected-locale-${timestamp}`,
|
|
162
|
+
labels: { singular: 'Projected', plural: 'Projected' },
|
|
163
|
+
fields: [
|
|
164
|
+
{ name: 'title', type: 'text', localized: true, optional: true },
|
|
165
|
+
// A localized field in a different store (JSON) from the title (text), so
|
|
166
|
+
// a title-only projection cannot see whether it is translated.
|
|
167
|
+
{ name: 'summary', type: 'richText', localized: true, optional: true },
|
|
168
|
+
],
|
|
169
|
+
};
|
|
170
|
+
describe('fallback locale selection under a changed default (mysql, live database)', () => {
|
|
171
|
+
let testDb;
|
|
172
|
+
let collectionId;
|
|
173
|
+
beforeAll(async () => {
|
|
174
|
+
testDb = setupTestDB([ProjectedLocaleCollectionConfig]);
|
|
175
|
+
const created = first(await testDb.commandBuilders.collections.create(ProjectedLocaleCollectionConfig.path, ProjectedLocaleCollectionConfig));
|
|
176
|
+
collectionId = created.id;
|
|
177
|
+
});
|
|
178
|
+
afterAll(async () => {
|
|
179
|
+
// `teardownTestDB()` is called once, at the very end of this file.
|
|
180
|
+
await testDb.commandBuilders.collections.delete(collectionId);
|
|
181
|
+
});
|
|
182
|
+
it('path lookup finds a document whose source differs from the current default', async () => {
|
|
183
|
+
const created = await testDb.commandBuilders.documents.createDocumentVersion({
|
|
184
|
+
collectionId,
|
|
185
|
+
collectionVersion: 1,
|
|
186
|
+
collectionConfig: ProjectedLocaleCollectionConfig,
|
|
187
|
+
action: 'create',
|
|
188
|
+
documentData: { title: { en: 'Path EN', es: 'Path ES' } },
|
|
189
|
+
path: `path-src-${timestamp}`,
|
|
190
|
+
locale: 'all',
|
|
191
|
+
status: 'published',
|
|
192
|
+
});
|
|
193
|
+
const id = created.document.document_id;
|
|
194
|
+
const frQueries = createQueryBuilders(testDb.db, [ProjectedLocaleCollectionConfig], 'fr', testDb.dbManager);
|
|
195
|
+
for (const locale of ['fr', 'es', 'en']) {
|
|
196
|
+
const byPath = await frQueries.documents.getDocumentByPath({
|
|
197
|
+
collection_id: collectionId,
|
|
198
|
+
path: `path-src-${timestamp}`,
|
|
199
|
+
locale,
|
|
200
|
+
reconstruct: true,
|
|
201
|
+
onMissingLocale: 'fallback',
|
|
202
|
+
});
|
|
203
|
+
expect(byPath?.document_id, `path lookup in ${locale}`).toBe(id);
|
|
204
|
+
const byId = (await frQueries.documents.getDocumentById({
|
|
205
|
+
collection_id: collectionId,
|
|
206
|
+
document_id: id,
|
|
207
|
+
locale,
|
|
208
|
+
onMissingLocale: 'fallback',
|
|
209
|
+
}));
|
|
210
|
+
const { documents } = await frQueries.documents.findDocuments({
|
|
211
|
+
collection_id: collectionId,
|
|
212
|
+
locale,
|
|
213
|
+
onMissingLocale: 'fallback',
|
|
214
|
+
pageSize: 200,
|
|
215
|
+
});
|
|
216
|
+
const listed = documents.find((d) => d.document_id === id);
|
|
217
|
+
expect(byPath?.fields.title).toBe(byId?.fields.title);
|
|
218
|
+
expect(listed?.fields.title).toBe(byId?.fields.title);
|
|
219
|
+
expect(byPath?.resolved_locale).toBe(byId?.resolved_locale);
|
|
220
|
+
expect(listed?.resolved_locale).toBe(byId?.resolved_locale);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
it('path lookup never returns an arbitrary document when slugs collide', async () => {
|
|
224
|
+
const slug = `shared-${timestamp}`;
|
|
225
|
+
const frCommands = createCommandBuilders(testDb.dbManager, 'fr');
|
|
226
|
+
const make = async (commands, title) => (await commands.documents.createDocumentVersion({
|
|
227
|
+
collectionId,
|
|
228
|
+
collectionVersion: 1,
|
|
229
|
+
collectionConfig: ProjectedLocaleCollectionConfig,
|
|
230
|
+
action: 'create',
|
|
231
|
+
documentData: { title },
|
|
232
|
+
path: slug,
|
|
233
|
+
locale: 'all',
|
|
234
|
+
status: 'published',
|
|
235
|
+
})).document.document_id;
|
|
236
|
+
const a = await make(testDb.commandBuilders, { en: 'A EN' });
|
|
237
|
+
const b = await make(frCommands, { fr: 'B FR' });
|
|
238
|
+
const lookup = (defaultLocale, locale, path = slug) => createQueryBuilders(testDb.db, [ProjectedLocaleCollectionConfig], defaultLocale, testDb.dbManager)
|
|
239
|
+
.documents.getDocumentByPath({
|
|
240
|
+
collection_id: collectionId,
|
|
241
|
+
path,
|
|
242
|
+
locale,
|
|
243
|
+
reconstruct: true,
|
|
244
|
+
onMissingLocale: 'fallback',
|
|
245
|
+
})
|
|
246
|
+
.then((doc) => doc?.document_id ?? null);
|
|
247
|
+
expect(await lookup('fr', 'en')).toBe(a);
|
|
248
|
+
expect(await lookup('en', 'fr')).toBe(b);
|
|
249
|
+
expect(await lookup('fr', 'es')).toBe(b);
|
|
250
|
+
expect(await lookup('en', 'es')).toBe(a);
|
|
251
|
+
expect(await lookup('de', 'es')).toBeNull();
|
|
252
|
+
const solo = `solo-${timestamp}`;
|
|
253
|
+
const c = (await testDb.commandBuilders.documents.createDocumentVersion({
|
|
254
|
+
collectionId,
|
|
255
|
+
collectionVersion: 1,
|
|
256
|
+
collectionConfig: ProjectedLocaleCollectionConfig,
|
|
257
|
+
action: 'create',
|
|
258
|
+
documentData: { title: { en: 'C EN' } },
|
|
259
|
+
path: solo,
|
|
260
|
+
locale: 'all',
|
|
261
|
+
status: 'published',
|
|
262
|
+
})).document.document_id;
|
|
263
|
+
expect(await lookup('de', 'es', solo)).toBe(c);
|
|
264
|
+
});
|
|
265
|
+
it('filters match each document in its own source across a mixed-source page', async () => {
|
|
266
|
+
const frCommands = createCommandBuilders(testDb.dbManager, 'fr');
|
|
267
|
+
const make = async (commands, title) => (await commands.documents.createDocumentVersion({
|
|
268
|
+
collectionId,
|
|
269
|
+
collectionVersion: 1,
|
|
270
|
+
collectionConfig: ProjectedLocaleCollectionConfig,
|
|
271
|
+
action: 'create',
|
|
272
|
+
documentData: { title },
|
|
273
|
+
path: `mixed-${timestamp}-${Object.keys(title)[0]}`,
|
|
274
|
+
locale: 'all',
|
|
275
|
+
status: 'published',
|
|
276
|
+
})).document.document_id;
|
|
277
|
+
const a = await make(testDb.commandBuilders, { en: 'Mixed A EN' });
|
|
278
|
+
const b = await make(frCommands, { fr: 'Mixed B FR' });
|
|
279
|
+
const matching = async (value) => (await testDb.queryBuilders.documents.findDocuments({
|
|
280
|
+
collection_id: collectionId,
|
|
281
|
+
locale: 'es',
|
|
282
|
+
onMissingLocale: 'fallback',
|
|
283
|
+
localeVisibility: 'public',
|
|
284
|
+
filters: [
|
|
285
|
+
{
|
|
286
|
+
kind: 'field',
|
|
287
|
+
fieldName: 'title',
|
|
288
|
+
storeType: 'text',
|
|
289
|
+
valueColumn: 'value',
|
|
290
|
+
operator: '$eq',
|
|
291
|
+
value,
|
|
292
|
+
},
|
|
293
|
+
],
|
|
294
|
+
pageSize: 200,
|
|
295
|
+
})).documents.map((d) => d.document_id);
|
|
296
|
+
expect(await matching('Mixed A EN')).toEqual([a]);
|
|
297
|
+
expect(await matching('Mixed B FR')).toEqual([b]);
|
|
298
|
+
});
|
|
299
|
+
it('a translated path row cannot bypass content eligibility', async () => {
|
|
300
|
+
// German is incomplete (title only; the summary is untranslated).
|
|
301
|
+
const created = await testDb.commandBuilders.documents.createDocumentVersion({
|
|
302
|
+
collectionId,
|
|
303
|
+
collectionVersion: 1,
|
|
304
|
+
collectionConfig: ProjectedLocaleCollectionConfig,
|
|
305
|
+
action: 'create',
|
|
306
|
+
documentData: {
|
|
307
|
+
title: { en: 'Slug EN', de: 'Slug DE' },
|
|
308
|
+
summary: { en: { root: { type: 'root', children: [{ type: 'text', text: 'S' }] } } },
|
|
309
|
+
},
|
|
310
|
+
path: `slug-src-${timestamp}`,
|
|
311
|
+
locale: 'all',
|
|
312
|
+
status: 'published',
|
|
313
|
+
});
|
|
314
|
+
const id = created.document.document_id;
|
|
315
|
+
await testDb.db.execute(sql `
|
|
316
|
+
INSERT INTO byline_document_paths (document_id, collection_id, locale, path)
|
|
317
|
+
VALUES (${id}, ${collectionId}, 'de', ${`slug-de-${timestamp}`})
|
|
318
|
+
`);
|
|
319
|
+
const byPath = await testDb.queryBuilders.documents.getDocumentByPath({
|
|
320
|
+
collection_id: collectionId,
|
|
321
|
+
path: `slug-de-${timestamp}`,
|
|
322
|
+
locale: 'de',
|
|
323
|
+
reconstruct: true,
|
|
324
|
+
onMissingLocale: 'fallback',
|
|
325
|
+
localeVisibility: 'public',
|
|
326
|
+
});
|
|
327
|
+
expect(byPath?.document_id).toBe(id);
|
|
328
|
+
expect(byPath?.fields.title, 'the incomplete translation is not delivered').toBe('Slug EN');
|
|
329
|
+
expect(byPath?.resolved_locale).toBe('en');
|
|
330
|
+
});
|
|
331
|
+
it('a projected list falls back to the document source, not the changed default', async () => {
|
|
332
|
+
const richText = (text) => ({
|
|
333
|
+
root: { type: 'root', children: [{ type: 'text', text }] },
|
|
334
|
+
});
|
|
335
|
+
// Source 'en' (the helper's default). French is translated in the text
|
|
336
|
+
// store but not in the JSON-store summary, so French is incomplete.
|
|
337
|
+
const created = await testDb.commandBuilders.documents.createDocumentVersion({
|
|
338
|
+
collectionId,
|
|
339
|
+
collectionVersion: 1,
|
|
340
|
+
collectionConfig: ProjectedLocaleCollectionConfig,
|
|
341
|
+
action: 'create',
|
|
342
|
+
documentData: {
|
|
343
|
+
title: { en: 'Projected EN', fr: 'Projected FR' },
|
|
344
|
+
summary: { en: richText('Summary EN') },
|
|
345
|
+
},
|
|
346
|
+
path: `projected-${timestamp}`,
|
|
347
|
+
locale: 'all',
|
|
348
|
+
status: 'published',
|
|
349
|
+
});
|
|
350
|
+
// The installation default is now 'fr', which differs from the source.
|
|
351
|
+
const frQueries = createQueryBuilders(testDb.db, [ProjectedLocaleCollectionConfig], 'fr', testDb.dbManager);
|
|
352
|
+
const { documents } = await frQueries.documents.findDocuments({
|
|
353
|
+
collection_id: collectionId,
|
|
354
|
+
locale: 'fr',
|
|
355
|
+
onMissingLocale: 'fallback',
|
|
356
|
+
fields: ['title'],
|
|
357
|
+
pageSize: 200,
|
|
358
|
+
});
|
|
359
|
+
// The chain is [fr, en]: French is incomplete in the ledger, so the read
|
|
360
|
+
// resolves to the source 'en', even though only the text store was loaded.
|
|
361
|
+
expect(documents.find((d) => d.document_id === created.document.document_id)?.fields.title).toBe('Projected EN');
|
|
362
|
+
});
|
|
363
|
+
});
|
|
157
364
|
describe('date/datetime field values are real Date objects on read (mysql, live database)', () => {
|
|
158
365
|
let testDb;
|
|
159
366
|
let collectionId;
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@byline/db-mysql",
|
|
3
3
|
"private": false,
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
|
-
"version": "6.
|
|
5
|
+
"version": "6.7.1",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"drizzle-orm": "^0.45.2",
|
|
51
51
|
"mysql2": "^3.24.4",
|
|
52
52
|
"uuid": "^14.0.2",
|
|
53
|
-
"@byline/admin": "6.
|
|
54
|
-
"@byline/core": "6.
|
|
53
|
+
"@byline/admin": "6.7.1",
|
|
54
|
+
"@byline/core": "6.7.1"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@biomejs/biome": "2.5.14",
|
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
"tsc-alias": "^1.9.5",
|
|
65
65
|
"typescript": "^7.0.2",
|
|
66
66
|
"vitest": "^5.0.1",
|
|
67
|
-
"@byline/
|
|
68
|
-
"@byline/
|
|
67
|
+
"@byline/auth": "6.7.1",
|
|
68
|
+
"@byline/client": "6.7.1",
|
|
69
69
|
"@byline/db-conformance": "0.0.10"
|
|
70
70
|
},
|
|
71
71
|
"publishConfig": {
|