Skip to content

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

Closed

Description

@cuibonobo

Problem

Typing was added as handle-first overloads on StackClient (#373), and most of what still hurts traces back to that shape:

  • Typing reaches only the verbs that got an overload. associate, dissociate, amendAssociations, grantAccess, revokeAccess, amendAccess, undelete and restoreVersion return untyped records, so a domain method that wants to return a typed record re-reads it through the handle: an extra round trip per call over APIAdapter.
  • Every compile error is TS2769. With two signatures per call, a typo'd field reports "No overload matches this call" and prints the expanded schema once per overload: 5 lines and about 1,400 characters for one misspelled key. The useful "Did you mean 'status'?" is buried.
  • Each typed operation decides its own scope. A typed query spans the family and migrates up; a typed subscription matches only the handle's exact version, so a change to a book still stored at @1 isn't delivered. Nothing at the call site hints at the difference.
  • A typed get() of another Type's ID throws StackBadRequestError (a 400 over the wire), so "not a book" needs a try/catch where apps want to treat it like "not found".
  • One record can blank a list. A typed read throws StackValidationError when a record holds an enum value the handle doesn't list, and query(handle) runs that check on every record. One book written by a newer app version with a new status makes the whole page throw, and an older app can't page past it because the cursor never comes back. Typed queries read at presentAt: 'latest', which throws for the whole page in the same way when one record is stored at a version newer than the app understands, so this isn't only about enums.
  • Typed subscribe() types a tombstone as content. Under ScopedStack a removed change carries the tombstone projection, content: {} (versioning.md § The tombstone is literal). Today's typed subscribe() narrows it, so a handler receives a record typed as the handle's content with no required fields. Unscoped Stack sends the full content instead.

Decision

The handle becomes the receiver

client.collection(handle) returns a Collection<C>, typed by the handle's content C = ContentOf<S>, whose methods all take and return the handle's types with one signature each. The handle-first overloads on StackClient are deleted. The untyped StackClient is otherwise unchanged and stays the API for Types known only at runtime.

const books = client.collection(Book);

await books.create({ title: 'Dune', author: 'Frank Herbert', status: 'want' }, { appId });
await books.get(id); // CollectionRecord<BookContent> | null
await books.query({ filter: { tags: ['fantasy'] } }); // { records, misfits, cursor }
await books.patchContent(id, { status: 'reading' });
await books.associate(id, [{ kind: 'tag', label: 'fantasy' }]); // typed, no re-read
await books.subscribe((change) => …);
  • Verbs: get, query, subscribe; create, mutate, patchContent, associate, dissociate, amendAssociations, grantAccess, revokeAccess, amendAccess, delete, undelete, restoreVersion. History (getVersions, getVersion, getJournal) spans versions by nature and stays on StackClient.
  • Scope is the handle's whole family, for reads and subscriptions alike. Every record is migrated in memory to the handle's version, one record at a time, through the Stack's registry. Not through presentAtLatest(), which migrates to the newest version the instance has defined and throws for a whole page.
  • Every method that returns a record returns it the way get would. The write already returned the record, so narrowing it costs no round trip. A restoreVersion() to a pre-migration snapshot reads back migrated, like any older record.
  • Typed by content, not schema. With the schema as the type parameter, hovering an inferred collection printed about 25 lines of field definitions. Typed by content it prints the 8-line content shape, and an app that names its content with an interface (interface BookContent extends ContentOf<typeof Book.schema> {}) sees that name. A type alias doesn't survive, because ContentOf flattens its result. So the outer types (Collection, CollectionRecord, CollectionPage, CollectionQuery) must never flatten their parameter.
  • Writes only reach records in the collection. A write to an ID outside the family is refused with StackNotFoundError before anything is written, via Stack checks a typed collection's write expectation #408's write expectation, at no extra read. get() of the same ID answers null.
  • collection() refuses a handle whose migratesFrom has no registered migration (from Handles carry their lineage; typed migrations are passed at open #407), with StackMigrationError naming the missing pair. It runs in the app's own process, which must have the migration.

A patch to a record stored at an older version migrates it first, as its own write

A patch is validated against the record's stored Type, and data-model.md § Type migrations rules out folding a migration into a patch: "an unrelated content edit can never fold an invisible schema rewrite into the same version-history entry." So when the caller may commit migrations (the owner, or an installed app within its own families), the collection calls commitMigration() with the migrated content and then applies the patch: two versions and two journal entries (migrate, then patch), the same history migrateAll() followed by the patch would leave. #408 tells the collection the record is at an older version, so this costs nothing on the normal path.

  • Fencing: the caller's ifVersion fences the migration, since that's the state they read. The patch is fenced to the version the migration produced. A write landing in between fails the patch with the ordinary StackVersionConflictError; the migration that already landed is what migrateAll() would have done anyway.
  • When the caller may not migrate (a friend holding write), the patch is refused with nothing written, with a StackPermissionError saying the record is stored at @1 and that only the owner or the app's install can migrate it.
  • The main source is a restore. restoreVersion() keeps a pre-migration snapshot's typeId, so reverting a record and then editing it is an ordinary sequence. Restore itself stays as it is.
  • Rejected: refusing and pointing at migrateAll(), which most callers can't run; one combined write, which the spec rule forbids; validating a @2-typed patch against the @1 schema; and making a collection's restoreVersion() re-migrate, which covers only one way a record ends up at an older version.

Records that don't fit are reported, not thrown

A misfit is a record in the handle's family that the handle can't represent:

  • unknown-enum: an enum field holds a value the handle doesn't list.
  • newer-version: the record is stored at a version newer than the handle's, so it can't be migrated down.

A record of another Type isn't a misfit; it isn't in the collection. A record at an older version with no migration to bridge the gap isn't one either: that's the app's setup, and it keeps throwing StackMigrationError.

type Misfit = {
  id: RecordId;
  reason: 'unknown-enum' | 'newer-version';
  record: StackRecord; // untyped, as stored
  errors?: ValidationError[]; // which fields, for unknown-enum
};

books.query(…);     // { records: CollectionRecord<C>[]; misfits: Misfit[]; cursor: string | null }
books.get(id);      // CollectionRecord<C> | null; throws MisfitError for a misfit
books.subscribe(h); // change.record, or change.misfit
  • query(): misfits go in misfits, so a page can hold fewer than limit records with a cursor still set. records.length < limit is not the end of the results; cursor === null is. The page and cursor survive, and the derived union stays exact: only records that fit reach records.
  • get(): a single read the app asked for by ID should be loud, so it throws MisfitError carrying the same reason and record.
  • subscribe(): covers the family. A change to an older record has its record migrated in memory; the change's own typeId stays the stored one, as RecordChange.typeId documents. A change whose record doesn't fit carries misfit instead of record. A change whose record has deletedAt set never carries a typed record, on either surface.
  • Writes: a content write to a newer-version record is refused with MisfitError before anything is written, since its patch would be validated against a schema the handle doesn't know. Otherwise the result is narrowed like a get(): a write that produces a misfit succeeds and then throws MisfitError with the stored record, so the app knows the write landed.
  • MisfitError sits outside the StackError hierarchy. It's raised only in the app's process and has no wire representation, so per wire-format.md § The taxonomy root it carries no Stack prefix.

Rejected for enum values: widening every enum to 'want' | … | (string & {}) (loses exhaustiveness everywhere and does nothing for newer-version records; offered as an opt-in in #411), coercing unknown values to a fallback (the UI would show something the data doesn't say), and making every enum widening a version bump (the record becomes a newer-version misfit instead).

Prototype results

A prototype collection built only on the public StackClient API (not committed) ran an example app end to end and confirmed:

  • The app's re-read helper disappeared: tagging, covers, restore and revert return typed records straight from the write.
  • Compile errors went from TS2769, 5 lines and ~1,400 characters, to a single line of 140–460 characters ending in "Did you mean…".
  • A page holding an unknown-enum record and a newer-version record returns the rest plus two misfits, and paging with limit: 2 still reaches every record.
  • A subscription delivers a @1 change migrated to @2, and delivers unknown-enum and @3 changes with misfit set.
  • Migrate-then-patch leaves journal entries create, migrate, patch; a stale ifVersion fences the migration with nothing written; revert-then-patch works; a friend with write but no migration rights is refused with the record unchanged.
  • Typing by content instead of schema changed no test outcome or compile check.

Actions

  • Add collection(handle) to StackClient, implemented once so Stack and ScopedStack share it, using the public verbs, Stack checks a typed collection's write expectation #408's write expectation and the Stack's migration registry. Export Collection<C>, CollectionRecord<C>, CollectionPage<C>, CollectionQuery<C>, CollectionChange<C>. None flattens its parameter.
  • Read as stored and migrate each record to the handle's version through the registry, classifying records one at a time.
  • Misfit and MisfitError; add MisfitError to wire-format.md § The taxonomy root's list of errors outside the hierarchy.
  • query() returns misfits; get() answers null outside the family and throws MisfitError for a misfit; subscribe() covers the family, migrates event records, never types a tombstone, and carries misfit.
  • Migrate-then-patch for content writes, fenced as above; refuse a caller who may not migrate with nothing written; refuse a content write to a newer-version record with MisfitError.
  • collection() refuses a handle whose migratesFrom has no registered migration.
  • Delete the handle-first overloads of get, query, create, mutate, patchContent and subscribe, TypedQuery, TypedSubscribeOptions, TypedChangeSet, and the overload helpers in type-handle.ts (typedGet, typedQuery, typedMutate, typedSubscribe and the rest).
  • Leave filter.content, filter.contentPresent and sort.contentField with their untyped shapes; Collection filters and sorts are checked against the content type #410 types them.
  • Tests: everything under "Prototype results"; get() of another Type's ID is null; every write verb returns CollectionRecord<C>; a ScopedStack tombstone change carries no typed record; collection() with a missing migration throws.
  • Spec, data-model.md § Type handles: rewrite around the collection (scope, verbs, misfits, short pages, writes outside the family refused as not found, migrate-then-patch). § Type migrations: a sentence that a collection's patch to an older record commits the migration as its own write first.
  • Changeset: minor for @haverstack/core.

Order

Blocked by #407 (tests use the final handle and open options; the missing-migration check needs migratesFrom on the handle) and #408 (writes outside the family, and older-version detection, without extra reads).

Activity

  1. added theissue type on Oct 5, 2026
  2. cuibonobo commented on Oct 10, 2026

    @cuibonobo
    MemberAuthor

    A note from #431 (the #408 write expectation) for whoever picks this up.

    There's no typed way to pass the expectation yet. WRITE_EXPECTATION lives in packages/core/src/write-expectation.ts. It isn't exported from the package, which is fine because the collection lives in core and can import it directly. The gap is in the types:

    • StackClient's verb signatures never declare the symbol key, on purpose, so passing { [WRITE_EXPECTATION]: … } through a StackClient doesn't typecheck.
    • On StackClient, associate, dissociate, amendAssociations, grantAccess, revokeAccess and amendAccess take no options argument at all. Stack and ScopedStack accept one as an optional trailing argument, but the interface doesn't show it.
    • For now the tests, and typedMutate, cast with as never.

    Since collection() is meant to be implemented once over StackClient, I'd suggest adding an internal type to write-expectation.ts instead of scattering casts. It would redeclare the write verbs with ExpectationOptions added to each one's options, and the collection would cast the client to it once, in one place. That keeps the key off the public surface and type-checks every call site that passes an expectation.

    If that lands, the as never in the tests can go too. typedMutate is deleted by this issue anyway.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions