Skip to content

feat(core): make enum its own field kind - #433

Merged
cuibonobo merged 9 commits into
mainfrom
feat/enum-kind
Oct 10, 2026
Merged

cuibonobo merged 9 commits into
mainfrom
feat/enum-kind

Conversation

@cuibonobo

@cuibonobo cuibonobo commented Oct 10, 2026 •

Copy link
Copy Markdown
Member

Summary

Makes enum its own field kind: { kind: 'enum', values: [...] } replaces { kind: 'string', enum: [...] }.

When enum was a modifier on string, nothing keyed on kind ever saw it. isCompatible() ignored it entirely, so a consumer requiring status: enum ['want', 'reading', 'finished'] accepted a candidate with a plain string status, which can hold any value. With enum as a kind, every exhaustive kind map (READ_COMPATIBLE, SCALAR_KINDS, jsTypeForScalar, contentSortEntry) has to account for it, so this kind of gap can't recur unnoticed.

  • Read compatibility: a required enum accepts only an enum candidate whose values are a subset of its own, including inside arrays and objects. A required string or text accepts an enum.
  • Drift: enum → string stays additive, the one kind change that is, because it accepts strictly more. string → enum and enum → text need a version bump. Removing values is drift; adding them is additive.
  • Validation: 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 ("enum" is a field kind, not a field key), so the retired spelling fails loudly instead of silently becoming a plain string.
  • Hash: values is sorted by UTF-16 code unit, as field names are, so reordering isn't a change. The spec now names that order.
  • Sorting: enums order as text, by value. The adapter conformance suite checks enum ordering end to end.
  • Type handles: an enum can widen in place, so a typed read can't promise a closed union. TypedRecord content is StoredContentOf<S>, which reads an enum as its listed values or UnlistedValue (string & {}). Typed writes (ContentOf, PatchOf) still take only the listed values; the stored schema's runtime validation decides what lands.
  • Typed subscribe: a record that migrateAll() moves off the handle's version still matches the filter by its previous typeId; it now arrives without record instead of throwing in the dispatcher before the handler runs.
  • Migrations: migration() types both sides as StoredContentOf. A step carries an unlisted value through rather than losing it, a lookup keyed on only the listed values does not compile, and migrateAll()'s runtime validation refuses what the target rejects.
  • Conformance fixtures: a 422 for a value outside the list and a 409 for removing values.

Trust boundary

Data is validated where it enters a stack: public API calls and wire requests a server decodes. What an adapter returns is trusted to match its type, adapter-api included, since its server is held to the wire contract the way a storage engine is held to the adapter contract. This PR adds that rule to AGENTS.md and CONTRIBUTING.md § Architecture conventions, and so carries no guards for hand-edited stores (missing values, unknown kinds).

Spec

docs/spec/data-model.md:

  • § Types: the enum kind, why it is a kind rather than a constraint on string, and the hash's sort order
  • § Schema drift detection and § Additive evolution within a version: enum → string is additive
  • § Type compatibility: enum row and column in the table and prose, the subset rule and its caveat about in-place widening
  • § Type handles: StoredContentOf; a typed read types an enum as its listed values or any other string; a record migrated off the handle's version arrives in a typed subscription without record
  • § Type migrations: both sides typed as StoredContentOf; unlisted values carry through
  • § Sorting by a content field: enums order by value

Verification

pnpm run format:check, check:refs, lint, test, build, typecheck: all pass locally. The new typed-subscribe test fails without the fix (the handler never runs and the dispatcher throws).

Notes for reviewers

  • The modifier form has been published (see the core CHANGELOG), so this is a breaking minor. Per AGENTS.md there's no compatibility path; the enum key is refused, not translated.
  • TypeScript does not narrow x === 'want' to 'want' when x includes string & {}; the default branch of a switch does see UnlistedValue. Passing a read value straight back into a typed write therefore needs the literal, which is the honest typing: the value may be unlisted.
  • A migration step can now emit a value the target handle doesn't list without a compile error; migrateAll() catches it at write time, and a typed read already types it as UnlistedValue. Migration output read live (presentAt: 'latest') is not validated for any field; that is outside this PR.
  • page.collection.order in commons is a natural enum but is left for a separate change. The format fields stay string on purpose: they're open vocabularies.
  • @haverstack/adapter-conformance gets a minor: the new enum-sort test can fail a third-party adapter.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PQamMFa1B7kk8RTmzoTdqQ
https://claude.ai/code/session_01MGpKBNfBMZv8ijfQwoEqyK
https://claude.ai/code/session_01265RetnERff8vRLus836Zk
https://claude.ai/code/session_01BHk2jVPk24WLxWVGekNdqY

`{ 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 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f607ebc

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@haverstack/core Minor
@haverstack/conformance-fixtures Minor
@haverstack/adapter-conformance Minor
@haverstack/adapter-api Patch
@haverstack/adapter-local Patch
@haverstack/blob-adapter-disk Patch
@haverstack/blob-adapter-s3 Patch
@haverstack/commons Patch
@haverstack/record-adapter-do-sqlite Patch
@haverstack/record-adapter-sqlite Patch
@haverstack/wire-types Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpyBKjVuG8NHtQcHvQoAnN
# Conflicts:
#	packages/core/src/type-handle.ts
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQamMFa1B7kk8RTmzoTdqQ
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGpKBNfBMZv8ijfQwoEqyK
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01265RetnERff8vRLus836Zk
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHk2jVPk24WLxWVGekNdqY
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHk2jVPk24WLxWVGekNdqY
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHk2jVPk24WLxWVGekNdqY
@cuibonobo
cuibonobo merged commit 46f3ae5 into main Oct 10, 2026
9 checks passed
@cuibonobo
cuibonobo deleted the feat/enum-kind branch October 10, 2026 16:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants