Skip to content

Commit fa3d0cf

Browse files
os-zhuangclaude
andauthored
feat(spec): field runtime value-shape contract — ADR-0104 phase 1 (D1) (#3429)
* feat(spec): field runtime value-shape contract — ADR-0104 phase 1 (D1) 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHpGw3GBA9aFpfwVArRWfd * docs(references): regenerate for field-value.zod.ts (gen:docs check) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHpGw3GBA9aFpfwVArRWfd * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHpGw3GBA9aFpfwVArRWfd --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8eb2234 commit fa3d0cf

17 files changed

Lines changed: 972 additions & 151 deletions
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": minor
4+
"@objectstack/rest": patch
5+
"@objectstack/driver-sql": patch
6+
---
7+
8+
feat(spec): field runtime value-shape contract — ADR-0104 phase 1 (D1)
9+
10+
`@objectstack/spec/data` now owns the runtime VALUE shape of every field type
11+
(`field-value.zod.ts`): semantic type classes (`STRING_VALUE_TYPES`,
12+
`NUMERIC_VALUE_TYPES`, `REFERENCE_VALUE_TYPES`, `FILE_REFERENCE_TYPES`,
13+
`STRUCTURED_JSON_TYPES`, `MULTI_CAPABLE_TYPES`, …), the shared
14+
`isMultiValueField`, and `valueSchemaFor(field, 'stored' | 'expanded')`. The
15+
four consumers that each hand-copied this knowledge (objectql record-validator,
16+
rest import-coerce, driver-sql column classification, qa conformance) now
17+
derive from the spec, and the field-zoo round-trip MATRIX is asserted against
18+
the contract so the two cannot drift.
19+
20+
**Write-path change (objectql, warn-first):** previously-unvalidated types —
21+
single `lookup`/`master_detail`/`user`/`tree`, `file`/`image`/`avatar`/
22+
`video`/`audio`, `location`, `address`, `composite`, `repeater`, `record`,
23+
`vector` — are now checked against the contract. A violation **logs a warning
24+
and passes** in this release (legacy rows must not strand their records);
25+
set `OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1` to enforce as a
26+
`400 VALIDATION_FAILED`. The flip to strict-by-default rides a later minor
27+
(ADR-0104 R1/R2).
28+
29+
**Deprecations (removal rides the next spec major), FROM → TO:**
30+
31+
- `CurrencyValueSchema` (`{value, currency}`) → none. A `currency` field's
32+
value is a **bare number** everywhere in the runtime (validator, SQL `float`
33+
column, import coercion, field-zoo oracle); the currency code lives in field
34+
config. Use `valueSchemaFor({type: 'currency'})`.
35+
- `LocationCoordinatesSchema` (`{latitude, longitude}`) → `LocationValueSchema`
36+
(`{lat, lng}`) — the shape the platform actually stores.
37+
- `AddressSchema` is **adopted** (unchanged) as the enforced `address` value
38+
contract via `AddressValueSchema`.
39+
40+
No stored data changes shape; the contract codifies deployed reality
41+
("reality wins", ADR-0104 D1).
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
---
2+
title: Field Value
3+
description: Field Value protocol schemas
4+
---
5+
6+
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
7+
8+
Field runtime VALUE-shape contract (ADR-0104 D1).
9+
10+
`FieldSchema` owns what a field *definition* looks like; this module owns
11+
12+
what a field's runtime *value* looks like — the shape the write path
13+
14+
accepts, drivers persist, and an unexpanded API read returns. Before this
15+
16+
module the knowledge lived as private, hand-copied type sets in objectql's
17+
18+
record-validator, rest's import-coerce, driver-sql, and verify; adding one
19+
20+
multi-capable or JSON-shaped type meant updating four lists or silently
21+
22+
corrupting data. Those consumers now derive from the classes below.
23+
24+
Two canonical forms exist per field (ADR-0104 D1):
25+
26+
- `stored` — the storage/wire form (e.g. lookup ⇒ record-id string,
27+
28+
`date``YYYY-MM-DD`, select ⇒ option code).
29+
30+
- `expanded` — the enriched `$expand` read form (lookup ⇒ the related
31+
32+
record object). For types without an expansion,
33+
34+
expanded ≡ stored.
35+
36+
"Reality wins": where the deployed stored shape is coherent, the contract
37+
38+
adopts it — deployed data is a wire contract we don't get to rewrite by
39+
40+
editing Zod. This is why `currency` is a bare number (not the never-consumed
41+
42+
`CurrencyValueSchema` object) and `location` is `\{lat, lng\}` (what field-zoo
43+
44+
stores), not the never-consumed `\{latitude, longitude\}` shape.
45+
46+
Purity: schemas/constants/derivation only — no runtime logic, no caching
47+
48+
(Prime Directive #2). Consumers cache `valueSchemaFor` results per field
49+
50+
definition; building a Zod schema per write is the one performance trap
51+
52+
this contract has (ADR-0104 performance budget).
53+
54+
<Callout type="info">
55+
**Source:** `packages/spec/src/data/field-value.zod.ts`
56+
</Callout>
57+
58+
## TypeScript Usage
59+
60+
```typescript
61+
import { AddressValue, CalendarDateValue, ClockTimeValue, FileLikeValue, InstantValue, LocationValue, ReferenceIdValue } from '@objectstack/spec/data';
62+
import type { AddressValue, CalendarDateValue, ClockTimeValue, FileLikeValue, InstantValue, LocationValue, ReferenceIdValue } from '@objectstack/spec/data';
63+
64+
// Validate data
65+
const result = AddressValue.parse(data);
66+
```
67+
68+
---
69+
70+
## AddressValue
71+
72+
### Properties
73+
74+
| Property | Type | Required | Description |
75+
| :--- | :--- | :--- | :--- |
76+
| **street** | `string` | optional | Street address |
77+
| **city** | `string` | optional | City name |
78+
| **state** | `string` | optional | State/Province |
79+
| **postalCode** | `string` | optional | Postal/ZIP code |
80+
| **country** | `string` | optional | Country name or code |
81+
| **countryCode** | `string` | optional | ISO country code (e.g., US, GB) |
82+
| **formatted** | `string` | optional | Formatted address string |
83+
84+
85+
---
86+
87+
88+
---
89+
90+
91+
---
92+
93+
## FileLikeValue
94+
95+
### Union Options
96+
97+
This schema accepts one of the following structures:
98+
99+
#### Option 1
100+
101+
Type: `string`
102+
103+
---
104+
105+
#### Option 2
106+
107+
### Properties
108+
109+
| Property | Type | Required | Description |
110+
| :--- | :--- | :--- | :--- |
111+
| **url** | `string` | optional | |
112+
| **name** | `string` | optional | |
113+
| **size** | `number` | optional | |
114+
| **alt** | `string` | optional | |
115+
| **duration** | `number` | optional | |
116+
117+
---
118+
119+
120+
---
121+
122+
123+
---
124+
125+
## LocationValue
126+
127+
### Properties
128+
129+
| Property | Type | Required | Description |
130+
| :--- | :--- | :--- | :--- |
131+
| **lat** | `number` || Latitude |
132+
| **lng** | `number` || Longitude |
133+
| **altitude** | `number` | optional | Altitude in meters |
134+
| **accuracy** | `number` | optional | Accuracy in meters |
135+
136+
137+
---
138+
139+
140+
---
141+

content/docs/references/data/index.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ This section contains all protocol schemas for the data layer of ObjectStack.
1818
<Card href="/docs/references/data/external-lookup" title="External Lookup" description="Source: packages/spec/src/data/external-lookup.zod.ts" />
1919
<Card href="/docs/references/data/feed" title="Feed" description="Source: packages/spec/src/data/feed.zod.ts" />
2020
<Card href="/docs/references/data/field" title="Field" description="Source: packages/spec/src/data/field.zod.ts" />
21+
<Card href="/docs/references/data/field-value" title="Field Value" description="Source: packages/spec/src/data/field-value.zod.ts" />
2122
<Card href="/docs/references/data/filter" title="Filter" description="Source: packages/spec/src/data/filter.zod.ts" />
2223
<Card href="/docs/references/data/hook" title="Hook" description="Source: packages/spec/src/data/hook.zod.ts" />
2324
<Card href="/docs/references/data/hook-body" title="Hook Body" description="Source: packages/spec/src/data/hook-body.zod.ts" />

content/docs/references/data/meta.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
"document",
2626
"feed",
2727
"seed",
28-
"seed-loader"
28+
"seed-loader",
29+
"---More---",
30+
"field-value"
2931
]
3032
}

