@byline/core 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.
@@ -360,6 +360,10 @@ export interface BeforeUpdateContext {
360
360
  sourceLocale: string;
361
361
  targetLocale: string;
362
362
  };
363
+ /** Set only when the update originates from a Delete-Locale operation. */
364
+ deleteLocale?: {
365
+ locale: string;
366
+ };
363
367
  }
364
368
  /**
365
369
  * Context passed to `afterUpdate` hooks.
@@ -385,6 +389,10 @@ export interface AfterUpdateContext {
385
389
  sourceLocale: string;
386
390
  targetLocale: string;
387
391
  };
392
+ /** Mirrors `BeforeUpdateContext.deleteLocale`. */
393
+ deleteLocale?: {
394
+ locale: string;
395
+ };
388
396
  }
389
397
  /**
390
398
  * Context passed to `beforeStatusChange` / `afterStatusChange` hooks.
@@ -729,7 +737,7 @@ export interface CollectionDefinition {
729
737
  * collection has at least one `localized` field, so the validator rejects
730
738
  * `advertiseLocales: true` on a collection with none.
731
739
  *
732
- * See `docs/AVAILABLE-LOCALES.md`.
740
+ * See `docs/I18N.md`.
733
741
  */
734
742
  advertiseLocales?: boolean;
735
743
  /**
@@ -864,8 +872,8 @@ export type BlocksUnion<Bs extends readonly Block[]> = Bs[number] extends infer
864
872
  * on hand-authored fields without waiting for them to be placed inside a
865
873
  * `fields: [...]` array. Replaces the `as const satisfies Field` pattern.
866
874
  *
867
- * For factories that *generate* a field shape from input (e.g. mapped-type
868
- * driven fields like `availableLanguagesField`), a custom return type is
875
+ * For factories that *generate* a field shape from input (e.g. a helper whose
876
+ * return type is a mapped type over its options), a custom return type is
869
877
  * still the right tool — `defineField` is for identity / passthrough cases.
870
878
  *
871
879
  * The companion data-shape extractor `FieldData<F>` lives in
@@ -182,8 +182,8 @@ export function defineBlock(definition) {
182
182
  * on hand-authored fields without waiting for them to be placed inside a
183
183
  * `fields: [...]` array. Replaces the `as const satisfies Field` pattern.
184
184
  *
185
- * For factories that *generate* a field shape from input (e.g. mapped-type
186
- * driven fields like `availableLanguagesField`), a custom return type is
185
+ * For factories that *generate* a field shape from input (e.g. a helper whose
186
+ * return type is a mapped type over its options), a custom return type is
187
187
  * still the right tool — `defineField` is for identity / passthrough cases.
188
188
  *
189
189
  * The companion data-shape extractor `FieldData<F>` lives in
@@ -39,7 +39,7 @@ export type ReadMode = 'any' | 'published';
39
39
  * safe default for internal/direct reads); `@byline/client` defaults it to
40
40
  * `'fallback'` for application reads. Availability follows path-coverage against
41
41
  * the default content locale; a document with no localized content is available
42
- * in every locale. See `docs/CONTENT-LOCALE-RESOLUTION.md`.
42
+ * in every locale. See `docs/I18N.md`.
43
43
  */
44
44
  export type MissingLocalePolicy = 'empty' | 'fallback' | 'omit';
45
45
  /**
@@ -215,7 +215,7 @@ export interface IDbAdapter {
215
215
  * boot by `initBylineCore` so in-place upgrades self-heal without a manual
216
216
  * step or a migrate-ordering constraint — a no-op (zero rows) once every
217
217
  * document is stamped. Optional so adapters that don't model `source_locale`
218
- * need not implement it. See docs/DEFAULT-LOCALE-SWITCHING.md.
218
+ * need not implement it. See docs/I18N.md.
219
219
  */
