@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.
@@ -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/AVAILABLE-LOCALES.md.
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/CONTENT-LOCALE-RESOLUTION.md.
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/DEFAULT-LOCALE-SWITCHING.md.
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/CONTENT-LOCALE-RESOLUTION.md.
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/DEFAULT-LOCALE-SWITCHING.md.
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/DEFAULT-LOCALE-SWITCHING.md.
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/DEFAULT-LOCALE-SWITCHING.md.
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/AVAILABLE-LOCALES.md.
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/CONTENT-LOCALE-RESOLUTION.md and docs/DEFAULT-LOCALE-SWITCHING.md.
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/DEFAULT-LOCALE-SWITCHING.md.
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/DEFAULT-LOCALE-SWITCHING.md.
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/CONTENT-LOCALE-RESOLUTION.md.
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/DEFAULT-LOCALE-SWITCHING.md.
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/DEFAULT-LOCALE-SWITCHING.md.
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/CONTENT-LOCALE-RESOLUTION.md.
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/CONTENT-LOCALE-RESOLUTION.md and docs/DEFAULT-LOCALE-SWITCHING.md.
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/DEFAULT-LOCALE-SWITCHING.md.
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/DEFAULT-LOCALE-SWITCHING.md.
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/CONTENT-LOCALE-RESOLUTION.md.
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/DEFAULT-LOCALE-SWITCHING.md.
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/DEFAULT-LOCALE-SWITCHING.md.
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/AVAILABLE-LOCALES.md.
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/DEFAULT-LOCALE-SWITCHING.md.
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/AVAILABLE-LOCALES.md.
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.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.0",
61
- "@byline/auth": "3.0.0",
62
- "@byline/core": "3.0.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",