@byline/db-postgres 3.0.1 → 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.
|
@@ -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
|
*
|
|
@@ -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
|
+
});
|
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/
|
|
61
|
-
"@byline/auth": "3.0.
|
|
62
|
-
"@byline/
|
|
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",
|