Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 14 additions & 14 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
82 changes: 70 additions & 12 deletions packages/core/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -73,24 +73,82 @@ export const hashSchema = async (schema: TypeSchema): Promise<string> => {
// -------------------------------------------------------

/**
* 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<ScalarFieldKind, ScalarFieldKind[]> = {
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
// -------------------------------------------------------
Expand Down
137 changes: 137 additions & 0 deletions packages/core/tests/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

// -------------------------------------------------------
Expand Down
Loading