220
220
  backfillSourceLocales?: () => Promise<{
221
221
  rowsUpdated: number;
@@ -315,7 +315,7 @@ export interface IDocumentCommands {
315
315
  * editorial advertised-locale set. `undefined` leaves the existing set
316
316
  * untouched (sticky across versions, like `path`); `[]` clears it. The
317
317
  * locale values are the advertised content locales themselves, not the
318
- * write locale. See `docs/AVAILABLE-LOCALES.md`.
318
+ * write locale. See `docs/I18N.md`.
319
319
  */
320
320
  availableLocales?: string[];
321
321
  locale?: string;
@@ -373,6 +373,24 @@ export interface IDocumentCommands {
373
373
  softDeleteDocument(params: {
374
374
  document_id: string;
375
375
  }): Promise<number>;
376
+ /**
377
+ * Remove one content locale's data from a document by writing a new
378
+ * immutable version that carries forward every store row except the target
379
+ * locale's (the `'all'` rows and all other locales are kept). The prior
380
+ * version still holds the deleted locale, so the operation is recoverable.
381
+ *
382
+ * `status` is the new version's status (the lifecycle service passes the
383
+ * workflow default — a fresh draft). Returns the new and previous version
384
+ * ids, or `null` when the document has no current version.
385
+ */
386
+ deleteDocumentLocale(params: {
387
+ documentId: string;
388
+ locale: string;
389
+ status?: string;
390
+ }): Promise<{
391
+ newVersionId: string;
392
+ previousVersionId: string;
393
+ } | null>;
376
394
  /**
377
395
  * Write the fractional-index `order_key` on a single `byline_documents`
378
396
  * row. Used by the reorder server fn for `orderable: true` collections.
@@ -63,7 +63,7 @@ export interface BaseConfig {
63
63
  * its path locale, and the completeness ledger — so changing this value
64
64
  * is safe for existing data: they keep reading against the locale they
65
65
  * were authored in. New documents created after the change anchor to the
66
- * new value. See docs/DEFAULT-LOCALE-SWITCHING.md.
66
+ * new value. See docs/I18N.md.
67
67
  *
68
68
  * (Switching this on a live system still needs the one-time
69
69
  * `backfillSourceLocales()` maintenance step to have stamped any rows
package/dist/core.js CHANGED
@@ -96,7 +96,7 @@ export const initBylineCore = async (config, pinoLogger) => {
96
96
  // and is a no-op (zero rows) once every document is stamped. Self-heals
97
97
  // in-place upgrades without a manual maintenance step. The write path stamps
98
98
  // new documents directly, so steady-state boots touch nothing. See
99
- // docs/DEFAULT-LOCALE-SWITCHING.md.
99
+ // docs/I18N.md.
100
100
  if (typeof composed.db.backfillSourceLocales === 'function') {
101
101
  const { rowsUpdated } = await composed.db.backfillSourceLocales();
102
102
  if (rowsUpdated > 0) {
@@ -197,7 +197,7 @@ export const createBaseSchema = (collection) => {
197
197
  id: z.uuid(),
198
198
  versionId: z.uuid().optional(),
199
199
  path: z.string().optional(),
200
- // The document's content source-locale anchor (see docs/DEFAULT-LOCALE-SWITCHING.md).
200
+ // The document's content source-locale anchor (see docs/I18N.md).
201
201
  // Carried through list/get responses so the admin can badge it; Zod would
202
202
  // otherwise strip it as an undeclared key.
203
203
  sourceLocale: z.string().optional(),
@@ -42,6 +42,7 @@ function createMockDb(options) {
42
42
  setDocumentStatus: vi.fn(fail),
43
43
  archivePublishedVersions: vi.fn(fail),
44
44
  softDeleteDocument: vi.fn(fail),
45
+ deleteDocumentLocale: vi.fn(fail),
45
46
  setOrderKey: vi.fn(fail),
46
47
  },
47
48
  counters: {
@@ -183,6 +184,7 @@ describe('ensureCollections', () => {
183
184
  setDocumentStatus: vi.fn(),
184
185
  archivePublishedVersions: vi.fn(),
185
186
  softDeleteDocument: vi.fn(),
187
+ deleteDocumentLocale: vi.fn(),
186
188
  setOrderKey: vi.fn(),
187
189
  },
188
190
  counters: {
@@ -27,6 +27,7 @@ function makeAdapter(options) {
27
27
  setDocumentStatus: vi.fn(fail),
28
28
  archivePublishedVersions: vi.fn(fail),
29
29
  softDeleteDocument: vi.fn(fail),
30
+ deleteDocumentLocale: vi.fn(fail),
30
31
  setOrderKey: vi.fn(fail),
31
32
  },
32
33
  counters: {
@@ -135,6 +135,13 @@ export interface CopyToLocaleResult {
135
135
  */
136
136
  fieldsUpdated: number;
137
137
  }
138
+ export interface DeleteLocaleResult {
139
+ documentId: string;
140
+ /** The newly-created version that omits the deleted locale's content. */
141
+ documentVersionId: string;
142
+ /** The content locale that was removed. */
143
+ locale: string;
144
+ }
138
145
  export interface DuplicateDocumentResult {
139
146
  /** The newly-created document's id. */
140
147
  documentId: string;
@@ -185,7 +192,7 @@ export declare function createDocument(ctx: DocumentLifecycleContext, params: {
185
192
  * sidebar widget). Document-grain and sticky like `path`: passed straight
186
193
  * to the storage primitive, which replaces the document's rows wholesale.
187
194
  * `undefined` writes nothing (a new document starts with an empty set —
188
- * the safe opt-in default); `[]` clears it. See docs/AVAILABLE-LOCALES.md.
195
+ * the safe opt-in default); `[]` clears it. See docs/I18N.md.
189
196
  */
190
197
  availableLocales?: string[];
191
198
  }): Promise<CreateDocumentResult>;
@@ -217,7 +224,7 @@ export declare function updateDocument(ctx: DocumentLifecycleContext, params: {
217
224
  * The editorial advertised-locale set. `undefined` leaves the existing
218
225
  * set untouched (sticky — document-grain, like `path`); an explicit array
219
226
  * (empty included) replaces it wholesale. Driven by the admin
220
- * available-locales sidebar widget. See docs/AVAILABLE-LOCALES.md.
227
+ * available-locales sidebar widget. See docs/I18N.md.
221
228
  */
222
229
  availableLocales?: string[];
223
230
  }): Promise<UpdateDocumentResult>;
@@ -252,7 +259,7 @@ export declare function updateDocumentWithPatches(ctx: DocumentLifecycleContext,
252
259
  * The editorial advertised-locale set (typically supplied alongside
253
260
  * patches when the admin available-locales widget has been edited).
254
261
  * `undefined` leaves the existing set untouched (sticky); an explicit
255
- * array replaces it wholesale. See docs/AVAILABLE-LOCALES.md.
262
+ * array replaces it wholesale. See docs/I18N.md.
256
263
  */
257
264
  availableLocales?: string[];
258
265
  }): Promise<UpdateDocumentWithPatchesResult>;
