@book.dev/sdk 3.10.0 → 3.13.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/database.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * StoredPage}. A **database** is a collection of pages (its *rows*) managed by
4
4
  * typed *properties* and presented through one or more configurable *views*
5
5
  * (table, list, gallery, board, calendar, timeline, map, relationship graph,
6
- * or a bar/pie chart).
6
+ * form, or a bar/pie chart).
7
7
  *
8
8
  * Three ideas make OpenBook databases different from a plain spreadsheet:
9
9
  *
@@ -28,6 +28,41 @@
28
28
  * record — properties, views, filters — lives in its own `databases` table.
29
29
  */
30
30
  import { evaluateFormula, FormulaError } from './formula';
31
+ /**
32
+ * Whether a public form may write a property type in v1. This exhaustive map is
33
+ * the shared allowlist for builders and the submission validator. Reference,
34
+ * identity/attestation, computed, and server-managed properties fail closed.
35
+ */
36
+ export const FORM_PROPERTY_TYPE_WRITABILITY = {
37
+ text: true,
38
+ number: true,
39
+ rating: true,
40
+ select: true,
41
+ multi_select: true,
42
+ status: true,
43
+ checkbox: true,
44
+ date: true,
45
+ url: true,
46
+ email: true,
47
+ phone: true,
48
+ location: true,
49
+ files: true,
50
+ relation: false,
51
+ dependency: false,
52
+ rollup: false,
53
+ created_time: false,
54
+ last_edited_time: false,
55
+ unique_id: false,
56
+ expr: false,
57
+ formula: false,
58
+ person: false,
59
+ verification: false,
60
+ backlinks: false,
61
+ };
62
+ /** True when `type` is safe for a public form fill in v1. */
63
+ export function isFormWritablePropertyType(type) {
64
+ return FORM_PROPERTY_TYPE_WRITABILITY[type] === true;
65
+ }
31
66
  /** Whether each side of a relation cardinality holds a single row. `1:1` is
32
67
  * single both ways; `1:n` links many but each target points back to one; `n:n`
33
68
  * is many both ways. */
@@ -50,6 +85,24 @@ export function asLocation(value) {
50
85
  ...(typeof v.address === 'string' && v.address.trim() ? { address: v.address } : {}),
51
86
  };
52
87
  }
88
+ /** Runtime list for fail-closed decoding of persisted view types. */
89
+ export const KNOWN_DATABASE_VIEW_TYPES = [
90
+ 'table',
91
+ 'list',
92
+ 'gallery',
93
+ 'board',
94
+ 'calendar',
95
+ 'timeline',
96
+ 'map',
97
+ 'graph',
98
+ 'form',
99
+ 'bar',
100
+ 'pie',
101
+ ];
102
+ /** True only for view layouts this SDK knows how to interpret. */
103
+ export function isDatabaseViewType(value) {
104
+ return typeof value === 'string' && KNOWN_DATABASE_VIEW_TYPES.includes(value);
105
+ }
53
106
  /** The value-less, relative-to-today date operators. */
