@byline/core 1.0.0 → 1.2.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.
@@ -320,21 +320,32 @@ export interface AfterCreateContext {
320
320
  documentVersionId: string;
321
321
  }
322
322
  /**
323
- * Context passed to `beforeUpdate` hooks — both PUT and patch flows.
323
+ * Context passed to `beforeUpdate` hooks — PUT, patch, and restore flows.
324
324
  *
325
325
  * `data` is the next version (mutable). `originalData` is the previous
326
326
  * version as reconstructed from storage.
327
+ *
328
+ * `restore` is set only when the update originates from a "make current"
329
+ * action against a historical version. Userland hooks that need to react
330
+ * differently (e.g. tag the audit entry, skip search re-index) can branch
331
+ * on its presence.
327
332
  */
328
333
  export interface BeforeUpdateContext {
329
334
  data: Record<string, any>;
330
335
  originalData: Record<string, any>;
331
336
  collectionPath: string;
337
+ restore?: {
338
+ sourceVersionId: string;
339
+ };
332
340
  }
333
341
  /**
334
342
  * Context passed to `afterUpdate` hooks.
335
343
  *
336
344
  * Includes the `documentId` and `documentVersionId` of the newly created
337
345
  * version so the hook can reference the persisted document.
346
+ *
347
+ * `restore` mirrors `BeforeUpdateContext.restore` — present only when the
348
+ * update was triggered by restoring a historical version.
338
349
  */
