From fdca4ca25760b19a79bc660404ebf95cf9c9048b Mon Sep 17 00:00:00 2001 From: Jen Garcia Date: Sat, 10 Oct 2026 08:55:56 -0400 Subject: [PATCH 1/8] feat(core): make enum its own field kind `{ kind: 'enum', values: [...] }` replaces `{ kind: 'string', enum: [...] }`. As a modifier on string, enum was invisible to every rule keyed on kind, so isCompatible() never checked it: a consumer requiring an enum accepted a plain string candidate that could hold any value. As a kind, each exhaustive kind map must account for it. A required enum now accepts only an enum candidate whose values it lists all of; a required string or text accepts an enum. Enum to string stays additive, being the one kind change that accepts strictly more. Enums sort as text by value. Co-Authored-By: Claude Opus 5.5 --- .changeset/enum-field-kind.md | 6 ++ docs/spec/data-model.md | 40 +++++----- packages/conformance-fixtures/src/index.ts | 58 +++++++++++++++ packages/core/src/schema.ts | 46 ++++++------ packages/core/src/sort.ts | 9 ++- packages/core/src/type-handle.ts | 16 ++-- packages/core/src/types.ts | 15 ++-- packages/core/src/validate.ts | 26 +++---- packages/core/tests/schema.test.ts | 85 ++++++++++++++++++---- packages/core/tests/sort.test.ts | 4 + packages/core/tests/type-handle.test.ts | 16 ++-- packages/core/tests/validate.test.ts | 50 ++++++------- 12 files changed, 251 insertions(+), 120 deletions(-) create mode 100644 .changeset/enum-field-kind.md diff --git a/.changeset/enum-field-kind.md b/.changeset/enum-field-kind.md new file mode 100644 index 00000000..e31727a0 --- /dev/null +++ b/.changeset/enum-field-kind.md @@ -0,0 +1,6 @@ +--- +'@haverstack/core': minor +'@haverstack/conformance-fixtures': minor +--- + +`enum` is a field kind: `{ kind: 'enum', values: [...] }` replaces `{ kind: 'string', enum: [...] }`. `isCompatible()` now checks enums: a required enum accepts only an enum candidate whose values it lists all of, and a required `string` or `text` accepts an enum. An enum becoming a plain `string` is additive; a `string` becoming an enum needs a version bump. Conformance fixtures pin a 422 for a value outside the list and a 409 for removing values. diff --git a/docs/spec/data-model.md b/docs/spec/data-model.md index f52f0086..eadc1be9 100644 --- a/docs/spec/data-model.md +++ b/docs/spec/data-model.md @@ -256,11 +256,12 @@ type ScalarFieldKind = | 'date' | 'text' // Long-form string (e.g. markdown body) | 'record-ref' // Reference to another record by ID - | 'file-ref'; // Reference to an attachment file ID (SHA-256 hex) + | 'file-ref' // Reference to an attachment file ID (SHA-256 hex) + | 'enum'; // One of a declared set of strings type FieldDef = - | { kind: 'string'; enum?: string[]; required?: boolean } - | { kind: Exclude; required?: boolean } + | { kind: 'enum'; values: string[]; required?: boolean } + | { kind: Exclude; required?: boolean } | { kind: 'array'; items: FieldDef; open?: false; required?: boolean } // recursive | { kind: 'array'; open: true; required?: boolean } // elements unvalidated | { kind: 'object'; properties: TypeSchema; open?: false; required?: boolean } // recursive @@ -304,7 +305,9 @@ await stack.defineType({ **Array and object fields** are schema-validated on write and reachable by query: a content filter key is a path, and an array along it is matched element-wise (see [Filter](#filter)). **`open: true` declares the container open** — a list or object whose interior the schema does not describe, and the one way to store content the schema cannot name. A container declares either its interior or `open`, never neither: opacity is a claim the schema makes, not something inferred from a missing `items`/`properties`, so a schema that forgets to describe its elements is a mistake rather than a silently unchecked field. Query reach is unaffected: a path still walks into an open container, since the query engine reads the content rather than the schema. See [Undeclared content fields](#undeclared-content-fields). -**A `string` field can declare `enum`, a non-empty list of the values it may hold.** A value outside the list is a `StackValidationError` at its path, naming the allowed values. `defineType()` refuses an empty list, non-string or duplicate entries, and `enum` on any kind but `string`. The schema is then the one place the allowed values live, so an app's union type (`'want' | 'reading' | 'finished'`) has something to derive from rather than drifting from it. The [schema hash](#schema-drift-detection) sorts the values, so reordering them is not a change. +**An `enum` field holds one of the strings its `values` list names.** A value outside the list is a `StackValidationError` at its path, naming the allowed values. `defineType()` refuses an `enum` with missing or empty `values`, non-string or duplicate entries, and `values` on any other kind. The schema is then the one place the allowed values live, so an app's union type (`'want' | 'reading' | 'finished'`) has something to derive from rather than drifting from it. The [schema hash](#schema-drift-detection) sorts the values, so reordering them is not a change. + +An enum is its own kind rather than a constraint on `string` because it promises a reader something a string does not: the value is one of a known set. Every rule keyed on kind — [read compatibility](#type-compatibility), [drift](#schema-drift-detection), [sort order](#sorting-by-a-content-field) — therefore states what it does with one. **`date` fields validate against an ISO 8601 shape, not bare `Date.parse`** — `YYYY-MM-DD`, optionally extended with `THH:mm:ss`, optional fractional seconds, and an optional `Z`/numeric-offset suffix. A regex pins the shape; `Date.parse` then runs as a calendar sanity check on top of it (catching e.g. an invalid month). `Date.parse` alone also accepts engine-dependent, non-ISO formats (`"March 1 2020"`), which would let cross-runtime stacks disagree about what's valid and produce non-canonical stored values. @@ -326,7 +329,7 @@ This matters because `defineType()` takes a `TypeSchema` but a schema arriving a - **Identical schema** (`schemaHash` matches) — a no-op; the stored Type is returned unchanged, `createdAt` untouched. Calling `defineType()` for every Type at every app startup is therefore cheap, not a rewrite each time. - **Identical schema, different `name`** — always persists (display metadata, not schema), `createdAt` still preserved. -- **Different schema** — legal only if the change is a pure [additive-in-place evolution](#additive-evolution-within-a-version): new _optional_ fields only, recursively into `object` properties and `array` items; nothing removed, no field's `kind` changed, no field's `required` flipped in either direction, no container [opened or closed](#undeclared-content-fields), and no `enum` that narrows what a string field accepts: adding an `enum` to a field, or removing values from one, needs a new version, while removing an `enum` or adding values to one accepts strictly more and stays in place. An illegal change throws `StackSchemaDriftError` (wire: **409**, code `schema_drift`) naming each violation — the remedy is always a new version (`defineType({ id: '...@n+1', ... })` plus a `migration()` passed to `Stack.open()`), never redefining the same `id` in place. +- **Different schema** — legal only if the change is a pure [additive-in-place evolution](#additive-evolution-within-a-version): new _optional_ fields only, recursively into `object` properties and `array` items; nothing removed, no field's `kind` changed, no field's `required` flipped in either direction, no container [opened or closed](#undeclared-content-fields), and no enum that loses values. **One kind change is additive: `enum` to `string`**, which accepts strictly more; `string` to `enum` narrows and needs a new version, as does `enum` to `text`, for the reason `string` to `text` does (below). Adding values to an enum stays in place. An illegal change throws `StackSchemaDriftError` (wire: **409**, code `schema_drift`) naming each violation — the remedy is always a new version (`defineType({ id: '...@n+1', ... })` plus a `migration()` passed to `Stack.open()`), never redefining the same `id` in place. `POST /types` (see [Wire format § Types](./wire-format.md#types)) applies the same check server-side, so the wire path can't silently replace a Type either. @@ -342,18 +345,21 @@ Structural/duck-typed — a Type is **read-compatible** with a required schema i 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` | `file-ref` | -| ------------ | -------- | ------ | -------- | --------- | ------ | ------------ | ---------- | -| `string` | ✓ | ✓ | | | | | | -| `text` | ✓ | ✓ | | | | | | -| `number` | | | ✓ | | | | | -| `boolean` | | | | ✓ | | | | -| `date` | | | | | ✓ | | | -| `record-ref` | | | | | | ✓ | | -| `file-ref` | | | | | | | ✓ | +| required → | `string` | `text` | `enum` | `number` | `boolean` | `date` | `record-ref` | `file-ref` | +| ------------ | -------- | ------ | ------ | -------- | --------- | ------ | ------------ | ---------- | +| `string` | ✓ | ✓ | ✓ | | | | | | +| `text` | ✓ | ✓ | ✓ | | | | | | +| `enum` | | | ⊆ | | | | | | +| `number` | | | | ✓ | | | | | +| `boolean` | | | | | ✓ | | | | +| `date` | | | | | | ✓ | | | +| `record-ref` | | | | | | | ✓ | | +| `file-ref` | | | | | | | | ✓ | `string` and `text` are mutually read-compatible — 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. +**An enum reads as a string, and a required enum accepts only a narrower enum** (⊆ above): a candidate `enum` whose `values` are all in the required list. A consumer expecting an enum has handled exactly the values it lists, so a `string` or `text` candidate, which can hold anything, is refused, as is an enum listing a value the consumer does not. The check is as of the Type it is given: an enum may [gain values in place](#additive-evolution-within-a-version), so a candidate compatible today can later hold a value the consumer does not list, which is the case a [typed read](#type-handles) refuses. + Apps that care about semantics filter by exact `typeId`; apps that want flexibility (e.g. "any Type with a required `text` field") 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 @@ -373,7 +379,7 @@ const noteV2ToV3 = migration(NoteV2, NoteV3, (content) => ({ ...content, pinned: const stack = await Stack.open(adapter, { migrations: [noteV1ToV2, noteV2ToV3] }); ``` -`migration()` takes two [type handles](#type-handles), so `fn` is checked as `ContentOf` the first to `ContentOf` the second: a step that drops a required field or yields a value outside an `enum` does not compile. Within a family it refuses a `to` whose `migratesFrom` is not `from.id`, so a step cannot be paired with the wrong handle. +`migration()` takes two [type handles](#type-handles), so `fn` is checked as `ContentOf` the first to `ContentOf` the second: a step that drops a required field or yields a value outside an enum's `values` does not compile. Within a family it refuses a `to` whose `migratesFrom` is not `from.id`, so a step cannot be paired with the wrong handle. **Lineage stays inside a family; a migration can leave it.** `migratesFrom` names an earlier version of the same family, and `typeHandle()` and `defineType()` refuse anything else with `StackBadRequestError`. A step whose `from` and `to` are in different families has no lineage to check, so `migration()` builds it from the two handles alone. This is how an app's own type moves into a shared one — a [commons](../commons/README.md) type, say — without the shared handle naming every family that has ever migrated into it. A migration is a separate value from either handle: a handle is data a manifest carries over the wire, and may be shared by apps that do not run its family's migrations. @@ -424,7 +430,7 @@ Not every schema change needs a version bump. **Additive-in-place** changes — This is what makes duck-typed cross-app consumption (`isCompatible()`, above) work in practice: most evolution needs no coordination at all, and consumers that were never taught about a field simply don't see it. Adding the field to the schema is what licenses writing it — see [Undeclared content fields](#undeclared-content-fields) — and that is a `defineType()` call, not a version bump. -Widening a string field's `enum` (adding values, or removing it) is additive for the same reason a new optional field is: the schema accepts strictly more. Narrowing one is not, and needs a version bump. See [Schema drift detection](#schema-drift-detection). +Widening an enum (adding values, or turning it into a plain `string`) is additive for the same reason a new optional field is: the schema accepts strictly more. Narrowing one is not, and needs a version bump. See [Schema drift detection](#schema-drift-detection). **A version bump is a consolidation point**, warranted when: @@ -629,7 +635,7 @@ A caller that genuinely needs a count follows `cursor` to exhaustion and counts **Every top-level scalar is sortable, with no per-field opt-in.** A `sortable` marker in `TypeSchema` would change the schema hash, so making an existing field sortable would run the [schema drift](#schema-drift-detection) path for a change that alters no stored shape. The write cost is bounded by the number of top-level scalars on a Type. -**A field orders as the kind its schema declares, not as the value a Record happens to hold.** `number` and `boolean` order numerically (false before true), `date` as epoch milliseconds — so a date field orders the way `createdAt` does rather than by ISO string collation — and every string-shaped kind as text. A value that doesn't match its declared kind orders as nothing. Deriving from the schema is what keeps an order stable across Records that spell a field differently. +**A field orders as the kind its schema declares, not as the value a Record happens to hold.** `number` and `boolean` order numerically (false before true), `date` as epoch milliseconds — so a date field orders the way `createdAt` does rather than by ISO string collation — and every string-shaped kind as text. An `enum` is one of those: it orders by value, not by its position in `values`, which the [schema hash](#schema-drift-detection) does not preserve. A value that doesn't match its declared kind orders as nothing. Deriving from the schema is what keeps an order stable across Records that spell a field differently. **A `date` holding no UTC offset is read as UTC.** The offset is optional in the [shape a `date` accepts](#types), and resolving an offset-less date-time in whatever zone the host runs in would make the order depend on the machine: an index written on one host and a cursor re-derived on another would disagree, dropping or repeating a Record at a page boundary. UTC is also what a date-only value already resolves to, so `2020-03-01` and `2020-03-01T00:00:00` order alike. diff --git a/packages/conformance-fixtures/src/index.ts b/packages/conformance-fixtures/src/index.ts index cc4013e6..66ce3105 100644 --- a/packages/conformance-fixtures/src/index.ts +++ b/packages/conformance-fixtures/src/index.ts @@ -2674,6 +2674,30 @@ export const errorResponseFixtures: ConformanceFixture[] = [ }, }, }, + { + name: 'error-validation-enum-value-not-listed', + description: + 'PATCH content setting an enum field to a value its `values` list does not name returns ' + + '422 with code "validation", the detail naming the allowed values. An enum value is a ' + + "string, so the type check passes and only the list refuses it. Assumes the record's " + + 'type declares { status: { kind: "enum", values: ["want", "reading", "finished"] } }.', + method: 'PATCH', + path: '/records/1hk153x00001', + requestBody: { contentPatch: { status: 'abandoned' } }, + responseStatus: 422, + responseBody: { + error: { + code: 'validation', + message: 'Content validation failed', + details: [ + { + path: 'status', + message: 'Expected one of "want", "reading", "finished", got "abandoned"', + }, + ], + }, + }, + }, { name: 'error-validation-failed-restore', description: @@ -3532,6 +3556,40 @@ export const errorResponseFixtures: ConformanceFixture[] = [ }, }, }, + { + name: 'error-schema-drift-enum-values-removed', + description: + 'POST /types redefining an enum field with fewer values narrows what the field accepts, ' + + 'so it is not additive and returns 409 with code "schema_drift", naming the removed ' + + 'values. Adding values, or an enum becoming a plain string, accepts strictly more and is ' + + 'additive. Assumes "com.example/book@1" is already stored with { status: { kind: "enum", ' + + 'values: ["want", "reading", "finished"] } }.', + method: 'POST', + path: '/types', + requestBody: { + id: 'com.example/book@1', + baseId: 'com.example/book', + version: 1, + name: 'Book', + schema: { status: { kind: 'enum', values: ['want', 'reading'] } }, + schemaHash: '5d1e0c9b8a7f6e5d4c3b2a1908f7e6d5c4b3a291807f6e5d4c3b2a1908f7e6d5', + createdAt: '2024-01-01T00:00:00.000Z', + }, + responseStatus: 409, + responseBody: { + error: { + code: 'schema_drift', + message: + 'Schema drift detected for type "com.example/book@1": the stored schema and the new ' + + 'definition differ beyond additive evolution (new optional fields only). Bump the ' + + 'version instead of redefining "com.example/book@1" in place.', + schemaDrift: { + typeId: 'com.example/book@1', + violations: [{ path: 'status', message: 'enum values removed: "finished"' }], + }, + }, + }, + }, { name: 'error-bad-request-id-invalid-charset', description: diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index be38f824..130947a5 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -58,11 +58,11 @@ const canonicalizeFieldDef = (def: FieldDef): unknown => { }; } - // Scalar. `enum` is sorted so reordering the values is not a change. + // Scalar. An enum's `values` are sorted so reordering them is not a change. return { - ...(def.kind === 'string' && def.enum && { enum: [...def.enum].sort() }), kind: def.kind, ...(def.required !== undefined && { required: def.required }), + ...(def.kind === 'enum' && { values: [...def.values].sort() }), }; }; @@ -97,17 +97,19 @@ export const hashSchema = async (schema: TypeSchema): Promise => { /** * Kinds acceptable in a candidate field, per required kind. `string` and - * `text` are mutually acceptable for reading; everything else requires an - * exact match. See docs/spec/data-model.md § Type compatibility. + * `text` are mutually acceptable for reading, and either accepts an enum, + * whose values are strings; everything else requires an exact match. See + * docs/spec/data-model.md § Type compatibility. */ const READ_COMPATIBLE: Record = { - string: ['string', 'text'], - text: ['text', 'string'], + string: ['string', 'text', 'enum'], + text: ['text', 'string', 'enum'], number: ['number'], boolean: ['boolean'], date: ['date'], 'record-ref': ['record-ref'], 'file-ref': ['file-ref'], + enum: ['enum'], }; // Candidate schemas can come from another app's Type definition (the @@ -148,6 +150,10 @@ const isFieldCompatible = (candidate: FieldDef, required: FieldDef, depth: numbe ); } if (candidate.kind === 'array' || candidate.kind === 'object') return false; + // A consumer expecting an enum can handle only the values it lists. + if (required.kind === 'enum') { + return candidate.kind === 'enum' && candidate.values.every((v) => required.values.includes(v)); + } return READ_COMPATIBLE[required.kind].includes(candidate.kind); }; @@ -207,7 +213,10 @@ const diffField = ( violations.push({ path, message: 'exceeds max nesting depth; cannot verify additive change' }); return; } - if (stored.kind !== candidate.kind) { + // An enum becoming a string accepts strictly more, so it is the one kind + // change that is additive. + const widensEnum = stored.kind === 'enum' && candidate.kind === 'string'; + if (stored.kind !== candidate.kind && !widensEnum) { violations.push({ path, message: `kind changed from "${stored.kind}" to "${candidate.kind}"`, @@ -220,20 +229,15 @@ const diffField = ( message: `required changed from ${!!stored.required} to ${!!candidate.required}`, }); } - // An enum narrows what a string field accepts when it is added or loses - // values, and widens it when it is removed or gains them. - if (stored.kind === 'string' && candidate.kind === 'string') { - if (!stored.enum && candidate.enum) { - violations.push({ path, message: 'enum added to an existing string field' }); - } else if (stored.enum && candidate.enum) { - const kept = new Set(candidate.enum); - const removed = stored.enum.filter((v) => !kept.has(v)); - if (removed.length > 0) { - violations.push({ - path, - message: `enum values removed: ${removed.map((v) => JSON.stringify(v)).join(', ')}`, - }); - } + // Losing a value narrows what an enum accepts; gaining one widens it. + if (stored.kind === 'enum' && candidate.kind === 'enum') { + const kept = new Set(candidate.values); + const removed = stored.values.filter((v) => !kept.has(v)); + if (removed.length > 0) { + violations.push({ + path, + message: `enum values removed: ${removed.map((v) => JSON.stringify(v)).join(', ')}`, + }); } } // Opening a declared container, or closing an open one, changes which diff --git a/packages/core/src/sort.ts b/packages/core/src/sort.ts index 3ff63332..bb632ae8 100644 --- a/packages/core/src/sort.ts +++ b/packages/core/src/sort.ts @@ -54,7 +54,8 @@ const ZONELESS_DATE_TIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?$/; * * `date` normalizes to epoch milliseconds so a date field orders the way * `createdAt` does instead of by ISO string collation, and `boolean` to - * 0/1 so false precedes true. + * 0/1 so false precedes true. An enum orders as text: its declaration + * order is not part of the schema. */ export function contentSortEntry(kind: ScalarFieldKind, value: unknown): SortEntry | null { switch (kind) { @@ -69,7 +70,11 @@ export function contentSortEntry(kind: ScalarFieldKind, value: unknown): SortEnt const ms = Date.parse(ZONELESS_DATE_TIME_RE.test(value) ? `${value}Z` : value); return Number.isNaN(ms) ? null : { kind: 'num', num: ms }; } - default: + case 'string': + case 'text': + case 'record-ref': + case 'file-ref': + case 'enum': return typeof value === 'string' ? { kind: 'text', text: value, key: contentSortKey(value) } : null; diff --git a/packages/core/src/type-handle.ts b/packages/core/src/type-handle.ts index f58cb8dc..36ac2753 100644 --- a/packages/core/src/type-handle.ts +++ b/packages/core/src/type-handle.ts @@ -37,16 +37,16 @@ import type { /** * `FieldDef` with readonly members, which is what a `const` type parameter - * infers for a literal (`enum` becomes a readonly tuple). Every mutable + * infers for a literal (`values` becomes a readonly tuple). Every mutable * `TypeSchema` is assignable to it, so it is also what `defineType()` takes. */ export type ReadonlyFieldDef = | { - readonly kind: 'string'; - readonly enum?: readonly string[]; + readonly kind: 'enum'; + readonly values: readonly string[]; readonly required?: boolean; } - | { readonly kind: Exclude; readonly required?: boolean } + | { readonly kind: Exclude; readonly required?: boolean } | { readonly kind: 'array'; readonly items: ReadonlyFieldDef; @@ -82,7 +82,7 @@ type IsRequired = D extends { readonly required: true } ? true : false; type Simplify = { [K in keyof T]: T[K] } & {}; -type ValueOf = D extends { readonly kind: 'string'; readonly enum: readonly (infer E)[] } +type ValueOf = D extends { readonly kind: 'enum'; readonly values: readonly (infer E)[] } ? E : D extends { readonly kind: 'string' | 'text' | 'date' | 'record-ref' | 'file-ref' } ? string @@ -273,11 +273,11 @@ const unknownEnumValues = ( errors: ValidationError[], ): void => { if (value === undefined || value === null) return; - if (def.kind === 'string') { - if (def.enum && typeof value === 'string' && !def.enum.includes(value)) { + if (def.kind === 'enum') { + if (typeof value === 'string' && !def.values.includes(value)) { errors.push({ path, - message: `Expected one of ${def.enum.map((v) => JSON.stringify(v)).join(', ')}, got ${JSON.stringify(value)}`, + message: `Expected one of ${def.values.map((v) => JSON.stringify(v)).join(', ')}, got ${JSON.stringify(value)}`, }); } return; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 178862c5..136138d6 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -312,21 +312,22 @@ export type ScalarFieldKind = | 'date' | 'text' // Long-form string (e.g. markdown body) | 'record-ref' // Reference to another record by ID - | 'file-ref'; // Reference to an attachment file ID (SHA-256 hex) — indexed, unlike a plain `string` fileId + | 'file-ref' // Reference to an attachment file ID (SHA-256 hex) — indexed, unlike a plain `string` fileId + | 'enum'; // One of a declared set of strings /** - * `enum` is a non-empty, duplicate-free list of the strings the field may + * `values` is a non-empty, duplicate-free list of the strings the field may * hold. See docs/spec/data-model.md § Types. */ -export type StringFieldDef = { - kind: 'string'; - enum?: string[]; +export type EnumFieldDef = { + kind: 'enum'; + values: string[]; required?: boolean; }; export type ScalarFieldDef = - | StringFieldDef - | { kind: Exclude; required?: boolean }; + | EnumFieldDef + | { kind: Exclude; required?: boolean }; /** * A list. `open: true` leaves its elements unvalidated, diff --git a/packages/core/src/validate.ts b/packages/core/src/validate.ts index b4aa2cde..ea55ae1d 100644 --- a/packages/core/src/validate.ts +++ b/packages/core/src/validate.ts @@ -42,6 +42,7 @@ const jsTypeForScalar = (kind: ScalarFieldKind): string => { case 'text': case 'record-ref': case 'file-ref': + case 'enum': return 'string'; case 'number': return 'number'; @@ -197,10 +198,10 @@ const validateField = ( }); } else if (typeof value === 'number' && !Number.isFinite(value)) { errors.push({ path, message: `Expected a finite number, got ${value}` }); - } else if (def.kind === 'string' && def.enum && !def.enum.includes(value as string)) { + } else if (def.kind === 'enum' && !def.values.includes(value as string)) { errors.push({ path, - message: `Expected one of ${def.enum.map((v) => JSON.stringify(v)).join(', ')}, got ${JSON.stringify(value)}`, + message: `Expected one of ${def.values.map((v) => JSON.stringify(v)).join(', ')}, got ${JSON.stringify(value)}`, }); } }; @@ -344,22 +345,23 @@ const SCALAR_KINDS: Record = { text: true, 'record-ref': true, 'file-ref': true, + enum: true, }; const isPlainObject = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); -const validateEnumShape = (list: unknown, path: string, errors: ValidationError[]): void => { +const validateEnumValuesShape = (list: unknown, path: string, errors: ValidationError[]): void => { if (!Array.isArray(list) || list.length === 0) { - errors.push({ path, message: '"enum" must be a non-empty array of strings' }); + errors.push({ path, message: 'An enum must declare "values", a non-empty array of strings' }); return; } const seen = new Set(); for (const entry of list) { if (typeof entry !== 'string') { - errors.push({ path, message: `"enum" entries must be strings, got ${typeName(entry)}` }); + errors.push({ path, message: `"values" entries must be strings, got ${typeName(entry)}` }); } else if (seen.has(entry)) { - errors.push({ path, message: `"enum" lists ${JSON.stringify(entry)} more than once` }); + errors.push({ path, message: `"values" lists ${JSON.stringify(entry)} more than once` }); } seen.add(entry as string); } @@ -389,8 +391,8 @@ const validateFieldDefShape = ( return; } - if (def.enum !== undefined && (def.kind === 'array' || def.kind === 'object')) { - errors.push({ path, message: `"enum" is only allowed on a string field, not "${def.kind}"` }); + if (def.values !== undefined && def.kind !== 'enum') { + errors.push({ path, message: `"values" is only allowed on an enum field, not "${def.kind}"` }); } if (def.kind === 'array') { @@ -438,13 +440,7 @@ const validateFieldDefShape = ( return; } - if (def.enum !== undefined) { - if (def.kind !== 'string') { - errors.push({ path, message: `"enum" is only allowed on a string field, not "${def.kind}"` }); - } else { - validateEnumShape(def.enum, path, errors); - } - } + if (def.kind === 'enum') validateEnumValuesShape(def.values, path, errors); }; /** diff --git a/packages/core/tests/schema.test.ts b/packages/core/tests/schema.test.ts index 860a76e2..825b58ff 100644 --- a/packages/core/tests/schema.test.ts +++ b/packages/core/tests/schema.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from 'vitest'; import { hashSchema, isCompatible, diffSchemas, parseTypeId, buildTypeId } from '../src/schema.js'; -import type { TypeSchema } from '../src/types.js'; +import type { FieldDef, TypeSchema } from '../src/types.js'; // ------------------------------------------------------- // hashSchema @@ -308,19 +308,61 @@ describe('isCompatible', () => { describe('hashSchema enum', () => { test('reordering enum values does not change the hash', async () => { - const h1 = await hashSchema({ s: { kind: 'string', enum: ['a', 'b'] } }); - const h2 = await hashSchema({ s: { kind: 'string', enum: ['b', 'a'] } }); + const h1 = await hashSchema({ s: { kind: 'enum', values: ['a', 'b'] } }); + const h2 = await hashSchema({ s: { kind: 'enum', values: ['b', 'a'] } }); expect(h1).toBe(h2); }); test('an enum, and its values, change the hash', async () => { const plain = await hashSchema({ s: { kind: 'string' } }); - const ab = await hashSchema({ s: { kind: 'string', enum: ['a', 'b'] } }); - const ac = await hashSchema({ s: { kind: 'string', enum: ['a', 'c'] } }); + const ab = await hashSchema({ s: { kind: 'enum', values: ['a', 'b'] } }); + const ac = await hashSchema({ s: { kind: 'enum', values: ['a', 'c'] } }); expect(new Set([plain, ab, ac]).size).toBe(3); }); }); +describe('isCompatible enum', () => { + const field = (def: FieldDef): TypeSchema => ({ s: { ...def, required: true } }); + const abc = field({ kind: 'enum', values: ['a', 'b', 'c'] }); + const ab = field({ kind: 'enum', values: ['b', 'a'] }); + + test('a required enum accepts an enum listing a subset of its values', () => { + expect(isCompatible(ab, abc)).toBe(true); + expect(isCompatible(abc, abc)).toBe(true); + }); + + test('a required enum refuses an enum listing a value it does not', () => { + expect(isCompatible(abc, ab)).toBe(false); + }); + + test('a required enum refuses a string or text candidate, which may hold any value', () => { + expect(isCompatible(field({ kind: 'string' }), abc)).toBe(false); + expect(isCompatible(field({ kind: 'text' }), abc)).toBe(false); + }); + + test('a required string or text accepts an enum, whose values are strings', () => { + expect(isCompatible(abc, field({ kind: 'string' }))).toBe(true); + expect(isCompatible(abc, field({ kind: 'text' }))).toBe(true); + }); + + test('the subset rule applies inside declared arrays and objects', () => { + const inArray = (values: string[]): TypeSchema => ({ + s: { kind: 'array', items: { kind: 'enum', values }, required: true }, + }); + const inObject = (values: string[]): TypeSchema => ({ + o: { + kind: 'object', + properties: { s: { kind: 'enum', values, required: true } }, + required: true, + }, + }); + expect(isCompatible(inArray(['a']), inArray(['a', 'b']))).toBe(true); + expect(isCompatible(inArray(['a', 'b']), inArray(['a']))).toBe(false); + expect(isCompatible(inObject(['a']), inObject(['a', 'b']))).toBe(true); + expect(isCompatible(inObject(['a', 'b']), inObject(['a']))).toBe(false); + }); +}); + // ------------------------------------------------------- // diffSchemas // ------------------------------------------------------- @@ -629,31 +671,42 @@ describe('buildTypeId', () => { }); describe('diffSchemas enum', () => { - const withEnum = (values?: string[]): TypeSchema => ({ - s: { kind: 'string', ...(values && { enum: values }) }, - }); + const field = (def: FieldDef): TypeSchema => ({ s: def }); + const values = (...v: string[]) => field({ kind: 'enum', values: v }); - test('adding an enum to an existing string field is drift', () => { - expect(diffSchemas(withEnum(), withEnum(['a']))).toEqual([ - { path: 's', message: 'enum added to an existing string field' }, + test('a string becoming an enum is drift', () => { + expect(diffSchemas(field({ kind: 'string' }), values('a'))).toEqual([ + { path: 's', message: 'kind changed from "string" to "enum"' }, ]); }); test('removing enum values is drift', () => { - expect(diffSchemas(withEnum(['a', 'b', 'c']), withEnum(['a']))).toEqual([ + expect(diffSchemas(values('a', 'b', 'c'), values('a'))).toEqual([ { path: 's', message: 'enum values removed: "b", "c"' }, ]); }); - test('removing the enum is additive', () => { - expect(diffSchemas(withEnum(['a']), withEnum())).toEqual([]); + test('an enum becoming a string is additive', () => { + expect(diffSchemas(values('a'), field({ kind: 'string' }))).toEqual([]); + }); + + test('an enum becoming a string still reports a required flip', () => { + expect(diffSchemas(values('a'), field({ kind: 'string', required: true }))).toEqual([ + { path: 's', message: 'required changed from false to true' }, + ]); + }); + + test('an enum becoming text is drift', () => { + expect(diffSchemas(values('a'), field({ kind: 'text' }))).toEqual([ + { path: 's', message: 'kind changed from "enum" to "text"' }, + ]); }); test('adding enum values is additive', () => { - expect(diffSchemas(withEnum(['a']), withEnum(['a', 'b']))).toEqual([]); + expect(diffSchemas(values('a'), values('a', 'b'))).toEqual([]); }); test('reordering enum values is not a change', () => { - expect(diffSchemas(withEnum(['a', 'b']), withEnum(['b', 'a']))).toEqual([]); + expect(diffSchemas(values('a', 'b'), values('b', 'a'))).toEqual([]); }); }); diff --git a/packages/core/tests/sort.test.ts b/packages/core/tests/sort.test.ts index 5ecbe61d..d96075b6 100644 --- a/packages/core/tests/sort.test.ts +++ b/packages/core/tests/sort.test.ts @@ -73,6 +73,10 @@ describe('contentSortEntry', () => { expect(contentSortEntry('boolean', true)).toEqual({ kind: 'num', num: 1 }); }); + test('reads an enum as text, ordered by value rather than declaration', () => { + expect(contentSortEntry('enum', 'Émile')).toEqual(contentSortEntry('string', 'Émile')); + }); + test('carries both the text and the key it orders by', () => { expect(contentSortEntry('string', 'Émile')).toEqual({ kind: 'text', diff --git a/packages/core/tests/type-handle.test.ts b/packages/core/tests/type-handle.test.ts index 122a8306..581637aa 100644 --- a/packages/core/tests/type-handle.test.ts +++ b/packages/core/tests/type-handle.test.ts @@ -17,7 +17,7 @@ const BookV1 = typeHandle({ name: 'Book', schema: { title: { kind: 'string', required: true }, - status: { kind: 'string', enum: ['want', 'reading', 'done'], required: true }, + status: { kind: 'enum', values: ['want', 'reading', 'done'], required: true }, }, }); @@ -27,7 +27,7 @@ const Book = typeHandle({ migratesFrom: BookV1, schema: { title: { kind: 'string', required: true }, - status: { kind: 'string', enum: ['want', 'reading', 'finished', 'abandoned'], required: true }, + status: { kind: 'enum', values: ['want', 'reading', 'finished', 'abandoned'], required: true }, pages: { kind: 'number' }, }, }); @@ -49,7 +49,7 @@ const Everything = typeHandle({ name: 'Everything', schema: { s: { kind: 'string', required: true }, - e: { kind: 'string', enum: ['a', 'b'], required: true }, + e: { kind: 'enum', values: ['a', 'b'], required: true }, t: { kind: 'text', required: true }, n: { kind: 'number', required: true }, b: { kind: 'boolean', required: true }, @@ -183,7 +183,7 @@ describe('typeHandle()', () => { }); test('core TypeSchema fits the literal schema type', () => { - const fromCore: TypeSchema = { a: { kind: 'string', enum: ['x'] } }; + const fromCore: TypeSchema = { a: { kind: 'enum', values: ['x'] } }; expect(typeHandle({ id: 'com.example/x@1', name: 'X', schema: fromCore }).schema).toBe( fromCore, ); @@ -318,8 +318,8 @@ describe.each([ schema: { ...Book.schema, status: { - kind: 'string', - enum: ['want', 'reading', 'finished', 'abandoned', 'paused'], + kind: 'enum', + values: ['want', 'reading', 'finished', 'abandoned', 'paused'], required: true, }, }, @@ -428,8 +428,8 @@ describe.each([ schema: { ...Book.schema, status: { - kind: 'string', - enum: ['want', 'reading', 'finished', 'abandoned', 'paused'], + kind: 'enum', + values: ['want', 'reading', 'finished', 'abandoned', 'paused'], required: true, }, }, diff --git a/packages/core/tests/validate.test.ts b/packages/core/tests/validate.test.ts index d4deb437..0965785b 100644 --- a/packages/core/tests/validate.test.ts +++ b/packages/core/tests/validate.test.ts @@ -630,12 +630,12 @@ describe('validatePatchValues', () => { }); // ------------------------------------------------------- -// String enums +// Enums // ------------------------------------------------------- -describe('string enum', () => { +describe('enum', () => { const schema: TypeSchema = { - status: { kind: 'string', enum: ['want', 'reading', 'finished'] }, + status: { kind: 'enum', values: ['want', 'reading', 'finished'] }, }; test('a listed value is accepted', () => { @@ -651,15 +651,15 @@ describe('string enum', () => { ]); }); - test('a non-string is refused by the type check, not the enum', () => { + test('a non-string is refused by the type check, not the value list', () => { expect(errorsFor({ status: 3 }, schema)).toEqual([ { path: 'status', message: 'Expected string, got number' }, ]); }); - test('an enum constrains a string inside a declared array', () => { + test('an enum constrains each element of a declared array', () => { const tags: TypeSchema = { - tags: { kind: 'array', items: { kind: 'string', enum: ['a', 'b'] } }, + tags: { kind: 'array', items: { kind: 'enum', values: ['a', 'b'] } }, }; expect(paths({ tags: ['a', 'c'] }, tags)).toEqual(['tags[1]']); }); @@ -669,36 +669,34 @@ describe('validateSchemaShape enum', () => { const messages = (json: string) => validateSchemaShape(JSON.parse(json)).map((e) => e.message); test('a non-empty list of distinct strings is well-formed', () => { - expect(messages('{"s": {"kind": "string", "enum": ["a", "b"]}}')).toEqual([]); + expect(messages('{"s": {"kind": "enum", "values": ["a", "b"]}}')).toEqual([]); }); - test('an empty or non-array enum is refused', () => { - const expected = ['"enum" must be a non-empty array of strings']; - expect(messages('{"s": {"kind": "string", "enum": []}}')).toEqual(expected); - expect(messages('{"s": {"kind": "string", "enum": "a"}}')).toEqual(expected); + test('missing, empty or non-array values are refused', () => { + const expected = ['An enum must declare "values", a non-empty array of strings']; + expect(messages('{"s": {"kind": "enum"}}')).toEqual(expected); + expect(messages('{"s": {"kind": "enum", "values": []}}')).toEqual(expected); + expect(messages('{"s": {"kind": "enum", "values": "a"}}')).toEqual(expected); }); - test('non-string and duplicate entries are refused', () => { - expect(messages('{"s": {"kind": "string", "enum": ["a", 1]}}')).toEqual([ - '"enum" entries must be strings, got number', + test('non-string and duplicate values are refused', () => { + expect(messages('{"s": {"kind": "enum", "values": ["a", 1]}}')).toEqual([ + '"values" entries must be strings, got number', ]); - expect(messages('{"s": {"kind": "string", "enum": ["a", "a"]}}')).toEqual([ - '"enum" lists "a" more than once', + expect(messages('{"s": {"kind": "enum", "values": ["a", "a"]}}')).toEqual([ + '"values" lists "a" more than once', ]); }); - test('an enum on a non-string kind is refused', () => { - expect(messages('{"s": {"kind": "text", "enum": ["a"]}}')).toEqual([ - '"enum" is only allowed on a string field, not "text"', + test('values on any other kind are refused', () => { + expect(messages('{"s": {"kind": "string", "values": ["a"]}}')).toEqual([ + '"values" is only allowed on an enum field, not "string"', ]); - }); - - test('an enum on an array or object is refused', () => { - expect(messages('{"a": {"kind": "array", "open": true, "enum": ["a"]}}')).toEqual([ - '"enum" is only allowed on a string field, not "array"', + expect(messages('{"a": {"kind": "array", "open": true, "values": ["a"]}}')).toEqual([ + '"values" is only allowed on an enum field, not "array"', ]); - expect(messages('{"o": {"kind": "object", "open": true, "enum": ["a"]}}')).toEqual([ - '"enum" is only allowed on a string field, not "object"', + expect(messages('{"o": {"kind": "object", "open": true, "values": ["a"]}}')).toEqual([ + '"values" is only allowed on an enum field, not "object"', ]); }); }); From 059e0b9e0f494f33be6bdb5191b86a0a750d52ca Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 13:05:00 +0000 Subject: [PATCH 2/8] fix(core): harden enum handling against malformed schemas - Guard isCompatible, write validation and typed reads against an enum def with no values list; stored Types are not re-validated on read. - Refuse the enum key on any field so the modifier spelling fails loudly instead of silently dropping its constraint. - contentSortEntry orders a kind outside the union as nothing. - Share the enum mismatch message between validate and type-handle. - Spec: typed reads refuse unlisted values only for a handle naming the Type; read compatibility and drift also disagree on enum. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XpyBKjVuG8NHtQcHvQoAnN --- .changeset/enum-field-kind.md | 2 +- docs/spec/data-model.md | 6 +++--- packages/core/src/schema.ts | 14 +++++++++++--- packages/core/src/sort.ts | 3 +++ packages/core/src/type-handle.ts | 9 +++------ packages/core/src/validate.ts | 25 ++++++++++++++++++++----- packages/core/tests/schema.test.ts | 5 +++++ packages/core/tests/sort.test.ts | 5 +++++ packages/core/tests/validate.test.ts | 11 +++++++++++ 9 files changed, 62 insertions(+), 18 deletions(-) diff --git a/.changeset/enum-field-kind.md b/.changeset/enum-field-kind.md index e31727a0..96ec4576 100644 --- a/.changeset/enum-field-kind.md +++ b/.changeset/enum-field-kind.md @@ -3,4 +3,4 @@ '@haverstack/conformance-fixtures': minor --- -`enum` is a field kind: `{ kind: 'enum', values: [...] }` replaces `{ kind: 'string', enum: [...] }`. `isCompatible()` now checks enums: a required enum accepts only an enum candidate whose values it lists all of, and a required `string` or `text` accepts an enum. An enum becoming a plain `string` is additive; a `string` becoming an enum needs a version bump. Conformance fixtures pin a 422 for a value outside the list and a 409 for removing values. +`enum` is a field kind: `{ kind: 'enum', values: [...] }` replaces `{ kind: 'string', enum: [...] }`. `defineType()` refuses the `enum` key on any field. `isCompatible()` now checks enums: a required enum accepts only an enum candidate whose values it lists all of, and a required `string` or `text` accepts an enum. An enum becoming a plain `string` is additive; a `string` becoming an enum needs a version bump. Conformance fixtures pin a 422 for a value outside the list and a 409 for removing values. diff --git a/docs/spec/data-model.md b/docs/spec/data-model.md index eadc1be9..210a0cb6 100644 --- a/docs/spec/data-model.md +++ b/docs/spec/data-model.md @@ -305,7 +305,7 @@ await stack.defineType({ **Array and object fields** are schema-validated on write and reachable by query: a content filter key is a path, and an array along it is matched element-wise (see [Filter](#filter)). **`open: true` declares the container open** — a list or object whose interior the schema does not describe, and the one way to store content the schema cannot name. A container declares either its interior or `open`, never neither: opacity is a claim the schema makes, not something inferred from a missing `items`/`properties`, so a schema that forgets to describe its elements is a mistake rather than a silently unchecked field. Query reach is unaffected: a path still walks into an open container, since the query engine reads the content rather than the schema. See [Undeclared content fields](#undeclared-content-fields). -**An `enum` field holds one of the strings its `values` list names.** A value outside the list is a `StackValidationError` at its path, naming the allowed values. `defineType()` refuses an `enum` with missing or empty `values`, non-string or duplicate entries, and `values` on any other kind. The schema is then the one place the allowed values live, so an app's union type (`'want' | 'reading' | 'finished'`) has something to derive from rather than drifting from it. The [schema hash](#schema-drift-detection) sorts the values, so reordering them is not a change. +**An `enum` field holds one of the strings its `values` list names.** A value outside the list is a `StackValidationError` at its path, naming the allowed values. `defineType()` refuses an `enum` with missing or empty `values`, non-string or duplicate entries, `values` on any other kind, and an `enum` key on any field, which would otherwise be ignored and silently drop the constraint. The schema is then the one place the allowed values live, so an app's union type (`'want' | 'reading' | 'finished'`) has something to derive from rather than drifting from it. The [schema hash](#schema-drift-detection) sorts the values, so reordering them is not a change. An enum is its own kind rather than a constraint on `string` because it promises a reader something a string does not: the value is one of a known set. Every rule keyed on kind — [read compatibility](#type-compatibility), [drift](#schema-drift-detection), [sort order](#sorting-by-a-content-field) — therefore states what it does with one. @@ -341,7 +341,7 @@ This matters because `defineType()` takes a `TypeSchema` but a schema arriving a 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). -**Two distinct relations, easy to conflate:** schema drift detection (above) answers _"may this schema replace that one under the same `id`?"_ — evolution legality. Type compatibility answers _"may a consumer expecting this shape read Records of that Type?"_ — read compatibility. They deliberately disagree on `text`/`string`: read-compatible (both are strings at the value level) but **not** evolution-legal — a stored `kind: 'string'` field silently becoming `kind: 'text'` is exactly the kind of change a version bump should surface, even though every existing reader could still consume the value. +**Two distinct relations, easy to conflate:** schema drift detection (above) answers _"may this schema replace that one under the same `id`?"_ — evolution legality. Type compatibility answers _"may a consumer expecting this shape read Records of that Type?"_ — read compatibility. They deliberately disagree on `text`/`string`: read-compatible (both are strings at the value level) but **not** evolution-legal — a stored `kind: 'string'` field silently becoming `kind: 'text'` is exactly the kind of change a version bump should surface, even though every existing reader could still consume the value. They disagree on `enum` too: a required `string` reads an enum, and `enum` to `string` is evolution-legal, but `enum` to `text` is not. A field's kind is read-compatible with a required kind per this table (row = required kind, columns = candidate kinds accepted): @@ -358,7 +358,7 @@ A field's kind is read-compatible with a required kind per this table (row = req `string` and `text` are mutually read-compatible — 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. -**An enum reads as a string, and a required enum accepts only a narrower enum** (⊆ above): a candidate `enum` whose `values` are all in the required list. A consumer expecting an enum has handled exactly the values it lists, so a `string` or `text` candidate, which can hold anything, is refused, as is an enum listing a value the consumer does not. The check is as of the Type it is given: an enum may [gain values in place](#additive-evolution-within-a-version), so a candidate compatible today can later hold a value the consumer does not list, which is the case a [typed read](#type-handles) refuses. +**An enum reads as a string, and a required enum accepts only a narrower enum** (⊆ above): a candidate `enum` whose `values` are all in the required list. A consumer expecting an enum has handled exactly the values it lists, so a `string` or `text` candidate, which can hold anything, is refused, as is an enum listing a value the consumer does not. The check is as of the Type it is given: an enum may [gain values in place](#additive-evolution-within-a-version), or become a plain `string`, so a candidate compatible today can later hold a value the consumer does not list. A [typed read](#type-handles) refuses such a value, but only for a handle naming that Type's `id`; a consumer reading a foreign Type by compatibility re-checks with `isCompatible()` or handles values it does not list. Apps that care about semantics filter by exact `typeId`; apps that want flexibility (e.g. "any Type with a required `text` field") 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. diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 130947a5..bbb196d4 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -11,6 +11,7 @@ import type { TypeSchema, FieldDef, ScalarFieldKind } from './types.js'; import type { ReadonlyTypeSchema } from './type-handle.js'; +import { enumAllows } from './validate.js'; // ------------------------------------------------------- // Canonical schema serialization @@ -109,6 +110,7 @@ const READ_COMPATIBLE: Record = { date: ['date'], 'record-ref': ['record-ref'], 'file-ref': ['file-ref'], + // Never consulted: a required enum takes the subset rule in isFieldCompatible. enum: ['enum'], }; @@ -150,9 +152,14 @@ const isFieldCompatible = (candidate: FieldDef, required: FieldDef, depth: numbe ); } if (candidate.kind === 'array' || candidate.kind === 'object') return false; - // A consumer expecting an enum can handle only the values it lists. + // A consumer expecting an enum can handle only the values it lists. An + // enum candidate with no `values` list promises nothing, so it fails closed. if (required.kind === 'enum') { - return candidate.kind === 'enum' && candidate.values.every((v) => required.values.includes(v)); + return ( + candidate.kind === 'enum' && + Array.isArray(candidate.values) && + candidate.values.every((v) => enumAllows(required.values, v)) + ); } return READ_COMPATIBLE[required.kind].includes(candidate.kind); }; @@ -187,7 +194,8 @@ export const isCompatible = ( // ------------------------------------------------------- // // Deliberately distinct from isCompatible() above: read compatibility and -// evolution legality are different relations that disagree on text/string. +// evolution legality are different relations that disagree on text/string +// and on enum. // See docs/spec/data-model.md § Type compatibility. export type SchemaDriftViolation = { diff --git a/packages/core/src/sort.ts b/packages/core/src/sort.ts index bb632ae8..0592b8de 100644 --- a/packages/core/src/sort.ts +++ b/packages/core/src/sort.ts @@ -78,6 +78,9 @@ export function contentSortEntry(kind: ScalarFieldKind, value: unknown): SortEnt return typeof value === 'string' ? { kind: 'text', text: value, key: contentSortKey(value) } : null; + default: + // A stored schema is not re-validated, so its kind may be outside the union. + return null; } } diff --git a/packages/core/src/type-handle.ts b/packages/core/src/type-handle.ts index 36ac2753..153e9c00 100644 --- a/packages/core/src/type-handle.ts +++ b/packages/core/src/type-handle.ts @@ -13,7 +13,7 @@ import { StackBadRequestError, StackMigrationError, StackValidationError } from './errors.js'; import { lineageProblem, parseTypeId } from './schema.js'; -import type { ValidationError } from './validate.js'; +import { enumAllows, enumMismatchMessage, type ValidationError } from './validate.js'; import type { CreateRecordOptions, StackClient } from './stack.js'; import type { BaseId, @@ -274,11 +274,8 @@ const unknownEnumValues = ( ): void => { if (value === undefined || value === null) return; if (def.kind === 'enum') { - if (typeof value === 'string' && !def.values.includes(value)) { - errors.push({ - path, - message: `Expected one of ${def.values.map((v) => JSON.stringify(v)).join(', ')}, got ${JSON.stringify(value)}`, - }); + if (typeof value === 'string' && !enumAllows(def.values, value)) { + errors.push({ path, message: enumMismatchMessage(def.values, value) }); } return; } diff --git a/packages/core/src/validate.ts b/packages/core/src/validate.ts index ea55ae1d..c9d3470c 100644 --- a/packages/core/src/validate.ts +++ b/packages/core/src/validate.ts @@ -36,6 +36,19 @@ export type ValidationError = { // Internal helpers // ------------------------------------------------------- +/** + * A stored Type is not re-validated on read, so an enum def may arrive + * without `values`; it then allows nothing rather than throwing. + */ +export const enumAllows = (values: readonly string[] | undefined, value: unknown): boolean => + Array.isArray(values) && values.includes(value as string); + +export const enumMismatchMessage = ( + values: readonly string[] | undefined, + value: unknown, +): string => + `Expected one of ${(values ?? []).map((v) => JSON.stringify(v)).join(', ')}, got ${JSON.stringify(value)}`; + const jsTypeForScalar = (kind: ScalarFieldKind): string => { switch (kind) { case 'string': @@ -198,11 +211,8 @@ const validateField = ( }); } else if (typeof value === 'number' && !Number.isFinite(value)) { errors.push({ path, message: `Expected a finite number, got ${value}` }); - } else if (def.kind === 'enum' && !def.values.includes(value as string)) { - errors.push({ - path, - message: `Expected one of ${def.values.map((v) => JSON.stringify(v)).join(', ')}, got ${JSON.stringify(value)}`, - }); + } else if (def.kind === 'enum' && !enumAllows(def.values, value)) { + errors.push({ path, message: enumMismatchMessage(def.values, value) }); } }; @@ -394,6 +404,11 @@ const validateFieldDefShape = ( if (def.values !== undefined && def.kind !== 'enum') { errors.push({ path, message: `"values" is only allowed on an enum field, not "${def.kind}"` }); } + // Unknown keys are otherwise ignored, but this one would silently drop + // the constraint its author meant to declare. + if (def.enum !== undefined) { + errors.push({ path, message: '"enum" is not a field modifier; use { kind: "enum", values }' }); + } if (def.kind === 'array') { if (isOpen) { diff --git a/packages/core/tests/schema.test.ts b/packages/core/tests/schema.test.ts index 825b58ff..8792f147 100644 --- a/packages/core/tests/schema.test.ts +++ b/packages/core/tests/schema.test.ts @@ -335,6 +335,11 @@ describe('isCompatible enum', () => { expect(isCompatible(abc, ab)).toBe(false); }); + test('a candidate enum with no values list is incompatible, not a throw', () => { + const malformed = { s: { kind: 'enum', required: true } } as unknown as TypeSchema; + expect(isCompatible(malformed, abc)).toBe(false); + }); + test('a required enum refuses a string or text candidate, which may hold any value', () => { expect(isCompatible(field({ kind: 'string' }), abc)).toBe(false); expect(isCompatible(field({ kind: 'text' }), abc)).toBe(false); diff --git a/packages/core/tests/sort.test.ts b/packages/core/tests/sort.test.ts index d96075b6..d952b070 100644 --- a/packages/core/tests/sort.test.ts +++ b/packages/core/tests/sort.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect } from 'vitest'; import { contentSortKey, contentSortEntry, compareSortEntries } from '../src/sort.js'; import type { SortEntry } from '../src/sort.js'; +import type { ScalarFieldKind } from '../src/types.js'; describe('contentSortKey', () => { test('folds case and combining marks away', () => { @@ -34,6 +35,10 @@ describe('contentSortKey', () => { }); describe('contentSortEntry', () => { + test('a kind outside the union orders as nothing', () => { + expect(contentSortEntry('nonsense' as ScalarFieldKind, 'x')).toBeNull(); + }); + test('reads a date as epoch milliseconds, not as an ISO string', () => { expect(contentSortEntry('date', '2020-03-01T00:00:00Z')).toEqual({ kind: 'num', diff --git a/packages/core/tests/validate.test.ts b/packages/core/tests/validate.test.ts index 0965785b..37059325 100644 --- a/packages/core/tests/validate.test.ts +++ b/packages/core/tests/validate.test.ts @@ -663,6 +663,11 @@ describe('enum', () => { }; expect(paths({ tags: ['a', 'c'] }, tags)).toEqual(['tags[1]']); }); + + test('a stored enum with no values list refuses every value rather than throwing', () => { + const malformed = { status: { kind: 'enum' } } as unknown as TypeSchema; + expect(paths({ status: 'want' }, malformed)).toEqual(['status']); + }); }); describe('validateSchemaShape enum', () => { @@ -699,4 +704,10 @@ describe('validateSchemaShape enum', () => { '"values" is only allowed on an enum field, not "object"', ]); }); + + test('an enum key on any field is refused, not ignored', () => { + expect(messages('{"s": {"kind": "string", "enum": ["a"]}}')).toEqual([ + '"enum" is not a field modifier; use { kind: "enum", values }', + ]); + }); }); From 56cfa42baaec2cbe940a566cbdbb89b3fc3bebd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 13:57:51 +0000 Subject: [PATCH 3/8] fix(core): tolerate malformed stored enums everywhere they are read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stored Type is not re-validated, so an enum def can arrive without `values`. enumAllows and isFieldCompatible already treated that as "allows nothing"; diffSchemas and hashSchema threw instead, turning a 409 drift refusal into a 500. - diffField and canonicalizeFieldDef guard the missing list. - narrowRecord refuses a non-string value in an enum field, which it previously let through typed as the literal union. - enumMismatchMessage names the empty list instead of printing "Expected one of , got ...". - The `enum`-key refusal states the invariant rather than pointing at a replacement. - READ_COMPATIBLE drops its unread `enum` entry. - data-model.md § Sorting by a content field states that an unknown declared kind orders as nothing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQamMFa1B7kk8RTmzoTdqQ --- docs/spec/data-model.md | 2 +- packages/core/src/schema.ts | 13 +++++++------ packages/core/src/type-handle.ts | 2 +- packages/core/src/validate.ts | 10 ++++++---- packages/core/tests/schema.test.ts | 10 ++++++++++ packages/core/tests/type-handle.test.ts | 13 +++++++++++-- packages/core/tests/validate.test.ts | 6 ++++-- 7 files changed, 40 insertions(+), 16 deletions(-) diff --git a/docs/spec/data-model.md b/docs/spec/data-model.md index 7d7e504b..9b2ff3cb 100644 --- a/docs/spec/data-model.md +++ b/docs/spec/data-model.md @@ -635,7 +635,7 @@ A caller that genuinely needs a count follows `cursor` to exhaustion and counts **Every top-level scalar is sortable, with no per-field opt-in.** A `sortable` marker in `TypeSchema` would change the schema hash, so making an existing field sortable would run the [schema drift](#schema-drift-detection) path for a change that alters no stored shape. The write cost is bounded by the number of top-level scalars on a Type. -**A field orders as the kind its schema declares, not as the value a Record happens to hold.** `number` and `boolean` order numerically (false before true), `date` as epoch milliseconds — so a date field orders the way `createdAt` does rather than by ISO string collation — and every string-shaped kind as text. An `enum` is one of those: it orders by value, not by its position in `values`, which the [schema hash](#schema-drift-detection) does not preserve. A value that doesn't match its declared kind orders as nothing. Deriving from the schema is what keeps an order stable across Records that spell a field differently. +**A field orders as the kind its schema declares, not as the value a Record happens to hold.** `number` and `boolean` order numerically (false before true), `date` as epoch milliseconds — so a date field orders the way `createdAt` does rather than by ISO string collation — and every string-shaped kind as text. An `enum` is one of those: it orders by value, not by its position in `values`, which the [schema hash](#schema-drift-detection) does not preserve. A value that doesn't match its declared kind orders as nothing. So does every value of a field whose declared kind is not one [§ Types](#types) defines, which a foreign or unvalidated stored schema can carry. Deriving from the schema is what keeps an order stable across Records that spell a field differently. **A `date` holding no UTC offset is read as UTC.** The offset is optional in the [shape a `date` accepts](#types), and resolving an offset-less date-time in whatever zone the host runs in would make the order depend on the machine: an index written on one host and a cursor re-derived on another would disagree, dropping or repeating a Record at a page boundary. UTC is also what a date-only value already resolves to, so `2020-03-01` and `2020-03-01T00:00:00` order alike. diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index bbb196d4..f093ba11 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -60,10 +60,11 @@ const canonicalizeFieldDef = (def: FieldDef): unknown => { } // Scalar. An enum's `values` are sorted so reordering them is not a change. + // A stored enum may lack the list; it hashes as one that allows nothing. return { kind: def.kind, ...(def.required !== undefined && { required: def.required }), - ...(def.kind === 'enum' && { values: [...def.values].sort() }), + ...(def.kind === 'enum' && Array.isArray(def.values) && { values: [...def.values].sort() }), }; }; @@ -99,10 +100,11 @@ export const hashSchema = async (schema: TypeSchema): Promise => { /** * Kinds acceptable in a candidate field, per required kind. `string` and * `text` are mutually acceptable for reading, and either accepts an enum, - * whose values are strings; everything else requires an exact match. See + * whose values are strings; everything else requires an exact match. A + * required enum takes the subset rule in isFieldCompatible instead. See * docs/spec/data-model.md § Type compatibility. */ -const READ_COMPATIBLE: Record = { +const READ_COMPATIBLE: Record, ScalarFieldKind[]> = { string: ['string', 'text', 'enum'], text: ['text', 'string', 'enum'], number: ['number'], @@ -110,8 +112,6 @@ const READ_COMPATIBLE: Record = { date: ['date'], 'record-ref': ['record-ref'], 'file-ref': ['file-ref'], - // Never consulted: a required enum takes the subset rule in isFieldCompatible. - enum: ['enum'], }; // Candidate schemas can come from another app's Type definition (the @@ -239,8 +239,9 @@ const diffField = ( } // Losing a value narrows what an enum accepts; gaining one widens it. if (stored.kind === 'enum' && candidate.kind === 'enum') { + // A stored Type is not re-validated, so its enum may lack the list. const kept = new Set(candidate.values); - const removed = stored.values.filter((v) => !kept.has(v)); + const removed = (Array.isArray(stored.values) ? stored.values : []).filter((v) => !kept.has(v)); if (removed.length > 0) { violations.push({ path, diff --git a/packages/core/src/type-handle.ts b/packages/core/src/type-handle.ts index f2eb294f..cd9f073b 100644 --- a/packages/core/src/type-handle.ts +++ b/packages/core/src/type-handle.ts @@ -275,7 +275,7 @@ const unknownEnumValues = ( ): void => { if (value === undefined || value === null) return; if (def.kind === 'enum') { - if (typeof value === 'string' && !enumAllows(def.values, value)) { + if (!enumAllows(def.values, value)) { errors.push({ path, message: enumMismatchMessage(def.values, value) }); } return; diff --git a/packages/core/src/validate.ts b/packages/core/src/validate.ts index c9d3470c..935dcc97 100644 --- a/packages/core/src/validate.ts +++ b/packages/core/src/validate.ts @@ -47,7 +47,9 @@ export const enumMismatchMessage = ( values: readonly string[] | undefined, value: unknown, ): string => - `Expected one of ${(values ?? []).map((v) => JSON.stringify(v)).join(', ')}, got ${JSON.stringify(value)}`; + Array.isArray(values) && values.length > 0 + ? `Expected one of ${values.map((v) => JSON.stringify(v)).join(', ')}, got ${JSON.stringify(value)}` + : `The schema declares no enum values, so ${JSON.stringify(value)} is not allowed`; const jsTypeForScalar = (kind: ScalarFieldKind): string => { switch (kind) { @@ -404,10 +406,10 @@ const validateFieldDefShape = ( if (def.values !== undefined && def.kind !== 'enum') { errors.push({ path, message: `"values" is only allowed on an enum field, not "${def.kind}"` }); } - // Unknown keys are otherwise ignored, but this one would silently drop - // the constraint its author meant to declare. + // Unknown keys are otherwise ignored, but `enum` names a kind: as a key it + // would read as a constraint that nothing enforces. if (def.enum !== undefined) { - errors.push({ path, message: '"enum" is not a field modifier; use { kind: "enum", values }' }); + errors.push({ path, message: '"enum" is a field kind, not a field key' }); } if (def.kind === 'array') { diff --git a/packages/core/tests/schema.test.ts b/packages/core/tests/schema.test.ts index 8792f147..4b63a3fc 100644 --- a/packages/core/tests/schema.test.ts +++ b/packages/core/tests/schema.test.ts @@ -319,6 +319,11 @@ describe('hashSchema enum', () => { const ac = await hashSchema({ s: { kind: 'enum', values: ['a', 'c'] } }); expect(new Set([plain, ab, ac]).size).toBe(3); }); + + test('a stored enum with no values list hashes rather than throwing', async () => { + const malformed = { s: { kind: 'enum' } } as unknown as TypeSchema; + await expect(hashSchema(malformed)).resolves.toMatch(/\S/); + }); }); describe('isCompatible enum', () => { @@ -714,4 +719,9 @@ describe('diffSchemas enum', () => { test('reordering enum values is not a change', () => { expect(diffSchemas(values('a', 'b'), values('b', 'a'))).toEqual([]); }); + + test('a stored enum with no values list diffs rather than throwing', () => { + const malformed = { s: { kind: 'enum' } } as unknown as TypeSchema; + expect(diffSchemas(malformed, values('a'))).toEqual([]); + }); }); diff --git a/packages/core/tests/type-handle.test.ts b/packages/core/tests/type-handle.test.ts index 61662cb2..ed68f9bf 100644 --- a/packages/core/tests/type-handle.test.ts +++ b/packages/core/tests/type-handle.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect, expectTypeOf, beforeEach, vi } from 'vitest'; import { Stack } from '../src/stack.js'; import { MemoryAdapter } from '../src/testing.js'; -import { migration, typeHandle } from '../src/type-handle.js'; +import { migration, narrowRecord, typeHandle } from '../src/type-handle.js'; import type { ContentOf, PatchOf, TypedChange, TypedRecord } from '../src/type-handle.js'; import { StackBadRequestError, @@ -10,7 +10,7 @@ import { StackValidationError, } from '../src/errors.js'; import type { StackClient } from '../src/stack.js'; -import type { TypeId, TypeSchema } from '../src/types.js'; +import type { StackRecord, TypeId, TypeSchema } from '../src/types.js'; const OWNER = 'owner-123'; @@ -244,6 +244,15 @@ describe('migration()', () => { // Typed reads and writes // ------------------------------------------------------- +describe('narrowRecord()', () => { + test('refuses a non-string value in an enum field', () => { + const record = { id: 'r1', typeId: Book.id, content: { title: 'Dune', status: 3 } }; + expect(() => narrowRecord(Book, record as unknown as StackRecord)).toThrow( + StackValidationError, + ); + }); +}); + describe.each([ ['Stack', (s: Stack): StackClient => s], ['ScopedStack', (s: Stack): StackClient => s.asEntity(OWNER)], diff --git a/packages/core/tests/validate.test.ts b/packages/core/tests/validate.test.ts index 37059325..a9982126 100644 --- a/packages/core/tests/validate.test.ts +++ b/packages/core/tests/validate.test.ts @@ -666,7 +666,9 @@ describe('enum', () => { test('a stored enum with no values list refuses every value rather than throwing', () => { const malformed = { status: { kind: 'enum' } } as unknown as TypeSchema; - expect(paths({ status: 'want' }, malformed)).toEqual(['status']); + expect(errorsFor({ status: 'want' }, malformed)).toEqual([ + { path: 'status', message: 'The schema declares no enum values, so "want" is not allowed' }, + ]); }); }); @@ -707,7 +709,7 @@ describe('validateSchemaShape enum', () => { test('an enum key on any field is refused, not ignored', () => { expect(messages('{"s": {"kind": "string", "enum": ["a"]}}')).toEqual([ - '"enum" is not a field modifier; use { kind: "enum", values }', + '"enum" is a field kind, not a field key', ]); }); }); From 70a1f5cb5a60dca43556e3c3aae77a1b1c508e07 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 14:12:24 +0000 Subject: [PATCH 4/8] fix(core): fail closed on unknown kinds and empty enums in isCompatible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isFieldCompatible no longer throws on a required field whose kind is outside the union, and an enum candidate with an empty values list is incompatible, matching one with no list. - The malformed-enum guards share one helper (declaredEnumValues), and the 422 and 409 messages share formatEnumValues. - hashSchema keeps a non-array values entry, so it no longer hashes the same as a missing one. - The adapter conformance suite checks that an enum field sorts by value. - Spec § Type handles and unknownEnumValues say that widening an enum to a string in place makes typed reads refuse unlisted strings. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGpKBNfBMZv8ijfQwoEqyK --- .changeset/enum-field-kind.md | 3 +- docs/spec/data-model.md | 2 +- packages/adapter-conformance/src/record.ts | 23 +++++++++++++++ packages/core/src/schema.ts | 33 +++++++++++----------- packages/core/src/type-handle.ts | 5 ++-- packages/core/src/validate.ts | 27 +++++++++++------- packages/core/tests/schema.test.ts | 18 ++++++++++++ 7 files changed, 80 insertions(+), 31 deletions(-) diff --git a/.changeset/enum-field-kind.md b/.changeset/enum-field-kind.md index 96ec4576..8ba3ed8e 100644 --- a/.changeset/enum-field-kind.md +++ b/.changeset/enum-field-kind.md @@ -1,6 +1,7 @@ --- '@haverstack/core': minor '@haverstack/conformance-fixtures': minor +'@haverstack/adapter-conformance': patch --- -`enum` is a field kind: `{ kind: 'enum', values: [...] }` replaces `{ kind: 'string', enum: [...] }`. `defineType()` refuses the `enum` key on any field. `isCompatible()` now checks enums: a required enum accepts only an enum candidate whose values it lists all of, and a required `string` or `text` accepts an enum. An enum becoming a plain `string` is additive; a `string` becoming an enum needs a version bump. Conformance fixtures pin a 422 for a value outside the list and a 409 for removing values. +`enum` is a field kind: `{ kind: 'enum', values: [...] }` replaces `{ kind: 'string', enum: [...] }`. `defineType()` refuses the `enum` key on any field. `isCompatible()` now checks enums: a required enum accepts only an enum candidate whose values it lists all of, and a required `string` or `text` accepts an enum. An enum becoming a plain `string` is additive; a `string` becoming an enum needs a version bump. Conformance fixtures pin a 422 for a value outside the list and a 409 for removing values. The adapter conformance suite checks that an enum field sorts by value. diff --git a/docs/spec/data-model.md b/docs/spec/data-model.md index 9b2ff3cb..4b2a5888 100644 --- a/docs/spec/data-model.md +++ b/docs/spec/data-model.md @@ -412,7 +412,7 @@ A handle names exactly one version. A call that means the whole family takes `ha - reads at [`presentAt: 'latest'`](#type-migrations), so the stale-writer error applies; - throws `StackBadRequestError` unless the record's `typeId` is exactly the handle's `id`, so a record of another Type, or one whose family has moved past the handle's version, is refused rather than cast; -- throws `StackValidationError` when an `enum` field holds a value the handle's schema does not list. An enum may gain values within a version ([additive evolution](#additive-evolution-within-a-version)), so a reader older than the writer can meet one; throwing keeps the derived union exact, and the reader fails loudly until it upgrades; +- throws `StackValidationError` when an `enum` field holds a value the handle's schema does not list. An enum may gain values, or become a plain `string`, within a version ([additive evolution](#additive-evolution-within-a-version)), so a reader older than the writer can meet any string there, and once another app widens the field, a typed query fails on the first record holding a value the handle does not list. Throwing keeps the derived union exact, and the reader fails loudly until it upgrades; - sees live records only. `includeDeleted` is not offered and is refused at runtime, so a tombstone, whose `content` is `{}`, is never typed as the handle's content. A caller who wants tombstones uses the untyped read. A typed `subscribe()` delivers only changes to records of exactly the handle's `id`, with `record`, when present, typed as the content. An event carries its record as stored, which a subscription cannot migrate, so other versions of the family are not delivered. A record holding an enum value the handle does not list arrives without its `record`, as it does wherever the emitter cannot supply one; the typed `get()` then says why. The change filter takes every key but `typeId` and `baseId`. diff --git a/packages/adapter-conformance/src/record.ts b/packages/adapter-conformance/src/record.ts index 1dbd64ce..f8c55127 100644 --- a/packages/adapter-conformance/src/record.ts +++ b/packages/adapter-conformance/src/record.ts @@ -1028,6 +1028,29 @@ export function runRecordAdapterConformance(options: RecordAdapterConformanceOpt 1, 2, 3, ]); }); + + test('orders an enum field by value, not by its position in values', async () => { + await adapter.saveType( + conformanceType({ + schema: { + title: { kind: 'string' }, + status: { kind: 'enum', values: ['want', 'reading', 'finished'] }, + }, + }), + ); + for (const status of ['want', 'finished', 'reading']) { + await adapter.createRecord(makeRecord({ content: { title: status, status } })); + } + + const result = await adapter.queryRecords({ + sort: { contentField: 'status', direction: 'asc' }, + }); + expect(result.records.map((r) => (r.content as { status: string }).status)).toEqual([ + 'finished', + 'reading', + 'want', + ]); + }); }); } diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index f093ba11..3f804578 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -11,7 +11,7 @@ import type { TypeSchema, FieldDef, ScalarFieldKind } from './types.js'; import type { ReadonlyTypeSchema } from './type-handle.js'; -import { enumAllows } from './validate.js'; +import { declaredEnumValues, enumAllows, formatEnumValues } from './validate.js'; // ------------------------------------------------------- // Canonical schema serialization @@ -60,11 +60,13 @@ const canonicalizeFieldDef = (def: FieldDef): unknown => { } // Scalar. An enum's `values` are sorted so reordering them is not a change. - // A stored enum may lack the list; it hashes as one that allows nothing. + // A malformed stored list is hashed as it is, so it never matches a + // different malformed one, or a def with no list at all. + const values = (def as { values?: unknown }).values; return { kind: def.kind, ...(def.required !== undefined && { required: def.required }), - ...(def.kind === 'enum' && Array.isArray(def.values) && { values: [...def.values].sort() }), + ...(values !== undefined && { values: Array.isArray(values) ? [...values].sort() : values }), }; }; @@ -153,15 +155,16 @@ const isFieldCompatible = (candidate: FieldDef, required: FieldDef, depth: numbe } if (candidate.kind === 'array' || candidate.kind === 'object') return false; // A consumer expecting an enum can handle only the values it lists. An - // enum candidate with no `values` list promises nothing, so it fails closed. + // enum candidate with a missing or empty `values` list promises nothing, + // so it fails closed. if (required.kind === 'enum') { - return ( - candidate.kind === 'enum' && - Array.isArray(candidate.values) && - candidate.values.every((v) => enumAllows(required.values, v)) - ); + if (candidate.kind !== 'enum') return false; + const values = declaredEnumValues(candidate.values); + return values.length > 0 && values.every((v) => enumAllows(required.values, v)); } - return READ_COMPATIBLE[required.kind].includes(candidate.kind); + // A required kind outside the union (a foreign or unvalidated schema) can't + // be checked, so it fails closed too. + return (READ_COMPATIBLE[required.kind] ?? []).includes(candidate.kind); }; const isCompatibleAtDepth = ( @@ -239,14 +242,10 @@ const diffField = ( } // Losing a value narrows what an enum accepts; gaining one widens it. if (stored.kind === 'enum' && candidate.kind === 'enum') { - // A stored Type is not re-validated, so its enum may lack the list. - const kept = new Set(candidate.values); - const removed = (Array.isArray(stored.values) ? stored.values : []).filter((v) => !kept.has(v)); + const kept = new Set(declaredEnumValues(candidate.values)); + const removed = declaredEnumValues(stored.values).filter((v) => !kept.has(v)); if (removed.length > 0) { - violations.push({ - path, - message: `enum values removed: ${removed.map((v) => JSON.stringify(v)).join(', ')}`, - }); + violations.push({ path, message: `enum values removed: ${formatEnumValues(removed)}` }); } } // Opening a declared container, or closing an open one, changes which diff --git a/packages/core/src/type-handle.ts b/packages/core/src/type-handle.ts index cd9f073b..0eede22b 100644 --- a/packages/core/src/type-handle.ts +++ b/packages/core/src/type-handle.ts @@ -264,8 +264,9 @@ export type TypedChangeSet = Omit - Array.isArray(values) && values.includes(value as string); +export const declaredEnumValues = (values: unknown): readonly string[] => + Array.isArray(values) ? (values as string[]) : []; -export const enumMismatchMessage = ( - values: readonly string[] | undefined, - value: unknown, -): string => - Array.isArray(values) && values.length > 0 - ? `Expected one of ${values.map((v) => JSON.stringify(v)).join(', ')}, got ${JSON.stringify(value)}` +export const enumAllows = (values: unknown, value: unknown): boolean => + declaredEnumValues(values).includes(value as string); + +/** Shared by the 422 for a value outside the list and the 409 for removed values. */ +export const formatEnumValues = (values: readonly string[]): string => + values.map((v) => JSON.stringify(v)).join(', '); + +export const enumMismatchMessage = (values: unknown, value: unknown): string => { + const allowed = declaredEnumValues(values); + return allowed.length > 0 + ? `Expected one of ${formatEnumValues(allowed)}, got ${JSON.stringify(value)}` : `The schema declares no enum values, so ${JSON.stringify(value)} is not allowed`; +}; const jsTypeForScalar = (kind: ScalarFieldKind): string => { switch (kind) { diff --git a/packages/core/tests/schema.test.ts b/packages/core/tests/schema.test.ts index 4b63a3fc..8e331a29 100644 --- a/packages/core/tests/schema.test.ts +++ b/packages/core/tests/schema.test.ts @@ -324,6 +324,14 @@ describe('hashSchema enum', () => { const malformed = { s: { kind: 'enum' } } as unknown as TypeSchema; await expect(hashSchema(malformed)).resolves.toMatch(/\S/); }); + + test('a malformed values entry hashes apart from a missing one', async () => { + const missing = await hashSchema({ s: { kind: 'enum' } } as unknown as TypeSchema); + const scalar = await hashSchema({ + s: { kind: 'enum', values: 'a,b' }, + } as unknown as TypeSchema); + expect(scalar).not.toBe(missing); + }); }); describe('isCompatible enum', () => { @@ -345,6 +353,16 @@ describe('isCompatible enum', () => { expect(isCompatible(malformed, abc)).toBe(false); }); + test('a candidate enum with an empty values list is incompatible', () => { + const empty = { s: { kind: 'enum', values: [], required: true } } as unknown as TypeSchema; + expect(isCompatible(empty, abc)).toBe(false); + }); + + test('a required field of unknown kind is incompatible, not a throw', () => { + const unknown = { s: { kind: 'nonsense', required: true } } as unknown as TypeSchema; + expect(isCompatible(field({ kind: 'string' }), unknown)).toBe(false); + }); + test('a required enum refuses a string or text candidate, which may hold any value', () => { expect(isCompatible(field({ kind: 'string' }), abc)).toBe(false); expect(isCompatible(field({ kind: 'text' }), abc)).toBe(false); From 1e70e9d58b97eb2a8f6fbc55218b9980ad70ad29 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 14:22:16 +0000 Subject: [PATCH 5/8] fix(core): harden enum compatibility, hashing, sorting and typed writes - isCompatible checks READ_COMPATIBLE with Object.hasOwn, so a foreign kind named after an Object.prototype key fails closed instead of throwing. - canonicalizeFieldDef hashes `values` only on an enum, matching what validation enforces. - The enum subset check builds the required set once. - contentSortEntry orders a kind outside the union as text, so existing index rows and cursor positions agree regardless of whether the reader recognizes the kind. - A typed write checks the content it would leave against the handle's enum values before anything lands, rather than committing and then throwing from narrowRecord. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01265RetnERff8vRLus836Zk --- docs/spec/data-model.md | 4 ++-- packages/core/src/schema.ts | 12 ++++++++---- packages/core/src/sort.ts | 7 ++++--- packages/core/src/stack.ts | 8 +++++++- packages/core/src/type-handle.ts | 15 ++++++++++++--- packages/core/src/write-expectation.ts | 10 ++++++++++ packages/core/tests/schema.test.ts | 12 ++++++++++++ packages/core/tests/sort.test.ts | 6 ++++-- packages/core/tests/type-handle.test.ts | 17 +++++++++++++++++ 9 files changed, 76 insertions(+), 15 deletions(-) diff --git a/docs/spec/data-model.md b/docs/spec/data-model.md index 4b2a5888..0d821376 100644 --- a/docs/spec/data-model.md +++ b/docs/spec/data-model.md @@ -417,7 +417,7 @@ A handle names exactly one version. A call that means the whole family takes `ha A typed `subscribe()` delivers only changes to records of exactly the handle's `id`, with `record`, when present, typed as the content. An event carries its record as stored, which a subscription cannot migrate, so other versions of the family are not delivered. A record holding an enum value the handle does not list arrives without its `record`, as it does wherever the emitter cannot supply one; the typed `get()` then says why. The change filter takes every key but `typeId` and `baseId`. -A typed `query()` matches the handle's whole family, then applies the checks above to every record. A typed write is checked against the read the write already makes, before anything lands. A record of another family is not found. A record of the family stored at another `typeId` is refused with `StackBadRequestError`, since a patch is validated against the record's own stored Type and the result could not be typed; a deleted record, or a stale `ifVersion`, is refused first, as the untyped write refuses it. +A typed `query()` matches the handle's whole family, then applies the checks above to every record. A typed write is checked against the read the write already makes, before anything lands. A record of another family is not found. A record of the family stored at another `typeId` is refused with `StackBadRequestError`, since a patch is validated against the record's own stored Type and the result could not be typed. A write whose resulting content holds an enum value the handle does not list is refused with `StackValidationError`, so a typed write never lands a record its own typed read would refuse. A deleted record, or a stale `ifVersion`, is refused first, as the untyped write refuses it. The derived types are a convenience over [runtime validation](#types), which stays the guarantee. A Type known only at runtime has no static shape and stays `Record`. @@ -635,7 +635,7 @@ A caller that genuinely needs a count follows `cursor` to exhaustion and counts **Every top-level scalar is sortable, with no per-field opt-in.** A `sortable` marker in `TypeSchema` would change the schema hash, so making an existing field sortable would run the [schema drift](#schema-drift-detection) path for a change that alters no stored shape. The write cost is bounded by the number of top-level scalars on a Type. -**A field orders as the kind its schema declares, not as the value a Record happens to hold.** `number` and `boolean` order numerically (false before true), `date` as epoch milliseconds — so a date field orders the way `createdAt` does rather than by ISO string collation — and every string-shaped kind as text. An `enum` is one of those: it orders by value, not by its position in `values`, which the [schema hash](#schema-drift-detection) does not preserve. A value that doesn't match its declared kind orders as nothing. So does every value of a field whose declared kind is not one [§ Types](#types) defines, which a foreign or unvalidated stored schema can carry. Deriving from the schema is what keeps an order stable across Records that spell a field differently. +**A field orders as the kind its schema declares, not as the value a Record happens to hold.** `number` and `boolean` order numerically (false before true), `date` as epoch milliseconds — so a date field orders the way `createdAt` does rather than by ISO string collation — and every string-shaped kind as text. An `enum` is one of those: it orders by value, not by its position in `values`, which the [schema hash](#schema-drift-detection) does not preserve. A value that doesn't match its declared kind orders as nothing. A field whose declared kind is not one [§ Types](#types) defines, which a foreign or unvalidated stored schema can carry, orders as text, so whether a reader recognizes a kind never changes where a Record sorts. Deriving from the schema is what keeps an order stable across Records that spell a field differently. **A `date` holding no UTC offset is read as UTC.** The offset is optional in the [shape a `date` accepts](#types), and resolving an offset-less date-time in whatever zone the host runs in would make the order depend on the machine: an index written on one host and a cursor re-derived on another would disagree, dropping or repeating a Record at a page boundary. UTC is also what a date-only value already resolves to, so `2020-03-01` and `2020-03-01T00:00:00` order alike. diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 3f804578..91d4d662 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -11,7 +11,7 @@ import type { TypeSchema, FieldDef, ScalarFieldKind } from './types.js'; import type { ReadonlyTypeSchema } from './type-handle.js'; -import { declaredEnumValues, enumAllows, formatEnumValues } from './validate.js'; +import { declaredEnumValues, formatEnumValues } from './validate.js'; // ------------------------------------------------------- // Canonical schema serialization @@ -62,7 +62,7 @@ const canonicalizeFieldDef = (def: FieldDef): unknown => { // Scalar. An enum's `values` are sorted so reordering them is not a change. // A malformed stored list is hashed as it is, so it never matches a // different malformed one, or a def with no list at all. - const values = (def as { values?: unknown }).values; + const values = def.kind === 'enum' ? (def.values as unknown) : undefined; return { kind: def.kind, ...(def.required !== undefined && { required: def.required }), @@ -160,11 +160,15 @@ const isFieldCompatible = (candidate: FieldDef, required: FieldDef, depth: numbe if (required.kind === 'enum') { if (candidate.kind !== 'enum') return false; const values = declaredEnumValues(candidate.values); - return values.length > 0 && values.every((v) => enumAllows(required.values, v)); + const allowed = new Set(declaredEnumValues(required.values)); + return values.length > 0 && values.every((v) => allowed.has(v)); } // A required kind outside the union (a foreign or unvalidated schema) can't // be checked, so it fails closed too. - return (READ_COMPATIBLE[required.kind] ?? []).includes(candidate.kind); + return ( + Object.hasOwn(READ_COMPATIBLE, required.kind) && + READ_COMPATIBLE[required.kind].includes(candidate.kind) + ); }; const isCompatibleAtDepth = ( diff --git a/packages/core/src/sort.ts b/packages/core/src/sort.ts index 0592b8de..04ca1bfa 100644 --- a/packages/core/src/sort.ts +++ b/packages/core/src/sort.ts @@ -70,17 +70,18 @@ export function contentSortEntry(kind: ScalarFieldKind, value: unknown): SortEnt const ms = Date.parse(ZONELESS_DATE_TIME_RE.test(value) ? `${value}Z` : value); return Number.isNaN(ms) ? null : { kind: 'num', num: ms }; } + // A stored schema is not re-validated, so its kind may be outside the + // union. It orders as text, so an index entry never depends on whether + // the reader recognizes the kind. case 'string': case 'text': case 'record-ref': case 'file-ref': case 'enum': + default: return typeof value === 'string' ? { kind: 'text', text: value, key: contentSortKey(value) } : null; - default: - // A stored schema is not re-validated, so its kind may be outside the union. - return null; } } diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index 03616cdb..5becad17 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -180,7 +180,12 @@ import { typedQuery, typedSubscribe, } from './type-handle.js'; -import { checkFamily, checkStoredVersion, WRITE_EXPECTATION } from './write-expectation.js'; +import { + checkFamily, + checkResultContent, + checkStoredVersion, + WRITE_EXPECTATION, +} from './write-expectation.js'; import type { ExpectationOptions } from './write-expectation.js'; import type { ContentOf, @@ -1305,6 +1310,7 @@ export class Stack implements StackClient { checkStoredVersion(existing, opts, changes.contentPatch !== undefined); const merged = await this.validateChangeSet(id, existing, changes); + checkResultContent(merged ?? existing.content, opts); const ops = changeSetOps(existing, changes, merged); // Nothing moved, so there is nothing to version. Decided here rather diff --git a/packages/core/src/type-handle.ts b/packages/core/src/type-handle.ts index 0eede22b..2da8c270 100644 --- a/packages/core/src/type-handle.ts +++ b/packages/core/src/type-handle.ts @@ -314,10 +314,14 @@ export const narrowRecord = ( if (record.typeId !== handle.id) { throw new StackBadRequestError(`Record "${record.id}" is ${record.typeId}, not ${handle.id}`); } + assertEnumsListed(handle, record.content); + return record as TypedRecord; +}; + +const assertEnumsListed = (handle: TypeHandle, content: Record): void => { const errors: ValidationError[] = []; - walkEnums(record.content, handle.schema, '', errors); + walkEnums(content, handle.schema, '', errors); if (errors.length > 0) throw new StackValidationError(errors); - return record as TypedRecord; }; /** The ways a typed read can be asked to see a tombstone. */ @@ -377,7 +381,12 @@ export const typedMutate = async ( // Checked against the read the write already makes, so nothing lands on // a record the handle does not type. See docs/spec/data-model.md § Type handles. const expect = { - [WRITE_EXPECTATION]: { baseId: handle.baseId, typeId: handle.id, exact: true }, + [WRITE_EXPECTATION]: { + baseId: handle.baseId, + typeId: handle.id, + exact: true, + checkContent: (content: Record) => assertEnumsListed(handle, content), + }, }; try { return narrowRecord(handle, await client.mutate(id, changes, { ...opts, ...expect } as never)); diff --git a/packages/core/src/write-expectation.ts b/packages/core/src/write-expectation.ts index a8695e53..0632691e 100644 --- a/packages/core/src/write-expectation.ts +++ b/packages/core/src/write-expectation.ts @@ -28,6 +28,8 @@ export type WriteExpectation = { readonly typeId?: TypeId; /** Hold every write to `typeId`, as a typed handle's write is. */ readonly exact?: boolean; + /** Throws if the content the write would leave can't be returned typed. */ + readonly checkContent?: (content: Record) => void; }; export type ExpectationOptions = { readonly [WRITE_EXPECTATION]?: WriteExpectation }; @@ -78,3 +80,11 @@ export const storedVersionDiffers = ( if (expected?.typeId === undefined || record.typeId === expected.typeId) return false; return patchesContent || expected.exact === true; }; + +/** The content a write would leave, checked after validation and before it lands. */ +export const checkResultContent = ( + content: Record, + opts: ExpectationOptions | undefined, +): void => { + opts?.[WRITE_EXPECTATION]?.checkContent?.(content); +}; diff --git a/packages/core/tests/schema.test.ts b/packages/core/tests/schema.test.ts index 8e331a29..a55c6871 100644 --- a/packages/core/tests/schema.test.ts +++ b/packages/core/tests/schema.test.ts @@ -54,6 +54,11 @@ describe('hashSchema', () => { expect(h1).not.toBe(h2); }); + test('values on a non-enum kind do not affect the hash, as validation ignores them', async () => { + const stray = { s: { kind: 'string', values: ['x'] } } as unknown as TypeSchema; + expect(await hashSchema(stray)).toBe(await hashSchema({ s: { kind: 'string' } })); + }); + test('adding a field produces a different hash', async () => { const h1 = await hashSchema({ text: { kind: 'text', required: true } }); const h2 = await hashSchema({ @@ -363,6 +368,13 @@ describe('isCompatible enum', () => { expect(isCompatible(field({ kind: 'string' }), unknown)).toBe(false); }); + test('a required field whose kind names an Object.prototype key is incompatible', () => { + for (const kind of ['toString', 'constructor', '__proto__']) { + const odd = { s: { kind, required: true } } as unknown as TypeSchema; + expect(isCompatible(field({ kind: 'string' }), odd)).toBe(false); + } + }); + test('a required enum refuses a string or text candidate, which may hold any value', () => { expect(isCompatible(field({ kind: 'string' }), abc)).toBe(false); expect(isCompatible(field({ kind: 'text' }), abc)).toBe(false); diff --git a/packages/core/tests/sort.test.ts b/packages/core/tests/sort.test.ts index d952b070..e676cc35 100644 --- a/packages/core/tests/sort.test.ts +++ b/packages/core/tests/sort.test.ts @@ -35,8 +35,10 @@ describe('contentSortKey', () => { }); describe('contentSortEntry', () => { - test('a kind outside the union orders as nothing', () => { - expect(contentSortEntry('nonsense' as ScalarFieldKind, 'x')).toBeNull(); + test('a kind outside the union orders as text', () => { + expect(contentSortEntry('nonsense' as ScalarFieldKind, 'x')).toEqual( + contentSortEntry('string', 'x'), + ); }); test('reads a date as epoch milliseconds, not as an ISO string', () => { diff --git a/packages/core/tests/type-handle.test.ts b/packages/core/tests/type-handle.test.ts index ed68f9bf..78085763 100644 --- a/packages/core/tests/type-handle.test.ts +++ b/packages/core/tests/type-handle.test.ts @@ -342,6 +342,23 @@ describe.each([ await expect(client.get(Book, book.id)).rejects.toThrow(StackValidationError); }); + test('mutate() refuses before landing a write that would leave an unlisted enum value', async () => { + const book = await client.create(Book, { title: 'Dune', status: 'want' }); + await stack.defineType({ + id: Book.id, + name: 'Book', + schema: { ...Book.schema, status: { kind: 'string', required: true } }, + }); + await stack.patchContent(book.id, { status: 'paused' }); + await expect(client.patchContent(Book, book.id, { title: 'Emma' })).rejects.toThrow( + StackValidationError, + ); + expect((await stack.get(book.id))?.content).toEqual({ title: 'Dune', status: 'paused' }); + + const fixed = await client.patchContent(Book, book.id, { title: 'Emma', status: 'finished' }); + expect(fixed.content).toEqual({ title: 'Emma', status: 'finished' }); + }); + test('get() reads a tombstone as null', async () => { const book = await client.create(Book, { title: 'Dune', status: 'want' }); await client.delete(book.id); From 2216fa5d3e7e8e0efd3059d337b5d8e6be163b0f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 15:44:07 +0000 Subject: [PATCH 6/8] fix(core): trust stored schemas and read unlisted enum values as strings Two design questions behind the earlier fix rounds on this PR, settled: - Data is validated where it enters (public API calls, wire requests) and trusted inside. Stored Types from an adapter are not re-guarded, so the malformed-enum helpers, the unknown-kind sort and compatibility branches, and the malformed-values hashing go. The rule is recorded in AGENTS.md and CONTRIBUTING.md. - An enum can widen in place, so a typed read cannot promise a closed union. TypedRecord content is StoredContentOf, which reads an enum as its listed values or UnlistedValue (string & {}). Typed writes still take only the listed values. The enum walk on typed reads, the pre-write checkContent expectation and the subscribe record-withholding go with it. Also exports EnumFieldDef, and contentSortEntry's switch is exhaustive over ScalarFieldKind. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BHk2jVPk24WLxWVGekNdqY --- .changeset/enum-field-kind.md | 2 +- AGENTS.md | 1 + CONTRIBUTING.md | 2 + docs/spec/data-model.md | 14 +-- packages/core/src/index.ts | 3 + packages/core/src/schema.ts | 27 ++--- packages/core/src/sort.ts | 4 - packages/core/src/stack.ts | 8 +- packages/core/src/type-handle.ts | 131 ++++++++---------------- packages/core/src/validate.ts | 25 +---- packages/core/src/write-expectation.ts | 10 -- packages/core/tests/schema.test.ts | 40 -------- packages/core/tests/sort.test.ts | 7 -- packages/core/tests/type-handle.test.ts | 85 +++++++++------ packages/core/tests/validate.test.ts | 7 -- 15 files changed, 126 insertions(+), 240 deletions(-) diff --git a/.changeset/enum-field-kind.md b/.changeset/enum-field-kind.md index 8ba3ed8e..92a0524c 100644 --- a/.changeset/enum-field-kind.md +++ b/.changeset/enum-field-kind.md @@ -4,4 +4,4 @@ '@haverstack/adapter-conformance': patch --- -`enum` is a field kind: `{ kind: 'enum', values: [...] }` replaces `{ kind: 'string', enum: [...] }`. `defineType()` refuses the `enum` key on any field. `isCompatible()` now checks enums: a required enum accepts only an enum candidate whose values it lists all of, and a required `string` or `text` accepts an enum. An enum becoming a plain `string` is additive; a `string` becoming an enum needs a version bump. Conformance fixtures pin a 422 for a value outside the list and a 409 for removing values. The adapter conformance suite checks that an enum field sorts by value. +`enum` is a field kind: `{ kind: 'enum', values: [...] }` replaces `{ kind: 'string', enum: [...] }`. `defineType()` refuses the `enum` key on any field. `isCompatible()` now checks enums: a required enum accepts only an enum candidate whose values it lists all of, and a required `string` or `text` accepts an enum. An enum becoming a plain `string` is additive; a `string` becoming an enum needs a version bump. A typed read types an enum field as its listed values or any other string (`StoredContentOf`, `UnlistedValue`) and no longer throws on an unlisted value, since an enum can widen in place; typed writes still take only the listed values (`ContentOf`, `PatchOf`). Conformance fixtures pin a 422 for a value outside the list and a 409 for removing values. The adapter conformance suite checks that an enum field sorts by value. diff --git a/AGENTS.md b/AGENTS.md index 8fdcf5ab..a1d6c367 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,6 +68,7 @@ This is about _our_ history. Genuinely foreign input — a third-party server's ## Architecture rules - **`Stack` is the invariant layer; adapters are storage engines.** Validation, `_config` protection, ID rules, migration policy, and permission checks live in `packages/core` so every adapter inherits them. Don't reimplement an invariant inside an adapter. (The one documented exception: the `_config` query exclusion must live in each adapter's own query predicate.) +- **Validate where data enters; trust it inside.** Public API calls and wire requests are checked once. A stored Type or Record from an adapter is trusted to match its type: don't guard for a hand-edited store (a missing `values` list, an unknown `kind`). An adapter reading a foreign source validates what it hands to `Stack`. See [CONTRIBUTING.md § Architecture conventions](./CONTRIBUTING.md#architecture-conventions). - **Package prefixes declare the contract**: `adapter-*` = full `StackAdapter`, `record-adapter-*` = `StackRecordAdapter`, `blob-adapter-*` = `StackBlobAdapter`. - **Optional capabilities are optional methods**, checked for truthiness at the call site with a documented fallback — never a boolean in `capabilities`. - **Shared SQLite logic goes in `@haverstack/sqlite-shared`**, not duplicated across adapters. It is `private` and bundled into its consumers at build time, so it is never a `dependencies` entry and adding an export to it does not widen any package's public API. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bb1ddfb7..bf3e9e9f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -233,6 +233,8 @@ Note that this is about _our_ history, not about inputs we genuinely receive. Ha **`Stack` is the invariant layer; adapters are storage engines.** Validation, `_config` protection, ID rules, migration policy, and permission logic live in `packages/core` so every adapter inherits them. An adapter that reimplements an invariant is a bug waiting to diverge. The deliberate exception is documented in the spec: the `_config` query exclusion must live in each adapter's own query predicate, because post-filtering in `Stack` would under-fill a page. +**Validate where data enters; trust it inside.** Input is checked once, at the boundary it crosses: a public API call such as `defineType()` or `create()`, or a wire request a server decodes. Past that point the types are true, and code reads a stored Type or Record as its type says. Data an adapter returns is inside: a store that holds something core never wrote was edited by hand, and guarding for that in every reader costs more than it buys. Each guard for malformed stored data also forces a policy choice (does a missing list allow nothing? does an unknown kind sort as text?), and those choices drift apart between call sites. A foreign source, such as a third-party server, is an edge, and the adapter that reads it validates what it hands to `Stack`. + **Package naming declares the adapter contract**, per [`docs/spec/adapters.md`](./docs/spec/adapters.md): | Prefix | Implements | diff --git a/docs/spec/data-model.md b/docs/spec/data-model.md index 0d821376..756cc596 100644 --- a/docs/spec/data-model.md +++ b/docs/spec/data-model.md @@ -358,7 +358,7 @@ A field's kind is read-compatible with a required kind per this table (row = req `string` and `text` are mutually read-compatible — 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. -**An enum reads as a string, and a required enum accepts only a narrower enum** (⊆ above): a candidate `enum` whose `values` are all in the required list. A consumer expecting an enum has handled exactly the values it lists, so a `string` or `text` candidate, which can hold anything, is refused, as is an enum listing a value the consumer does not. The check is as of the Type it is given: an enum may [gain values in place](#additive-evolution-within-a-version), or become a plain `string`, so a candidate compatible today can later hold a value the consumer does not list. A [typed read](#type-handles) refuses such a value, but only for a handle naming that Type's `id`; a consumer reading a foreign Type by compatibility re-checks with `isCompatible()` or handles values it does not list. +**An enum reads as a string, and a required enum accepts only a narrower enum** (⊆ above): a candidate `enum` whose `values` are all in the required list. A consumer expecting an enum has handled exactly the values it lists, so a `string` or `text` candidate, which can hold anything, is refused, as is an enum listing a value the consumer does not. The check is as of the Type it is given: an enum may [gain values in place](#additive-evolution-within-a-version), or become a plain `string`, so a candidate compatible today can later hold a value the consumer does not list. A consumer handles such a value, as a [typed read](#type-handles) does. Apps that care about semantics filter by exact `typeId`; apps that want flexibility (e.g. "any Type with a required `text` field") 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. @@ -379,7 +379,7 @@ const noteV2ToV3 = migration(NoteV2, NoteV3, (content) => ({ ...content, pinned: const stack = await Stack.open(adapter, { migrations: [noteV1ToV2, noteV2ToV3] }); ``` -`migration()` takes two [type handles](#type-handles), so `fn` is checked as `ContentOf` the first to `ContentOf` the second: a step that drops a required field or yields a value outside an enum's `values` does not compile. Within a family it refuses a `to` whose `migratesFrom` is not `from.id`, so a step cannot be paired with the wrong handle. +`migration()` takes two [type handles](#type-handles), so `fn` is checked as `ContentOf` the first to `ContentOf` the second: a step that drops a required field or yields a value outside an enum's `values` does not compile. The source is typed as written rather than as `StoredContentOf`, so an identity step compiles; a source value the handle does not list reaches the target's runtime validation when `migrateAll()` writes it. Within a family it refuses a `to` whose `migratesFrom` is not `from.id`, so a step cannot be paired with the wrong handle. **Lineage stays inside a family; a migration can leave it.** `migratesFrom` names an earlier version of the same family, and `typeHandle()` and `defineType()` refuse anything else with `StackBadRequestError`. A step whose `from` and `to` are in different families has no lineage to check, so `migration()` builds it from the two handles alone. This is how an app's own type moves into a shared one — a [commons](../commons/README.md) type, say — without the shared handle naming every family that has ever migrated into it. A migration is a separate value from either handle: a handle is data a manifest carries over the wire, and may be shared by apps that do not run its family's migrations. @@ -402,7 +402,7 @@ The migration registry is **per-stack-instance** and lives in memory — differe ### Type handles -A schema written once as a literal gives the compiler everything it needs to type content. `typeHandle({ id, name, schema, migratesFrom })` returns a plain, frozen value carrying the fields `defineType()` takes — the `TypeId` (`id`), the display `name`, the `schema` and, for a version after the first, `migratesFrom` — plus its family (`baseId`). `migratesFrom` may be given as the previous version's handle or its `TypeId`, and is stored as the `TypeId`. `ContentOf` derives the content type from the schema and `PatchOf` the `contentPatch` type. A required field is present in `ContentOf`; every other field is optional. `PatchOf` makes every field optional and allows `null` only on a field that is not required, since a required field can be replaced but not removed. +A schema written once as a literal gives the compiler everything it needs to type content. `typeHandle({ id, name, schema, migratesFrom })` returns a plain, frozen value carrying the fields `defineType()` takes — the `TypeId` (`id`), the display `name`, the `schema` and, for a version after the first, `migratesFrom` — plus its family (`baseId`). `migratesFrom` may be given as the previous version's handle or its `TypeId`, and is stored as the `TypeId`. `ContentOf` derives the content type a write supplies, `StoredContentOf` the one a read returns, and `PatchOf` the `contentPatch` type. A required field is present in `ContentOf`; every other field is optional. `PatchOf` makes every field optional and allows `null` only on a field that is not required, since a required field can be replaced but not removed. A handle is a `DefineTypeOptions`, so `defineType(handle)` and a manifest's `types` take it as written, and `name` and `migratesFrom` are written once. It carries no migration function, which keeps it data: see [Type migrations](#type-migrations). @@ -412,12 +412,12 @@ A handle names exactly one version. A call that means the whole family takes `ha - reads at [`presentAt: 'latest'`](#type-migrations), so the stale-writer error applies; - throws `StackBadRequestError` unless the record's `typeId` is exactly the handle's `id`, so a record of another Type, or one whose family has moved past the handle's version, is refused rather than cast; -- throws `StackValidationError` when an `enum` field holds a value the handle's schema does not list. An enum may gain values, or become a plain `string`, within a version ([additive evolution](#additive-evolution-within-a-version)), so a reader older than the writer can meet any string there, and once another app widens the field, a typed query fails on the first record holding a value the handle does not list. Throwing keeps the derived union exact, and the reader fails loudly until it upgrades; +- types an `enum` field as its listed values or any other string (`'want' | 'reading' | 'finished' | UnlistedValue`). An enum may gain values, or become a plain `string`, within a version ([additive evolution](#additive-evolution-within-a-version)), so a reader older than the writer can meet any string there. Records are shared across apps that upgrade independently, so a closed union would promise what no handle can check; the reader handles a value it does not list, as a `default` branch does; - sees live records only. `includeDeleted` is not offered and is refused at runtime, so a tombstone, whose `content` is `{}`, is never typed as the handle's content. A caller who wants tombstones uses the untyped read. -A typed `subscribe()` delivers only changes to records of exactly the handle's `id`, with `record`, when present, typed as the content. An event carries its record as stored, which a subscription cannot migrate, so other versions of the family are not delivered. A record holding an enum value the handle does not list arrives without its `record`, as it does wherever the emitter cannot supply one; the typed `get()` then says why. The change filter takes every key but `typeId` and `baseId`. +A typed `subscribe()` delivers only changes to records of exactly the handle's `id`, with `record`, when present, typed as the content. An event carries its record as stored, which a subscription cannot migrate, so other versions of the family are not delivered. The change filter takes every key but `typeId` and `baseId`. -A typed `query()` matches the handle's whole family, then applies the checks above to every record. A typed write is checked against the read the write already makes, before anything lands. A record of another family is not found. A record of the family stored at another `typeId` is refused with `StackBadRequestError`, since a patch is validated against the record's own stored Type and the result could not be typed. A write whose resulting content holds an enum value the handle does not list is refused with `StackValidationError`, so a typed write never lands a record its own typed read would refuse. A deleted record, or a stale `ifVersion`, is refused first, as the untyped write refuses it. +A typed `query()` matches the handle's whole family, then applies the checks above to every record. A typed write is checked against the read the write already makes, before anything lands. A record of another family is not found. A record of the family stored at another `typeId` is refused with `StackBadRequestError`, since a patch is validated against the record's own stored Type and the result could not be typed. A typed write takes only the values the handle lists, in `ContentOf` and `PatchOf`; the stored schema's runtime validation decides what lands. A deleted record, or a stale `ifVersion`, is refused first, as the untyped write refuses it. The derived types are a convenience over [runtime validation](#types), which stays the guarantee. A Type known only at runtime has no static shape and stays `Record`. @@ -635,7 +635,7 @@ A caller that genuinely needs a count follows `cursor` to exhaustion and counts **Every top-level scalar is sortable, with no per-field opt-in.** A `sortable` marker in `TypeSchema` would change the schema hash, so making an existing field sortable would run the [schema drift](#schema-drift-detection) path for a change that alters no stored shape. The write cost is bounded by the number of top-level scalars on a Type. -**A field orders as the kind its schema declares, not as the value a Record happens to hold.** `number` and `boolean` order numerically (false before true), `date` as epoch milliseconds — so a date field orders the way `createdAt` does rather than by ISO string collation — and every string-shaped kind as text. An `enum` is one of those: it orders by value, not by its position in `values`, which the [schema hash](#schema-drift-detection) does not preserve. A value that doesn't match its declared kind orders as nothing. A field whose declared kind is not one [§ Types](#types) defines, which a foreign or unvalidated stored schema can carry, orders as text, so whether a reader recognizes a kind never changes where a Record sorts. Deriving from the schema is what keeps an order stable across Records that spell a field differently. +**A field orders as the kind its schema declares, not as the value a Record happens to hold.** `number` and `boolean` order numerically (false before true), `date` as epoch milliseconds — so a date field orders the way `createdAt` does rather than by ISO string collation — and every string-shaped kind as text. An `enum` is one of those: it orders by value, not by its position in `values`, which the [schema hash](#schema-drift-detection) does not preserve. A value that doesn't match its declared kind orders as nothing. Deriving from the schema is what keeps an order stable across Records that spell a field differently. **A `date` holding no UTC offset is read as UTC.** The offset is optional in the [shape a `date` accepts](#types), and resolving an offset-less date-time in whatever zone the host runs in would make the order depend on the machine: an index written on one host and a cursor re-derived on another would disagree, dropping or repeating a Record at a page boundary. UTC is also what a date-only value already resolves to, so `2020-03-01` and `2020-03-01T00:00:00` order alike. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c636ea9c..50eb7df6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -44,6 +44,8 @@ export type { TypeHandle, Migration, ContentOf, + StoredContentOf, + UnlistedValue, PatchOf, TypedRecord, TypedQuery, @@ -101,6 +103,7 @@ export type { TypeSchema, FieldDef, ScalarFieldDef, + EnumFieldDef, ArrayFieldDef, ObjectFieldDef, ScalarFieldKind, diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 91d4d662..75b75067 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -11,7 +11,7 @@ import type { TypeSchema, FieldDef, ScalarFieldKind } from './types.js'; import type { ReadonlyTypeSchema } from './type-handle.js'; -import { declaredEnumValues, formatEnumValues } from './validate.js'; +import { formatEnumValues } from './validate.js'; // ------------------------------------------------------- // Canonical schema serialization @@ -60,13 +60,10 @@ const canonicalizeFieldDef = (def: FieldDef): unknown => { } // Scalar. An enum's `values` are sorted so reordering them is not a change. - // A malformed stored list is hashed as it is, so it never matches a - // different malformed one, or a def with no list at all. - const values = def.kind === 'enum' ? (def.values as unknown) : undefined; return { kind: def.kind, ...(def.required !== undefined && { required: def.required }), - ...(values !== undefined && { values: Array.isArray(values) ? [...values].sort() : values }), + ...(def.kind === 'enum' && { values: [...def.values].sort() }), }; }; @@ -154,21 +151,13 @@ const isFieldCompatible = (candidate: FieldDef, required: FieldDef, depth: numbe ); } if (candidate.kind === 'array' || candidate.kind === 'object') return false; - // A consumer expecting an enum can handle only the values it lists. An - // enum candidate with a missing or empty `values` list promises nothing, - // so it fails closed. + // A consumer expecting an enum can handle only the values it lists. if (required.kind === 'enum') { if (candidate.kind !== 'enum') return false; - const values = declaredEnumValues(candidate.values); - const allowed = new Set(declaredEnumValues(required.values)); - return values.length > 0 && values.every((v) => allowed.has(v)); + const allowed = new Set(required.values); + return candidate.values.every((v) => allowed.has(v)); } - // A required kind outside the union (a foreign or unvalidated schema) can't - // be checked, so it fails closed too. - return ( - Object.hasOwn(READ_COMPATIBLE, required.kind) && - READ_COMPATIBLE[required.kind].includes(candidate.kind) - ); + return READ_COMPATIBLE[required.kind].includes(candidate.kind); }; const isCompatibleAtDepth = ( @@ -246,8 +235,8 @@ const diffField = ( } // Losing a value narrows what an enum accepts; gaining one widens it. if (stored.kind === 'enum' && candidate.kind === 'enum') { - const kept = new Set(declaredEnumValues(candidate.values)); - const removed = declaredEnumValues(stored.values).filter((v) => !kept.has(v)); + const kept = new Set(candidate.values); + const removed = stored.values.filter((v) => !kept.has(v)); if (removed.length > 0) { violations.push({ path, message: `enum values removed: ${formatEnumValues(removed)}` }); } diff --git a/packages/core/src/sort.ts b/packages/core/src/sort.ts index 04ca1bfa..bb632ae8 100644 --- a/packages/core/src/sort.ts +++ b/packages/core/src/sort.ts @@ -70,15 +70,11 @@ export function contentSortEntry(kind: ScalarFieldKind, value: unknown): SortEnt const ms = Date.parse(ZONELESS_DATE_TIME_RE.test(value) ? `${value}Z` : value); return Number.isNaN(ms) ? null : { kind: 'num', num: ms }; } - // A stored schema is not re-validated, so its kind may be outside the - // union. It orders as text, so an index entry never depends on whether - // the reader recognizes the kind. case 'string': case 'text': case 'record-ref': case 'file-ref': case 'enum': - default: return typeof value === 'string' ? { kind: 'text', text: value, key: contentSortKey(value) } : null; diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index 5becad17..03616cdb 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -180,12 +180,7 @@ import { typedQuery, typedSubscribe, } from './type-handle.js'; -import { - checkFamily, - checkResultContent, - checkStoredVersion, - WRITE_EXPECTATION, -} from './write-expectation.js'; +import { checkFamily, checkStoredVersion, WRITE_EXPECTATION } from './write-expectation.js'; import type { ExpectationOptions } from './write-expectation.js'; import type { ContentOf, @@ -1310,7 +1305,6 @@ export class Stack implements StackClient { checkStoredVersion(existing, opts, changes.contentPatch !== undefined); const merged = await this.validateChangeSet(id, existing, changes); - checkResultContent(merged ?? existing.content, opts); const ops = changeSetOps(existing, changes, merged); // Nothing moved, so there is nothing to version. Decided here rather diff --git a/packages/core/src/type-handle.ts b/packages/core/src/type-handle.ts index 2da8c270..efbb496f 100644 --- a/packages/core/src/type-handle.ts +++ b/packages/core/src/type-handle.ts @@ -2,18 +2,17 @@ * Stack — Type handles * ------------------------------------------------------- * A Type's schema written once as a literal, from which the compiler derives - * the content type: `ContentOf` for a read or a create, `PatchOf` for a - * `contentPatch`. A handle is a plain value, so `StackClient` code can use it - * wherever it has no `defineType()` result. + * the content type: `ContentOf` for a create, `StoredContentOf` for a read, + * `PatchOf` for a `contentPatch`. A handle is a plain value, so `StackClient` + * code can use it wherever it has no `defineType()` result. * * The derived types layer over runtime validation, which stays the guarantee. * What a typed read adds is a runtime check that the record is the handle's * Type. See docs/spec/data-model.md § Type handles. */ -import { StackBadRequestError, StackMigrationError, StackValidationError } from './errors.js'; +import { StackBadRequestError, StackMigrationError } from './errors.js'; import { lineageProblem, parseTypeId } from './schema.js'; -import { enumAllows, enumMismatchMessage, type ValidationError } from './validate.js'; import { StoredVersionError, WRITE_EXPECTATION } from './write-expectation.js'; import type { CreateRecordOptions, StackClient } from './stack.js'; import type { @@ -83,8 +82,23 @@ type IsRequired = D extends { readonly required: true } ? true : false; type Simplify = { [K in keyof T]: T[K] } & {}; -type ValueOf = D extends { readonly kind: 'enum'; readonly values: readonly (infer E)[] } - ? E +/** What a value is read as or written as; they differ only for an enum. */ +type Direction = 'read' | 'write'; + +/** + * A string an enum's `values` do not list. An enum may widen in place, so a + * read can meet one; `string & {}` keeps the listed values as completions. + * See docs/spec/data-model.md § Type handles. + */ +export type UnlistedValue = string & {}; + +type ValueOf = D extends { + readonly kind: 'enum'; + readonly values: readonly (infer E)[]; +} + ? M extends 'read' + ? E | UnlistedValue + : E : D extends { readonly kind: 'string' | 'text' | 'date' | 'record-ref' | 'file-ref' } ? string : D extends { readonly kind: 'number' } @@ -94,39 +108,47 @@ type ValueOf = D extends { readonly kind: 'enum'; readonly values: readonly ( : D extends { readonly kind: 'array'; readonly open: true } ? unknown[] : D extends { readonly kind: 'array'; readonly items: infer I } - ? ValueOf[] + ? ValueOf[] : D extends { readonly kind: 'object'; readonly open: true } ? Record : D extends { readonly kind: 'object'; readonly properties: infer P } ? P extends ReadonlyTypeSchema - ? ContentOf

