@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.
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 +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
- * Whether a given widget honours this flag is per-widget — most ui-kit
201
- * input components support a native readOnly state, and widgets are
202
- * being progressively updated to forward this prop through.
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.describe(rule.message || 'Invalid email address');
13
+ return acc.email(rule.message || 'Invalid email address');
10
14
  case 'url':
11
- return acc.describe(rule.message || 'Invalid URL');
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?.pattern) {
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
- schema = z.number().int();
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
- schema = z.any().array();
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
- // Group fields are complex nested structures validated at the
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('leaves group children permissive inside items', () => {
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(true);
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,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 ?? getDefaultStatus(definition),
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 workflow's first status — restoring an
27
- * old `published` version must never silently re-publish content. The user
28
- * runs the restored draft through the normal workflow.
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 workflow's first status — restoring an
29
- * old `published` version must never silently re-publish content. The user
30
- * runs the restored draft through the normal workflow.
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
  }