@@ -421,3 +428,32 @@ export declare function copyToLocale(ctx: DocumentLifecycleContext, params: {
421
428
  targetLocale: string;
422
429
  overwrite: boolean;
423
430
  }): Promise<CopyToLocaleResult>;
431
+ /**
432
+ * Remove one content locale's data from a document, in place on the same
433
+ * document, by writing a new immutable version that omits that locale's
434
+ * store rows (every other locale and all non-localized `'all'` rows are
435
+ * carried forward by the storage primitive).
436
+ *
437
+ * The default content locale is the document's anchor (path + source_locale)
438
+ * and can never be removed — rejected up front. The new version lands as the
439
+ * workflow's default status (a fresh draft), exactly like `copyToLocale`: the
440
+ * previously-published version keeps serving — including the locale being
441
+ * removed — until the new version is reviewed and published. The deletion is
442
+ * recoverable: the prior version still holds the locale, so restoring it
443
+ * brings the content back.
444
+ *
445
+ * Flow:
446
+ * 1. `assertActorCanPerform('update')` — removing a translation is an edit.
447
+ * 2. Reject `locale === defaultLocale`.
448
+ * 3. Read the document in the target locale (validates existence; supplies
449
+ * `originalData` for hooks and the availability set for the presence
450
+ * check).
451
+ * 4. Reject when the locale has no content to delete.
452
+ * 5. `hooks.beforeUpdate({ …, deleteLocale: { locale } })`.
453
+ * 6. `db.commands.documents.deleteDocumentLocale({ …, status: default })`.
454
+ * 7. `hooks.afterUpdate({ …, deleteLocale: { locale } })`.
455
+ */
456
+ export declare function deleteLocale(ctx: DocumentLifecycleContext, params: {
457
+ documentId: string;
458
+ locale: string;
459
+ }): Promise<DeleteLocaleResult>;
@@ -134,7 +134,7 @@ function resolvePathForUpdate(args) {
134
134
  // skip the write (existing path row stays as-is — sticky). The path row
135
135
  // lives under the document's source_locale (its anchor), not the mutable
136
136
  // global default — so this stays correct after the global default is
137
- // switched. See docs/DEFAULT-LOCALE-SWITCHING.md.
137
+ // switched. See docs/I18N.md.
138
138
  return explicitPath ?? undefined;
139
139
  }