339
350
  export interface AfterUpdateContext {
340
351
  data: Record<string, any>;
@@ -342,6 +353,9 @@ export interface AfterUpdateContext {
342
353
  collectionPath: string;
343
354
  documentId: string;
344
355
  documentVersionId: string;
356
+ restore?: {
357
+ sourceVersionId: string;
358
+ };
345
359
  }
346
360
  /**
347
361
  * Context passed to `beforeStatusChange` / `afterStatusChange` hooks.
@@ -112,6 +112,11 @@ export interface UnpublishResult {
112
112
  export interface DeleteDocumentResult {
113
113
  deletedVersionCount: number;
114
114
  }
115
+ export interface RestoreVersionResult {
116
+ documentId: string;
117
+ documentVersionId: string;
118
+ sourceVersionId: string;
119
+ }
115
120
  /**
116
121
  * Create a new document.
117
122
  *
@@ -216,6 +221,50 @@ export declare function changeDocumentStatus(ctx: DocumentLifecycleContext, para
216
221
  export declare function unpublishDocument(ctx: DocumentLifecycleContext, params: {
217
222
  documentId: string;
218
223
  }): Promise<UnpublishResult>;
224
+ /**
225
+ * Restore a historical document version as the new current version.
226
+ *
227
+ * Reads the source version with `locale: 'all'` so the entire multi-locale
228
+ * field tree (with `_id` / `_type` meta inlined onto blocks and array items
229
+ * by `reconstructFromUnifiedRows`) is captured. That tree is then re-emitted
230
+ * through `createDocumentVersion` with `locale: 'all'`, which produces a
231
+ * fresh version row with a new UUIDv7 id and the latest `created_at`. The
232
+ * `current_documents` view (`ROW_NUMBER() OVER PARTITION BY document_id
233
+ * ORDER BY created_at DESC`) automatically promotes the new row to current.
234
+ *
235
+ * Status is hard-defaulted to the workflow's first status — restoring an
236
+ * old `published` version must never silently re-publish content. The user
237
+ * runs the restored draft through the normal workflow.
238
+ *
239
+ * `path` is sticky from the previous current version (not from the source),
240
+ * matching the semantics of `updateDocument`. A path change made between
241
+ * the source and now should not be undone by the restore.
242
+ *
243
+ * Auth reuses the `update` ability — restore is conceptually an edit
244
+ * against an existing document.
245
+ *
246
+ * Hooks: fires `beforeUpdate` / `afterUpdate` with a `restore: { sourceVersionId }`
247
+ * field on the context. Userland hooks that need to react differently
248
+ * (e.g. tag the audit entry, skip search re-index) can branch on its
249
+ * presence.
250
+ *
251
+ * Flow:
252
+ * 1. Auth: `update` ability.
253
+ * 2. Read source version with `locale: 'all'`.
254
+ * 3. Validate source belongs to `documentId` (defence against forged
255
+ * cross-document version ids).
256
+ * 4. Read current version metadata; reject if the source IS already the
257
+ * current version (nothing to restore).
258
+ * 5. Read current document with reconstruction for hook `originalData`.
259
+ * 6. `hooks.beforeUpdate({ data, originalData, collectionPath, restore })`
260
+ * 7. `db.commands.documents.createDocumentVersion(...)` with
261
+ * `action: 'restore'`, `locale: 'all'`, sticky path, default status.
262
+ * 8. `hooks.afterUpdate({ ..., restore })`
263
+ */
264
+ export declare function restoreDocumentVersion(ctx: DocumentLifecycleContext, params: {
265
+ documentId: string;
266
+ sourceVersionId: string;
267
+ }): Promise<RestoreVersionResult>;
219
268
  /**
220
269
  * Soft-delete a document.
221
270
  *
@@ -383,6 +383,146 @@ export async function unpublishDocument(ctx, params) {
383
383
  return { archivedCount };
384
384
  });
385
385
  }
386
+ /**
387
+ * Restore a historical document version as the new current version.
388
+ *
389
+ * Reads the source version with `locale: 'all'` so the entire multi-locale
390
+ * field tree (with `_id` / `_type` meta inlined onto blocks and array items
391
+ * by `reconstructFromUnifiedRows`) is captured. That tree is then re-emitted
392
+ * through `createDocumentVersion` with `locale: 'all'`, which produces a
393
+ * fresh version row with a new UUIDv7 id and the latest `created_at`. The
394
+ * `current_documents` view (`ROW_NUMBER() OVER PARTITION BY document_id
395
+ * ORDER BY created_at DESC`) automatically promotes the new row to current.
396
+ *
397
+ * Status is hard-defaulted to the workflow's first status — restoring an
398
+ * old `published` version must never silently re-publish content. The user
399
+ * runs the restored draft through the normal workflow.
400
+ *
401
+ * `path` is sticky from the previous current version (not from the source),
402
+ * matching the semantics of `updateDocument`. A path change made between
403
+ * the source and now should not be undone by the restore.
404
+ *
405
+ * Auth reuses the `update` ability — restore is conceptually an edit
406
+ * against an existing document.
407
+ *
408
+ * Hooks: fires `beforeUpdate` / `afterUpdate` with a `restore: { sourceVersionId }`
409
+ * field on the context. Userland hooks that need to react differently
410
+ * (e.g. tag the audit entry, skip search re-index) can branch on its
411
+ * presence.
412
+ *
413
+ * Flow:
414
+ * 1. Auth: `update` ability.
415
+ * 2. Read source version with `locale: 'all'`.
416
+ * 3. Validate source belongs to `documentId` (defence against forged
417
+ * cross-document version ids).
418
+ * 4. Read current version metadata; reject if the source IS already the
419
+ * current version (nothing to restore).
420
+ * 5. Read current document with reconstruction for hook `originalData`.
421
+ * 6. `hooks.beforeUpdate({ data, originalData, collectionPath, restore })`
422
+ * 7. `db.commands.documents.createDocumentVersion(...)` with
423
+ * `action: 'restore'`, `locale: 'all'`, sticky path, default status.
424
+ * 8. `hooks.afterUpdate({ ..., restore })`
425
+ */
426
+ export async function restoreDocumentVersion(ctx, params) {
427
+ return withLogContext({ domain: 'services', module: 'lifecycle', function: 'restoreDocumentVersion' }, async () => {
428
+ const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
429
+ assertActorCanPerform(ctx.requestContext, collectionPath, 'update');
430
+ const hooks = definition.hooks;
431
+ // 1. Read source version (full multi-locale tree).
432
+ const source = await db.queries.documents.getDocumentByVersion({
433
+ document_version_id: params.sourceVersionId,
434
+ locale: 'all',
435
+ });
436
+ if (source == null) {
437
+ throw ERR_NOT_FOUND({
438
+ message: 'source version not found',
439
+ details: { sourceVersionId: params.sourceVersionId },
440
+ }).log(ctx.logger);
441
+ }
442
+ // 2. Cross-document forgery check.
443
+ if (source.document_id !== params.documentId) {
444
+ throw ERR_VALIDATION({
445
+ message: 'source version does not belong to the target document',
446
+ details: {
447
+ documentId: params.documentId,
448
+ sourceVersionId: params.sourceVersionId,
449
+ sourceDocumentId: source.document_id,
450
+ },
451
+ }).log(ctx.logger);
452
+ }
453
+ // 3. Current version metadata — used both for the already-current
454
+ // guard and for the sticky path resolution.
455
+ const currentMeta = await db.queries.documents.getCurrentVersionMetadata({
456
+ collection_id: collectionId,
457
+ document_id: params.documentId,
458
+ });
459
+ if (currentMeta == null) {
460
+ throw ERR_NOT_FOUND({
461
+ message: 'document not found',
462
+ details: { documentId: params.documentId },
463
+ }).log(ctx.logger);
464
+ }
465
+ if (currentMeta.document_version_id === params.sourceVersionId) {
466
+ throw ERR_INVALID_TRANSITION({
467
+ message: 'source version is already the current version of this document',
468
+ details: {
469
+ documentId: params.documentId,
470
+ sourceVersionId: params.sourceVersionId,
471
+ },
472
+ }).log(ctx.logger);
473
+ }
474
+ // 4. originalData for hooks: full reconstruction of the current
475
+ // version (locale-scoped, matching updateDocument's semantics).
476
+ const latest = await db.queries.documents.getDocumentById({
477
+ collection_id: collectionId,
478
+ document_id: params.documentId,
479
+ locale: defaultLocale,
480
+ reconstruct: true,
481
+ });
482
+ const originalData = latest ?? {};
483
+ const sourceFields = source.fields ?? {};
484
+ const restoreContext = { sourceVersionId: params.sourceVersionId };
485
+ // 5. beforeUpdate.
486
+ await invokeHook(hooks?.beforeUpdate, {
487
+ data: sourceFields,
488
+ originalData,
489
+ collectionPath,
490
+ restore: restoreContext,
491
+ });
492
+ // 6. Persist new version. locale: 'all' carries every locale row in
493
+ // the source tree forward in a single flatten pass — the
494
+ // cross-locale carry-forward branch in createDocumentVersion does
495
+ // not fire when locale === 'all'.
496
+ const result = await db.commands.documents.createDocumentVersion({
497
+ documentId: params.documentId,
498
+ collectionId,
499
+ collectionVersion: ctx.collectionVersion,
500
+ collectionConfig: definition,
501
+ action: 'restore',
502
+ documentData: sourceFields,
503
+ path: currentMeta.path,
504
+ status: getDefaultStatus(definition),
505
+ locale: 'all',
506
+ previousVersionId: currentMeta.document_version_id,
507
+ });
508
+ const documentId = extractDocumentId(result.document) || params.documentId;
509
+ const documentVersionId = extractVersionId(result.document);
510
+ // 7. afterUpdate.
511
+ await invokeHook(hooks?.afterUpdate, {
512
+ data: sourceFields,
513
+ originalData,
514
+ collectionPath,
515
+ documentId,
516
+ documentVersionId,
517
+ restore: restoreContext,
518
+ });
519
+ return {
520
+ documentId,
521
+ documentVersionId,
522
+ sourceVersionId: params.sourceVersionId,
523
+ };
524
+ });
525
+ }
386
526
  /**
387
527
  * Soft-delete a document.
388
528
  *
@@ -8,7 +8,7 @@
8
8
  import { AdminAuth, AuthError, AuthErrorCodes, createRequestContext, createSuperAdminContext, } from '@byline/auth';
9
9
  import { describe, expect, it, vi } from 'vitest';
10
10
  import { BylineError, ErrorCodes } from '../lib/errors.js';
11
- import { changeDocumentStatus, createDocument, deleteDocument, unpublishDocument, updateDocument, updateDocumentWithPatches, } from './document-lifecycle.js';
11
+ import { changeDocumentStatus, createDocument, deleteDocument, restoreDocumentVersion, unpublishDocument, updateDocument, updateDocumentWithPatches, } from './document-lifecycle.js';
12
12
  // ---------------------------------------------------------------------------
13
13
  // Fixtures / Helpers
14
14
  // ---------------------------------------------------------------------------
@@ -648,6 +648,180 @@ describe('Document lifecycle service', () => {
648
648
  });
649
649
  });
650
650
  // -----------------------------------------------------------------------
651
+ // restoreDocumentVersion
652
+ // -----------------------------------------------------------------------
653
+ describe('restoreDocumentVersion', () => {
654
+ function setupRestore(opts) {
655
+ const sourceDocumentId = opts?.sourceDocumentId ?? 'doc-1';
656
+ const currentVersionId = opts?.currentVersionId ?? 'ver-current';
657
+ const sourceVersionId = 'ver-source';
658
+ const currentPath = opts?.currentPath ?? 'sticky-path';
659
+ const sourceFields = opts?.sourceFields ?? {
660
+ title: { en: 'Old EN', fr: 'Vieux FR' },
661
+ };
662
+ const mocks = createMockDb();
663
+ const { db } = mocks;
664
+ db.queries.documents.getDocumentByVersion.mockResolvedValue({
665
+ document_version_id: sourceVersionId,
666
+ document_id: sourceDocumentId,
667
+ path: 'long-ago-path',
668
+ status: 'archived',
669
+ fields: sourceFields,
670
+ });
671
+ mocks.getCurrentVersionMetadata.mockResolvedValue({
672
+ document_version_id: currentVersionId,
673
+ document_id: 'doc-1',
674
+ collection_id: 'col-1',
675
+ path: currentPath,
676
+ status: 'published',
677
+ created_at: new Date(),
678
+ updated_at: new Date(),
679
+ });
680
+ mocks.getDocumentById.mockResolvedValue({
681
+ document_version_id: currentVersionId,
682
+ path: currentPath,
683
+ status: 'published',
684
+ fields: { title: 'Currently Published' },
685
+ });
686
+ mocks.createDocumentVersion.mockResolvedValue({
687
+ document: { id: 'ver-restored', document_id: 'doc-1' },
688
+ fieldCount: 5,
689
+ });
690
+ return { ...mocks, sourceVersionId, currentVersionId, sourceFields, currentPath };
691
+ }
692
+ it('reads the source with locale: "all" and re-emits via createDocumentVersion', async () => {
693
+ const { db, createDocumentVersion, sourceVersionId, sourceFields, currentVersionId } = setupRestore();
694
+ const ctx = buildCtx(db);
695
+ const result = await restoreDocumentVersion(ctx, {
696
+ documentId: 'doc-1',
697
+ sourceVersionId,
698
+ });
699
+ expect(db.queries.documents.getDocumentByVersion).toHaveBeenCalledWith({
700
+ document_version_id: sourceVersionId,
701
+ locale: 'all',
702
+ });
703
+ expect(createDocumentVersion).toHaveBeenCalledOnce();
704
+ const call = createDocumentVersion.mock.calls[0]?.[0];
705
+ expect(call.action).toBe('restore');
706
+ expect(call.locale).toBe('all');
707
+ expect(call.documentData).toEqual(sourceFields);
708
+ expect(call.previousVersionId).toBe(currentVersionId);
709
+ expect(result).toEqual({
710
+ documentId: 'doc-1',
711
+ documentVersionId: 'ver-restored',
712
+ sourceVersionId,
713
+ });
714
+ });
715
+ it('hard-defaults the new version status to the workflow default (never inherits source status)', async () => {
716
+ const { db, createDocumentVersion, sourceVersionId } = setupRestore();
717
+ const ctx = buildCtx(db);
718
+ await restoreDocumentVersion(ctx, { documentId: 'doc-1', sourceVersionId });
719
+ // Source version was 'archived'; default status for minimalCollection is 'draft'.
720
+ expect(createDocumentVersion.mock.calls[0]?.[0].status).toBe('draft');
721
+ });
722
+ it('keeps path sticky from the current version, not the source', async () => {
723
+ const { db, createDocumentVersion, sourceVersionId } = setupRestore({
724
+ currentPath: 'sticky-path',
725
+ });
726
+ const ctx = buildCtx(db);
727
+ await restoreDocumentVersion(ctx, { documentId: 'doc-1', sourceVersionId });
728
+ expect(createDocumentVersion.mock.calls[0]?.[0].path).toBe('sticky-path');
729
+ });
730
+ it('rejects when the source version belongs to a different document', async () => {
731
+ const { db, sourceVersionId, createDocumentVersion } = setupRestore({
732
+ sourceDocumentId: 'doc-OTHER',
733
+ });
734
+ const ctx = buildCtx(db);
735
+ try {
736
+ await restoreDocumentVersion(ctx, { documentId: 'doc-1', sourceVersionId });
737
+ expect.fail('expected ERR_VALIDATION');
738
+ }
739
+ catch (err) {
740
+ expect(err).toBeInstanceOf(BylineError);
741
+ expect(err.code).toBe(ErrorCodes.VALIDATION);
742
+ }
743
+ expect(createDocumentVersion).not.toHaveBeenCalled();
744
+ });
745
+ it('rejects when the source is already the current version', async () => {
746
+ const sourceVersionId = 'ver-source';
747
+ const mocks = createMockDb();
748
+ const { db } = mocks;
749
+ db.queries.documents.getDocumentByVersion.mockResolvedValue({
750
+ document_version_id: sourceVersionId,
751
+ document_id: 'doc-1',
752
+ path: 'p',
753
+ status: 'draft',
754
+ fields: {},
755
+ });
756
+ mocks.getCurrentVersionMetadata.mockResolvedValue({
757
+ document_version_id: sourceVersionId, // SAME as source
758
+ document_id: 'doc-1',
759
+ collection_id: 'col-1',
760
+ path: 'p',
761
+ status: 'draft',
762
+ created_at: new Date(),
763
+ updated_at: new Date(),
764
+ });
765
+ const ctx = buildCtx(db);
766
+ try {
767
+ await restoreDocumentVersion(ctx, { documentId: 'doc-1', sourceVersionId });
768
+ expect.fail('expected ERR_INVALID_TRANSITION');
769
+ }
770
+ catch (err) {
771
+ expect(err.code).toBe(ErrorCodes.INVALID_TRANSITION);
772
+ }
773
+ expect(mocks.createDocumentVersion).not.toHaveBeenCalled();
774
+ });
775
+ it('fires beforeUpdate / afterUpdate with restore: { sourceVersionId } context', async () => {
776
+ const beforeUpdate = vi.fn();
777
+ const afterUpdate = vi.fn();
778
+ const { db, sourceVersionId } = setupRestore();
779
+ const definition = { ...minimalCollection, hooks: { beforeUpdate, afterUpdate } };
780
+ const ctx = buildCtx(db, definition);
781
+ await restoreDocumentVersion(ctx, { documentId: 'doc-1', sourceVersionId });
782
+ expect(beforeUpdate).toHaveBeenCalledWith(expect.objectContaining({
783
+ collectionPath: 'articles',
784
+ restore: { sourceVersionId },
785
+ originalData: expect.objectContaining({
786
+ fields: expect.objectContaining({ title: 'Currently Published' }),
787
+ }),
788
+ }));
789
+ expect(afterUpdate).toHaveBeenCalledWith(expect.objectContaining({
790
+ documentId: 'doc-1',
791
+ documentVersionId: 'ver-restored',
792
+ restore: { sourceVersionId },
793
+ }));
794
+ });
795
+ it('throws ERR_FORBIDDEN when actor lacks collections.<path>.update', async () => {
796
+ const { db, sourceVersionId } = setupRestore();
797
+ const ctx = buildCtx(db);
798
+ const actor = new AdminAuth({
799
+ id: 'editor',
800
+ abilities: ['collections.articles.read'],
801
+ });
802
+ ctx.requestContext = createRequestContext({ actor });
803
+ try {
804
+ await restoreDocumentVersion(ctx, { documentId: 'doc-1', sourceVersionId });
805
+ expect.fail('expected ERR_FORBIDDEN');
806
+ }
807
+ catch (err) {
808
+ expect(err.code).toBe(AuthErrorCodes.FORBIDDEN);
809
+ expect(err.message).toContain('collections.articles.update');
810
+ }
811
+ });
812
+ it('permits restore when actor holds only collections.<path>.update (no separate restore verb needed)', async () => {
813
+ const { db, createDocumentVersion, sourceVersionId } = setupRestore();
814
+ const ctx = buildCtx(db);
815
+ const actor = new AdminAuth({
816
+ id: 'editor',
817
+ abilities: ['collections.articles.update', 'collections.articles.read'],
818
+ });
819
+ ctx.requestContext = createRequestContext({ actor });
820
+ await restoreDocumentVersion(ctx, { documentId: 'doc-1', sourceVersionId });
821
+ expect(createDocumentVersion).toHaveBeenCalledOnce();
822
+ });
823
+ });
824
+ // -----------------------------------------------------------------------
651
825
  // Ability enforcement (Phase 4)
652
826
  // -----------------------------------------------------------------------
653
827
  describe('ability enforcement', () => {
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": "1.0.0",
5
+ "version": "1.2.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -73,7 +73,7 @@
73
73
  "pino": "^10.3.1",
74
74
  "uuid": "^14.0.0",
75
75
  "zod": "^4.4.2",
76
- "@byline/auth": "1.0.0"
76
+ "@byline/auth": "1.2.0"
77
77
  },
78
78
  "devDependencies": {
79
79
  "@biomejs/biome": "2.4.14",