Repository navigation
feat(core): type a collection's content filters and sorts from its content type - #447
Merged
Merged
Conversation
…ntent type filter.content, filter.contentPresent and sort.contentField were untyped inside a collection, so a typo'd field or enum value compiled and silently matched nothing. They are now derived from the content type C: - ContentPathOf<C>: top-level fields plus dotted paths through declared objects and arrays, any suffix below an open node, typed six segments deep to stay within the compiler's instantiation limit. - SortableFieldOf<C>: top-level scalars only. Matching still runs on content as stored; the spec says so and points at migrateAll() for records at an older version. Closes #410 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KQtAGzBB8fDpfqZhc84ZdN
🦋 Changeset detectedLatest commit: 5ca55b0 The changes in this PR will be included in the next version bump. This PR includes changesets to release 10 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 |
…ndle's version A content filter or sort runs on content as stored, and a migration is an opaque function, so a predicate typed in the handle's shape cannot be translated into an older version's: run across the family, it silently missed or misplaced records whose field a migration renamed or remapped. A collection query with a content filter or sort now reads only records stored at the handle's version, and its page sets skippedOlder when older records match the rest of the query. Declarative migrations that would let predicates span versions are filed as #448. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KQtAGzBB8fDpfqZhc84ZdN
Review findings on #447: - A narrowed page now reports records at a newer version too, as skipped.newer, where they were dropped with no signal. The family's versions come from listTypes(), passed in by Stack and ScopedStack, rather than counted down from the handle's version. - The probe is one limit-1 read per side, run beside the page's own; an empty scoped page with a cursor counts as a match instead of paging on. - Path typing covered seven segments where the docs say six. - contentPresent and contentField use ContentPathOf and SortableFieldOf; both print expanded unions in errors, so the inline copy was not needed. - The spec notes that filtering for an unlisted enum value takes a cast. An undefined content value still narrows: every adapter reads it as null. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KQtAGzBB8fDpfqZhc84ZdN
Second review on #447: - A filter value at a path reaching an object compiled but never matched, since adapters compare the value a path reaches. Such a path now takes only null, and a value below an open node any scalar. - skipped is computed on the first page only; it depends on the query. - The narrowed read and the probes pass concrete typeIds without baseId, so the family's types are listed once per first page rather than on every read underneath. - Family versions come from StackType.version in one pass. - The spec says the untyped query() still matches across the family. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KQtAGzBB8fDpfqZhc84ZdN
This was referenced Oct 12, 2026
Merged
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
Closes #410.
Inside a collection,
filter.content,filter.contentPresentandsort.contentFieldwere untyped, so{ stauts: 'want' }or{ status: 'wnat' }compiled and silently returned nothing. They're now derived from the content typeC:ContentPathOf<C>(exported): top-level fields plus dotted paths through declared objects and arrays; any suffix below anopenobject/array. Filter values are the field's type as a write takes it (an array's element type, since matching is element-wise) ornull. A path reaching an object takes onlynull— adapters compare the value a path reaches, so an object value never matches — and a value below anopennode is any scalar.SortableFieldOf<C>(exported): top-level scalars only.Content filters and sorts reach only the handle's version. Matching runs on stored content, and a migration is an opaque function, so a predicate in
@2's shape can't be translated into another version's; run family-wide, a rename or remapped enum value silently missed records. So a collectionquery()with a non-emptycontent/contentPresentor acontentFieldsort reads onlytypeId = handle.id. Its first page carriesskipped: { older, newer }, set when records at another version match the rest of the query. Each flag is onelimit: 1read run beside the page's own; an empty scoped page with a cursor counts as a match rather than paging on. The family's versions come from onelistTypes()(passed in byStackandScopedStack), and every read passes concrete typeIds, so nothing underneath re-lists types. A query with no content keys still reads the whole family; the untypedquery({ filter: { baseId, content } })still matches across versions, as stored.Spec
docs/spec/data-model.md § Type handles: the narrowing rule and why (opaque migrations; mixed versions are lasting, since an older app may keep writing), the untyped cross-version escape hatch,skippedon the first page, the key and value typing with its six-segment typing limit vs. the 32-segment query limit, and that filtering for an unlisted enum value takes a cast.Verification
All six:
format:check,check:refs,lint,test,build,typecheck— clean.Tests in
packages/core/tests/collection.test.ts, on bothStackandScopedStack:@ts-expect-errorfor a typo'd field, an off-enum value, a wrong nested value type, a path through a scalar, object values (declared, array element, open), a typo'd presence path, nested/object/typo'd sort fields, and a typo at the sixth segment; nested, array-of-object andopenpaths compile, as doesnullon an object path and any suffix past six segments.@2records and flags both@1and@3; content sort andcontentPresentnarrow the same way;skippedis on the first page only and honours the rest of the filter; empty content keys read the whole family; anundefinedcontent value narrows, since every adapter reads it asnull.Notes for reviewers
Alternatives weighed: refusing content filters while other versions exist (refuses fields no migration touched, locks out apps that can't migrate), and typing keys against every version (a union doesn't fix the miss; an intersection permanently blocks renamed fields). The real fix — declarative migrations that let core rewrite a predicate per version — is filed as #448.
🤖 Generated with Claude Code
https://claude.ai/code/session_01KQtAGzBB8fDpfqZhc84ZdN