Skip to content

Handles carry their lineage; typed migrations are passed at open #407

Closed

Description

@cuibonobo

Problem

  • Migrations are untyped. registerMigration({ from: BookV1.id, to: Book.id, migrate }) takes TypeIds, and migrate receives and returns Record<string, unknown>. The one place two schema versions meet is the one place with no type help: a migration that forgets a new required field is caught only when migrateAll() validates its output.
  • The handle doesn't carry name or migratesFrom. Every defineType() and every manifest entry repeats them beside the spread handle ({ ...Book, name: 'Book', migratesFrom: BookV1.id }). migratesFrom is part of what a version is. @haverstack/commons works around the same gap with its own CommonsType wrapper (a handle plus name).
  • Registration is a side effect the app must remember on every start. The registry lives in memory on each Stack, so apps end up with a separate "register migrations" function called after every Stack.open(), and a read before it sees an empty registry.

Decision

A handle carries the same fields defineType() takes: id, name, schema and optional migratesFrom. It stays plain data. A migration is a separate typed value built from two handles and passed to Stack.open(). registerMigration() is deleted.

export const Book = typeHandle({
  id: 'com.example.reading/book@2',
  name: 'Book',
  migratesFrom: BookV1, // a handle or a TypeId; stored as the TypeId
  schema: { … },
});

export const bookV1ToV2 = migration(BookV1, Book, (c) => ({
  ...c,
  status: c.status === 'done' ? 'finished' : c.status,
})); // c: ContentOf<V1>, must return ContentOf<V2>

const stack = await Stack.open(adapter, { migrations: [bookV1ToV2] });
await stack.defineType(Book);
const manifest = { …, types: [Shelf, BookV1, Book, Review] };

Why the handle doesn't carry the migration function too

  • Manifests need data. A manifest is sent over the wire (POST /installs) and a plan holds a frozen copy of it. A handle holding a function would need stripping first, or would rely on JSON.stringify dropping it silently. apps.md already says migration functions are app code, not part of a manifest.
  • It wouldn't remove the startup step. Registering when a handle is first used makes behaviour depend on call order: an untyped read or migrateAll(Book.baseId) before any typed use wouldn't know the chain. Fixing that needs an explicit "use this handle" call, which is registerMigration() under another name.
  • Handles get shared. A handle published so others can read a family (a commons type, say) shouldn't carry one app's migration code.
  • The typing doesn't need it. migration(from, to, fn) takes both handles, so fn is checked as ContentOf<V1> → ContentOf<V2>.

Why at open

That's when the app can name everything it needs, and it replaces a call the app had to remember on every start. The registry is complete before the first read, so call order stops being a source of bugs.

Consequences

  • defineType(Book) and manifests take the handle as-is; name and migratesFrom are written once.
  • migration(from, to, fn) refuses a to whose migratesFrom isn't from.id, so a migration can't be paired with the wrong handle.
  • A handle whose migratesFrom has no registered migration can't be caught in defineType(): an owner applying another app's manifest defines types whose migrations live in that app's process. The check belongs to Typed access through collections, with records that don't fit reported rather than thrown #409, where collection() runs in the app's own process.

Actions

  • typeHandle() takes { id, name, schema, migratesFrom? }, with migratesFrom as a handle or a TypeId, stored as the TypeId. TypeHandle<S> is assignable to DefineTypeOptions.
  • Add migration(from, to, fn), returning a typed Migration. Add migrations to StackOptions. Delete registerMigration(). Keep the duplicate-from refusal, at open.
  • @haverstack/commons: drop CommonsType; its exports become plain handles with name.
  • Type tests: a migration returning content missing a required field, or with a value outside an enum, fails to compile.
  • Move every test that calls registerMigration() to the open option (at least stack.test.ts, change-events.test.ts, install.test.ts, type-handle.test.ts).
  • Spec: data-model.md § Type migrations (migrations passed at open) and § Type handles (what a handle carries). In apps.md, the line saying migration functions are "registered at every startup with registerMigration()" becomes "passed to Stack.open()".
  • Changeset: minor for @haverstack/core and @haverstack/commons.

Order

Early in #405: it rewrites every typeHandle() call and every test that registers a migration, so #409's tests are written against the final handle and open options. Independent of #408; both must land before #409.

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