54
107
  export const RELATIVE_DATE_OPS = [
55
108
  'is_today',
@@ -62,6 +115,25 @@ export const RELATIVE_DATE_OPS = [
62
115
  export function isFilterGroup(node) {
63
116
  return node.conjunction !== undefined;
64
117
  }
118
+ /** Stable machine-readable failures returned by {@link validateRowAgainstForm}. */
119
+ export const FORM_ROW_VALIDATION_ERROR_CODES = [
120
+ 'view_type',
121
+ 'unknown_field',
122
+ 'required',
123
+ 'type',
124
+ 'min',
125
+ 'max',
126
+ 'minLength',
127
+ 'maxLength',
128
+ 'pattern',
129
+ 'option',
130
+ 'range',
131
+ 'too_large',
132
+ 'date_format',
133
+ 'email_format',
134
+ 'url_format',
135
+ 'phone_format',
136
+ ];
65
137
  /**
66
138
  * Validate + normalise a database's {@link AutoExpiryConfig} against its schema,
67
139
  * returning the rule to apply, or `null` when auto-expiry should do nothing:
@@ -699,6 +771,21 @@ export function defaultDatabaseSchema() {
699
771
  /** A fresh view of a given type with sensible defaults for its layout. */
700
772
  export function defaultView(type, name, properties) {
701
773
  const view = { id: shortId('view'), name, type, filters: [], sorts: [] };
774
+ if (type === 'form') {
775
+ // A form's field list is always explicit and fail-closed. Form-only fields
776
+ // are added as ordinary pageHidden properties by the builder, then appended
777
+ // here just like any other form-writable database column.
778
+ view.visiblePropertyIds = [
779
+ TITLE_PROPERTY_ID,
780
+ ...properties
781
+ .filter((property) => property.id !== TITLE_PROPERTY_ID
782
+ && !property.id.startsWith('sys_')
783
+ && isFormWritablePropertyType(property.type))
784
+ .map((property) => property.id),
785
+ ];
786
+ view.formFields = {};
787
+ view.formConfig = { acceptingResponses: true };
788
+ }
702
789
  if (type === 'board' || type === 'bar' || type === 'pie') {
703
790
  // Default the grouping to the first categorical property (select/status/
704
791
  // relation — kanban columns / chart categories read best off one). With no
@@ -747,10 +834,11 @@ export const STATUS_GROUPS = [
747
834
  /**
748
835
  * Remove a property from a schema and scrub **every** dangling reference to it:
749
836
  * each view's filters (flat list *and* the nested {@link filterRoot} tree),
750
- * sorts, visible columns, summaries, and the group-by / date / cover config; plus
751
- * any `rollup` on another property that aggregated through or over it. Pure —
752
- * returns a fresh schema — so it can be unit-tested and shared by the delete
753
- * action. (Renders already tolerate stale refs; this keeps the schema clean.)
837
+ * sorts, visible columns, form-field metadata, summaries, and the group-by /
838
+ * date / cover config; plus any `rollup` on another property that aggregated
839
+ * through or over it. Pure — returns a fresh schema — so it can be unit-tested
840
+ * and shared by the delete action. (Renders already tolerate stale refs; this
841
+ * keeps the schema clean.)
754
842
  */
755
843
  export function removeProperty(schema, propertyId) {
756
844
  const pruneNode = (node) => {
@@ -777,12 +865,16 @@ export function removeProperty(schema, propertyId) {
777
865
  const summaries = v.summaries
778
866
  ? Object.fromEntries(Object.entries(v.summaries).filter(([k]) => k !== propertyId))
779
867
  : undefined;
868
+ const formFields = v.formFields
869
+ ? Object.fromEntries(Object.entries(v.formFields).filter(([k]) => k !== propertyId))
870
+ : undefined;
780
871
  return {
781
872
  ...v,
782
873
  filters: (v.filters ?? []).filter((f) => f.propertyId !== propertyId),
783
874
  filterRoot: v.filterRoot ? pruneNode(v.filterRoot) : undefined,
784
875
  sorts: (v.sorts ?? []).filter((s) => s.propertyId !== propertyId),
785
876
  visiblePropertyIds: v.visiblePropertyIds?.filter((id) => id !== propertyId),
877
+ formFields,
786
878
  summaries,
787
879
  groupByPropertyId: v.groupByPropertyId === propertyId ? undefined : v.groupByPropertyId,
788
880
  subGroupByPropertyId: v.subGroupByPropertyId === propertyId ? undefined : v.subGroupByPropertyId,
@@ -800,6 +892,388 @@ export function removeProperty(schema, propertyId) {
800
892
  : t),
801
893
  };
802
894
  }
895
+ const FORM_EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
896
+ const FORM_PHONE_RE = /^\+?[0-9().\-\s]+$/;
897
+ const FORM_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
898
+ const FORM_DATE_TIME_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:\d{2})?$/;
899
+ function formHasOwn(value, key) {
900
+ return Object.prototype.hasOwnProperty.call(value, key);
901
+ }
902
+ function validFormCalendarDate(year, month, day) {
903
+ if (month < 1 || month > 12)
904
+ return false;
905
+ const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
906
+ const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
907
+ return day >= 1 && day <= days[month - 1];
908
+ }
909
+ function validFormDate(value, includeTime) {
910
+ const match = (includeTime ? FORM_DATE_TIME_RE : FORM_DATE_RE).exec(value);
911
+ if (!match)
912
+ return false;
913
+ if (!validFormCalendarDate(Number(match[1]), Number(match[2]), Number(match[3])))
914
+ return false;
915
+ if (!includeTime)
916
+ return true;
917
+ return Number(match[4]) <= 23 && Number(match[5]) <= 59 && (match[6] === undefined || Number(match[6]) <= 59);
918
+ }
919
+ function formFieldIsEmpty(property, value) {
920
+ if (value === undefined || value === null)
921
+ return true;
922
+ // HTML controls can emit an empty string even when the backing property is
923
+ // numeric, boolean, or otherwise non-string. Treat it as absence before type
924
+ // validation so a required blank consistently reports `required`.
925
+ if (typeof value === 'string' && value.trim() === '')
926
+ return true;
927
+ switch (property.type) {
928
+ case 'multi_select':
929
+ case 'files':
930
+ return Array.isArray(value) && value.length === 0;
931
+ case 'date':
932
+ if (property.dateRange && typeof value === 'object' && !Array.isArray(value)) {
933
+ const range = value;
934
+ return !range.start && !range.end;
935
+ }
936
+ return false;
937
+ default:
938
+ return false;
939
+ }
940
+ }
941
+ function validateFormDateValue(property, value) {
942
+ const validPart = (part) => typeof part === 'string' && validFormDate(part, property.includeTime === true);
943
+ if (!property.dateRange)
944
+ return validPart(value) ? null : 'date_format';
945
+ if (!value || typeof value !== 'object' || Array.isArray(value))
946
+ return 'type';
947
+ const range = value;
948
+ if (Object.keys(range).some((key) => key !== 'start' && key !== 'end'))
949
+ return 'type';
950
+ if (range.start !== null && range.start !== undefined && !validPart(range.start))
951
+ return 'date_format';
952
+ if (range.end !== null && range.end !== undefined && !validPart(range.end))
953
+ return 'date_format';
954
+ if (!validPart(range.start))
955
+ return 'date_format';
956
+ if (validPart(range.end)) {
957
+ // Date-only values sort chronologically as ISO strings. Date-times do not:
958
+ // mixed offsets can make a lexically earlier value represent a later instant
959
+ // (and vice versa), so compare their parsed instants instead.
960
+ const endBeforeStart = property.includeTime === true
961
+ ? Date.parse(range.end) < Date.parse(range.start)
962
+ : range.end < range.start;
963
+ if (endBeforeStart)
964
+ return 'range';
965
+ }
966
+ return null;
967
+ }
968
+ function validateFormLocation(value) {
969
+ if (!value || typeof value !== 'object' || Array.isArray(value))
970
+ return 'type';
971
+ const location = value;
972
+ if (Object.keys(location).some((key) => !['lat', 'lng', 'label', 'address'].includes(key)))
973
+ return 'type';
974
+ if (typeof location.lat !== 'number' || !Number.isFinite(location.lat))
975
+ return 'type';
976
+ if (typeof location.lng !== 'number' || !Number.isFinite(location.lng))
977
+ return 'type';
978
+ if (location.lat < -90 || location.lat > 90 || location.lng < -180 || location.lng > 180)
979
+ return 'range';
980
+ if (location.label !== undefined && typeof location.label !== 'string')
981
+ return 'type';
982
+ if (location.address !== undefined && typeof location.address !== 'string')
983
+ return 'type';
984
+ return null;
985
+ }
986
+ function validateFormPropertyValue(property, value) {
987
+ switch (property.type) {
988
+ case 'text':
989
+ return typeof value === 'string' ? null : 'type';
990
+ case 'number':
991
+ return typeof value === 'number' && Number.isFinite(value) ? null : 'type';
992
+ case 'rating': {
993
+ if (typeof value !== 'number' || !Number.isInteger(value))
994
+ return 'type';
995
+ const max = property.numberTarget && property.numberTarget > 0 ? Math.min(10, Math.round(property.numberTarget)) : 5;
996
+ return value >= 1 && value <= max ? null : 'range';
997
+ }
998
+ case 'select':
999
+ case 'status':
1000
+ if (typeof value !== 'string')
1001
+ return 'type';
1002
+ return property.options?.some((option) => option.id === value) ? null : 'option';
1003
+ case 'multi_select': {
1004
+ if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string'))
1005
+ return 'type';
1006
+ if (new Set(value).size !== value.length)
1007
+ return 'option';
1008
+ const options = new Set((property.options ?? []).map((option) => option.id));
1009
+ return value.every((entry) => options.has(entry)) ? null : 'option';
1010
+ }
1011
+ case 'checkbox':
1012
+ return typeof value === 'boolean' ? null : 'type';
1013
+ case 'date':
1014
+ return validateFormDateValue(property, value);
1015
+ case 'url':
1016
+ if (typeof value !== 'string')
1017
+ return 'type';
1018
+ try {
1019
+ const url = new URL(value);
1020
+ return (url.protocol === 'http:' || url.protocol === 'https:') && url.hostname ? null : 'url_format';
1021
+ }
1022
+ catch {
1023
+ return 'url_format';
1024
+ }
1025
+ case 'email':
1026
+ return typeof value !== 'string' ? 'type' : FORM_EMAIL_RE.test(value) ? null : 'email_format';
1027
+ case 'phone': {
1028
+ if (typeof value !== 'string')
1029
+ return 'type';
1030
+ const digits = value.replace(/\D/g, '').length;
1031
+ return FORM_PHONE_RE.test(value) && digits >= 7 && digits <= 15 ? null : 'phone_format';
1032
+ }
1033
+ case 'location':
1034
+ return validateFormLocation(value);
1035
+ case 'files':
1036
+ return Array.isArray(value) && value.every((entry) => typeof entry === 'string' && entry.length > 0) ? null : 'type';
1037
+ default:
1038
+ return 'unknown_field';
1039
+ }
1040
+ }
1041
+ function copyFormValue(value) {
1042
+ if (Array.isArray(value))
1043
+ return [...value];
1044
+ if (value && typeof value === 'object')
1045
+ return { ...value };
1046
+ return value;
1047
+ }
1048
+ const FORM_TITLE_PROPERTY = {
1049
+ id: TITLE_PROPERTY_ID,
1050
+ name: 'Title',
1051
+ type: 'text',
1052
+ };
1053
+ /** Resolve the ordered, current, writable field mapping, including row title. */
1054
+ function mappedFormProperties(schema, view) {
1055
+ const byId = new Map(schema.properties.map((property) => [property.id, property]));
1056
+ return (view.visiblePropertyIds ?? [])
1057
+ .map((propertyId) => propertyId === TITLE_PROPERTY_ID ? FORM_TITLE_PROPERTY : byId.get(propertyId))
1058
+ .filter((property) => property !== undefined && !property.id.startsWith('sys_') && isFormWritablePropertyType(property.type));
1059
+ }
1060
+ /** Conservative structural screen for common exponential-backtracking forms. */
1061
+ export function formPatternIsUnsafe(pattern) {
1062
+ if (/\\(?:[1-9]|k<)/.test(pattern))
1063
+ return true;
1064
+ const frames = [{ hasAlternation: false, hasQuantifier: false }];
1065
+ let inClass = false;
1066
+ let quantifiers = 0;
1067
+ for (let i = 0; i < pattern.length; i += 1) {
1068
+ const char = pattern[i];
1069
+ if (char === '\\') {
1070
+ i += 1;
1071
+ continue;
1072
+ }
1073
+ if (inClass) {
1074
+ if (char === ']')
1075
+ inClass = false;
1076
+ continue;
1077
+ }
1078
+ if (char === '[') {
1079
+ inClass = true;
1080
+ continue;
1081
+ }
1082
+ if (char === '(') {
1083
+ if (pattern[i + 1] === '?' && pattern[i + 2] !== ':')
1084
+ return true;
1085
+ if (pattern[i + 1] === '?' && pattern[i + 2] === ':')
1086
+ i += 2;
1087
+ frames.push({ hasAlternation: false, hasQuantifier: false });
1088
+ continue;
1089
+ }
1090
+ if (char === '|') {
1091
+ frames[frames.length - 1].hasAlternation = true;
1092
+ continue;
1093
+ }
1094
+ if (char === ')') {
1095
+ if (frames.length === 1)
1096
+ continue;
1097
+ const frame = frames.pop();
1098
+ const next = pattern[i + 1];
1099
+ const quantified = next === '*' || next === '+' || next === '?' || next === '{';
1100
+ if (quantified && (frame.hasAlternation || frame.hasQuantifier))
1101
+ return true;
1102
+ const parent = frames[frames.length - 1];
1103
+ parent.hasAlternation || (parent.hasAlternation = frame.hasAlternation);
1104
+ parent.hasQuantifier || (parent.hasQuantifier = frame.hasQuantifier || quantified);
1105
+ continue;
1106
+ }
1107
+ if (char === '*' || char === '+' || char === '?' || char === '{') {
1108
+ frames[frames.length - 1].hasQuantifier = true;
1109
+ quantifiers += 1;
1110
+ if (quantifiers > 8)
1111
+ return true;
1112
+ }
1113
+ }
1114
+ return inClass;
1115
+ }
1116
+ function validateFormFieldConstraints(metadata, value) {
1117
+ const validation = metadata?.validation;
1118
+ if (!validation)
1119
+ return [];
1120
+ const errors = [];
1121
+ if (typeof value === 'number') {
1122
+ if (typeof validation.min === 'number' && value < validation.min)
1123
+ errors.push('min');
1124
+ if (typeof validation.max === 'number' && value > validation.max)
1125
+ errors.push('max');
1126
+ }
1127
+ const length = typeof value === 'string' || Array.isArray(value) ? value.length : undefined;
1128
+ if (length !== undefined) {
1129
+ if (typeof validation.minLength === 'number' && length < validation.minLength)
1130
+ errors.push('minLength');
1131
+ if (typeof validation.maxLength === 'number' && length > validation.maxLength)
1132
+ errors.push('maxLength');
1133
+ }
1134
+ if (validation.pattern !== undefined && typeof value === 'string') {
1135
+ const pattern = validation.pattern;
1136
+ if (typeof pattern !== 'string'
1137
+ || pattern.length > 256
1138
+ || value.length > 1024
1139
+ || formPatternIsUnsafe(pattern)) {
1140
+ errors.push('pattern');
1141
+ }
1142
+ else {
1143
+ try {
1144
+ if (!new RegExp(pattern).test(value))
1145
+ errors.push('pattern');
1146
+ }
1147
+ catch {
1148
+ errors.push('pattern');
1149
+ }
1150
+ }
1151
+ }
1152
+ return errors;
1153
+ }
1154
+ /**
1155
+ * Project only the public rendering contract from a freshly loaded form view.
1156
+ * Returns `null` for a non-form view so callers cannot accidentally serialize a
1157
+ * normal database view through the public endpoint.
1158
+ */
1159
+ export function projectDatabaseFormDescriptor(schema, view) {
1160
+ if (view.type !== 'form')
1161
+ return null;
1162
+ const config = view.formConfig;
1163
+ const acceptingResponses = config?.acceptingResponses === true;
1164
+ const fields = mappedFormProperties(schema, view).map((property) => {
1165
+ const metadata = view.formFields && formHasOwn(view.formFields, property.id)
1166
+ ? view.formFields[property.id]
1167
+ : undefined;
1168
+ const field = {
1169
+ propertyId: property.id,
1170
+ type: property.type,
1171
+ label: metadata?.label ?? property.name,
1172
+ help: metadata?.help ?? '',
1173
+ required: metadata?.required === true,
1174
+ placeholder: metadata?.placeholder ?? '',
1175
+ };
1176
+ if (property.type === 'text' && metadata?.multiline !== undefined) {
1177
+ field.multiline = metadata.multiline;
1178
+ }
1179
+ const validation = metadata?.validation;
1180
+ if (validation) {
1181
+ const displayValidation = {
1182
+ ...(validation.min !== undefined ? { min: validation.min } : {}),
1183
+ ...(validation.max !== undefined ? { max: validation.max } : {}),
1184
+ ...(validation.minLength !== undefined ? { minLength: validation.minLength } : {}),
1185
+ ...(validation.maxLength !== undefined ? { maxLength: validation.maxLength } : {}),
1186
+ };
1187
+ if (Object.keys(displayValidation).length > 0)
1188
+ field.validation = displayValidation;
1189
+ }
1190
+ if ((property.type === 'select' || property.type === 'multi_select' || property.type === 'status')
1191
+ && property.options !== undefined) {
1192
+ field.options = property.options.map((option) => ({ ...option }));
1193
+ }
1194
+ if (property.type === 'date') {
1195
+ if (property.includeTime !== undefined)
1196
+ field.includeTime = property.includeTime;
1197
+ if (property.dateRange !== undefined)
1198
+ field.dateRange = property.dateRange;
1199
+ }
1200
+ if ((property.type === 'number' || property.type === 'rating') && property.numberTarget !== undefined) {
1201
+ field.numberTarget = property.numberTarget;
1202
+ }
1203
+ return field;
1204
+ });
1205
+ return {
1206
+ title: config?.title ?? view.name,
1207
+ description: config?.description ?? '',
1208
+ submitLabel: config?.submitLabel ?? 'Submit',
1209
+ acceptingResponses,
1210
+ ...(!acceptingResponses && config?.closedMessage !== undefined
1211
+ ? { closedMessage: config.closedMessage }
1212
+ : {}),
1213
+ fields,
1214
+ };
1215
+ }
1216
+ /**
1217
+ * Validate an untrusted public fill against the database's current schema and
1218
+ * form mapping. The allowlist is the form's explicit `visiblePropertyIds`
1219
+ * intersected with current form-writable properties. Unknown, deleted,
1220
+ * non-visible, computed, and managed fields are rejected; successful output is
1221
+ * a fresh record containing only validated fields ready for row creation.
1222
+ */
1223
+ export function validateRowAgainstForm(schema, view, fields) {
1224
+ if (view.type !== 'form')
1225
+ return { ok: false, errors: [{ propertyId: '', code: 'view_type' }] };
1226
+ if (!fields || typeof fields !== 'object' || Array.isArray(fields)) {
1227
+ return { ok: false, errors: [{ propertyId: '', code: 'type' }] };
1228
+ }
1229
+ const mapped = mappedFormProperties(schema, view);
1230
+ const allowedIds = new Set(mapped.map((property) => property.id));
1231
+ const errors = [];
1232
+ for (const propertyId of Object.keys(fields)) {
1233
+ if (!allowedIds.has(propertyId))
1234
+ errors.push({ propertyId, code: 'unknown_field' });
1235
+ }
1236
+ const validated = {};
1237
+ let name;
1238
+ for (const property of mapped) {
1239
+ const present = formHasOwn(fields, property.id);
1240
+ const value = present ? fields[property.id] : undefined;
1241
+ const metadata = view.formFields && formHasOwn(view.formFields, property.id)
1242
+ ? view.formFields[property.id]
1243
+ : undefined;
1244
+ if (!present || formFieldIsEmpty(property, value)) {
1245
+ if (metadata?.required === true)
1246
+ errors.push({ propertyId: property.id, code: 'required' });
1247
+ if (property.id === TITLE_PROPERTY_ID)
1248
+ name = '';
1249
+ continue;
1250
+ }
1251
+ const code = validateFormPropertyValue(property, value);
1252
+ if (code) {
1253
+ errors.push({ propertyId: property.id, code });
1254
+ continue;
1255
+ }
1256
+ const constraintCodes = validateFormFieldConstraints(metadata, value);
1257
+ if (constraintCodes.length > 0) {
1258
+ for (const constraintCode of constraintCodes)
1259
+ errors.push({ propertyId: property.id, code: constraintCode });
1260
+ continue;
1261
+ }
1262
+ if (property.id === TITLE_PROPERTY_ID) {
1263
+ name = value;
1264
+ continue;
1265
+ }
1266
+ Object.defineProperty(validated, property.id, {
1267
+ value: copyFormValue(value),
1268
+ enumerable: true,
1269
+ configurable: true,
1270
+ writable: true,
1271
+ });
1272
+ }
1273
+ if (errors.length > 0)
1274
+ return { ok: false, errors };
1275
+ return name === undefined ? { ok: true, fields: validated } : { ok: true, name, fields: validated };
1276
+ }
803
1277
  // ── Number formatting ────────────────────────────────────────────────────────
804
1278
  const FORMAT_PREFIX = { dollar: '$', euro: '€', pound: '£', rupee: '₹' };
805
1279
  /** Format a numeric value for display per a {@link NumberFormat}. Non-numbers pass through as text. */