Skip to content

Commit 235de83

Browse files
committed
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
1 parent 8eb2234 commit 235de83

13 files changed

Lines changed: 802 additions & 149 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).

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
}

packages/plugins/driver-sql/src/sql-driver.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import type { QueryAST, DriverOptions, SchemaMode } from '@objectstack/spec/data';
1111
import { parseAutonumberFormat, renderAutonumber, missingFieldValues, isTenancyDisabled, type AutonumberToken } from '@objectstack/spec/data';
12+
import { STRUCTURED_JSON_TYPES, FILE_REFERENCE_TYPES, MULTI_OPTION_TYPES, NUMERIC_VALUE_TYPES } from '@objectstack/spec/data';
1213
import type { IDataDriver } from '@objectstack/spec/contracts';
1314
import { StorageNameMapping } from '@objectstack/spec/system';
1415
import { ExternalSchemaModeViolationError } from '@objectstack/spec/shared';
@@ -57,10 +58,14 @@ const GLOBAL_TENANT = '__global__';
5758
* rest are arrays.
5859
*/
5960
const JSON_COLUMN_TYPES = new Set<string>([
60-
'json', 'object', 'array', 'record',
61-
'image', 'file', 'avatar', 'video', 'audio',
62-
'location', 'address', 'composite',
63-
'multiselect', 'checkboxes', 'tags', 'repeater', 'vector',
61+
// Spec value-shape classes (ADR-0104 D1): structured JSON payloads, the
62+
// (pre-D3) inline file metadata objects, and the inherently-array option
63+
// types. Membership is owned by @objectstack/spec — a type added there
64+
// becomes a JSON column here without touching this file.
65+
...STRUCTURED_JSON_TYPES, ...FILE_REFERENCE_TYPES, ...MULTI_OPTION_TYPES,
66+
// Driver-internal aliases (external/introspected columns) — not authorable
67+
// FieldTypes, so they stay a local extra.
68+
'object', 'array',
6469
]);
6570

6671
/**
@@ -79,9 +84,9 @@ const JSON_COLUMN_TYPES = new Set<string>([
7984
* gap for the numeric scalars.
8085
*/
8186
const NUMERIC_SCALAR_TYPES = new Set<string>([
82-
'integer', 'int',
83-
'float', 'number', 'currency', 'percent', 'summary',
84-
'rating', 'slider', 'progress',
87+
// Spec numeric value class (ADR-0104 D1) + driver-internal SQL aliases.
88+
...NUMERIC_VALUE_TYPES,
89+
'integer', 'int', 'float',
8590
]);
8691

8792
/**

0 commit comments

Comments
 (0)