+ ? ContentAs : never : never; -/** - * The content a schema describes: a required field is present, any other may - * be absent. See docs/spec/data-model.md § Type handles. - */ -export type ContentOf = Simplify< +type ContentAs = Simplify< { - -readonly [K in keyof S as IsRequired extends true ? K : never]: ValueOf; + -readonly [K in keyof S as IsRequired extends true ? K : never]: ValueOf; } & { - -readonly [K in keyof S as IsRequired extends true ? never : K]?: ValueOf; + -readonly [K in keyof S as IsRequired extends true ? never : K]?: ValueOf; } >; +/** + * The content a schema describes, as a write supplies it: a required field + * is present, any other may be absent. See docs/spec/data-model.md § Type + * handles. + */ +export type ContentOf = ContentAs; + +/** `ContentOf`, as a read returns it: an enum may hold an unlisted value. */ +export type StoredContentOf = ContentAs; + /** * The `contentPatch` a schema accepts: every field optional, and `null` * (removal) only on a field that is not required — a required field can be * replaced but not removed. */ export type PatchOf = Simplify<{ - -readonly [K in keyof S]?: IsRequired extends true ? ValueOf : ValueOf | null; + -readonly [K in keyof S]?: IsRequired extends true + ? ValueOf + : ValueOf | null; }>; /** A record whose content is the handle's, as a typed read returns it. */ export type TypedRecord = Omit & { - content: ContentOf; + content: StoredContentOf; }; // ------------------------------------------------------- @@ -239,12 +261,7 @@ export type TypedQuery = Omit & { filter?: Omit; }; -/** - * A change whose `record`, when present, is the handle's content. Absent - * where an untyped change's would be, and also where the record holds an - * enum value the handle does not list — re-read it with the typed `get()`, - * which says why. - */ +/** A change whose `record`, when present, is the handle's content. */ export type TypedChange = Omit & { record?: TypedRecord; }; @@ -262,50 +279,10 @@ export type TypedChangeSet = Omit { - if (value === undefined || value === null) return; - if (def.kind === 'enum') { - if (!enumAllows(def.values, value)) { - errors.push({ path, message: enumMismatchMessage(def.values, value) }); - } - return; - } - if (def.kind === 'array' && !def.open && Array.isArray(value)) { - value.forEach((item, i) => unknownEnumValues(item, def.items, `${path}[${i}]`, errors)); - return; - } - if (def.kind === 'object' && !def.open && typeof value === 'object' && !Array.isArray(value)) { - walkEnums(value as Record, def.properties, path, errors); - } -}; - -const walkEnums = ( - content: Record, - schema: ReadonlyTypeSchema, - prefix: string, - errors: ValidationError[], -): void => { - for (const key of Object.keys(schema)) { - unknownEnumValues(content[key], schema[key], prefix ? `${prefix}.${key}` : key, errors); - } -}; - /** * Narrow a record to a handle's content type: it must be exactly the - * handle's Type, and every enum field must hold a value the handle lists. - * A record of another Type throws, and so does one whose family has moved - * past the handle's version. + * handle's Type. A record of another Type throws, and so does one whose + * family has moved past the handle's version. */ export const narrowRecord = ( handle: TypeHandle, @@ -314,16 +291,9 @@ export const narrowRecord = ( if (record.typeId !== handle.id) { throw new StackBadRequestError(`Record "${record.id}" is ${record.typeId}, not ${handle.id}`); } - assertEnumsListed(handle, record.content); return record as TypedRecord; }; -const assertEnumsListed = (handle: TypeHandle, content: Record): void => { - const errors: ValidationError[] = []; - walkEnums(content, handle.schema, '', errors); - if (errors.length > 0) throw new StackValidationError(errors); -}; - /** The ways a typed read can be asked to see a tombstone. */ export const assertLiveFilter = (filter: object | undefined): void => { if ((filter as RecordFilter | undefined)?.includeDeleted) { @@ -381,12 +351,7 @@ export const typedMutate = async ( // Checked against the read the write already makes, so nothing lands on // a record the handle does not type. See docs/spec/data-model.md § Type handles. const expect = { - [WRITE_EXPECTATION]: { - baseId: handle.baseId, - typeId: handle.id, - exact: true, - checkContent: (content: Record) => assertEnumsListed(handle, content), - }, + [WRITE_EXPECTATION]: { baseId: handle.baseId, typeId: handle.id, exact: true }, }; try { return narrowRecord(handle, await client.mutate(id, changes, { ...opts, ...expect } as never)); @@ -408,13 +373,7 @@ export const typedSubscribe = ( client.subscribe( (change) => { const { record, ...rest } = change; - let narrowed: TypedRecord | undefined; - try { - narrowed = record && narrowRecord(handle, record); - } catch (err) { - if (!(err instanceof StackValidationError)) throw err; - } - handler(narrowed ? { ...rest, record: narrowed } : rest); + handler(record ? { ...rest, record: narrowRecord(handle, record) } : rest); }, { ...opts, filter: { ...opts.filter, typeId: handle.id } }, ); diff --git a/packages/core/src/validate.ts b/packages/core/src/validate.ts index a3b92edd..522a3b17 100644 --- a/packages/core/src/validate.ts +++ b/packages/core/src/validate.ts @@ -36,28 +36,10 @@ export type ValidationError = { // Internal helpers // ------------------------------------------------------- -/** - * The values an enum def allows. A stored Type is not re-validated on read, - * so its def may arrive without a `values` list; it then allows nothing - * rather than throwing. - */ -export const declaredEnumValues = (values: unknown): readonly string[] => - Array.isArray(values) ? (values as string[]) : []; - -export const enumAllows = (values: unknown, value: unknown): boolean => - declaredEnumValues(values).includes(value as string); - /** Shared by the 422 for a value outside the list and the 409 for removed values. */ export const formatEnumValues = (values: readonly string[]): string => values.map((v) => JSON.stringify(v)).join(', '); -export const enumMismatchMessage = (values: unknown, value: unknown): string => { - const allowed = declaredEnumValues(values); - return allowed.length > 0 - ? `Expected one of ${formatEnumValues(allowed)}, got ${JSON.stringify(value)}` - : `The schema declares no enum values, so ${JSON.stringify(value)} is not allowed`; -}; - const jsTypeForScalar = (kind: ScalarFieldKind): string => { switch (kind) { case 'string': @@ -220,8 +202,11 @@ const validateField = ( }); } else if (typeof value === 'number' && !Number.isFinite(value)) { errors.push({ path, message: `Expected a finite number, got ${value}` }); - } else if (def.kind === 'enum' && !enumAllows(def.values, value)) { - errors.push({ path, message: enumMismatchMessage(def.values, value) }); + } else if (def.kind === 'enum' && !def.values.includes(value as string)) { + errors.push({ + path, + message: `Expected one of ${formatEnumValues(def.values)}, got ${JSON.stringify(value)}`, + }); } }; diff --git a/packages/core/src/write-expectation.ts b/packages/core/src/write-expectation.ts index 0632691e..a8695e53 100644 --- a/packages/core/src/write-expectation.ts +++ b/packages/core/src/write-expectation.ts @@ -28,8 +28,6 @@ export type WriteExpectation = { readonly typeId?: TypeId; /** Hold every write to `typeId`, as a typed handle's write is. */ readonly exact?: boolean; - /** Throws if the content the write would leave can't be returned typed. */ - readonly checkContent?: (content: Record) => void; }; export type ExpectationOptions = { readonly [WRITE_EXPECTATION]?: WriteExpectation }; @@ -80,11 +78,3 @@ export const storedVersionDiffers = ( if (expected?.typeId === undefined || record.typeId === expected.typeId) return false; return patchesContent || expected.exact === true; }; - -/** The content a write would leave, checked after validation and before it lands. */ -export const checkResultContent = ( - content: Record, - opts: ExpectationOptions | undefined, -): void => { - opts?.[WRITE_EXPECTATION]?.checkContent?.(content); -}; diff --git a/packages/core/tests/schema.test.ts b/packages/core/tests/schema.test.ts index a55c6871..a6224174 100644 --- a/packages/core/tests/schema.test.ts +++ b/packages/core/tests/schema.test.ts @@ -324,19 +324,6 @@ describe('hashSchema enum', () => { const ac = await hashSchema({ s: { kind: 'enum', values: ['a', 'c'] } }); expect(new Set([plain, ab, ac]).size).toBe(3); }); - - test('a stored enum with no values list hashes rather than throwing', async () => { - const malformed = { s: { kind: 'enum' } } as unknown as TypeSchema; - await expect(hashSchema(malformed)).resolves.toMatch(/\S/); - }); - - test('a malformed values entry hashes apart from a missing one', async () => { - const missing = await hashSchema({ s: { kind: 'enum' } } as unknown as TypeSchema); - const scalar = await hashSchema({ - s: { kind: 'enum', values: 'a,b' }, - } as unknown as TypeSchema); - expect(scalar).not.toBe(missing); - }); }); describe('isCompatible enum', () => { @@ -353,28 +340,6 @@ describe('isCompatible enum', () => { expect(isCompatible(abc, ab)).toBe(false); }); - test('a candidate enum with no values list is incompatible, not a throw', () => { - const malformed = { s: { kind: 'enum', required: true } } as unknown as TypeSchema; - expect(isCompatible(malformed, abc)).toBe(false); - }); - - test('a candidate enum with an empty values list is incompatible', () => { - const empty = { s: { kind: 'enum', values: [], required: true } } as unknown as TypeSchema; - expect(isCompatible(empty, abc)).toBe(false); - }); - - test('a required field of unknown kind is incompatible, not a throw', () => { - const unknown = { s: { kind: 'nonsense', required: true } } as unknown as TypeSchema; - expect(isCompatible(field({ kind: 'string' }), unknown)).toBe(false); - }); - - test('a required field whose kind names an Object.prototype key is incompatible', () => { - for (const kind of ['toString', 'constructor', '__proto__']) { - const odd = { s: { kind, required: true } } as unknown as TypeSchema; - expect(isCompatible(field({ kind: 'string' }), odd)).toBe(false); - } - }); - test('a required enum refuses a string or text candidate, which may hold any value', () => { expect(isCompatible(field({ kind: 'string' }), abc)).toBe(false); expect(isCompatible(field({ kind: 'text' }), abc)).toBe(false); @@ -749,9 +714,4 @@ describe('diffSchemas enum', () => { test('reordering enum values is not a change', () => { expect(diffSchemas(values('a', 'b'), values('b', 'a'))).toEqual([]); }); - - test('a stored enum with no values list diffs rather than throwing', () => { - const malformed = { s: { kind: 'enum' } } as unknown as TypeSchema; - expect(diffSchemas(malformed, values('a'))).toEqual([]); - }); }); diff --git a/packages/core/tests/sort.test.ts b/packages/core/tests/sort.test.ts index e676cc35..d96075b6 100644 --- a/packages/core/tests/sort.test.ts +++ b/packages/core/tests/sort.test.ts @@ -1,7 +1,6 @@ import { describe, test, expect } from 'vitest'; import { contentSortKey, contentSortEntry, compareSortEntries } from '../src/sort.js'; import type { SortEntry } from '../src/sort.js'; -import type { ScalarFieldKind } from '../src/types.js'; describe('contentSortKey', () => { test('folds case and combining marks away', () => { @@ -35,12 +34,6 @@ describe('contentSortKey', () => { }); describe('contentSortEntry', () => { - test('a kind outside the union orders as text', () => { - expect(contentSortEntry('nonsense' as ScalarFieldKind, 'x')).toEqual( - contentSortEntry('string', 'x'), - ); - }); - test('reads a date as epoch milliseconds, not as an ISO string', () => { expect(contentSortEntry('date', '2020-03-01T00:00:00Z')).toEqual({ kind: 'num', diff --git a/packages/core/tests/type-handle.test.ts b/packages/core/tests/type-handle.test.ts index 78085763..69566920 100644 --- a/packages/core/tests/type-handle.test.ts +++ b/packages/core/tests/type-handle.test.ts @@ -1,16 +1,18 @@ import { describe, test, expect, expectTypeOf, beforeEach, vi } from 'vitest'; import { Stack } from '../src/stack.js'; import { MemoryAdapter } from '../src/testing.js'; -import { migration, narrowRecord, typeHandle } from '../src/type-handle.js'; -import type { ContentOf, PatchOf, TypedChange, TypedRecord } from '../src/type-handle.js'; -import { - StackBadRequestError, - StackMigrationError, - StackNotFoundError, - StackValidationError, -} from '../src/errors.js'; +import { migration, typeHandle } from '../src/type-handle.js'; +import type { + ContentOf, + PatchOf, + StoredContentOf, + TypedChange, + TypedRecord, + UnlistedValue, +} from '../src/type-handle.js'; +import { StackBadRequestError, StackMigrationError, StackNotFoundError } from '../src/errors.js'; import type { StackClient } from '../src/stack.js'; -import type { StackRecord, TypeId, TypeSchema } from '../src/types.js'; +import type { TypeId, TypeSchema } from '../src/types.js'; const OWNER = 'owner-123'; @@ -114,6 +116,36 @@ describe('ContentOf', () => { }); }); +describe('StoredContentOf', () => { + test('reads an enum as its listed values or any other string', () => { + expectTypeOf>().toEqualTypeOf<{ + title: string; + status: 'want' | 'reading' | 'finished' | 'abandoned' | UnlistedValue; + pages?: number; + }>(); + }); + + test('reads an enum nested in an array or object the same way', () => { + const Nested = typeHandle({ + id: 'com.example/nested@1', + name: 'Nested', + schema: { + tags: { kind: 'array', items: { kind: 'enum', values: ['x'] }, required: true }, + meta: { + kind: 'object', + required: true, + properties: { mood: { kind: 'enum', values: ['y'], required: true } }, + }, + }, + }); + expect(Nested.baseId).toBe('com.example/nested'); + expectTypeOf>().toEqualTypeOf<{ + tags: ('x' | UnlistedValue)[]; + meta: { mood: 'y' | UnlistedValue }; + }>(); + }); +}); + describe('PatchOf', () => { test('makes every field optional and allows null only on optional fields', () => { expectTypeOf>().toEqualTypeOf<{ @@ -244,15 +276,6 @@ describe('migration()', () => { // Typed reads and writes // ------------------------------------------------------- -describe('narrowRecord()', () => { - test('refuses a non-string value in an enum field', () => { - const record = { id: 'r1', typeId: Book.id, content: { title: 'Dune', status: 3 } }; - expect(() => narrowRecord(Book, record as unknown as StackRecord)).toThrow( - StackValidationError, - ); - }); -}); - describe.each([ ['Stack', (s: Stack): StackClient => s], ['ScopedStack', (s: Stack): StackClient => s.asEntity(OWNER)], @@ -323,9 +346,9 @@ describe.each([ await expect(client.get(Book, book.id)).rejects.toThrow(StackMigrationError); }); - test('get() throws on an enum value the handle does not list', async () => { + test('get() returns an enum value the handle does not list', async () => { const book = await client.create(Book, { title: 'Dune', status: 'want' }); - // The writer's schema listed one more value than this reader's handle. + // The writer's schema lists one more value than this reader's handle. await stack.defineType({ id: Book.id, name: 'Book', @@ -339,10 +362,10 @@ describe.each([ }, }); await stack.patchContent(book.id, { status: 'paused' }); - await expect(client.get(Book, book.id)).rejects.toThrow(StackValidationError); + expect((await client.get(Book, book.id))?.content.status).toBe('paused'); }); - test('mutate() refuses before landing a write that would leave an unlisted enum value', async () => { + test('mutate() keeps an enum value the handle does not list', async () => { const book = await client.create(Book, { title: 'Dune', status: 'want' }); await stack.defineType({ id: Book.id, @@ -350,13 +373,12 @@ describe.each([ schema: { ...Book.schema, status: { kind: 'string', required: true } }, }); await stack.patchContent(book.id, { status: 'paused' }); - await expect(client.patchContent(Book, book.id, { title: 'Emma' })).rejects.toThrow( - StackValidationError, - ); - expect((await stack.get(book.id))?.content).toEqual({ title: 'Dune', status: 'paused' }); - - const fixed = await client.patchContent(Book, book.id, { title: 'Emma', status: 'finished' }); - expect(fixed.content).toEqual({ title: 'Emma', status: 'finished' }); + const updated = await client.mutate(Book, book.id, { + contentPatch: { title: 'Emma' }, + unlisted: true, + }); + expect(updated.content).toEqual({ title: 'Emma', status: 'paused' }); + expect(updated.unlistedAt).toBeDefined(); }); test('get() reads a tombstone as null', async () => { @@ -450,7 +472,7 @@ describe.each([ expect(seen).toEqual(['removed']); }); - test('subscribe() withholds a record holding an enum value the handle does not list', async () => { + test('subscribe() delivers a record holding an enum value the handle does not list', async () => { const seen: TypedChange[] = []; const book = await client.create(Book, { title: 'Dune', status: 'want' }); await stack.defineType({ @@ -473,8 +495,7 @@ describe.each([ stop(); expect(seen).toHaveLength(1); - expect(seen[0].recordId).toBe(book.id); - expect(seen[0].record).toBeUndefined(); + expect(seen[0].record?.content).toEqual({ title: 'Dune', status: 'paused' }); }); test('a typed write to a record of another family finds no record and writes nothing', async () => { diff --git a/packages/core/tests/validate.test.ts b/packages/core/tests/validate.test.ts index a9982126..a9dc27f2 100644 --- a/packages/core/tests/validate.test.ts +++ b/packages/core/tests/validate.test.ts @@ -663,13 +663,6 @@ describe('enum', () => { }; expect(paths({ tags: ['a', 'c'] }, tags)).toEqual(['tags[1]']); }); - - test('a stored enum with no values list refuses every value rather than throwing', () => { - const malformed = { status: { kind: 'enum' } } as unknown as TypeSchema; - expect(errorsFor({ status: 'want' }, malformed)).toEqual([ - { path: 'status', message: 'The schema declares no enum values, so "want" is not allowed' }, - ]); - }); }); describe('validateSchemaShape enum', () => { From 61eb1d387ac997bab09b5e73877e3813e8dc3e52 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 16:04:57 +0000 Subject: [PATCH 7/8] fix(core): type migrations on stored content and tidy enum review items - migration() types both sides as StoredContentOf. A stored enum can hold a value the source handle does not list; typing the source as ContentOf let a lookup-table step compile and silently write undefined for such a record. The step now carries the value through, a lookup keyed on only the listed values does not compile, and migrateAll()'s runtime validation refuses what the target rejects. - The typed get() JSDoc no longer says unlisted enum values throw. - Spec: the compatibility prose matches the enum row and column, and the schema hash names its sort order (UTF-16 code unit) for field names and enum values. - A misspelled kind reports only the unknown kind, not its values. - Drop the hash test that pinned behavior for an invalid schema. - @haverstack/adapter-conformance is a minor: a new conformance test can fail a third-party adapter. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BHk2jVPk24WLxWVGekNdqY --- .changeset/enum-field-kind.md | 4 ++-- docs/spec/data-model.md | 6 +++--- packages/core/src/stack.ts | 4 ++-- packages/core/src/type-handle.ts | 7 ++++--- packages/core/src/validate.ts | 4 +++- packages/core/tests/schema.test.ts | 5 ----- packages/core/tests/type-handle.test.ts | 17 +++++++++++++---- packages/core/tests/validate.test.ts | 6 ++++++ 8 files changed, 33 insertions(+), 20 deletions(-) diff --git a/.changeset/enum-field-kind.md b/.changeset/enum-field-kind.md index 92a0524c..d43f55d4 100644 --- a/.changeset/enum-field-kind.md +++ b/.changeset/enum-field-kind.md @@ -1,7 +1,7 @@ --- '@haverstack/core': minor '@haverstack/conformance-fixtures': minor -'@haverstack/adapter-conformance': patch +'@haverstack/adapter-conformance': minor --- -`enum` is a field kind: `{ kind: 'enum', values: [...] }` replaces `{ kind: 'string', enum: [...] }`. `defineType()` refuses the `enum` key on any field. `isCompatible()` now checks enums: a required enum accepts only an enum candidate whose values it lists all of, and a required `string` or `text` accepts an enum. An enum becoming a plain `string` is additive; a `string` becoming an enum needs a version bump. A typed read types an enum field as its listed values or any other string (`StoredContentOf`, `UnlistedValue`) and no longer throws on an unlisted value, since an enum can widen in place; typed writes still take only the listed values (`ContentOf`, `PatchOf`). Conformance fixtures pin a 422 for a value outside the list and a 409 for removing values. The adapter conformance suite checks that an enum field sorts by value. +`enum` is a field kind: `{ kind: 'enum', values: [...] }` replaces `{ kind: 'string', enum: [...] }`. `defineType()` refuses the `enum` key on any field. `isCompatible()` now checks enums: a required enum accepts only an enum candidate whose values it lists all of, and a required `string` or `text` accepts an enum. An enum becoming a plain `string` is additive; a `string` becoming an enum needs a version bump. A typed read types an enum field as its listed values or any other string (`StoredContentOf`, `UnlistedValue`) and no longer throws on an unlisted value, since an enum can widen in place; typed writes still take only the listed values (`ContentOf`, `PatchOf`). `migration()` types both sides as `StoredContentOf`, so a step carries an unlisted value through. Conformance fixtures pin a 422 for a value outside the list and a 409 for removing values. The adapter conformance suite checks that an enum field sorts by value. diff --git a/docs/spec/data-model.md b/docs/spec/data-model.md index 756cc596..115f0c23 100644 --- a/docs/spec/data-model.md +++ b/docs/spec/data-model.md @@ -277,7 +277,7 @@ type StackType = { version: number; // Incrementing integer name: string; // Human-readable label, e.g. "Note" schema: TypeSchema; - schemaHash: string; // SHA-256 of canonical (minified, alpha-sorted) schema + schemaHash: string; // SHA-256 of canonical (minified) schema; field names and enum values sorted by UTF-16 code unit migratesFrom?: TypeId; // an earlier version of the same family — documents lineage createdAt: Date; }; @@ -356,7 +356,7 @@ A field's kind is read-compatible with a required kind per this table (row = req | `record-ref` | | | | | | | ✓ | | | `file-ref` | | | | | | | | ✓ | -`string` and `text` are mutually read-compatible — 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. +`string` and `text` are mutually read-compatible — the distinction is presentation/indexing intent — and each also accepts an enum. A required enum takes the subset rule below. 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. **An enum reads as a string, and a required enum accepts only a narrower enum** (⊆ above): a candidate `enum` whose `values` are all in the required list. A consumer expecting an enum has handled exactly the values it lists, so a `string` or `text` candidate, which can hold anything, is refused, as is an enum listing a value the consumer does not. The check is as of the Type it is given: an enum may [gain values in place](#additive-evolution-within-a-version), or become a plain `string`, so a candidate compatible today can later hold a value the consumer does not list. A consumer handles such a value, as a [typed read](#type-handles) does. @@ -379,7 +379,7 @@ const noteV2ToV3 = migration(NoteV2, NoteV3, (content) => ({ ...content, pinned: const stack = await Stack.open(adapter, { migrations: [noteV1ToV2, noteV2ToV3] }); ``` -`migration()` takes two [type handles](#type-handles), so `fn` is checked as `ContentOf` the first to `ContentOf` the second: a step that drops a required field or yields a value outside an enum's `values` does not compile. The source is typed as written rather than as `StoredContentOf`, so an identity step compiles; a source value the handle does not list reaches the target's runtime validation when `migrateAll()` writes it. Within a family it refuses a `to` whose `migratesFrom` is not `from.id`, so a step cannot be paired with the wrong handle. +`migration()` takes two [type handles](#type-handles), so `fn` is checked as `StoredContentOf` the first to `StoredContentOf` the second: a step that drops a required field does not compile. A stored enum may hold a value the source handle does not list, so the step carries it through rather than losing it, and a lookup keyed on only the listed values does not compile. A value the target does not accept is refused by its runtime validation when `migrateAll()` writes it. Within a family it refuses a `to` whose `migratesFrom` is not `from.id`, so a step cannot be paired with the wrong handle. **Lineage stays inside a family; a migration can leave it.** `migratesFrom` names an earlier version of the same family, and `typeHandle()` and `defineType()` refuse anything else with `StackBadRequestError`. A step whose `from` and `to` are in different families has no lineage to check, so `migration()` builds it from the two handles alone. This is how an app's own type moves into a shared one — a [commons](../commons/README.md) type, say — without the shared handle naming every family that has ever migrated into it. A migration is a separate value from either handle: a handle is data a manifest carries over the wire, and may be shared by apps that do not run its family's migrations. diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index 03616cdb..a939163b 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -441,8 +441,8 @@ export interface StackClient { ): Promise; /** * Typed read: the record at `presentAt: 'latest'`, checked to be exactly - * the handle's Type with every enum field holding a value the handle - * lists, else it throws. Live records only — a tombstone reads as `null`, + * the handle's Type, else it throws. An enum field may hold a value the + * handle does not list. Live records only — a tombstone reads as `null`, * and there is no `includeDeleted`. * See docs/spec/data-model.md § Type handles. */ diff --git a/packages/core/src/type-handle.ts b/packages/core/src/type-handle.ts index efbb496f..e95d0264 100644 --- a/packages/core/src/type-handle.ts +++ b/packages/core/src/type-handle.ts @@ -230,15 +230,16 @@ export type Migration, To = Record( from: TypeHandle, to: TypeHandle, - fn: (content: ContentOf) => NoInfer>, -): Migration, ContentOf> => { + fn: (content: StoredContentOf) => NoInfer>, +): Migration, StoredContentOf> => { if (to.baseId === from.baseId && to.migratesFrom !== from.id) { throw new StackMigrationError( `Cannot migrate "${from.id}" to "${to.id}": "${to.id}" migrates from ` + diff --git a/packages/core/src/validate.ts b/packages/core/src/validate.ts index 522a3b17..1555d8bf 100644 --- a/packages/core/src/validate.ts +++ b/packages/core/src/validate.ts @@ -395,7 +395,9 @@ const validateFieldDefShape = ( return; } - if (def.values !== undefined && def.kind !== 'enum') { + const knownKind = + def.kind === 'array' || def.kind === 'object' || Object.hasOwn(SCALAR_KINDS, def.kind); + if (def.values !== undefined && knownKind && def.kind !== 'enum') { errors.push({ path, message: `"values" is only allowed on an enum field, not "${def.kind}"` }); } // Unknown keys are otherwise ignored, but `enum` names a kind: as a key it diff --git a/packages/core/tests/schema.test.ts b/packages/core/tests/schema.test.ts index a6224174..825b58ff 100644 --- a/packages/core/tests/schema.test.ts +++ b/packages/core/tests/schema.test.ts @@ -54,11 +54,6 @@ describe('hashSchema', () => { expect(h1).not.toBe(h2); }); - test('values on a non-enum kind do not affect the hash, as validation ignores them', async () => { - const stray = { s: { kind: 'string', values: ['x'] } } as unknown as TypeSchema; - expect(await hashSchema(stray)).toBe(await hashSchema({ s: { kind: 'string' } })); - }); - test('adding a field produces a different hash', async () => { const h1 = await hashSchema({ text: { kind: 'text', required: true } }); const h2 = await hashSchema({ diff --git a/packages/core/tests/type-handle.test.ts b/packages/core/tests/type-handle.test.ts index 69566920..c4d228fb 100644 --- a/packages/core/tests/type-handle.test.ts +++ b/packages/core/tests/type-handle.test.ts @@ -230,7 +230,7 @@ describe('typeHandle()', () => { describe('migration()', () => { test('types the function from both handles', () => { const m = migration(BookV1, Book, (c) => { - expectTypeOf(c).toEqualTypeOf>(); + expectTypeOf(c).toEqualTypeOf>(); return { ...c, status: c.status === 'done' ? 'finished' : c.status }; }); expect(m.from).toBe(BookV1.id); @@ -246,9 +246,18 @@ describe('migration()', () => { migration(BookV1, Book, (c) => ({ title: c.title })); }); - test('refuses at compile time a value outside the target enum', () => { - // @ts-expect-error 'done' is not one of the target's statuses - migration(BookV1, Book, (c) => ({ ...c })); + test('carries a value the source handle does not list through', () => { + const m = migration(BookV1, Book, (c) => c); + expect(m.migrate({ title: 'Dune', status: 'paused' })).toEqual({ + title: 'Dune', + status: 'paused', + }); + }); + + test('refuses at compile time a lookup keyed on only the listed values', () => { + const renamed = { want: 'want', reading: 'reading', done: 'finished' } as const; + // @ts-expect-error a stored status the source does not list has no entry + migration(BookV1, Book, (c) => ({ ...c, status: renamed[c.status] })); }); test('refuses a target in the same family that does not migrate from the source', () => { diff --git a/packages/core/tests/validate.test.ts b/packages/core/tests/validate.test.ts index a9dc27f2..7daab0c3 100644 --- a/packages/core/tests/validate.test.ts +++ b/packages/core/tests/validate.test.ts @@ -672,6 +672,12 @@ describe('validateSchemaShape enum', () => { expect(messages('{"s": {"kind": "enum", "values": ["a", "b"]}}')).toEqual([]); }); + test('a misspelled kind is named as the problem, not its values', () => { + expect(messages('{"s": {"kind": "enm", "values": ["a"]}}')).toEqual([ + '"enm" is not a field kind', + ]); + }); + test('missing, empty or non-array values are refused', () => { const expected = ['An enum must declare "values", a non-empty array of strings']; expect(messages('{"s": {"kind": "enum"}}')).toEqual(expected); From f607ebcfb3d14c3a7e2c943963737c34a0a7586a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 16:18:59 +0000 Subject: [PATCH 8/8] fix(core): deliver a migrated-off record without it in typed subscribe - The change filter matches a record's previous typeId, so migrateAll() moving a record off a handle's version delivered an event whose record narrowRecord refused, throwing inside the dispatcher before the handler ran. The change now arrives without `record`. - The trust-inside rule names adapter-api's server as held to the wire contract, rather than asking adapters to validate what they read. - The compatibility depth-bound comment no longer calls candidate schemas untrusted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BHk2jVPk24WLxWVGekNdqY --- .changeset/enum-field-kind.md | 2 +- AGENTS.md | 2 +- CONTRIBUTING.md | 2 +- docs/spec/data-model.md | 2 +- packages/core/src/schema.ts | 4 ++-- packages/core/src/type-handle.ts | 12 ++++++++++-- packages/core/tests/type-handle.test.ts | 25 +++++++++++++++++++++++++ 7 files changed, 41 insertions(+), 8 deletions(-) diff --git a/.changeset/enum-field-kind.md b/.changeset/enum-field-kind.md index d43f55d4..b6e9b483 100644 --- a/.changeset/enum-field-kind.md +++ b/.changeset/enum-field-kind.md @@ -4,4 +4,4 @@ '@haverstack/adapter-conformance': minor --- -`enum` is a field kind: `{ kind: 'enum', values: [...] }` replaces `{ kind: 'string', enum: [...] }`. `defineType()` refuses the `enum` key on any field. `isCompatible()` now checks enums: a required enum accepts only an enum candidate whose values it lists all of, and a required `string` or `text` accepts an enum. An enum becoming a plain `string` is additive; a `string` becoming an enum needs a version bump. A typed read types an enum field as its listed values or any other string (`StoredContentOf`, `UnlistedValue`) and no longer throws on an unlisted value, since an enum can widen in place; typed writes still take only the listed values (`ContentOf`, `PatchOf`). `migration()` types both sides as `StoredContentOf`, so a step carries an unlisted value through. Conformance fixtures pin a 422 for a value outside the list and a 409 for removing values. The adapter conformance suite checks that an enum field sorts by value. +`enum` is a field kind: `{ kind: 'enum', values: [...] }` replaces `{ kind: 'string', enum: [...] }`. `defineType()` refuses the `enum` key on any field. `isCompatible()` now checks enums: a required enum accepts only an enum candidate whose values it lists all of, and a required `string` or `text` accepts an enum. An enum becoming a plain `string` is additive; a `string` becoming an enum needs a version bump. A typed read types an enum field as its listed values or any other string (`StoredContentOf`, `UnlistedValue`) and no longer throws on an unlisted value, since an enum can widen in place; typed writes still take only the listed values (`ContentOf`, `PatchOf`). `migration()` types both sides as `StoredContentOf`, so a step carries an unlisted value through. A typed `subscribe()` delivers a record that `migrateAll()` moves off the handle's version without its `record`, instead of throwing in the dispatcher. Conformance fixtures pin a 422 for a value outside the list and a 409 for removing values. The adapter conformance suite checks that an enum field sorts by value. diff --git a/AGENTS.md b/AGENTS.md index a1d6c367..86eb80e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,7 +68,7 @@ This is about _our_ history. Genuinely foreign input — a third-party server's ## Architecture rules - **`Stack` is the invariant layer; adapters are storage engines.** Validation, `_config` protection, ID rules, migration policy, and permission checks live in `packages/core` so every adapter inherits them. Don't reimplement an invariant inside an adapter. (The one documented exception: the `_config` query exclusion must live in each adapter's own query predicate.) -- **Validate where data enters; trust it inside.** Public API calls and wire requests are checked once. A stored Type or Record from an adapter is trusted to match its type: don't guard for a hand-edited store (a missing `values` list, an unknown `kind`). An adapter reading a foreign source validates what it hands to `Stack`. See [CONTRIBUTING.md § Architecture conventions](./CONTRIBUTING.md#architecture-conventions). +- **Validate where data enters; trust it inside.** Public API calls and wire requests are checked once. A stored Type or Record from an adapter is trusted to match its type: don't guard for a hand-edited store (a missing `values` list, an unknown `kind`). That includes `adapter-api`: its server is held to the wire contract the way a storage engine is held to the adapter contract. See [CONTRIBUTING.md § Architecture conventions](./CONTRIBUTING.md#architecture-conventions). - **Package prefixes declare the contract**: `adapter-*` = full `StackAdapter`, `record-adapter-*` = `StackRecordAdapter`, `blob-adapter-*` = `StackBlobAdapter`. - **Optional capabilities are optional methods**, checked for truthiness at the call site with a documented fallback — never a boolean in `capabilities`. - **Shared SQLite logic goes in `@haverstack/sqlite-shared`**, not duplicated across adapters. It is `private` and bundled into its consumers at build time, so it is never a `dependencies` entry and adding an export to it does not widen any package's public API. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bf3e9e9f..7ad5e9b3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -233,7 +233,7 @@ Note that this is about _our_ history, not about inputs we genuinely receive. Ha **`Stack` is the invariant layer; adapters are storage engines.** Validation, `_config` protection, ID rules, migration policy, and permission logic live in `packages/core` so every adapter inherits them. An adapter that reimplements an invariant is a bug waiting to diverge. The deliberate exception is documented in the spec: the `_config` query exclusion must live in each adapter's own query predicate, because post-filtering in `Stack` would under-fill a page. -**Validate where data enters; trust it inside.** Input is checked once, at the boundary it crosses: a public API call such as `defineType()` or `create()`, or a wire request a server decodes. Past that point the types are true, and code reads a stored Type or Record as its type says. Data an adapter returns is inside: a store that holds something core never wrote was edited by hand, and guarding for that in every reader costs more than it buys. Each guard for malformed stored data also forces a policy choice (does a missing list allow nothing? does an unknown kind sort as text?), and those choices drift apart between call sites. A foreign source, such as a third-party server, is an edge, and the adapter that reads it validates what it hands to `Stack`. +**Validate where data enters; trust it inside.** Input is checked once, at the boundary it crosses: a public API call such as `defineType()` or `create()`, or a wire request a server decodes. Past that point the types are true, and code reads a stored Type or Record as its type says. Data an adapter returns is inside: a store that holds something core never wrote was edited by hand, and guarding for that in every reader costs more than it buys. Each guard for malformed stored data also forces a policy choice (does a missing list allow nothing? does an unknown kind sort as text?), and those choices drift apart between call sites. That holds for `adapter-api` too: the server behind it is held to the wire contract, which `@haverstack/conformance-fixtures` pins, the way a storage engine is held to the adapter contract. **Package naming declares the adapter contract**, per [`docs/spec/adapters.md`](./docs/spec/adapters.md): diff --git a/docs/spec/data-model.md b/docs/spec/data-model.md index 115f0c23..8fbd7ed3 100644 --- a/docs/spec/data-model.md +++ b/docs/spec/data-model.md @@ -415,7 +415,7 @@ A handle names exactly one version. A call that means the whole family takes `ha - types an `enum` field as its listed values or any other string (`'want' | 'reading' | 'finished' | UnlistedValue`). An enum may gain values, or become a plain `string`, within a version ([additive evolution](#additive-evolution-within-a-version)), so a reader older than the writer can meet any string there. Records are shared across apps that upgrade independently, so a closed union would promise what no handle can check; the reader handles a value it does not list, as a `default` branch does; - sees live records only. `includeDeleted` is not offered and is refused at runtime, so a tombstone, whose `content` is `{}`, is never typed as the handle's content. A caller who wants tombstones uses the untyped read. -A typed `subscribe()` delivers only changes to records of exactly the handle's `id`, with `record`, when present, typed as the content. An event carries its record as stored, which a subscription cannot migrate, so other versions of the family are not delivered. The change filter takes every key but `typeId` and `baseId`. +A typed `subscribe()` delivers only changes to records of exactly the handle's `id`, with `record`, when present, typed as the content. An event carries its record as stored, which a subscription cannot migrate, so other versions of the family are not delivered. A record that [`migrateAll()`](#type-migrations) moves off the handle's version still matches the filter by its previous `typeId`, and arrives without its `record`, as it does wherever the emitter cannot supply one; the typed `get()` then says why. The change filter takes every key but `typeId` and `baseId`. A typed `query()` matches the handle's whole family, then applies the checks above to every record. A typed write is checked against the read the write already makes, before anything lands. A record of another family is not found. A record of the family stored at another `typeId` is refused with `StackBadRequestError`, since a patch is validated against the record's own stored Type and the result could not be typed. A typed write takes only the values the handle lists, in `ContentOf` and `PatchOf`; the stored schema's runtime validation decides what lands. A deleted record, or a stale `ifVersion`, is refused first, as the untyped write refuses it. diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 75b75067..97adbc32 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -113,8 +113,8 @@ const READ_COMPATIBLE: Record, ScalarFieldKind[ 'file-ref': ['file-ref'], }; -// Candidate schemas can come from another app's Type definition (the -// untrusted side of duck-typed consumption), so recursion into array +// Candidate schemas can come from another app's Type definition, whose +// shape validation bounds its depth, 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. diff --git a/packages/core/src/type-handle.ts b/packages/core/src/type-handle.ts index e95d0264..d9942d91 100644 --- a/packages/core/src/type-handle.ts +++ b/packages/core/src/type-handle.ts @@ -262,7 +262,11 @@ export type TypedQuery = Omit & { filter?: Omit; }; -/** A change whose `record`, when present, is the handle's content. */ +/** + * A change whose `record`, when present, is the handle's content. Absent + * where an untyped change's would be, and when the record has migrated off + * the handle's version; the typed `get()` then says why. + */ export type TypedChange = Omit & { record?: TypedRecord; }; @@ -374,7 +378,11 @@ export const typedSubscribe = ( client.subscribe( (change) => { const { record, ...rest } = change; - handler(record ? { ...rest, record: narrowRecord(handle, record) } : rest); + // A record migrated off the handle's version still matches the filter + // by its previous typeId, and arrives without `record`. + handler( + record?.typeId === handle.id ? { ...rest, record: narrowRecord(handle, record) } : rest, + ); }, { ...opts, filter: { ...opts.filter, typeId: handle.id } }, ); diff --git a/packages/core/tests/type-handle.test.ts b/packages/core/tests/type-handle.test.ts index c4d228fb..e4b80020 100644 --- a/packages/core/tests/type-handle.test.ts +++ b/packages/core/tests/type-handle.test.ts @@ -481,6 +481,31 @@ describe.each([ expect(seen).toEqual(['removed']); }); + test('subscribe() delivers a record migrated off the handle without its record', async () => { + const BookV3 = typeHandle({ + id: 'com.example.reading/book@3', + name: 'Book', + schema: { ...Book.schema, isbn: { kind: 'string' } }, + migratesFrom: Book, + }); + stack = await Stack.open(adapter, { migrations: [migration(Book, BookV3, (c) => c)] }); + await stack.defineType(BookV3); + client = view(stack); + const book = await client.create(Book, { title: 'Dune', status: 'want' }); + + const seen: TypedChange[] = []; + const stop = await client.subscribe(Book, (change) => seen.push(change), { + includeRecords: true, + }); + await stack.migrateAll(Book.baseId); + await settle(); + stop(); + + expect(seen).toHaveLength(1); + expect(seen[0].recordId).toBe(book.id); + expect(seen[0].record).toBeUndefined(); + }); + test('subscribe() delivers a record holding an enum value the handle does not list', async () => { const seen: TypedChange[] = []; const book = await client.create(Book, { title: 'Dune', status: 'want' });