Skip to content

Typed content through type handles derived from the schema #373

Closed

Description

@cuibonobo

Problem

Typed content stops at create(). create<T>() returns content: T, but T isn't checked against the schema, and get, query, mutate, patchContent and subscribe all return Record<string, unknown>. Every read site casts, and a typo in a patch key is only caught at runtime.

Decision

Core derives an app's content type from its schema at compile time, and typed reads and writes go through a type handle.

Core can't contain TypeScript for an app's types, but it owns the schema format, which is small and fixed. So core ships a mapping from that format to TypeScript types, the way zod and TypeBox do. The app writes the schema once, as a literal, and the compiler derives the content type from it. There's no hand-written interface to keep in step with the schema.

const Book = typeHandle('com.example.reading/book@2', {
  title: { kind: 'string', required: true },
  status: { kind: 'string', enum: ['want', 'reading', 'finished', 'abandoned'], required: true },
  pages: { kind: 'number' },
});

type BookContent = ContentOf<typeof Book.schema>;
// { title: string; status: 'want' | 'reading' | 'finished' | 'abandoned'; pages?: number }
  • ContentOf<S> gives the content type. PatchOf<S> gives the contentPatch type: every field optional, and null allowed only on optional fields, since a required field can be replaced but not removed.
  • typeHandle(id, schema) is a plain value carrying the TypeId (Book.id), its family (Book.baseId) and the schema. Book.baseId is there so a call that means the whole family, like migrateAll(Book.baseId), is as easy to write as one that passes Book.id by mistake (Family arguments take a BaseId and refuse a TypeId #369). Because it's a value rather than a defineType() result, StackClient code can use it. Its const type parameter keeps the literal types, so callers don't need as const.
  • Typed reads take the handle, read with presentAt: 'latest', and check typeId against the handle before narrowing. That runtime check is what makes a typed read more than a cast. A record of another Type throws, and so does one whose family has moved past the handle's version (the stale-writer case presentAt: 'latest' already reports).
  • The derived types are a convenience layered on runtime validation, which stays the guarantee. A Type known only at runtime (loaded from a stack, or another app's) has no static shape and stays Record<string, unknown>.

To decide in this issue

Enum values the handle doesn't know

Adding values to an enum is additive within a version (#372). So an older app's typed read can receive a value its derived union excludes, e.g. 'paused' when its type says 'want' | 'reading' | 'finished' | 'abandoned', and a switch the compiler calls exhaustive misses a case. Choose one:

  • Check on read: validate enum fields against the handle's schema and throw on an unknown value. The type stays exact; an older app fails loudly until it upgrades.
  • Wider type: derive 'want' | … | (string & {}) so callers must handle unknown values. Nothing throws; editor completion still offers the known values, but exhaustiveness checks are lost.

Tombstones

Under ScopedStack, a soft-deleted record is served as a tombstone with content: {} (versioning.md § The tombstone is literal). A typed read that returns one would type that empty object as ContentOf<S>, claiming required fields it doesn't have. After #368, a plain get() never returns a tombstone, but get(…, { includeDeleted: true }) and query({ filter: { includeDeleted: true } }) still can. Choose one:

  • Refuse includeDeleted on typed reads. Typed reads only ever see live records; a caller who wants tombstones uses the untyped read.
  • Type the result as a union of a live record with ContentOf<S> and a tombstone (deletedAt: Date, content: {}), so callers narrow on deletedAt before touching content.

Prototype results

A prototype built against core's real FieldDef union (not committed) showed:

  • A compile-time check that core's TypeSchema fits the prototype's schema type passes, so every field kind has a mapping.
  • A test schema using every kind derives exactly the expected type: nested objects, arrays of arrays, and open arrays and objects (unknown[] and Record<string, unknown>).
  • These fail to compile: a typo'd patch key, null on a required field, a value outside the enum, an array with neither items nor open, and an unknown kind.
  • A round trip through a real Stack works, and reading a book through a Shelf handle throws Record … is com.example.reading/book@2, not com.example.reading/shelf@1.
  • Not checked yet: how readable compiler errors are for a subtly wrong nested schema, and editor hover output.

Actions

  • Core: export ContentOf<S>, PatchOf<S>, TypeHandle<S> and typeHandle(). A handle carries id, baseId and schema.
  • Add typed read/write overloads to StackClient that take a handle (get, query, create, mutate/patchContent), implemented on Stack and ScopedStack. Typed reads use presentAt: 'latest' and check typeId.
  • Let defineType() accept a handle's id and schema directly, so one literal serves both.
  • Decide and implement the enum behavior above.
  • Decide and implement the tombstone behavior above, with tests for typed get() and query() under includeDeleted on both Stack and ScopedStack.
  • Type tests in core pinning the mapping for every field kind (compile-time assertions, plus @ts-expect-error cases).
  • Spec, data-model.md: where typed handles and presentAt: 'latest' are documented, a note that a handle names exactly one version, and the enum and tombstone behaviors chosen above.
  • READMEs: show a handle in the quick starts.
  • Changeset: minor for @haverstack/core.

Order

Blocked by #369 (the handle's baseId), #370 (derived types assume fields are never null), #372 (enum unions) and #368 (so a plain typed get() never returns a tombstone).

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