|
31 | 31 | * the UI can highlight the specific input. |
32 | 32 | */ |
33 | 33 |
|
| 34 | +import { |
| 35 | + isMultiValueField as specIsMultiValueField, |
| 36 | + valueSchemaFor, |
| 37 | + REFERENCE_VALUE_TYPES, |
| 38 | + FILE_REFERENCE_TYPES, |
| 39 | + STRUCTURED_JSON_TYPES, |
| 40 | +} from '@objectstack/spec/data'; |
| 41 | + |
34 | 42 | // Lifecycle columns the engine always owns and the client never supplies. These |
35 | 43 | // are skipped by NAME because they are not author-declared business fields. |
36 | 44 | // NOTE: `organization_id` / `tenant_id` are intentionally NOT here (#1592) — the |
@@ -145,17 +153,11 @@ function optionValues(options: FieldDef['options']): string[] { |
145 | 153 | /** |
146 | 154 | * A field whose persisted value is an ARRAY of scalars: either an |
147 | 155 | * inherently-multi type, or a single-value type flagged `multiple: true`. |
148 | | - * Per the spec (field.zod.ts), `multiple` applies to select/lookup/file/image; |
149 | | - * `radio` shares the select branch and `user` is stored identically to |
150 | | - * `lookup` (FK column, `multiple` ⇒ JSON array) — the runtime expands |
151 | | - * `Field.user` with `type: 'user'`, so it must be recognized here too. |
| 156 | + * THE definition is the spec's (ADR-0104 D1) — previously a hand-copy here |
| 157 | + * and in rest/import-coerce that had to be kept in lock-step manually. |
152 | 158 | */ |
153 | | -const MULTI_CAPABLE_TYPES = new Set(['select', 'radio', 'lookup', 'user', 'file', 'image']); |
154 | | - |
155 | 159 | function isMultiValueField(def: FieldDef): boolean { |
156 | | - const t = def.type; |
157 | | - if (t === 'multiselect' || t === 'checkboxes' || t === 'tags') return true; |
158 | | - return MULTI_CAPABLE_TYPES.has(t as string) && def.multiple === true; |
| 160 | + return specIsMultiValueField(def as { type: string; multiple?: boolean }); |
159 | 161 | } |
160 | 162 |
|
161 | 163 | /** |
@@ -223,12 +225,12 @@ export function coerceBooleanFields<T extends Record<string, unknown>>( |
223 | 225 | return (copy ?? row) as T; |
224 | 226 | } |
225 | 227 |
|
226 | | -function validateOne(name: string, def: FieldDef, value: unknown): FieldValidationError | null { |
| 228 | +function validateOne(name: string, def: FieldDef, value: unknown, skipRequired = false): FieldValidationError | null { |
227 | 229 | // ── required ──────────────────────────────────────────────────── |
228 | 230 | // `autonumber` is runtime-owned: the value is generated by the engine / |
229 | 231 | // driver (the SQL driver assigns it from a persistent sequence AFTER this |
230 | 232 | // validation runs), so a missing value is never a client error — see #1603. |
231 | | - if (def.required && isMissing(value) && def.type !== 'autonumber') { |
| 233 | + if (!skipRequired && def.required && isMissing(value) && def.type !== 'autonumber') { |
232 | 234 | return { field: name, code: 'required', message: `${name} is required` }; |
233 | 235 | } |
234 | 236 | if (isMissing(value)) return null; // nothing else to check |
@@ -338,12 +340,62 @@ function validateOne(name: string, def: FieldDef, value: unknown): FieldValidati |
338 | 340 | return null; |
339 | 341 | } |
340 | 342 |
|
341 | | - // Other types (lookup, file, formula, json, location, etc.) — no |
342 | | - // strict shape check at this layer; reference integrity is handled |
343 | | - // elsewhere (lookup) and the rest are opaque payloads. |
| 343 | + // ── previously-opaque types: value-shape contract (ADR-0104 D1) ───── |
| 344 | + // Single-value references (id string), file-likes (id/url string or the |
| 345 | + // pre-D3 inline metadata object), and structured JSON payloads |
| 346 | + // (location/address/composite/repeater/record/vector) are checked against |
| 347 | + // the spec's `valueSchemaFor`. Warn-first rollout (ADR-0104 R1/R2): a |
| 348 | + // violation logs instead of rejecting, so legacy rows written under the |
| 349 | + // lax regime don't strand their records; set |
| 350 | + // `OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1` to enforce as a 400. The flip to |
| 351 | + // error-by-default rides a later minor once telemetry is quiet. |
| 352 | + if (REFERENCE_VALUE_TYPES.has(t) || FILE_REFERENCE_TYPES.has(t) || STRUCTURED_JSON_TYPES.has(t)) { |
| 353 | + const parsed = shapeSchemaFor(def).safeParse(value); |
| 354 | + if (!parsed.success) { |
| 355 | + const detail = parsed.error.issues[0]?.message ?? 'invalid value shape'; |
| 356 | + const message = `${name} has an invalid ${t} value: ${detail}`; |
| 357 | + if (VALUE_SHAPE_STRICT()) { |
| 358 | + return { field: name, code: 'invalid_type', message }; |
| 359 | + } |
| 360 | + warnOnce(`${t}:${name}`, `[value-shape] ${message} — accepted for now (ADR-0104 warn-first; set OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1 to enforce)`); |
| 361 | + } |
| 362 | + return null; |
| 363 | + } |
| 364 | + |
| 365 | + // Remaining types (formula/summary/autonumber outputs, json/code payloads) |
| 366 | + // are explicitly open per the spec contract — see field-value.zod.ts. |
344 | 367 | return null; |
345 | 368 | } |
346 | 369 |
|
| 370 | +/** Strict value-shape enforcement opt-in (ADR-0104 warn-first rollout). */ |
| 371 | +function VALUE_SHAPE_STRICT(): boolean { |
| 372 | + return typeof process !== 'undefined' && process.env?.OS_DATA_VALUE_SHAPE_STRICT_ENABLED === '1'; |
| 373 | +} |
| 374 | + |
| 375 | +const warnedShapes = new Set<string>(); |
| 376 | +function warnOnce(key: string, message: string): void { |
| 377 | + if (warnedShapes.has(key)) return; |
| 378 | + warnedShapes.add(key); |
| 379 | + console.warn(message); |
| 380 | +} |
| 381 | + |
| 382 | +/** |
| 383 | + * Per-field-definition cache of the spec's derived value schema. Building a |
| 384 | + * Zod schema is an order of magnitude costlier than parsing with it, so the |
| 385 | + * derivation runs once per field def (ADR-0104 performance budget). Keyed on |
| 386 | + * def identity — `validateRecord`'s update path passes the registry's own |
| 387 | + * field objects (no clones), so the map hits. |
| 388 | + */ |
| 389 | +const shapeSchemaCache = new WeakMap<FieldDef, ReturnType<typeof valueSchemaFor>>(); |
| 390 | +function shapeSchemaFor(def: FieldDef): ReturnType<typeof valueSchemaFor> { |
| 391 | + let schema = shapeSchemaCache.get(def); |
| 392 | + if (!schema) { |
| 393 | + schema = valueSchemaFor(def as { type: string; multiple?: boolean; options?: FieldDef['options'] }, 'stored'); |
| 394 | + shapeSchemaCache.set(def, schema); |
| 395 | + } |
| 396 | + return schema; |
| 397 | +} |
| 398 | + |
347 | 399 | /** |
348 | 400 | * Validate a payload against a list of declared fields. `objectSchema` |
349 | 401 | * comes from `ObjectQL.getRegistry().getObject(name)` and exposes a |
@@ -377,8 +429,10 @@ export function validateRecord( |
377 | 429 | const def = fields[name]; |
378 | 430 | if (!def) continue; |
379 | 431 | if (def.system || def.readonly) continue; |
380 | | - // Clone def with required=false so PATCH-omitted-fields don't 400. |
381 | | - const err = validateOne(name, { ...def, required: false }, value); |
| 432 | + // skipRequired: PATCH-omitted fields must not 400. (No def clone — the |
| 433 | + // registry's own field object flows through so the ADR-0104 value-shape |
| 434 | + // schema cache, keyed on def identity, hits.) |
| 435 | + const err = validateOne(name, def, value, true); |
382 | 436 | if (err) errors.push(err); |
383 | 437 | } |
384 | 438 | } |
|
0 commit comments