packages/objectql/src/validation/record-validator.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,3 +345,63 @@ describe('validateRecord — url field accepts relative + inline URLs', () => {
345345
expect(() => validateRecord(schema, { image: 'notaurl' }, 'update')).toThrow(/valid URL/i);
346346
});
347347
});
348+
349+
/**
350+
* ADR-0104 D1 — value-shape contract for previously-opaque types.
351+
*
352+
* Warn-first rollout: a shape violation on reference/file/structured-JSON
353+
* types logs (once per field) and passes; `OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1`
354+
* turns it into a normal invalid_type rejection.
355+
*/
356+
describe('validateRecord — ADR-0104 value shapes (warn-first / strict)', () => {
357+
const schema = {
358+
fields: {
359+
account: { type: 'lookup', reference: 'accounts' },
360+
geo: { type: 'location' },
361+
doc: { type: 'file' },
362+
dims: { type: 'vector' },
363+
},
364+
};
365+
366+
const withStrict = (fn: () => void) => {
367+
process.env.OS_DATA_VALUE_SHAPE_STRICT_ENABLED = '1';
368+
try { fn(); } finally { delete process.env.OS_DATA_VALUE_SHAPE_STRICT_ENABLED; }
369+
};
370+
371+
it('accepts contract-conformant values in both modes', () => {
372+
const data = {
373+
account: 'acc_0001',
374+
geo: { lat: 37.77, lng: -122.42 },
375+
doc: { url: 'https://cdn/f.pdf', name: 'f.pdf', size: 1024 },
376+
dims: [0.1, 0.2],
377+
};
378+
expect(() => validateRecord(schema, { ...data }, 'update')).not.toThrow();
379+
withStrict(() => expect(() => validateRecord(schema, { ...data }, 'update')).not.toThrow());
380+
});
381+
382+
it('warn-first: malformed shapes pass by default (legacy rows must not strand records)', () => {
383+
expect(() => validateRecord(schema, { geo: { latitude: 1, longitude: 2 } }, 'update')).not.toThrow();
384+
expect(() => validateRecord(schema, { account: { id: 'acc_1' } }, 'update')).not.toThrow();
385+
});
386+
387+
it('strict: malformed shapes reject with invalid_type', () => {
388+
withStrict(() => {
389+
try {
390+
validateRecord(schema, { geo: { latitude: 37.77, longitude: -122.42 } }, 'update');
391+
expect.unreachable('expected ValidationError');
392+
} catch (e) {
393+
expect(e).toBeInstanceOf(ValidationError);
394+
const err = e as ValidationError;
395+
expect(err.fields[0]?.field).toBe('geo');
396+
expect(err.fields[0]?.code).toBe('invalid_type');
397+
}
398+
// expanded-form object at a stored-form position (unexpanded write)
399+
expect(() => validateRecord(schema, { account: { id: 'acc_1', name: 'Acme' } }, 'update')).toThrow(ValidationError);
400+
// scalar at a vector
401+
expect(() => validateRecord(schema, { dims: 'not-a-vector' }, 'update')).toThrow(ValidationError);
402+
// file: id/url string AND inline object both remain legal pre-D3
403+
expect(() => validateRecord(schema, { doc: 'file_01HXYZ' }, 'update')).not.toThrow();
404+
expect(() => validateRecord(schema, { doc: 42 }, 'update')).toThrow(ValidationError);
405+
});
406+
});
407+
});

