feat(extension-api): let an extension type its own models — 0.9.1 - #140
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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).extDogProfileresolves throughan index signature, so arguments and results are
unknown, andextDogProfilescompiles and returnsundefinedat runtime.ScopedOf<T>keeps exactly the thirteen scoped operationsThas, with theirreal Prisma types, and structurally drops the rest —
$queryRawincluded, sothe 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, not0.10.0.The variance problem
ExtensionRouteDefinition.handleandTrellisExtension.extendRecapare nowmethods rather than function-typed properties, and that is the load-bearing
part of the diff.
Under
strictFunctionTypesa function-typed property compares parameterscontravariantly. A route taking
ExtensionContext<DogModels>is therefore notassignable 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:
…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.
expectTypeOfin a vitest file is a runtime no-op, andapps/api'stsc --buildexcludestest/— so the existing convention would haveproved nothing here.
The tests are compiled instead:
packages/extension-api/type-tests/, its owntsconfig.type-tests.json, its own step in the API snapshot gate. They importthrough the package name, so they run against the built declarations and
the
exportsmap — 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.ScopedOperationtracksScopedDelegateScopedOfkeeps the scoped ops with real typesunknownScopedOfdrops the rest$queryRaw,fieldsWhat 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
before this reached CI. The doc gained a "Typing your own models" section
rather than just a bumped number.
"dev": truemarkers onto esbuild's optional platform packages; that wouldhave shipped as noise in a one-line change.
Verified
extension-api build ·
apps/apitsc --build· type tests · both public APIsnapshots · version lockstep gate ·
npm ci --dry-run·smoke-pack.sh(21entry 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