Skip to content

feat(extension-api): let an extension type its own models — 0.9.1 - #140

Merged
rkm1 merged 1 commit into
mainfrom
feat/generic-scoped-db
Aug 13, 2026
Merged

feat(extension-api): let an extension type its own models — 0.9.1#140
rkm1 merged 1 commit into
mainfrom
feat/generic-scoped-db

Conversation

@rkm1

@rkm1 rkm1 commented Aug 13, 2026

Copy link
Copy Markdown
Member

Closes the last open item of A.5. It was deferred as "additive and not a
one-way door" — both true — but it is also the first thing an extension author
meets when they touch data: ctx.db.tenant(tid).extDogProfile resolves through
an index signature, so arguments and results are unknown, and
extDogProfiles compiles and returns undefined at runtime.

type DogModels = { extDogProfile: ScopedOf<Prisma.ExtDogProfileDelegate> };
export const dogExtension: TrellisExtension<DogModels> = { /* … */ };

// then, inside a handler or a job:
const rows = await ctx.db.tenant(tid).extDogProfile.findMany({
  where: { breed: "collie" }, // typed against the schema
});
rows[0].breed;                  // string
ctx.db.tenant(tid).extDogProfiles; // compile error

ScopedOf<T> keeps exactly the thirteen scoped operations T has, with their
real Prisma types, and structurally drops the rest — $queryRaw included, so
the absent raw-SQL escape hatch survives being handed a full delegate.

Additive: every new parameter is defaulted to the previous open index
signature. Hence 0.9.1, not 0.10.0.

The variance problem

ExtensionRouteDefinition.handle and TrellisExtension.extendRecap are now
methods rather than function-typed properties, and that is the load-bearing
part of the diff.

Under strictFunctionTypes a function-typed property compares parameters
contravariantly. A route taking ExtensionContext<DogModels> is therefore not
assignable to the ExtensionContext<OpenScopedModels> core's registry holds —
so the feature would have worked only for extensions with no routes, i.e. not
the ones that want it. Method parameters compare bivariantly.

Measured, not reasoned about. A probe declaring both forms side by side:

error TS2322: Type 'ExtA<DogModels>' is not assignable to type 'ExtA<OpenModels>'.
  Property 'extDogProfile' is missing in type 'OpenModels' but required in type 'DogModels'.

…and no error on the method form. No call signature moved; only the variance.
The soundness given up is theoretical here — core supplies the context, and it
supplies the same proxy either way.

How a type-only feature is gated

A type-only change verified by tests that never typecheck is verification
theatre. expectTypeOf in a vitest file is a runtime no-op, and apps/api's
tsc --build excludes test/ — so the existing convention would have
proved nothing here.

The tests are compiled instead: packages/extension-api/type-tests/, its own
tsconfig.type-tests.json, its own step in the API snapshot gate. They import
through the package name, so they run against the built declarations and
the exports map — the author's path, not a source shortcut.

Every negative case is an @ts-expect-error, which is fail-closed both ways:
if the error stops occurring, tsc reports the directive as unused and the build
fails. And the positive assertions were checked for vacuity by asserting a
deliberately false equality and confirming TS2344, then restoring.

Asserted
ScopedOperation tracks ScopedDelegate exactly the 13 names
ScopedOf keeps the scoped ops with real types args and results, not unknown
ScopedOf drops the rest $queryRaw, fields
a declared map types its own models and rejects a typo
the default surface is unchanged any model name still resolves
the 9 core models are exactly the named half
a typed extension fits core's untyped registry the variance property
core can dispatch into its routes and jobs with an open context
the constraint rejects a non-delegate value

What this deliberately does not do

The default stays open. An extension that declares no map keeps the
misspelling hazard. That is the price of the change being additive. Closing the
default is a later minor and cheap — there is exactly one extension, and once
it has opted in the flip costs nothing.

Notes

  • The five-source version gate caught the reference doc's version callout
    before this reached CI. The doc gained a "Typing your own models" section
    rather than just a bumped number.
  • Lockfile regenerated with npm 11. npm 10 rewrites twenty unrelated
    "dev": true markers onto esbuild's optional platform packages; that would
    have shipped as noise in a one-line change.

Verified

