@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
|
@@ -197,9 +197,9 @@ interface BaseField {
|
|
|
197
197
|
* externally-assigned IDs like DOIs, fields that should only be set
|
|
198
198
|
* by a workflow transition), not as a security boundary.
|
|
199
199
|
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
200
|
+
* Built-in admin widgets honor this flag, including structural controls
|
|
201
|
+
* and descendants of groups, arrays, and blocks. Custom field components
|
|
202
|
+
* and rich-text editor replacements must implement the hint themselves.
|
|
203
203
|
*/
|
|
204
204
|
readOnly?: boolean;
|
|
205
205
|
/**
|
|
@@ -222,6 +222,25 @@ interface BaseField {
|
|
|
222
222
|
* validation. Server-side schema validation knows nothing about
|
|
223
223
|
* conditions, so pair conditionally-hidden required fields with
|
|
224
224
|
* `optional: true` or a `defaultValue`.
|
|
225
|
+
*
|
|
226
|
+
* A field that is required only in particular circumstances is expressed
|
|
227
|
+
* explicitly, with `optional: true` plus a `validate` callback that decides
|
|
228
|
+
* from the document. The callback runs on both sides — the lifecycle
|
|
229
|
+
* enforces it, and the form reports it wherever the field is visible:
|
|
230
|
+
*
|
|
231
|
+
* {
|
|
232
|
+
* type: 'text', name: 'doi', optional: true,
|
|
233
|
+
* condition: (data) => data.kind === 'journal',
|
|
234
|
+
* validate: (value, data) =>
|
|
235
|
+
* data.kind === 'journal' && !value ? 'DOI is required for journal articles' : undefined,
|
|
236
|
+
* }
|
|
237
|
+
*
|
|
238
|
+
* Do not reach for `condition` to express requiredness. Beyond the contract
|
|
239
|
+
* above, restore and duplicate validate with `locale: 'all'`, where a
|
|
240
|
+
* localized value is a per-locale map rather than the single value the
|
|
241
|
+
* editor supplies — so a predicate reading one can disagree with itself
|
|
242
|
+
* across those paths. A `validate` callback that must run under `'all'`
|
|
243
|
+
* should account for the same shape.
|
|
225
244
|
*/
|
|
226
245
|
condition?: FieldCondition;
|
|
227
246
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -25,5 +25,6 @@ export { normalizeRootRelativeRedirect } from './utils/root-relative-redirect.js
|
|
|
25
25
|
export { formatTextValue, looksLikeISODate, type SlugifierFn, type SlugifyContext, slugify, } from './utils/slugify.js';
|
|
26
26
|
export { type FilenameSlugifierFn, type FilenameSlugifyContext, resolveUploadFilename, slugifyFilename, } from './utils/slugify-filename.js';
|
|
27
27
|
export { getUploadFields, hasUploadField, isUploadField } from './utils/storage-utils.js';
|
|
28
|
+
export { type DocumentFieldIssue, type DocumentFieldValidationDetails, getDocumentFieldValidationDetails, validateDocumentFields, } from './validation/document-fields.js';
|
|
28
29
|
export * from './workflow/index.js';
|
|
29
30
|
export type { ClaimedRecurringTask, ISchedulerStore, ReconcileTaskInput, RecurringTaskContext, RecurringTaskDefinition, RecurringTaskHealth, RecurringTaskResult, RecurringTaskStatus, } from './scheduler/types.js';
|
package/dist/index.js
CHANGED
|
@@ -37,4 +37,5 @@ export { normalizeRootRelativeRedirect } from './utils/root-relative-redirect.js
|
|
|
37
37
|
export { formatTextValue, looksLikeISODate, slugify, } from './utils/slugify.js';
|
|
38
38
|
export { resolveUploadFilename, slugifyFilename, } from './utils/slugify-filename.js';
|
|
39
39
|
export { getUploadFields, hasUploadField, isUploadField } from './utils/storage-utils.js';
|
|
40
|
+
export { getDocumentFieldValidationDetails, validateDocumentFields, } from './validation/document-fields.js';
|
|
40
41
|
export * from './workflow/index.js';
|
|
@@ -15,7 +15,7 @@ export declare const createBaseSchema: (collection?: CollectionDefinition) => z.
|
|
|
15
15
|
createdBy: z.ZodOptional<z.ZodUUID>;
|
|
16
16
|
eventType: z.ZodOptional<z.ZodString>;
|
|
17
17
|
}, z.core.$strip>;
|
|
18
|
-
export declare const createFieldsSchema: (fields: Field[], strict?: boolean) => z.ZodObject<{
|
|
18
|
+
export declare const createFieldsSchema: (fields: readonly Field[], strict?: boolean) => z.ZodObject<{
|
|
19
19
|
[x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
|
|
20
20
|
}, z.core.$strip>;
|
|
21
21
|
export declare const createListMetaSchema: () => z.ZodObject<{
|
|
@@ -5,10 +5,14 @@ import { getCollectionDefinition } from '../../config/config.js';
|
|
|
5
5
|
const applyValidationRules = (schema, rules) => {
|
|
6
6
|
return rules.reduce((acc, rule) => {
|
|
7
7
|
switch (rule.type) {
|
|
8
|
+
case 'min':
|
|
9
|
+
return acc.min(rule.value, rule.message);
|
|
10
|
+
case 'max':
|
|
11
|
+
return acc.max(rule.value, rule.message);
|
|
8
12
|
case 'email':
|
|
9
|
-
return acc.
|
|
13
|
+
return acc.email(rule.message || 'Invalid email address');
|
|
10
14
|
case 'url':
|
|
11
|
-
return acc.
|
|
15
|
+
return acc.url(rule.message || 'Invalid URL');
|
|
12
16
|
case 'pattern':
|
|
13
17
|
return acc.regex(new RegExp(rule.value), rule.message);
|
|
14
18
|
case 'custom':
|
|
@@ -21,13 +25,13 @@ const applyValidationRules = (schema, rules) => {
|
|
|
21
25
|
// Helper function to apply text field validation
|
|
22
26
|
const applyTextValidation = (schema, field) => {
|
|
23
27
|
let validatedSchema = schema;
|
|
24
|
-
if (field.validation?.minLength) {
|
|
28
|
+
if (field.validation?.minLength != null) {
|
|
25
29
|
validatedSchema = validatedSchema.min(field.validation.minLength);
|
|
26
30
|
}
|
|
27
|
-
if (field.validation?.maxLength) {
|
|
31
|
+
if (field.validation?.maxLength != null) {
|
|
28
32
|
validatedSchema = validatedSchema.max(field.validation.maxLength);
|
|
29
33
|
}
|
|
30
|
-
if (field.validation
|
|
34
|
+
if (field.validation && 'pattern' in field.validation && field.validation.pattern) {
|
|
31
35
|
validatedSchema = validatedSchema.regex(new RegExp(field.validation.pattern));
|
|
32
36
|
}
|
|
33
37
|
return validatedSchema;
|
|
@@ -57,8 +61,6 @@ export const fieldToZodSchema = (field, strict = true) => {
|
|
|
57
61
|
// (assigned client-side on add, persisted via store_meta) — always
|
|
58
62
|
// accepted, never required. `.passthrough()` tolerates auxiliary keys
|
|
59
63
|
// (e.g. legacy seed-data `id` aliases) instead of stripping them.
|
|
60
|
-
// Group children stay `z.any()` via the `group` case below — the same
|
|
61
|
-
// depth boundary top-level groups have.
|
|
62
64
|
const itemShape = {
|
|
63
65
|
_id: z.string().optional(),
|
|
64
66
|
};
|
|
@@ -75,6 +77,8 @@ export const fieldToZodSchema = (field, strict = true) => {
|
|
|
75
77
|
schema = arraySchema;
|
|
76
78
|
break;
|
|
77
79
|
}
|
|
80
|
+
case 'textArea':
|
|
81
|
+
case 'code':
|
|
78
82
|
case 'text': {
|
|
79
83
|
let textSchema = z.string();
|
|
80
84
|
textSchema = applyTextValidation(textSchema, field);
|
|
@@ -97,6 +101,10 @@ export const fieldToZodSchema = (field, strict = true) => {
|
|
|
97
101
|
schema = z.string();
|
|
98
102
|
}
|
|
99
103
|
break;
|
|
104
|
+
case 'time':
|
|
105
|
+
schema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?$/);
|
|
106
|
+
break;
|
|
107
|
+
case 'date':
|
|
100
108
|
case 'datetime': {
|
|
101
109
|
const dateSchema = z.preprocess((val) => (val === '' || val == null ? null : val), z.coerce.date().refine((val) => val.toString() !== 'Invalid Date', {
|
|
102
110
|
message: 'Invalid date',
|
|
@@ -106,6 +114,12 @@ export const fieldToZodSchema = (field, strict = true) => {
|
|
|
106
114
|
schema = dateSchema;
|
|
107
115
|
break;
|
|
108
116
|
}
|
|
117
|
+
case 'json':
|
|
118
|
+
schema = z.json();
|
|
119
|
+
break;
|
|
120
|
+
case 'object':
|
|
121
|
+
schema = z.record(z.string(), z.json());
|
|
122
|
+
break;
|
|
109
123
|
case 'richText': {
|
|
110
124
|
const richTextSchema = z.any();
|
|
111
125
|
// TODO: Implement rich text validation if needed
|
|
@@ -122,39 +136,21 @@ export const fieldToZodSchema = (field, strict = true) => {
|
|
|
122
136
|
schema = richTextSchema;
|
|
123
137
|
break;
|
|
124
138
|
}
|
|
125
|
-
case 'textArea': {
|
|
126
|
-
let textAreaSchema = z.string();
|
|
127
|
-
if (field.validation?.minLength) {
|
|
128
|
-
textAreaSchema = textAreaSchema.min(field.validation.minLength);
|
|
129
|
-
}
|
|
130
|
-
if (field.validation?.maxLength) {
|
|
131
|
-
textAreaSchema = textAreaSchema.max(field.validation.maxLength);
|
|
132
|
-
}
|
|
133
|
-
schema = textAreaSchema;
|
|
134
|
-
break;
|
|
135
|
-
}
|
|
136
|
-
case 'code': {
|
|
137
|
-
let codeSchema = z.string();
|
|
138
|
-
if (field.validation?.minLength) {
|
|
139
|
-
codeSchema = codeSchema.min(field.validation.minLength);
|
|
140
|
-
}
|
|
141
|
-
if (field.validation?.maxLength) {
|
|
142
|
-
codeSchema = codeSchema.max(field.validation.maxLength);
|
|
143
|
-
}
|
|
144
|
-
schema = codeSchema;
|
|
145
|
-
break;
|
|
146
|
-
}
|
|
147
139
|
case 'integer':
|
|
148
|
-
|
|
140
|
+
case 'float': {
|
|
141
|
+
let numeric = field.type === 'integer' ? z.number().int() : z.number();
|
|
142
|
+
if (field.validation?.min != null)
|
|
143
|
+
numeric = numeric.min(field.validation.min);
|
|
144
|
+
if (field.validation?.max != null)
|
|
145
|
+
numeric = numeric.max(field.validation.max);
|
|
146
|
+
schema = numeric;
|
|
149
147
|
break;
|
|
148
|
+
}
|
|
150
149
|
case 'counter':
|
|
151
150
|
// Counter values are allocator-assigned; the wrapping below forces
|
|
152
151
|
// the schema to allow undefined regardless of `field.optional`.
|
|
153
152
|
schema = z.number().int();
|
|
154
153
|
break;
|
|
155
|
-
case 'float':
|
|
156
|
-
schema = z.number();
|
|
157
|
-
break;
|
|
158
154
|
case 'decimal':
|
|
159
155
|
// Postgres numeric values are restored as strings to preserve precision.
|
|
160
156
|
schema = z.string();
|
|
@@ -184,14 +180,19 @@ export const fieldToZodSchema = (field, strict = true) => {
|
|
|
184
180
|
.passthrough();
|
|
185
181
|
break;
|
|
186
182
|
}
|
|
187
|
-
case 'blocks':
|
|
188
|
-
|
|
183
|
+
case 'blocks': {
|
|
184
|
+
const variants = field.blocks.map((block) => z
|
|
185
|
+
.object({
|
|
186
|
+
...createFieldsSchema(block.fields, strict).shape,
|
|
187
|
+
_id: z.string().optional(),
|
|
188
|
+
_type: z.literal(block.blockType),
|
|
189
|
+
})
|
|
190
|
+
.passthrough());
|
|
191
|
+
schema = strict ? z.array(variants.length ? z.union(variants) : z.never()) : z.any().array();
|
|
189
192
|
break;
|
|
193
|
+
}
|
|
190
194
|
case 'group':
|
|
191
|
-
|
|
192
|
-
// field-renderer level. The shape depends on the group's child fields
|
|
193
|
-
// (recursive) and is not constrained here.
|
|
194
|
-
schema = z.any();
|
|
195
|
+
schema = strict ? createFieldsSchema(field.fields, true) : z.any();
|
|
195
196
|
break;
|
|
196
197
|
case 'relation': {
|
|
197
198
|
// Relation values are `RelatedDocumentValue` objects:
|
|
@@ -100,12 +100,12 @@ describe('array item schemas', () => {
|
|
|
100
100
|
const result = schemas.fields.safeParse({ title: 'FAQ page', faq: [] });
|
|
101
101
|
expect(result.success).toBe(false);
|
|
102
102
|
});
|
|
103
|
-
it('
|
|
103
|
+
it('validates required group children inside items', () => {
|
|
104
104
|
const result = schemas.fields.safeParse({
|
|
105
105
|
title: 'FAQ page',
|
|
106
106
|
faq: [{ ...faqItem, meta: { anything: true } }],
|
|
107
107
|
});
|
|
108
|
-
expect(result.success).toBe(
|
|
108
|
+
expect(result.success).toBe(false);
|
|
109
109
|
});
|
|
110
110
|
it('keeps reads lenient — items with missing children still parse in the get schema', () => {
|
|
111
111
|
const parsed = schemas.get.parse({
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { defineCollection } from '../../@types/collection-types.js';
|
|
3
|
+
import { createCollectionSchemas } from './builder.js';
|
|
4
|
+
const collection = defineCollection({
|
|
5
|
+
path: 'review-probe', labels: { singular: 'Probe', plural: 'Probes' },
|
|
6
|
+
fields: [{ name: 'choice', type: 'select', options: [{ label: 'A', value: 'a' }] }],
|
|
7
|
+
});
|
|
8
|
+
const schemas = createCollectionSchemas(collection);
|
|
9
|
+
const doc = {
|
|
10
|
+
id: '550e8400-e29b-41d4-a716-446655440000', status: 'draft',
|
|
11
|
+
createdAt: '2026-09-16T12:00:00.000Z', updatedAt: '2026-09-16T12:00:00.000Z',
|
|
12
|
+
fields: { choice: 'a' },
|
|
13
|
+
};
|
|
14
|
+
const response = {
|
|
15
|
+
docs: [doc], meta: { page: 1, pageSize: 10, total: 1, totalPages: 1 },
|
|
16
|
+
included: { collection: { id: 'review-probe', path: collection.path, labels: collection.labels } },
|
|
17
|
+
};
|
|
18
|
+
describe('review comparison: invalid values reject a read response', () => {
|
|
19
|
+
it('accepts a valid response', () => expect(schemas.list.safeParse(response).success).toBe(true));
|
|
20
|
+
it('rejects the response if one document has an invalid select', () => {
|
|
21
|
+
expect(schemas.list.safeParse({ ...response, docs: [doc, { ...doc, fields: { choice: 'bogus' } }] }).success).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
it('rejects the response if one document has an invalid status', () => {
|
|
24
|
+
expect(schemas.list.safeParse({ ...response, docs: [doc, { ...doc, status: 'bogus' }] }).success).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -11,7 +11,7 @@ import { ERR_VALIDATION } from '../../lib/errors.js';
|
|
|
11
11
|
import { withLogContext } from '../../lib/logger.js';
|
|
12
12
|
import { normaliseDateFields } from '../../utils/normalise-dates.js';
|
|
13
13
|
import { slugify } from '../../utils/slugify.js';
|
|
14
|
-
import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
14
|
+
import { getDefaultStatus, getWorkflowStatuses } from '../../workflow/workflow.js';
|
|
15
15
|
import { assignCounterValues } from '../assign-counter-values.js';
|
|
16
16
|
import { normalizeNumericFields } from '../normalize-numeric-fields.js';
|
|
17
17
|
import { requireTreeAuditCapability } from './audit.js';
|
|
@@ -19,6 +19,21 @@ import { runCommittedDocumentHook } from './committed-hook.js';
|
|
|
19
19
|
import { applyRichTextEmbed, derivePath, extractDocumentId, extractVersionId, invokeHook, maybeAppendOrderKey, rethrowPathConflict, } from './internals.js';
|
|
20
20
|
import { persistInitialDocumentVersion } from './persistence.js';
|
|
21
21
|
import { assertLifecycleTransactionOwnership } from './revision-guard.js';
|
|
22
|
+
/** Creating at the configured default is authorized by create itself. */
|
|
23
|
+
function initialStatus(ctx, requested) {
|
|
24
|
+
const defaultStatus = getDefaultStatus(ctx.definition);
|
|
25
|
+
const status = requested ?? defaultStatus;
|
|
26
|
+
if (typeof status !== 'string' ||
|
|
27
|
+
!getWorkflowStatuses(ctx.definition).some((s) => s.name === status)) {
|
|
28
|
+
throw ERR_VALIDATION({ message: 'The initial status is not declared by this collection.' });
|
|
29
|
+
}
|
|
30
|
+
if (status !== defaultStatus) {
|
|
31
|
+
assertActorCanPerform(ctx.requestContext, ctx.definition, 'changeStatus');
|
|
32
|
+
if (status === 'published')
|
|
33
|
+
assertActorCanPerform(ctx.requestContext, ctx.definition, 'publish');
|
|
34
|
+
}
|
|
35
|
+
return status;
|
|
36
|
+
}
|
|
22
37
|
/**
|
|
23
38
|
* Create a new document.
|
|
24
39
|
*
|
|
@@ -45,6 +60,12 @@ export async function createDocument(ctx, params) {
|
|
|
45
60
|
const slugifier = ctx.slugifier ?? slugify;
|
|
46
61
|
const hooks = await resolveHooks(definition);
|
|
47
62
|
const data = params.data;
|
|
63
|
+
if (data == null || typeof data !== 'object' || Array.isArray(data)) {
|
|
64
|
+
throw ERR_VALIDATION({ message: 'Document fields must be an object.' });
|
|
65
|
+
}
|
|
66
|
+
// Reject unauthorized overrides before hooks or counter allocation. Recheck
|
|
67
|
+
// after preparation too: hooks can change the legacy data.status value.
|
|
68
|
+
initialStatus(ctx, params.status ?? data.status);
|
|
48
69
|
if (params.locale != null && params.locale !== defaultLocale) {
|
|
49
70
|
throw ERR_VALIDATION({
|
|
50
71
|
message: `documents must be created in the default content locale ('${defaultLocale}'); received '${params.locale}'. Create the default-locale version first, then add localised versions via update.`,
|
|
@@ -80,7 +101,7 @@ export async function createDocument(ctx, params) {
|
|
|
80
101
|
documentData: data,
|
|
81
102
|
path: resolvedPath,
|
|
82
103
|
availableLocales: params.availableLocales,
|
|
83
|
-
status: params.status ?? data.status
|
|
104
|
+
status: initialStatus(ctx, params.status ?? data.status),
|
|
84
105
|
locale: params.locale ?? defaultLocale,
|
|
85
106
|
orderKey,
|
|
86
107
|
}).catch((err) => rethrowPathConflict(db, err, resolvedPath, defaultLocale, 'create'));
|
|
@@ -5,11 +5,47 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
+
import { assertDocumentFields } from '../../validation/document-fields.js';
|
|
8
9
|
import { requireAuditCapability } from './audit.js';
|
|
9
10
|
import { actorId } from './internals.js';
|
|
10
11
|
import { commitContentVersionWithScheduleSuspension } from './publish-schedule-consistency.js';
|
|
11
12
|
import { normalizeDocumentVersionParentError } from './stale-document.js';
|
|
12
13
|
import { appendTreeRoot } from './tree.js';
|
|
14
|
+
/**
|
|
15
|
+
* The single content gate for versioned writes.
|
|
16
|
+
*
|
|
17
|
+
* Restore is exempt, and the exemption is tied to `action: 'restore'` rather
|
|
18
|
+
* than to a caller-supplied flag: a write cannot obtain it without also
|
|
19
|
+
* declaring itself a restore in the audit trail. Only the document and
|
|
20
|
+
* singleton restore services set that action.
|
|
21
|
+
*
|
|
22
|
+
* The exemption exists because a historical version cannot be corrected
|
|
23
|
+
* through ordinary editing before it is restored. Two independent sources of
|
|
24
|
+
* failure make that fatal rather than inconvenient: the collection's schema
|
|
25
|
+
* may have tightened since the version was written, and validation rules
|
|
26
|
+
* themselves may have changed — corrected `email` and `url` rules can fail a
|
|
27
|
+
* version whose schema never moved at all. Either way the content becomes
|
|
28
|
+
* permanently unrecoverable.
|
|
29
|
+
*
|
|
30
|
+
* Its consequences are deliberate recovery semantics, not guarantees about
|
|
31
|
+
* restored content:
|
|
32
|
+
*
|
|
33
|
+
* - `beforeUpdate` hooks on a restore run inside the exemption, so content
|
|
34
|
+
* they produce is not validated either.
|
|
35
|
+
* - A workflow whose `defaultStatus` is `published` republishes restored
|
|
36
|
+
* content that fails today's validation.
|
|
37
|
+
*
|
|
38
|
+
* The restored version is still subject to authorization, revision and version
|
|
39
|
+
* ownership checks, hooks, and storage constraints, and the next ordinary
|
|
40
|
+
* content save enforces current validation in full. Schema-aware
|
|
41
|
+
* reconstruction and repair-before-commit are separate future work; skipping
|
|
42
|
+
* validation does not address them.
|
|
43
|
+
*/
|
|
44
|
+
function assertWritableContent(ctx, write) {
|
|
45
|
+
if (write.action === 'restore')
|
|
46
|
+
return;
|
|
47
|
+
assertDocumentFields(ctx.definition.fields, write.documentData, write.locale ?? ctx.defaultLocale, ctx.logger);
|
|
48
|
+
}
|
|
13
49
|
/**
|
|
14
50
|
* Persist the first version of a logical document with lifecycle-owned
|
|
15
51
|
* registration and actor metadata. Authentication, hooks, normalization,
|
|
@@ -17,6 +53,7 @@ import { appendTreeRoot } from './tree.js';
|
|
|
17
53
|
* so their sequencing remains explicit.
|
|
18
54
|
*/
|
|
19
55
|
export async function persistInitialDocumentVersion(ctx, write) {
|
|
56
|
+
assertWritableContent(ctx, write);
|
|
20
57
|
return requireAuditCapability(ctx.db).withTransaction(async () => {
|
|
21
58
|
await ctx.db.commands.collections.lockCollectionRegistration(ctx.collectionId, ctx.definition.tree === true || ctx.definition.singleton === true ? 'exclusive' : 'shared');
|
|
22
59
|
const result = await ctx.db.commands.documents.createDocumentVersion({
|
|
@@ -37,6 +74,7 @@ export async function persistInitialDocumentVersion(ctx, write) {
|
|
|
37
74
|
* hook sequencing remains in the caller.
|
|
38
75
|
*/
|
|
39
76
|
export function persistExistingDocumentVersion(ctx, write) {
|
|
77
|
+
assertWritableContent(ctx, write);
|
|
40
78
|
return commitContentVersionWithScheduleSuspension({
|
|
41
79
|
ctx,
|
|
42
80
|
documentId: write.documentId,
|
|
@@ -5,12 +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 './context.js';
|
|
9
10
|
export interface RestoreVersionResult {
|
|
10
11
|
revision: number;
|
|
11
12
|
documentId: string;
|
|
12
13
|
documentVersionId: string;
|
|
13
14
|
sourceVersionId: string;
|
|
15
|
+
/**
|
|
16
|
+
* Ways the restored content fails today's field validation. Restore is
|
|
17
|
+
* exempt from that gate, so these never block it — they are reported so the
|
|
18
|
+
* editor can say what must be fixed before the next ordinary save, which is
|
|
19
|
+
* validated in full.
|
|
20
|
+
*/
|
|
21
|
+
validationIssues?: DocumentFieldIssue[];
|
|
14
22
|
}
|
|
15
23
|
/**
|
|
16
24
|
* Restore a historical document version as the new current version.
|
|
@@ -23,9 +31,22 @@ export interface RestoreVersionResult {
|
|
|
23
31
|
* `current_documents` view (`ROW_NUMBER() OVER PARTITION BY document_id
|
|
24
32
|
* ORDER BY created_at DESC`) automatically promotes the new row to current.
|
|
25
33
|
*
|
|
26
|
-
* Status is hard-defaulted to the
|
|
27
|
-
*
|
|
28
|
-
*
|
|
34
|
+
* Status is hard-defaulted to the collection's configured default status.
|
|
35
|
+
* For the standard editorial workflow that is `draft`, so restoring an old
|
|
36
|
+
* `published` version does not re-publish it and the user runs the restored
|
|
37
|
+
* draft through the normal workflow. It is NOT a guarantee: a workflow whose
|
|
38
|
+
* `defaultStatus` is `published` — `SINGLE_STATUS_WORKFLOW`, or any workflow
|
|
39
|
+
* configured that way — restores directly to published.
|
|
40
|
+
*
|
|
41
|
+
* Restore is exempt from the field-validation gate that every other versioned
|
|
42
|
+
* write passes through (see `assertWritableContent`). Historical content was
|
|
43
|
+
* persisted under earlier schema and validation rules, and cannot be corrected
|
|
44
|
+
* before it is restored; enforcing today's rules would make it permanently
|
|
45
|
+
* unrecoverable. Combined with the status default above, that means a
|
|
46
|
+
* published-default workflow can republish restored content which fails
|
|
47
|
+
* current validation, and `beforeUpdate` hooks on a restore run inside the
|
|
48
|
+
* same exemption. Both are accepted recovery semantics. The issues are
|
|
49
|
+
* computed and returned as `validationIssues` for the editor to report.
|
|
29
50
|
*
|
|
30
51
|
* `path` is sticky from the previous current version (not from the source),
|
|
31
52
|
* matching the semantics of `updateDocument`. A path change made between
|
|
@@ -9,6 +9,7 @@ import { resolveHooks } from '../../@types/index.js';
|
|
|
9
9
|
import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
|
|
10
10
|
import { ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_VALIDATION } from '../../lib/errors.js';
|
|
11
11
|
import { withLogContext } from '../../lib/logger.js';
|
|
12
|
+
import { validateDocumentFields } from '../../validation/document-fields.js';
|
|
12
13
|
import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
13
14
|
import { runCommittedDocumentHook } from './committed-hook.js';
|
|
14
15
|
import { applyRichTextEmbed, extractDocumentId, extractVersionId, invokeHook } from './internals.js';
|
|
@@ -25,9 +26,22 @@ import { commitGuardedDocumentMutation, readDocumentForMutation } from './revisi
|
|
|
25
26
|
* `current_documents` view (`ROW_NUMBER() OVER PARTITION BY document_id
|
|
26
27
|
* ORDER BY created_at DESC`) automatically promotes the new row to current.
|
|
27
28
|
*
|
|
28
|
-
* Status is hard-defaulted to the
|
|
29
|
-
*
|
|
30
|
-
*
|
|
29
|
+
* Status is hard-defaulted to the collection's configured default status.
|
|
30
|
+
* For the standard editorial workflow that is `draft`, so restoring an old
|
|
31
|
+
* `published` version does not re-publish it and the user runs the restored
|
|
32
|
+
* draft through the normal workflow. It is NOT a guarantee: a workflow whose
|
|
33
|
+
* `defaultStatus` is `published` — `SINGLE_STATUS_WORKFLOW`, or any workflow
|
|
34
|
+
* configured that way — restores directly to published.
|
|
35
|
+
*
|
|
36
|
+
* Restore is exempt from the field-validation gate that every other versioned
|
|
37
|
+
* write passes through (see `assertWritableContent`). Historical content was
|
|
38
|
+
* persisted under earlier schema and validation rules, and cannot be corrected
|
|
39
|
+
* before it is restored; enforcing today's rules would make it permanently
|
|
40
|
+
* unrecoverable. Combined with the status default above, that means a
|
|
41
|
+
* published-default workflow can republish restored content which fails
|
|
42
|
+
* current validation, and `beforeUpdate` hooks on a restore run inside the
|
|
43
|
+
* same exemption. Both are accepted recovery semantics. The issues are
|
|
44
|
+
* computed and returned as `validationIssues` for the editor to report.
|
|
31
45
|
*
|
|
32
46
|
* `path` is sticky from the previous current version (not from the source),
|
|
33
47
|
* matching the semantics of `updateDocument`. A path change made between
|
|
@@ -119,6 +133,15 @@ export async function restoreDocumentVersion(ctx, params) {
|
|
|
119
133
|
//
|
|
120
134
|
// No `path` is passed: restore does not change the document's path
|
|
121
135
|
// (the existing byline_document_paths row stays as-is — sticky).
|
|
136
|
+
// Restore is exempt from the content-validation gate (see
|
|
137
|
+
// assertWritableContent). Validate anyway, for reporting only: the
|
|
138
|
+
// editor needs to know what today's rules object to before its next
|
|
139
|
+
// save, which is validated in full. Run it after the hook and embed
|
|
140
|
+
// pass so it describes exactly what is about to be persisted.
|
|
141
|
+
const validationIssues = validateDocumentFields(definition.fields, sourceFields, {
|
|
142
|
+
locale: 'all',
|
|
143
|
+
onCallbackError: ({ path, site, error }) => ctx.logger.error({ err: error, fieldPath: path, site }, 'restore diagnostic callback threw'),
|
|
144
|
+
});
|
|
122
145
|
const committed = await commitGuardedDocumentMutation(ctx, { ...params, previousVersionId, locale: 'all' }, async () => {
|
|
123
146
|
const result = await persistExistingDocumentVersion(ctx, {
|
|
124
147
|
documentId: params.documentId,
|
|
@@ -150,6 +173,7 @@ export async function restoreDocumentVersion(ctx, params) {
|
|
|
150
173
|
documentVersionId,
|
|
151
174
|
sourceVersionId: params.sourceVersionId,
|
|
152
175
|
revision,
|
|
176
|
+
...(validationIssues.length > 0 ? { validationIssues } : {}),
|
|
153
177
|
};
|
|
154
178
|
});
|
|
155
179
|
}
|