140
140
  // Non-source-locale (translation) write: reject any path change with a warn
@@ -1341,3 +1341,107 @@ export async function copyToLocale(ctx, params) {
1341
1341
  };
1342
1342
  });
1343
1343
  }
1344
+ // ---------------------------------------------------------------------------
1345
+ // deleteLocale
1346
+ // ---------------------------------------------------------------------------
1347
+ /**
1348
+ * Remove one content locale's data from a document, in place on the same
1349
+ * document, by writing a new immutable version that omits that locale's
1350
+ * store rows (every other locale and all non-localized `'all'` rows are
1351
+ * carried forward by the storage primitive).
1352
+ *
1353
+ * The default content locale is the document's anchor (path + source_locale)
1354
+ * and can never be removed — rejected up front. The new version lands as the
1355
+ * workflow's default status (a fresh draft), exactly like `copyToLocale`: the
1356
+ * previously-published version keeps serving — including the locale being
1357
+ * removed — until the new version is reviewed and published. The deletion is
1358
+ * recoverable: the prior version still holds the locale, so restoring it
1359
+ * brings the content back.
1360
+ *
1361
+ * Flow:
1362
+ * 1. `assertActorCanPerform('update')` — removing a translation is an edit.
1363
+ * 2. Reject `locale === defaultLocale`.
1364
+ * 3. Read the document in the target locale (validates existence; supplies
1365
+ * `originalData` for hooks and the availability set for the presence
1366
+ * check).
1367
+ * 4. Reject when the locale has no content to delete.
1368
+ * 5. `hooks.beforeUpdate({ …, deleteLocale: { locale } })`.
1369
+ * 6. `db.commands.documents.deleteDocumentLocale({ …, status: default })`.
1370
+ * 7. `hooks.afterUpdate({ …, deleteLocale: { locale } })`.
1371
+ */
1372
+ export async function deleteLocale(ctx, params) {
1373
+ return withLogContext({ domain: 'services', module: 'lifecycle', function: 'deleteLocale' }, async () => {
1374
+ const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
1375
+ assertActorCanPerform(ctx.requestContext, collectionPath, 'update');
1376
+ // The default locale anchors the document's path and source_locale —
1377
+ // it cannot be deleted (the other locales fall back to it).
1378
+ if (params.locale === defaultLocale) {
1379
+ throw ERR_VALIDATION({
1380
+ message: `cannot delete the default content locale ('${defaultLocale}')`,
1381
+ details: { documentId: params.documentId, locale: params.locale, collectionPath },
1382
+ }).log(ctx.logger);
1383
+ }
1384
+ // Read the document in the locale being removed — validates existence,
1385
+ // supplies originalData for hooks, and the availability set for the
1386
+ // content-presence check below.
1387
+ const target = await db.queries.documents.getDocumentById({
1388
+ collection_id: collectionId,
1389
+ document_id: params.documentId,
1390
+ locale: params.locale,
1391
+ reconstruct: true,
1392
+ lenient: true,
1393
+ requestContext: ctx.requestContext,
1394
+ });
1395
+ if (target == null) {
1396
+ throw ERR_NOT_FOUND({
1397
+ message: 'document not found',
1398
+ details: { documentId: params.documentId, collectionPath },
1399
+ }).log(ctx.logger);
1400
+ }
1401
+ const targetRecord = target;
1402
+ // `_availableVersionLocales` is the derived (path-coverage) set; it is
1403
+ // the same source the editor's Delete-Locale picker is built from, so a
1404
+ // locale offered in the UI resolves here. A partially-translated locale
1405
+ // that never reached full coverage is not deletable through this path.
1406
+ const available = targetRecord._availableVersionLocales ?? [];
1407
+ if (!available.includes(params.locale)) {
1408
+ throw ERR_NOT_FOUND({
1409
+ message: `locale '${params.locale}' has no content to delete`,
1410
+ details: { documentId: params.documentId, locale: params.locale, collectionPath },
1411
+ }).log(ctx.logger);
1412
+ }
1413
+ const hooks = definition.hooks;
1414
+ const deleteLocaleMarker = { locale: params.locale };
1415
+ const originalData = targetRecord.fields ?? {};
1416
+ await invokeHook(hooks?.beforeUpdate, {
1417
+ data: originalData,
1418
+ originalData,
1419
+ collectionPath,
1420
+ deleteLocale: deleteLocaleMarker,
1421
+ });
1422
+ const result = await db.commands.documents.deleteDocumentLocale({
1423
+ documentId: params.documentId,
1424
+ locale: params.locale,
1425
+ status: getDefaultStatus(definition),
1426
+ });
1427
+ if (result == null) {
1428
+ throw ERR_NOT_FOUND({
1429
+ message: 'document not found',
1430
+ details: { documentId: params.documentId, collectionPath },
1431
+ }).log(ctx.logger);
1432
+ }
1433
+ await invokeHook(hooks?.afterUpdate, {
1434
+ data: originalData,
1435
+ originalData,
1436
+ collectionPath,
1437
+ documentId: params.documentId,
1438
+ documentVersionId: result.newVersionId,
1439
+ deleteLocale: deleteLocaleMarker,
1440
+ });
1441
+ return {
1442
+ documentId: params.documentId,
1443
+ documentVersionId: result.newVersionId,
1444
+ locale: params.locale,
1445
+ };
1446
+ });
1447
+ }
@@ -47,6 +47,7 @@ function createMockDb() {
47
47
  setDocumentStatus,
48
48
  archivePublishedVersions,
49
49
  softDeleteDocument,
50
+ deleteDocumentLocale: vi.fn(),
50
51
  setOrderKey: vi.fn(),
51
52
  },
52
53
  counters: {
@@ -56,6 +56,7 @@ function createMockDb() {
56
56
  setDocumentStatus: vi.fn(),
57
57
  archivePublishedVersions: vi.fn(),
58
58
  softDeleteDocument: vi.fn(),
59
+ deleteDocumentLocale: vi.fn(),
59
60
  setOrderKey: vi.fn(),
60
61
  },
61
62
  counters: {
@@ -144,6 +144,7 @@ function makeMockAdapter(store = {}, pathByCollectionId = {}) {
144
144
  setDocumentStatus: vi.fn(),
145
145
  archivePublishedVersions: vi.fn(),
146
146
  softDeleteDocument: vi.fn(),
147
+ deleteDocumentLocale: vi.fn(),
147
148
  setOrderKey: vi.fn(),
148
149
  },
149
150
  counters: {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/core",
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
  },
@@ -79,7 +79,7 @@
79
79
  "sharp": "^0.34.5",
80
80
  "uuid": "^14.0.0",
81
81
  "zod": "^4.4.3",
82
- "@byline/auth": "3.0.0"
82
+ "@byline/auth": "3.0.2"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@biomejs/biome": "2.4.15",