@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.
@@ -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
- .insert(documentPaths)
128
- .values({
129
- document_id: documentId,
126
+ await this.writeDocumentPath(tx, {
127
+ documentId,
130
128
  locale: sourceLocale,
131
- collection_id: params.collectionId,
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
- .delete(documentAvailableLocales)
152
- .where(eq(documentAvailableLocales.document_id, documentId));
153
- const locales = [...new Set(params.availableLocales)];
154
- if (locales.length > 0) {
155
- await tx.insert(documentAvailableLocales).values(locales.map((locale) => ({
156
- document_id: documentId,
157
- locale,
158
- collection_id: params.collectionId,
159
- })));
160
- }
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.2.0",
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/core": "3.2.0",
61
- "@byline/admin": "3.2.0",
62
- "@byline/auth": "3.2.0"
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",