diff --git a/docs/spec.md b/docs/spec.md index 8baf764..5fd0bcc 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -213,22 +213,22 @@ type StackType = { **Schema drift detection:** If two Records share a `typeId` but their Type definitions have different `schemaHash` values, that is unambiguously a bug — intentional changes always produce a new version number. -**Type compatibility:** Structural/duck-typed — a Type is compatible with a required schema if it contains all required fields with matching kinds. Used by apps that want to work with Records regardless of exact Type, e.g. any Type with `{ text: string }`: +**Type compatibility:** Structural/duck-typed — a Type is **read-compatible** with a required schema if, for every required field, the candidate declares that same field as required, at a read-compatible kind. Array and object fields recurse: their `items`/`properties` must themselves be read-compatible. This licenses _consuming_ Records, not writing them — a consumer writing through a "compatible" view still has to validate against the candidate's full schema (its other required fields, which compatibility checking never inspects). -```ts -function isCompatible( - candidateSchema: TypeSchema, // the Record's actual Type - requiredSchema: TypeSchema, // minimum fields the app needs -): boolean { - return Object.entries(requiredSchema).every(([key, def]) => { - if (!def.required) return true; - const field = candidateSchema[key]; - return field !== undefined && field.kind === def.kind; - }); -} -``` +A field's kind is read-compatible with a required kind per this table (row = required kind, columns = candidate kinds accepted): + +| required → | `string` | `text` | `number` | `boolean` | `date` | `record-ref` | +| ------------ | -------- | ------ | -------- | --------- | ------ | ------------ | +| `string` | ✓ | ✓ | | | | | +| `text` | ✓ | ✓ | | | | | +| `number` | | | ✓ | | | | +| `boolean` | | | | ✓ | | | +| `date` | | | | | ✓ | | +| `record-ref` | | | | | | ✓ | + +`string` and `text` are mutually read-compatible — both are strings at the value level, and the distinction is presentation/indexing intent. Every other kind requires an exact match; notably `date` is not compatible with `string`, since `date` carries a parse/validity guarantee a plain string doesn't. -Apps that care about semantics filter by exact `typeId`. Apps that want flexibility use `isCompatible()`. +Used by apps that want to work with Records regardless of exact Type, e.g. any Type with a required `text` or `string` field. Apps that care about semantics filter by exact `typeId`. Apps that want flexibility use `isCompatible()` — see `packages/core/src/schema.ts` for the authoritative implementation and `packages/core/tests/schema.test.ts` for its behavior under nesting and the string/text equivalence. **System types** (reserved, library-defined): `_config@1`, `_entity@1`, `_app@1`, `_group@1`, `_grant@1`, `_attachment@1`. System types follow the same versioned ID format as user-defined types and can evolve using the same migration mechanism. All six are pre-seeded when a Stack is created via `Stack.create()` — they are always available without any setup by the caller. diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index a9a278b..3d07af7 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -9,7 +9,7 @@ * the namespaced ID controlled by the app author. */ -import type { TypeSchema, FieldDef } from './types.js'; +import type { TypeSchema, FieldDef, ScalarFieldKind } from './types.js'; // ------------------------------------------------------- // Canonical schema serialization @@ -73,24 +73,82 @@ export const hashSchema = async (schema: TypeSchema): Promise => { // ------------------------------------------------------- /** - * Check whether a candidate schema satisfies a required schema. - * - * A candidate is compatible if it contains all *required* fields from - * the required schema with matching kinds. Optional fields in the - * required schema are ignored. Array and object fields are matched - * shallowly — only the top-level kind is checked. - * - * Apps that need precise type matching should compare typeIds directly. - * isCompatible() is for duck-typed consumption across types. + * Kinds acceptable in a candidate field, per required kind. `string` and + * `text` share an identical value set (the distinction is presentation/ + * indexing intent, not data shape), so they're mutually acceptable for + * reading. Everything else requires an exact kind match — notably `date` + * is NOT compatible with `string`: it carries a parse/validity guarantee + * a plain string doesn't. + */ +const READ_COMPATIBLE: Record = { + string: ['string', 'text'], + text: ['text', 'string'], + number: ['number'], + boolean: ['boolean'], + date: ['date'], + 'record-ref': ['record-ref'], +}; + +// Candidate schemas can come from another app's Type definition (the +// untrusted side of duck-typed consumption), so recursion into array +// items / object properties is depth-bounded — matches MAX_VALIDATION_DEPTH +// in validate.ts. Past the limit we can't verify compatibility, so we +// fail closed (treat as incompatible) rather than risk a stack overflow. +const MAX_COMPATIBILITY_DEPTH = 32; + +/** + * Check whether a candidate field satisfies a required field, recursing + * into array items and object properties. */ -export const isCompatible = (candidateSchema: TypeSchema, requiredSchema: TypeSchema): boolean => { +const isFieldCompatible = (candidate: FieldDef, required: FieldDef, depth: number): boolean => { + if (depth > MAX_COMPATIBILITY_DEPTH) return false; + if (required.kind === 'array') { + return ( + candidate.kind === 'array' && isFieldCompatible(candidate.items, required.items, depth + 1) + ); + } + if (required.kind === 'object') { + return ( + candidate.kind === 'object' && + isCompatibleAtDepth(candidate.properties, required.properties, depth + 1) + ); + } + if (candidate.kind === 'array' || candidate.kind === 'object') return false; + return READ_COMPATIBLE[required.kind].includes(candidate.kind); +}; + +const isCompatibleAtDepth = ( + candidateSchema: TypeSchema, + requiredSchema: TypeSchema, + depth: number, +): boolean => { + if (depth > MAX_COMPATIBILITY_DEPTH) return false; return Object.entries(requiredSchema).every(([key, def]) => { if (!def.required) return true; const field = candidateSchema[key]; - return field !== undefined && field.kind === def.kind; + return field !== undefined && field.required === true && isFieldCompatible(field, def, depth); }); }; +/** + * Check whether a candidate schema is read-compatible with a required schema: + * whether records of the candidate Type carry every field an app needs to + * *read*, at a kind the app can safely consume. This licenses consuming + * records, not writing them — a consumer writing through a "compatible" view + * still has to validate against the candidate's full schema. + * + * A candidate is compatible if, for every *required* field in the required + * schema, the candidate declares that field as required with a read-compatible + * kind (see READ_COMPATIBLE). Optional fields in the required schema are + * ignored. Array and object fields recurse into their items/properties, up + * to MAX_COMPATIBILITY_DEPTH. + * + * Apps that need precise type matching should compare typeIds directly. + * isCompatible() is for duck-typed consumption across types. + */ +export const isCompatible = (candidateSchema: TypeSchema, requiredSchema: TypeSchema): boolean => + isCompatibleAtDepth(candidateSchema, requiredSchema, 0); + // ------------------------------------------------------- // Type ID parsing // ------------------------------------------------------- diff --git a/packages/core/tests/schema.test.ts b/packages/core/tests/schema.test.ts index 3435516..b0b3e80 100644 --- a/packages/core/tests/schema.test.ts +++ b/packages/core/tests/schema.test.ts @@ -149,6 +149,143 @@ describe('isCompatible', () => { const required: TypeSchema = { text: { kind: 'text', required: true } }; expect(isCompatible({}, required)).toBe(false); }); + + test('candidate declaring a required field as optional is not compatible', () => { + const candidate: TypeSchema = { title: { kind: 'string', required: false } }; + const required: TypeSchema = { title: { kind: 'string', required: true } }; + expect(isCompatible(candidate, required)).toBe(false); + }); + + test('candidate declaring a required field with no required flag at all is not compatible', () => { + const candidate: TypeSchema = { title: { kind: 'string' } }; + const required: TypeSchema = { title: { kind: 'string', required: true } }; + expect(isCompatible(candidate, required)).toBe(false); + }); + + test('text candidate satisfies a required string field', () => { + const candidate: TypeSchema = { body: { kind: 'text', required: true } }; + const required: TypeSchema = { body: { kind: 'string', required: true } }; + expect(isCompatible(candidate, required)).toBe(true); + }); + + test('string candidate satisfies a required text field', () => { + const candidate: TypeSchema = { body: { kind: 'string', required: true } }; + const required: TypeSchema = { body: { kind: 'text', required: true } }; + expect(isCompatible(candidate, required)).toBe(true); + }); + + test('date candidate does not satisfy a required string field', () => { + const candidate: TypeSchema = { body: { kind: 'date', required: true } }; + const required: TypeSchema = { body: { kind: 'string', required: true } }; + expect(isCompatible(candidate, required)).toBe(false); + }); + + test('string candidate does not satisfy a required date field', () => { + const candidate: TypeSchema = { body: { kind: 'string', required: true } }; + const required: TypeSchema = { body: { kind: 'date', required: true } }; + expect(isCompatible(candidate, required)).toBe(false); + }); + + test('nested required object property mismatch is not compatible', () => { + const candidate: TypeSchema = { + author: { + kind: 'object', + required: true, + properties: { + name: { kind: 'string', required: false }, + }, + }, + }; + const required: TypeSchema = { + author: { + kind: 'object', + required: true, + properties: { + name: { kind: 'string', required: true }, + }, + }, + }; + expect(isCompatible(candidate, required)).toBe(false); + }); + + test('nested object property satisfying required sub-field is compatible', () => { + const candidate: TypeSchema = { + author: { + kind: 'object', + required: true, + properties: { + name: { kind: 'string', required: true }, + }, + }, + }; + const required: TypeSchema = { + author: { + kind: 'object', + required: true, + properties: { + name: { kind: 'string', required: true }, + }, + }, + }; + expect(isCompatible(candidate, required)).toBe(true); + }); + + test('nested array item kind mismatch is not compatible', () => { + const candidate: TypeSchema = { + tags: { kind: 'array', required: true, items: { kind: 'number' } }, + }; + const required: TypeSchema = { + tags: { kind: 'array', required: true, items: { kind: 'string' } }, + }; + expect(isCompatible(candidate, required)).toBe(false); + }); + + test('nested array item kind text/string equivalence is compatible', () => { + const candidate: TypeSchema = { + tags: { kind: 'array', required: true, items: { kind: 'text' } }, + }; + const required: TypeSchema = { + tags: { kind: 'array', required: true, items: { kind: 'string' } }, + }; + expect(isCompatible(candidate, required)).toBe(true); + }); + + test("spec's motivating example: a text body satisfies a required string requirement", () => { + const candidate: TypeSchema = { + text: { kind: 'text', required: true }, + }; + const required: TypeSchema = { + text: { kind: 'string', required: true }, + }; + expect(isCompatible(candidate, required)).toBe(true); + }); + + test('deeply nested matching schemas beyond the depth limit are not compatible (fails closed)', () => { + const buildNested = (depth: number): TypeSchema => { + let schema: TypeSchema = { leaf: { kind: 'string', required: true } }; + for (let i = 0; i < depth; i++) { + schema = { nested: { kind: 'object', required: true, properties: schema } }; + } + return schema; + }; + const candidate = buildNested(1000); + const required = buildNested(1000); + expect(() => isCompatible(candidate, required)).not.toThrow(); + expect(isCompatible(candidate, required)).toBe(false); + }); + + test('matching schemas within the depth limit remain compatible', () => { + const buildNested = (depth: number): TypeSchema => { + let schema: TypeSchema = { leaf: { kind: 'string', required: true } }; + for (let i = 0; i < depth; i++) { + schema = { nested: { kind: 'object', required: true, properties: schema } }; + } + return schema; + }; + const candidate = buildNested(10); + const required = buildNested(10); + expect(isCompatible(candidate, required)).toBe(true); + }); }); // -------------------------------------------------------