@byline/core 3.0.1 → 3.1.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/@types/collection-types.d.ts +47 -1
- package/dist/@types/db-types.d.ts +33 -0
- package/dist/services/collection-bootstrap.test.node.js +4 -0
- package/dist/services/discover-counter-groups.test.node.js +2 -0
- package/dist/services/document-lifecycle.d.ts +36 -0
- package/dist/services/document-lifecycle.js +139 -2
- package/dist/services/document-lifecycle.test.node.js +76 -0
- package/dist/services/field-upload.test.node.js +2 -0
- package/dist/services/populate.test.node.js +2 -0
- package/package.json +2 -2
|
@@ -323,6 +323,12 @@ export interface BeforeCreateContext {
|
|
|
323
323
|
* Includes the `documentId` and `documentVersionId` returned by storage
|
|
324
324
|
* so the hook can reference the persisted document.
|
|
325
325
|
*
|
|
326
|
+
* `path` is the document's canonical (source-locale) routing path as
|
|
327
|
+
* written into `byline_document_paths` — the value a consumer needs to
|
|
328
|
+
* invalidate a cache key, purge a CDN URL, or fire a webhook against the
|
|
329
|
+
* specific document. (Resolved *after* `beforeCreate`, since that hook may
|
|
330
|
+
* mutate the source field that path derivation reads.)
|
|
331
|
+
*
|
|
326
332
|
* `duplicate` mirrors `BeforeCreateContext.duplicate` — present only when
|
|
327
333
|
* the create was triggered by `duplicateDocument`.
|
|
328
334
|
*/
|
|
@@ -331,6 +337,8 @@ export interface AfterCreateContext {
|
|
|
331
337
|
collectionPath: string;
|
|
332
338
|
documentId: string;
|
|
333
339
|
documentVersionId: string;
|
|
340
|
+
/** The document's canonical (source-locale) routing path. */
|
|
341
|
+
path: string;
|
|
334
342
|
duplicate?: {
|
|
335
343
|
sourceDocumentId: string;
|
|
336
344
|
};
|
|
@@ -360,6 +368,10 @@ export interface BeforeUpdateContext {
|
|
|
360
368
|
sourceLocale: string;
|
|
361
369
|
targetLocale: string;
|
|
362
370
|
};
|
|
371
|
+
/** Set only when the update originates from a Delete-Locale operation. */
|
|
372
|
+
deleteLocale?: {
|
|
373
|
+
locale: string;
|
|
374
|
+
};
|
|
363
375
|
}
|
|
364
376
|
/**
|
|
365
377
|
* Context passed to `afterUpdate` hooks.
|
|
@@ -367,6 +379,11 @@ export interface BeforeUpdateContext {
|
|
|
367
379
|
* Includes the `documentId` and `documentVersionId` of the newly created
|
|
368
380
|
* version so the hook can reference the persisted document.
|
|
369
381
|
*
|
|
382
|
+
* `path` is the document's canonical (source-locale) routing path after the
|
|
383
|
+
* update — surfaced explicitly so cache-invalidation / CDN-purge / webhook
|
|
384
|
+
* hooks need not dig it out of `originalData`. (Previously available only
|
|
385
|
+
* implicitly via `originalData.path`.)
|
|
386
|
+
*
|
|
370
387
|
* `restore` mirrors `BeforeUpdateContext.restore` — present only when the
|
|
371
388
|
* update was triggered by restoring a historical version.
|
|
372
389
|
* `copyToLocale` mirrors `BeforeUpdateContext.copyToLocale` — present only
|
|
@@ -378,6 +395,8 @@ export interface AfterUpdateContext {
|
|
|
378
395
|
collectionPath: string;
|
|
379
396
|
documentId: string;
|
|
380
397
|
documentVersionId: string;
|
|
398
|
+
/** The document's canonical (source-locale) routing path. */
|
|
399
|
+
path: string;
|
|
381
400
|
restore?: {
|
|
382
401
|
sourceVersionId: string;
|
|
383
402
|
};
|
|
@@ -385,40 +404,67 @@ export interface AfterUpdateContext {
|
|
|
385
404
|
sourceLocale: string;
|
|
386
405
|
targetLocale: string;
|
|
387
406
|
};
|
|
407
|
+
/** Mirrors `BeforeUpdateContext.deleteLocale`. */
|
|
408
|
+
deleteLocale?: {
|
|
409
|
+
locale: string;
|
|
410
|
+
};
|
|
388
411
|
}
|
|
389
412
|
/**
|
|
390
413
|
* Context passed to `beforeStatusChange` / `afterStatusChange` hooks.
|
|
414
|
+
*
|
|
415
|
+
* `path` is the document's canonical (source-locale) routing path — present
|
|
416
|
+
* so a status-change hook (publish → purge CDN, archive → drop cache key) can
|
|
417
|
+
* act on the specific document/URL rather than invalidating the whole
|
|
418
|
+
* collection.
|
|
391
419
|
*/
|
|
392
420
|
export interface StatusChangeContext {
|
|
393
421
|
documentId: string;
|
|
394
422
|
documentVersionId: string;
|
|
395
423
|
collectionPath: string;
|
|
424
|
+
/** The document's canonical (source-locale) routing path. */
|
|
425
|
+
path: string;
|
|
396
426
|
previousStatus: string;
|
|
397
427
|
nextStatus: string;
|
|
398
428
|
}
|
|
399
429
|
/**
|
|
400
430
|
* Context passed to `beforeUnpublish` hooks.
|
|
431
|
+
*
|
|
432
|
+
* `path` is the document's canonical (source-locale) routing path — present
|
|
433
|
+
* so an unpublish hook can target the specific document/URL.
|
|
401
434
|
*/
|
|
402
435
|
export interface BeforeUnpublishContext {
|
|
403
436
|
documentId: string;
|
|
404
437
|
collectionPath: string;
|
|
438
|
+
/** The document's canonical (source-locale) routing path. */
|
|
439
|
+
path: string;
|
|
405
440
|
}
|
|
406
441
|
/**
|
|
407
442
|
* Context passed to `afterUnpublish` hooks.
|
|
408
443
|
*
|
|
409
444
|
* `archivedCount` indicates how many published versions were archived.
|
|
445
|
+
*
|
|
446
|
+
* `path` is the document's canonical (source-locale) routing path — present
|
|
447
|
+
* so an unpublish hook can target the specific document/URL.
|
|
410
448
|
*/
|
|
411
449
|
export interface AfterUnpublishContext {
|
|
412
450
|
documentId: string;
|
|
413
451
|
collectionPath: string;
|
|
452
|
+
/** The document's canonical (source-locale) routing path. */
|
|
453
|
+
path: string;
|
|
414
454
|
archivedCount: number;
|
|
415
455
|
}
|
|
416
456
|
/**
|
|
417
|
-
* Context passed to `beforeDelete` / `afterDelete` hooks
|
|
457
|
+
* Context passed to `beforeDelete` / `afterDelete` hooks.
|
|
458
|
+
*
|
|
459
|
+
* `path` is the document's canonical (source-locale) routing path — present
|
|
460
|
+
* so a delete hook can purge the specific document/URL (cache key, CDN, search
|
|
461
|
+
* index) rather than the entire collection.
|
|
418
462
|
*/
|
|
419
463
|
export interface DeleteContext {
|
|
420
464
|
documentId: string;
|
|
421
465
|
collectionPath: string;
|
|
466
|
+
/** The document's canonical (source-locale) routing path. */
|
|
467
|
+
path: string;
|
|
422
468
|
}
|
|
423
469
|
/**
|
|
424
470
|
* Context passed to `beforeStore` hooks (configured on
|
|
@@ -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.
|
|
@@ -450,6 +468,21 @@ export interface IDocumentQueries {
|
|
|
450
468
|
created_at: Date;
|
|
451
469
|
updated_at: Date;
|
|
452
470
|
} | null>;
|
|
471
|
+
/**
|
|
472
|
+
* Resolve a document's canonical (source-locale) routing path.
|
|
473
|
+
*
|
|
474
|
+
* Returns the `byline_document_paths` row for the document under its own
|
|
475
|
+
* `source_locale` anchor (falling back to the configured default content
|
|
476
|
+
* locale for rows predating `source_locale`). Narrow by design — used by
|
|
477
|
+
* the lifecycle to populate `path` on the status-change / unpublish hook
|
|
478
|
+
* contexts without widening `getCurrentVersionMetadata`.
|
|
479
|
+
*
|
|
480
|
+
* Returns `null` when the document has no path row (or does not exist).
|
|
481
|
+
*/
|
|
482
|
+
getCurrentPath(params: {
|
|
483
|
+
collection_id: string;
|
|
484
|
+
document_id: string;
|
|
485
|
+
}): Promise<string | null>;
|
|
453
486
|
getDocumentByPath(params: {
|
|
454
487
|
collection_id: string;
|
|
455
488
|
path: string;
|
|
@@ -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: {
|
|
@@ -58,6 +59,7 @@ function createMockDb(options) {
|
|
|
58
59
|
documents: {
|
|
59
60
|
getDocumentById: vi.fn(fail),
|
|
60
61
|
getCurrentVersionMetadata: vi.fn(fail),
|
|
62
|
+
getCurrentPath: vi.fn(fail),
|
|
61
63
|
getDocumentByPath: vi.fn(fail),
|
|
62
64
|
getDocumentByVersion: vi.fn(fail),
|
|
63
65
|
getDocumentsByVersionIds: vi.fn(fail),
|
|
@@ -183,6 +185,7 @@ describe('ensureCollections', () => {
|
|
|
183
185
|
setDocumentStatus: vi.fn(),
|
|
184
186
|
archivePublishedVersions: vi.fn(),
|
|
185
187
|
softDeleteDocument: vi.fn(),
|
|
188
|
+
deleteDocumentLocale: vi.fn(),
|
|
186
189
|
setOrderKey: vi.fn(),
|
|
187
190
|
},
|
|
188
191
|
counters: {
|
|
@@ -199,6 +202,7 @@ describe('ensureCollections', () => {
|
|
|
199
202
|
documents: {
|
|
200
203
|
getDocumentById: vi.fn(),
|
|
201
204
|
getCurrentVersionMetadata: vi.fn(),
|
|
205
|
+
getCurrentPath: vi.fn(),
|
|
202
206
|
getDocumentByPath: vi.fn(),
|
|
203
207
|
getDocumentByVersion: vi.fn(),
|
|
204
208
|
getDocumentsByVersionIds: vi.fn(),
|
|
@@ -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: {
|
|
@@ -43,6 +44,7 @@ function makeAdapter(options) {
|
|
|
43
44
|
documents: {
|
|
44
45
|
getDocumentById: vi.fn(fail),
|
|
45
46
|
getCurrentVersionMetadata: vi.fn(fail),
|
|
47
|
+
getCurrentPath: vi.fn(fail),
|
|
46
48
|
getDocumentByPath: vi.fn(fail),
|
|
47
49
|
getDocumentByVersion: vi.fn(fail),
|
|
48
50
|
getDocumentsByVersionIds: vi.fn(fail),
|
|
@@ -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;
|
|
@@ -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>;
|
|
@@ -255,6 +255,7 @@ export async function createDocument(ctx, params) {
|
|
|
255
255
|
collectionPath,
|
|
256
256
|
documentId,
|
|
257
257
|
documentVersionId,
|
|
258
|
+
path: resolvedPath,
|
|
258
259
|
});
|
|
259
260
|
return { documentId, documentVersionId };
|
|
260
261
|
});
|
|
@@ -340,6 +341,7 @@ export async function updateDocument(ctx, params) {
|
|
|
340
341
|
collectionPath,
|
|
341
342
|
documentId,
|
|
342
343
|
documentVersionId,
|
|
344
|
+
path: pathForCommand ?? originalData.path,
|
|
343
345
|
});
|
|
344
346
|
return { documentId, documentVersionId };
|
|
345
347
|
});
|
|
@@ -453,6 +455,7 @@ export async function updateDocumentWithPatches(ctx, params) {
|
|
|
453
455
|
collectionPath,
|
|
454
456
|
documentId,
|
|
455
457
|
documentVersionId,
|
|
458
|
+
path: pathForCommand ?? originalData.path,
|
|
456
459
|
});
|
|
457
460
|
return { documentId, documentVersionId };
|
|
458
461
|
});
|
|
@@ -514,10 +517,18 @@ export async function changeDocumentStatus(ctx, params) {
|
|
|
514
517
|
details: { currentStatus, nextStatus: params.nextStatus },
|
|
515
518
|
}).log(ctx.logger);
|
|
516
519
|
}
|
|
520
|
+
// Resolve the document's canonical path so the hooks can act on the
|
|
521
|
+
// specific document/URL (CDN purge, cache-key drop). Narrow lookup —
|
|
522
|
+
// getCurrentVersionMetadata deliberately omits the path subquery.
|
|
523
|
+
const path = (await ctx.db.queries.documents.getCurrentPath({
|
|
524
|
+
collection_id: collectionId,
|
|
525
|
+
document_id: params.documentId,
|
|
526
|
+
})) ?? '';
|
|
517
527
|
const hookCtx = {
|
|
518
528
|
documentId: params.documentId,
|
|
519
529
|
documentVersionId,
|
|
520
530
|
collectionPath,
|
|
531
|
+
path,
|
|
521
532
|
previousStatus: currentStatus,
|
|
522
533
|
nextStatus: params.nextStatus,
|
|
523
534
|
};
|
|
@@ -550,7 +561,7 @@ export async function changeDocumentStatus(ctx, params) {
|
|
|
550
561
|
*/
|
|
551
562
|
export async function unpublishDocument(ctx, params) {
|
|
552
563
|
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'unpublishDocument' }, async () => {
|
|
553
|
-
const { db, collectionPath, definition } = ctx;
|
|
564
|
+
const { db, collectionId, collectionPath, definition } = ctx;
|
|
554
565
|
// Unpublish is a workflow transition out of `published` — reuse the
|
|
555
566
|
// changeStatus gate rather than a separate ability.
|
|
556
567
|
assertActorCanPerform(ctx.requestContext, collectionPath, 'changeStatus');
|
|
@@ -563,9 +574,16 @@ export async function unpublishDocument(ctx, params) {
|
|
|
563
574
|
}).log(ctx.logger);
|
|
564
575
|
}
|
|
565
576
|
const hooks = definition.hooks;
|
|
577
|
+
// Resolve the document's canonical path so the hooks can target the
|
|
578
|
+
// specific document/URL (CDN purge, cache-key drop).
|
|
579
|
+
const path = (await db.queries.documents.getCurrentPath({
|
|
580
|
+
collection_id: collectionId,
|
|
581
|
+
document_id: params.documentId,
|
|
582
|
+
})) ?? '';
|
|
566
583
|
await invokeHook(hooks?.beforeUnpublish, {
|
|
567
584
|
documentId: params.documentId,
|
|
568
585
|
collectionPath,
|
|
586
|
+
path,
|
|
569
587
|
});
|
|
570
588
|
const archivedCount = await db.commands.documents.archivePublishedVersions({
|
|
571
589
|
document_id: params.documentId,
|
|
@@ -573,6 +591,7 @@ export async function unpublishDocument(ctx, params) {
|
|
|
573
591
|
await invokeHook(hooks?.afterUnpublish, {
|
|
574
592
|
documentId: params.documentId,
|
|
575
593
|
collectionPath,
|
|
594
|
+
path,
|
|
576
595
|
archivedCount,
|
|
577
596
|
});
|
|
578
597
|
return { archivedCount };
|
|
@@ -709,13 +728,15 @@ export async function restoreDocumentVersion(ctx, params) {
|
|
|
709
728
|
});
|
|
710
729
|
const documentId = extractDocumentId(result.document) || params.documentId;
|
|
711
730
|
const documentVersionId = extractVersionId(result.document);
|
|
712
|
-
// 7. afterUpdate.
|
|
731
|
+
// 7. afterUpdate. Restore is path-sticky: the canonical path comes
|
|
732
|
+
// from the current version's envelope (originalData), not the source.
|
|
713
733
|
await invokeHook(hooks?.afterUpdate, {
|
|
714
734
|
data: sourceFields,
|
|
715
735
|
originalData,
|
|
716
736
|
collectionPath,
|
|
717
737
|
documentId,
|
|
718
738
|
documentVersionId,
|
|
739
|
+
path: originalData.path ?? '',
|
|
719
740
|
restore: restoreContext,
|
|
720
741
|
});
|
|
721
742
|
return {
|
|
@@ -797,6 +818,11 @@ export async function deleteDocument(ctx, params) {
|
|
|
797
818
|
const hookCtx = {
|
|
798
819
|
documentId: params.documentId,
|
|
799
820
|
collectionPath,
|
|
821
|
+
// The current document was fetched above (reconstructed only for
|
|
822
|
+
// upload collections, but the envelope carries the locale-resolved
|
|
823
|
+
// `path` projection either way). Surface it so delete hooks can purge
|
|
824
|
+
// the specific document/URL.
|
|
825
|
+
path: latest.path ?? '',
|
|
800
826
|
};
|
|
801
827
|
// 2. beforeDelete hook.
|
|
802
828
|
await invokeHook(hooks?.beforeDelete, hookCtx);
|
|
@@ -1041,6 +1067,7 @@ export async function duplicateDocument(ctx, params) {
|
|
|
1041
1067
|
collectionPath,
|
|
1042
1068
|
documentId: newDocumentId,
|
|
1043
1069
|
documentVersionId: newDocumentVersionId,
|
|
1070
|
+
path: finalPath,
|
|
1044
1071
|
duplicate: duplicateMarker,
|
|
1045
1072
|
});
|
|
1046
1073
|
return {
|
|
@@ -1330,6 +1357,9 @@ export async function copyToLocale(ctx, params) {
|
|
|
1330
1357
|
collectionPath,
|
|
1331
1358
|
documentId: params.documentId,
|
|
1332
1359
|
documentVersionId,
|
|
1360
|
+
// Path is sticky and source-locale-anchored; copy-to-locale never
|
|
1361
|
+
// touches it. Read it off the target envelope.
|
|
1362
|
+
path: targetRecord.path ?? '',
|
|
1333
1363
|
copyToLocale: copyToLocaleMarker,
|
|
1334
1364
|
});
|
|
1335
1365
|
return {
|
|
@@ -1341,3 +1371,110 @@ export async function copyToLocale(ctx, params) {
|
|
|
1341
1371
|
};
|
|
1342
1372
|
});
|
|
1343
1373
|
}
|
|
1374
|
+
// ---------------------------------------------------------------------------
|
|
1375
|
+
// deleteLocale
|
|
1376
|
+
// ---------------------------------------------------------------------------
|
|
1377
|
+
/**
|
|
1378
|
+
* Remove one content locale's data from a document, in place on the same
|
|
1379
|
+
* document, by writing a new immutable version that omits that locale's
|
|
1380
|
+
* store rows (every other locale and all non-localized `'all'` rows are
|
|
1381
|
+
* carried forward by the storage primitive).
|
|
1382
|
+
*
|
|
1383
|
+
* The default content locale is the document's anchor (path + source_locale)
|
|
1384
|
+
* and can never be removed — rejected up front. The new version lands as the
|
|
1385
|
+
* workflow's default status (a fresh draft), exactly like `copyToLocale`: the
|
|
1386
|
+
* previously-published version keeps serving — including the locale being
|
|
1387
|
+
* removed — until the new version is reviewed and published. The deletion is
|
|
1388
|
+
* recoverable: the prior version still holds the locale, so restoring it
|
|
1389
|
+
* brings the content back.
|
|
1390
|
+
*
|
|
1391
|
+
* Flow:
|
|
1392
|
+
* 1. `assertActorCanPerform('update')` — removing a translation is an edit.
|
|
1393
|
+
* 2. Reject `locale === defaultLocale`.
|
|
1394
|
+
* 3. Read the document in the target locale (validates existence; supplies
|
|
1395
|
+
* `originalData` for hooks and the availability set for the presence
|
|
1396
|
+
* check).
|
|
1397
|
+
* 4. Reject when the locale has no content to delete.
|
|
1398
|
+
* 5. `hooks.beforeUpdate({ …, deleteLocale: { locale } })`.
|
|
1399
|
+
* 6. `db.commands.documents.deleteDocumentLocale({ …, status: default })`.
|
|
1400
|
+
* 7. `hooks.afterUpdate({ …, deleteLocale: { locale } })`.
|
|
1401
|
+
*/
|
|
1402
|
+
export async function deleteLocale(ctx, params) {
|
|
1403
|
+
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'deleteLocale' }, async () => {
|
|
1404
|
+
const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
|
|
1405
|
+
assertActorCanPerform(ctx.requestContext, collectionPath, 'update');
|
|
1406
|
+
// The default locale anchors the document's path and source_locale —
|
|
1407
|
+
// it cannot be deleted (the other locales fall back to it).
|
|
1408
|
+
if (params.locale === defaultLocale) {
|
|
1409
|
+
throw ERR_VALIDATION({
|
|
1410
|
+
message: `cannot delete the default content locale ('${defaultLocale}')`,
|
|
1411
|
+
details: { documentId: params.documentId, locale: params.locale, collectionPath },
|
|
1412
|
+
}).log(ctx.logger);
|
|
1413
|
+
}
|
|
1414
|
+
// Read the document in the locale being removed — validates existence,
|
|
1415
|
+
// supplies originalData for hooks, and the availability set for the
|
|
1416
|
+
// content-presence check below.
|
|
1417
|
+
const target = await db.queries.documents.getDocumentById({
|
|
1418
|
+
collection_id: collectionId,
|
|
1419
|
+
document_id: params.documentId,
|
|
1420
|
+
locale: params.locale,
|
|
1421
|
+
reconstruct: true,
|
|
1422
|
+
lenient: true,
|
|
1423
|
+
requestContext: ctx.requestContext,
|
|
1424
|
+
});
|
|
1425
|
+
if (target == null) {
|
|
1426
|
+
throw ERR_NOT_FOUND({
|
|
1427
|
+
message: 'document not found',
|
|
1428
|
+
details: { documentId: params.documentId, collectionPath },
|
|
1429
|
+
}).log(ctx.logger);
|
|
1430
|
+
}
|
|
1431
|
+
const targetRecord = target;
|
|
1432
|
+
// `_availableVersionLocales` is the derived (path-coverage) set; it is
|
|
1433
|
+
// the same source the editor's Delete-Locale picker is built from, so a
|
|
1434
|
+
// locale offered in the UI resolves here. A partially-translated locale
|
|
1435
|
+
// that never reached full coverage is not deletable through this path.
|
|
1436
|
+
const available = targetRecord._availableVersionLocales ?? [];
|
|
1437
|
+
if (!available.includes(params.locale)) {
|
|
1438
|
+
throw ERR_NOT_FOUND({
|
|
1439
|
+
message: `locale '${params.locale}' has no content to delete`,
|
|
1440
|
+
details: { documentId: params.documentId, locale: params.locale, collectionPath },
|
|
1441
|
+
}).log(ctx.logger);
|
|
1442
|
+
}
|
|
1443
|
+
const hooks = definition.hooks;
|
|
1444
|
+
const deleteLocaleMarker = { locale: params.locale };
|
|
1445
|
+
const originalData = targetRecord.fields ?? {};
|
|
1446
|
+
await invokeHook(hooks?.beforeUpdate, {
|
|
1447
|
+
data: originalData,
|
|
1448
|
+
originalData,
|
|
1449
|
+
collectionPath,
|
|
1450
|
+
deleteLocale: deleteLocaleMarker,
|
|
1451
|
+
});
|
|
1452
|
+
const result = await db.commands.documents.deleteDocumentLocale({
|
|
1453
|
+
documentId: params.documentId,
|
|
1454
|
+
locale: params.locale,
|
|
1455
|
+
status: getDefaultStatus(definition),
|
|
1456
|
+
});
|
|
1457
|
+
if (result == null) {
|
|
1458
|
+
throw ERR_NOT_FOUND({
|
|
1459
|
+
message: 'document not found',
|
|
1460
|
+
details: { documentId: params.documentId, collectionPath },
|
|
1461
|
+
}).log(ctx.logger);
|
|
1462
|
+
}
|
|
1463
|
+
await invokeHook(hooks?.afterUpdate, {
|
|
1464
|
+
data: originalData,
|
|
1465
|
+
originalData,
|
|
1466
|
+
collectionPath,
|
|
1467
|
+
documentId: params.documentId,
|
|
1468
|
+
documentVersionId: result.newVersionId,
|
|
1469
|
+
// Path is sticky and source-locale-anchored; deleting a translation
|
|
1470
|
+
// never touches it. Read it off the target envelope.
|
|
1471
|
+
path: targetRecord.path ?? '',
|
|
1472
|
+
deleteLocale: deleteLocaleMarker,
|
|
1473
|
+
});
|
|
1474
|
+
return {
|
|
1475
|
+
documentId: params.documentId,
|
|
1476
|
+
documentVersionId: result.newVersionId,
|
|
1477
|
+
locale: params.locale,
|
|
1478
|
+
};
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
@@ -35,6 +35,7 @@ function createMockDb() {
|
|
|
35
35
|
const softDeleteDocument = vi.fn().mockResolvedValue(1);
|
|
36
36
|
const getDocumentById = vi.fn().mockResolvedValue(null);
|
|
37
37
|
const getCurrentVersionMetadata = vi.fn().mockResolvedValue(null);
|
|
38
|
+
const getCurrentPath = vi.fn().mockResolvedValue('current-path');
|
|
38
39
|
const db = {
|
|
39
40
|
commands: {
|
|
40
41
|
collections: {
|
|
@@ -47,6 +48,7 @@ function createMockDb() {
|
|
|
47
48
|
setDocumentStatus,
|
|
48
49
|
archivePublishedVersions,
|
|
49
50
|
softDeleteDocument,
|
|
51
|
+
deleteDocumentLocale: vi.fn(),
|
|
50
52
|
setOrderKey: vi.fn(),
|
|
51
53
|
},
|
|
52
54
|
counters: {
|
|
@@ -63,6 +65,7 @@ function createMockDb() {
|
|
|
63
65
|
documents: {
|
|
64
66
|
getDocumentById,
|
|
65
67
|
getCurrentVersionMetadata,
|
|
68
|
+
getCurrentPath,
|
|
66
69
|
getDocumentByPath: vi.fn(),
|
|
67
70
|
getDocumentByVersion: vi.fn(),
|
|
68
71
|
getDocumentsByVersionIds: vi.fn(),
|
|
@@ -86,6 +89,7 @@ function createMockDb() {
|
|
|
86
89
|
softDeleteDocument,
|
|
87
90
|
getDocumentById,
|
|
88
91
|
getCurrentVersionMetadata,
|
|
92
|
+
getCurrentPath,
|
|
89
93
|
};
|
|
90
94
|
}
|
|
91
95
|
const noopLogger = {
|
|
@@ -164,6 +168,14 @@ describe('Document lifecycle service', () => {
|
|
|
164
168
|
documentVersionId: 'ver-1',
|
|
165
169
|
}));
|
|
166
170
|
});
|
|
171
|
+
it('afterCreate receives the resolved canonical path', async () => {
|
|
172
|
+
const afterCreate = vi.fn();
|
|
173
|
+
const { db } = createMockDb();
|
|
174
|
+
const definition = { ...minimalCollection, hooks: { afterCreate } };
|
|
175
|
+
const ctx = buildCtx(db, definition);
|
|
176
|
+
await createDocument(ctx, { data: { title: 'X' }, path: 'my-explicit-path' });
|
|
177
|
+
expect(afterCreate).toHaveBeenCalledWith(expect.objectContaining({ path: 'my-explicit-path' }));
|
|
178
|
+
});
|
|
167
179
|
it('beforeCreate can mutate data before persistence', async () => {
|
|
168
180
|
const { db, createDocumentVersion } = createMockDb();
|
|
169
181
|
const definition = {
|
|
@@ -327,6 +339,36 @@ describe('Document lifecycle service', () => {
|
|
|
327
339
|
documentVersionId: 'ver-1',
|
|
328
340
|
}));
|
|
329
341
|
});
|
|
342
|
+
it('afterUpdate carries the sticky path forward when none is supplied', async () => {
|
|
343
|
+
const afterUpdate = vi.fn();
|
|
344
|
+
const { db, getDocumentById } = createMockDb();
|
|
345
|
+
getDocumentById.mockResolvedValue({
|
|
346
|
+
document_version_id: 'prev-ver',
|
|
347
|
+
path: 'sticky-path',
|
|
348
|
+
fields: { title: 'Old' },
|
|
349
|
+
});
|
|
350
|
+
const definition = { ...minimalCollection, hooks: { afterUpdate } };
|
|
351
|
+
const ctx = buildCtx(db, definition);
|
|
352
|
+
await updateDocument(ctx, { documentId: 'doc-1', data: { title: 'New' } });
|
|
353
|
+
expect(afterUpdate).toHaveBeenCalledWith(expect.objectContaining({ path: 'sticky-path' }));
|
|
354
|
+
});
|
|
355
|
+
it('afterUpdate surfaces an explicitly-supplied path', async () => {
|
|
356
|
+
const afterUpdate = vi.fn();
|
|
357
|
+
const { db, getDocumentById } = createMockDb();
|
|
358
|
+
getDocumentById.mockResolvedValue({
|
|
359
|
+
document_version_id: 'prev-ver',
|
|
360
|
+
path: 'sticky-path',
|
|
361
|
+
fields: { title: 'Old' },
|
|
362
|
+
});
|
|
363
|
+
const definition = { ...minimalCollection, hooks: { afterUpdate } };
|
|
364
|
+
const ctx = buildCtx(db, definition);
|
|
365
|
+
await updateDocument(ctx, {
|
|
366
|
+
documentId: 'doc-1',
|
|
367
|
+
data: { title: 'New' },
|
|
368
|
+
path: 'new-path',
|
|
369
|
+
});
|
|
370
|
+
expect(afterUpdate).toHaveBeenCalledWith(expect.objectContaining({ path: 'new-path' }));
|
|
371
|
+
});
|
|
330
372
|
it('does not pass path to the storage primitive when no explicit path is supplied', async () => {
|
|
331
373
|
const { db, getDocumentById, createDocumentVersion } = createMockDb();
|
|
332
374
|
getDocumentById.mockResolvedValue({
|
|
@@ -640,7 +682,9 @@ describe('Document lifecycle service', () => {
|
|
|
640
682
|
nextStatus: 'published',
|
|
641
683
|
documentId: 'doc-1',
|
|
642
684
|
documentVersionId: 'ver-1',
|
|
685
|
+
path: 'current-path',
|
|
643
686
|
}));
|
|
687
|
+
expect(hooks.afterStatusChange).toHaveBeenCalledWith(expect.objectContaining({ path: 'current-path' }));
|
|
644
688
|
});
|
|
645
689
|
it('does not invoke hooks when transition is invalid', async () => {
|
|
646
690
|
const hooks = {
|
|
@@ -727,9 +771,11 @@ describe('Document lifecycle service', () => {
|
|
|
727
771
|
const ctx = buildCtx(db, definition);
|
|
728
772
|
await unpublishDocument(ctx, { documentId: 'doc-1' });
|
|
729
773
|
expect(callOrder).toEqual(['before', 'archive', 'after']);
|
|
774
|
+
expect(hooks.beforeUnpublish).toHaveBeenCalledWith(expect.objectContaining({ documentId: 'doc-1', path: 'current-path' }));
|
|
730
775
|
expect(hooks.afterUnpublish).toHaveBeenCalledWith(expect.objectContaining({
|
|
731
776
|
documentId: 'doc-1',
|
|
732
777
|
archivedCount: 2,
|
|
778
|
+
path: 'current-path',
|
|
733
779
|
}));
|
|
734
780
|
});
|
|
735
781
|
it('works when no hooks are defined', async () => {
|
|
@@ -771,6 +817,27 @@ describe('Document lifecycle service', () => {
|
|
|
771
817
|
});
|
|
772
818
|
});
|
|
773
819
|
// -----------------------------------------------------------------------
|
|
820
|
+
// deleteDocument
|
|
821
|
+
// -----------------------------------------------------------------------
|
|
822
|
+
describe('deleteDocument', () => {
|
|
823
|
+
it('invokes beforeDelete / afterDelete with the document path', async () => {
|
|
824
|
+
const beforeDelete = vi.fn();
|
|
825
|
+
const afterDelete = vi.fn();
|
|
826
|
+
const { db, getDocumentById } = createMockDb();
|
|
827
|
+
getDocumentById.mockResolvedValue({
|
|
828
|
+
document_version_id: 'ver-1',
|
|
829
|
+
document_id: 'doc-1',
|
|
830
|
+
path: 'doc-to-delete',
|
|
831
|
+
fields: {},
|
|
832
|
+
});
|
|
833
|
+
const definition = { ...minimalCollection, hooks: { beforeDelete, afterDelete } };
|
|
834
|
+
const ctx = buildCtx(db, definition);
|
|
835
|
+
await deleteDocument(ctx, { documentId: 'doc-1' });
|
|
836
|
+
expect(beforeDelete).toHaveBeenCalledWith(expect.objectContaining({ documentId: 'doc-1', path: 'doc-to-delete' }));
|
|
837
|
+
expect(afterDelete).toHaveBeenCalledWith(expect.objectContaining({ documentId: 'doc-1', path: 'doc-to-delete' }));
|
|
838
|
+
});
|
|
839
|
+
});
|
|
840
|
+
// -----------------------------------------------------------------------
|
|
774
841
|
// restoreDocumentVersion
|
|
775
842
|
// -----------------------------------------------------------------------
|
|
776
843
|
describe('restoreDocumentVersion', () => {
|
|
@@ -915,6 +982,8 @@ describe('Document lifecycle service', () => {
|
|
|
915
982
|
expect(afterUpdate).toHaveBeenCalledWith(expect.objectContaining({
|
|
916
983
|
documentId: 'doc-1',
|
|
917
984
|
documentVersionId: 'ver-restored',
|
|
985
|
+
// Sticky path comes from the current version's envelope, not the source.
|
|
986
|
+
path: 'sticky-path',
|
|
918
987
|
restore: { sourceVersionId },
|
|
919
988
|
}));
|
|
920
989
|
});
|
|
@@ -1194,6 +1263,11 @@ describe('Document lifecycle service', () => {
|
|
|
1194
1263
|
expect(afterCtx.duplicate).toEqual({ sourceDocumentId: 'doc-source' });
|
|
1195
1264
|
expect(afterCtx.documentId).toBe('doc-new');
|
|
1196
1265
|
expect(afterCtx.documentVersionId).toBe('ver-new');
|
|
1266
|
+
// afterCreate carries the final path written for the duplicate — the
|
|
1267
|
+
// same value handed to createDocumentVersion.
|
|
1268
|
+
const writtenPath = mocks.createDocumentVersion.mock.calls[0]?.[0]?.path;
|
|
1269
|
+
expect(typeof afterCtx.path).toBe('string');
|
|
1270
|
+
expect(afterCtx.path).toBe(writtenPath);
|
|
1197
1271
|
});
|
|
1198
1272
|
it('enforces collections.<path>.create — rejects an admin actor missing the ability', async () => {
|
|
1199
1273
|
const mocks = createMockDb();
|
|
@@ -1483,6 +1557,8 @@ describe('Document lifecycle service', () => {
|
|
|
1483
1557
|
sourceLocale: 'en',
|
|
1484
1558
|
targetLocale: 'fr',
|
|
1485
1559
|
});
|
|
1560
|
+
// Sticky path read off the target-locale envelope.
|
|
1561
|
+
expect(afterUpdate.mock.calls[0]?.[0].path).toBe('hello');
|
|
1486
1562
|
});
|
|
1487
1563
|
it('enforces collections.<path>.update — rejects an admin actor missing the ability', async () => {
|
|
1488
1564
|
const { mocks } = setupSourceTarget();
|
|
@@ -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: {
|
|
@@ -72,6 +73,7 @@ function createMockDb() {
|
|
|
72
73
|
documents: {
|
|
73
74
|
getDocumentById: vi.fn(),
|
|
74
75
|
getCurrentVersionMetadata: vi.fn(),
|
|
76
|
+
getCurrentPath: vi.fn(),
|
|
75
77
|
getDocumentByPath: vi.fn(),
|
|
76
78
|
getDocumentByVersion: vi.fn(),
|
|
77
79
|
getDocumentsByVersionIds: vi.fn(),
|
|
@@ -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: {
|
|
@@ -160,6 +161,7 @@ function makeMockAdapter(store = {}, pathByCollectionId = {}) {
|
|
|
160
161
|
documents: {
|
|
161
162
|
getDocumentById: vi.fn(),
|
|
162
163
|
getCurrentVersionMetadata: vi.fn(),
|
|
164
|
+
getCurrentPath: vi.fn(),
|
|
163
165
|
getDocumentByPath: vi.fn(),
|
|
164
166
|
getDocumentByVersion: vi.fn(),
|
|
165
167
|
getDocumentsByVersionIds: vi.fn(),
|
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.1.0",
|
|
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.1.0"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
85
|
"@biomejs/biome": "2.4.15",
|