@byline/core 6.3.0 → 6.5.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.
Files changed (29) hide show
  1. package/dist/@types/field-types.d.ts +22 -3
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.js +1 -0
  4. package/dist/schemas/zod/builder.d.ts +1 -1
  5. package/dist/schemas/zod/builder.js +40 -39
  6. package/dist/schemas/zod/builder.test.node.js +2 -2
  7. package/dist/schemas/zod/review-comparison-probe.test.node.d.ts +1 -0
  8. package/dist/schemas/zod/review-comparison-probe.test.node.js +26 -0
  9. package/dist/services/document-lifecycle/create.js +23 -2
  10. package/dist/services/document-lifecycle/persistence.js +38 -0
  11. package/dist/services/document-lifecycle/restore.d.ts +24 -3
  12. package/dist/services/document-lifecycle/restore.js +27 -3
  13. package/dist/services/document-lifecycle.test.node.js +222 -9
  14. package/dist/services/normalize-numeric-fields.d.ts +1 -1
  15. package/dist/services/normalize-numeric-fields.js +38 -13
  16. package/dist/services/normalize-numeric-fields.test.node.js +8 -0
  17. package/dist/services/restore-review-probe.test.node.d.ts +8 -0
  18. package/dist/services/restore-review-probe.test.node.js +3397 -0
  19. package/dist/services/review-probes.test.node.d.ts +8 -0
  20. package/dist/services/review-probes.test.node.js +349 -0
  21. package/dist/services/singleton-lifecycle/index.d.ts +1 -0
  22. package/dist/services/singleton-lifecycle/restore.d.ts +11 -1
  23. package/dist/services/singleton-lifecycle/restore.js +14 -2
  24. package/dist/services/singleton-lifecycle.test.node.js +34 -0
  25. package/dist/validation/document-fields.d.ts +71 -0
  26. package/dist/validation/document-fields.js +218 -0
  27. package/dist/validation/document-fields.test.node.d.ts +8 -0
  28. package/dist/validation/document-fields.test.node.js +290 -0
  29. package/package.json +8 -8
