@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.
@@ -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 (future).
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.1",
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.1"
82
+ "@byline/auth": "3.1.0"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@biomejs/biome": "2.4.15",