@byline/db-postgres 3.2.0 → 3.3.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 +60 -0
- package/dist/modules/storage/storage-commands.js +105 -24
- package/dist/modules/storage/tests/storage-system-fields-direct-write.test.d.ts +8 -0
- package/dist/modules/storage/tests/storage-system-fields-direct-write.test.js +151 -0
- package/package.json +4 -4
|
@@ -129,6 +129,66 @@ export declare class DocumentCommands implements IDocumentCommands {
|
|
|
129
129
|
};
|
|
130
130
|
fieldCount: number;
|
|
131
131
|
}>;
|
|
132
|
+
/**
|
|
133
|
+
* writeDocumentPath
|
|
134
|
+
*
|
|
135
|
+
* Upsert the `byline_document_paths` row for a (document, locale) pair. The
|
|
136
|
+
* path row is document-grain and sticky across versions — it lives under the
|
|
137
|
+
* document's `source_locale` (its data anchor), not the mutable global
|
|
138
|
+
* default. Shared by `createDocumentVersion` (step 2a, create write path) and
|
|
139
|
+
* the standalone `updateDocumentPath` command (the non-versioned admin path
|
|
140
|
+
* widget write). The unique constraint on `(collection_id, locale, path)` may
|
|
141
|
+
* raise a `23505`, which the lifecycle layer maps to `ERR_PATH_CONFLICT`.
|
|
142
|
+
*/
|
|
143
|
+
private writeDocumentPath;
|
|
144
|
+
/**
|
|
145
|
+
* writeDocumentAvailableLocales
|
|
146
|
+
*
|
|
147
|
+
* Replace a document's `byline_document_available_locales` rows wholesale —
|
|
148
|
+
* the editorial advertised-locale set. Document-grain and sticky across
|
|
149
|
+
* versions: `delete`-then-`insert`, deduplicated so a caller-supplied
|
|
150
|
+
* duplicate doesn't collide on the `(document_id, locale)` primary key. An
|
|
151
|
+
* empty array clears the set (advertise nothing). Shared by
|
|
152
|
+
* `createDocumentVersion` (step 2b, create write path) and the standalone
|
|
153
|
+
* `setDocumentAvailableLocales` command (the non-versioned admin
|
|
154
|
+
* available-locales widget write). See docs/I18N.md.
|
|
155
|
+
*/
|
|
156
|
+
private writeDocumentAvailableLocales;
|
|
157
|
+
/**
|
|
158
|
+
* updateDocumentPath
|
|
159
|
+
*
|
|
160
|
+
* Standalone, non-versioned write of a document's URL path. Backs the admin
|
|
161
|
+
* path widget's direct-write Save path: it edits `byline_document_paths`
|
|
162
|
+
* in-place (document-grain, sticky) **without** minting a new document
|
|
163
|
+
* version or touching workflow status. The path's document-grain nature means
|
|
164
|
+
* the change is immediate and applies across every version of the document.
|
|
165
|
+
*
|
|
166
|
+
* Source-locale enforcement and `ERR_PATH_CONFLICT` mapping live in the
|
|
167
|
+
* lifecycle service that calls this; the command itself only performs the
|
|
168
|
+
* upsert (and surfaces the raw `23505` for the service to translate).
|
|
169
|
+
*/
|
|
170
|
+
updateDocumentPath(params: {
|
|
171
|
+
documentId: string;
|
|
172
|
+
collectionId: string;
|
|
173
|
+
locale: string;
|
|
174
|
+
path: string;
|
|
175
|
+
}): Promise<void>;
|
|
176
|
+
/**
|
|
177
|
+
* setDocumentAvailableLocales
|
|
178
|
+
*
|
|
179
|
+
* Standalone, non-versioned write of a document's editorial advertised-locale
|
|
180
|
+
* set. Backs the admin available-locales widget's direct-write Save path: it
|
|
181
|
+
* replaces `byline_document_available_locales` wholesale (document-grain)
|
|
182
|
+
* **without** minting a new document version or touching workflow status. The
|
|
183
|
+
* change is immediate and applies across every version of the document; the
|
|
184
|
+
* public advertised set remains the intersection with the resolved version's
|
|
185
|
+
* completeness ledger. See docs/I18N.md.
|
|
186
|
+
*/
|
|
187
|
+
setDocumentAvailableLocales(params: {
|
|
188
|
+
documentId: string;
|
|
189
|
+
collectionId: string;
|
|
190
|
+
availableLocales: string[];
|
|
191
|
+
}): Promise<void>;
|
|
132
192
|
/**
|
|
133
193
|
* writeVersionLocaleLedger
|
|
134
194
|
*
|
|
@@ -123,21 +123,11 @@ export class DocumentCommands {
|
|
|
123
123
|
// (collection_id, locale, path) bubble up as a Postgres error which the
|
|
124
124
|
// lifecycle wraps as ERR_PATH_CONFLICT.
|
|
125
125
|
if (params.path !== undefined) {
|
|
126
|
-
await tx
|
|
127
|
-
|
|
128
|
-
.values({
|
|
129
|
-
document_id: documentId,
|
|
126
|
+
await this.writeDocumentPath(tx, {
|
|
127
|
+
documentId,
|
|
130
128
|
locale: sourceLocale,
|
|
131
|
-
|
|
129
|
+
collectionId: params.collectionId,
|
|
132
130
|
path: params.path,
|
|
133
|
-
})
|
|
134
|
-
.onConflictDoUpdate({
|
|
135
|
-
target: [documentPaths.document_id, documentPaths.locale],
|
|
136
|
-
set: {
|
|
137
|
-
path: params.path,
|
|
138
|
-
collection_id: params.collectionId,
|
|
139
|
-
updated_at: new Date(),
|
|
140
|
-
},
|
|
141
131
|
});
|
|
142
132
|
}
|
|
143
133
|
// 2b. Replace the document_available_locales rows when an editorial set
|
|
@@ -147,17 +137,11 @@ export class DocumentCommands {
|
|
|
147
137
|
// included — replaces it wholesale. Deduplicated so a caller-supplied
|
|
148
138
|
// duplicate doesn't collide on the (document_id, locale) primary key.
|
|
149
139
|
if (params.availableLocales !== undefined) {
|
|
150
|
-
await tx
|
|
151
|
-
|
|
152
|
-
.
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
await tx.insert(documentAvailableLocales).values(locales.map((locale) => ({
|
|
156
|
-
document_id: documentId,
|
|
157
|
-
locale,
|
|
158
|
-
collection_id: params.collectionId,
|
|
159
|
-
})));
|
|
160
|
-
}
|
|
140
|
+
await this.writeDocumentAvailableLocales(tx, {
|
|
141
|
+
documentId,
|
|
142
|
+
collectionId: params.collectionId,
|
|
143
|
+
availableLocales: params.availableLocales,
|
|
144
|
+
});
|
|
161
145
|
}
|
|
162
146
|
// 3. Flatten the document data to field values
|
|
163
147
|
const flattenedFields = flattenFieldSetData(params.collectionConfig.fields, params.documentData, params.locale ?? 'all');
|
|
@@ -279,6 +263,103 @@ export class DocumentCommands {
|
|
|
279
263
|
};
|
|
280
264
|
});
|
|
281
265
|
}
|
|
266
|
+
/**
|
|
267
|
+
* writeDocumentPath
|
|
268
|
+
*
|
|
269
|
+
* Upsert the `byline_document_paths` row for a (document, locale) pair. The
|
|
270
|
+
* path row is document-grain and sticky across versions — it lives under the
|
|
271
|
+
* document's `source_locale` (its data anchor), not the mutable global
|
|
272
|
+
* default. Shared by `createDocumentVersion` (step 2a, create write path) and
|
|
273
|
+
* the standalone `updateDocumentPath` command (the non-versioned admin path
|
|
274
|
+
* widget write). The unique constraint on `(collection_id, locale, path)` may
|
|
275
|
+
* raise a `23505`, which the lifecycle layer maps to `ERR_PATH_CONFLICT`.
|
|
276
|
+
*/
|
|
277
|
+
async writeDocumentPath(tx, args) {
|
|
278
|
+
await tx
|
|
279
|
+
.insert(documentPaths)
|
|
280
|
+
.values({
|
|
281
|
+
document_id: args.documentId,
|
|
282
|
+
locale: args.locale,
|
|
283
|
+
collection_id: args.collectionId,
|
|
284
|
+
path: args.path,
|
|
285
|
+
})
|
|
286
|
+
.onConflictDoUpdate({
|
|
287
|
+
target: [documentPaths.document_id, documentPaths.locale],
|
|
288
|
+
set: {
|
|
289
|
+
path: args.path,
|
|
290
|
+
collection_id: args.collectionId,
|
|
291
|
+
updated_at: new Date(),
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* writeDocumentAvailableLocales
|
|
297
|
+
*
|
|
298
|
+
* Replace a document's `byline_document_available_locales` rows wholesale —
|
|
299
|
+
* the editorial advertised-locale set. Document-grain and sticky across
|
|
300
|
+
* versions: `delete`-then-`insert`, deduplicated so a caller-supplied
|
|
301
|
+
* duplicate doesn't collide on the `(document_id, locale)` primary key. An
|
|
302
|
+
* empty array clears the set (advertise nothing). Shared by
|
|
303
|
+
* `createDocumentVersion` (step 2b, create write path) and the standalone
|
|
304
|
+
* `setDocumentAvailableLocales` command (the non-versioned admin
|
|
305
|
+
* available-locales widget write). See docs/I18N.md.
|
|
306
|
+
*/
|
|
307
|
+
async writeDocumentAvailableLocales(tx, args) {
|
|
308
|
+
await tx
|
|
309
|
+
.delete(documentAvailableLocales)
|
|
310
|
+
.where(eq(documentAvailableLocales.document_id, args.documentId));
|
|
311
|
+
const locales = [...new Set(args.availableLocales)];
|
|
312
|
+
if (locales.length > 0) {
|
|
313
|
+
await tx.insert(documentAvailableLocales).values(locales.map((locale) => ({
|
|
314
|
+
document_id: args.documentId,
|
|
315
|
+
locale,
|
|
316
|
+
collection_id: args.collectionId,
|
|
317
|
+
})));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* updateDocumentPath
|
|
322
|
+
*
|
|
323
|
+
* Standalone, non-versioned write of a document's URL path. Backs the admin
|
|
324
|
+
* path widget's direct-write Save path: it edits `byline_document_paths`
|
|
325
|
+
* in-place (document-grain, sticky) **without** minting a new document
|
|
326
|
+
* version or touching workflow status. The path's document-grain nature means
|
|
327
|
+
* the change is immediate and applies across every version of the document.
|
|
328
|
+
*
|
|
329
|
+
* Source-locale enforcement and `ERR_PATH_CONFLICT` mapping live in the
|
|
330
|
+
* lifecycle service that calls this; the command itself only performs the
|
|
331
|
+
* upsert (and surfaces the raw `23505` for the service to translate).
|
|
332
|
+
*/
|
|
333
|
+
async updateDocumentPath(params) {
|
|
334
|
+
await this.db.transaction(async (tx) => {
|
|
335
|
+
await this.writeDocumentPath(tx, {
|
|
336
|
+
documentId: params.documentId,
|
|
337
|
+
locale: params.locale,
|
|
338
|
+
collectionId: params.collectionId,
|
|
339
|
+
path: params.path,
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* setDocumentAvailableLocales
|
|
345
|
+
*
|
|
346
|
+
* Standalone, non-versioned write of a document's editorial advertised-locale
|
|
347
|
+
* set. Backs the admin available-locales widget's direct-write Save path: it
|
|
348
|
+
* replaces `byline_document_available_locales` wholesale (document-grain)
|
|
349
|
+
* **without** minting a new document version or touching workflow status. The
|
|
350
|
+
* change is immediate and applies across every version of the document; the
|
|
351
|
+
* public advertised set remains the intersection with the resolved version's
|
|
352
|
+
* completeness ledger. See docs/I18N.md.
|
|
353
|
+
*/
|
|
354
|
+
async setDocumentAvailableLocales(params) {
|
|
355
|
+
await this.db.transaction(async (tx) => {
|
|
356
|
+
await this.writeDocumentAvailableLocales(tx, {
|
|
357
|
+
documentId: params.documentId,
|
|
358
|
+
collectionId: params.collectionId,
|
|
359
|
+
availableLocales: params.availableLocales,
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
}
|
|
282
363
|
/**
|
|
283
364
|
* writeVersionLocaleLedger
|
|
284
365
|
*
|
|
@@ -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,151 @@
|
|
|
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 DirectWriteCollectionConfig = {
|
|
14
|
+
path: `direct-write-${timestamp}`,
|
|
15
|
+
labels: { singular: 'DirectWriteTest', plural: 'DirectWriteTests' },
|
|
16
|
+
useAsPath: 'title',
|
|
17
|
+
fields: [{ name: 'title', type: 'text', localized: true }],
|
|
18
|
+
};
|
|
19
|
+
let testCollection = {};
|
|
20
|
+
function readById(documentId) {
|
|
21
|
+
return queryBuilders.documents.getDocumentById({
|
|
22
|
+
collection_id: testCollection.id,
|
|
23
|
+
document_id: documentId,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
describe('non-versioned system-field commands (direct write)', () => {
|
|
27
|
+
beforeAll(async () => {
|
|
28
|
+
const testDB = setupTestDB([DirectWriteCollectionConfig]);
|
|
29
|
+
commandBuilders = testDB.commandBuilders;
|
|
30
|
+
queryBuilders = testDB.queryBuilders;
|
|
31
|
+
const result = await commandBuilders.collections.create(DirectWriteCollectionConfig.path, DirectWriteCollectionConfig);
|
|
32
|
+
const collection = result[0];
|
|
33
|
+
if (collection == null) {
|
|
34
|
+
throw new Error('Failed to create test collection');
|
|
35
|
+
}
|
|
36
|
+
testCollection = { id: collection.id, name: collection.path };
|
|
37
|
+
});
|
|
38
|
+
afterAll(async () => {
|
|
39
|
+
try {
|
|
40
|
+
await commandBuilders.collections.delete(testCollection.id);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
console.error('Failed to cleanup test collection:', error);
|
|
44
|
+
}
|
|
45
|
+
await teardownTestDB();
|
|
46
|
+
});
|
|
47
|
+
it('setDocumentAvailableLocales writes the set without a new version or status change', async () => {
|
|
48
|
+
const created = await commandBuilders.documents.createDocumentVersion({
|
|
49
|
+
collectionId: testCollection.id,
|
|
50
|
+
collectionVersion: 1,
|
|
51
|
+
collectionConfig: DirectWriteCollectionConfig,
|
|
52
|
+
action: 'create',
|
|
53
|
+
documentData: { title: 'Advertise direct' },
|
|
54
|
+
path: 'advertise-direct',
|
|
55
|
+
availableLocales: ['en'],
|
|
56
|
+
locale: 'all',
|
|
57
|
+
status: 'published',
|
|
58
|
+
});
|
|
59
|
+
const documentId = created.document.document_id;
|
|
60
|
+
const before = await readById(documentId);
|
|
61
|
+
expect(before?.availableLocales).toEqual(['en']);
|
|
62
|
+
await commandBuilders.documents.setDocumentAvailableLocales({
|
|
63
|
+
documentId,
|
|
64
|
+
collectionId: testCollection.id,
|
|
65
|
+
availableLocales: ['en', 'fr'],
|
|
66
|
+
});
|
|
67
|
+
const after = await readById(documentId);
|
|
68
|
+
expect(after?.availableLocales, 'set rewritten').toEqual(['en', 'fr']);
|
|
69
|
+
expect(after?.document_version_id, 'no new version minted').toBe(before?.document_version_id);
|
|
70
|
+
expect(after?.status, 'status not reset to draft').toBe('published');
|
|
71
|
+
});
|
|
72
|
+
it('setDocumentAvailableLocales clears the set with an empty array, still no new version', async () => {
|
|
73
|
+
const created = await commandBuilders.documents.createDocumentVersion({
|
|
74
|
+
collectionId: testCollection.id,
|
|
75
|
+
collectionVersion: 1,
|
|
76
|
+
collectionConfig: DirectWriteCollectionConfig,
|
|
77
|
+
action: 'create',
|
|
78
|
+
documentData: { title: 'Advertise clear' },
|
|
79
|
+
path: 'advertise-clear',
|
|
80
|
+
availableLocales: ['en', 'fr'],
|
|
81
|
+
locale: 'all',
|
|
82
|
+
status: 'published',
|
|
83
|
+
});
|
|
84
|
+
const documentId = created.document.document_id;
|
|
85
|
+
const before = await readById(documentId);
|
|
86
|
+
await commandBuilders.documents.setDocumentAvailableLocales({
|
|
87
|
+
documentId,
|
|
88
|
+
collectionId: testCollection.id,
|
|
89
|
+
availableLocales: [],
|
|
90
|
+
});
|
|
91
|
+
const after = await readById(documentId);
|
|
92
|
+
expect(after?.availableLocales).toEqual([]);
|
|
93
|
+
expect(after?.document_version_id).toBe(before?.document_version_id);
|
|
94
|
+
expect(after?.status).toBe('published');
|
|
95
|
+
});
|
|
96
|
+
it('updateDocumentPath writes the path without a new version or status change', async () => {
|
|
97
|
+
const created = await commandBuilders.documents.createDocumentVersion({
|
|
98
|
+
collectionId: testCollection.id,
|
|
99
|
+
collectionVersion: 1,
|
|
100
|
+
collectionConfig: DirectWriteCollectionConfig,
|
|
101
|
+
action: 'create',
|
|
102
|
+
documentData: { title: 'Path direct' },
|
|
103
|
+
path: 'path-before',
|
|
104
|
+
locale: 'all',
|
|
105
|
+
status: 'published',
|
|
106
|
+
});
|
|
107
|
+
const documentId = created.document.document_id;
|
|
108
|
+
const before = await readById(documentId);
|
|
109
|
+
expect(before?.path).toBe('path-before');
|
|
110
|
+
await commandBuilders.documents.updateDocumentPath({
|
|
111
|
+
documentId,
|
|
112
|
+
collectionId: testCollection.id,
|
|
113
|
+
locale: 'en',
|
|
114
|
+
path: 'path-after',
|
|
115
|
+
});
|
|
116
|
+
const after = await readById(documentId);
|
|
117
|
+
expect(after?.path, 'path rewritten').toBe('path-after');
|
|
118
|
+
expect(after?.document_version_id, 'no new version minted').toBe(before?.document_version_id);
|
|
119
|
+
expect(after?.status, 'status not reset to draft').toBe('published');
|
|
120
|
+
});
|
|
121
|
+
it('updateDocumentPath raises on a colliding path (unique constraint)', async () => {
|
|
122
|
+
const a = await commandBuilders.documents.createDocumentVersion({
|
|
123
|
+
collectionId: testCollection.id,
|
|
124
|
+
collectionVersion: 1,
|
|
125
|
+
collectionConfig: DirectWriteCollectionConfig,
|
|
126
|
+
action: 'create',
|
|
127
|
+
documentData: { title: 'Collision A' },
|
|
128
|
+
path: 'collision-taken',
|
|
129
|
+
locale: 'all',
|
|
130
|
+
status: 'published',
|
|
131
|
+
});
|
|
132
|
+
const b = await commandBuilders.documents.createDocumentVersion({
|
|
133
|
+
collectionId: testCollection.id,
|
|
134
|
+
collectionVersion: 1,
|
|
135
|
+
collectionConfig: DirectWriteCollectionConfig,
|
|
136
|
+
action: 'create',
|
|
137
|
+
documentData: { title: 'Collision B' },
|
|
138
|
+
path: 'collision-free',
|
|
139
|
+
locale: 'all',
|
|
140
|
+
status: 'published',
|
|
141
|
+
});
|
|
142
|
+
expect(a.document.document_id).toBeTruthy();
|
|
143
|
+
await expect(commandBuilders.documents.updateDocumentPath({
|
|
144
|
+
documentId: b.document.document_id,
|
|
145
|
+
collectionId: testCollection.id,
|
|
146
|
+
locale: 'en',
|
|
147
|
+
// Same (collection, locale) path as document A — must collide.
|
|
148
|
+
path: 'collision-taken',
|
|
149
|
+
})).rejects.toThrow();
|
|
150
|
+
});
|
|
151
|
+
});
|
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.
|
|
5
|
+
"version": "3.3.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/
|
|
61
|
-
"@byline/
|
|
62
|
-
"@byline/
|
|
60
|
+
"@byline/admin": "3.3.0",
|
|
61
|
+
"@byline/auth": "3.3.0",
|
|
62
|
+
"@byline/core": "3.3.0"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@biomejs/biome": "2.4.15",
|