@@ -0,0 +1,8 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ export {};
@@ -0,0 +1,349 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { AdminAuth, AuthError, AuthErrorCodes, createRequestContext, createSuperAdminContext, } from '@byline/auth';
9
+ import { describe, expect, it, vi } from 'vitest';
10
+ import { BylineError, DbErrorCodes, ERR_DOCUMENT_STALE, ERR_NOT_FOUND, ErrorCodes, } from '../lib/errors.js';
11
+ import { runReadSnapshot } from '../storage/read-snapshot.js';
12
+ import { unusedRevisionStore } from '../storage/revision-store.test-helper.js';
13
+ import { cancelDocumentScheduledPublish, changeDocumentStatus, confirmDocumentScheduledPublish, copyToLocale, createDocument, deleteDocument, deleteLocale, duplicateDocument, listDocumentPublishSchedules, restoreDocumentVersion, scheduleDocumentPublish, unpublishDocument, updateDocument, updateDocumentSystemFields, updateDocumentWithPatches, } from './document-lifecycle/index.js';
14
+ // ---------------------------------------------------------------------------
15
+ // Fixtures / Helpers
16
+ // ---------------------------------------------------------------------------
17
+ const minimalCollection = {
18
+ path: 'articles',
19
+ labels: { singular: 'Article', plural: 'Articles' },
20
+ fields: [{ name: 'title', type: 'text', label: 'Title' }],
21
+ workflow: {
22
+ statuses: [
23
+ { name: 'draft', label: 'Draft' },
24
+ { name: 'published', label: 'Published' },
25
+ { name: 'archived', label: 'Archived' },
26
+ ],
27
+ },
28
+ };
29
+ const numericCollection = {
30
+ ...minimalCollection,
31
+ fields: [
32
+ { name: 'quantity', type: 'integer' },
33
+ { name: 'score', type: 'float' },
34
+ { name: 'price', type: 'decimal' },
35
+ ],
36
+ };
37
+ function publishScheduleRow(overrides = {}) {
38
+ const now = new Date('2026-08-22T12:00:00.000Z');
39
+ return {
40
+ documentId: 'doc-1',
41
+ collectionId: 'col-1',
42
+ targetVersionId: 'ver-1',
43
+ authorizedRevision: 1,
44
+ publishAt: new Date('2026-08-23T12:00:00.000Z'),
45
+ state: 'armed',
46
+ suspendedAt: null,
47
+ suspendedReason: null,
48
+ scheduledBy: TEST_ACTOR_ID,
49
+ lastAuthorizedBy: TEST_ACTOR_ID,
50
+ lastAuthorizedAt: now,
51
+ scheduledAt: now,
52
+ updatedAt: now,
53
+ executionToken: null,
54
+ executionExpiresAt: null,
55
+ lastAttemptAt: null,
56
+ nextAttemptAt: new Date('2026-08-23T12:00:00.000Z'),
57
+ attemptCount: 0,
58
+ lastError: null,
59
+ ...overrides,
60
+ };
61
+ }
62
+ /** Build a mock IDbAdapter. Returns the adapter plus individual mock fns. */
63
+ function createMockDb() {
64
+ const createDocumentVersion = vi.fn().mockResolvedValue({
65
+ document: { id: 'ver-1', document_id: 'doc-1' },
66
+ fieldCount: 3,
67
+ });
68
+ const setDocumentStatus = vi.fn().mockResolvedValue(undefined);
69
+ const archivePublishedVersions = vi.fn().mockResolvedValue(0);
70
+ const softDeleteDocument = vi.fn().mockResolvedValue(1);
71
+ const deleteDocumentLocale = vi.fn().mockResolvedValue({ newVersionId: 'ver-2' });
72
+ const getDocumentById = vi.fn().mockImplementation(async () => {
73
+ const metadata = await getCurrentVersionMetadata();
74
+ return metadata == null ? null : { ...metadata, fields: {}, path: await getCurrentPath() };
75
+ });
76
+ const getDocumentSystemFieldsForUpdate = vi.fn().mockResolvedValue(null);
77
+ const getCurrentVersionMetadata = vi.fn().mockResolvedValue(null);
78
+ const getCurrentPath = vi.fn().mockResolvedValue('current-path');
79
+ const publishSchedule = vi.fn().mockResolvedValue({ status: 'document_not_found' });
80
+ const confirmPublishSchedule = vi.fn().mockResolvedValue({ status: 'schedule_not_found' });
81
+ const cancelPublishSchedule = vi.fn().mockResolvedValue(null);
82
+ const suspendPublishSchedule = vi.fn().mockResolvedValue({ status: 'schedule_not_found' });
83
+ const claimDuePublishSchedules = vi.fn().mockResolvedValue([]);
84
+ const lockPublishScheduleClaim = vi.fn().mockResolvedValue(null);
85
+ const deletePublishScheduleClaim = vi.fn().mockResolvedValue(false);
86
+ const suspendPublishScheduleClaim = vi.fn().mockResolvedValue(false);
87
+ const releasePublishScheduleClaim = vi.fn().mockResolvedValue(false);
88
+ const getPublishSchedule = vi.fn().mockResolvedValue(null);
89
+ const listPublishSchedules = vi.fn().mockResolvedValue({ schedules: [], total: 0 });
90
+ // Audit capability (docs/07-auth-and-security/02-auditability.md — W2). `withTransaction` is a passthrough
91
+ // in unit tests (runs the unit of work immediately, no real tx); `append`
92
+ // records the calls so write-point tests can assert the audit rows emitted.
93
+ const auditAppend = vi.fn().mockResolvedValue({ id: 'audit-1' });
94
+ let transactionDepth = 0;
95
+ const revisions = new Map();
96
+ const transaction = {
97
+ run: async (fn) => {
98
+ transactionDepth++;
99
+ try {
100
+ return await fn();
101
+ }
102
+ finally {
103
+ transactionDepth--;
104
+ }
105
+ },
106
+ };
107
+ const withTransaction = vi.spyOn(transaction, 'run');
108
+ const fail = () => {
109
+ throw new Error('unexpected singleton mapping call');
110
+ };
111
+ // Fake classifier mirroring db-postgres's real one closely enough for
112
+ // rethrowPathConflict's consumption of the seam: a pg-shaped 23505 with a
113
+ // constraint name classifies as a unique violation; anything else is
114
+ // unknown. Individual tests inject pg-shaped errors on `createDocumentVersion`
115
+ // (see `translates a Postgres unique-constraint violation...` below).
116
+ const classifyError = vi.fn((err) => {
117
+ const e = err;
118
+ if (e?.code === '23505') {
119
+ return { code: DbErrorCodes.UNIQUE_VIOLATION, constraint: e.constraint };
120
+ }
121
+ return { code: DbErrorCodes.UNKNOWN };
122
+ });
123
+ const db = {
124
+ classifyError,
125
+ revisions: {
126
+ ...unusedRevisionStore,
127
+ isInTransaction: () => transactionDepth > 0,
128
+ lock: vi.fn(async (targets) => {
129
+ const observations = [];
130
+ for (const target of targets) {
131
+ const revision = revisions.get(target.documentId) ?? 1;
132
+ if (target.expectedRevision !== revision)
133
+ throw ERR_DOCUMENT_STALE({
134
+ message: 'stale',
135
+ details: {
136
+ reason: 'revision_mismatch',
137
+ documentId: target.documentId,
138
+ expectedRevision: target.expectedRevision,
139
+ currentRevision: revision,
140
+ },
141
+ });
142
+ const system = await getDocumentSystemFieldsForUpdate({
143
+ collection_id: target.collectionId,
144
+ document_id: target.documentId,
145
+ });
146
+ const source = await getDocumentById.mock.results.at(-1)?.value;
147
+ if (!system && !source)
148
+ throw ERR_NOT_FOUND({ message: 'document not found' });
149
+ observations.push({
150
+ documentId: target.documentId,
151
+ collectionId: target.collectionId,
152
+ revision,
153
+ currentVersionId: source?.document_version_id ?? 'ver-1',
154
+ status: source?.status ?? 'draft',
155
+ sourceLocale: system?.source_locale ?? source?.source_locale ?? 'en',
156
+ path: system?.path ?? source?.path ?? null,
157
+ availableLocales: system?.availableLocales ?? source?.availableLocales ?? [],
158
+ });
159
+ }
160
+ return observations;
161
+ }),
162
+ advance: vi.fn(async (locked) => {
163
+ const revision = locked.revision + 1;
164
+ revisions.set(locked.documentId, revision);
165
+ return { documentId: locked.documentId, revision };
166
+ }),
167
+ },
168
+ withReadSnapshot: (fn) => runReadSnapshot(db.queries, fn),
169
+ commands: {
170
+ collections: {
171
+ lockCollectionRegistration: vi.fn(async () => { }),
172
+ create: vi.fn(),
173
+ update: vi.fn(),
174
+ delete: vi.fn(),
175
+ },
176
+ documents: {
177
+ publishSchedules: {
178
+ lockDocuments: vi.fn(async () => { }),
179
+ schedule: publishSchedule,
180
+ confirm: confirmPublishSchedule,
181
+ cancel: cancelPublishSchedule,
182
+ suspendForContentEdit: suspendPublishSchedule,
183
+ claimDue: claimDuePublishSchedules,
184
+ lockClaim: lockPublishScheduleClaim,
185
+ deleteClaim: deletePublishScheduleClaim,
186
+ suspendClaimForContentEdit: suspendPublishScheduleClaim,
187
+ releaseClaim: releasePublishScheduleClaim,
188
+ },
189
+ createDocumentVersion,
190
+ updateDocumentPath: vi.fn().mockResolvedValue(undefined),
191
+ setDocumentAvailableLocales: vi.fn().mockResolvedValue(undefined),
192
+ setDocumentStatus,
193
+ archivePublishedVersions,
194
+ softDeleteDocument,
195
+ restoreSoftDeletedDocument: vi.fn(),
196
+ deleteDocumentLocale: deleteDocumentLocale,
197
+ setOrderKey: vi.fn(),
198
+ placeTreeNode: vi.fn(),
199
+ removeFromTree: vi.fn(),
200
+ promoteChildrenAndRemoveFromTree: vi.fn(async () => ({
201
+ removed: {
202
+ changed: false,
203
+ before: { placed: false, parentDocumentId: null, orderKey: null, index: null },
204
+ after: { placed: false, parentDocumentId: null, orderKey: null, index: null },
205
+ beforeSiblingDocumentIds: [],
206
+ beforeSubtreeDocumentIds: [],
207
+ },
208
+ promoted: [],
209
+ })),
210
+ },
211
+ counters: {
212
+ ensureCounterGroup: vi.fn(),
213
+ nextCounterValue: vi.fn(),
214
+ nextScopedCounterValue: vi.fn(),
215
+ },
216
+ audit: { append: auditAppend },
217
+ singletons: {
218
+ lockSlot: vi.fn(async () => { }),
219
+ setMapping: vi.fn(fail),
220
+ clearMapping: vi.fn(fail),
221
+ },
222
+ },
223
+ withTransaction: transaction.run,
224
+ queries: {
225
+ collections: {
226
+ getAllCollections: vi.fn(),
227
+ getCollectionByPath: vi.fn(),
228
+ getCollectionById: vi.fn(),
229
+ },
230
+ documents: {
231
+ publishSchedules: {
232
+ get: getPublishSchedule,
233
+ list: listPublishSchedules,
234
+ },
235
+ getDocumentRevision: async ({ document_id }) => revisions.get(document_id) ?? 1,
236
+ getDocumentSystemFieldsForUpdate,
237
+ getDocumentById,
238
+ getCurrentVersionMetadata,
239
+ getCurrentPath,
240
+ getDocumentByPath: vi.fn(),
241
+ getDocumentByVersion: vi.fn(),
242
+ getDocumentsByVersionIds: vi.fn(),
243
+ getDocumentsByDocumentIds: vi.fn(),
244
+ getDocumentHistory: vi.fn(),
245
+ getPublishedVersion: vi.fn(),
246
+ getPublishedDocumentIds: vi.fn(),
247
+ getDocumentCountsByStatus: vi.fn(),
248
+ findDocuments: vi.fn(),
249
+ getLastOrderKey: vi.fn(),
250
+ getNeighborOrderKeys: vi.fn(),
251
+ getCanonicalDocumentOrder: vi.fn(),
252
+ getTreeAncestors: vi.fn(),
253
+ getTreeChildren: vi.fn(),
254
+ getTreeParent: vi.fn(),
255
+ getTreeSubtree: vi.fn(),
256
+ },
257
+ audit: {
258
+ getDocumentAuditLog: vi.fn(async () => ({
259
+ entries: [],
260
+ meta: { total: 0, page: 1, pageSize: 20, totalPages: 0 },
261
+ })),
262
+ findAuditLog: vi.fn(async () => ({
263
+ entries: [],
264
+ meta: { total: 0, page: 1, pageSize: 20, totalPages: 0 },
265
+ })),
266
+ },
267
+ singletons: { getMappedDocumentId: vi.fn(fail) },
268
+ },
269
+ };
270
+ return {
271
+ db,
272
+ createDocumentVersion,
273
+ setDocumentStatus,
274
+ archivePublishedVersions,
275
+ softDeleteDocument,
276
+ deleteDocumentLocale,
277
+ getDocumentById,
278
+ getDocumentSystemFieldsForUpdate,
279
+ getCurrentVersionMetadata,
280
+ getCurrentPath,
281
+ publishSchedule,
282
+ confirmPublishSchedule,
283
+ cancelPublishSchedule,
284
+ suspendPublishSchedule,
285
+ claimDuePublishSchedules,
286
+ lockPublishScheduleClaim,
287
+ deletePublishScheduleClaim,
288
+ suspendPublishScheduleClaim,
289
+ releasePublishScheduleClaim,
290
+ getPublishSchedule,
291
+ listPublishSchedules,
292
+ auditAppend,
293
+ withTransaction,
294
+ };
295
+ }
296
+ function pathUniqueViolation() {
297
+ return Object.assign(new Error('duplicate key value violates unique constraint'), {
298
+ code: '23505',
299
+ constraint: 'idx_document_paths_collection_locale_path',
300
+ });
301
+ }
302
+ const noopLogger = {
303
+ log: vi.fn(),
304
+ fatal: vi.fn(),
305
+ error: vi.fn(),
306
+ warn: vi.fn(),
307
+ info: vi.fn(),
308
+ debug: vi.fn(),
309
+ trace: vi.fn(),
310
+ silent: vi.fn(),
311
+ };
312
+ // A real persisted-user id is a UUID; `actorId()` only attributes UUIDs (see
313
+ // the regression note below). Use a valid UUID so the default context's
314
+ // writes are attributed.
315
+ const TEST_ACTOR_ID = '01901234-0000-7000-8000-000000000001';
316
+ function buildCtx(db, definition = minimalCollection) {
317
+ return {
318
+ db,
319
+ definition,
320
+ collectionId: 'col-1',
321
+ collectionVersion: 1,
322
+ collectionPath: definition.path,
323
+ logger: noopLogger,
324
+ defaultLocale: 'en',
325
+ // Inject a super-admin context by default so the bulk of existing
326
+ // tests do not have to care about ability enforcement. The dedicated
327
+ // "enforcement" block below covers the missing-context / missing-ability
328
+ // negative cases.
329
+ requestContext: createSuperAdminContext({ id: TEST_ACTOR_ID }),
330
+ };
331
+ }
332
+ // ---------------------------------------------------------------------------
333
+ // Tests
334
+ // ---------------------------------------------------------------------------
335
+ describe('review probes', () => {
336
+ it('rejects caller-selected published status for a create-only actor', async () => {
337
+ const { db } = createMockDb();
338
+ const ctx = buildCtx(db);
339
+ ctx.requestContext = createRequestContext({ actor: new AdminAuth({
340
+ id: 'editor', abilities: ['collections.articles.create'],
341
+ }) });
342
+ await expect(createDocument(ctx, { data: { title: 'Direct publish' }, status: 'published' })).rejects.toMatchObject({ code: AuthErrorCodes.FORBIDDEN });
343
+ });
344
+ it('rejects an undeclared initial workflow status', async () => {
345
+ const { db } = createMockDb();
346
+ const ctx = buildCtx(db);
347
+ await expect(createDocument(ctx, { data: { title: 'Invalid status' }, status: 'not-a-status' })).rejects.toBeDefined();
348
+ });
349
+ });
@@ -11,4 +11,5 @@ export { restoreSingletonVersion } from './restore.js';
11
11
  export { updateSingleton } from './update.js';
