@byline/db-mysql 6.5.0 → 6.7.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.
@@ -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 — an EXISTS against
142
- * the version-locale ledger (`byline_document_version_locales`) that keeps
143
- * only documents available in the requested locale. The `'all'` sentinel row
144
- * covers locale-agnostic documents (no localized content). Returns `null`
145
- * when the gate does not apply — a non-`'omit'` policy (`'empty'` /
146
- * `'fallback'` / unset), or the admin sentinel `'all'` read — so callers can
147
- * conditionally push it into a WHERE. No dialect-specific SQL — ports
148
- * unchanged from pg.
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 — an EXISTS against
184
- * the version-locale ledger (`byline_document_version_locales`) that keeps
185
- * only documents available in the requested locale. The `'all'` sentinel row
186
- * covers locale-agnostic documents (no localized content). Returns `null`
187
- * when the gate does not apply — a non-`'omit'` policy (`'empty'` /
188
- * `'fallback'` / unset), or the admin sentinel `'all'` read — so callers can
189
- * conditionally push it into a WHERE. No dialect-specific SQL — ports
190
- * unchanged from pg.
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 sql `EXISTS (
196
- SELECT 1 FROM byline_document_version_locales dvl
197
- WHERE dvl.document_version_id = ${versionId}
198
- AND (dvl.locale = ${locale} OR dvl.locale = 'all')
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
- const chain = this.buildLocaleChain(requestedLocale);
321
- const chainSql = sql.join(chain.map((l) => sql `${l}`), sql `, `);
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 ${documentPaths.document_id} FROM ${documentPaths}
324
- WHERE ${documentPaths.collection_id} = ${collection_id}
325
- AND ${documentPaths.path} = ${path}
326
- AND ${documentPaths.alive} = true
327
- AND ${documentPaths.locale} IN (${chainSql})
328
- ORDER BY FIELD(${documentPaths.locale}, ${chainSql})
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
- const resolveLocale = locale === 'all'
434
- ? undefined
435
- : onMissingLocale === 'fallback'
436
- ? this.resolveEffectiveLocale(flattenedData, this.buildLocaleChain(locale, sourceLocale))
437
- : locale;
438
- const { data, warnings } = restoreFieldSetData(definition.fields, flattenedData, resolveLocale);
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: availability?.localeAgnostic ?? false,
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: availability?.localeAgnostic ?? false,
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 { fields } = this.reconstructFromUnifiedRows(unifiedFieldValues, definition, locale, metaRows, false, undefined, document.source_locale);
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
- const childGate = this.buildTreeVisibility(sql `child_document_id`, filters, locale, readMode, 'cv0');
970
- const anchorParentGate = this.buildTreeVisibility(sql `parent_document_id`, filters, locale, readMode, 'pv0');
971
- const recursiveParentGate = this.buildTreeVisibility(sql `r.parent_document_id`, filters, locale, readMode, 'pv1');
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
- const childGate = this.buildTreeVisibility(sql `r.child_document_id`, filters, locale, readMode, 'cv0');
1027
- const parentGate = this.buildTreeVisibility(sql `r.parent_document_id`, filters, locale, readMode, 'pv0');
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 = ${locale} OR locale = 'all')
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 = ${locale} OR locale = 'all')
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, outerScope.docVersionId);
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.0",
5
+ "version": "6.7.0",
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.5.0",
54
- "@byline/core": "6.5.0"
53
+ "@byline/admin": "6.7.0",
54
+ "@byline/core": "6.7.0"
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/auth": "6.5.0",
68
- "@byline/client": "6.5.0",
67
+ "@byline/client": "6.7.0",
68
+ "@byline/auth": "6.7.0",
69
69
  "@byline/db-conformance": "0.0.10"
70
70
  },
71
71
  "publishConfig": {