diff --git a/.changeset/enum-field-kind.md b/.changeset/enum-field-kind.md new file mode 100644 index 00000000..b6e9b483 --- /dev/null +++ b/.changeset/enum-field-kind.md @@ -0,0 +1,7 @@ +--- +'@haverstack/core': minor +'@haverstack/conformance-fixtures': minor +'@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. 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 8fdcf5ab..86eb80e8 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`). 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 bb1ddfb7..7ad5e9b3 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. 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): | Prefix | Implements | diff --git a/docs/spec/data-model.md b/docs/spec/data-model.md index cf9b4cf1..8fbd7ed3 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 @@ -276,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; }; @@ -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, `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. **`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. @@ -338,21 +341,24 @@ 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): -| 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 — 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. -`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 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. @@ -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 `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. @@ -396,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). @@ -406,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 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; +- 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. 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 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`. @@ -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/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/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/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 be38f824..97adbc32 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 { formatEnumValues } from './validate.js'; // ------------------------------------------------------- // Canonical schema serialization @@ -58,11 +59,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,12 +98,14 @@ 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. A + * required enum takes the subset rule in isFieldCompatible instead. See + * docs/spec/data-model.md § Type compatibility. */ -const READ_COMPATIBLE: Record = { - string: ['string', 'text'], - text: ['text', 'string'], +const READ_COMPATIBLE: Record, ScalarFieldKind[]> = { + string: ['string', 'text', 'enum'], + text: ['text', 'string', 'enum'], number: ['number'], boolean: ['boolean'], date: ['date'], @@ -110,8 +113,8 @@ const READ_COMPATIBLE: Record = { '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. @@ -148,6 +151,12 @@ 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') { + if (candidate.kind !== 'enum') return false; + const allowed = new Set(required.values); + return candidate.values.every((v) => allowed.has(v)); + } return READ_COMPATIBLE[required.kind].includes(candidate.kind); }; @@ -181,7 +190,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 = { @@ -207,7 +217,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 +233,12 @@ 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: ${formatEnumValues(removed)}` }); } } // 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/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 29a499af..d9942d91 100644 --- a/packages/core/src/type-handle.ts +++ b/packages/core/src/type-handle.ts @@ -2,19 +2,18 @@ * 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 { StoredVersionError, WRITE_EXPECTATION } from './write-expectation.js'; -import type { ValidationError } from './validate.js'; import type { CreateRecordOptions, StackClient } from './stack.js'; import type { BaseId, @@ -38,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; @@ -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: 'string'; readonly enum: 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: 'string'; readonly enum: 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; }; // ------------------------------------------------------- @@ -208,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 ` + @@ -241,9 +264,8 @@ export type TypedQuery = 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. + * 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; @@ -262,52 +284,10 @@ export type TypedChangeSet = Omit { - if (value === undefined || value === null) return; - if (def.kind === 'string') { - if (def.enum && typeof value === 'string' && !def.enum.includes(value)) { - errors.push({ - path, - message: `Expected one of ${def.enum.map((v) => JSON.stringify(v)).join(', ')}, got ${JSON.stringify(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, @@ -316,9 +296,6 @@ export const narrowRecord = ( if (record.typeId !== handle.id) { throw new StackBadRequestError(`Record "${record.id}" is ${record.typeId}, not ${handle.id}`); } - const errors: ValidationError[] = []; - walkEnums(record.content, handle.schema, '', errors); - if (errors.length > 0) throw new StackValidationError(errors); return record as TypedRecord; }; @@ -401,13 +378,11 @@ 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); + // 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/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..1555d8bf 100644 --- a/packages/core/src/validate.ts +++ b/packages/core/src/validate.ts @@ -36,12 +36,17 @@ export type ValidationError = { // Internal helpers // ------------------------------------------------------- +/** 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(', '); + const jsTypeForScalar = (kind: ScalarFieldKind): string => { switch (kind) { case 'string': case 'text': case 'record-ref': case 'file-ref': + case 'enum': return 'string'; case 'number': return 'number'; @@ -197,10 +202,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 ${formatEnumValues(def.values)}, got ${JSON.stringify(value)}`, }); } }; @@ -344,22 +349,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 +395,15 @@ 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}"` }); + 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 + // would read as a constraint that nothing enforces. + if (def.enum !== undefined) { + errors.push({ path, message: '"enum" is a field kind, not a field key' }); } if (def.kind === 'array') { @@ -438,13 +451,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 066d68dc..e4b80020 100644 --- a/packages/core/tests/type-handle.test.ts +++ b/packages/core/tests/type-handle.test.ts @@ -2,13 +2,15 @@ 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 type { ContentOf, PatchOf, TypedChange, TypedRecord } from '../src/type-handle.js'; -import { - StackBadRequestError, - StackMigrationError, - StackNotFoundError, - StackValidationError, -} from '../src/errors.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 { TypeId, TypeSchema } from '../src/types.js'; @@ -22,7 +24,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 }, }, }); @@ -32,7 +34,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' }, }, }); @@ -54,7 +56,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 }, @@ -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<{ @@ -188,7 +220,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, ); @@ -198,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); @@ -214,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', () => { @@ -314,23 +355,39 @@ 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', schema: { ...Book.schema, status: { - kind: 'string', - enum: ['want', 'reading', 'finished', 'abandoned', 'paused'], + kind: 'enum', + values: ['want', 'reading', 'finished', 'abandoned', 'paused'], required: true, }, }, }); 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() 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, + name: 'Book', + schema: { ...Book.schema, status: { kind: 'string', required: true } }, + }); + await stack.patchContent(book.id, { status: 'paused' }); + 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 () => { @@ -424,7 +481,32 @@ 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 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' }); await stack.defineType({ @@ -433,8 +515,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, }, }, @@ -447,8 +529,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 d4deb437..7daab0c3 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,46 @@ 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('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); + 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"', + ]); + 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, "values": ["a"]}}')).toEqual([ + '"values" is only allowed on an enum field, not "object"', ]); }); - 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('{"o": {"kind": "object", "open": true, "enum": ["a"]}}')).toEqual([ - '"enum" is only allowed on a string field, not "object"', + test('an enum key on any field is refused, not ignored', () => { + expect(messages('{"s": {"kind": "string", "enum": ["a"]}}')).toEqual([ + '"enum" is a field kind, not a field key', ]); }); });