extension-api build · apps/api tsc --build · type tests · both public API
snapshots · version lockstep gate · npm ci --dry-run · smoke-pack.sh (21
entry points, ESM + CJS load of the packed tarball, version 0.9.1) · 98 unit
tests across the four extension-surface suites.

🤖 Generated with Claude Code

Closes the last open item of A.5, deferred at the time as "additive and not a
one-way door". It is both of those, and it is also the item an extension author
hits on their first line of data access: `ctx.db.tenant(tid).extDogProfile`
resolves through an index signature, so its arguments and results are `unknown`
and `extDogProfiles` compiles.

`ScopedDb` now takes an optional model map. `ScopedOf<T>` narrows a generated
Prisma delegate to the thirteen scoped operations, keeping their real argument
and result types and structurally dropping everything else — `$queryRaw`
included, so the absence of a raw-SQL escape hatch survives being handed a full
delegate:

    type DogModels = { extDogProfile: ScopedOf<Prisma.ExtDogProfileDelegate> };
    export const dogExtension: TrellisExtension<DogModels> = { ... };

The parameter threads through ExtensionDb, ExtensionContext,
ExtensionJobContext, ExtensionRouteDefinition, ExtensionJobDecl,
ExtensionHandler and TrellisExtension, every one defaulted to the previous open
index signature. Nothing that omits it changes, which is why this is 0.9.1 and
not 0.10.0.

THE VARIANCE PROBLEM, which is why this looked bigger than a type parameter.

`ExtensionRouteDefinition.handle` and `TrellisExtension.extendRecap` are now
declared as methods rather than function-typed properties. Under
`strictFunctionTypes` a function-typed property compares its parameters
contravariantly, so a route taking `ExtensionContext<DogModels>` is not
assignable to the `ExtensionContext<OpenScopedModels>` that core's registry
holds — the feature would have been unusable for precisely the extensions that
want it, and usable only for extensions with no routes. Method parameters
compare bivariantly.

This was measured, not reasoned about: a probe declaring both forms side by
side fails on the property form with "Property 'extDogProfile' is missing in
type 'OpenScopedModels'" and compiles on the method form. Nothing about the
call signatures moved; only the variance.

The soundness given up is theoretical here. Core supplies the context, and it
supplies the same proxy either way.

HOW THIS IS GATED, given that nothing about it exists at runtime.

A type-only feature verified by tests that never typecheck is verification
theatre. `expectTypeOf` in a vitest file is a runtime no-op, and apps/api's
`tsc --build` excludes `test/` — so the existing convention would have proved
nothing at all here.

So the tests are compiled instead, by their own tsconfig, as their own CI step
in the API snapshot gate. They import through the package name rather than
`../src`, exercising the built declarations and the `exports` map along the
author's path. Every negative case is an `@ts-expect-error`, which is
fail-closed in both directions: if the error stops occurring, tsc reports the
directive as unused and the build fails.

Verified the assertions are not vacuous by asserting a deliberately false
equality and confirming it fails (TS2344), then restoring.

Covered: ScopedOperation tracks ScopedDelegate; ScopedOf keeps the scoped ops
with real types and drops `$queryRaw`/`fields`; a declared map types its models
and rejects a typo; the default surface still admits any model name; the nine
core models are exactly the named half; a typed extension is assignable to
core's untyped registry and core can dispatch into its routes and jobs; a
non-delegate value is rejected by the constraint.

WHAT THIS DOES NOT DO. The default stays open, so an extension that declares
no map keeps the misspelling hazard. That is the price of the change being
additive; closing the default is a later minor, cheap once the only extension
has opted in.

Also: the five-source version gate earned its place again, failing on the
reference doc's version callout before this was pushed anywhere. The doc gained
a "Typing your own models" section rather than just a bumped number.

Lockfile regenerated with npm 11 — npm 10 rewrites twenty unrelated `"dev":
true` markers on esbuild's optional platform packages, which would have shipped
as noise.

Verified: extension-api build, apps/api `tsc --build`, type tests, both public
API snapshots, the version lockstep gate, `npm ci --dry-run`, smoke-pack (21
entry points + ESM/CJS load of the packed tarball at 0.9.1), and 98 unit tests
across the four extension-surface suites.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rkm1
rkm1 merged commit 71b9b32 into main Aug 13, 2026
13 checks passed
@rkm1
rkm1 deleted the feat/generic-scoped-db branch August 13, 2026 04:55
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.

1 participant