@byline/db-postgres 2.7.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,9 +5,9 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import { and, eq, ne, sql } from 'drizzle-orm';
8
+ import { and, desc, eq, ne, sql } from 'drizzle-orm';
9
9
  import { v7 as uuidv7 } from 'uuid';
10
- import { booleanStore, collections, datetimeStore, documentPaths, documents, documentVersions, fileStore, jsonStore, metaStore, numericStore, relationStore, textStore, } from '../../database/schema/index.js';
10
+ import { booleanStore, collections, datetimeStore, documentAvailableLocales, documentPaths, documents, documentVersionLocales, documentVersions, fileStore, jsonStore, metaStore, numericStore, relationStore, textStore, } from '../../database/schema/index.js';
11
11
  import { flattenFieldSetData } from './storage-flatten.js';
12
12
  import { prepareFieldInsertBuckets } from './storage-insert.js';
13
13
  import { getFirstOrThrow } from './storage-utils.js';
@@ -68,19 +68,39 @@ export class DocumentCommands {
68
68
  async createDocumentVersion(params) {
69
69
  return await this.db.transaction(async (tx) => {
70
70
  let documentId = params.documentId;
71
- // 1. Create the main document if needed
71
+ // 1. Create the main document if needed, and resolve the document's
72
+ // `source_locale` — its per-document data anchor. A brand-new document
73
+ // is anchored to the configured default content locale (the locale it is
74
+ // authored in; `createDocument` enforces create-in-default). An existing
75
+ // document carries its own anchor on `byline_documents`; read it so the
76
+ // path row and the completeness ledger below key off *this document's*
77
+ // source locale rather than the mutable global default. NULL (a row not
78
+ // yet touched by `backfillSourceLocales`) falls back to the configured
79
+ // default — the value it was implicitly authored against.
80
+ // See docs/I18N.md.
81
+ let sourceLocale;
72
82
  if (documentId == null) {
73
83
  documentId = uuidv7();
84
+ sourceLocale = this.defaultContentLocale;
74
85
  const _document = await tx
75
86
  .insert(documents)
76
87
  .values({
77
88
  id: documentId,
78
89
  collection_id: params.collectionId,
79
90
  order_key: params.orderKey ?? null,
91
+ source_locale: sourceLocale,
80
92
  })
81
93
  .returning()
82
94
  .then(getFirstOrThrow('Failed to create document'));
83
95
  }
96
+ else {
97
+ const existing = await tx
98
+ .select({ source_locale: documents.source_locale })
99
+ .from(documents)
100
+ .where(eq(documents.id, documentId))
101
+ .then(getFirstOrThrow('Failed to load document for new version'));
102
+ sourceLocale = existing.source_locale ?? this.defaultContentLocale;
103
+ }
84
104
  // 2. Create the document version
85
105
  const documentVersion = await tx
86
106
  .insert(documentVersions)
@@ -94,18 +114,20 @@ export class DocumentCommands {
94
114
  })
95
115
  .returning()
96
116
  .then(getFirstOrThrow('Failed to create document version'));
97
- // 2a. Upsert the document_paths row when a path is supplied. Path
98
- // is only ever written under the installation's default content
99
- // locale in phase 1; the lifecycle layer skips this param for
100
- // non-default-locale (translation) saves. Unique-constraint
101
- // violations on (collection_id, locale, path) bubble up as a
102
- // Postgres error which the lifecycle wraps as ERR_PATH_CONFLICT.
117
+ // 2a. Upsert the document_paths row when a path is supplied. The path
118
+ // row lives under the document's `source_locale` (its data anchor),
119
+ // not the mutable global default so a re-anchored document, or any
120
+ // document read after the global default is switched, still resolves by
121
+ // path. The lifecycle layer skips this param for non-source-locale
122
+ // (translation) saves. Unique-constraint violations on
123
+ // (collection_id, locale, path) bubble up as a Postgres error which the
124
+ // lifecycle wraps as ERR_PATH_CONFLICT.
103
125
  if (params.path !== undefined) {
104
126
  await tx
105
127
  .insert(documentPaths)
106
128
  .values({
107
129
  document_id: documentId,
108
- locale: this.defaultContentLocale,
130
+ locale: sourceLocale,
109
131
  collection_id: params.collectionId,
110
132
  path: params.path,
111
133
  })
@@ -118,6 +140,25 @@ export class DocumentCommands {
118
140
  },
119
141
  });
120
142
  }
143
+ // 2b. Replace the document_available_locales rows when an editorial set
144
+ // is supplied. Document-grain and sticky across versions: `undefined`
145
+ // leaves the existing set untouched (the lifecycle omits the param on
146
+ // saves that don't touch advertising), while an explicit array — empty
147
+ // included — replaces it wholesale. Deduplicated so a caller-supplied
148
+ // duplicate doesn't collide on the (document_id, locale) primary key.
149
+ if (params.availableLocales !== undefined) {
150
+ await tx
151
+ .delete(documentAvailableLocales)
152
+ .where(eq(documentAvailableLocales.document_id, documentId));
153
+ const locales = [...new Set(params.availableLocales)];
154
+ if (locales.length > 0) {
155
+ await tx.insert(documentAvailableLocales).values(locales.map((locale) => ({
156
+ document_id: documentId,
157
+ locale,
158
+ collection_id: params.collectionId,
159
+ })));
160
+ }
161
+ }
121
162
  // 3. Flatten the document data to field values
122
163
  const flattenedFields = flattenFieldSetData(params.collectionConfig.fields, params.documentData, params.locale ?? 'all');
123
164
  // 4. Batch-insert all field values, grouped by store type
@@ -223,12 +264,349 @@ export class DocumentCommands {
223
264
  ON CONFLICT (document_version_id, field_path, locale) DO NOTHING
224
265
  `);
225
266
  }
267
+ // 6. Record the version's available content locales for
268
+ // `localeFallback: 'strict'` reads. A locale is "available" when it
269
+ // covers every localized field path the default content locale has
270
+ // (path-coverage). Derived from the *persisted* localized rows, so it
271
+ // accounts for the per-locale carry-forward in step 5 — not just the
272
+ // freshly-flattened locale. A version with no localized content at all
273
+ // records a single `'all'` sentinel (it renders identically in any
274
+ // locale). Status-blind by design — see docs/I18N.md.
275
+ await this.writeVersionLocaleLedger(tx, documentVersion.id, sourceLocale);
226
276
  return {
227
277
  document: documentVersion,
228
278
  fieldCount: flattenedFields.length,
229
279
  };
230
280
  });
231
281
  }
282
+ /**
283
+ * writeVersionLocaleLedger
284
+ *
285
+ * Compute and insert a version's `byline_document_version_locales` rows: a
286
+ * locale is recorded when it covers every localized field path the version's
287
+ * `sourceLocale` has (path-coverage), and a version with no localized content
288
+ * records a single `'all'` sentinel. Reads the version's persisted store rows,
289
+ * so callers must have written them first. Shared by the create write path
290
+ * (step 6) and `reAnchorDocument` (which recomputes against the new source).
291
+ * Assumes the version has no ledger rows yet (a freshly-inserted version).
292
+ * See docs/I18N.md.
293
+ */
294
+ async writeVersionLocaleLedger(tx, versionId, sourceLocale) {
295
+ await tx.execute(sql `
296
+ WITH loc AS (
297
+ SELECT field_path, locale FROM byline_store_text WHERE document_version_id = ${versionId}::uuid AND locale <> 'all'
298
+ UNION SELECT field_path, locale FROM byline_store_numeric WHERE document_version_id = ${versionId}::uuid AND locale <> 'all'
299
+ UNION SELECT field_path, locale FROM byline_store_boolean WHERE document_version_id = ${versionId}::uuid AND locale <> 'all'
300
+ UNION SELECT field_path, locale FROM byline_store_datetime WHERE document_version_id = ${versionId}::uuid AND locale <> 'all'
301
+ UNION SELECT field_path, locale FROM byline_store_file WHERE document_version_id = ${versionId}::uuid AND locale <> 'all'
302
+ UNION SELECT field_path, locale FROM byline_store_relation WHERE document_version_id = ${versionId}::uuid AND locale <> 'all'
303
+ UNION SELECT field_path, locale FROM byline_store_json WHERE document_version_id = ${versionId}::uuid AND locale <> 'all'
304
+ ),
305
+ canonical AS (
306
+ SELECT field_path FROM loc WHERE locale = ${sourceLocale}
307
+ ),
308
+ covering AS (
309
+ SELECT l.locale
310
+ FROM loc l
311
+ GROUP BY l.locale
312
+ HAVING NOT EXISTS (
313
+ SELECT 1 FROM canonical c
314
+ WHERE NOT EXISTS (
315
+ SELECT 1 FROM loc l2 WHERE l2.locale = l.locale AND l2.field_path = c.field_path
316
+ )
317
+ )
318
+ )
319
+ INSERT INTO byline_document_version_locales (document_version_id, locale)
320
+ SELECT ${versionId}::uuid, locale FROM covering
321
+ UNION ALL
322
+ SELECT ${versionId}::uuid, 'all' WHERE NOT EXISTS (SELECT 1 FROM loc)
323
+ `);
324
+ }
325
+ /**
326
+ * copyAllVersionStoreRows
327
+ *
328
+ * Copy every store row — all eight tables, all locales, including the `meta`
329
+ * identity rows (so block / array-item `_id`s are preserved) — from one
330
+ * document version to another, verbatim. New `id`s are minted; the target
331
+ * `document_version_id` is rebound; timestamps are refreshed. The target
332
+ * version is assumed fresh (no rows), so no conflict handling is needed.
333
+ * Used by `reAnchorDocument` to snapshot the current version into the new
334
+ * re-anchored one without re-flattening (lossless, identity-preserving).
335
+ */
336
+ async copyAllVersionStoreRows(tx, fromVersionId, toVersionId) {
337
+ const from = sql `${fromVersionId}::uuid`;
338
+ const to = sql `${toVersionId}::uuid`;
339
+ await tx.execute(sql `
340
+ INSERT INTO byline_store_text
341
+ (id, document_version_id, collection_id, field_path, field_name, locale, parent_path, value, word_count, created_at, updated_at)
342
+ SELECT gen_random_uuid(), ${to}, collection_id, field_path, field_name, locale, parent_path, value, word_count, NOW(), NOW()
343
+ FROM byline_store_text WHERE document_version_id = ${from}
344
+ `);
345
+ await tx.execute(sql `
346
+ INSERT INTO byline_store_numeric
347
+ (id, document_version_id, collection_id, field_path, field_name, locale, parent_path, number_type, value_integer, value_decimal, value_float, created_at, updated_at)
348
+ SELECT gen_random_uuid(), ${to}, collection_id, field_path, field_name, locale, parent_path, number_type, value_integer, value_decimal, value_float, NOW(), NOW()
349
+ FROM byline_store_numeric WHERE document_version_id = ${from}
350
+ `);
351
+ await tx.execute(sql `
352
+ INSERT INTO byline_store_boolean
353
+ (id, document_version_id, collection_id, field_path, field_name, locale, parent_path, value, created_at, updated_at)
354
+ SELECT gen_random_uuid(), ${to}, collection_id, field_path, field_name, locale, parent_path, value, NOW(), NOW()
355
+ FROM byline_store_boolean WHERE document_version_id = ${from}
356
+ `);
357
+ await tx.execute(sql `
358
+ INSERT INTO byline_store_datetime
359
+ (id, document_version_id, collection_id, field_path, field_name, locale, parent_path, date_type, value_date, value_time, value_timestamp_tz, created_at, updated_at)
360
+ SELECT gen_random_uuid(), ${to}, collection_id, field_path, field_name, locale, parent_path, date_type, value_date, value_time, value_timestamp_tz, NOW(), NOW()
361
+ FROM byline_store_datetime WHERE document_version_id = ${from}
362
+ `);
363
+ await tx.execute(sql `
364
+ INSERT INTO byline_store_json
365
+ (id, document_version_id, collection_id, field_path, field_name, locale, parent_path, value, json_schema, object_keys, created_at, updated_at)
366
+ SELECT gen_random_uuid(), ${to}, collection_id, field_path, field_name, locale, parent_path, value, json_schema, object_keys, NOW(), NOW()
367
+ FROM byline_store_json WHERE document_version_id = ${from}
368
+ `);
369
+ await tx.execute(sql `
370
+ INSERT INTO byline_store_relation
371
+ (id, document_version_id, collection_id, field_path, field_name, locale, parent_path, target_document_id, target_collection_id, relationship_type, cascade_delete, created_at, updated_at)
372
+ SELECT gen_random_uuid(), ${to}, collection_id, field_path, field_name, locale, parent_path, target_document_id, target_collection_id, relationship_type, cascade_delete, NOW(), NOW()
373
+ FROM byline_store_relation WHERE document_version_id = ${from}
374
+ `);
375
+ await tx.execute(sql `
376
+ INSERT INTO byline_store_file
377
+ (id, document_version_id, collection_id, field_path, field_name, locale, parent_path, file_id, filename, original_filename, mime_type, file_size, file_hash, storage_provider, storage_path, storage_url, image_width, image_height, image_format, processing_status, thumbnail_generated, created_at, updated_at)
378
+ SELECT gen_random_uuid(), ${to}, collection_id, field_path, field_name, locale, parent_path, file_id, filename, original_filename, mime_type, file_size, file_hash, storage_provider, storage_path, storage_url, image_width, image_height, image_format, processing_status, thumbnail_generated, NOW(), NOW()
379
+ FROM byline_store_file WHERE document_version_id = ${from}
380
+ `);
381
+ await tx.execute(sql `
382
+ INSERT INTO byline_store_meta
383
+ (id, document_version_id, collection_id, type, path, item_id, meta, created_at, updated_at)
384
+ SELECT gen_random_uuid(), ${to}, collection_id, type, path, item_id, meta, NOW(), NOW()
385
+ FROM byline_store_meta WHERE document_version_id = ${from}
386
+ `);
387
+ }
388
+ /**
389
+ * reAnchorDocument
390
+ *
391
+ * Change a single document's content source locale to `targetLocale` — its
392
+ * fallback floor, path locale, and completeness yardstick. Refuses unless the
393
+ * document is **complete** in the target (the current version's ledger covers
394
+ * it, or the document is locale-agnostic) — never manufactures a primary
395
+ * language with holes. In one transaction: flips `source_locale`, moves the
396
+ * path row onto the new locale (keeping the slug), writes a **new version**
397
+ * that is a verbatim copy of the current one (immutable version event,
398
+ * identities preserved), and computes that version's ledger against the new
399
+ * source. `dryRun` performs only the eligibility check and reports the
400
+ * outcome that *would* result, writing nothing. See
401
+ * docs/I18N.md.
402
+ */
403
+ async reAnchorDocument(params) {
404
+ const { documentId, targetLocale, dryRun = false } = params;
405
+ return this.db.transaction(async (tx) => {
406
+ // 1. Current (latest, non-deleted) version + the document's anchor.
407
+ const current = await tx
408
+ .select({
409
+ versionId: documentVersions.id,
410
+ collectionId: documentVersions.collection_id,
411
+ collectionVersion: documentVersions.collection_version,
412
+ status: documentVersions.status,
413
+ sourceLocale: documents.source_locale,
414
+ })
415
+ .from(documentVersions)
416
+ .innerJoin(documents, eq(documents.id, documentVersions.document_id))
417
+ .where(and(eq(documentVersions.document_id, documentId), eq(documentVersions.is_deleted, false)))
418
+ .orderBy(desc(documentVersions.id))
419
+ .limit(1)
420
+ .then((rows) => rows[0]);
421
+ if (current == null) {
422
+ return { documentId, status: 'not-found', toLocale: targetLocale };
423
+ }
424
+ const fromLocale = current.sourceLocale ?? this.defaultContentLocale;
425
+ if (fromLocale === targetLocale) {
426
+ return { documentId, status: 'already-anchored', fromLocale, toLocale: targetLocale };
427
+ }
428
+ // 2. Eligibility: the current version must be complete in the target —
429
+ // its ledger contains the target locale, or it is locale-agnostic
430
+ // (the `'all'` sentinel → renders identically in any locale).
431
+ const ledgerRows = await tx
432
+ .select({ locale: documentVersionLocales.locale })
433
+ .from(documentVersionLocales)
434
+ .where(eq(documentVersionLocales.document_version_id, current.versionId));
435
+ const ledger = new Set(ledgerRows.map((r) => r.locale));
436
+ const complete = ledger.has(targetLocale) || ledger.has('all');
437
+ if (!complete) {
438
+ return { documentId, status: 'skipped-incomplete', fromLocale, toLocale: targetLocale };
439
+ }
440
+ if (dryRun) {
441
+ return { documentId, status: 'reanchored', fromLocale, toLocale: targetLocale };
442
+ }
443
+ // 3. Flip the document's content anchor.
444
+ await tx
445
+ .update(documents)
446
+ .set({ source_locale: targetLocale })
447
+ .where(eq(documents.id, documentId));
448
+ // 4. Move the path row onto the new source locale (re-tag the slug, do
449
+ // not regenerate it — the document's URL is unchanged).
450
+ await tx
451
+ .update(documentPaths)
452
+ .set({ locale: targetLocale, updated_at: new Date() })
453
+ .where(and(eq(documentPaths.document_id, documentId), eq(documentPaths.locale, fromLocale)));
454
+ // 5. New immutable version: a verbatim snapshot of the current version.
455
+ const newVersionId = uuidv7();
456
+ await tx.insert(documentVersions).values({
457
+ id: newVersionId,
458
+ document_id: documentId,
459
+ collection_id: current.collectionId,
460
+ collection_version: current.collectionVersion,
461
+ event_type: 'update',
462
+ status: current.status ?? 'draft',
463
+ change_summary: `re-anchored content source locale ${fromLocale} → ${targetLocale}`,
464
+ });
465
+ await this.copyAllVersionStoreRows(tx, current.versionId, newVersionId);
466
+ // 6. Ledger for the new version, computed against the new source locale.
467
+ await this.writeVersionLocaleLedger(tx, newVersionId, targetLocale);
468
+ return { documentId, status: 'reanchored', fromLocale, toLocale: targetLocale, newVersionId };
469
+ });
470
+ }
471
+ /**
472
+ * reAnchorDocuments
473
+ *
474
+ * Bulk re-anchor: walk every (non-deleted) logical document — optionally
475
+ * scoped to one collection — and re-anchor each that is complete in
476
+ * `targetLocale`, skipping (and reporting) the rest. Each document runs in its
477
+ * own transaction via `reAnchorDocument`, so one failure or skip never rolls
478
+ * back the others; the command is idempotent and resumable. This is the
479
+ * "client switched the default content locale, move every fully-translated
480
+ * document onto it" operation; the `skipped-incomplete` results double as the
481
+ * outstanding-translation backlog. `dryRun` reports what would happen without
482
+ * writing. See docs/I18N.md.
483
+ */
484
+ async reAnchorDocuments(params) {
485
+ const { targetLocale, collectionId, dryRun = false } = params;
486
+ const conditions = [eq(documentVersions.is_deleted, false)];
487
+ if (collectionId) {
488
+ conditions.push(eq(documentVersions.collection_id, collectionId));
489
+ }
490
+ const docs = await this.db
491
+ .selectDistinct({ documentId: documentVersions.document_id })
492
+ .from(documentVersions)
493
+ .where(and(...conditions));
494
+ const report = {
495
+ targetLocale,
496
+ dryRun,
497
+ total: docs.length,
498
+ reanchored: 0,
499
+ skippedIncomplete: 0,
500
+ alreadyAnchored: 0,
501
+ notFound: 0,
502
+ results: [],
503
+ };
504
+ for (const { documentId } of docs) {
505
+ const result = await this.reAnchorDocument({ documentId, targetLocale, dryRun });
506
+ report.results.push(result);
507
+ switch (result.status) {
508
+ case 'reanchored':
509
+ report.reanchored++;
510
+ break;
511
+ case 'skipped-incomplete':
512
+ report.skippedIncomplete++;
513
+ break;
514
+ case 'already-anchored':
515
+ report.alreadyAnchored++;
516
+ break;
517
+ case 'not-found':
518
+ report.notFound++;
519
+ break;
520
+ }
521
+ }
522
+ return report;
523
+ }
524
+ /**
525
+ * backfillVersionLocales
526
+ *
527
+ * One-time maintenance: populate `byline_document_version_locales` for
528
+ * versions written *before* the ledger existed (i.e. before the migration
529
+ * that added it). Going forward `createDocumentVersion` step 6 keeps the
530
+ * ledger current; this fills the historical gap so `localeFallback:
531
+ * 'strict'` reads can see pre-existing documents.
532
+ *
533
+ * Same path-coverage rule as the write path, applied set-wise across every
534
+ * version in one statement. The `canonical` anchor is each document's own
535
+ * `source_locale` (joined via `byline_document_versions` → `byline_documents`),
536
+ * falling back to the adapter's configured default content locale for rows
537
+ * not yet stamped by `backfillSourceLocales` — mirroring the per-document
538
+ * anchor the write path uses, rather than a single global locale. Idempotent
539
+ * — safe to re-run (PK + `ON CONFLICT DO NOTHING`); versions are immutable, so
540
+ * a version's computed locale set never changes. Returns the number of
541
+ * `(version, locale)` rows inserted.
542
+ *
543
+ * See docs/I18N.md.
544
+ */
545
+ async backfillVersionLocales() {
546
+ const result = await this.db.execute(sql `
547
+ WITH loc AS (
548
+ SELECT document_version_id, field_path, locale FROM byline_store_text WHERE locale <> 'all'
549
+ UNION SELECT document_version_id, field_path, locale FROM byline_store_numeric WHERE locale <> 'all'
550
+ UNION SELECT document_version_id, field_path, locale FROM byline_store_boolean WHERE locale <> 'all'
551
+ UNION SELECT document_version_id, field_path, locale FROM byline_store_datetime WHERE locale <> 'all'
552
+ UNION SELECT document_version_id, field_path, locale FROM byline_store_file WHERE locale <> 'all'
553
+ UNION SELECT document_version_id, field_path, locale FROM byline_store_relation WHERE locale <> 'all'
554
+ UNION SELECT document_version_id, field_path, locale FROM byline_store_json WHERE locale <> 'all'
555
+ ),
556
+ canonical AS (
557
+ SELECT l.document_version_id, l.field_path
558
+ FROM loc l
559
+ JOIN byline_document_versions v ON v.id = l.document_version_id
560
+ JOIN byline_documents d ON d.id = v.document_id
561
+ WHERE l.locale = COALESCE(d.source_locale, ${this.defaultContentLocale})
562
+ ),
563
+ covering AS (
564
+ SELECT l.document_version_id, l.locale
565
+ FROM loc l
566
+ GROUP BY l.document_version_id, l.locale
567
+ HAVING NOT EXISTS (
568
+ SELECT 1 FROM canonical c
569
+ WHERE c.document_version_id = l.document_version_id
570
+ AND NOT EXISTS (
571
+ SELECT 1 FROM loc l2
572
+ WHERE l2.document_version_id = l.document_version_id
573
+ AND l2.locale = l.locale
574
+ AND l2.field_path = c.field_path
575
+ )
576
+ )
577
+ )
578
+ INSERT INTO byline_document_version_locales (document_version_id, locale)
579
+ SELECT document_version_id, locale FROM covering
580
+ UNION ALL
581
+ SELECT v.id, 'all' FROM byline_document_versions v
582
+ WHERE NOT EXISTS (SELECT 1 FROM loc WHERE loc.document_version_id = v.id)
583
+ ON CONFLICT DO NOTHING
584
+ `);
585
+ return { rowsInserted: result.rowCount ?? 0 };
586
+ }
587
+ /**
588
+ * backfillSourceLocales
589
+ *
590
+ * One-time maintenance: stamp `byline_documents.source_locale` for documents
591
+ * created *before* the column existed. Sets every row whose `source_locale`
592
+ * is still NULL to the adapter's configured default content locale — the
593
+ * anchor those documents were implicitly authored against (a static SQL
594
+ * migration cannot know the configured default, mirroring
595
+ * `backfillVersionLocales`). Idempotent: only touches NULL rows, so re-runs
596
+ * and rows already stamped by the write path are left alone. Must run before
597
+ * the follow-up migration that sets the column NOT NULL.
598
+ *
599
+ * Returns the number of document rows stamped.
600
+ *
601
+ * See docs/I18N.md.
602
+ */
603
+ async backfillSourceLocales() {
604
+ const result = await this.db
605
+ .update(documents)
606
+ .set({ source_locale: this.defaultContentLocale })
607
+ .where(sql `${documents.source_locale} IS NULL`);
608
+ return { rowsUpdated: result.rowCount ?? 0 };
609
+ }
232
610
  /**
233
611
  * setDocumentStatus
234
612
  *
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import type { CollectionDefinition, DocumentFilter, FieldSort, ICollectionQueries, IDocumentQueries, ReadMode } from '@byline/core';
8
+ import type { CollectionDefinition, DocumentFilter, FieldSort, FlattenedStore, ICollectionQueries, IDocumentQueries, MissingLocalePolicy, ReadMode } from '@byline/core';
9
9
  import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
10
10
  import type * as schema from '../../database/schema/index.js';
11
11
  type DatabaseConnection = NodePgDatabase<typeof schema>;
@@ -78,16 +78,44 @@ export declare class DocumentQueries implements IDocumentQueries {
78
78
  */
79
79
  private pickCurrentView;
80
80
  /**
81
- * Build the locale priority chain for path resolution:
82
- * `[requested, default]`, deduplicated when both are the same.
83
- *
84
- * Used by every read path that touches `byline_document_paths`. In
85
- * phase 1 only the default-locale row is ever populated, so a non-
86
- * default `requested` locale always falls through to the default —
87
- * but the chain shape is correct for phase 2 when per-locale rows
88
- * begin to exist.
81
+ * Build the locale priority chain for fallback resolution:
82
+ * `[requested, floor]`, deduplicated when both are the same. The floor is
83
+ * the document's own `source_locale` anchor when known (so a re-anchored
84
+ * document, or any document read after the global default is switched, falls
85
+ * back to the locale it was actually authored in) otherwise the configured
86
+ * global default, which is correct for not-yet-anchored rows and for
87
+ * row-less lookups (findByPath). See docs/I18N.md.
89
88
  */
90
89
  private buildLocaleChain;
90
+ /**
91
+ * Build the `onMissingLocale: 'omit'` availability gate — an EXISTS against
92
+ * the version-locale ledger (`byline_document_version_locales`) that keeps
93
+ * only documents available in the requested locale. The `'all'` sentinel row
94
+ * covers locale-agnostic documents (no localized content). Returns `null`
95
+ * when the gate does not apply — a non-`'omit'` policy (`'empty'` /
96
+ * `'fallback'` / unset), or the admin sentinel `'all'` read — so callers can
97
+ * conditionally push it into a WHERE.
98
+ */
99
+ private localeAvailabilityExists;
100
+ /**
101
+ * Batch-fetch the version-locale availability sets from the
102
+ * `byline_document_version_locales` ledger. For each version returns the
103
+ * concrete locales its content is complete in (`availableLocales`, sorted),
104
+ * or `localeAgnostic: true` when the version carries only the `'all'`
105
+ * sentinel (no localized content → renders identically in every locale).
106
+ * Drives the `_availableVersionLocales` read metadata. One indexed query per call.
107
+ */
108
+ private getAvailableLocalesByVersion;
109
+ /**
110
+ * Batch-fetch the editorial advertised-locale sets from
111
+ * `byline_document_available_locales` (document-grain). For each logical
112
+ * document returns the sorted set of locales the editor has elected to
113
+ * advertise. Surfaced on reads as `availableLocales` — the deliberate
114
+ * counterpart to the version-grain `_availableVersionLocales` ledger fact;
115
+ * the public advertised set is their intersection. One indexed query per
116
+ * call. See docs/I18N.md.
117
+ */
118
+ private getAdvertisedLocalesByDocument;
91
119
  /**
92
120
  * Emit a SQL fragment that resolves the path string for a document via
93
121
  * the locale priority chain. Used as a projected column expression
@@ -137,6 +165,24 @@ export declare class DocumentQueries implements IDocumentQueries {
137
165
  * outer `=` predicate fail cleanly (no document found).
138
166
  */
139
167
  private resolveDocumentIdByPath;
168
+ /**
169
+ * Resolve the single effective content locale a version should be restored
170
+ * in, walking the fallback chain (`[requested, default]`) and returning the
171
+ * first locale the version is *available* in.
172
+ *
173
+ * Phase-1 availability rule — **path-coverage against the default locale**:
174
+ * the default (terminal) locale defines the canonical set of localized field
175
+ * paths; a candidate locale `L` is available iff it covers every one of them.
176
+ * This needs only the rows already in hand (no schema walk) and is correct
177
+ * because Byline shares document structure across locales (meta rows are
178
+ * `'all'`) — only leaf values vary per locale.
179
+ *
180
+ * Edge cases: an empty canonical set (the version has no localized content)
181
+ * means any requested locale is trivially available, so the requested locale
182
+ * is returned and the (non-localized, `'all'`) values render identically. The
183
+ * chain always terminates at the default locale, guaranteeing a return value.
184
+ */
185
+ private resolveEffectiveLocale;
140
186
  /**
141
187
  * Reconstruct document fields from unified row values using schema-aware
142
188
  * restoration. Meta rows (from store_meta) are converted to
@@ -178,7 +224,7 @@ export declare class DocumentQueries implements IDocumentQueries {
178
224
  * rather than thrown. This is the admin edit path's "best-effort load"
179
225
  * mode for documents written under a previous collection schema.
180
226
  */
181
- getDocumentById({ collection_id, document_id, locale, reconstruct, readMode, filters, lenient, }: {
227
+ getDocumentById({ collection_id, document_id, locale, reconstruct, readMode, filters, lenient, onMissingLocale, }: {
182
228
  collection_id: string;
183
229
  document_id: string;
184
230
  locale?: string;
@@ -186,31 +232,62 @@ export declare class DocumentQueries implements IDocumentQueries {
186
232
  readMode?: ReadMode;
187
233
  filters?: DocumentFilter[];
188
234
  lenient?: boolean;
235
+ onMissingLocale?: MissingLocalePolicy;
189
236
  }): Promise<{
190
237
  restoreWarnings?: string[] | undefined;
191
238
  document_version_id: string;
192
239
  document_id: string;
193
240
  path: string;
241
+ source_locale: string;
194
242
  status: string | null;
195
243
  created_at: Date;
196
244
  updated_at: Date;
197
245
  fields: any;
246
+ availableLocales: string[];
247
+ _availableVersionLocales: string[];
248
+ _localeAgnostic: boolean;
249
+ } | {
250
+ document_version_id: string;
251
+ document_id: string;
252
+ path: string;
253
+ source_locale: string;
254
+ status: string | null;
255
+ created_at: Date;
256
+ updated_at: Date;
257
+ fields: FlattenedStore[];
198
258
  } | null>;
199
- getDocumentByPath({ collection_id, path, locale, reconstruct, readMode, filters, }: {
259
+ getDocumentByPath({ collection_id, path, locale, reconstruct, readMode, filters, onMissingLocale, }: {
200
260
  collection_id: string;
201
261
  path: string;
202
262
  locale?: string;
203
263
  reconstruct: boolean;
204
264
  readMode?: ReadMode;
205
265
  filters?: DocumentFilter[];
266
+ onMissingLocale?: MissingLocalePolicy;
206
267
  }): Promise<{
207
268
  document_version_id: string;
208
269
  document_id: string;
209
270
  path: string;
271
+ source_locale: string;
210
272
  status: string | null;
211
273
  created_at: Date;
212
274
  updated_at: Date;
213
275
  fields: any;
276
+ availableLocales: string[];
277
+ _availableVersionLocales: string[];
278
+ _localeAgnostic: boolean;
279
+ } | {
280
+ document_version_id: string;
281
+ document_id: string;
282
+ path: string;
283
+ source_locale: string;
284
+ status: string | null;
285
+ created_at: Date;
286
+ updated_at: Date;
287
+ fields: FlattenedStore[];
288
+ availableLocales?: undefined;
289
+ _availableVersionLocales?: undefined;
290
+ _localeAgnostic?: undefined;
214
291
  } | null>;
215
292
  /**
216
293
  * getDocumentByVersion — fetches a specific version and reconstructs its fields.
@@ -392,7 +469,7 @@ export declare class DocumentQueries implements IDocumentQueries {
392
469
  * Document-level conditions (status, path) are applied directly on the
393
470
  * current_documents view.
394
471
  */
395
- findDocuments({ collection_id, filters, status, pathFilter, query, sort, orderBy, orderDirection, locale, page, pageSize, fields: requestedFields, readMode, }: {
472
+ findDocuments({ collection_id, filters, status, pathFilter, query, sort, orderBy, orderDirection, locale, page, pageSize, fields: requestedFields, readMode, onMissingLocale, }: {
396
473
  collection_id: string;
397
474
  filters?: DocumentFilter[];
398
475
  status?: string;
@@ -409,6 +486,7 @@ export declare class DocumentQueries implements IDocumentQueries {
409
486
  pageSize?: number;
410
487
  fields?: string[];
411
488
  readMode?: ReadMode;
489
+ onMissingLocale?: MissingLocalePolicy;
412
490
  }): Promise<{
413
491
  documents: any[];
414
492
  total: number;