@byline/core 3.0.2 → 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
  };
@@ -371,6 +379,11 @@ export interface BeforeUpdateContext {
371
379
  * Includes the `documentId` and `documentVersionId` of the newly created
372
380
  * version so the hook can reference the persisted document.
373
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
+ *
374
387
  * `restore` mirrors `BeforeUpdateContext.restore` — present only when the
375
388
  * update was triggered by restoring a historical version.
376
389
  * `copyToLocale` mirrors `BeforeUpdateContext.copyToLocale` — present only
@@ -382,6 +395,8 @@ export interface AfterUpdateContext {
382
395
  collectionPath: string;
383
396
  documentId: string;
384
397
  documentVersionId: string;
398
+ /** The document's canonical (source-locale) routing path. */
399
+ path: string;
385
400
  restore?: {
386
401
  sourceVersionId: string;
387
402
  };
@@ -396,37 +411,60 @@ export interface AfterUpdateContext {
396
411
  }
397
412
  /**
398
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.
399
419
  */
400
420
  export interface StatusChangeContext {
401
421
  documentId: string;
402
422
  documentVersionId: string;
403
423
  collectionPath: string;
424
+ /** The document's canonical (source-locale) routing path. */
425
+ path: string;
404
426
  previousStatus: string;
405
427
  nextStatus: string;
406
428
  }
407
429
  /**
408
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.
409
434
  */
410
435
  export interface BeforeUnpublishContext {
411
436
  documentId: string;
412
437
  collectionPath: string;
438
+ /** The document's canonical (source-locale) routing path. */
439
+ path: string;
413
440
  }
414
441
  /**
415
442
  * Context passed to `afterUnpublish` hooks.
416
443
  *
417
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.
418
448
  */
419
449
  export interface AfterUnpublishContext {
420
450
  documentId: string;
421
451
  collectionPath: string;
452
+ /** The document's canonical (source-locale) routing path. */
453
+ path: string;
422
454
  archivedCount: number;
423
455
  }
424
456
  /**
425
- * 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.
426
462
  */
427
463
  export interface DeleteContext {
428
464
  documentId: string;
429
465
  collectionPath: string;
466
+ /** The document's canonical (source-locale) routing path. */
467
+ path: string;
430
468
  }
431
469
  /**
432
470
  * Context passed to `beforeStore` hooks (configured on
@@ -468,6 +468,21 @@ export interface IDocumentQueries {
468
468
  created_at: Date;
469
469
  updated_at: Date;
470
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>;
471
486
  getDocumentByPath(params: {
472
487
  collection_id: string;
473
488
  path: string;
@@ -59,6 +59,7 @@ function createMockDb(options) {
59
59
  documents: {
60
60
  getDocumentById: vi.fn(fail),
61
61
  getCurrentVersionMetadata: vi.fn(fail),
62
+ getCurrentPath: vi.fn(fail),
62
63
  getDocumentByPath: vi.fn(fail),
63
64
  getDocumentByVersion: vi.fn(fail),
64
65
  getDocumentsByVersionIds: vi.fn(fail),
@@ -201,6 +202,7 @@ describe('ensureCollections', () => {
201
202
  documents: {
202
203
  getDocumentById: vi.fn(),
203
204
  getCurrentVersionMetadata: vi.fn(),
205
+ getCurrentPath: vi.fn(),
204
206
  getDocumentByPath: vi.fn(),
205
207
  getDocumentByVersion: vi.fn(),
206
208
  getDocumentsByVersionIds: vi.fn(),
@@ -44,6 +44,7 @@ function makeAdapter(options) {
44
44
  documents: {
45
45
  getDocumentById: vi.fn(fail),
46
46
  getCurrentVersionMetadata: vi.fn(fail),
47
+ getCurrentPath: vi.fn(fail),
47
48
  getDocumentByPath: vi.fn(fail),
48
49
  getDocumentByVersion: vi.fn(fail),
49
50
  getDocumentsByVersionIds: vi.fn(fail),
@@ -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 {
@@ -1436,6 +1466,9 @@ export async function deleteLocale(ctx, params) {
1436
1466
  collectionPath,
1437
1467
  documentId: params.documentId,
1438
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 ?? '',
1439
1472
  deleteLocale: deleteLocaleMarker,
1440
1473
  });
1441
1474
  return {
@@ -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: {
@@ -64,6 +65,7 @@ function createMockDb() {
64
65
  documents: {
65
66
  getDocumentById,
66
67
  getCurrentVersionMetadata,
68
+ getCurrentPath,
67
69
  getDocumentByPath: vi.fn(),
68
70
  getDocumentByVersion: vi.fn(),
69
71
  getDocumentsByVersionIds: vi.fn(),
@@ -87,6 +89,7 @@ function createMockDb() {
87
89
  softDeleteDocument,
88
90
  getDocumentById,
89
91
  getCurrentVersionMetadata,
92
+ getCurrentPath,
90
93
  };
91
94
  }
92
95
  const noopLogger = {
@@ -165,6 +168,14 @@ describe('Document lifecycle service', () => {
165
168
  documentVersionId: 'ver-1',
166
169
  }));
167
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
+ });
168
179
  it('beforeCreate can mutate data before persistence', async () => {
169
180
  const { db, createDocumentVersion } = createMockDb();
170
181
  const definition = {
@@ -328,6 +339,36 @@ describe('Document lifecycle service', () => {
328
339
  documentVersionId: 'ver-1',
329
340
  }));
330
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
+ });
331
372
  it('does not pass path to the storage primitive when no explicit path is supplied', async () => {
332
373
  const { db, getDocumentById, createDocumentVersion } = createMockDb();
333
374
  getDocumentById.mockResolvedValue({
@@ -641,7 +682,9 @@ describe('Document lifecycle service', () => {
641
682
  nextStatus: 'published',
642
683
  documentId: 'doc-1',
643
684
  documentVersionId: 'ver-1',
685
+ path: 'current-path',
644
686
  }));
687
+ expect(hooks.afterStatusChange).toHaveBeenCalledWith(expect.objectContaining({ path: 'current-path' }));
645
688
  });
646
689
  it('does not invoke hooks when transition is invalid', async () => {
647
690
  const hooks = {
@@ -728,9 +771,11 @@ describe('Document lifecycle service', () => {
728
771
  const ctx = buildCtx(db, definition);
729
772
  await unpublishDocument(ctx, { documentId: 'doc-1' });
730
773
  expect(callOrder).toEqual(['before', 'archive', 'after']);
774
+ expect(hooks.beforeUnpublish).toHaveBeenCalledWith(expect.objectContaining({ documentId: 'doc-1', path: 'current-path' }));
731
775
  expect(hooks.afterUnpublish).toHaveBeenCalledWith(expect.objectContaining({
732
776
  documentId: 'doc-1',
733
777
  archivedCount: 2,
778
+ path: 'current-path',
734
779
  }));
735
780
  });
736
781
  it('works when no hooks are defined', async () => {
@@ -772,6 +817,27 @@ describe('Document lifecycle service', () => {
772
817
  });
773
818
  });
774
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
+ // -----------------------------------------------------------------------
775
841
  // restoreDocumentVersion
776
842
  // -----------------------------------------------------------------------
777
843
  describe('restoreDocumentVersion', () => {
@@ -916,6 +982,8 @@ describe('Document lifecycle service', () => {
916
982
  expect(afterUpdate).toHaveBeenCalledWith(expect.objectContaining({
917
983
  documentId: 'doc-1',
918
984
  documentVersionId: 'ver-restored',
985
+ // Sticky path comes from the current version's envelope, not the source.
986
+ path: 'sticky-path',
919
987
  restore: { sourceVersionId },
920
988
  }));
921
989
  });
@@ -1195,6 +1263,11 @@ describe('Document lifecycle service', () => {
1195
1263
  expect(afterCtx.duplicate).toEqual({ sourceDocumentId: 'doc-source' });
1196
1264
  expect(afterCtx.documentId).toBe('doc-new');
1197
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);
1198
1271
  });
1199
1272
  it('enforces collections.<path>.create — rejects an admin actor missing the ability', async () => {
1200
1273
  const mocks = createMockDb();
@@ -1484,6 +1557,8 @@ describe('Document lifecycle service', () => {
1484
1557
  sourceLocale: 'en',
1485
1558
  targetLocale: 'fr',
1486
1559
  });
1560
+ // Sticky path read off the target-locale envelope.
1561
+ expect(afterUpdate.mock.calls[0]?.[0].path).toBe('hello');
1487
1562
  });
1488
1563
  it('enforces collections.<path>.update — rejects an admin actor missing the ability', async () => {
1489
1564
  const { mocks } = setupSourceTarget();
@@ -73,6 +73,7 @@ function createMockDb() {
73
73
  documents: {
74
74
  getDocumentById: vi.fn(),
75
75
  getCurrentVersionMetadata: vi.fn(),
76
+ getCurrentPath: vi.fn(),
76
77
  getDocumentByPath: vi.fn(),
77
78
  getDocumentByVersion: vi.fn(),
78
79
  getDocumentsByVersionIds: vi.fn(),
@@ -161,6 +161,7 @@ function makeMockAdapter(store = {}, pathByCollectionId = {}) {
161
161
  documents: {
162
162
  getDocumentById: vi.fn(),
163
163
  getCurrentVersionMetadata: vi.fn(),
164
+ getCurrentPath: vi.fn(),
164
165
  getDocumentByPath: vi.fn(),
165
166
  getDocumentByVersion: vi.fn(),
166
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.2",
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.2"
82
+ "@byline/auth": "3.1.0"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@biomejs/biome": "2.4.15",