Skip to content

feat(core): typed access through collections, with misfits reported - #446

Merged
cuibonobo merged 3 commits into
mainfrom
feat/collections
Oct 11, 2026
Merged

cuibonobo merged 3 commits into
mainfrom
feat/collections

Conversation

@cuibonobo

Copy link
Copy Markdown
Member

Summary

Closes #409.

client.collection(handle) returns a Collection<C> typed by the handle's content, with one signature per verb: get, query, subscribe, create, mutate, patchContent, associate, dissociate, amendAssociations, grantAccess, revokeAccess, amendAccess, delete, undelete and restoreVersion. The handle-first overloads on StackClient are deleted, along with TypedRecord, TypedQuery, TypedChange, TypedChangeSet, TypedSubscribeOptions, the typed* helpers and isTypeHandle.

  • Scope is the whole family. Each record is read as stored and migrated in memory to the handle's version through the opening Stack's registry. It does not go through presentAt: 'latest'.
  • Misfits are reported, not thrown, for a page. A record stored at a version newer than the handle's goes in query()'s misfits, and the page and cursor survive. get() throws MisfitError, and subscribe() sets misfit on the change. MisfitError sits outside the StackError taxonomy.
  • Every write returns a typed record without a re-read. A write outside the family is refused with StackNotFoundError via Stack checks a typed collection's write expectation #408's write expectation, and get() of the same ID answers null.
  • Migrate-then-patch. A content write to an older record first commits the migration with commitMigration(), then applies the patch, so the journal reads migrate then patch. The caller's ifVersion fences the migration, and the patch is fenced to the version the migration produced. A caller who may not migrate gets StackPermissionError naming the stored version, and nothing is written.
  • Missing migrations are caught up front. collection() refuses a handle whose migratesFrom has no registered migration.
  • Following the comment on Typed access through collections, with records that don't fit reported rather than thrown #409, an internal ExpectingClient type in write-expectation.ts replaces the as never casts.

Spec

  • docs/spec/data-model.md § Type handles is rewritten around the collection: scope, verbs, misfits, short pages, writes outside the family, and migrate-then-patch.
  • docs/spec/data-model.md § Type migrations gains a sentence on a collection's patch to an older record.
  • docs/spec/wire-format.md § The taxonomy root now lists MisfitError.

Verification

pnpm run format:check, check:refs, lint, test, build and typecheck all pass. The new packages/core/tests/collection.test.ts runs against both Stack and ScopedStack and covers everything under the issue's "Prototype results", plus:

  • get() of another Type's ID is null
  • every write verb returns CollectionRecord<C>
  • a tombstone change carries no typed record
  • collection() throws when a migration is missing
  • a friend holding write is refused the migrate-then-patch

Notes for reviewers

  • Departure from the issue: there is no unknown-enum misfit. feat(core): make enum its own field kind #433, which merged after the issue was written, settled that reads type an enum as its listed values or UnlistedValue, so a record holding an unlisted value is an ordinary record. String enums can be declared open #411's premise needs revisiting as a result.
  • Collection<C> is typed by write content (ContentOf<S>). The read content (StoredContent<C>) and the patch shape (ContentPatch<C>) are worked out from C, so an app's interface name is kept.
  • CollectionQuery<C> doesn't use C yet; it is reserved for Collection filters and sorts are checked against the content type #410.
  • When a subscription sees an older record that no migration reaches, it delivers the change without record instead of throwing in the emitter.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Nt3xYmwF4FW5kEUAPqgjYo


Generated by Claude Code

client.collection(handle) replaces the handle-first overloads on
StackClient. A collection spans the handle's whole family, migrates each
record in memory to the handle's version, and returns a typed record from
every write verb without a re-read. A record stored at a newer version is
a misfit: query() reports it beside the page, get() throws MisfitError,
subscribe() carries it as `misfit`. A content write to an older record
commits its migration as its own write first.

Enums keep reading as their listed values or UnlistedValue, as settled in
#433, so 'newer-version' is the only misfit reason.

Closes #409

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nt3xYmwF4FW5kEUAPqgjYo
@changeset-bot

changeset-bot Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 340b91b

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/commons Patch
@haverstack/adapter-api Patch
@haverstack/adapter-conformance Patch
@haverstack/adapter-local Patch
@haverstack/blob-adapter-disk Patch
@haverstack/blob-adapter-s3 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

From review of #446:

- subscribe() delivers a change that migrates a record out of the family
  without its record, instead of reading it as a newer-version misfit, and
  delivers a change whose migration function throws the same way.
- A restoreVersion() under a write expectation refuses a snapshot from
  another family before writing, as a version not found.
- query() drops typeId and presentAt from an untyped caller.
- Spec: content filters and sorts apply to content as stored; a failed
  patch after a migration leaves the migration; the 500 row names the
  collection's StackMigrationError sources.
- Changeset names @haverstack/commons, whose README and source changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nt3xYmwF4FW5kEUAPqgjYo
From a second review of #446:

- PatchOf is deleted. A collection's patch type is ContentPatch<C>,
  derived from its content type, and the spec now names it and
  StoredContent<C> alongside ContentOf and StoredContentOf.
- A collection's subscribe() drops a filter's typeId from an untyped
  caller, as query() does, instead of narrowing the family to one version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nt3xYmwF4FW5kEUAPqgjYo
@cuibonobo
cuibonobo merged commit 2b2232a into main Oct 11, 2026
9 checks passed
@cuibonobo
cuibonobo deleted the feat/collections branch October 11, 2026 18:08
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.

Typed access through collections, with records that don't fit reported rather than thrown

2 participants