Skip to content

feat(core): type a collection's content filters and sorts from its content type - #447

Merged
cuibonobo merged 4 commits into
mainfrom
claude/laughing-cannon-ylgf21
Oct 12, 2026
Merged

cuibonobo merged 4 commits into
mainfrom
claude/laughing-cannon-ylgf21

Conversation

@cuibonobo

@cuibonobo cuibonobo commented Oct 11, 2026 •

Copy link
Copy Markdown
Member

Summary

Closes #410.

Inside a collection, filter.content, filter.contentPresent and sort.contentField were untyped, so { stauts: 'want' } or { status: 'wnat' } compiled and silently returned nothing. They're now derived from the content type C:

  • ContentPathOf<C> (exported): top-level fields plus dotted paths through declared objects and arrays; any suffix below an open object/array. Filter values are the field's type as a write takes it (an array's element type, since matching is element-wise) or null. A path reaching an object takes only null — adapters compare the value a path reaches, so an object value never matches — and a value below an open node is any scalar.
  • SortableFieldOf<C> (exported): top-level scalars only.
  • Path typing covers six segments and accepts any suffix below that, avoiding TS2589. Array nesting counts toward the depth.
  • A string-indexed content type (an untyped handle) still accepts any path and sort field.

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 collection query() with a non-empty content/contentPresent or a contentField sort reads only typeId = handle.id. Its first page carries skipped: { older, newer }, set when records at another version match the rest of the query. Each flag is one limit: 1 read 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 one listTypes() (passed in by Stack and ScopedStack), and every read passes concrete typeIds, so nothing underneath re-lists types. A query with no content keys still reads the whole family; the untyped query({ 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, skipped on 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 both Stack and ScopedStack:

  • type tests: @ts-expect-error for 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 and open paths compile, as does null on an object path and any suffix past six segments.
  • runtime: a content filter returns only @2 records and flags both @1 and @3; content sort and contentPresent narrow the same way; skipped is on the first page only and honours the rest of the filter; empty content keys read the whole family; an undefined content value narrows, since every adapter reads it as null.

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

…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-bot

changeset-bot Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5ca55b0

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

This PR includes changesets to release 10 packages
Name Type
@haverstack/core Minor
@haverstack/adapter-api Patch
@haverstack/adapter-conformance 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

…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
@cuibonobo
cuibonobo merged commit 30f8e7d into main Oct 12, 2026
9 checks passed
@cuibonobo
cuibonobo deleted the claude/laughing-cannon-ylgf21 branch October 12, 2026 17:56
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.

Collection filters and sorts are checked against the content type

2 participants