From 235de831b43c937f892907fa2d61ab2092a18db9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 21:14:44 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(spec):=20field=20runtime=20value-shape?= =?UTF-8?q?=20contract=20=E2=80=94=20ADR-0104=20phase=201=20(D1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec now owns the runtime value shape of every field type (data/field-value.zod.ts): semantic type classes, the shared isMultiValueField, and valueSchemaFor(field, 'stored' | 'expanded'). Consumers converged (each loses its private hand-copied type lists): - objectql record-validator: derives from spec; previously-opaque types (single references, file-likes, location/address/composite/repeater/ record/vector) get warn-first shape checks (strict via OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1); update path no longer clones field defs so the per-def schema cache hits. - rest import-coerce: six local sets → spec-derived. - driver-sql: JSON_COLUMN_TYPES / NUMERIC_SCALAR_TYPES membership now spec-derived (+ driver-internal aliases). - qa/dogfood: field-zoo MATRIX extracted to field-zoo.matrix.ts; new field-zoo-value-shape.test.ts pins contract ⇔ oracle (45 vectors). Deprecated (removal rides next spec major; FROM→TO in changeset): CurrencyValueSchema (currency IS a bare number), LocationCoordinatesSchema (stored shape is {lat, lng} → LocationValueSchema). AddressSchema adopted as the enforced address value contract. Tests: spec 6834, objectql 1039, rest 331, driver-sql 284, dogfood value-shape 45 — all green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SHpGw3GBA9aFpfwVArRWfd --- .../adr-0104-d1-value-shape-contract.md | 41 +++ .../src/validation/record-validator.test.ts | 60 +++++ .../src/validation/record-validator.ts | 86 ++++-- packages/plugins/driver-sql/src/sql-driver.ts | 19 +- .../test/field-zoo-roundtrip.dogfood.test.ts | 98 +------ .../test/field-zoo-value-shape.test.ts | 38 +++ packages/qa/dogfood/test/field-zoo.matrix.ts | 105 ++++++++ packages/rest/src/import-coerce.ts | 46 ++-- packages/spec/json-schema.manifest.json | 7 + packages/spec/src/data/field-value.test.ts | 183 +++++++++++++ packages/spec/src/data/field-value.zod.ts | 251 ++++++++++++++++++ packages/spec/src/data/field.zod.ts | 15 +- packages/spec/src/data/index.ts | 2 + 13 files changed, 802 insertions(+), 149 deletions(-) create mode 100644 .changeset/adr-0104-d1-value-shape-contract.md create mode 100644 packages/qa/dogfood/test/field-zoo-value-shape.test.ts create mode 100644 packages/qa/dogfood/test/field-zoo.matrix.ts create mode 100644 packages/spec/src/data/field-value.test.ts create mode 100644 packages/spec/src/data/field-value.zod.ts diff --git a/.changeset/adr-0104-d1-value-shape-contract.md b/.changeset/adr-0104-d1-value-shape-contract.md new file mode 100644 index 0000000000..8f677d3213 --- /dev/null +++ b/.changeset/adr-0104-d1-value-shape-contract.md @@ -0,0 +1,41 @@ +--- +"@objectstack/spec": minor +"@objectstack/objectql": minor +"@objectstack/rest": patch +"@objectstack/driver-sql": patch +--- + +feat(spec): field runtime value-shape contract — ADR-0104 phase 1 (D1) + +`@objectstack/spec/data` now owns the runtime VALUE shape of every field type +(`field-value.zod.ts`): semantic type classes (`STRING_VALUE_TYPES`, +`NUMERIC_VALUE_TYPES`, `REFERENCE_VALUE_TYPES`, `FILE_REFERENCE_TYPES`, +`STRUCTURED_JSON_TYPES`, `MULTI_CAPABLE_TYPES`, …), the shared +`isMultiValueField`, and `valueSchemaFor(field, 'stored' | 'expanded')`. The +four consumers that each hand-copied this knowledge (objectql record-validator, +rest import-coerce, driver-sql column classification, qa conformance) now +derive from the spec, and the field-zoo round-trip MATRIX is asserted against +the contract so the two cannot drift. + +**Write-path change (objectql, warn-first):** previously-unvalidated types — +single `lookup`/`master_detail`/`user`/`tree`, `file`/`image`/`avatar`/ +`video`/`audio`, `location`, `address`, `composite`, `repeater`, `record`, +`vector` — are now checked against the contract. A violation **logs a warning +and passes** in this release (legacy rows must not strand their records); +set `OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1` to enforce as a +`400 VALIDATION_FAILED`. The flip to strict-by-default rides a later minor +(ADR-0104 R1/R2). + +**Deprecations (removal rides the next spec major), FROM → TO:** + +- `CurrencyValueSchema` (`{value, currency}`) → none. A `currency` field's + value is a **bare number** everywhere in the runtime (validator, SQL `float` + column, import coercion, field-zoo oracle); the currency code lives in field + config. Use `valueSchemaFor({type: 'currency'})`. +- `LocationCoordinatesSchema` (`{latitude, longitude}`) → `LocationValueSchema` + (`{lat, lng}`) — the shape the platform actually stores. +- `AddressSchema` is **adopted** (unchanged) as the enforced `address` value + contract via `AddressValueSchema`. + +No stored data changes shape; the contract codifies deployed reality +("reality wins", ADR-0104 D1). diff --git a/packages/objectql/src/validation/record-validator.test.ts b/packages/objectql/src/validation/record-validator.test.ts index 1364f9cabe..daf35b7ef4 100644 --- a/packages/objectql/src/validation/record-validator.test.ts +++ b/packages/objectql/src/validation/record-validator.test.ts @@ -345,3 +345,63 @@ describe('validateRecord — url field accepts relative + inline URLs', () => { expect(() => validateRecord(schema, { image: 'notaurl' }, 'update')).toThrow(/valid URL/i); }); }); + +/** + * ADR-0104 D1 — value-shape contract for previously-opaque types. + * + * Warn-first rollout: a shape violation on reference/file/structured-JSON + * types logs (once per field) and passes; `OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1` + * turns it into a normal invalid_type rejection. + */ +describe('validateRecord — ADR-0104 value shapes (warn-first / strict)', () => { + const schema = { + fields: { + account: { type: 'lookup', reference: 'accounts' }, + geo: { type: 'location' }, + doc: { type: 'file' }, + dims: { type: 'vector' }, + }, + }; + + const withStrict = (fn: () => void) => { + process.env.OS_DATA_VALUE_SHAPE_STRICT_ENABLED = '1'; + try { fn(); } finally { delete process.env.OS_DATA_VALUE_SHAPE_STRICT_ENABLED; } + }; + + it('accepts contract-conformant values in both modes', () => { + const data = { + account: 'acc_0001', + geo: { lat: 37.77, lng: -122.42 }, + doc: { url: 'https://cdn/f.pdf', name: 'f.pdf', size: 1024 }, + dims: [0.1, 0.2], + }; + expect(() => validateRecord(schema, { ...data }, 'update')).not.toThrow(); + withStrict(() => expect(() => validateRecord(schema, { ...data }, 'update')).not.toThrow()); + }); + + it('warn-first: malformed shapes pass by default (legacy rows must not strand records)', () => { + expect(() => validateRecord(schema, { geo: { latitude: 1, longitude: 2 } }, 'update')).not.toThrow(); + expect(() => validateRecord(schema, { account: { id: 'acc_1' } }, 'update')).not.toThrow(); + }); + + it('strict: malformed shapes reject with invalid_type', () => { + withStrict(() => { + try { + validateRecord(schema, { geo: { latitude: 37.77, longitude: -122.42 } }, 'update'); + expect.unreachable('expected ValidationError'); + } catch (e) { + expect(e).toBeInstanceOf(ValidationError); + const err = e as ValidationError; + expect(err.fields[0]?.field).toBe('geo'); + expect(err.fields[0]?.code).toBe('invalid_type'); + } + // expanded-form object at a stored-form position (unexpanded write) + expect(() => validateRecord(schema, { account: { id: 'acc_1', name: 'Acme' } }, 'update')).toThrow(ValidationError); + // scalar at a vector + expect(() => validateRecord(schema, { dims: 'not-a-vector' }, 'update')).toThrow(ValidationError); + // file: id/url string AND inline object both remain legal pre-D3 + expect(() => validateRecord(schema, { doc: 'file_01HXYZ' }, 'update')).not.toThrow(); + expect(() => validateRecord(schema, { doc: 42 }, 'update')).toThrow(ValidationError); + }); + }); +}); diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index 2c81c1ea17..3bcec2de3b 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -31,6 +31,14 @@ * the UI can highlight the specific input. */ +import { + isMultiValueField as specIsMultiValueField, + valueSchemaFor, + REFERENCE_VALUE_TYPES, + FILE_REFERENCE_TYPES, + STRUCTURED_JSON_TYPES, +} from '@objectstack/spec/data'; + // Lifecycle columns the engine always owns and the client never supplies. These // are skipped by NAME because they are not author-declared business fields. // NOTE: `organization_id` / `tenant_id` are intentionally NOT here (#1592) — the @@ -145,17 +153,11 @@ function optionValues(options: FieldDef['options']): string[] { /** * A field whose persisted value is an ARRAY of scalars: either an * inherently-multi type, or a single-value type flagged `multiple: true`. - * Per the spec (field.zod.ts), `multiple` applies to select/lookup/file/image; - * `radio` shares the select branch and `user` is stored identically to - * `lookup` (FK column, `multiple` ⇒ JSON array) — the runtime expands - * `Field.user` with `type: 'user'`, so it must be recognized here too. + * THE definition is the spec's (ADR-0104 D1) — previously a hand-copy here + * and in rest/import-coerce that had to be kept in lock-step manually. */ -const MULTI_CAPABLE_TYPES = new Set(['select', 'radio', 'lookup', 'user', 'file', 'image']); - function isMultiValueField(def: FieldDef): boolean { - const t = def.type; - if (t === 'multiselect' || t === 'checkboxes' || t === 'tags') return true; - return MULTI_CAPABLE_TYPES.has(t as string) && def.multiple === true; + return specIsMultiValueField(def as { type: string; multiple?: boolean }); } /** @@ -223,12 +225,12 @@ export function coerceBooleanFields>( return (copy ?? row) as T; } -function validateOne(name: string, def: FieldDef, value: unknown): FieldValidationError | null { +function validateOne(name: string, def: FieldDef, value: unknown, skipRequired = false): FieldValidationError | null { // ── required ──────────────────────────────────────────────────── // `autonumber` is runtime-owned: the value is generated by the engine / // driver (the SQL driver assigns it from a persistent sequence AFTER this // validation runs), so a missing value is never a client error — see #1603. - if (def.required && isMissing(value) && def.type !== 'autonumber') { + if (!skipRequired && def.required && isMissing(value) && def.type !== 'autonumber') { return { field: name, code: 'required', message: `${name} is required` }; } if (isMissing(value)) return null; // nothing else to check @@ -338,12 +340,62 @@ function validateOne(name: string, def: FieldDef, value: unknown): FieldValidati return null; } - // Other types (lookup, file, formula, json, location, etc.) — no - // strict shape check at this layer; reference integrity is handled - // elsewhere (lookup) and the rest are opaque payloads. + // ── previously-opaque types: value-shape contract (ADR-0104 D1) ───── + // Single-value references (id string), file-likes (id/url string or the + // pre-D3 inline metadata object), and structured JSON payloads + // (location/address/composite/repeater/record/vector) are checked against + // the spec's `valueSchemaFor`. Warn-first rollout (ADR-0104 R1/R2): a + // violation logs instead of rejecting, so legacy rows written under the + // lax regime don't strand their records; set + // `OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1` to enforce as a 400. The flip to + // error-by-default rides a later minor once telemetry is quiet. + if (REFERENCE_VALUE_TYPES.has(t) || FILE_REFERENCE_TYPES.has(t) || STRUCTURED_JSON_TYPES.has(t)) { + const parsed = shapeSchemaFor(def).safeParse(value); + if (!parsed.success) { + const detail = parsed.error.issues[0]?.message ?? 'invalid value shape'; + const message = `${name} has an invalid ${t} value: ${detail}`; + if (VALUE_SHAPE_STRICT()) { + return { field: name, code: 'invalid_type', message }; + } + warnOnce(`${t}:${name}`, `[value-shape] ${message} — accepted for now (ADR-0104 warn-first; set OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1 to enforce)`); + } + return null; + } + + // Remaining types (formula/summary/autonumber outputs, json/code payloads) + // are explicitly open per the spec contract — see field-value.zod.ts. return null; } +/** Strict value-shape enforcement opt-in (ADR-0104 warn-first rollout). */ +function VALUE_SHAPE_STRICT(): boolean { + return typeof process !== 'undefined' && process.env?.OS_DATA_VALUE_SHAPE_STRICT_ENABLED === '1'; +} + +const warnedShapes = new Set(); +function warnOnce(key: string, message: string): void { + if (warnedShapes.has(key)) return; + warnedShapes.add(key); + console.warn(message); +} + +/** + * Per-field-definition cache of the spec's derived value schema. Building a + * Zod schema is an order of magnitude costlier than parsing with it, so the + * derivation runs once per field def (ADR-0104 performance budget). Keyed on + * def identity — `validateRecord`'s update path passes the registry's own + * field objects (no clones), so the map hits. + */ +const shapeSchemaCache = new WeakMap>(); +function shapeSchemaFor(def: FieldDef): ReturnType { + let schema = shapeSchemaCache.get(def); + if (!schema) { + schema = valueSchemaFor(def as { type: string; multiple?: boolean; options?: FieldDef['options'] }, 'stored'); + shapeSchemaCache.set(def, schema); + } + return schema; +} + /** * Validate a payload against a list of declared fields. `objectSchema` * comes from `ObjectQL.getRegistry().getObject(name)` and exposes a @@ -377,8 +429,10 @@ export function validateRecord( const def = fields[name]; if (!def) continue; if (def.system || def.readonly) continue; - // Clone def with required=false so PATCH-omitted-fields don't 400. - const err = validateOne(name, { ...def, required: false }, value); + // skipRequired: PATCH-omitted fields must not 400. (No def clone — the + // registry's own field object flows through so the ADR-0104 value-shape + // schema cache, keyed on def identity, hits.) + const err = validateOne(name, def, value, true); if (err) errors.push(err); } } diff --git a/packages/plugins/driver-sql/src/sql-driver.ts b/packages/plugins/driver-sql/src/sql-driver.ts index 3fcfe0736d..0bc4b456ab 100644 --- a/packages/plugins/driver-sql/src/sql-driver.ts +++ b/packages/plugins/driver-sql/src/sql-driver.ts @@ -9,6 +9,7 @@ import type { QueryAST, DriverOptions, SchemaMode } from '@objectstack/spec/data'; import { parseAutonumberFormat, renderAutonumber, missingFieldValues, isTenancyDisabled, type AutonumberToken } from '@objectstack/spec/data'; +import { STRUCTURED_JSON_TYPES, FILE_REFERENCE_TYPES, MULTI_OPTION_TYPES, NUMERIC_VALUE_TYPES } from '@objectstack/spec/data'; import type { IDataDriver } from '@objectstack/spec/contracts'; import { StorageNameMapping } from '@objectstack/spec/system'; import { ExternalSchemaModeViolationError } from '@objectstack/spec/shared'; @@ -57,10 +58,14 @@ const GLOBAL_TENANT = '__global__'; * rest are arrays. */ const JSON_COLUMN_TYPES = new Set([ - 'json', 'object', 'array', 'record', - 'image', 'file', 'avatar', 'video', 'audio', - 'location', 'address', 'composite', - 'multiselect', 'checkboxes', 'tags', 'repeater', 'vector', + // Spec value-shape classes (ADR-0104 D1): structured JSON payloads, the + // (pre-D3) inline file metadata objects, and the inherently-array option + // types. Membership is owned by @objectstack/spec — a type added there + // becomes a JSON column here without touching this file. + ...STRUCTURED_JSON_TYPES, ...FILE_REFERENCE_TYPES, ...MULTI_OPTION_TYPES, + // Driver-internal aliases (external/introspected columns) — not authorable + // FieldTypes, so they stay a local extra. + 'object', 'array', ]); /** @@ -79,9 +84,9 @@ const JSON_COLUMN_TYPES = new Set([ * gap for the numeric scalars. */ const NUMERIC_SCALAR_TYPES = new Set([ - 'integer', 'int', - 'float', 'number', 'currency', 'percent', 'summary', - 'rating', 'slider', 'progress', + // Spec numeric value class (ADR-0104 D1) + driver-internal SQL aliases. + ...NUMERIC_VALUE_TYPES, + 'integer', 'int', 'float', ]); /** diff --git a/packages/qa/dogfood/test/field-zoo-roundtrip.dogfood.test.ts b/packages/qa/dogfood/test/field-zoo-roundtrip.dogfood.test.ts index 2c4a072afb..9b7d741361 100644 --- a/packages/qa/dogfood/test/field-zoo-roundtrip.dogfood.test.ts +++ b/packages/qa/dogfood/test/field-zoo-roundtrip.dogfood.test.ts @@ -21,103 +21,7 @@ import showcaseStack from '@objectstack/example-showcase'; import { SECRET_MASK } from '@objectstack/objectql'; import { bootStack, type VerifyStack } from '@objectstack/verify'; -// A field-type coverage entry. `write` is the value POSTed; `expect` describes -// how the value must come back. `equal` = exact (or set-equal for arrays); -// `computed`/`present` cover server-owned fields you don't write; `masked` -// covers `secret`, which is encrypt-on-write and masked on read. -type Check = - | { kind: 'equal'; write: unknown } - | { kind: 'setEqual'; write: unknown[] } - | { kind: 'present'; write?: unknown } // written but only asserted non-null (e.g. one-way/opaque) - | { kind: 'masked'; write: unknown } // secret: POSTed plaintext must read back as SECRET_MASK - | { kind: 'computed'; expected: unknown }; // derived, asserted not written - -interface FieldCase { - field: string; - type: string; - check: Check; - // KNOWN GAP: the schema→SQL-column mapping / read coercion doesn't yet cover - // this type, so it round-trips with the wrong JS type (e.g. '4' not 4). The - // value persists (no data loss), but fidelity leaks. Quarantined via it.fails - // so it stays visible AND auto-flags the day it's fixed. Tracked separately. - xfail?: boolean; -} - -// The #2004 headliners are the three array types + f_time. The rest broaden the -// matrix across scalars, temporals, structured JSON, and computed/system fields. -const MATRIX: FieldCase[] = [ - // text-ish - { field: 'f_textarea', type: 'textarea', check: { kind: 'equal', write: 'line1\nline2' } }, - { field: 'f_email', type: 'email', check: { kind: 'equal', write: 'zoo@example.com' } }, - { field: 'f_url', type: 'url', check: { kind: 'equal', write: 'https://objectstack.ai' } }, - { field: 'f_phone', type: 'phone', check: { kind: 'equal', write: '+14155550123' } }, - // numbers - { field: 'f_number', type: 'number', check: { kind: 'equal', write: 42 } }, - { field: 'f_currency', type: 'currency', check: { kind: 'equal', write: 1234.56 } }, - { field: 'f_percent', type: 'percent', check: { kind: 'equal', write: 75 } }, - { field: 'f_rating', type: 'rating', check: { kind: 'equal', write: 4 } }, - { field: 'f_slider', type: 'slider', check: { kind: 'equal', write: 25 } }, - // temporal — f_time is a #2004 fix (time-of-day) - { field: 'f_date', type: 'date', check: { kind: 'equal', write: '2024-03-15' } }, - { field: 'f_time', type: 'time', check: { kind: 'equal', write: '14:30:00' } }, - // logic - { field: 'f_boolean', type: 'boolean', check: { kind: 'equal', write: true } }, - { field: 'f_toggle', type: 'toggle', check: { kind: 'equal', write: true } }, - // scalar selection - { field: 'f_select', type: 'select', check: { kind: 'equal', write: 'high' } }, - { field: 'f_radio', type: 'radio', check: { kind: 'equal', write: 'yes' } }, - // ── #2004 array headliners — these silently dropped before the fix ── - { field: 'f_multiselect', type: 'multiselect', check: { kind: 'setEqual', write: ['red', 'blue'] } }, - { field: 'f_checkboxes', type: 'checkboxes', check: { kind: 'setEqual', write: ['email', 'push'] } }, - { field: 'f_tags', type: 'tags', check: { kind: 'setEqual', write: ['alpha', 'beta', 'gamma'] } }, - // numeric scalar — same fidelity class as rating/slider (was TEXT-affinity) - { field: 'f_progress', type: 'progress', check: { kind: 'equal', write: 60 } }, - // rich text — all plain strings on the wire - { field: 'f_markdown', type: 'markdown', check: { kind: 'equal', write: '# Heading\n\nbody' } }, - { field: 'f_html', type: 'html', check: { kind: 'equal', write: '

hi

' } }, - { field: 'f_richtext', type: 'richtext', check: { kind: 'equal', write: 'rich' } }, - { field: 'f_code', type: 'code', check: { kind: 'equal', write: '{\n "a": 1\n}' } }, - { field: 'f_signature', type: 'signature', check: { kind: 'equal', write: 'data:image/png;base64,AAAA' } }, - { field: 'f_qrcode', type: 'qrcode', check: { kind: 'equal', write: 'https://objectstack.ai' } }, - // temporal — instant - { field: 'f_datetime', type: 'datetime', check: { kind: 'equal', write: '2024-03-15T14:30:00.000Z' } }, - // structured JSON - { field: 'f_json', type: 'json', check: { kind: 'equal', write: { a: 1, b: [2, 3] } } }, - { field: 'f_color', type: 'color', check: { kind: 'equal', write: '#FF8800' } }, - { field: 'f_vector', type: 'vector', check: { kind: 'equal', write: [0.1, 0.2, 0.3] } }, - // object-valued types that must store/parse as JSON, not stringify to TEXT - { field: 'f_record', type: 'record', check: { kind: 'equal', write: { home: '+1', work: '+2' } } }, - { field: 'f_video', type: 'video', check: { kind: 'equal', write: { url: 'https://cdn/v.mp4', duration: 12 } } }, - { field: 'f_audio', type: 'audio', check: { kind: 'equal', write: { url: 'https://cdn/a.mp3', duration: 30 } } }, - { field: 'f_composite', type: 'composite', check: { kind: 'equal', write: { label: 'x', n: 1 } } }, - { field: 'f_repeater', type: 'repeater', check: { kind: 'equal', write: [{ a: 1 }, { a: 2 }] } }, - { field: 'f_location', type: 'location', check: { kind: 'equal', write: { lat: 37.77, lng: -122.42 } } }, - { field: 'f_address', type: 'address', check: { kind: 'equal', write: { street: '1 Main', city: 'SF', country: 'US' } } }, - { field: 'f_image', type: 'image', check: { kind: 'equal', write: { url: 'https://cdn/i.png', alt: 'i' } } }, - { field: 'f_file', type: 'file', check: { kind: 'equal', write: { url: 'https://cdn/f.pdf', name: 'f.pdf', size: 1024 } } }, - { field: 'f_avatar', type: 'avatar', check: { kind: 'equal', write: { url: 'https://cdn/a.png' } } }, - // relational — store a reference id as a string and read it back verbatim. - // FK enforcement is off in this harness, so this asserts value fidelity - // (id string → id string), not referential integrity / $expand (covered - // elsewhere). The point here is the stored type doesn't drift. - { field: 'f_lookup', type: 'lookup', check: { kind: 'equal', write: 'acc_synthetic_0001' } }, - { field: 'f_master_detail', type: 'master_detail', check: { kind: 'equal', write: 'proj_synthetic_0001' } }, - { field: 'f_tree', type: 'tree', check: { kind: 'equal', write: 'cat_synthetic_0001' } }, - // security — both credential types mask on read (plaintext never echoes back - // over HTTP). `secret` is encrypted at rest; `password` on a generic object is - // plaintext at rest but masked to SECRET_MASK on read (ADR-0100 / #2036 — the - // generic path does NOT hash it; auth owns hashing for its identity tables). - { field: 'f_secret', type: 'secret', check: { kind: 'masked', write: 'topsecret-value' } }, - { field: 'f_password', type: 'password', check: { kind: 'masked', write: 'p@ssw0rd!' } }, - // NB: f_summary (roll-up) is intentionally absent — it's a computed - // aggregate over related records, null on a childless fixture row; its - // semantics are covered by dedicated roll-up tests, not value fidelity. - // computed / system — not written, must materialize - { field: 'f_autonumber', type: 'autonumber', check: { kind: 'present' } }, - // f_number(42) * f_percent(75) / 100 = 31.5 - { field: 'f_formula', type: 'formula', check: { kind: 'computed', expected: 31.5 } }, -]; - +import { MATRIX } from './field-zoo.matrix'; describe('dogfood: field-type capability matrix round-trips over HTTP (#2004)', () => { let stack: VerifyStack; let record: Record; diff --git a/packages/qa/dogfood/test/field-zoo-value-shape.test.ts b/packages/qa/dogfood/test/field-zoo-value-shape.test.ts new file mode 100644 index 0000000000..5a9a91b57b --- /dev/null +++ b/packages/qa/dogfood/test/field-zoo-value-shape.test.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0104 D1 — contract ⇔ oracle interlock. +// +// The field-zoo MATRIX is the executable oracle of what each FieldType stores +// (driven over real HTTP by field-zoo-roundtrip.dogfood.test.ts). The spec's +// `valueSchemaFor` is the declared value-shape contract. This test pins them +// together: every MATRIX write vector must parse under the contract's stored +// form. A contract change that would break the wire — or a MATRIX change the +// contract doesn't admit — fails here instead of shipping silently. +// +// No stack boot: this is a pure unit-level assertion, cheap enough for every +// CI run (the HTTP round-trip stays in the dogfood tier). + +import { describe, it, expect } from 'vitest'; +import { valueSchemaFor } from '@objectstack/spec/data'; +import { MATRIX } from './field-zoo.matrix'; + +describe('ADR-0104: field-zoo MATRIX write vectors parse under valueSchemaFor(stored)', () => { + const writable = MATRIX.filter( + (c) => c.check.kind === 'equal' || c.check.kind === 'setEqual' || c.check.kind === 'masked', + ); + + it('covers a meaningful slice of the matrix', () => { + expect(writable.length).toBeGreaterThanOrEqual(40); + }); + + for (const c of writable) { + it(`${c.field} (${c.type})`, () => { + const write = (c.check as { write: unknown }).write; + const result = valueSchemaFor({ type: c.type }, 'stored').safeParse(write); + expect( + result.success, + result.success ? undefined : `${c.type} write ${JSON.stringify(write)} rejected: ${result.error.issues[0]?.message}`, + ).toBe(true); + }); + } +}); diff --git a/packages/qa/dogfood/test/field-zoo.matrix.ts b/packages/qa/dogfood/test/field-zoo.matrix.ts new file mode 100644 index 0000000000..8931479642 --- /dev/null +++ b/packages/qa/dogfood/test/field-zoo.matrix.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The field-zoo capability MATRIX — the executable oracle of what each +// authorable FieldType stores on the wire. Extracted from +// field-zoo-roundtrip.dogfood.test.ts (which drives it over real HTTP) so the +// ADR-0104 value-shape contract test can assert against the SAME vectors +// without booting a stack: the contract (@objectstack/spec valueSchemaFor) +// and this oracle cannot drift apart without a test going red. + +// A field-type coverage entry. `write` is the value POSTed; `expect` describes +// how the value must come back. `equal` = exact (or set-equal for arrays); +// `computed`/`present` cover server-owned fields you don't write; `masked` +// covers `secret`, which is encrypt-on-write and masked on read. +export type Check = + | { kind: 'equal'; write: unknown } + | { kind: 'setEqual'; write: unknown[] } + | { kind: 'present'; write?: unknown } // written but only asserted non-null (e.g. one-way/opaque) + | { kind: 'masked'; write: unknown } // secret: POSTed plaintext must read back as SECRET_MASK + | { kind: 'computed'; expected: unknown }; // derived, asserted not written + +export interface FieldCase { + field: string; + type: string; + check: Check; + // KNOWN GAP: the schema→SQL-column mapping / read coercion doesn't yet cover + // this type, so it round-trips with the wrong JS type (e.g. '4' not 4). The + // value persists (no data loss), but fidelity leaks. Quarantined via it.fails + // so it stays visible AND auto-flags the day it's fixed. Tracked separately. + xfail?: boolean; +} + +// The #2004 headliners are the three array types + f_time. The rest broaden the +// matrix across scalars, temporals, structured JSON, and computed/system fields. +export const MATRIX: FieldCase[] = [ + // text-ish + { field: 'f_textarea', type: 'textarea', check: { kind: 'equal', write: 'line1\nline2' } }, + { field: 'f_email', type: 'email', check: { kind: 'equal', write: 'zoo@example.com' } }, + { field: 'f_url', type: 'url', check: { kind: 'equal', write: 'https://objectstack.ai' } }, + { field: 'f_phone', type: 'phone', check: { kind: 'equal', write: '+14155550123' } }, + // numbers + { field: 'f_number', type: 'number', check: { kind: 'equal', write: 42 } }, + { field: 'f_currency', type: 'currency', check: { kind: 'equal', write: 1234.56 } }, + { field: 'f_percent', type: 'percent', check: { kind: 'equal', write: 75 } }, + { field: 'f_rating', type: 'rating', check: { kind: 'equal', write: 4 } }, + { field: 'f_slider', type: 'slider', check: { kind: 'equal', write: 25 } }, + // temporal — f_time is a #2004 fix (time-of-day) + { field: 'f_date', type: 'date', check: { kind: 'equal', write: '2024-03-15' } }, + { field: 'f_time', type: 'time', check: { kind: 'equal', write: '14:30:00' } }, + // logic + { field: 'f_boolean', type: 'boolean', check: { kind: 'equal', write: true } }, + { field: 'f_toggle', type: 'toggle', check: { kind: 'equal', write: true } }, + // scalar selection + { field: 'f_select', type: 'select', check: { kind: 'equal', write: 'high' } }, + { field: 'f_radio', type: 'radio', check: { kind: 'equal', write: 'yes' } }, + // ── #2004 array headliners — these silently dropped before the fix ── + { field: 'f_multiselect', type: 'multiselect', check: { kind: 'setEqual', write: ['red', 'blue'] } }, + { field: 'f_checkboxes', type: 'checkboxes', check: { kind: 'setEqual', write: ['email', 'push'] } }, + { field: 'f_tags', type: 'tags', check: { kind: 'setEqual', write: ['alpha', 'beta', 'gamma'] } }, + // numeric scalar — same fidelity class as rating/slider (was TEXT-affinity) + { field: 'f_progress', type: 'progress', check: { kind: 'equal', write: 60 } }, + // rich text — all plain strings on the wire + { field: 'f_markdown', type: 'markdown', check: { kind: 'equal', write: '# Heading\n\nbody' } }, + { field: 'f_html', type: 'html', check: { kind: 'equal', write: '

hi

' } }, + { field: 'f_richtext', type: 'richtext', check: { kind: 'equal', write: 'rich' } }, + { field: 'f_code', type: 'code', check: { kind: 'equal', write: '{\n "a": 1\n}' } }, + { field: 'f_signature', type: 'signature', check: { kind: 'equal', write: 'data:image/png;base64,AAAA' } }, + { field: 'f_qrcode', type: 'qrcode', check: { kind: 'equal', write: 'https://objectstack.ai' } }, + // temporal — instant + { field: 'f_datetime', type: 'datetime', check: { kind: 'equal', write: '2024-03-15T14:30:00.000Z' } }, + // structured JSON + { field: 'f_json', type: 'json', check: { kind: 'equal', write: { a: 1, b: [2, 3] } } }, + { field: 'f_color', type: 'color', check: { kind: 'equal', write: '#FF8800' } }, + { field: 'f_vector', type: 'vector', check: { kind: 'equal', write: [0.1, 0.2, 0.3] } }, + // object-valued types that must store/parse as JSON, not stringify to TEXT + { field: 'f_record', type: 'record', check: { kind: 'equal', write: { home: '+1', work: '+2' } } }, + { field: 'f_video', type: 'video', check: { kind: 'equal', write: { url: 'https://cdn/v.mp4', duration: 12 } } }, + { field: 'f_audio', type: 'audio', check: { kind: 'equal', write: { url: 'https://cdn/a.mp3', duration: 30 } } }, + { field: 'f_composite', type: 'composite', check: { kind: 'equal', write: { label: 'x', n: 1 } } }, + { field: 'f_repeater', type: 'repeater', check: { kind: 'equal', write: [{ a: 1 }, { a: 2 }] } }, + { field: 'f_location', type: 'location', check: { kind: 'equal', write: { lat: 37.77, lng: -122.42 } } }, + { field: 'f_address', type: 'address', check: { kind: 'equal', write: { street: '1 Main', city: 'SF', country: 'US' } } }, + { field: 'f_image', type: 'image', check: { kind: 'equal', write: { url: 'https://cdn/i.png', alt: 'i' } } }, + { field: 'f_file', type: 'file', check: { kind: 'equal', write: { url: 'https://cdn/f.pdf', name: 'f.pdf', size: 1024 } } }, + { field: 'f_avatar', type: 'avatar', check: { kind: 'equal', write: { url: 'https://cdn/a.png' } } }, + // relational — store a reference id as a string and read it back verbatim. + // FK enforcement is off in this harness, so this asserts value fidelity + // (id string → id string), not referential integrity / $expand (covered + // elsewhere). The point here is the stored type doesn't drift. + { field: 'f_lookup', type: 'lookup', check: { kind: 'equal', write: 'acc_synthetic_0001' } }, + { field: 'f_master_detail', type: 'master_detail', check: { kind: 'equal', write: 'proj_synthetic_0001' } }, + { field: 'f_tree', type: 'tree', check: { kind: 'equal', write: 'cat_synthetic_0001' } }, + // security — both credential types mask on read (plaintext never echoes back + // over HTTP). `secret` is encrypted at rest; `password` on a generic object is + // plaintext at rest but masked to SECRET_MASK on read (ADR-0100 / #2036 — the + // generic path does NOT hash it; auth owns hashing for its identity tables). + { field: 'f_secret', type: 'secret', check: { kind: 'masked', write: 'topsecret-value' } }, + { field: 'f_password', type: 'password', check: { kind: 'masked', write: 'p@ssw0rd!' } }, + // NB: f_summary (roll-up) is intentionally absent — it's a computed + // aggregate over related records, null on a childless fixture row; its + // semantics are covered by dedicated roll-up tests, not value fidelity. + // computed / system — not written, must materialize + { field: 'f_autonumber', type: 'autonumber', check: { kind: 'present' } }, + // f_number(42) * f_percent(75) / 100 = 31.5 + { field: 'f_formula', type: 'formula', check: { kind: 'computed', expected: 31.5 } }, +]; diff --git a/packages/rest/src/import-coerce.ts b/packages/rest/src/import-coerce.ts index 0edf6660c6..f12204f4ba 100644 --- a/packages/rest/src/import-coerce.ts +++ b/packages/rest/src/import-coerce.ts @@ -32,41 +32,31 @@ */ import type { ExportFieldMeta } from './export-format.js'; - -/** Field types whose stored value points at another record (id). */ -const REFERENCE_TYPES = new Set(['lookup', 'master_detail', 'user', 'reference', 'tree']); -/** Single-select option types (store one option value). */ -const OPTION_TYPES = new Set(['select', 'radio']); -/** Multi-select option types (store an array of option values). */ -const MULTI_OPTION_TYPES = new Set(['multiselect', 'checkboxes', 'tags']); -/** Numeric field types (store a finite number). */ -const NUMBER_TYPES = new Set(['number', 'currency', 'percent', 'rating', 'slider']); -/** Boolean field types (store a real boolean). */ -const BOOL_TYPES = new Set(['boolean', 'toggle']); -/** Attachment field types (store a file id / url, or an array when `multiple`). */ -const FILE_TYPES = new Set(['file', 'image']); +import { + SINGLE_OPTION_TYPES as OPTION_TYPES, + MULTI_OPTION_TYPES, + NUMERIC_VALUE_TYPES as NUMBER_TYPES, + BOOLEAN_VALUE_TYPES as BOOL_TYPES, + FILE_REFERENCE_TYPES as FILE_TYPES, + REFERENCE_VALUE_TYPES, + isMultiValueField as specIsMultiValueField, +} from '@objectstack/spec/data'; /** - * Single-value field types that become an ARRAY when flagged `multiple: true`. - * Mirrors objectql `record-validator.ts` (`MULTI_CAPABLE_TYPES`): per the spec - * (`field.zod.ts`), `multiple` applies to select / lookup / file / image; - * `radio` shares the select branch and `user` is stored identically to `lookup`. - * master_detail / reference / tree are NOT multi-capable, so a stray - * `multiple: true` on them is ignored (they stay single — same as the engine). + * Field types whose stored value points at another record (id). The spec's + * reference class (ADR-0104 D1) plus `reference` — a legacy external-object + * alias that is not an authorable `FieldType` and so stays a local extra. */ -const MULTI_CAPABLE_TYPES = new Set(['select', 'radio', 'lookup', 'user', 'file', 'image']); +const REFERENCE_TYPES = new Set([...REFERENCE_VALUE_TYPES, 'reference']); /** - * Whether a field's stored value is an array — an inherently-multi type - * (multiselect / checkboxes / tags) or a multi-capable type flagged - * `multiple: true`. Kept in lock-step with the engine's `isMultiValueField` - * so a coerced cell has the SAME shape the engine will accept on insert. + * Whether a field's stored value is an array. Delegates to the spec's + * `isMultiValueField` (ADR-0104 D1) — the shared definition the engine's + * record-validator uses — so a coerced cell has the SAME shape the engine + * will accept on insert. */ function isMultiValueField(meta: ExportFieldMeta | undefined): boolean { - const t = meta?.type; - if (!t) return false; - if (MULTI_OPTION_TYPES.has(t)) return true; - return MULTI_CAPABLE_TYPES.has(t) && meta?.multiple === true; + return meta?.type ? specIsMultiValueField(meta as { type: string; multiple?: boolean }) : false; } /** diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index d38c439937..e4f32caf2f 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -681,6 +681,7 @@ "cloud/UserReview", "cloud/VersionRelease", "data/Address", + "data/AddressValue", "data/AggregationFunction", "data/AggregationMetricType", "data/AggregationNode", @@ -689,6 +690,8 @@ "data/AnalyticsQuery", "data/ApiMethod", "data/BaseEngineOptions", + "data/CalendarDateValue", + "data/ClockTimeValue", "data/ComputedFieldCache", "data/ConditionalValidation", "data/ConsistencyLevel", @@ -756,6 +759,7 @@ "data/FieldNode", "data/FieldReference", "data/FieldType", + "data/FileLikeValue", "data/FilterCondition", "data/FormatValidation", "data/FullTextSearch", @@ -765,6 +769,7 @@ "data/HookContext", "data/HookEvent", "data/Index", + "data/InstantValue", "data/JSONValidation", "data/JoinNode", "data/JoinStrategy", @@ -772,6 +777,7 @@ "data/Lifecycle", "data/LifecycleClass", "data/LocationCoordinates", + "data/LocationValue", "data/Mapping", "data/Metric", "data/ModeSchema", @@ -797,6 +803,7 @@ "data/PoolConfig", "data/Query", "data/QueryFilter", + "data/ReferenceIdValue", "data/ReferenceResolution", "data/ReferenceResolutionError", "data/ReplicationConfig", diff --git a/packages/spec/src/data/field-value.test.ts b/packages/spec/src/data/field-value.test.ts new file mode 100644 index 0000000000..ed65457c68 --- /dev/null +++ b/packages/spec/src/data/field-value.test.ts @@ -0,0 +1,183 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Field runtime value-shape contract (ADR-0104 D1). + * + * The write-vectors here deliberately mirror the field-zoo round-trip MATRIX + * (packages/qa/dogfood/test/field-zoo-roundtrip.dogfood.test.ts) — the + * executable oracle of what the platform actually stores. If a case here and + * a MATRIX case disagree, the MATRIX (deployed reality) wins. + */ + +import { describe, it, expect } from 'vitest'; +import { FieldType } from './field.zod'; +import { + STRING_VALUE_TYPES, + NUMERIC_VALUE_TYPES, + BOOLEAN_VALUE_TYPES, + CALENDAR_DATE_TYPES, + INSTANT_TYPES, + CLOCK_TIME_TYPES, + SINGLE_OPTION_TYPES, + MULTI_OPTION_TYPES, + REFERENCE_VALUE_TYPES, + FILE_REFERENCE_TYPES, + STRUCTURED_JSON_TYPES, + COMPUTED_VALUE_TYPES, + MULTI_CAPABLE_TYPES, + isMultiValueField, + valueSchemaFor, +} from './field-value.zod'; + +const ok = (def: Parameters[0], v: unknown, form?: 'stored' | 'expanded') => + expect(valueSchemaFor(def, form).safeParse(v).success).toBe(true); +const bad = (def: Parameters[0], v: unknown, form?: 'stored' | 'expanded') => + expect(valueSchemaFor(def, form).safeParse(v).success).toBe(false); + +describe('semantic type classes', () => { + it('every class member is a declared FieldType', () => { + const all = new Set(FieldType.options); + for (const cls of [ + STRING_VALUE_TYPES, NUMERIC_VALUE_TYPES, BOOLEAN_VALUE_TYPES, + CALENDAR_DATE_TYPES, INSTANT_TYPES, CLOCK_TIME_TYPES, + SINGLE_OPTION_TYPES, MULTI_OPTION_TYPES, REFERENCE_VALUE_TYPES, + FILE_REFERENCE_TYPES, STRUCTURED_JSON_TYPES, COMPUTED_VALUE_TYPES, + MULTI_CAPABLE_TYPES, + ]) { + for (const t of cls) expect(all).toContain(t); + } + }); + + it('every FieldType lands in at least one value class (no unclassified types)', () => { + const classified = new Set([ + ...STRING_VALUE_TYPES, ...NUMERIC_VALUE_TYPES, ...BOOLEAN_VALUE_TYPES, + ...CALENDAR_DATE_TYPES, ...INSTANT_TYPES, ...CLOCK_TIME_TYPES, + ...SINGLE_OPTION_TYPES, ...MULTI_OPTION_TYPES, ...REFERENCE_VALUE_TYPES, + ...FILE_REFERENCE_TYPES, ...STRUCTURED_JSON_TYPES, ...COMPUTED_VALUE_TYPES, + ]); + const unclassified = FieldType.options.filter((t) => !classified.has(t)); + expect(unclassified).toEqual([]); + }); + + it('the shape classes are mutually disjoint (COMPUTED is the orthogonal who-writes axis: `summary` is numeric AND computed)', () => { + const classes = [ + STRING_VALUE_TYPES, NUMERIC_VALUE_TYPES, BOOLEAN_VALUE_TYPES, + CALENDAR_DATE_TYPES, INSTANT_TYPES, CLOCK_TIME_TYPES, + SINGLE_OPTION_TYPES, MULTI_OPTION_TYPES, REFERENCE_VALUE_TYPES, + FILE_REFERENCE_TYPES, STRUCTURED_JSON_TYPES, + ]; + const seen = new Map(); + classes.forEach((cls, i) => { + for (const t of cls) { + expect(seen.has(t), `type "${t}" appears in class #${seen.get(t)} and #${i}`).toBe(false); + seen.set(t, i); + } + }); + }); +}); + +describe('isMultiValueField', () => { + it('inherently-multi option types are always arrays', () => { + for (const type of ['multiselect', 'checkboxes', 'tags']) { + expect(isMultiValueField({ type })).toBe(true); + expect(isMultiValueField({ type, multiple: false })).toBe(true); + } + }); + it('multi-capable types require the multiple flag', () => { + for (const type of ['select', 'radio', 'lookup', 'user', 'file', 'image']) { + expect(isMultiValueField({ type })).toBe(false); + expect(isMultiValueField({ type, multiple: true })).toBe(true); + } + }); + it('a stray multiple on a non-multi-capable type is ignored (matches the engine)', () => { + for (const type of ['master_detail', 'tree', 'text', 'number']) { + expect(isMultiValueField({ type, multiple: true })).toBe(false); + } + }); +}); + +describe('valueSchemaFor — stored form (field-zoo reality)', () => { + it('strings', () => { + ok({ type: 'text' }, 'hello'); + ok({ type: 'signature' }, 'data:image/png;base64,AAAA'); + ok({ type: 'color' }, '#FF8800'); + bad({ type: 'text' }, 42); + }); + + it('numerics — currency is a BARE number (the retired CurrencyValueSchema object is rejected)', () => { + ok({ type: 'currency' }, 1234.56); + bad({ type: 'currency' }, { value: 1234.56, currency: 'USD' }); + ok({ type: 'progress' }, 60); + bad({ type: 'number' }, 'NaN-ish'); + bad({ type: 'number' }, Infinity); + }); + + it('booleans are real booleans', () => { + ok({ type: 'boolean' }, true); + bad({ type: 'boolean' }, 1); + }); + + it('date is a calendar day, datetime a zoned instant, time a wall clock (#2004 / ADR-0053)', () => { + ok({ type: 'date' }, '2024-03-15'); + bad({ type: 'date' }, '2024-03-15T14:30:00.000Z'); + ok({ type: 'datetime' }, '2024-03-15T14:30:00.000Z'); + ok({ type: 'datetime' }, '2024-03-15T14:30:00+08:00'); + bad({ type: 'datetime' }, '2024-03-15 14:30:00'); + bad({ type: 'datetime' }, '2024-03-15'); + ok({ type: 'time' }, '14:30:00'); + ok({ type: 'time' }, '14:30'); + bad({ type: 'time' }, '14:60'); + bad({ type: 'time' }, 'not-a-time'); + }); + + it('option types enforce declared option codes; free-form without options', () => { + const options = [{ value: 'high' }, { value: 'low' }]; + ok({ type: 'select', options }, 'high'); + bad({ type: 'select', options }, 'HIGH'); + ok({ type: 'select' }, 'anything'); + ok({ type: 'multiselect', options }, ['high', 'low']); + bad({ type: 'multiselect', options }, ['high', 'nope']); + bad({ type: 'multiselect', options }, 'high'); // scalar at a multi field + ok({ type: 'tags' }, ['alpha', 'beta']); + }); + + it('references store id strings; multiple stores arrays of ids', () => { + ok({ type: 'lookup' }, 'acc_synthetic_0001'); + bad({ type: 'lookup' }, { id: 'acc_1', name: 'Acme' }); // expanded form ≠ stored form + bad({ type: 'lookup' }, ''); + ok({ type: 'user', multiple: true }, ['usr_1', 'usr_2']); + bad({ type: 'user', multiple: true }, 'usr_1'); + }); + + it('expanded form admits the in-place $expand record object for references', () => { + ok({ type: 'lookup' }, { id: 'acc_1', name: 'Acme' }, 'expanded'); + ok({ type: 'lookup' }, 'acc_1', 'expanded'); // unresolvable ids stay ids + }); + + it('file-likes admit the transitional inline object OR an opaque id/url string (pre-D3)', () => { + ok({ type: 'file' }, { url: 'https://cdn/f.pdf', name: 'f.pdf', size: 1024 }); + ok({ type: 'image' }, { url: 'https://cdn/i.png', alt: 'i' }); + ok({ type: 'file' }, 'file_01HXYZ'); + ok({ type: 'image', multiple: true }, ['a.png', 'b.png']); + bad({ type: 'file' }, 42); + bad({ type: 'file' }, {}); + }); + + it('structured JSON types', () => { + ok({ type: 'location' }, { lat: 37.77, lng: -122.42 }); + bad({ type: 'location' }, { latitude: 37.77, longitude: -122.42 }); // the retired spec-only shape + ok({ type: 'address' }, { street: '1 Main', city: 'SF', country: 'US' }); + ok({ type: 'vector' }, [0.1, 0.2, 0.3]); + bad({ type: 'vector' }, [0.1, 'x']); + ok({ type: 'repeater' }, [{ a: 1 }, { a: 2 }]); + bad({ type: 'repeater' }, { a: 1 }); + ok({ type: 'record' }, { home: '+1', work: '+2' }); + ok({ type: 'composite' }, { label: 'x', n: 1 }); + }); + + it('json/code and computed types are explicitly open', () => { + ok({ type: 'json' }, { a: 1, b: [2, 3] }); + ok({ type: 'formula' }, 31.5); + ok({ type: 'autonumber' }, 'INV-0001'); + }); +}); diff --git a/packages/spec/src/data/field-value.zod.ts b/packages/spec/src/data/field-value.zod.ts new file mode 100644 index 0000000000..69f930ac6f --- /dev/null +++ b/packages/spec/src/data/field-value.zod.ts @@ -0,0 +1,251 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Field runtime VALUE-shape contract (ADR-0104 D1). + * + * `FieldSchema` owns what a field *definition* looks like; this module owns + * what a field's runtime *value* looks like — the shape the write path + * accepts, drivers persist, and an unexpanded API read returns. Before this + * module the knowledge lived as private, hand-copied type sets in objectql's + * record-validator, rest's import-coerce, driver-sql, and verify; adding one + * multi-capable or JSON-shaped type meant updating four lists or silently + * corrupting data. Those consumers now derive from the classes below. + * + * Two canonical forms exist per field (ADR-0104 D1): + * - `stored` — the storage/wire form (e.g. lookup ⇒ record-id string, + * `date` ⇒ `YYYY-MM-DD`, select ⇒ option code). + * - `expanded` — the enriched `$expand` read form (lookup ⇒ the related + * record object). For types without an expansion, + * expanded ≡ stored. + * + * "Reality wins": where the deployed stored shape is coherent, the contract + * adopts it — deployed data is a wire contract we don't get to rewrite by + * editing Zod. This is why `currency` is a bare number (not the never-consumed + * `CurrencyValueSchema` object) and `location` is `{lat, lng}` (what field-zoo + * stores), not the never-consumed `{latitude, longitude}` shape. + * + * Purity: schemas/constants/derivation only — no runtime logic, no caching + * (Prime Directive #2). Consumers cache `valueSchemaFor` results per field + * definition; building a Zod schema per write is the one performance trap + * this contract has (ADR-0104 performance budget). + */ + +import { z } from 'zod'; +import { lazySchema } from '../shared/lazy-schema'; +import type { FieldType } from './field.zod'; +import { AddressSchema } from './field.zod'; + +/* ──────────────────────────────────────────────────────────────────────────── + * Semantic type classes + * + * Membership is over `FieldType` values only. Driver-internal aliases that are + * NOT authorable field types (`integer`, `int`, `float`, `object`, `array`, + * external `reference`) stay in their drivers, layered on top of these sets. + * ──────────────────────────────────────────────────────────────────────────── */ + +/** Value is a plain string (validated per-type: email/url/phone formats, lengths). */ +export const STRING_VALUE_TYPES: ReadonlySet = new Set([ + 'text', 'textarea', 'email', 'url', 'phone', 'password', 'secret', + 'markdown', 'html', 'richtext', 'code', + 'color', 'signature', 'qrcode', +] as const satisfies readonly FieldType[]); + +/** Value is a finite numeric scalar. `currency` IS a bare number (see header). */ +export const NUMERIC_VALUE_TYPES: ReadonlySet = new Set([ + 'number', 'currency', 'percent', 'rating', 'slider', 'progress', 'summary', +] as const satisfies readonly FieldType[]); + +/** Value is a JS boolean on the wire (driver read-coercion repairs SQL 0/1). */ +export const BOOLEAN_VALUE_TYPES: ReadonlySet = new Set([ + 'boolean', 'toggle', +] as const satisfies readonly FieldType[]); + +/** Naive calendar day, stored `YYYY-MM-DD` — NOT an instant (ADR-0053). */ +export const CALENDAR_DATE_TYPES: ReadonlySet = new Set([ + 'date', +] as const satisfies readonly FieldType[]); + +/** UTC instant, stored as ISO-8601 with explicit zone (ADR-0053). */ +export const INSTANT_TYPES: ReadonlySet = new Set([ + 'datetime', +] as const satisfies readonly FieldType[]); + +/** Wall-clock time of day, `HH:MM[:SS[.fff]]` (+ optional zone) — not `Date.parse`-able (#2004). */ +export const CLOCK_TIME_TYPES: ReadonlySet = new Set([ + 'time', +] as const satisfies readonly FieldType[]); + +/** Single-choice option types: value is one declared option code. */ +export const SINGLE_OPTION_TYPES: ReadonlySet = new Set([ + 'select', 'radio', +] as const satisfies readonly FieldType[]); + +/** Inherently-multi option types: value is an array of option codes (tags: free-form). */ +export const MULTI_OPTION_TYPES: ReadonlySet = new Set([ + 'multiselect', 'checkboxes', 'tags', +] as const satisfies readonly FieldType[]); + +/** + * Value points at another record: a record-id string in stored form, the + * related record object in expanded form (`$expand` overwrites in place). + */ +export const REFERENCE_VALUE_TYPES: ReadonlySet = new Set([ + 'lookup', 'master_detail', 'user', 'tree', +] as const satisfies readonly FieldType[]); + +/** + * Media/attachment types. Stored form TODAY is the legacy inline metadata + * object (`{url, name?, size?, ...}`) or an opaque file-id/url string; + * ADR-0104 D3 (file-as-reference) narrows this to a `sys_file` id. The stored + * schema below deliberately admits both until D3 lands. + */ +export const FILE_REFERENCE_TYPES: ReadonlySet = new Set([ + 'image', 'file', 'avatar', 'video', 'audio', +] as const satisfies readonly FieldType[]); + +/** Structured JSON payloads persisted in JSON columns. */ +export const STRUCTURED_JSON_TYPES: ReadonlySet = new Set([ + 'json', 'composite', 'repeater', 'record', 'location', 'address', 'vector', +] as const satisfies readonly FieldType[]); + +/** Server-computed types: never client-written; shape is producer-owned. */ +export const COMPUTED_VALUE_TYPES: ReadonlySet = new Set([ + 'formula', 'summary', 'autonumber', +] as const satisfies readonly FieldType[]); + +/** + * Single-value types that become an ARRAY when flagged `multiple: true` + * (`FieldSchema.multiple`: select/lookup/file/image; `radio` shares the select + * branch; `user` stores identically to `lookup`). Previously hand-copied in + * objectql record-validator AND rest import-coerce. + */ +export const MULTI_CAPABLE_TYPES: ReadonlySet = new Set([ + 'select', 'radio', 'lookup', 'user', 'file', 'image', +] as const satisfies readonly FieldType[]); + +/** + * The minimal slice of a field definition the value contract reads. Structural + * (not `Field`) so runtime callers with their own trimmed field-def interfaces + * (objectql's `FieldDef`, rest's `ExportFieldMeta`) can pass theirs verbatim. + */ +export interface ValueShapeFieldDef { + type: string; + multiple?: boolean; + options?: Array<{ value: string | number } | string | number>; +} + +/** + * Whether a field's persisted value is an array — an inherently-multi option + * type, or a multi-capable type flagged `multiple: true`. THE shared + * definition (was duplicated verbatim in record-validator + import-coerce). + */ +export function isMultiValueField(def: ValueShapeFieldDef): boolean { + if (MULTI_OPTION_TYPES.has(def.type)) return true; + return MULTI_CAPABLE_TYPES.has(def.type) && def.multiple === true; +} + +/* ──────────────────────────────────────────────────────────────────────────── + * Canonical value schemas + * ──────────────────────────────────────────────────────────────────────────── */ + +/** `YYYY-MM-DD` — the calendar-day stored form (driver collapses Date → day). */ +export const CalendarDateValueSchema = lazySchema(() => + z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'expected YYYY-MM-DD (calendar day, not an instant)')); + +/** ISO-8601 instant with explicit zone — the `datetime` stored form. */ +export const InstantValueSchema = lazySchema(() => + z.string().refine((s) => !Number.isNaN(Date.parse(s)) && (/[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s.slice(10))), + 'expected an ISO-8601 instant with explicit zone (e.g. 2026-03-15T14:30:00.000Z)')); + +/** `HH:MM[:SS[.fff]]` with optional zone — the `time` stored form (#2004). */ +export const ClockTimeValueSchema = lazySchema(() => + z.string().regex(/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?(Z|[+-]([01]\d|2[0-3]):?[0-5]\d)?$/, + 'expected HH:MM or HH:MM:SS (wall-clock time of day)')); + +/** GPS point — the shape field-zoo stores and renderers read. See header re: the retired `{latitude, longitude}` form. */ +export const LocationValueSchema = lazySchema(() => z.object({ + lat: z.number().min(-90).max(90).describe('Latitude'), + lng: z.number().min(-180).max(180).describe('Longitude'), + altitude: z.number().optional().describe('Altitude in meters'), + accuracy: z.number().optional().describe('Accuracy in meters'), +})); + +/** Structured address value — adopts the (previously unconsumed) `AddressSchema` as the enforced contract. */ +export const AddressValueSchema = AddressSchema; + +/** + * Media/attachment stored value — TRANSITIONAL (pre-D3): either an opaque + * file-id/url string, or the legacy inline metadata object. ADR-0104 D3 + * narrows this to a `sys_file` id string; new writers should prefer the + * string form now. + */ +export const FileLikeValueSchema = lazySchema(() => z.union([ + z.string().min(1), + z.looseObject({ + url: z.string().optional(), + name: z.string().optional(), + size: z.number().optional(), + alt: z.string().optional(), + duration: z.number().optional(), + }).refine((o) => Object.keys(o).length > 0, 'empty file value'), +])); + +/** Record-id string — the stored form of every reference type. */ +export const ReferenceIdValueSchema = lazySchema(() => z.string().min(1)); + +function optionCodes(def: ValueShapeFieldDef): string[] { + if (!Array.isArray(def.options)) return []; + return def.options.map((o) => (typeof o === 'object' && o !== null ? String(o.value) : String(o))); +} + +export type ValueForm = 'stored' | 'expanded'; + +/** + * The runtime value schema for one field definition. Pure derivation — no + * caching here; runtime consumers MUST cache per field definition (building a + * `z.object` per write is an order of magnitude costlier than parsing). + * + * The schema describes a PRESENT value: null/undefined/required handling stays + * with the caller (insert vs PATCH semantics differ — see record-validator). + * Where the contract is deliberately open (`json`, `code` payloads, computed + * types), the schema is `z.unknown()` — openness is now an explicit decision, + * not an accident of nobody checking. + */ +export function valueSchemaFor(def: ValueShapeFieldDef, form: ValueForm = 'stored'): z.ZodType { + const t = def.type; + + const element = ((): z.ZodType => { + if (STRING_VALUE_TYPES.has(t)) return z.string(); + if (NUMERIC_VALUE_TYPES.has(t)) return z.number().finite(); + if (BOOLEAN_VALUE_TYPES.has(t)) return z.boolean(); + if (CALENDAR_DATE_TYPES.has(t)) return CalendarDateValueSchema; + if (INSTANT_TYPES.has(t)) return InstantValueSchema; + if (CLOCK_TIME_TYPES.has(t)) return ClockTimeValueSchema; + if (SINGLE_OPTION_TYPES.has(t) || MULTI_OPTION_TYPES.has(t)) { + // tags (and option types authored without options) are free-form strings. + const codes = optionCodes(def); + return codes.length > 0 ? z.enum(codes as [string, ...string[]]) : z.string(); + } + if (REFERENCE_VALUE_TYPES.has(t)) { + // Expanded form: `$expand` replaces the id in place with the related + // record object (objectql engine `expandRelatedRecords`). The record's + // own shape is that object's contract, not this field's — hence open. + return form === 'expanded' + ? z.union([ReferenceIdValueSchema, z.record(z.string(), z.unknown())]) + : ReferenceIdValueSchema; + } + if (FILE_REFERENCE_TYPES.has(t)) return FileLikeValueSchema; + if (t === 'location') return LocationValueSchema; + if (t === 'address') return AddressValueSchema; + if (t === 'composite') return z.record(z.string(), z.unknown()); + if (t === 'record') return z.record(z.string(), z.unknown()); + if (t === 'repeater') return z.array(z.record(z.string(), z.unknown())); + if (t === 'vector') return z.array(z.number()); + // `json` payloads, computed outputs not covered by a shape class above + // (`formula` / `autonumber` — producer-owned), and any future type default + // to explicitly-open. Openness is a decision here, not an accident. + return z.unknown(); + })(); + + return isMultiValueField(def) ? z.array(element) : element; +} diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 5a19a34663..477bfa0eb7 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -117,6 +117,12 @@ export const SelectOptionSchema = lazySchema(() => z.object({ /** * Location Coordinates Schema * GPS coordinates for location field type + * + * @deprecated Never consumed by the runtime, and its key names contradict what + * the platform actually stores: a `location` value is `{lat, lng}` (see the + * field-zoo round-trip oracle), not `{latitude, longitude}`. Use + * `LocationValueSchema` / `valueSchemaFor` from `field-value.zod.ts` + * (ADR-0104 D1). Removal rides the next spec major. */ export const LocationCoordinatesSchema = lazySchema(() => z.object({ latitude: z.number().min(-90).max(90).describe('Latitude coordinate'), @@ -144,9 +150,16 @@ export const CurrencyConfigSchema = lazySchema(() => z.object({ /** * Currency Value Schema * Runtime value structure for currency fields - * + * * Note: Currency codes are validated by length only (3 characters) to support flexibility. * See CurrencyConfigSchema for details on currency code validation strategy. + * + * @deprecated This shape was never consumed and contradicts the actual runtime + * contract: a `currency` field's value is a BARE NUMBER everywhere (validator, + * SQL driver `float` column, import coercion, field-zoo oracle); the currency + * code lives in field config (`CurrencyConfigSchema`), not per value. Use + * `valueSchemaFor` from `field-value.zod.ts` (ADR-0104 D1). Removal rides the + * next spec major. */ export const CurrencyValueSchema = lazySchema(() => z.object({ value: z.number().describe('Monetary amount'), diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index e2d49b5ea6..b43ea0c6ed 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -5,6 +5,8 @@ export * from './filter.zod'; export * from './date-macros.zod'; export * from './object.zod'; export * from './field.zod'; +// Field runtime value-shape contract (ADR-0104 D1) +export * from './field-value.zod'; export * from './autonumber-format'; export * from './validation.zod'; export * from './hook.zod'; From 34bf4d90614f38da85a2fbc6610f323a635efdac Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 11:31:22 +0000 Subject: [PATCH 2/3] docs(references): regenerate for field-value.zod.ts (gen:docs check) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SHpGw3GBA9aFpfwVArRWfd --- content/docs/references/data/field-value.mdx | 141 +++++++++++++++++++ content/docs/references/data/index.mdx | 1 + content/docs/references/data/meta.json | 4 +- 3 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 content/docs/references/data/field-value.mdx diff --git a/content/docs/references/data/field-value.mdx b/content/docs/references/data/field-value.mdx new file mode 100644 index 0000000000..8db95f2643 --- /dev/null +++ b/content/docs/references/data/field-value.mdx @@ -0,0 +1,141 @@ +--- +title: Field Value +description: Field Value protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +Field runtime VALUE-shape contract (ADR-0104 D1). + +`FieldSchema` owns what a field *definition* looks like; this module owns + +what a field's runtime *value* looks like — the shape the write path + +accepts, drivers persist, and an unexpanded API read returns. Before this + +module the knowledge lived as private, hand-copied type sets in objectql's + +record-validator, rest's import-coerce, driver-sql, and verify; adding one + +multi-capable or JSON-shaped type meant updating four lists or silently + +corrupting data. Those consumers now derive from the classes below. + +Two canonical forms exist per field (ADR-0104 D1): + +- `stored` — the storage/wire form (e.g. lookup ⇒ record-id string, + +`date` ⇒ `YYYY-MM-DD`, select ⇒ option code). + +- `expanded` — the enriched `$expand` read form (lookup ⇒ the related + +record object). For types without an expansion, + +expanded ≡ stored. + +"Reality wins": where the deployed stored shape is coherent, the contract + +adopts it — deployed data is a wire contract we don't get to rewrite by + +editing Zod. This is why `currency` is a bare number (not the never-consumed + +`CurrencyValueSchema` object) and `location` is `\{lat, lng\}` (what field-zoo + +stores), not the never-consumed `\{latitude, longitude\}` shape. + +Purity: schemas/constants/derivation only — no runtime logic, no caching + +(Prime Directive #2). Consumers cache `valueSchemaFor` results per field + +definition; building a Zod schema per write is the one performance trap + +this contract has (ADR-0104 performance budget). + + +**Source:** `packages/spec/src/data/field-value.zod.ts` + + +## TypeScript Usage + +```typescript +import { AddressValue, CalendarDateValue, ClockTimeValue, FileLikeValue, InstantValue, LocationValue, ReferenceIdValue } from '@objectstack/spec/data'; +import type { AddressValue, CalendarDateValue, ClockTimeValue, FileLikeValue, InstantValue, LocationValue, ReferenceIdValue } from '@objectstack/spec/data'; + +// Validate data +const result = AddressValue.parse(data); +``` + +--- + +## AddressValue + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **street** | `string` | optional | Street address | +| **city** | `string` | optional | City name | +| **state** | `string` | optional | State/Province | +| **postalCode** | `string` | optional | Postal/ZIP code | +| **country** | `string` | optional | Country name or code | +| **countryCode** | `string` | optional | ISO country code (e.g., US, GB) | +| **formatted** | `string` | optional | Formatted address string | + + +--- + + +--- + + +--- + +## FileLikeValue + +### Union Options + +This schema accepts one of the following structures: + +#### Option 1 + +Type: `string` + +--- + +#### Option 2 + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **url** | `string` | optional | | +| **name** | `string` | optional | | +| **size** | `number` | optional | | +| **alt** | `string` | optional | | +| **duration** | `number` | optional | | + +--- + + +--- + + +--- + +## LocationValue + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **lat** | `number` | ✅ | Latitude | +| **lng** | `number` | ✅ | Longitude | +| **altitude** | `number` | optional | Altitude in meters | +| **accuracy** | `number` | optional | Accuracy in meters | + + +--- + + +--- + diff --git a/content/docs/references/data/index.mdx b/content/docs/references/data/index.mdx index 9498ac5742..9d9f6e28db 100644 --- a/content/docs/references/data/index.mdx +++ b/content/docs/references/data/index.mdx @@ -18,6 +18,7 @@ This section contains all protocol schemas for the data layer of ObjectStack. + diff --git a/content/docs/references/data/meta.json b/content/docs/references/data/meta.json index 3b7f0e4678..9f551ea1b0 100644 --- a/content/docs/references/data/meta.json +++ b/content/docs/references/data/meta.json @@ -25,6 +25,8 @@ "document", "feed", "seed", - "seed-loader" + "seed-loader", + "---More---", + "field-value" ] } \ No newline at end of file From ad466692267320c38e8b04fe065e7ec0be7c4169 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 11:40:40 +0000 Subject: [PATCH 3/3] chore(spec): update API-surface snapshot for field-value contract exports 24 additive exports (semantic type classes, isMultiValueField, valueSchemaFor, value schemas), 0 breaking. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SHpGw3GBA9aFpfwVArRWfd --- packages/spec/api-surface.json | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index fc62fe7695..ff6c8b0ba1 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -163,6 +163,7 @@ "ALL_OPERATORS (const)", "Address (type)", "AddressSchema (const)", + "AddressValueSchema (const)", "AggregationFunction (const)", "AggregationMetricType (const)", "AggregationNode (type)", @@ -175,9 +176,15 @@ "AnalyticsQuerySchema (const)", "ApiMethod (type)", "AutonumberToken (type)", + "BOOLEAN_VALUE_TYPES (const)", "BaseEngineOptions (type)", "BaseEngineOptionsSchema (const)", "BaseValidationRuleShape (interface)", + "CALENDAR_DATE_TYPES (const)", + "CLOCK_TIME_TYPES (const)", + "COMPUTED_VALUE_TYPES (const)", + "CalendarDateValueSchema (const)", + "ClockTimeValueSchema (const)", "ComparisonOperatorSchema (const)", "Compatibility (type)", "ComputedFieldCache (type)", @@ -306,6 +313,7 @@ "ExternalTable (type)", "ExternalTableSchema (const)", "FIELD_GROUP_SYSTEM_FIELDS (const)", + "FILE_REFERENCE_TYPES (const)", "FILTER_OPERATORS (const)", "FeedFilterMode (type)", "FeedItemType (type)", @@ -322,6 +330,7 @@ "FieldReferenceSchema (const)", "FieldSchema (const)", "FieldType (type)", + "FileLikeValueSchema (const)", "Filter (type)", "FilterCondition (type)", "FilterConditionSchema (const)", @@ -341,7 +350,9 @@ "HookEvent (const)", "HookEventType (type)", "HookSchema (const)", + "INSTANT_TYPES (const)", "IndexSchema (const)", + "InstantValueSchema (const)", "JSONValidation (type)", "JSONValidationSchema (const)", "JoinNode (type)", @@ -356,13 +367,17 @@ "LifecycleSchema (const)", "LocationCoordinates (type)", "LocationCoordinatesSchema (const)", + "LocationValueSchema (const)", "LogicalOperatorKey (type)", "MEASURE_FIELD_TYPES (const)", + "MULTI_CAPABLE_TYPES (const)", + "MULTI_OPTION_TYPES (const)", "Mapping (type)", "MappingInput (type)", "MappingSchema (const)", "Metric (type)", "MetricSchema (const)", + "NUMERIC_VALUE_TYPES (const)", "NoSQLDataTypeMapping (type)", "NoSQLDataTypeMappingSchema (const)", "NoSQLDatabaseType (type)", @@ -415,6 +430,7 @@ "QueryInput (type)", "QuerySchema (const)", "RECORD_SURFACE_PAGE_THRESHOLD (const)", + "REFERENCE_VALUE_TYPES (const)", "RangeOperatorSchema (const)", "RecordFlow (type)", "RecordFlowContainer (type)", @@ -422,6 +438,7 @@ "RecordSurface (type)", "RecordSurfaceOptions (interface)", "RecordSurfaceViewport (type)", + "ReferenceIdValueSchema (const)", "ReferenceResolution (type)", "ReferenceResolutionError (type)", "ReferenceResolutionErrorSchema (const)", @@ -436,6 +453,7 @@ "RowCrudActionOverrideInput (type)", "RowCrudActionOverrideSchema (const)", "RowCrudPredicates (interface)", + "SINGLE_OPTION_TYPES (const)", "SQLDialect (type)", "SQLDialectSchema (const)", "SQLDriverConfig (type)", @@ -444,6 +462,8 @@ "SQLiteDataTypeMappingDefaults (const)", "SSLConfig (type)", "SSLConfigSchema (const)", + "STRING_VALUE_TYPES (const)", + "STRUCTURED_JSON_TYPES (const)", "Scalar (type)", "SchemaMode (type)", "SchemaModeSchema (const)", @@ -493,6 +513,8 @@ "VALID_AST_OPERATORS (const)", "ValidationRule (type)", "ValidationRuleSchema (const)", + "ValueForm (type)", + "ValueShapeFieldDef (interface)", "WindowFunction (const)", "WindowFunctionNode (type)", "WindowFunctionNodeSchema (const)", @@ -516,6 +538,7 @@ "isDateMacroToken (function)", "isFilterAST (function)", "isIncoherentAggregate (function)", + "isMultiValueField (function)", "isTenancyDisabled (function)", "isTitleEligible (function)", "missingFieldValues (function)", @@ -531,7 +554,8 @@ "resolveDisplayField (function)", "resolveRecordDisplayName (function)", "sequenceWidth (function)", - "suggestFieldType (function)" + "suggestFieldType (function)", + "valueSchemaFor (function)" ], "./system": [ "AccessControlConfig (type)",