@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.
- package/dist/@types/collection-types.d.ts +11 -3
- package/dist/@types/collection-types.js +2 -2
- package/dist/@types/db-types.d.ts +21 -3
- package/dist/@types/site-config.d.ts +1 -1
- package/dist/core.js +1 -1
- package/dist/schemas/zod/builder.js +1 -1
- package/dist/services/collection-bootstrap.test.node.js +2 -0
- package/dist/services/discover-counter-groups.test.node.js +1 -0
- package/dist/services/document-lifecycle.d.ts +39 -3
- package/dist/services/document-lifecycle.js +105 -1
- package/dist/services/document-lifecycle.test.node.js +1 -0
- package/dist/services/field-upload.test.node.js +1 -0
- package/dist/services/populate.test.node.js +1 -0
- package/package.json +2 -2
|
@@ -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/
|
|
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.
|
|
868
|
-
*
|
|
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.
|
|
186
|
-
*
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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: {
|
|
@@ -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/
|
|
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/
|
|
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/
|
|
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/
|
|
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
|
+
}
|
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.
|
|
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.
|
|
82
|
+
"@byline/auth": "3.0.2"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
85
|
"@biomejs/biome": "2.4.15",
|