Repository navigation
feat(core): make enum its own field kind - #433
Merged
Merged
Conversation
`{ 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 detectedLatest commit: f607ebc The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Makes
enumits 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 requiringstatus: enum ['want', 'reading', 'finished']accepted a candidate with a plainstringstatus, 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.stringortextaccepts an enum.enum→stringstays additive, the one kind change that is, because it accepts strictly more.string→enumandenum→textneed a version bump. Removing values is drift; adding them is additive.defineType()refuses an enum with missing or emptyvalues, non-string or duplicate entries,valueson any other kind, and anenumkey 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.valuesis sorted by UTF-16 code unit, as field names are, so reordering isn't a change. The spec now names that order.TypedRecordcontent isStoredContentOf<S>, which reads an enum as its listed values orUnlistedValue(string & {}). Typed writes (ContentOf,PatchOf) still take only the listed values; the stored schema's runtime validation decides what lands.migrateAll()moves off the handle's version still matches the filter by its previoustypeId; it now arrives withoutrecordinstead of throwing in the dispatcher before the handler runs.migration()types both sides asStoredContentOf. A step carries an unlisted value through rather than losing it, a lookup keyed on only the listed values does not compile, andmigrateAll()'s runtime validation refuses what the target rejects.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-apiincluded, 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 (missingvalues, unknown kinds).Spec
docs/spec/data-model.md:enumkind, why it is a kind rather than a constraint onstring, and the hash's sort orderenum→stringis additiveenumrow and column in the table and prose, the subset rule and its caveat about in-place wideningStoredContentOf; 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 withoutrecordStoredContentOf; unlisted values carry throughVerification
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
minor. Per AGENTS.md there's no compatibility path; theenumkey is refused, not translated.x === 'want'to'want'whenxincludesstring & {}; thedefaultbranch of aswitchdoes seeUnlistedValue. Passing a read value straight back into a typed write therefore needs the literal, which is the honest typing: the value may be unlisted.migrateAll()catches it at write time, and a typed read already types it asUnlistedValue. Migration output read live (presentAt: 'latest') is not validated for any field; that is outside this PR.page.collection.orderin commons is a natural enum but is left for a separate change. Theformatfields staystringon purpose: they're open vocabularies.@haverstack/adapter-conformancegets aminor: 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