12
12
  export type { CopySingletonToLocaleParams } from './copy-to-locale.js';
13
13
  export type { SingletonSaveResult } from './internals.js';
14
+ export type { RestoreSingletonVersionResult } from './restore.js';
14
15
  export type { UpdateSingletonParams, UpdateSingletonResult } from './update.js';
@@ -5,10 +5,20 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
+ import type { DocumentFieldIssue } from '../../validation/document-fields.js';
8
9
  import type { DocumentLifecycleContext } from '../document-lifecycle/context.js';
9
10
  import type { SingletonSaveResult } from './internals.js';
11
+ export interface RestoreSingletonVersionResult extends SingletonSaveResult {
12
+ /**
13
+ * Ways the restored content fails today's field validation. Restore is
14
+ * exempt from that gate, so these never block it — they are reported so the
15
+ * editor can say what must be fixed before the next ordinary save. Mirrors
16
+ * `RestoreVersionResult.validationIssues` on the collection path.
17
+ */
18
+ validationIssues?: DocumentFieldIssue[];
19
+ }
10
20
  /** Restore one historical singleton version as the new current version. */
11
21
  export declare function restoreSingletonVersion(ctx: DocumentLifecycleContext, params: {
12
22
  sourceVersionId: string;
13
23
  expectedRevision: number;
14
- }): Promise<SingletonSaveResult>;
24
+ }): Promise<RestoreSingletonVersionResult>;
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_VALIDATION } from '../../lib/errors.js';
9
9
  import { withLogContext } from '../../lib/logger.js';