packages/objectql/src/validation/record-validator.ts

Lines changed: 70 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@
3131
* the UI can highlight the specific input.
3232
*/
3333

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+
3442
// Lifecycle columns the engine always owns and the client never supplies. These
3543
// are skipped by NAME because they are not author-declared business fields.
3644
// NOTE: `organization_id` / `tenant_id` are intentionally NOT here (#1592) — the
@@ -145,17 +153,11 @@ function optionValues(options: FieldDef['options']): string[] {
145153
/**
146154
* A field whose persisted value is an ARRAY of scalars: either an
147155
* 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.
152158
*/
153-
const MULTI_CAPABLE_TYPES = new Set(['select', 'radio', 'lookup', 'user', 'file', 'image']);
154-
155159
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 });
159161
}
160162

161163
/**
@@ -223,12 +225,12 @@ export function coerceBooleanFields<T extends Record<string, unknown>>(
223225
return (copy ?? row) as T;
224226
}
225227

226-
function validateOne(name: string, def: FieldDef, value: unknown): FieldValidationError | null {
228+
function validateOne(name: string, def: FieldDef, value: unknown, skipRequired = false): FieldValidationError | null {
227229
// ── required ────────────────────────────────────────────────────
228230
// `autonumber` is runtime-owned: the value is generated by the engine /
229231
// driver (the SQL driver assigns it from a persistent sequence AFTER this
230232
// 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') {
232234
return { field: name, code: 'required', message: `${name} is required` };
233235
}
234236
if (isMissing(value)) return null; // nothing else to check
@@ -338,12 +340,62 @@ function validateOne(name: string, def: FieldDef, value: unknown): FieldValidati
338340
return null;
339341
}
340342

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.
344367
return null;
345368
}
346369

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+
347399
/**
348400
* Validate a payload against a list of declared fields. `objectSchema`
349401
* comes from `ObjectQL.getRegistry().getObject(name)` and exposes a
@@ -377,8 +429,10 @@ export function validateRecord(
377429
const def = fields[name];
378430
if (!def) continue;
379431
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);
382436
if (err) errors.push(err);
383437
}
384438
}

0 commit comments

Comments
 (0)