feat!: typed RPC connection contracts (slice R6) - #13
Conversation
…tion proof The design for typing service-to-service Connections: a framework-owned, protocol-parametric Contract; a neutral contract with a server adapter (serve()) and a client adapter (load()); the binding (network/in-memory/mock) chosen by hex wiring; RPC-first; untyped http() retained as the escape hatch. The compatibility check is proven, not asserted (contract-satisfaction.poc.ts, compiled --strict): the core does plain assignability on an opaque Cmp with NoInfer on the kind brand; the RPC kind makes it correct by building Cmp as a concrete function map (rpc() returns a concrete (input)=>Promise<output>), so TS applies contravariant-input / covariant-output. Records the trap it avoids — deriving Client<M> as a mapped type relates the M's covariantly and silently accepts incompatible providers. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n 12 Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…(unit 1)
The type-level foundation for typed RPC connections, faithful to the compiled
proof (contract-satisfaction.poc.ts):
- core: Contract<Kind, Cmp> (opaque Cmp, kind brand, runtime satisfies) + the
standalone provision() wiring helper (plain assignability, NoInfer on the brand;
runtime mirror via satisfies). Not yet wired into HexBuilder.
- new @makerkit/rpc: contract(fns) -> Contract<'rpc', Fns>; rpc({input,output})
types a concrete (input)=>Promise<output> (the trick that makes assignability
apply real function variance) over Standard Schema (arktype); Client<C>; nominal
satisfies (value === value).
- type-tests port the proof's matrix to real arktype — accept exact/extra-output/
extra-method, reject extra-input/missing/wrong-kind + client usage. Verified real
(every @ts-expect-error fires for the intended reason).
No runtime transport (serve/client) yet, no example changes, http()/compute()/hex
untouched. typecheck 10/10, tests, biome green.
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ection-end (unit 2)
- core: optional expose (Expose = record of named output-port Contracts) on
ServiceNode/RunnableServiceNode, threaded through service(); compute() passes it.
- @makerkit/rpc runtime (web-standard fetch/Request/Response, runtime-agnostic):
· makeClient(contract, url, {fetch?}) — POST <url>/rpc/<method>, arktype-validate
the response; the transport is injectable (a serve() handler IS a valid transport).
· serve(service, handlers) — fetch server generated from service.expose: flat
/rpc/<method> dispatch, validate input -> handler(deps from load()) -> validate
output; 404/405/400/500, never crashes; Handlers<S> forces an exhaustive, typed
handler map (missing/mistyped = compile error; extra allowed).
· rpc(contract) — the typed connection-end sibling of http(), same {url} param.
- tests: in-process round trip (client wired to the serve handler), bad-input/unknown-
method/handler-throw paths, output-validation catching a lying server, serve-handler
type-tests, expose threading, invariants.
No examples, no deploy. typecheck 10/10, tests, biome green.
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Shared auth.contract.ts (verify: {token} -> {ok}). auth exposes { rpc: authContract }
and serves it via serve(service, {rpc:{verify}}) — the handler pings the DB and returns
{ok:false} on error (never throws; keeps FT-5219 resilience) — replacing the hand-rolled
Hono server. storefront depends via rpc(authContract) and its page calls
auth.verify({token}) typed, rendering 'Auth /verify says: true'. Build configs inline
@makerkit/rpc + arktype; e2e-verify.sh grep updated. The hex/deploy graph is unchanged
(rpc connection resolves the producer URL like http() did).
Local proof: POST /rpc/verify -> {ok:false} against an unreachable DB (RPC dispatch +
arktype validation + resilience); bad input 400; GET 405. typecheck/tests/biome green.
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Deleted. This matrix is now a CI-typechecked type-test: @makerkit/rpc's src/__tests__/contract-satisfaction.test-d.ts (283a8a8).
There was a problem hiding this comment.
Deleted — same as above, its accept/reject cases are covered by contract-satisfaction.test-d.ts (283a8a8).
There was a problem hiding this comment.
This is a long-lived doc written as a transitory description.
I want you to read this doc with fresh eyes. Pretend you’re a member of my team who doesn’t have all our context. In particular, the doc should start with a clear grounding example and then have a strong narrative that clearly builds up the topic bit by bit, explaining clearly throughout. Lead with the decision we’re making, end with alternatives considered, to avoid overloading the reader with information we’re making irrelevant.
These are long-lived documentation. They should not contain references to Linear tickets, milestones in your project, or states the system passed through during or preceding your refactor which will never be seen again.
Rewrite the document to address the issues you identified.
There was a problem hiding this comment.
Rewritten (283a8a8): leads with the decision, opens with the worked example, builds the mechanism up, ends with alternatives considered. Dropped the "being finalized" language and the POC-file reference.
| import { compute, http } from '@makerkit/prisma-cloud'; | ||
| import { compute } from '@makerkit/prisma-cloud'; | ||
| import { rpc } from '@makerkit/rpc'; | ||
| import { authContract } from '../../auth/src/contract.ts'; |
There was a problem hiding this comment.
This is not ideal. Can we set up a proper monorepo structure so this imports from eg "@example/auth/contract"?
There was a problem hiding this comment.
Done (283a8a8). Renamed the hexes to @storefront-auth/auth and @storefront-auth/storefront with subpath exports; storefront now imports @storefront-auth/auth/contract, and hex.ts imports both services by package name. Proven with next build + tsdown.
| * contract; `storefront` consumes it (auth's `rpc` port → storefront's `auth` | ||
| * slot, compat-checked). Transparent wiring, executed at Load. | ||
| */ | ||
| export default hex('storefront-auth', (h: HexBuilder) => { |
There was a problem hiding this comment.
can we drop the explicit HexBuilder type annotation?
There was a problem hiding this comment.
Dropped — hex(name, body) infers h as HexBuilder (283a8a8).
wmadden
left a comment
There was a problem hiding this comment.
A few remaining comments, preemptively approved
|
|
||
| ### Growing the runtime check | ||
|
|
||
| Growing nominal into **structural** later is backward-compatible: runtime accepts |
There was a problem hiding this comment.
We never said that it was a nominal check, and in fact that's an implementation detail of the RPC contract, isn't it? We happen to do a nominal check right now.
There was a problem hiding this comment.
Reworded (e762620). The doc now says the identity comparison is the RPC contract's own satisfies() implementation — not a rule the framework or the Contract abstraction imposes — and that it can grow to structural inside the RPC contract without the framework knowing.
|
|
||
| ## Alternatives considered | ||
|
|
||
| - **RPC first, not HTTP.** RPC removes the HTTP semantic surface (methods, paths, |
There was a problem hiding this comment.
You mean "RPC first, not REST". We're still using HTTP under RPC
There was a problem hiding this comment.
Fixed (e762620): "RPC first, not REST." HTTP stays the transport under RPC; the choice is the interface style — RPC drops REST's semantic surface (resources, verbs, status codes).
| /** How the app's entry is built + assembled. */ | ||
| readonly build: BuildAdapter; | ||
| /** Named output ports this service exposes — the Contracts a consumer's `rpc(contract)` can require. */ | ||
| readonly expose?: E; |
There was a problem hiding this comment.
Allow inputs to the factory methods to be optional, keep all data structures predictable: no optional keys, but potentially undefined values.
There was a problem hiding this comment.
Done (e762620). ServiceNode.expose is now an always-present key with a possibly-undefined value (E | undefined); the service() factory input stays optional but always sets the key.
| export interface ConnectionEnd<C = unknown, Req = unknown> extends NodeBase { | ||
| readonly kind: 'connection'; | ||
| readonly connection: Connection<Params, C>; | ||
| readonly required?: Req; |
There was a problem hiding this comment.
Same here. Check all other data structures
There was a problem hiding this comment.
Done (e762620). Same for ConnectionEnd.required. Audited the rest: also fixed ConfigDeclaration.default (config.ts). The remaining ?: are all factory/options inputs — service()/connectionEnd()/compute() defs, and lower()/target()/client() options — which you said should stay optional, so I left them.
| return loaded; | ||
| }, | ||
| }) as RunnableServiceNode<D, typeof computeParams>; | ||
| }) as RunnableServiceNode<D, typeof computeParams, E>; |
There was a problem hiding this comment.
Removed (e762620): compute() now builds a typed RunnableServiceNode local and Object.freezes it — no return cast. (One as remains in load(), asserting the untyped deserialized config record to Loaded; that's a genuine runtime assertion — it becomes a blindCast once the no-bare-cast utils land.)
… typed hex wiring + rpc client R6 build checkpoint (previously "wip"): refines the Contract type and its runtime satisfies() check, the typed connection wiring and compatibility validation across node.ts/graph.ts, and the rpc client binding, with broadened compat type-tests. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
Closes the review's finding: the typed compat is now enforced at the REAL hex, not just a standalone helper. ConnectionEnd carries its required contract; ProvisionedRef carries the provider's exposed ports (RefPort); HexBuilder.provision is typed (an incompatible wiring is a compile error); loadHex runs a satisfies() backstop (LoadError on mismatch). rpc(contract) carries its required contract; the example wires authRef.rpc. Untyped http() + makerkit-hello unchanged (escape hatch intact; R5 hex/lowering tests unmodified). Plus the two client cleanups (preserve base path; surface the server error body). typed-hex-wiring.poc.ts is the compiled proof. typecheck 10/10, tests, biome green. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…owns it The contract is auth's public interface, so it lives with the service (hexes/auth/ src/contract.ts) rather than floating at the app root — how a user would lay it out. auth imports it locally; storefront imports it cross-hex (the provider-owns-the- contract pattern). Runtime unchanged (same contract, same wiring); both hexes still build self-contained (arktype inlined). Top-level deps kept — alchemy.run.ts transitively needs @makerkit/rpc + arktype through the services. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The app topology now lives in a clean hex.ts (hex + two provisions); alchemy.run.ts is a thin interim deploy adapter that imports it and lowers onto Prisma Cloud. `alchemy deploy` still finds alchemy.run.ts by convention, so no tooling changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
- Rename example hexes to a real scope (@storefront-auth/auth, @storefront-auth/storefront) with subpath exports, so storefront imports the auth contract as @storefront-auth/auth/contract instead of a cross-hex relative path. hex.ts imports both services by package name. - Drop the explicit HexBuilder annotation in hex.ts (inferred from hex()). - Delete the two design POC files; their accept/reject matrix already lives as a CI-typechecked type-test (@makerkit/rpc contract-satisfaction.test-d.ts). - Rewrite connection-contracts.md as long-lived docs: lead with the decision, open with a worked example, build the mechanism up, end with alternatives; drop transitory references (POC files, "being finalized"). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
Contract is the abstraction (parametric over kind); a bare "Contract" never means a specific kind. Every kind is qualified — RPC Contract, Data Contract — with the prose name tracking the `kind` brand. Drop "protocol" for the discriminant (RPC is a kind, not a protocol); reserve "Connection" for the port. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
…ndle The rename to `@storefront-auth/auth` turned storefront/service.ts's contract import from a relative path into a package specifier. tsdown's noExternal only matched @makerkit/* and arktype, so it left `@storefront-auth/auth/contract` external — an unresolved import in main.mjs that crashed the storefront on boot, so Compute served "Service not found" and the E2E round trip failed. Inline the app's own hex packages (@storefront-auth/*) too. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…, drop cast Data structures carry no optional keys, only possibly-undefined values (factory inputs stay optional): ServiceNode.expose, ConnectionEnd.required, and ConfigDeclaration.default are now always-present keys. Factories and the expected test declarations set them explicitly. connection-contracts.md: the identity comparison is the RPC contract's own satisfies() implementation, not a rule the framework or Contract abstraction imposes; "RPC first, not REST" — HTTP is still the transport under RPC. compute(): drop the `as RunnableServiceNode` return cast — build a typed local and freeze it. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…from prisma-next The DCO workflow requires a Signed-off-by trailer on every commit; git-staging.mdc documents that (and explicit staging), and the multiline-commit-messages skill keeps Shell-tool commit messages from garbling. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
0e1c316 to
1271b97
Compare
Forbid bare `as` casts in production TS in favour of auditable helpers. - blindCast<T, "Reason">/castAs<T> in @makerkit/core/casts, plus assertDefined/invariant in @makerkit/core/assertions as the safe narrowing alternatives. - no-bare-cast GritQL Biome plugin (info severity; test files exempt, matching the existing biome test-file overrides). - lint:casts CI ratchet + unit tests: counts plugin diagnostics at HEAD vs the PR base and fails on any per-PR increase. Scans the base in a worktree using HEAD's config+plugin, so it self-bootstraps — the 59 existing casts are the baseline, delta=0. - PR-only cast-ratchet CI job (credential-free: points origin/main at the base sha already in history) + .cursor rules docs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
Now that the no-bare-cast machinery is in the repo, R6 should not add casts. Removed 9 by narrowing (`in`/typeof guards in graph.ts, rpc.ts, client.ts) and named the genuine runtime assertions with blindCast<T, "reason"> (serve.ts/client.ts read schemas off the function value; compute.ts merges the untyped config record; a frozenShallowCopy helper in node.ts). Net delta vs main is -4 casts, so the ratchet passes. Also updates the core exports invariant test for the new ./casts + ./assertions entries. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ssion brief R6 (typed RPC contracts) merged via PR #13. plan.md: R6 slice -> [x] merged (the two .poc.ts files folded into contract-satisfaction.test-d.ts; no-bare-cast machinery landed alongside), a current-position update line, and two deferred items — the E2E bundling rule (noExternal must inline the app own hex packages) and the @makerkit/node naming nit. Adds next-session-brief.md as the fresh-session handoff. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Types service-to-service Connections. A dependency can now declare a Contract (an RPC interface — methods with arktype-validated input/output); the provider is forced to satisfy it, the consumer gets a typed client, and an incompatible wiring is rejected at compile time, at Load, and per call. The untyped
http()stays as the escape hatch.Proven live on real Prisma Cloud (twice): the storefront's page calls
auth.verify({ token })as a typed client → a realPOST /rpc/verify→ auth's generated RPC server validates input, pings Postgres, validates output, returns{ ok: true }, which the page renders.What it adds
@makerkit/rpc(new):contract()/rpc()(Standard Schema / arktype),serve(service, handlers)(generates the RPC fetch server fromservice.expose; forces handlers to satisfy the exposed contracts), the typed client binding, andrpc(contract)— the typed sibling ofhttp().Contract<Kind, Cmp>;exposeon services;ConnectionEndcarries its required contract;ProvisionedRefcarries the provider's exposed ports;HexBuilder.provisionis typed (an incompatible wiring is a compile error);loadHexruns asatisfies()backstop (LoadErroron mismatch).storefront-authon the typed contract — the contract lives in the auth hex (hexes/auth/src/contract.ts, the service owns it), storefront depends on it, hex wiring is compat-checked viaauthRef.rpc.The compat mechanism (the interesting part)
The core stays protocol-blind: plain assignability on an opaque
CmpwithNoInferon the kind brand. The RPC kind makes that correct by buildingCmpas a concrete function map (so TS applies contravariant-input / covariant-output). This caught and avoided a real trap — deriving the comparison as a mapped-type-over-schema silently accepts incompatible providers. The design + the full accept/reject matrix are compiled proofs in the repo (contract-satisfaction.poc.ts,typed-hex-wiring.poc.ts), and the type-tests + the runtimeLoadErrortest were mutation-checked (non-vacuous).Design:
docs/design/10-domains/connection-contracts.md· slice:.drive/projects/authoring-layer/slices/r6-typed-rpc-contracts/.Status
Green and proven live; ready for review. typecheck 10/10, tests, biome clean. The Opus review's finding (compat not wired into the real hex) is closed — it's now enforced at
HexBuilder.provision+loadHex. R5 is untouched: untypedhttp()andmakerkit-hellowork, and the existing hex/lowering tests passed unmodified. Review cleanups (client base-path, server error body) included. The earlywipcommit will be squashed at merge.Follow-ups (tracked, out of scope)
Structural (vs nominal)
satisfies; gRPC/WebSocket kinds; PDL authoring; distributed published-spec comparison; in-memory + mock bindings;contract()raw-function guard; POC-in-CI-tsconfig.🤖 Generated with Claude Code