@byline/core 6.2.1 → 6.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/@types/field-types.d.ts +22 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/schemas/zod/builder.d.ts +1 -1
- package/dist/schemas/zod/builder.js +40 -39
- package/dist/schemas/zod/builder.test.node.js +2 -2
- package/dist/schemas/zod/review-comparison-probe.test.node.d.ts +1 -0
- package/dist/schemas/zod/review-comparison-probe.test.node.js +26 -0
- package/dist/services/document-lifecycle/create.js +23 -2
- package/dist/services/document-lifecycle/persistence.js +38 -0
- package/dist/services/document-lifecycle/restore.d.ts +24 -3
- package/dist/services/document-lifecycle/restore.js +27 -3
- package/dist/services/document-lifecycle.test.node.js +222 -9
- package/dist/services/normalize-numeric-fields.d.ts +1 -1
- package/dist/services/normalize-numeric-fields.js +38 -13
- package/dist/services/normalize-numeric-fields.test.node.js +8 -0
- package/dist/services/restore-review-probe.test.node.d.ts +8 -0
- package/dist/services/restore-review-probe.test.node.js +3397 -0
- package/dist/services/review-probes.test.node.d.ts +8 -0
- package/dist/services/review-probes.test.node.js +349 -0
- package/dist/services/singleton-lifecycle/index.d.ts +1 -0
- package/dist/services/singleton-lifecycle/restore.d.ts +11 -1
- package/dist/services/singleton-lifecycle/restore.js +14 -2
- package/dist/services/singleton-lifecycle.test.node.js +34 -0
- package/dist/validation/document-fields.d.ts +71 -0
- package/dist/validation/document-fields.js +218 -0
- package/dist/validation/document-fields.test.node.d.ts +8 -0
- package/dist/validation/document-fields.test.node.js +290 -0
- package/package.json +6 -6
|
@@ -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<
|
|
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
|
-
|
|
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: () =>
|
|
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 {};
|