@byline/db-postgres 3.0.1 → 3.1.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/modules/storage/storage-commands.d.ts +35 -0
- package/dist/modules/storage/storage-commands.js +79 -8
- package/dist/modules/storage/storage-queries.d.ts +14 -0
- package/dist/modules/storage/storage-queries.js +20 -0
- 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-document-paths.test.js +77 -0
- package/package.json +4 -4
|
@@ -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
|
*
|
|
@@ -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
|
*
|
|
@@ -216,6 +216,20 @@ export declare class DocumentQueries implements IDocumentQueries {
|
|
|
216
216
|
created_at: Date;
|
|
217
217
|
updated_at: Date;
|
|
218
218
|
} | null>;
|
|
219
|
+
/**
|
|
220
|
+
* getCurrentPath — resolve a document's canonical (source-locale) path.
|
|
221
|
+
*
|
|
222
|
+
* Reuses `pathProjection` against `current_documents`, passing
|
|
223
|
+
* `requestedLocale: undefined` so the projection's fallback floor — the
|
|
224
|
+
* document's own `source_locale` (COALESCE-guarded to the default content
|
|
225
|
+
* locale for not-yet-anchored rows) — supplies the canonical path. Used by
|
|
226
|
+
* the lifecycle to populate `path` on the status-change / unpublish hook
|
|
227
|
+
* contexts. Returns `null` when no path row (or document) exists.
|
|
228
|
+
*/
|
|
229
|
+
getCurrentPath({ collection_id, document_id, }: {
|
|
230
|
+
collection_id: string;
|
|
231
|
+
document_id: string;
|
|
232
|
+
}): Promise<string | null>;
|
|
219
233
|
/**
|
|
220
234
|
* getDocumentById — gets the current version of a document by its logical document ID.
|
|
221
235
|
*
|
|
@@ -439,6 +439,26 @@ export class DocumentQueries {
|
|
|
439
439
|
updated_at: row.updated_at ?? new Date(),
|
|
440
440
|
};
|
|
441
441
|
}
|
|
442
|
+
/**
|
|
443
|
+
* getCurrentPath — resolve a document's canonical (source-locale) path.
|
|
444
|
+
*
|
|
445
|
+
* Reuses `pathProjection` against `current_documents`, passing
|
|
446
|
+
* `requestedLocale: undefined` so the projection's fallback floor — the
|
|
447
|
+
* document's own `source_locale` (COALESCE-guarded to the default content
|
|
448
|
+
* locale for not-yet-anchored rows) — supplies the canonical path. Used by
|
|
449
|
+
* the lifecycle to populate `path` on the status-change / unpublish hook
|
|
450
|
+
* contexts. Returns `null` when no path row (or document) exists.
|
|
451
|
+
*/
|
|
452
|
+
async getCurrentPath({ collection_id, document_id, }) {
|
|
453
|
+
const [row] = await this.db
|
|
454
|
+
.select({
|
|
455
|
+
path: this.pathProjection(sql `${currentDocumentsView.document_id}`, undefined, sql `${currentDocumentsView.source_locale}`),
|
|
456
|
+
})
|
|
457
|
+
.from(currentDocumentsView)
|
|
458
|
+
.where(and(eq(currentDocumentsView.collection_id, collection_id), eq(currentDocumentsView.document_id, document_id)))
|
|
459
|
+
.limit(1);
|
|
460
|
+
return row?.path ?? null;
|
|
461
|
+
}
|
|
442
462
|
/**
|
|
443
463
|
* getDocumentById — gets the current version of a document by its logical document ID.
|
|
444
464
|
*
|
|
@@ -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
|
+
});
|
|
@@ -181,4 +181,81 @@ describe('byline_document_paths integration', () => {
|
|
|
181
181
|
});
|
|
182
182
|
expect(result).toBe(null);
|
|
183
183
|
});
|
|
184
|
+
describe('getCurrentPath', () => {
|
|
185
|
+
it('resolves a document’s canonical path under its default source locale', async () => {
|
|
186
|
+
const canonicalPath = `current-path-${Date.now()}`;
|
|
187
|
+
const created = await commandBuilders.documents.createDocumentVersion({
|
|
188
|
+
collectionId: testCollection.id,
|
|
189
|
+
collectionVersion: 1,
|
|
190
|
+
collectionConfig: PathsCollectionConfig,
|
|
191
|
+
action: 'create',
|
|
192
|
+
documentData: { title: 'Has Path' },
|
|
193
|
+
path: canonicalPath,
|
|
194
|
+
locale: 'all',
|
|
195
|
+
status: 'draft',
|
|
196
|
+
});
|
|
197
|
+
const documentId = created.document.document_id;
|
|
198
|
+
const path = await queryBuilders.documents.getCurrentPath({
|
|
199
|
+
collection_id: testCollection.id,
|
|
200
|
+
document_id: documentId,
|
|
201
|
+
});
|
|
202
|
+
expect(path).toBe(canonicalPath);
|
|
203
|
+
});
|
|
204
|
+
it('follows the source-locale anchor after a document is re-anchored', async () => {
|
|
205
|
+
const canonicalPath = `reanchor-path-${Date.now()}`;
|
|
206
|
+
// Create locale-agnostic content (ledger carries the 'all' sentinel) so
|
|
207
|
+
// the document is "complete" in any target and re-anchoring is eligible.
|
|
208
|
+
const created = await commandBuilders.documents.createDocumentVersion({
|
|
209
|
+
collectionId: testCollection.id,
|
|
210
|
+
collectionVersion: 1,
|
|
211
|
+
collectionConfig: PathsCollectionConfig,
|
|
212
|
+
action: 'create',
|
|
213
|
+
documentData: { title: 'Re-anchor me' },
|
|
214
|
+
path: canonicalPath,
|
|
215
|
+
locale: 'all',
|
|
216
|
+
status: 'draft',
|
|
217
|
+
});
|
|
218
|
+
const documentId = created.document.document_id;
|
|
219
|
+
// Flip the document's source locale from the default ('en') to 'fr'.
|
|
220
|
+
// reAnchorDocument moves the path row onto the new source locale,
|
|
221
|
+
// keeping the slug. getCurrentPath passes requestedLocale: undefined, so
|
|
222
|
+
// its fallback floor is COALESCE(source_locale, default) — it must now
|
|
223
|
+
// resolve via the 'fr' anchor, not the global default 'en'.
|
|
224
|
+
const result = await commandBuilders.documents.reAnchorDocument({
|
|
225
|
+
documentId,
|
|
226
|
+
targetLocale: 'fr',
|
|
227
|
+
});
|
|
228
|
+
expect(result.status).toBe('reanchored');
|
|
229
|
+
const path = await queryBuilders.documents.getCurrentPath({
|
|
230
|
+
collection_id: testCollection.id,
|
|
231
|
+
document_id: documentId,
|
|
232
|
+
});
|
|
233
|
+
expect(path).toBe(canonicalPath);
|
|
234
|
+
});
|
|
235
|
+
it('returns null when the document has no path row', async () => {
|
|
236
|
+
// Create a version without a `path` — no document_paths row is written.
|
|
237
|
+
const created = await commandBuilders.documents.createDocumentVersion({
|
|
238
|
+
collectionId: testCollection.id,
|
|
239
|
+
collectionVersion: 1,
|
|
240
|
+
collectionConfig: PathsCollectionConfig,
|
|
241
|
+
action: 'create',
|
|
242
|
+
documentData: { title: 'No Path' },
|
|
243
|
+
locale: 'all',
|
|
244
|
+
status: 'draft',
|
|
245
|
+
});
|
|
246
|
+
const documentId = created.document.document_id;
|
|
247
|
+
const path = await queryBuilders.documents.getCurrentPath({
|
|
248
|
+
collection_id: testCollection.id,
|
|
249
|
+
document_id: documentId,
|
|
250
|
+
});
|
|
251
|
+
expect(path).toBe(null);
|
|
252
|
+
});
|
|
253
|
+
it('returns null for a non-existent document', async () => {
|
|
254
|
+
const path = await queryBuilders.documents.getCurrentPath({
|
|
255
|
+
collection_id: testCollection.id,
|
|
256
|
+
document_id: crypto.randomUUID(),
|
|
257
|
+
});
|
|
258
|
+
expect(path).toBe(null);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
184
261
|
});
|
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.1.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/core": "3.0
|
|
61
|
-
"@byline/auth": "3.0
|
|
62
|
-
"@byline/admin": "3.0
|
|
60
|
+
"@byline/core": "3.1.0",
|
|
61
|
+
"@byline/auth": "3.1.0",
|
|
62
|
+
"@byline/admin": "3.1.0"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@biomejs/biome": "2.4.15",
|