Repository navigation
Description
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,undeleteandrestoreVersionreturn 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 overAPIAdapter. - 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
@1isn't delivered. Nothing at the call site hints at the difference. - A typed
get()of another Type's ID throwsStackBadRequestError(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
StackValidationErrorwhen a record holds an enum value the handle doesn't list, andquery(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 atpresentAt: '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. UnderScopedStackaremovedchange carries the tombstone projection,content: {}(versioning.md § The tombstone is literal). Today's typedsubscribe()narrows it, so a handler receives a record typed as the handle's content with no required fields. UnscopedStacksends 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 onStackClient. - 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 throughpresentAtLatest(), 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
getwould. The write already returned the record, so narrowing it costs no round trip. ArestoreVersion()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. Atypealias doesn't survive, becauseContentOfflattens 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
StackNotFoundErrorbefore 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 answersnull. collection()refuses a handle whosemigratesFromhas no registered migration (from Handles carry their lineage; typed migrations are passed at open #407), withStackMigrationErrornaming 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
ifVersionfences 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 ordinaryStackVersionConflictError; the migration that already landed is whatmigrateAll()would have done anyway. - When the caller may not migrate (a friend holding
write), the patch is refused with nothing written, with aStackPermissionErrorsaying the record is stored at@1and that only the owner or the app's install can migrate it. - The main source is a restore.
restoreVersion()keeps a pre-migration snapshot'stypeId, 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@1schema; and making a collection'srestoreVersion()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.misfitquery(): misfits go inmisfits, so a page can hold fewer thanlimitrecords with a cursor still set.records.length < limitis not the end of the results;cursor === nullis. The page and cursor survive, and the derived union stays exact: only records that fit reachrecords.get(): a single read the app asked for by ID should be loud, so it throwsMisfitErrorcarrying the samereasonandrecord.subscribe(): covers the family. A change to an older record has itsrecordmigrated in memory; the change's owntypeIdstays the stored one, asRecordChange.typeIddocuments. A change whose record doesn't fit carriesmisfitinstead ofrecord. A change whose record hasdeletedAtset never carries a typedrecord, on either surface.- Writes: a content write to a newer-version record is refused with
MisfitErrorbefore anything is written, since its patch would be validated against a schema the handle doesn't know. Otherwise the result is narrowed like aget(): a write that produces a misfit succeeds and then throwsMisfitErrorwith the stored record, so the app knows the write landed. MisfitErrorsits outside theStackErrorhierarchy. It's raised only in the app's process and has no wire representation, so perwire-format.md § The taxonomy rootit carries noStackprefix.
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: 2still reaches every record. - A subscription delivers a
@1change migrated to@2, and delivers unknown-enum and@3changes withmisfitset. - Migrate-then-patch leaves journal entries
create,migrate,patch; a staleifVersionfences the migration with nothing written; revert-then-patch works; a friend withwritebut 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)toStackClient, implemented once soStackandScopedStackshare it, using the public verbs, Stack checks a typed collection's write expectation #408's write expectation and theStack's migration registry. ExportCollection<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.
-
MisfitandMisfitError; addMisfitErrortowire-format.md § The taxonomy root's list of errors outside the hierarchy. -
query()returnsmisfits;get()answersnulloutside the family and throwsMisfitErrorfor a misfit;subscribe()covers the family, migrates event records, never types a tombstone, and carriesmisfit. - 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 whosemigratesFromhas no registered migration. - Delete the handle-first overloads of
get,query,create,mutate,patchContentandsubscribe,TypedQuery,TypedSubscribeOptions,TypedChangeSet, and the overload helpers intype-handle.ts(typedGet,typedQuery,typedMutate,typedSubscribeand the rest). - Leave
filter.content,filter.contentPresentandsort.contentFieldwith 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 isnull; every write verb returnsCollectionRecord<C>; aScopedStacktombstone 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:
minorfor@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
cuibonobo commentedon Oct 10, 2026
A note from #431 (the #408 write expectation) for whoever picks this up.
There's no typed way to pass the expectation yet.
WRITE_EXPECTATIONlives inpackages/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 aStackClientdoesn't typecheck.StackClient,associate,dissociate,amendAssociations,grantAccess,revokeAccessandamendAccesstake no options argument at all.StackandScopedStackaccept one as an optional trailing argument, but the interface doesn't show it.typedMutate, cast withas never.Since
collection()is meant to be implemented once overStackClient, I'd suggest adding an internal type towrite-expectation.tsinstead of scattering casts. It would redeclare the write verbs withExpectationOptionsadded 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 neverin the tests can go too.typedMutateis deleted by this issue anyway.Generated by Claude Code