@byline/host-tanstack-start 6.3.0 → 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.
@@ -8,6 +8,7 @@ import { getAdminRoutePath } from "../../routes/admin-path.js";
8
8
  import { clearListReturnState } from "../../routes/list-return-storage.js";
9
9
  import { cancelCollectionDocumentScheduledPublish, confirmCollectionDocumentScheduledPublish, copyDocumentToLocale, deleteDocument, deleteDocumentLocale, duplicateCollectionDocument, hasCommittedDocumentHookFailure, hasDeleteSideEffectFailures, scheduleCollectionDocumentPublish, unpublishDocument, updateCollectionDocumentWithPatches, updateDocumentStatus } from "../../server-fns/collections/index.js";
10
10
  import { useNavigate } from "../chrome/loose-router.js";
11
+ import { describeMutationFailure } from "../describe-mutation-failure.js";
11
12
  import { useDocumentMutationState } from "../document-mutation-state.js";
12
13
  import { useTanStackNavigationGuard } from "./tanstack-navigation-guard.js";
13
14
  import { ViewMenu } from "./view-menu.js";
@@ -432,7 +433,7 @@ const EditView = ({ collectionDefinition, adminConfig, initialData: loadedData,
432
433
  }
433
434
  console.error('Duplicate error:', err);
434
435
  const description = t("collections.edit.duplicateFailedDescription", {
435
- message: t('documentConcurrency.failed')
436
+ message: describeMutationFailure(err, t)
436
437
  });
437
438
  toastManager.add({
438
439
  title: t('collections.edit.duplicateTitle', {
@@ -533,7 +534,7 @@ const EditView = ({ collectionDefinition, adminConfig, initialData: loadedData,
533
534
  }
534
535
  console.error('Copy to locale error:', err);
535
536
  const description = t("collections.edit.copyFailedDescription", {
536
- message: t('documentConcurrency.failed')
537
+ message: describeMutationFailure(err, t)
537
538
  });
538
539
  toastManager.add({
539
540
  title: t('collections.edit.copyToLocaleTitle', {
@@ -17,6 +17,19 @@ function RestoreVersionModal({ disabled = false, onMutationError, versionLabel,
17
17
  setError(null);
18
18
  try {
19
19
  const result = await restoreVersion();
20
+ const issues = getRestoreValidationIssues(result);
21
+ if (issues.length > 0) toastManager.add({
22
+ title: t('collections.restore.validationIssuesToast'),
23
+ description: t("collections.restore.validationIssuesDescription", {
24
+ fields: issues.join(', ')
25
+ }),
26
+ data: {
27
+ intent: 'warning',
28
+ iconType: 'warning',
29
+ icon: true,
30
+ close: true
31
+ }
32
+ });
20
33
  if (hasCommittedDocumentHookFailure(result)) toastManager.add({
21
34
  title: t('collections.save.hookFailedToast'),
22
35
  description: t("collections.save.hookFailedDescription"),
@@ -126,6 +139,12 @@ function RestoreVersionModal({ disabled = false, onMutationError, versionLabel,
126
139
  ]
127
140
  });
128
141
  }
142
+ function getRestoreValidationIssues(result) {
143
+ if (null == result || 'object' != typeof result) return [];
144
+ const issues = result.validationIssues;
145
+ if (!Array.isArray(issues)) return [];
146
+ return issues.map((issue)=>null != issue && 'object' == typeof issue && 'string' == typeof issue.field ? issue.field : '').filter((field)=>field.length > 0);
147
+ }
129
148
  function getErrorCode(err) {
130
149
  return 'string' == typeof err?.code ? err.code : null;
131
150
  }
@@ -0,0 +1,18 @@
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 { TranslationValues } from '@byline/i18n';
9
+ /**
10
+ * Describe a failed document mutation for the editor.
11
+ *
12
+ * Content writes that are not restores — create, update, patch saves,
13
+ * duplication and locale copies — keep the field-validation gate, because each
14
+ * reads or authors content the editor can open and correct. Naming the
15
+ * offending fields is what makes that correction actionable; without it the
16
+ * editor sees only a generic failure.
17
+ */
18
+ export declare function describeMutationFailure(err: unknown, t: (key: string, values?: TranslationValues) => string): string;
@@ -0,0 +1,9 @@
1
+ import { getDocumentFieldValidationDetails } from "@byline/core";
2
+ function describeMutationFailure(err, t) {
3
+ const validation = getDocumentFieldValidationDetails(err);
4
+ if (null == validation) return t('documentConcurrency.failed');
5
+ return t('collections.edit.invalidFieldsMessage', {
6
+ fields: validation.issues.map((issue)=>issue.field || '?').join(', ')
7
+ });
8
+ }
9
+ export { describeMutationFailure };
@@ -10,6 +10,7 @@ import { hasCommittedDocumentHookFailure } from "../../server-fns/collections/in
10
10
  import { cancelSingletonScheduledPublish, changeSingletonStatus, confirmSingletonScheduledPublish, copySingletonToLocale, scheduleSingletonPublish, unpublishSingleton, updateSingleton } from "../../server-fns/singletons/index.js";
11
11
  import { useNavigate } from "../chrome/loose-router.js";
12
12
  import { useTanStackNavigationGuard } from "../collections/tanstack-navigation-guard.js";
13
+ import { describeMutationFailure } from "../describe-mutation-failure.js";
13
14
  import { useDocumentMutationState } from "../document-mutation-state.js";
14
15
  import { SingletonViewMenu } from "./view-menu.js";
15
16
  function SingletonView({ singletonDefinition, adminConfig, document: loadedDocument, initialData, locale, contentLocales, defaultContentLocale }) {
@@ -201,7 +202,7 @@ function SingletonView({ singletonDefinition, adminConfig, document: loadedDocum
201
202
  toast(t('collections.edit.copyToLocaleTitle', {
202
203
  label
203
204
  }), t("collections.edit.copyFailedDescription", {
204
- message: t('documentConcurrency.failed')
205
+ message: describeMutationFailure(err, t)
205
206
  }), 'danger');
206
207
  throw err;
207
208
  }
@@ -2,15 +2,17 @@ import { createServerFn } from "@tanstack/react-start";
2
2
  import { getAdminRequestContext } from "@byline/client/server";
3
3
  import { ERR_NOT_FOUND, getLogger, getServerConfig } from "@byline/core";
4
4
  import { createDocument } from "@byline/core/services";
5
+ import { getDefaultStatus } from "@byline/core/workflow";
5
6
  import { ensureCollection } from "../../integrations/api-utils.js";
6
7
  import { adminSessionMiddleware } from "../../integrations/session-middleware.js";
7
8
  import { withDocumentMutationErrors } from "../document-mutation-errors.js";
9
+ import { validateDocumentWriteInput } from "../document-write-input.js";
8
10
  import { toCommittedDocumentHookFailureResponse } from "./save-outcome.js";
9
11
  const createCollectionDocument = createServerFn({
10
12
  method: 'POST'
11
13
  }).middleware([
12
14
  adminSessionMiddleware
13
- ]).validator((input)=>input).handler(withDocumentMutationErrors(async ({ data: input })=>{
15
+ ]).validator((input)=>validateDocumentWriteInput(input, 'create')).handler(withDocumentMutationErrors(async ({ data: input })=>{
14
16
  const { collection: path, data: documentData, locale, path: explicitPath, availableLocales } = input;
15
17
  const logger = getLogger();
16
18
  const config = await ensureCollection(path);
@@ -36,7 +38,7 @@ const createCollectionDocument = createServerFn({
36
38
  try {
37
39
  result = await createDocument(ctx, {
38
40
  data: structuredClone(documentData),
39
- status: documentData.status,
41
+ status: getDefaultStatus(config.definition),
40
42
  locale: locale ?? serverConfig.i18n.content.defaultLocale,
41
43
  path: explicitPath,
42
44
  availableLocales
@@ -23,4 +23,5 @@ export declare const restoreDocumentVersion: import("@tanstack/react-start").Req
23
23
  revision: number;
24
24
  documentVersionId: string;
25
25
  sourceVersionId: string;
26
+ validationIssues?: import("@byline/core").DocumentFieldIssue[] | undefined;
26
27
  }>>;
@@ -43,7 +43,10 @@ const restore_version_restoreDocumentVersion = createServerFn({
43
43
  documentId: result.documentId,
44
44
  revision: result.revision,
45
45
  documentVersionId: result.documentVersionId,
46
- sourceVersionId: result.sourceVersionId
46
+ sourceVersionId: result.sourceVersionId,
47
+ ...result.validationIssues ? {
48
+ validationIssues: result.validationIssues
49
+ } : {}
47
50
  };
48
51
  } catch (error) {
49
52
  const committedFailure = toCommittedDocumentHookFailureResponse(error);
@@ -5,12 +5,13 @@ import { saveDocument, updateDocumentSystemFields } from "@byline/core/services"
5
5
  import { ensureCollection } from "../../integrations/api-utils.js";
6
6
  import { adminSessionMiddleware } from "../../integrations/session-middleware.js";
7
7
  import { withDocumentMutationErrors } from "../document-mutation-errors.js";
8
+ import { validateDocumentWriteInput } from "../document-write-input.js";
8
9
  import { toCommittedDocumentHookFailureResponse } from "./save-outcome.js";
9
10
  const updateCollectionDocumentWithPatches = createServerFn({
10
11
  method: 'POST'
11
12
  }).middleware([
12
13
  adminSessionMiddleware
13
- ]).validator((input)=>input).handler(withDocumentMutationErrors(async ({ data: input })=>{
14
+ ]).validator((input)=>validateDocumentWriteInput(input, 'patch')).handler(withDocumentMutationErrors(async ({ data: input })=>{
14
15
  const { collection: path, id, patches, expectedRevision, locale, path: explicitPath, availableLocales } = input;
15
16
  const logger = getLogger();
16
17
  const config = await ensureCollection(path);
@@ -60,7 +61,7 @@ const updateCollectionDocumentSystemFields = createServerFn({
60
61
  method: 'POST'
61
62
  }).middleware([
62
63
  adminSessionMiddleware
63
- ]).validator((input)=>input).handler(withDocumentMutationErrors(async ({ data: input })=>{
64
+ ]).validator((input)=>validateDocumentWriteInput(input, 'metadata')).handler(withDocumentMutationErrors(async ({ data: input })=>{
64
65
  const { collection: path, id, expectedRevision, locale, path: explicitPath, availableLocales } = input;
65
66
  const logger = getLogger();
66
67
  const config = await ensureCollection(path);
@@ -1,4 +1,4 @@
1
- import { BylineError, ErrorCodes, getDocumentRevisionValidationDetails, getDocumentStaleDetails, getLockConflictDetails } from "@byline/core";
1
+ import { BylineError, ErrorCodes, getDocumentFieldValidationDetails, getDocumentRevisionValidationDetails, getDocumentStaleDetails, getLockConflictDetails } from "@byline/core";
2
2
  import { getDocumentHookCommittedDetails } from "@byline/core/services";
3
3
  import { committedTreeReceipt } from "./structural-receipt.js";
4
4
  function documentMutationError(error) {
@@ -14,6 +14,12 @@ function documentMutationError(error) {
14
14
  message: 'Reload this document before making changes.',
15
15
  details: validation
16
16
  };
17
+ const fields = getDocumentFieldValidationDetails(error);
18
+ if (fields) return {
19
+ code: ErrorCodes.VALIDATION,
20
+ message: 'Some document fields are invalid.',
21
+ details: fields
22
+ };
17
23
  const lock = getLockConflictDetails(error);
18
24
  if (lock) return {
19
25
  code: ErrorCodes.LOCK_CONFLICT,
@@ -0,0 +1,9 @@
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
+ /** Shape checks complement lifecycle authorization, revision and field validation. */
9
+ export declare function validateDocumentWriteInput<T>(input: T, mode: 'create' | 'patch' | 'metadata'): T;
@@ -0,0 +1,44 @@
1
+ import { ERR_VALIDATION, parseInstancePath } from "@byline/core";
2
+ const object = (value)=>null !== value && 'object' == typeof value && !Array.isArray(value);
3
+ const document_write_input_text = (value)=>'string' == typeof value && value.trim().length > 0;
4
+ const index = (value)=>Number.isSafeInteger(value) && value >= 0;
5
+ const path = (value)=>document_write_input_text(value) && parseInstancePath(value).ok;
6
+ function patches(value) {
7
+ return Array.isArray(value) && value.every((patch)=>{
8
+ if (!object(patch) || !path(patch.path)) return false;
9
+ if (void 0 !== patch.opId && !document_write_input_text(patch.opId)) return false;
10
+ if (void 0 !== patch.timestamp && !document_write_input_text(patch.timestamp)) return false;
11
+ switch(patch.kind){
12
+ case 'field.set':
13
+ return 'value' in patch;
14
+ case 'field.clear':
15
+ return true;
16
+ case 'array.insert':
17
+ return 'item' in patch && (void 0 === patch.index || index(patch.index));
18
+ case 'array.move':
19
+ return document_write_input_text(patch.itemId) && index(patch.toIndex);
20
+ case 'array.remove':
21
+ return document_write_input_text(patch.itemId);
22
+ case 'array.updateItem':
23
+ return document_write_input_text(patch.itemId) && patches(patch.patches);
24
+ case 'block.add':
25
+ return document_write_input_text(patch.blockType) && (void 0 === patch.index || index(patch.index));
26
+ case 'block.move':
27
+ return document_write_input_text(patch.blockId) && index(patch.toIndex);
28
+ case 'block.remove':
29
+ return document_write_input_text(patch.blockId);
30
+ case 'block.updateField':
31
+ return document_write_input_text(patch.blockId) && path(patch.fieldPath) && 'value' in patch;
32
+ default:
33
+ return false;
34
+ }
35
+ });
36
+ }
37
+ function validateDocumentWriteInput(input, mode) {
38
+ const valid = object(input) && document_write_input_text(input.collection) && ('create' === mode ? object(input.data) : document_write_input_text(input.id)) && (void 0 === input.locale || document_write_input_text(input.locale)) && (null == input.path || 'string' == typeof input.path) && (void 0 === input.availableLocales || Array.isArray(input.availableLocales) && input.availableLocales.every(document_write_input_text)) && ('patch' !== mode || patches(input.patches));
39
+ if (!valid) throw ERR_VALIDATION({
40
+ message: 'Invalid document write request.'
41
+ });
42
+ return input;
43
+ }
44
+ export { validateDocumentWriteInput };
@@ -15,4 +15,4 @@ export declare const restoreSingletonVersion: import("@tanstack/react-start").Re
15
15
  expectedRevision: number;
16
16
  singleton: string;
17
17
  versionId: string;
18
- }, Promise<import("@byline/core").SingletonSaveResult>>;
18
+ }, Promise<import("../collections/save-outcome.js").CollectionDocumentCommittedHookFailureResponse | import("@byline/core").RestoreSingletonVersionResult>>;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "private": false,
4
4
  "type": "module",
5
5
  "license": "MPL-2.0",
6
- "version": "6.3.0",
6
+ "version": "6.4.0",
7
7
  "engines": {
8
8
  "node": ">=20.9.0"
9
9
  },
@@ -124,16 +124,16 @@
124
124
  "clsx": "^2.1.1",
125
125
  "react-json-view-lite": "^2.5.0",
126
126
  "react-swipeable": "^7.0.2",
127
- "zod": "^4.6.2",
128
- "@byline/admin": "6.3.0",
129
- "@byline/analytics": "6.3.0",
130
- "@byline/analytics-agent": "6.3.0",
131
- "@byline/ai": "6.3.0",
132
- "@byline/client": "6.3.0",
133
- "@byline/auth": "6.3.0",
134
- "@byline/i18n": "6.3.0",
135
- "@byline/ui": "6.3.0",
136
- "@byline/core": "6.3.0"
127
+ "zod": "^4.6.5",
128
+ "@byline/ai": "6.4.0",
129
+ "@byline/analytics": "6.4.0",
130
+ "@byline/analytics-agent": "6.4.0",
131
+ "@byline/client": "6.4.0",
132
+ "@byline/ui": "6.4.0",
133
+ "@byline/admin": "6.4.0",
134
+ "@byline/auth": "6.4.0",
135
+ "@byline/core": "6.4.0",
136
+ "@byline/i18n": "6.4.0"
137
137
  },
138
138
  "peerDependencies": {
139
139
  "@tanstack/react-router": "^1.167.0",
@@ -145,10 +145,10 @@
145
145
  "devDependencies": {
146
146
  "@biomejs/biome": "2.5.13",
147
147
  "@rsbuild/plugin-react": "^2.1.0",
148
- "@rslib/core": "^1.0.0",
149
- "@tanstack/react-router": "^1.170.35",
150
- "@tanstack/react-start": "^1.168.52",
151
- "@types/node": "^26.5.1",
148
+ "@rslib/core": "^1.0.1",
149
+ "@tanstack/react-router": "^1.170.36",
150
+ "@tanstack/react-start": "^1.168.54",
151
+ "@types/node": "^26.6.1",
152
152
  "@types/react": "19.3.0",
153
153
  "@types/react-dom": "19.3.0",
154
154
  "@vitejs/plugin-react": "^6.1.1",
@@ -159,7 +159,7 @@
159
159
  "typescript": "^7.0.2",
160
160
  "typescript-plugin-css-modules": "^5.2.0",
161
161
  "vite": "^8.3.0",
162
- "vitest": "^5.0.0"
162
+ "vitest": "^5.0.1"
163
163
  },
164
164
  "publishConfig": {
165
165
  "access": "public",
@@ -37,6 +37,7 @@ import {
37
37
  updateDocumentStatus,
38
38
  } from '../../server-fns/collections/index.js'
39
39
  import { useNavigate } from '../chrome/loose-router.js'
40
+ import { describeMutationFailure } from '../describe-mutation-failure.js'
40
41
  import { useDocumentMutationState } from '../document-mutation-state.js'
41
42
  import { useTanStackNavigationGuard } from './tanstack-navigation-guard.js'
42
43
  import { ViewMenu } from './view-menu.js'
@@ -430,7 +431,7 @@ export const EditView = ({
430
431
  }
431
432
  console.error('Duplicate error:', err)
432
433
  const description = t('collections.edit.duplicateFailedDescription', {
433
- message: t('documentConcurrency.failed'),
434
+ message: describeMutationFailure(err, t),
434
435
  })
435
436
  toastManager.add({
436
437
  title: t('collections.edit.duplicateTitle', { label: singular }),
@@ -526,7 +527,7 @@ export const EditView = ({
526
527
  }
527
528
  console.error('Copy to locale error:', err)
528
529
  const description = t('collections.edit.copyFailedDescription', {
529
- message: t('documentConcurrency.failed'),
530
+ message: describeMutationFailure(err, t),
530
531
  })
531
532
  toastManager.add({
532
533
  title: t('collections.edit.copyToLocaleTitle', { label: singular }),
@@ -57,6 +57,18 @@ export function RestoreVersionModal({
57
57
  setError(null)
58
58
  try {
59
59
  const result = await restoreVersion()
60
+ // Restore is exempt from field validation, so this never blocks it. Name
61
+ // the fields so the editor understands why the next save is refused.
62
+ const issues = getRestoreValidationIssues(result)
63
+ if (issues.length > 0) {
64
+ toastManager.add({
65
+ title: t('collections.restore.validationIssuesToast'),
66
+ description: t('collections.restore.validationIssuesDescription', {
67
+ fields: issues.join(', '),
68
+ }),
69
+ data: { intent: 'warning', iconType: 'warning', icon: true, close: true },
70
+ })
71
+ }
60
72
  if (hasCommittedDocumentHookFailure(result)) {
61
73
  toastManager.add({
62
74
  title: t('collections.save.hookFailedToast'),
@@ -146,6 +158,20 @@ export function RestoreVersionModal({
146
158
  )
147
159
  }
148
160
 
161
+ /** Field paths of the restored content that today's validation rejects. */
162
+ function getRestoreValidationIssues(result: unknown): string[] {
163
+ if (result == null || typeof result !== 'object') return []
164
+ const issues = (result as { validationIssues?: unknown }).validationIssues
165
+ if (!Array.isArray(issues)) return []
166
+ return issues
167
+ .map((issue) =>
168
+ issue != null && typeof issue === 'object' && typeof (issue as any).field === 'string'
169
+ ? (issue as { field: string }).field
170
+ : ''
171
+ )
172
+ .filter((field) => field.length > 0)
173
+ }
174
+
149
175
  function getErrorCode(err: unknown): string | null {
150
176
  return typeof (err as { code?: unknown })?.code === 'string'
151
177
  ? (err as { code: string }).code
@@ -0,0 +1,30 @@
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
+
9
+ import { getDocumentFieldValidationDetails } from '@byline/core'
10
+ import type { TranslationValues } from '@byline/i18n'
11
+
12
+ /**
13
+ * Describe a failed document mutation for the editor.
14
+ *
15
+ * Content writes that are not restores — create, update, patch saves,
16
+ * duplication and locale copies — keep the field-validation gate, because each
17
+ * reads or authors content the editor can open and correct. Naming the
18
+ * offending fields is what makes that correction actionable; without it the
19
+ * editor sees only a generic failure.
20
+ */
21
+ export function describeMutationFailure(
22
+ err: unknown,
23
+ t: (key: string, values?: TranslationValues) => string
24
+ ): string {
25
+ const validation = getDocumentFieldValidationDetails(err)
26
+ if (validation == null) return t('documentConcurrency.failed')
27
+ return t('collections.edit.invalidFieldsMessage', {
28
+ fields: validation.issues.map((issue) => issue.field || '?').join(', '),
29
+ })
30
+ }
@@ -37,6 +37,7 @@ import {
37
37
  } from '../../server-fns/singletons/index.js'
38
38
  import { useNavigate } from '../chrome/loose-router.js'
39
39
  import { useTanStackNavigationGuard } from '../collections/tanstack-navigation-guard.js'
40
+ import { describeMutationFailure } from '../describe-mutation-failure.js'
40
41
  import { useDocumentMutationState } from '../document-mutation-state.js'
41
42
  import { SingletonViewMenu } from './view-menu.js'
42
43
  import type { ContentLocaleOption } from '../collections/view-menu.js'
@@ -287,7 +288,7 @@ export function SingletonView({
287
288
  }
288
289
  toast(
289
290
  t('collections.edit.copyToLocaleTitle', { label }),
290
- t('collections.edit.copyFailedDescription', { message: t('documentConcurrency.failed') }),
291
+ t('collections.edit.copyFailedDescription', { message: describeMutationFailure(err, t) }),
291
292
  'danger'
292
293
  )
293
294
  throw err
@@ -12,10 +12,12 @@ import { getAdminRequestContext } from '@byline/client/server'
12
12
  import { ERR_NOT_FOUND, getLogger, getServerConfig } from '@byline/core'
13
13
  import type { CreateDocumentResult, DocumentLifecycleContext } from '@byline/core/services'
14
14
  import { createDocument } from '@byline/core/services'
15
+ import { getDefaultStatus } from '@byline/core/workflow'
15
16
 
16
17
  import { ensureCollection } from '../../integrations/api-utils.js'
17
18
  import { adminSessionMiddleware } from '../../integrations/session-middleware.js'
18
19
  import { withDocumentMutationErrors } from '../document-mutation-errors.js'
20
+ import { validateDocumentWriteInput } from '../document-write-input.js'
19
21
  import { toCommittedDocumentHookFailureResponse } from './save-outcome.js'
20
22
 
21
23
  // ---------------------------------------------------------------------------
@@ -31,7 +33,7 @@ export const createCollectionDocument = createServerFn({ method: 'POST' })
31
33
  locale?: string
32
34
  path?: string
33
35
  availableLocales?: string[]
34
- }) => input
36
+ }) => validateDocumentWriteInput(input, 'create')
35
37
  )
36
38
  .handler(
37
39
  withDocumentMutationErrors(async ({ data: input }) => {
@@ -68,7 +70,8 @@ export const createCollectionDocument = createServerFn({ method: 'POST' })
68
70
  try {
69
71
  result = await createDocument(ctx, {
70
72
  data: structuredClone(documentData),
71
- status: documentData.status,
73
+ // The admin create flow always uses the configured initial status.
74
+ status: getDefaultStatus(config.definition),
72
75
  locale: locale ?? serverConfig.i18n.content.defaultLocale,
73
76
  path: explicitPath,
74
77
  availableLocales,
@@ -66,6 +66,10 @@ export const restoreDocumentVersion = createServerFn({ method: 'POST' })
66
66
  revision: result.revision,
67
67
  documentVersionId: result.documentVersionId,
68
68
  sourceVersionId: result.sourceVersionId,
69
+ // Restore is exempt from field validation; these describe what
70
+ // today's rules object to, so the editor can say what must be fixed
71
+ // before the next ordinary save.
72
+ ...(result.validationIssues ? { validationIssues: result.validationIssues } : {}),
69
73
  }
70
74
  } catch (error) {
71
75
  const committedFailure = toCommittedDocumentHookFailureResponse(error)
@@ -17,6 +17,7 @@ import { saveDocument, updateDocumentSystemFields } from '@byline/core/services'
17
17
  import { ensureCollection } from '../../integrations/api-utils.js'
18
18
  import { adminSessionMiddleware } from '../../integrations/session-middleware.js'
19
19
  import { withDocumentMutationErrors } from '../document-mutation-errors.js'
20
+ import { validateDocumentWriteInput } from '../document-write-input.js'
20
21
  import { toCommittedDocumentHookFailureResponse } from './save-outcome.js'
21
22
 
22
23
  // ---------------------------------------------------------------------------
@@ -35,7 +36,7 @@ export const updateCollectionDocumentWithPatches = createServerFn({ method: 'POS
35
36
  path?: string | null
36
37
  availableLocales?: string[]
37
38
  locale?: string
38
- }) => input
39
+ }) => validateDocumentWriteInput(input, 'patch')
39
40
  )
40
41
  .handler(
41
42
  withDocumentMutationErrors(async ({ data: input }) => {
@@ -115,7 +116,7 @@ export const updateCollectionDocumentSystemFields = createServerFn({ method: 'PO
115
116
  path?: string | null
116
117
  /** Editorial advertised-locale set; omitted means no advertised-locale write. */
117
118
  availableLocales?: string[]
118
- }) => input
119
+ }) => validateDocumentWriteInput(input, 'metadata')
119
120
  )
120
121
  .handler(
121
122
  withDocumentMutationErrors(async ({ data: input }) => {
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  BylineError,
3
3
  ErrorCodes,
4
+ getDocumentFieldValidationDetails,
4
5
  getDocumentRevisionValidationDetails,
5
6
  getDocumentStaleDetails,
6
7
  getLockConflictDetails,
@@ -27,6 +28,13 @@ export function documentMutationError(error: unknown): unknown {
27
28
  message: 'Reload this document before making changes.',
28
29
  details: validation,
29
30
  }
31
+ const fields = getDocumentFieldValidationDetails(error)
32
+ if (fields)
33
+ return {
34
+ code: ErrorCodes.VALIDATION,
35
+ message: 'Some document fields are invalid.',
36
+ details: fields,
37
+ }
30
38
  const lock = getLockConflictDetails(error)
31
39
  if (lock)
32
40
  return {
@@ -9,6 +9,7 @@ import {
9
9
  ERR_NOT_FOUND,
10
10
  ERR_TREE_HOOK_COMMITTED,
11
11
  ERR_VALIDATION,
12
+ getDocumentFieldValidationDetails,
12
13
  getDocumentRevisionValidationDetails,
13
14
  getDocumentStaleDetails,
14
15
  getLockConflictDetails,
@@ -209,3 +210,43 @@ it('transports unavailable-document errors without provider details', async () =
209
210
  })
210
211
  expect(wire).not.toMatch(/private|internal/)
211
212
  })
213
+
214
+ describe('field validation over the installed transport', () => {
215
+ it('retains stable field paths and messages while dropping diagnostics', async () => {
216
+ const issues = [
217
+ { field: 'items[id=item-1].title', message: 'Title is required', kind: 'required' as const },
218
+ ]
219
+ const { value, wire } = await roundTrip({}, async () => {
220
+ throw ERR_VALIDATION({
221
+ message: 'private detail',
222
+ details: { reason: 'invalid_document_fields', issues, sql: 'SELECT secret' },
223
+ cause: new Error('private cause'),
224
+ })
225
+ })
226
+ expect(getDocumentFieldValidationDetails(value.error)).toEqual({
227
+ reason: 'invalid_document_fields',
228
+ issues,
229
+ })
230
+ expect(wire).not.toMatch(/private detail|SELECT secret|private cause/)
231
+ expect(getDocumentRevisionValidationDetails(value.error)).toBeNull()
232
+ })
233
+
234
+ it('never lets a malformed payload smuggle a waivable kind across the wire', async () => {
235
+ const { value } = await roundTrip({}, async () => {
236
+ throw ERR_VALIDATION({
237
+ message: 'private detail',
238
+ details: {
239
+ reason: 'invalid_document_fields',
240
+ issues: [
241
+ { field: 'title', message: 'Title is required' },
242
+ { field: 'summary', message: 'Summary is wrong', kind: 'bogus' },
243
+ ],
244
+ },
245
+ })
246
+ })
247
+ expect(getDocumentFieldValidationDetails(value.error)?.issues.map((i) => i.kind)).toEqual([
248
+ 'invalid',
249
+ 'invalid',
250
+ ])
251
+ })
252
+ })
@@ -0,0 +1,48 @@
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
+
9
+ import { describe, expect, it } from 'vitest'
10
+
11
+ import { validateDocumentWriteInput } from './document-write-input.js'
12
+
13
+ describe('document write request shapes', () => {
14
+ it.each([null, {}, { collection: 'pages', data: [] }, { collection: 42, data: {} }])(
15
+ 'rejects malformed create requests: %j',
16
+ (input) => {
17
+ expect(() => validateDocumentWriteInput(input, 'create')).toThrow(
18
+ 'Invalid document write request'
19
+ )
20
+ }
21
+ )
22
+ it.each([
23
+ null,
24
+ [null],
25
+ [{ kind: 'field.set', path: 'title[' }],
26
+ [{ kind: 'array.move', path: 'items', itemId: 'a', toIndex: -1 }],
27
+ [{ kind: 'unknown', path: 'title' }],
28
+ ])('rejects malformed patches: %j', (patches) => {
29
+ expect(() =>
30
+ validateDocumentWriteInput({ collection: 'pages', id: 'doc', patches }, 'patch')
31
+ ).toThrow()
32
+ })
33
+ it('accepts nested patches and leaves revision checks to their dedicated contract', () => {
34
+ const input = {
35
+ collection: 'pages',
36
+ id: 'doc',
37
+ patches: [
38
+ {
39
+ kind: 'array.updateItem',
40
+ path: 'items',
41
+ itemId: 'a',
42
+ patches: [{ kind: 'field.set', path: 'title', value: 'Hello' }],
43
+ },
44
+ ],
45
+ }
46
+ expect(validateDocumentWriteInput(input, 'patch')).toBe(input)
47
+ })
48
+ })
@@ -0,0 +1,67 @@
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
+
9
+ import { ERR_VALIDATION, parseInstancePath } from '@byline/core'
10
+
11
+ const object = (value: unknown): value is Record<string, unknown> =>
12
+ value !== null && typeof value === 'object' && !Array.isArray(value)
13
+ const text = (value: unknown): value is string =>
14
+ typeof value === 'string' && value.trim().length > 0
15
+ const index = (value: unknown) => Number.isSafeInteger(value) && (value as number) >= 0
16
+ const path = (value: unknown) => text(value) && parseInstancePath(value).ok
17
+
18
+ function patches(value: unknown): boolean {
19
+ return (
20
+ Array.isArray(value) &&
21
+ value.every((patch) => {
22
+ if (!object(patch) || !path(patch.path)) return false
23
+ if (patch.opId !== undefined && !text(patch.opId)) return false
24
+ if (patch.timestamp !== undefined && !text(patch.timestamp)) return false
25
+ switch (patch.kind) {
26
+ case 'field.set':
27
+ return 'value' in patch
28
+ case 'field.clear':
29
+ return true
30
+ case 'array.insert':
31
+ return 'item' in patch && (patch.index === undefined || index(patch.index))
32
+ case 'array.move':
33
+ return text(patch.itemId) && index(patch.toIndex)
34
+ case 'array.remove':
35
+ return text(patch.itemId)
36
+ case 'array.updateItem':
37
+ return text(patch.itemId) && patches(patch.patches)
38
+ case 'block.add':
39
+ return text(patch.blockType) && (patch.index === undefined || index(patch.index))
40
+ case 'block.move':
41
+ return text(patch.blockId) && index(patch.toIndex)
42
+ case 'block.remove':
43
+ return text(patch.blockId)
44
+ case 'block.updateField':
45
+ return text(patch.blockId) && path(patch.fieldPath) && 'value' in patch
46
+ default:
47
+ return false
48
+ }
49
+ })
50
+ )
51
+ }
52
+
53
+ /** Shape checks complement lifecycle authorization, revision and field validation. */
54
+ export function validateDocumentWriteInput<T>(input: T, mode: 'create' | 'patch' | 'metadata'): T {
55
+ const valid =
56
+ object(input) &&
57
+ text(input.collection) &&
58
+ (mode === 'create' ? object(input.data) : text(input.id)) &&
59
+ (input.locale === undefined || text(input.locale)) &&
60
+ (input.path == null || typeof input.path === 'string') &&
61
+ (input.availableLocales === undefined ||
62
+ (Array.isArray(input.availableLocales) && input.availableLocales.every(text))) &&
63
+ (mode !== 'patch' || patches(input.patches))
64
+ // Revision errors keep their existing, specialized lifecycle decoder.
65
+ if (!valid) throw ERR_VALIDATION({ message: 'Invalid document write request.' })
66
+ return input
67
+ }