@byline/db-postgres 2.6.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,615 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { sql } from 'drizzle-orm';
9
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
10
+ import { setupTestDB, teardownTestDB } from '../../../lib/test-helper.js';
11
+ import { createQueryBuilders } from '../storage-queries.js';
12
+ let commandBuilders;
13
+ let queryBuilders;
14
+ let db;
15
+ const timestamp = Date.now();
16
+ const LocaleCollectionConfig = {
17
+ path: `locale-fallback-${timestamp}`,
18
+ labels: { singular: 'LocaleTest', plural: 'LocaleTests' },
19
+ fields: [
20
+ { name: 'title', type: 'text', localized: true, optional: true },
21
+ { name: 'body', type: 'textArea', localized: true, optional: true },
22
+ { name: 'sku', type: 'text', optional: true },
23
+ ],
24
+ };
25
+ let testCollection = {};
26
+ let seq = 0;
27
+ /** Create a logical document from a multi-locale (`'all'`) field tree. */
28
+ async function createDoc(documentData) {
29
+ seq += 1;
30
+ const result = await commandBuilders.documents.createDocumentVersion({
31
+ collectionId: testCollection.id,
32
+ collectionVersion: 1,
33
+ collectionConfig: LocaleCollectionConfig,
34
+ action: 'create',
35
+ documentData,
36
+ path: `loc-${timestamp}-${seq}`,
37
+ locale: 'all',
38
+ status: 'published',
39
+ });
40
+ return result.document.document_id;
41
+ }
42
+ function readById(documentId, locale, onMissingLocale) {
43
+ return queryBuilders.documents.getDocumentById({
44
+ collection_id: testCollection.id,
45
+ document_id: documentId,
46
+ locale,
47
+ onMissingLocale,
48
+ });
49
+ }
50
+ describe('content-locale resolution & fallback', () => {
51
+ beforeAll(async () => {
52
+ const testDB = setupTestDB([LocaleCollectionConfig]);
53
+ commandBuilders = testDB.commandBuilders;
54
+ queryBuilders = testDB.queryBuilders;
55
+ db = testDB.db;
56
+ const result = await commandBuilders.collections.create(LocaleCollectionConfig.path, LocaleCollectionConfig);
57
+ const collection = result[0];
58
+ if (collection == null) {
59
+ throw new Error('Failed to create test collection');
60
+ }
61
+ testCollection = { id: collection.id, name: collection.path };
62
+ });
63
+ afterAll(async () => {
64
+ try {
65
+ await commandBuilders.collections.delete(testCollection.id);
66
+ }
67
+ catch (error) {
68
+ console.error('Failed to cleanup test collection:', error);
69
+ }
70
+ await teardownTestDB();
71
+ });
72
+ it('renders the requested locale when fully translated', async () => {
73
+ const id = await createDoc({
74
+ title: { en: 'Hello', de: 'Hallo' },
75
+ body: { en: 'World', de: 'Welt' },
76
+ sku: 'X1',
77
+ });
78
+ const doc = await readById(id, 'de');
79
+ expect(doc?.fields).toMatchObject({ title: 'Hallo', body: 'Welt', sku: 'X1' });
80
+ });
81
+ it("'fallback' renders the default for a partial translation — never mixes locales", async () => {
82
+ // `de` has a title but no body. Under path-coverage `de` is unavailable,
83
+ // so the whole document renders in `en` — NOT { title: 'Hallo', body: 'World' }.
84
+ const id = await createDoc({
85
+ title: { en: 'Hello', de: 'Hallo' },
86
+ body: { en: 'World' },
87
+ sku: 'X2',
88
+ });
89
+ const doc = await readById(id, 'de', 'fallback');
90
+ expect(doc?.fields).toMatchObject({ title: 'Hello', body: 'World', sku: 'X2' });
91
+ expect(doc?.fields.title, 'must not show the orphan German title').not.toBe('Hallo');
92
+ });
93
+ it("'fallback' renders the default when the locale is entirely absent", async () => {
94
+ const id = await createDoc({
95
+ title: { en: 'Hello' },
96
+ body: { en: 'World' },
97
+ sku: 'X3',
98
+ });
99
+ const doc = await readById(id, 'de', 'fallback');
100
+ expect(doc?.fields).toMatchObject({ title: 'Hello', body: 'World', sku: 'X3' });
101
+ });
102
+ it("default/'empty' restores the requested locale exactly — empty where untranslated (admin edit view)", async () => {
103
+ // Partial `de`: title translated, body not. The raw per-locale view shows
104
+ // the `de` title and leaves the `de` body empty — no fallback to `en`.
105
+ // Non-localized fields (`sku`, stored under 'all') are always present.
106
+ const id = await createDoc({
107
+ title: { en: 'Hello', de: 'Hallo' },
108
+ body: { en: 'World' },
109
+ sku: 'N1',
110
+ });
111
+ const omitted = await readById(id, 'de');
112
+ expect(omitted?.fields.title, 'de title shown as-is').toBe('Hallo');
113
+ expect(omitted?.fields.body, 'untranslated de body stays empty (no fallback)').toBeUndefined();
114
+ expect(omitted?.fields.sku, 'non-localized field always present').toBe('N1');
115
+ // Explicit 'empty' behaves identically to the omitted default.
116
+ const explicit = await readById(id, 'de', 'empty');
117
+ expect(explicit?.fields.title).toBe('Hallo');
118
+ expect(explicit?.fields.body).toBeUndefined();
119
+ });
120
+ it('returns non-localized values for a document with no localized content', async () => {
121
+ // Empty canonical set → any requested locale is trivially available.
122
+ const id = await createDoc({ sku: 'X4' });
123
+ const doc = await readById(id, 'de');
124
+ expect(doc?.fields.sku).toBe('X4');
125
+ expect(doc?.fields.title).toBeUndefined();
126
+ expect(doc?.fields.body).toBeUndefined();
127
+ });
128
+ it("preserves the per-locale map shape for an admin 'all' read", async () => {
129
+ const id = await createDoc({
130
+ title: { en: 'Hello', de: 'Hallo' },
131
+ body: { en: 'World', de: 'Welt' },
132
+ sku: 'X5',
133
+ });
134
+ const doc = await readById(id, 'all');
135
+ expect(doc?.fields.title).toEqual({ en: 'Hello', de: 'Hallo' });
136
+ expect(doc?.fields.body).toEqual({ en: 'World', de: 'Welt' });
137
+ expect(doc?.fields.sku).toBe('X5');
138
+ });
139
+ it("'fallback' resolves an effective locale per document across a list query", async () => {
140
+ const translated = await createDoc({
141
+ title: { en: 'Listed EN', de: 'Listed DE' },
142
+ body: { en: 'B', de: 'B-de' },
143
+ sku: 'L1',
144
+ });
145
+ const enOnly = await createDoc({
146
+ title: { en: 'EN Only' },
147
+ body: { en: 'B' },
148
+ sku: 'L2',
149
+ });
150
+ const { documents } = await queryBuilders.documents.findDocuments({
151
+ collection_id: testCollection.id,
152
+ locale: 'de',
153
+ onMissingLocale: 'fallback',
154
+ pageSize: 100,
155
+ });
156
+ const byId = new Map(documents.map((d) => [d.document_id, d]));
157
+ expect(byId.get(translated)?.fields.title).toBe('Listed DE');
158
+ expect(byId.get(enOnly)?.fields.title, 'untranslated row falls back to en').toBe('EN Only');
159
+ });
160
+ // --- onMissingLocale: 'omit' (version-locale ledger gate) ---------------
161
+ it('omit: returns the document for a detail read when the locale is available', async () => {
162
+ const id = await createDoc({
163
+ title: { en: 'Hello', de: 'Hallo' },
164
+ body: { en: 'World', de: 'Welt' },
165
+ sku: 'S1',
166
+ });
167
+ const doc = await queryBuilders.documents.getDocumentById({
168
+ collection_id: testCollection.id,
169
+ document_id: id,
170
+ locale: 'de',
171
+ onMissingLocale: 'omit',
172
+ });
173
+ expect(doc?.fields).toMatchObject({ title: 'Hallo', body: 'Welt' });
174
+ });
175
+ it('omit: returns null for a detail read when the locale is unavailable', async () => {
176
+ // Partial `de` (body missing) → not available in `de`.
177
+ const id = await createDoc({
178
+ title: { en: 'Hello', de: 'Hallo' },
179
+ body: { en: 'World' },
180
+ sku: 'S2',
181
+ });
182
+ const strict = await queryBuilders.documents.getDocumentById({
183
+ collection_id: testCollection.id,
184
+ document_id: id,
185
+ locale: 'de',
186
+ onMissingLocale: 'omit',
187
+ });
188
+ expect(strict, 'strict resolves to null → caller 404s').toBeNull();
189
+ // 'fallback' still returns it, rendered in the default locale.
190
+ const always = await queryBuilders.documents.getDocumentById({
191
+ collection_id: testCollection.id,
192
+ document_id: id,
193
+ locale: 'de',
194
+ onMissingLocale: 'fallback',
195
+ });
196
+ expect(always?.fields.title).toBe('Hello');
197
+ });
198
+ it('omit: includes a locale-agnostic document (no localized content)', async () => {
199
+ const id = await createDoc({ sku: 'S3' });
200
+ const doc = await queryBuilders.documents.getDocumentById({
201
+ collection_id: testCollection.id,
202
+ document_id: id,
203
+ locale: 'de',
204
+ onMissingLocale: 'omit',
205
+ });
206
+ expect(doc?.fields.sku, 'the "all" sentinel row makes it available everywhere').toBe('S3');
207
+ });
208
+ it('omit: excludes untranslated documents from a list query', async () => {
209
+ const translated = await createDoc({
210
+ title: { en: 'T-en', de: 'T-de' },
211
+ body: { en: 'b', de: 'b-de' },
212
+ sku: 'S4',
213
+ });
214
+ const untranslated = await createDoc({
215
+ title: { en: 'U-en' },
216
+ body: { en: 'b' },
217
+ sku: 'S5',
218
+ });
219
+ const strict = await queryBuilders.documents.findDocuments({
220
+ collection_id: testCollection.id,
221
+ locale: 'de',
222
+ onMissingLocale: 'omit',
223
+ pageSize: 200,
224
+ });
225
+ const strictIds = new Set(strict.documents.map((d) => d.document_id));
226
+ expect(strictIds.has(translated), 'translated doc kept').toBe(true);
227
+ expect(strictIds.has(untranslated), 'untranslated doc excluded').toBe(false);
228
+ // A non-'omit' read (here the default 'empty') includes the untranslated
229
+ // doc, and its total is strictly larger — proving 'omit' gates at the
230
+ // SQL layer (pagination-safe).
231
+ const unfiltered = await queryBuilders.documents.findDocuments({
232
+ collection_id: testCollection.id,
233
+ locale: 'de',
234
+ pageSize: 200,
235
+ });
236
+ const unfilteredIds = new Set(unfiltered.documents.map((d) => d.document_id));
237
+ expect(unfilteredIds.has(untranslated)).toBe(true);
238
+ expect(strict.total).toBeLessThan(unfiltered.total);
239
+ });
240
+ // --- backfill (pre-existing versions) ------------------------------------
241
+ it('backfillVersionLocales rebuilds the ledger for versions missing rows', async () => {
242
+ const created = await commandBuilders.documents.createDocumentVersion({
243
+ collectionId: testCollection.id,
244
+ collectionVersion: 1,
245
+ collectionConfig: LocaleCollectionConfig,
246
+ action: 'create',
247
+ documentData: {
248
+ title: { en: 'Hello', de: 'Hallo' },
249
+ body: { en: 'World', de: 'Welt' },
250
+ sku: 'B1',
251
+ },
252
+ path: `loc-backfill-${timestamp}`,
253
+ locale: 'all',
254
+ status: 'published',
255
+ });
256
+ const versionId = created.document.id;
257
+ const documentId = created.document.document_id;
258
+ // Simulate a version written before the ledger existed: drop its rows.
259
+ await db.execute(sql `DELETE FROM byline_document_version_locales WHERE document_version_id = ${versionId}::uuid`);
260
+ const before = await queryBuilders.documents.getDocumentById({
261
+ collection_id: testCollection.id,
262
+ document_id: documentId,
263
+ locale: 'de',
264
+ onMissingLocale: 'omit',
265
+ });
266
+ expect(before, 'ledger removed → strict can no longer see it').toBeNull();
267
+ // Backfill rebuilds it from the persisted content.
268
+ const result = await commandBuilders.documents.backfillVersionLocales();
269
+ expect(result.rowsInserted).toBeGreaterThan(0);
270
+ const after = await queryBuilders.documents.getDocumentById({
271
+ collection_id: testCollection.id,
272
+ document_id: documentId,
273
+ locale: 'de',
274
+ onMissingLocale: 'omit',
275
+ });
276
+ expect(after?.fields.title, 'strict can see it again, rendered in de').toBe('Hallo');
277
+ // Idempotent: a second run inserts nothing (everything already covered).
278
+ const second = await commandBuilders.documents.backfillVersionLocales();
279
+ expect(second.rowsInserted).toBe(0);
280
+ });
281
+ it('backfillSourceLocales stamps NULL source_locale rows with the default locale', async () => {
282
+ const id = await createDoc({
283
+ title: { en: 'Hello', de: 'Hallo' },
284
+ body: { en: 'World', de: 'Welt' },
285
+ sku: 'S1',
286
+ });
287
+ // The write path now stamps source_locale on create (Slice 2), so simulate
288
+ // a row written before the column existed by nulling it — exactly the
289
+ // pre-existing-data shape backfill exists to repair.
290
+ await db.execute(sql `UPDATE byline_documents SET source_locale = NULL WHERE id = ${id}::uuid`);
291
+ const before = await db.execute(sql `SELECT source_locale FROM byline_documents WHERE id = ${id}::uuid`);
292
+ expect(before.rows[0].source_locale).toBeNull();
293
+ const result = await commandBuilders.documents.backfillSourceLocales();
294
+ expect(result.rowsUpdated).toBeGreaterThan(0);
295
+ const after = await db.execute(sql `SELECT source_locale FROM byline_documents WHERE id = ${id}::uuid`);
296
+ expect(after.rows[0].source_locale, 'stamped with the adapter default content locale (en)').toBe('en');
297
+ // Idempotent: a second run touches nothing (no NULL rows remain).
298
+ const second = await commandBuilders.documents.backfillSourceLocales();
299
+ expect(second.rowsUpdated).toBe(0);
300
+ });
301
+ // --- source_locale write path (Slice 2) ----------------------------------
302
+ it('records source_locale on create and writes the path row under it', async () => {
303
+ const created = await commandBuilders.documents.createDocumentVersion({
304
+ collectionId: testCollection.id,
305
+ collectionVersion: 1,
306
+ collectionConfig: LocaleCollectionConfig,
307
+ action: 'create',
308
+ documentData: { title: { en: 'Hello' }, sku: 'SL1' },
309
+ path: `loc-source-${timestamp}`,
310
+ locale: 'all',
311
+ status: 'published',
312
+ });
313
+ const documentId = created.document.document_id;
314
+ // A new document is anchored to the configured default (en).
315
+ const doc = await db.execute(sql `SELECT source_locale FROM byline_documents WHERE id = ${documentId}::uuid`);
316
+ expect(doc.rows[0].source_locale).toBe('en');
317
+ // Its path row lives under that source locale, not a hardcoded default.
318
+ const paths = await db.execute(sql `SELECT locale FROM byline_document_paths WHERE document_id = ${documentId}::uuid`);
319
+ expect(paths.rows.map((r) => r.locale)).toEqual(['en']);
320
+ });
321
+ it('keys the completeness ledger off the document source_locale, not the global default', async () => {
322
+ // en has only {title}; de has {title, body}. Anchored to en, the canonical
323
+ // checklist is {title} and both locales cover it. Re-anchored to de, the
324
+ // checklist becomes {title, body} and only de covers it.
325
+ const content = {
326
+ title: { en: 'Hello', de: 'Hallo' },
327
+ body: { de: 'Welt' },
328
+ sku: 'SL2',
329
+ };
330
+ const v1 = await commandBuilders.documents.createDocumentVersion({
331
+ collectionId: testCollection.id,
332
+ collectionVersion: 1,
333
+ collectionConfig: LocaleCollectionConfig,
334
+ action: 'create',
335
+ documentData: content,
336
+ path: `loc-anchor-${timestamp}`,
337
+ locale: 'all',
338
+ status: 'published',
339
+ });
340
+ const documentId = v1.document.document_id;
341
+ const v1Id = v1.document.id;
342
+ // Anchored to en: canonical {title} → both en and de are complete.
343
+ const ledgerV1 = await db.execute(sql `SELECT locale FROM byline_document_version_locales WHERE document_version_id = ${v1Id}::uuid ORDER BY locale`);
344
+ expect(ledgerV1.rows.map((r) => r.locale)).toEqual(['de', 'en']);
345
+ // Simulate a re-anchor: flip the document's source_locale to de.
346
+ await db.execute(sql `UPDATE byline_documents SET source_locale = 'de' WHERE id = ${documentId}::uuid`);
347
+ // A new version now computes canonical against de {title, body}; en no
348
+ // longer covers it (missing body), so only de is complete.
349
+ const v2 = await commandBuilders.documents.createDocumentVersion({
350
+ documentId,
351
+ collectionId: testCollection.id,
352
+ collectionVersion: 1,
353
+ collectionConfig: LocaleCollectionConfig,
354
+ action: 'update',
355
+ documentData: content,
356
+ locale: 'all',
357
+ status: 'published',
358
+ previousVersionId: v1Id,
359
+ });
360
+ const v2Id = v2.document.id;
361
+ const ledgerV2 = await db.execute(sql `SELECT locale FROM byline_document_version_locales WHERE document_version_id = ${v2Id}::uuid ORDER BY locale`);
362
+ expect(ledgerV2.rows.map((r) => r.locale), 'canonical re-based onto de: only de is complete').toEqual(['de']);
363
+ });
364
+ it('backfillVersionLocales recomputes the ledger against each document source_locale', async () => {
365
+ const content = {
366
+ title: { en: 'Hi', de: 'Hallo' },
367
+ body: { de: 'Welt' },
368
+ sku: 'SL3',
369
+ };
370
+ const created = await commandBuilders.documents.createDocumentVersion({
371
+ collectionId: testCollection.id,
372
+ collectionVersion: 1,
373
+ collectionConfig: LocaleCollectionConfig,
374
+ action: 'create',
375
+ documentData: content,
376
+ path: `loc-backfill-anchor-${timestamp}`,
377
+ locale: 'all',
378
+ status: 'published',
379
+ });
380
+ const documentId = created.document.document_id;
381
+ const versionId = created.document.id;
382
+ // Re-anchor to de and wipe the (en-computed) ledger to simulate a version
383
+ // whose ledger predates the re-anchor.
384
+ await db.execute(sql `UPDATE byline_documents SET source_locale = 'de' WHERE id = ${documentId}::uuid`);
385
+ await db.execute(sql `DELETE FROM byline_document_version_locales WHERE document_version_id = ${versionId}::uuid`);
386
+ await commandBuilders.documents.backfillVersionLocales();
387
+ // Rebuilt against de {title, body}: only de is complete (en lacks body).
388
+ const ledger = await db.execute(sql `SELECT locale FROM byline_document_version_locales WHERE document_version_id = ${versionId}::uuid ORDER BY locale`);
389
+ expect(ledger.rows.map((r) => r.locale)).toEqual(['de']);
390
+ });
391
+ // --- source_locale read path (Slice 3) -----------------------------------
392
+ it('field fallback resolves to the document source_locale, not the global default', async () => {
393
+ // Content lives only in de; the global default is en (no en content here).
394
+ const id = await createDoc({ title: { de: 'Hallo' }, body: { de: 'Welt' }, sku: 'RD1' });
395
+ // Re-anchor the document to de (global default stays en).
396
+ await db.execute(sql `UPDATE byline_documents SET source_locale = 'de' WHERE id = ${id}::uuid`);
397
+ // A fr read (absent) with fallback walks the chain [fr, <source=de>] and
398
+ // resolves de — NOT the global default en, which has no content here and
399
+ // would render empty.
400
+ const detail = await readById(id, 'fr', 'fallback');
401
+ expect(detail?.fields).toMatchObject({ title: 'Hallo', body: 'Welt', sku: 'RD1' });
402
+ expect(detail?.source_locale).toBe('de');
403
+ // Same per-document floor across a list query (exercises reconstructDocuments
404
+ // + the batched field fetch, which collects per-row source locales).
405
+ const { documents } = await queryBuilders.documents.findDocuments({
406
+ collection_id: testCollection.id,
407
+ locale: 'fr',
408
+ onMissingLocale: 'fallback',
409
+ pageSize: 100,
410
+ });
411
+ const listed = documents.find((d) => d.document_id === id);
412
+ expect(listed?.fields.title, 'list row falls back to its own source de').toBe('Hallo');
413
+ });
414
+ it('projects the path under the document source_locale floor', async () => {
415
+ const id = await createDoc({ title: { de: 'Hallo' }, sku: 'RP1' });
416
+ const pathRow = await db.execute(sql `SELECT path FROM byline_document_paths WHERE document_id = ${id}::uuid`);
417
+ const slug = pathRow.rows[0].path;
418
+ // Proper re-anchor: move both the anchor and the path row to de.
419
+ await db.execute(sql `UPDATE byline_documents SET source_locale = 'de' WHERE id = ${id}::uuid`);
420
+ await db.execute(sql `UPDATE byline_document_paths SET locale = 'de' WHERE document_id = ${id}::uuid`);
421
+ // A fr read projects path via [fr, <source=de>] → finds the de path row.
422
+ // If the floor were the global default en, the (now-de) row wouldn't match
423
+ // and path would come back empty.
424
+ const detail = await readById(id, 'fr', 'fallback');
425
+ expect(detail?.path).toBe(slug);
426
+ });
427
+ // --- config-default flip safety (Slice 4) --------------------------------
428
+ it('a global default flip leaves existing documents intact (they ride source_locale)', async () => {
429
+ // Authored under the en default → source_locale 'en', en content, en path.
430
+ const id = await createDoc({ title: { en: 'Hello' }, body: { en: 'World' }, sku: 'FS1' });
431
+ const pathRow = await db.execute(sql `SELECT path FROM byline_document_paths WHERE document_id = ${id}::uuid`);
432
+ const slug = pathRow.rows[0].path;
433
+ // Simulate the global default switched to fr: a fresh query layer built
434
+ // with defaultContentLocale = 'fr' over the very same rows.
435
+ const frQueries = createQueryBuilders(db, [LocaleCollectionConfig], 'fr');
436
+ // Detail read in fr (the NEW default) with fallback: the doc has no fr
437
+ // content, but rides its own source_locale 'en' floor → returns the en
438
+ // content. A naive [fr]-only chain (pre-source_locale) would render empty.
439
+ const detail = (await frQueries.documents.getDocumentById({
440
+ collection_id: testCollection.id,
441
+ document_id: id,
442
+ locale: 'fr',
443
+ onMissingLocale: 'fallback',
444
+ }));
445
+ expect(detail?.fields).toMatchObject({ title: 'Hello', body: 'World', sku: 'FS1' });
446
+ expect(detail?.source_locale).toBe('en');
447
+ // The path still resolves when looked up under the document's own source
448
+ // locale (its URL didn't move when the global default flipped).
449
+ const byPath = await frQueries.documents.getDocumentByPath({
450
+ collection_id: testCollection.id,
451
+ path: slug,
452
+ locale: 'en',
453
+ reconstruct: true,
454
+ });
455
+ expect(byPath?.document_id).toBe(id);
456
+ // List read under the fr default still surfaces the en content per-row.
457
+ const { documents } = await frQueries.documents.findDocuments({
458
+ collection_id: testCollection.id,
459
+ locale: 'fr',
460
+ onMissingLocale: 'fallback',
461
+ pageSize: 200,
462
+ });
463
+ expect(documents.find((d) => d.document_id === id)?.fields.title).toBe('Hello');
464
+ });
465
+ // --- availability metadata (Phase 6: _availableVersionLocales) -----------
466
+ it('exposes _availableVersionLocales + _localeAgnostic on a detail read', async () => {
467
+ const id = await createDoc({
468
+ title: { en: 'Hello', de: 'Hallo' },
469
+ body: { en: 'World', de: 'Welt' },
470
+ sku: 'M1',
471
+ });
472
+ const doc = await readById(id, 'en');
473
+ expect(doc?._availableVersionLocales, 'sorted concrete locales').toEqual(['de', 'en']);
474
+ expect(doc?._localeAgnostic).toBe(false);
475
+ });
476
+ it('flags a locale-agnostic document (no localized content)', async () => {
477
+ const id = await createDoc({ sku: 'M2' });
478
+ const doc = await readById(id, 'en');
479
+ expect(doc?._availableVersionLocales).toEqual([]);
480
+ expect(doc?._localeAgnostic, 'the "all" sentinel surfaces as _localeAgnostic').toBe(true);
481
+ });
482
+ it('exposes _availableVersionLocales per row on a list read', async () => {
483
+ const both = await createDoc({
484
+ title: { en: 'B-en', de: 'B-de' },
485
+ body: { en: 'x', de: 'y' },
486
+ sku: 'M3',
487
+ });
488
+ const enOnly = await createDoc({
489
+ title: { en: 'C-en' },
490
+ body: { en: 'x' },
491
+ sku: 'M4',
492
+ });
493
+ const { documents } = await queryBuilders.documents.findDocuments({
494
+ collection_id: testCollection.id,
495
+ locale: 'en',
496
+ pageSize: 200,
497
+ });
498
+ const byId = new Map(documents.map((d) => [d.document_id, d]));
499
+ expect(byId.get(both)?._availableVersionLocales).toEqual(['de', 'en']);
500
+ expect(byId.get(enOnly)?._availableVersionLocales).toEqual(['en']);
501
+ });
502
+ // --- re-anchor (Slice 5) -------------------------------------------------
503
+ // Placed last: the bulk re-anchor mutates every complete document in the
504
+ // collection, so no later test should depend on the pre-re-anchor state.
505
+ it('re-anchors a complete document: flips source, moves the path, writes a new version', async () => {
506
+ const id = await createDoc({
507
+ title: { en: 'Hello', de: 'Hallo' },
508
+ body: { en: 'World', de: 'Welt' },
509
+ sku: 'RA1',
510
+ });
511
+ const pathRow = await db.execute(sql `SELECT path FROM byline_document_paths WHERE document_id = ${id}::uuid`);
512
+ const slug = pathRow.rows[0].path;
513
+ const before = await db.execute(sql `SELECT count(*)::int AS n FROM byline_document_versions WHERE document_id = ${id}::uuid`);
514
+ const result = await commandBuilders.documents.reAnchorDocument({
515
+ documentId: id,
516
+ targetLocale: 'de',
517
+ });
518
+ expect(result.status).toBe('reanchored');
519
+ expect(result.fromLocale).toBe('en');
520
+ expect(result.toLocale).toBe('de');
521
+ expect(result.newVersionId).toBeTruthy();
522
+ // Anchor flipped.
523
+ const doc = await db.execute(sql `SELECT source_locale FROM byline_documents WHERE id = ${id}::uuid`);
524
+ expect(doc.rows[0].source_locale).toBe('de');
525
+ // Path row moved to de (same slug), with no en row left behind.
526
+ const paths = await db.execute(sql `SELECT locale, path FROM byline_document_paths WHERE document_id = ${id}::uuid`);
527
+ expect(paths.rows).toEqual([{ locale: 'de', path: slug }]);
528
+ // A new immutable version was written and is now current.
529
+ const after = await db.execute(sql `SELECT count(*)::int AS n FROM byline_document_versions WHERE document_id = ${id}::uuid`);
530
+ expect(after.rows[0].n).toBe(before.rows[0].n + 1);
531
+ // Content preserved verbatim across the copy.
532
+ const all = await readById(id, 'all');
533
+ expect(all?.fields.title).toEqual({ en: 'Hello', de: 'Hallo' });
534
+ expect(all?.fields.body).toEqual({ en: 'World', de: 'Welt' });
535
+ expect(all?.fields.sku).toBe('RA1');
536
+ expect(all?.source_locale).toBe('de');
537
+ // New version's ledger computed against de (both locales cover it).
538
+ const ledger = await db.execute(sql `SELECT locale FROM byline_document_version_locales WHERE document_version_id = ${result.newVersionId}::uuid ORDER BY locale`);
539
+ expect(ledger.rows.map((r) => r.locale)).toEqual(['de', 'en']);
540
+ });
541
+ it('refuses to re-anchor a document not complete in the target', async () => {
542
+ // en is full {title, body}; de has only title → de does not cover en.
543
+ const id = await createDoc({
544
+ title: { en: 'Hi', de: 'Hallo' },
545
+ body: { en: 'World' },
546
+ sku: 'RA2',
547
+ });
548
+ const result = await commandBuilders.documents.reAnchorDocument({
549
+ documentId: id,
550
+ targetLocale: 'de',
551
+ });
552
+ expect(result.status).toBe('skipped-incomplete');
553
+ const doc = await db.execute(sql `SELECT source_locale FROM byline_documents WHERE id = ${id}::uuid`);
554
+ expect(doc.rows[0].source_locale).toBe('en');
555
+ const versions = await db.execute(sql `SELECT count(*)::int AS n FROM byline_document_versions WHERE document_id = ${id}::uuid`);
556
+ expect(versions.rows[0].n).toBe(1);
557
+ });
558
+ it('no-ops when the document is already anchored to the target', async () => {
559
+ const id = await createDoc({ title: { en: 'Hello' }, sku: 'RA3' });
560
+ const result = await commandBuilders.documents.reAnchorDocument({
561
+ documentId: id,
562
+ targetLocale: 'en',
563
+ });
564
+ expect(result.status).toBe('already-anchored');
565
+ });
566
+ it('treats a locale-agnostic document as eligible for any target', async () => {
567
+ const id = await createDoc({ sku: 'RA4' });
568
+ const result = await commandBuilders.documents.reAnchorDocument({
569
+ documentId: id,
570
+ targetLocale: 'de',
571
+ });
572
+ expect(result.status).toBe('reanchored');
573
+ const doc = await db.execute(sql `SELECT source_locale FROM byline_documents WHERE id = ${id}::uuid`);
574
+ expect(doc.rows[0].source_locale).toBe('de');
575
+ });
576
+ it('dryRun reports the would-be outcome without writing', async () => {
577
+ const id = await createDoc({
578
+ title: { en: 'Hello', de: 'Hallo' },
579
+ body: { en: 'World', de: 'Welt' },
580
+ sku: 'RA5',
581
+ });
582
+ const result = await commandBuilders.documents.reAnchorDocument({
583
+ documentId: id,
584
+ targetLocale: 'de',
585
+ dryRun: true,
586
+ });
587
+ expect(result.status).toBe('reanchored');
588
+ const doc = await db.execute(sql `SELECT source_locale FROM byline_documents WHERE id = ${id}::uuid`);
589
+ expect(doc.rows[0].source_locale).toBe('en');
590
+ const versions = await db.execute(sql `SELECT count(*)::int AS n FROM byline_document_versions WHERE document_id = ${id}::uuid`);
591
+ expect(versions.rows[0].n).toBe(1);
592
+ });
593
+ it('bulk re-anchors complete documents and reports the incomplete ones', async () => {
594
+ const complete = await createDoc({
595
+ title: { en: 'C', de: 'C-de' },
596
+ body: { en: 'B', de: 'B-de' },
597
+ sku: 'RB1',
598
+ });
599
+ const incomplete = await createDoc({
600
+ title: { en: 'I', de: 'I-de' },
601
+ body: { en: 'B' }, // no de body → incomplete in de
602
+ sku: 'RB2',
603
+ });
604
+ const report = await commandBuilders.documents.reAnchorDocuments({
605
+ targetLocale: 'de',
606
+ collectionId: testCollection.id,
607
+ });
608
+ const byId = new Map(report.results.map((r) => [r.documentId, r]));
609
+ expect(byId.get(complete)?.status).toBe('reanchored');
610
+ expect(byId.get(incomplete)?.status).toBe('skipped-incomplete');
611
+ expect(report.total).toBeGreaterThanOrEqual(2);
612
+ expect(report.reanchored).toBeGreaterThanOrEqual(1);
613
+ expect(report.skippedIncomplete).toBeGreaterThanOrEqual(1);
614
+ });
615
+ });
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/db-postgres",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "2.6.1",
5
+ "version": "3.0.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -57,9 +57,9 @@
57
57
  "pg": "^8.21.0",
58
58
  "uuid": "^14.0.0",
59
59
  "zod": "^4.4.3",
60
- "@byline/admin": "2.6.1",
61
- "@byline/core": "2.6.1",
62
- "@byline/auth": "2.6.1"
60
+ "@byline/admin": "3.0.0",
61
+ "@byline/auth": "3.0.0",
62
+ "@byline/core": "3.0.0"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@biomejs/biome": "2.4.15",