@byline/db-postgres 3.0.0 → 3.0.2
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.js +3 -3
- package/dist/index.d.ts +4 -4
- package/dist/modules/storage/storage-commands.d.ts +41 -6
- package/dist/modules/storage/storage-commands.js +86 -15
- package/dist/modules/storage/storage-queries.d.ts +2 -2
- package/dist/modules/storage/storage-queries.js +2 -2
- package/dist/modules/storage/tests/storage-delete-locale.test.d.ts +8 -0
- package/dist/modules/storage/tests/storage-delete-locale.test.js +155 -0
- package/dist/modules/storage/tests/storage-locale-fallback.test.js +0 -20
- package/package.json +4 -4
|
@@ -148,7 +148,7 @@ export const documentPaths = pgTable('byline_document_paths', {
|
|
|
148
148
|
// carries forward across edits and survives restore. Surfaced on reads as
|
|
149
149
|
// `availableLocales`; the public advertised set is the intersection with the
|
|
150
150
|
// ledger's `_availableVersionLocales`. Replaced wholesale on write (the lifecycle
|
|
151
|
-
// deletes then re-inserts the set), never appended. See docs/
|
|
151
|
+
// deletes then re-inserts the set), never appended. See docs/I18N.md.
|
|
152
152
|
export const documentAvailableLocales = pgTable('byline_document_available_locales', {
|
|
153
153
|
document_id: uuid('document_id')
|
|
154
154
|
.notNull()
|
|
@@ -172,7 +172,7 @@ export const documentAvailableLocales = pgTable('byline_document_available_local
|
|
|
172
172
|
// identically in any locale). Computed status-blind at write time and frozen
|
|
173
173
|
// on the immutable version, so restore / point-in-time reads stay consistent.
|
|
174
174
|
// Drives `localeFallback: 'strict'` reads via an indexed EXISTS gate without
|
|
175
|
-
// scanning the store_* tables. See docs/
|
|
175
|
+
// scanning the store_* tables. See docs/I18N.md.
|
|
176
176
|
export const documentVersionLocales = pgTable('byline_document_version_locales', {
|
|
177
177
|
document_version_id: uuid('document_version_id')
|
|
178
178
|
.notNull()
|
|
@@ -248,7 +248,7 @@ export const currentDocumentsView = pgView('byline_current_documents').as((qb) =
|
|
|
248
248
|
// read paths (`buildLocaleChain` / `pathProjection` / field-fallback)
|
|
249
249
|
// re-base onto the per-document source rather than the mutable global
|
|
250
250
|
// default — a primary-key join, already present for `order_key`.
|
|
251
|
-
// See docs/
|
|
251
|
+
// See docs/I18N.md.
|
|
252
252
|
source_locale: documents.source_locale,
|
|
253
253
|
})
|
|
254
254
|
.from(sq)
|
package/dist/index.d.ts
CHANGED
|
@@ -31,7 +31,7 @@ export interface PgAdapter extends IDbAdapter {
|
|
|
31
31
|
* existed, so `localeFallback: 'strict'` reads can see pre-existing
|
|
32
32
|
* documents. Idempotent; uses the configured default content locale. Kept
|
|
33
33
|
* off the core `IDbAdapter` contract (no service depends on it) — see
|
|
34
|
-
* docs/
|
|
34
|
+
* docs/I18N.md.
|
|
35
35
|
*/
|
|
36
36
|
backfillVersionLocales(): Promise<{
|
|
37
37
|
rowsInserted: number;
|
|
@@ -42,7 +42,7 @@ export interface PgAdapter extends IDbAdapter {
|
|
|
42
42
|
* default content locale (the anchor they were implicitly authored against).
|
|
43
43
|
* Idempotent; run automatically at boot by `initBylineCore` (also exposed on
|
|
44
44
|
* the core `IDbAdapter` contract as an optional method) — see
|
|
45
|
-
* docs/
|
|
45
|
+
* docs/I18N.md.
|
|
46
46
|
*/
|
|
47
47
|
backfillSourceLocales(): Promise<{
|
|
48
48
|
rowsUpdated: number;
|
|
@@ -53,7 +53,7 @@ export interface PgAdapter extends IDbAdapter {
|
|
|
53
53
|
* unless the document is complete in the target. Writes a new immutable
|
|
54
54
|
* version. `dryRun` reports the would-be outcome without writing. Off the
|
|
55
55
|
* core `IDbAdapter` contract (maintenance/admin operation) — see
|
|
56
|
-
* docs/
|
|
56
|
+
* docs/I18N.md.
|
|
57
57
|
*/
|
|
58
58
|
reAnchorDocument(params: {
|
|
59
59
|
documentId: string;
|
|
@@ -83,7 +83,7 @@ export declare const pgAdapter: ({ connectionString, collections, defaultContent
|
|
|
83
83
|
* for documents whose `source_locale` is not yet backfilled. Per-document
|
|
84
84
|
* reads and writes otherwise re-base onto each document's own `source_locale`
|
|
85
85
|
* (carried on the current-documents views), so changing this value does not
|
|
86
|
-
* re-interpret existing data. See docs/
|
|
86
|
+
* re-interpret existing data. See docs/I18N.md.
|
|
87
87
|
*/
|
|
88
88
|
defaultContentLocale: string;
|
|
89
89
|
/**
|
|
@@ -104,7 +104,7 @@ export declare class DocumentCommands implements IDocumentCommands {
|
|
|
104
104
|
* `undefined` leaves the existing set untouched (sticky across versions,
|
|
105
105
|
* like `path`); an empty array clears it (advertise nothing). The locale
|
|
106
106
|
* values are the advertised content locales themselves, not the default
|
|
107
|
-
* locale. See docs/
|
|
107
|
+
* locale. See docs/I18N.md.
|
|
108
108
|
*/
|
|
109
109
|
availableLocales?: string[];
|
|
110
110
|
locale?: string;
|
|
@@ -139,7 +139,7 @@ export declare class DocumentCommands implements IDocumentCommands {
|
|
|
139
139
|
* so callers must have written them first. Shared by the create write path
|
|
140
140
|
* (step 6) and `reAnchorDocument` (which recomputes against the new source).
|
|
141
141
|
* Assumes the version has no ledger rows yet (a freshly-inserted version).
|
|
142
|
-
* See docs/
|
|
142
|
+
* See docs/I18N.md.
|
|
143
143
|
*/
|
|
144
144
|
private writeVersionLocaleLedger;
|
|
145
145
|
/**
|
|
@@ -152,8 +152,43 @@ export declare class DocumentCommands implements IDocumentCommands {
|
|
|
152
152
|
* version is assumed fresh (no rows), so no conflict handling is needed.
|
|
153
153
|
* Used by `reAnchorDocument` to snapshot the current version into the new
|
|
154
154
|
* re-anchored one without re-flattening (lossless, identity-preserving).
|
|
155
|
+
*
|
|
156
|
+
* When `excludeLocale` is provided, rows for that locale are skipped in the
|
|
157
|
+
* seven value-store tables — the carry-forward that powers `deleteDocumentLocale`
|
|
158
|
+
* (drop one locale's content, keep `'all'` + every other locale). The
|
|
159
|
+
* locale-agnostic `byline_store_meta` rows (block / array-item identities) are
|
|
160
|
+
* always copied wholesale, since a block's identity is shared across locales.
|
|
155
161
|
*/
|
|
156
162
|
private copyAllVersionStoreRows;
|
|
163
|
+
/**
|
|
164
|
+
* deleteDocumentLocale
|
|
165
|
+
*
|
|
166
|
+
* Remove one content locale's data from a document by writing a **new
|
|
167
|
+
* immutable version** that carries forward every store row except the
|
|
168
|
+
* target locale's (the `'all'` rows and all other locales are kept). The
|
|
169
|
+
* prior version still holds the deleted locale, so the operation is
|
|
170
|
+
* recoverable via version restore, and a previously-published version keeps
|
|
171
|
+
* serving until the new version is published.
|
|
172
|
+
*
|
|
173
|
+
* The new version's status is supplied by the caller (the lifecycle service
|
|
174
|
+
* passes the workflow's default — a fresh draft, matching `copyToLocale`).
|
|
175
|
+
* The derived availability ledger is recomputed from the carried-forward
|
|
176
|
+
* rows, so the deleted locale drops out automatically. The default content
|
|
177
|
+
* locale (the document's anchor) must never be passed here — the lifecycle
|
|
178
|
+
* service enforces that.
|
|
179
|
+
*
|
|
180
|
+
* Mirrors `reAnchorDocument`'s new-version mechanics; defensively returns
|
|
181
|
+
* `null` when the document has no current version (the service validates
|
|
182
|
+
* existence first, so this is a guard).
|
|
183
|
+
*/
|
|
184
|
+
deleteDocumentLocale(params: {
|
|
185
|
+
documentId: string;
|
|
186
|
+
locale: string;
|
|
187
|
+
status?: string;
|
|
188
|
+
}): Promise<{
|
|
189
|
+
newVersionId: string;
|
|
190
|
+
previousVersionId: string;
|
|
191
|
+
} | null>;
|
|
157
192
|
/**
|
|
158
193
|
* reAnchorDocument
|
|
159
194
|
*
|
|
@@ -167,7 +202,7 @@ export declare class DocumentCommands implements IDocumentCommands {
|
|
|
167
202
|
* identities preserved), and computes that version's ledger against the new
|
|
168
203
|
* source. `dryRun` performs only the eligibility check and reports the
|
|
169
204
|
* outcome that *would* result, writing nothing. See
|
|
170
|
-
* docs/
|
|
205
|
+
* docs/I18N.md.
|
|
171
206
|
*/
|
|
172
207
|
reAnchorDocument(params: {
|
|
173
208
|
documentId: string;
|
|
@@ -185,7 +220,7 @@ export declare class DocumentCommands implements IDocumentCommands {
|
|
|
185
220
|
* "client switched the default content locale, move every fully-translated
|
|
186
221
|
* document onto it" operation; the `skipped-incomplete` results double as the
|
|
187
222
|
* outstanding-translation backlog. `dryRun` reports what would happen without
|
|
188
|
-
* writing. See docs/
|
|
223
|
+
* writing. See docs/I18N.md.
|
|
189
224
|
*/
|
|
190
225
|
reAnchorDocuments(params: {
|
|
191
226
|
targetLocale: string;
|
|
@@ -211,7 +246,7 @@ export declare class DocumentCommands implements IDocumentCommands {
|
|
|
211
246
|
* a version's computed locale set never changes. Returns the number of
|
|
212
247
|
* `(version, locale)` rows inserted.
|
|
213
248
|
*
|
|
214
|
-
* See docs/
|
|
249
|
+
* See docs/I18N.md.
|
|
215
250
|
*/
|
|
216
251
|
backfillVersionLocales(): Promise<{
|
|
217
252
|
rowsInserted: number;
|
|
@@ -230,7 +265,7 @@ export declare class DocumentCommands implements IDocumentCommands {
|
|
|
230
265
|
*
|
|
231
266
|
* Returns the number of document rows stamped.
|
|
232
267
|
*
|
|
233
|
-
* See docs/
|
|
268
|
+
* See docs/I18N.md.
|
|
234
269
|
*/
|
|
235
270
|
backfillSourceLocales(): Promise<{
|
|
236
271
|
rowsUpdated: number;
|
|
@@ -77,7 +77,7 @@ export class DocumentCommands {
|
|
|
77
77
|
// source locale rather than the mutable global default. NULL (a row not
|
|
78
78
|
// yet touched by `backfillSourceLocales`) falls back to the configured
|
|
79
79
|
// default — the value it was implicitly authored against.
|
|
80
|
-
// See docs/
|
|
80
|
+
// See docs/I18N.md.
|
|
81
81
|
let sourceLocale;
|
|
82
82
|
if (documentId == null) {
|
|
83
83
|
documentId = uuidv7();
|
|
@@ -271,7 +271,7 @@ export class DocumentCommands {
|
|
|
271
271
|
// accounts for the per-locale carry-forward in step 5 — not just the
|
|
272
272
|
// freshly-flattened locale. A version with no localized content at all
|
|
273
273
|
// records a single `'all'` sentinel (it renders identically in any
|
|
274
|
-
// locale). Status-blind by design — see docs/
|
|
274
|
+
// locale). Status-blind by design — see docs/I18N.md.
|
|
275
275
|
await this.writeVersionLocaleLedger(tx, documentVersion.id, sourceLocale);
|
|
276
276
|
return {
|
|
277
277
|
document: documentVersion,
|
|
@@ -289,7 +289,7 @@ export class DocumentCommands {
|
|
|
289
289
|
* so callers must have written them first. Shared by the create write path
|
|
290
290
|
* (step 6) and `reAnchorDocument` (which recomputes against the new source).
|
|
291
291
|
* Assumes the version has no ledger rows yet (a freshly-inserted version).
|
|
292
|
-
* See docs/
|
|
292
|
+
* See docs/I18N.md.
|
|
293
293
|
*/
|
|
294
294
|
async writeVersionLocaleLedger(tx, versionId, sourceLocale) {
|
|
295
295
|
await tx.execute(sql `
|
|
@@ -332,51 +332,61 @@ export class DocumentCommands {
|
|
|
332
332
|
* version is assumed fresh (no rows), so no conflict handling is needed.
|
|
333
333
|
* Used by `reAnchorDocument` to snapshot the current version into the new
|
|
334
334
|
* re-anchored one without re-flattening (lossless, identity-preserving).
|
|
335
|
+
*
|
|
336
|
+
* When `excludeLocale` is provided, rows for that locale are skipped in the
|
|
337
|
+
* seven value-store tables — the carry-forward that powers `deleteDocumentLocale`
|
|
338
|
+
* (drop one locale's content, keep `'all'` + every other locale). The
|
|
339
|
+
* locale-agnostic `byline_store_meta` rows (block / array-item identities) are
|
|
340
|
+
* always copied wholesale, since a block's identity is shared across locales.
|
|
335
341
|
*/
|
|
336
|
-
async copyAllVersionStoreRows(tx, fromVersionId, toVersionId) {
|
|
342
|
+
async copyAllVersionStoreRows(tx, fromVersionId, toVersionId, excludeLocale) {
|
|
337
343
|
const from = sql `${fromVersionId}::uuid`;
|
|
338
344
|
const to = sql `${toVersionId}::uuid`;
|
|
345
|
+
// Appended to each value-store WHERE so the dropped locale's rows are not
|
|
346
|
+
// carried into the new version. Empty fragment (copy everything) when no
|
|
347
|
+
// exclusion is requested — the `reAnchorDocument` snapshot path.
|
|
348
|
+
const localeFilter = excludeLocale ? sql ` AND locale <> ${excludeLocale}` : sql ``;
|
|
339
349
|
await tx.execute(sql `
|
|
340
350
|
INSERT INTO byline_store_text
|
|
341
351
|
(id, document_version_id, collection_id, field_path, field_name, locale, parent_path, value, word_count, created_at, updated_at)
|
|
342
352
|
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}
|
|
353
|
+
FROM byline_store_text WHERE document_version_id = ${from}${localeFilter}
|
|
344
354
|
`);
|
|
345
355
|
await tx.execute(sql `
|
|
346
356
|
INSERT INTO byline_store_numeric
|
|
347
357
|
(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
358
|
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}
|
|
359
|
+
FROM byline_store_numeric WHERE document_version_id = ${from}${localeFilter}
|
|
350
360
|
`);
|
|
351
361
|
await tx.execute(sql `
|
|
352
362
|
INSERT INTO byline_store_boolean
|
|
353
363
|
(id, document_version_id, collection_id, field_path, field_name, locale, parent_path, value, created_at, updated_at)
|
|
354
364
|
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}
|
|
365
|
+
FROM byline_store_boolean WHERE document_version_id = ${from}${localeFilter}
|
|
356
366
|
`);
|
|
357
367
|
await tx.execute(sql `
|
|
358
368
|
INSERT INTO byline_store_datetime
|
|
359
369
|
(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
370
|
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}
|
|
371
|
+
FROM byline_store_datetime WHERE document_version_id = ${from}${localeFilter}
|
|
362
372
|
`);
|
|
363
373
|
await tx.execute(sql `
|
|
364
374
|
INSERT INTO byline_store_json
|
|
365
375
|
(id, document_version_id, collection_id, field_path, field_name, locale, parent_path, value, json_schema, object_keys, created_at, updated_at)
|
|
366
376
|
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}
|
|
377
|
+
FROM byline_store_json WHERE document_version_id = ${from}${localeFilter}
|
|
368
378
|
`);
|
|
369
379
|
await tx.execute(sql `
|
|
370
380
|
INSERT INTO byline_store_relation
|
|
371
381
|
(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
382
|
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}
|
|
383
|
+
FROM byline_store_relation WHERE document_version_id = ${from}${localeFilter}
|
|
374
384
|
`);
|
|
375
385
|
await tx.execute(sql `
|
|
376
386
|
INSERT INTO byline_store_file
|
|
377
387
|
(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
388
|
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}
|
|
389
|
+
FROM byline_store_file WHERE document_version_id = ${from}${localeFilter}
|
|
380
390
|
`);
|
|
381
391
|
await tx.execute(sql `
|
|
382
392
|
INSERT INTO byline_store_meta
|
|
@@ -385,6 +395,67 @@ export class DocumentCommands {
|
|
|
385
395
|
FROM byline_store_meta WHERE document_version_id = ${from}
|
|
386
396
|
`);
|
|
387
397
|
}
|
|
398
|
+
/**
|
|
399
|
+
* deleteDocumentLocale
|
|
400
|
+
*
|
|
401
|
+
* Remove one content locale's data from a document by writing a **new
|
|
402
|
+
* immutable version** that carries forward every store row except the
|
|
403
|
+
* target locale's (the `'all'` rows and all other locales are kept). The
|
|
404
|
+
* prior version still holds the deleted locale, so the operation is
|
|
405
|
+
* recoverable via version restore, and a previously-published version keeps
|
|
406
|
+
* serving until the new version is published.
|
|
407
|
+
*
|
|
408
|
+
* The new version's status is supplied by the caller (the lifecycle service
|
|
409
|
+
* passes the workflow's default — a fresh draft, matching `copyToLocale`).
|
|
410
|
+
* The derived availability ledger is recomputed from the carried-forward
|
|
411
|
+
* rows, so the deleted locale drops out automatically. The default content
|
|
412
|
+
* locale (the document's anchor) must never be passed here — the lifecycle
|
|
413
|
+
* service enforces that.
|
|
414
|
+
*
|
|
415
|
+
* Mirrors `reAnchorDocument`'s new-version mechanics; defensively returns
|
|
416
|
+
* `null` when the document has no current version (the service validates
|
|
417
|
+
* existence first, so this is a guard).
|
|
418
|
+
*/
|
|
419
|
+
async deleteDocumentLocale(params) {
|
|
420
|
+
const { documentId, locale, status } = params;
|
|
421
|
+
return this.db.transaction(async (tx) => {
|
|
422
|
+
// 1. Current (latest, non-deleted) version + the document's anchor.
|
|
423
|
+
const current = await tx
|
|
424
|
+
.select({
|
|
425
|
+
versionId: documentVersions.id,
|
|
426
|
+
collectionId: documentVersions.collection_id,
|
|
427
|
+
collectionVersion: documentVersions.collection_version,
|
|
428
|
+
sourceLocale: documents.source_locale,
|
|
429
|
+
})
|
|
430
|
+
.from(documentVersions)
|
|
431
|
+
.innerJoin(documents, eq(documents.id, documentVersions.document_id))
|
|
432
|
+
.where(and(eq(documentVersions.document_id, documentId), eq(documentVersions.is_deleted, false)))
|
|
433
|
+
.orderBy(desc(documentVersions.id))
|
|
434
|
+
.limit(1)
|
|
435
|
+
.then((rows) => rows[0]);
|
|
436
|
+
if (current == null)
|
|
437
|
+
return null;
|
|
438
|
+
const sourceLocale = current.sourceLocale ?? this.defaultContentLocale;
|
|
439
|
+
// 2. New immutable version: a snapshot of the current version with the
|
|
440
|
+
// target locale's value rows dropped (meta + 'all' + other locales
|
|
441
|
+
// carried forward).
|
|
442
|
+
const newVersionId = uuidv7();
|
|
443
|
+
await tx.insert(documentVersions).values({
|
|
444
|
+
id: newVersionId,
|
|
445
|
+
document_id: documentId,
|
|
446
|
+
collection_id: current.collectionId,
|
|
447
|
+
collection_version: current.collectionVersion,
|
|
448
|
+
event_type: 'delete_locale',
|
|
449
|
+
status: status ?? 'draft',
|
|
450
|
+
change_summary: `deleted content locale ${locale}`,
|
|
451
|
+
});
|
|
452
|
+
await this.copyAllVersionStoreRows(tx, current.versionId, newVersionId, locale);
|
|
453
|
+
// 3. Recompute the new version's availability ledger against the source
|
|
454
|
+
// locale — the dropped locale no longer covers it, so it falls out.
|
|
455
|
+
await this.writeVersionLocaleLedger(tx, newVersionId, sourceLocale);
|
|
456
|
+
return { newVersionId, previousVersionId: current.versionId };
|
|
457
|
+
});
|
|
458
|
+
}
|
|
388
459
|
/**
|
|
389
460
|
* reAnchorDocument
|
|
390
461
|
*
|
|
@@ -398,7 +469,7 @@ export class DocumentCommands {
|
|
|
398
469
|
* identities preserved), and computes that version's ledger against the new
|
|
399
470
|
* source. `dryRun` performs only the eligibility check and reports the
|
|
400
471
|
* outcome that *would* result, writing nothing. See
|
|
401
|
-
* docs/
|
|
472
|
+
* docs/I18N.md.
|
|
402
473
|
*/
|
|
403
474
|
async reAnchorDocument(params) {
|
|
404
475
|
const { documentId, targetLocale, dryRun = false } = params;
|
|
@@ -479,7 +550,7 @@ export class DocumentCommands {
|
|
|
479
550
|
* "client switched the default content locale, move every fully-translated
|
|
480
551
|
* document onto it" operation; the `skipped-incomplete` results double as the
|
|
481
552
|
* outstanding-translation backlog. `dryRun` reports what would happen without
|
|
482
|
-
* writing. See docs/
|
|
553
|
+
* writing. See docs/I18N.md.
|
|
483
554
|
*/
|
|
484
555
|
async reAnchorDocuments(params) {
|
|
485
556
|
const { targetLocale, collectionId, dryRun = false } = params;
|
|
@@ -540,7 +611,7 @@ export class DocumentCommands {
|
|
|
540
611
|
* a version's computed locale set never changes. Returns the number of
|
|
541
612
|
* `(version, locale)` rows inserted.
|
|
542
613
|
*
|
|
543
|
-
* See docs/
|
|
614
|
+
* See docs/I18N.md.
|
|
544
615
|
*/
|
|
545
616
|
async backfillVersionLocales() {
|
|
546
617
|
const result = await this.db.execute(sql `
|
|
@@ -598,7 +669,7 @@ export class DocumentCommands {
|
|
|
598
669
|
*
|
|
599
670
|
* Returns the number of document rows stamped.
|
|
600
671
|
*
|
|
601
|
-
* See docs/
|
|
672
|
+
* See docs/I18N.md.
|
|
602
673
|
*/
|
|
603
674
|
async backfillSourceLocales() {
|
|
604
675
|
const result = await this.db
|
|
@@ -84,7 +84,7 @@ export declare class DocumentQueries implements IDocumentQueries {
|
|
|
84
84
|
* document, or any document read after the global default is switched, falls
|
|
85
85
|
* back to the locale it was actually authored in) — otherwise the configured
|
|
86
86
|
* global default, which is correct for not-yet-anchored rows and for
|
|
87
|
-
* row-less lookups (findByPath). See docs/
|
|
87
|
+
* row-less lookups (findByPath). See docs/I18N.md.
|
|
88
88
|
*/
|
|
89
89
|
private buildLocaleChain;
|
|
90
90
|
/**
|
|
@@ -113,7 +113,7 @@ export declare class DocumentQueries implements IDocumentQueries {
|
|
|
113
113
|
* advertise. Surfaced on reads as `availableLocales` — the deliberate
|
|
114
114
|
* counterpart to the version-grain `_availableVersionLocales` ledger fact;
|
|
115
115
|
* the public advertised set is their intersection. One indexed query per
|
|
116
|
-
* call. See docs/
|
|
116
|
+
* call. See docs/I18N.md.
|
|
117
117
|
*/
|
|
118
118
|
private getAdvertisedLocalesByDocument;
|
|
119
119
|
/**
|
|
@@ -105,7 +105,7 @@ export class DocumentQueries {
|
|
|
105
105
|
* document, or any document read after the global default is switched, falls
|
|
106
106
|
* back to the locale it was actually authored in) — otherwise the configured
|
|
107
107
|
* global default, which is correct for not-yet-anchored rows and for
|
|
108
|
-
* row-less lookups (findByPath). See docs/
|
|
108
|
+
* row-less lookups (findByPath). See docs/I18N.md.
|
|
109
109
|
*/
|
|
110
110
|
buildLocaleChain(requestedLocale, sourceLocale) {
|
|
111
111
|
const floor = sourceLocale ?? this.defaultContentLocale;
|
|
@@ -171,7 +171,7 @@ export class DocumentQueries {
|
|
|
171
171
|
* advertise. Surfaced on reads as `availableLocales` — the deliberate
|
|
172
172
|
* counterpart to the version-grain `_availableVersionLocales` ledger fact;
|
|
173
173
|
* the public advertised set is their intersection. One indexed query per
|
|
174
|
-
* call. See docs/
|
|
174
|
+
* call. See docs/I18N.md.
|
|
175
175
|
*/
|
|
176
176
|
async getAdvertisedLocalesByDocument(documentIds) {
|
|
177
177
|
const result = new Map();
|
|
@@ -0,0 +1,8 @@
|
|
|
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
|
+
export {};
|
|
@@ -0,0 +1,155 @@
|
|
|
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 { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
9
|
+
import { setupTestDB, teardownTestDB } from '../../../lib/test-helper.js';
|
|
10
|
+
let commandBuilders;
|
|
11
|
+
let queryBuilders;
|
|
12
|
+
const timestamp = Date.now();
|
|
13
|
+
const DeleteLocaleCollectionConfig = {
|
|
14
|
+
path: `delete-locale-${timestamp}`,
|
|
15
|
+
labels: { singular: 'DeleteLocaleTest', plural: 'DeleteLocaleTests' },
|
|
16
|
+
fields: [
|
|
17
|
+
{ name: 'title', type: 'text', localized: true },
|
|
18
|
+
// Non-localized → lives on `locale: 'all'` rows; must survive the delete.
|
|
19
|
+
{ name: 'sku', type: 'text' },
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
let testCollection = {};
|
|
23
|
+
function readById(documentId, locale) {
|
|
24
|
+
return queryBuilders.documents.getDocumentById({
|
|
25
|
+
collection_id: testCollection.id,
|
|
26
|
+
document_id: documentId,
|
|
27
|
+
locale,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Create a fresh document with three localized titles (en/fr/de) and one
|
|
32
|
+
* shared non-localized field, returning the document id and the current
|
|
33
|
+
* (latest) version id.
|
|
34
|
+
*/
|
|
35
|
+
async function seedTrilingualDoc(titlePrefix) {
|
|
36
|
+
const v1 = await commandBuilders.documents.createDocumentVersion({
|
|
37
|
+
collectionId: testCollection.id,
|
|
38
|
+
collectionVersion: 1,
|
|
39
|
+
collectionConfig: DeleteLocaleCollectionConfig,
|
|
40
|
+
action: 'create',
|
|
41
|
+
documentData: { title: `${titlePrefix} EN`, sku: 'SKU-1' },
|
|
42
|
+
locale: 'en',
|
|
43
|
+
status: 'published',
|
|
44
|
+
});
|
|
45
|
+
const documentId = v1.document.document_id;
|
|
46
|
+
const v2 = await commandBuilders.documents.createDocumentVersion({
|
|
47
|
+
documentId,
|
|
48
|
+
collectionId: testCollection.id,
|
|
49
|
+
collectionVersion: 1,
|
|
50
|
+
collectionConfig: DeleteLocaleCollectionConfig,
|
|
51
|
+
action: 'update',
|
|
52
|
+
documentData: { title: `${titlePrefix} FR`, sku: 'SKU-1' },
|
|
53
|
+
locale: 'fr',
|
|
54
|
+
status: 'published',
|
|
55
|
+
previousVersionId: v1.document.id,
|
|
56
|
+
});
|
|
57
|
+
const v3 = await commandBuilders.documents.createDocumentVersion({
|
|
58
|
+
documentId,
|
|
59
|
+
collectionId: testCollection.id,
|
|
60
|
+
collectionVersion: 1,
|
|
61
|
+
collectionConfig: DeleteLocaleCollectionConfig,
|
|
62
|
+
action: 'update',
|
|
63
|
+
documentData: { title: `${titlePrefix} DE`, sku: 'SKU-1' },
|
|
64
|
+
locale: 'de',
|
|
65
|
+
status: 'published',
|
|
66
|
+
previousVersionId: v2.document.id,
|
|
67
|
+
});
|
|
68
|
+
return { documentId, currentVersionId: v3.document.id };
|
|
69
|
+
}
|
|
70
|
+
describe('deleteDocumentLocale integration', () => {
|
|
71
|
+
beforeAll(async () => {
|
|
72
|
+
const testDB = setupTestDB([DeleteLocaleCollectionConfig]);
|
|
73
|
+
commandBuilders = testDB.commandBuilders;
|
|
74
|
+
queryBuilders = testDB.queryBuilders;
|
|
75
|
+
const result = await commandBuilders.collections.create(DeleteLocaleCollectionConfig.path, DeleteLocaleCollectionConfig);
|
|
76
|
+
const collection = result[0];
|
|
77
|
+
if (collection == null) {
|
|
78
|
+
throw new Error('Failed to create test collection');
|
|
79
|
+
}
|
|
80
|
+
testCollection = { id: collection.id, name: collection.path };
|
|
81
|
+
});
|
|
82
|
+
afterAll(async () => {
|
|
83
|
+
try {
|
|
84
|
+
await commandBuilders.collections.delete(testCollection.id);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
console.error('Failed to cleanup test collection:', error);
|
|
88
|
+
}
|
|
89
|
+
await teardownTestDB();
|
|
90
|
+
});
|
|
91
|
+
it('drops the target locale from availability, keeps the others', async () => {
|
|
92
|
+
const { documentId } = await seedTrilingualDoc('Avail');
|
|
93
|
+
const before = await readById(documentId, 'en');
|
|
94
|
+
expect(before?._availableVersionLocales, 'all three locales present').toEqual([
|
|
95
|
+
'de',
|
|
96
|
+
'en',
|
|
97
|
+
'fr',
|
|
98
|
+
]);
|
|
99
|
+
await commandBuilders.documents.deleteDocumentLocale({
|
|
100
|
+
documentId,
|
|
101
|
+
locale: 'fr',
|
|
102
|
+
status: 'draft',
|
|
103
|
+
});
|
|
104
|
+
const after = await readById(documentId, 'en');
|
|
105
|
+
expect(after?._availableVersionLocales, 'fr removed, de/en remain').toEqual(['de', 'en']);
|
|
106
|
+
});
|
|
107
|
+
it("keeps other locales' localized content and the shared non-localized field", async () => {
|
|
108
|
+
const { documentId } = await seedTrilingualDoc('Content');
|
|
109
|
+
await commandBuilders.documents.deleteDocumentLocale({
|
|
110
|
+
documentId,
|
|
111
|
+
locale: 'fr',
|
|
112
|
+
status: 'draft',
|
|
113
|
+
});
|
|
114
|
+
const de = await readById(documentId, 'de');
|
|
115
|
+
expect(de?.fields.title, 'de content untouched').toBe('Content DE');
|
|
116
|
+
expect(de?.fields.sku, "non-localized 'all' field preserved").toBe('SKU-1');
|
|
117
|
+
const en = await readById(documentId, 'en');
|
|
118
|
+
expect(en?.fields.title, 'default-locale content untouched').toBe('Content EN');
|
|
119
|
+
});
|
|
120
|
+
it('lands the new version with the supplied status (a fresh draft)', async () => {
|
|
121
|
+
const { documentId } = await seedTrilingualDoc('Status');
|
|
122
|
+
await commandBuilders.documents.deleteDocumentLocale({
|
|
123
|
+
documentId,
|
|
124
|
+
locale: 'fr',
|
|
125
|
+
status: 'draft',
|
|
126
|
+
});
|
|
127
|
+
const meta = await queryBuilders.documents.getCurrentVersionMetadata({
|
|
128
|
+
collection_id: testCollection.id,
|
|
129
|
+
document_id: documentId,
|
|
130
|
+
});
|
|
131
|
+
expect(meta?.status, 'delete-locale version is a draft').toBe('draft');
|
|
132
|
+
});
|
|
133
|
+
it('is recoverable — the prior version still holds the deleted locale', async () => {
|
|
134
|
+
const { documentId, currentVersionId } = await seedTrilingualDoc('Recover');
|
|
135
|
+
await commandBuilders.documents.deleteDocumentLocale({
|
|
136
|
+
documentId,
|
|
137
|
+
locale: 'fr',
|
|
138
|
+
status: 'draft',
|
|
139
|
+
});
|
|
140
|
+
// The pre-delete version is immutable and still carries the fr content.
|
|
141
|
+
const prior = (await queryBuilders.documents.getDocumentByVersion({
|
|
142
|
+
document_version_id: currentVersionId,
|
|
143
|
+
locale: 'fr',
|
|
144
|
+
}));
|
|
145
|
+
expect(prior?.fields.title, 'fr content survives in the prior version').toBe('Recover FR');
|
|
146
|
+
});
|
|
147
|
+
it('returns null for an unknown document', async () => {
|
|
148
|
+
const result = await commandBuilders.documents.deleteDocumentLocale({
|
|
149
|
+
documentId: crypto.randomUUID(),
|
|
150
|
+
locale: 'fr',
|
|
151
|
+
status: 'draft',
|
|
152
|
+
});
|
|
153
|
+
expect(result).toBeNull();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -278,26 +278,6 @@ describe('content-locale resolution & fallback', () => {
|
|
|
278
278
|
const second = await commandBuilders.documents.backfillVersionLocales();
|
|
279
279
|
expect(second.rowsInserted).toBe(0);
|
|
280
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
281
|
// --- source_locale write path (Slice 2) ----------------------------------
|
|
302
282
|
it('records source_locale on create and writes the path row under it', async () => {
|
|
303
283
|
const created = await commandBuilders.documents.createDocumentVersion({
|
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": "3.0.
|
|
5
|
+
"version": "3.0.2",
|
|
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": "3.0.
|
|
61
|
-
"@byline/auth": "3.0.
|
|
62
|
-
"@byline/core": "3.0.
|
|
60
|
+
"@byline/admin": "3.0.2",
|
|
61
|
+
"@byline/auth": "3.0.2",
|
|
62
|
+
"@byline/core": "3.0.2"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@biomejs/biome": "2.4.15",
|