10
+ import { validateDocumentFields } from '../../validation/document-fields.js';
10
11
  import { getDefaultStatus } from '../../workflow/workflow.js';
11
12
  import { applyRichTextEmbed } from '../document-lifecycle/internals.js';
12
13
  import { persistExistingDocumentVersion } from '../document-lifecycle/persistence.js';
@@ -16,7 +17,11 @@ export async function restoreSingletonVersion(ctx, params) {
16
17
  params = { ...params };
17
18
  return withLogContext({ domain: 'services', module: 'singleton-lifecycle', function: 'restoreSingletonVersion' }, async () => {
18
19
  const definition = authorizeSingletonUpdate(ctx);
19
- return commitSingletonSave({
20
+ // Restore is exempt from the content gate (see assertWritableContent).
21
+ // Collect the issues for reporting only, after the embed pass, so the
22
+ // editor can explain why its next ordinary save is refused.
23
+ let validationIssues = [];
24
+ const result = await commitSingletonSave({
20
25
  ctx,
21
26
  definition,
22
27
  expectedRevision: params.expectedRevision,
@@ -77,7 +82,13 @@ export async function restoreSingletonVersion(ctx, params) {
77
82
  data,
78
83
  originalData,
79
84
  locale: 'all',
80
- prepareWrite: () => applyRichTextEmbed(ctx, data),
85
+ prepareWrite: async () => {
86
+ await applyRichTextEmbed(ctx, data);
87
+ validationIssues = validateDocumentFields(definition.fields, data, {
88
+ locale: 'all',
89
+ onCallbackError: ({ path, site, error }) => ctx.logger.error({ err: error, fieldPath: path, site }, 'restore diagnostic callback threw'),
90
+ });
91
+ },
81
92
  write: async () => {
82
93
  return persistExistingDocumentVersion(ctx, {
83
94
  documentId: slot.documentId,
@@ -91,5 +102,6 @@ export async function restoreSingletonVersion(ctx, params) {
91
102
  };
92
103
  },
93
104
  });
105
+ return { ...result, ...(validationIssues.length > 0 ? { validationIssues } : {}) };
94
106
  });
95
107
  }
@@ -411,6 +411,40 @@ describe('singleton lifecycle service', () => {
411
411
  expect(afterSave).toHaveBeenCalledWith(hookShape);
412
412
  expect(harness.createDocumentVersion).toHaveBeenCalledWith(expect.objectContaining({ action: 'restore', locale: 'all' }));
413
413
  });
414
+ it('reports validation issues on a restore without blocking it', async () => {
415
+ // The singleton restore path shares the persistence exemption, so it must
416
+ // also share the reporting the shared restore modal renders.
417
+ const harness = createHarness({ mapped: true });
418
+ harness.views.set('all', { fields: { title: { en: 'Current' }, count: 2 } });
419
+ harness.versions.set('ver-source', {
420
+ document_id: 'doc-1',
421
+ // Predates `title` — a required localized field — and carries a count
422
+ // that today's integer rule rejects.
423
+ fields: { count: 'not-a-number' },
424
+ });
425
+ const result = await restoreSingletonVersion(harness.ctx, {
426
+ expectedRevision: 1,
427
+ sourceVersionId: 'ver-source',
428
+ });
429
+ expect(harness.createDocumentVersion).toHaveBeenCalledWith(expect.objectContaining({ action: 'restore', locale: 'all' }));
430
+ expect(result.validationIssues?.map((issue) => [issue.field, issue.kind])).toEqual([
431
+ ['title', 'required'],
432
+ ['count', 'invalid'],
433
+ ]);
434
+ });
435
+ it('omits validationIssues when the restored singleton version is clean', async () => {
436
+ const harness = createHarness({ mapped: true });
437
+ harness.views.set('all', { fields: { title: { en: 'Current' }, count: 2 } });
438
+ harness.versions.set('ver-source', {
439
+ document_id: 'doc-1',
440
+ fields: { title: { en: 'Historic' }, count: 1 },
441
+ });
442
+ const result = await restoreSingletonVersion(harness.ctx, {
443
+ expectedRevision: 1,
444
+ sourceVersionId: 'ver-source',
445
+ });
446
+ expect(result.validationIssues).toBeUndefined();
447
+ });
414
448
  it('copies the merged target payload and distinguishes overwrite modes', async () => {
415
449
  const run = async (overwrite) => {
416
450
  const beforeSave = vi.fn();
@@ -0,0 +1,71 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import type { FieldSet } from '../@types/field-types.js';
9
+ import type { BylineLogger } from '../lib/logger.js';
10
+ export interface DocumentFieldIssue {
11
+ field: string;
12
+ message: string;
13
+ /**
14
+ * `required` marks a value that is absent but declared non-optional — the
15
+ * shape schema tightening produces. Every other failure, including a custom
16
+ * `validate` callback's message, is `invalid`: it describes a value that is
17
+ * present and wrong, or a container that cannot be stored. Nothing branches
18
+ * on this to decide whether a write proceeds; it exists so callers can tell
19
+ * an editor what is missing separately from what is wrong.
20
+ */
21
+ kind: 'required' | 'invalid';
22
+ }
23
+ export interface DocumentFieldValidationDetails {
24
+ reason: 'invalid_document_fields';
25
+ issues: DocumentFieldIssue[];
26
+ }
27
+ type CallbackSite = 'validate' | 'condition' | 'schema';
28
+ /**
29
+ * Validate schema data without transforming or stripping persistence values.
30
+ *
31
+ * Never throws. Three things here run schema-author code and can fail on data
32
+ * they did not anticipate: `validate`, `condition`, and the per-field Zod
33
+ * schema, whose `validation.rules` may carry a throwing `custom` predicate or a
34
+ * malformed `pattern` that fails while the schema is being built. A validator
35
+ * calling `value.trim()` meets a historical version that lacks the field. A raw
36
+ * exception would abort the walk, discarding the issues already collected, and
37
+ * escape as an unhandled error rather than a field result.
38
+ *
39
+ * Each site is therefore isolated and a failure recorded as an `invalid` issue,
40
+ * so an ordinary save still refuses the write, and a caller using this for
41
+ * diagnostics only — restore — is never blocked by it. The editor-facing
42
+ * message is fixed; the exception goes to `onCallbackError`.
43
+ */
44
+ export declare function validateDocumentFields(fields: FieldSet, data: Record<string, any>, options?: {
45
+ locale?: string;
46
+ /** Browser-only presentation precheck; the lifecycle never exempts hidden fields. */
47
+ respectConditions?: boolean;
48
+ /** Pending uploads are validated after transport by the lifecycle. */
49
+ skip?: (path: string) => boolean;
50
+ /**
51
+ * Receives the real exception when a schema-author callback throws, for
52
+ * server-side diagnostics. The editor only ever sees `CALLBACK_FAILED`.
53
+ */
54
+ onCallbackError?: (detail: {
55
+ path: string;
56
+ site: CallbackSite;
57
+ error: unknown;
58
+ }) => void;
59
+ }): DocumentFieldIssue[];
60
+ /**
61
+ * Enforce the collection's declared field contract over prepared content.
62
+ *
63
+ * Every versioned write passes through this except a restore, which is exempt
64
+ * structurally rather than by a parameter here — see `assertWritableContent`
65
+ * in the lifecycle's `persistence.ts` for why, and for what that exemption
66
+ * does and does not promise.
67
+ */
68
+ export declare function assertDocumentFields(fields: FieldSet, data: Record<string, any>, locale?: string, logger?: Pick<BylineLogger, 'error'>): void;
69
+ /** Accept live errors and serialized reports, discarding all non-contract data. */
70
+ export declare function getDocumentFieldValidationDetails(error: unknown): DocumentFieldValidationDetails | null;
71
+ export {};