@byline/db-postgres 2.6.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/database/schema/index.d.ts +190 -0
- package/dist/database/schema/index.js +60 -1
- package/dist/index.d.ts +55 -4
- package/dist/index.js +5 -1
- package/dist/modules/storage/storage-commands.d.ts +141 -3
- package/dist/modules/storage/storage-commands.js +388 -10
- package/dist/modules/storage/storage-queries.d.ts +90 -12
- package/dist/modules/storage/storage-queries.js +285 -48
- package/dist/modules/storage/tests/storage-document-available-locales.test.d.ts +8 -0
- package/dist/modules/storage/tests/storage-document-available-locales.test.js +198 -0
- package/dist/modules/storage/tests/storage-locale-fallback.test.d.ts +8 -0
- package/dist/modules/storage/tests/storage-locale-fallback.test.js +615 -0
- package/package.json +4 -4
|
@@ -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/DEFAULT-LOCALE-SWITCHING.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.
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
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:
|
|
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/CONTENT-LOCALE-RESOLUTION.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/CONTENT-LOCALE-RESOLUTION.md and docs/DEFAULT-LOCALE-SWITCHING.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/DEFAULT-LOCALE-SWITCHING.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/DEFAULT-LOCALE-SWITCHING.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/CONTENT-LOCALE-RESOLUTION.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/DEFAULT-LOCALE-SWITCHING.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
|
|
82
|
-
* `[requested,
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* default
|
|
87
|
-
*
|
|
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/DEFAULT-LOCALE-SWITCHING.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/AVAILABLE-LOCALES.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;
|