feat(examples): add a Temporal worker runtime on temporal-contract - #7
Merged
Conversation
…real wait The third deployment of the clean-architecture example, and the first runtime whose transport has genuine drain semantics of its own. `packages/start`, `order-domain`, `order-application` and `order-infrastructure` are unchanged: the same `ApplicationModule` + `PersistenceModule` composition boots under `temporalWorkerRuntime` with nothing amended below the transport. `Serving.drain` maps onto `worker.shutdown()`, which moves the worker to DRAINING immediately — polling stops at once, in-flight activities finish, and `run()` resolves when the last one has. So drain returns that AsyncResult and the wait is real, where the oRPC and queue runtimes stop accepting and have nothing left to await. `stop()` is the same call made idempotent, guarded on `getState()` because shutting a non-RUNNING worker down throws. The activity is the kernel unit: workflow code runs in a deterministic sandbox that cannot reach di, so the activity implementations are built inside `Runtime.start`, closing over the `RuntimeHost`. `UnitMeta.id` is Temporal's base64 task token — a workflow id is not unique per unit, since an activity retries under the same execution and a workflow id may be reused once an execution closes — and the workflow id is the `traceId`. The same `Err` now lands three ways: `DuplicateOrder` is a CONFLICT over oRPC, a dead-letter on the queue, and a `nonRetryable` typed contract error here — where naming a failure also tells the platform to stop retrying it, and an unmodelled one is left to the contract's retry policy. No Docker: a real TypedWorker polls a real task queue against the time-skipping test server. That 64 MB binary is the one thing in this repository that needs the network on a cold cache, so it is pinned to a gitignored `.cache/temporal-test-server` with a year-long ttl rather than the OS temp directory and the SDK's one-day default. CI cannot yet cache that path — `ci.yml` delegates wholly to the shared reusable workflow — which is recorded in CLAUDE.md rather than left to be rediscovered.
There was a problem hiding this comment.
Pull request overview
Adds a third “runtime” consumer for the clean-architecture example: a Temporal worker built on temporal-contract, intended to exercise @btravstack/start’s runtime contract (especially draining) against a real worker lifecycle, while keeping the kernel package unchanged.
Changes:
- Introduces a new
examples/order-temporalworkspace implementing a Temporal worker runtime, contract/workflow/activity wiring, fixtures, and specs. - Updates root/docs to reflect six example packages and three deployments, and documents the cold-cache Temporal test-server download/caching approach.
- Updates workspace/tooling metadata (pnpm catalog/lockfile, knip, gitignore) to include Temporal dependencies and the new example.
Reviewed changes
Copilot reviewed 21 out of 24 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Updates repo-level documentation to reference six examples and three runtimes. |
| pnpm-workspace.yaml | Adds @temporal-contract/* + @temporalio/* catalog pins and allowBuilds entries needed by Temporal deps. |
| pnpm-lock.yaml | Locks new Temporal/contract dependency graph for the added example workspace. |
| knip.json | Registers examples/order-temporal entrypoint for dead-code/unused-deps checking. |
| examples/README.md | Expands examples overview to three deployments; adds Temporal-specific unit/id/traceId narrative. |
| examples/order-temporal/package.json | Defines the new private example workspace and its scripts/dependencies. |
| examples/order-temporal/README.md | Documents the Temporal example’s purpose, contract mapping, unit semantics, draining, and cold-cache binary download. |
| examples/order-temporal/vitest.config.ts | Adds Vitest config with longer timeouts to accommodate first-time binary download. |
| examples/order-temporal/tsconfig.json | Adds TS config for the new workspace (incl. allowImportingTsExtensions for Prisma-generated imports). |
| examples/order-temporal/tsconfig.test-d.json | Adds TS config for type-level (*.test-d.ts) gate tests. |
| examples/order-temporal/src/index.ts | Public barrel for the example workspace’s exports. |
| examples/order-temporal/src/contract.ts | Defines the Temporal contract (workflow/activity, declared errors, retry policy). |
| examples/order-temporal/src/workflows.ts | Implements workflow logic and error rehydration/propagation rules. |
| examples/order-temporal/src/temporal-runtime.ts | Implements the Temporal-backed Runtime and activity handler that opens kernel units. |
| examples/order-temporal/src/module.ts | Adds OrderTemporalModule composition root (Application + Persistence). |
| examples/order-temporal/src/env.ts | Adds schema-based env validation returning Result. |
| examples/order-temporal/src/main.ts | Adds entry point that validates env, connects to Temporal, starts app, and runs runMain. |
| examples/order-temporal/src/test-fixtures.ts | Adds Temporal test environment + per-test task queue + app boot/shutdown fixtures. |
| examples/order-temporal/src/temporal-runtime.spec.ts | Adds runtime-level specs covering mapping, info publishing, trace IDs, and drain behavior. |
| examples/order-temporal/src/env.spec.ts | Adds env validation specs for defaults and error reporting. |
| examples/order-temporal/src/needs-gate.test-d.ts | Adds compile-time needs-gate tests for the Temporal runtime. |
| examples/order-temporal/src/vitest.d.ts | Registers @unthrown/vitest matchers/types for the workspace. |
| CLAUDE.md | Updates authoritative spec to include the Temporal example, beta pin rationale, and cold-cache network caveat. |
| .gitignore | Ignores the repo-local .cache/ directory used for Temporal test-server downloads. |
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
btravers
commented
Aug 12, 2026
btravers
commented
Aug 12, 2026
A contract is a shared artifact: the point of declaring one before any implementation exists is that a client and a server both depend on it. While `order-api/src/contract.ts` lived inside the transport, a would-be client could only reach it by depending on the router, the di wiring, the Prisma-backed repository and the kernel behind them. `examples/order-api-contract` holds it now, depending on `@orpc/contract` and nothing else, with `order-api` a consumer over `workspace:*`. Two things keep it that way: `layering.test-d.ts` imports the transport package under a `@ts-expect-error`, so gaining that dependency fails `test:types`; and `client.spec.ts` builds a real oRPC client from `RouterContractClient<typeof orderContract>` over a stub `fetch`, proving the client half works with nothing from the implementation side.
Three parties read a Temporal contract — the worker that implements the activity, the workflow running in the sandbox, and the client that starts the execution — and only the first wants a di container, a Prisma-backed repository and the kernel behind it. While it sat in `order-temporal/src/contract.ts` the other two could not have it without them. `examples/order-temporal-contract` holds it now, depending on `@temporal-contract/contract` and `zod`, with `order-temporal` a consumer over `workspace:*` (whose own `@temporal-contract/contract` entry goes with it — it no longer imports one). `layering.test-d.ts` imports the worker package under a `@ts-expect-error`, so gaining that dependency fails `test:types`, and `contract.spec.ts` runs the workflow's own input schema as a validator returning a `Result` — the check a caller makes before starting an execution, performed with no worker, no connection and no implementation in scope.
…time `Serving.drain(signal)` was ignored (`void signal`) and both `drain` and `stop` returned `worker.run()` unconditionally. `run()` settles on Temporal's own `shutdownForceTime` (30s by default), so an activity that never finishes held `finish()`'s `serving.stop()` well past the kernel's `drainTimeoutMs` — the one runtime the deadline could not release. Both methods now race `running` against the deadline signal, and the signal is kept from `drain` so the `stop()` that follows it is released by the same abort. `@temporalio/worker` 1.22 exposes no public forced shutdown to escalate to (`Worker.forceShutdown$` is protected, `Runtime.shutdown()` is process-global), so stopping the wait is the escalation available to a runtime.
`NativeConnection.connect(...)` was created in `main.ts` and never closed, so the transport outlived `runMain` — an open connection holds the event loop. The close lands in the entry point, not the runtime's `stop()`: whoever opens it closes it. The runtime is handed a connection it did not open, and `test-fixtures.ts` is the proof — every test boots a fresh worker against the one shared `testEnv.nativeConnection`, so a runtime closing what it was given would tear the environment down under the next test. It goes in a `.finally` on `runMain`'s promise rather than a `flatTap`, because the defect path is exactly the one that must still close; and a close failure is written to stderr rather than surfaced, so teardown cannot overwrite the exit code `runMain` just set.
The digits-only string, `.transform(Number)`, `.pipe(z.int().min().max())` construction was the over-built form of `z.coerce.number().int().min(min).max(max).default(fallback)`. Coercion is only the `Number()` trap the old comment described **without** bounds behind it: with them, `abc` (NaN), `3.5` and out-of-range values are all validation issues. One genuine behaviour change, in `order-api` alone: an empty or whitespace-only value coerces to `0`, and a port's `min` is `0` so that an ephemeral bind stays expressible — so `PORT=` now binds an ephemeral port instead of being reported. `order-worker`'s `CONCURRENCY` (`min(1)`) and every other field are unaffected, and their specs pass untouched. `order-api`'s one affected fixture moves from `PROBE_PORT: """ to `PROBE_PORT: "3.5"`, keeping the test's shape and its two named issues; the hole is written down in each `env.ts`, the README and CLAUDE.md.
Cases sharing a handler are grouped into one arm rather than duplicated — the library's own documented preference, and what `no-catch-all-pattern` steers you toward instead of a wildcard. The narrowed parameter stays the union of the grouped patterns, so `error._tag` still names which case it was, and the arm is still an enumeration: a new case does not compile. Three sites, the whole of what the examples carry: - `order-temporal/src/workflows.ts` — the two activity-machinery tags - `order-worker/src/queue-runtime.ts` — the two domain errors that dead-letter - `order-api/src/orpc-runtime.spec.ts` — the two client-side codes `order-infrastructure`'s `prisma-order-repository.ts` carries a fourth (`ForeignKeyViolation` / `RecordNotFound`, both `defect(...)`) and is left alone: that package is out of this PR's scope.
…to 0
The previous commit's bare `z.coerce.number()` dropped a guard that was earning
its keep. `Number("")` is `0`, and a port's `min` is `0` so that an ephemeral
bind stays expressible — so `PORT=` silently bound an ephemeral port instead of
being reported. That is a regression, not a documented quirk, and an example
that ships a footgun teaches the footgun.
A non-empty trimmed string in front of the coercion closes it while keeping the
simplification the reviewer asked for:
z.string().trim().min(1)
.pipe(z.coerce.number<string>().int().min(min).max(max))
.default(fallback)
Still no regex and no hand-rolled `.transform(Number)`. The `<string>` type
argument is required because `z.coerce.number()`'s input is `unknown`, which
`.pipe` will not accept from a `string`. `.default(...)` still applies only when
the variable is genuinely absent, so reject-to-default is unchanged too.
All seven cases are now pinned by a test in each of the three env specs: absent,
"", whitespace, "abc", "3.5", a valid value, out of range. `order-api`'s original
`{ PORT: "abc", PROBE_PORT: "" }` fixture is restored and passes unchanged, and
the notes describing the hole are gone from the three env.ts files, the README
and CLAUDE.md #6 — which states the rule plainly again.
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.
A third runtime over the same application: a Temporal worker built on
temporal-contract, running in the default gate with no Docker.This closes a gap I should have caught earlier. The worker example used a hand-rolled in-memory queue, and the stated reason — "real infrastructure needs Docker" — turned out to hold for
@amqp-contract/testing(testcontainers) but not fortemporal-contract, which ships a time-skipping environment that needs no Docker at all. A spike confirmed it empirically before any of this was built.Why this one earns its place
The drain contract finally meets real shutdown semantics.
Serving.drain(signal)maps ontoworker.shutdown()— the worker moves toDRAINING, polling stops immediately, in-flight activities finish, andrun()resolves when the last one does. The kernel reports{ inFlightAtStart: 1, completed: 1, abandoned: 0 }, asserted and visible in the event stream. Every previous drain test ran against a fixture the example itself wrote; this one runs against a real worker's own lifecycle.A third mapping of the same
Result:OkDuplicateOrderORPCErrorCONFLICTContractError, rehydrated client-sideDefectINTERNAL_SERVER_ERRORpackages/start,order-domain,order-applicationandorder-infrastructureare byte-unchanged — verified by diff stat. Three transports now, no change to the layers below.A subtlety worth reading
UnitMeta.idmust be unique per unit — a contract documented after an earlier example silently gave every request the same trace id. The obvious candidate here, the workflow execution id, is not unique per unit: activities retry under a single execution, and Temporal permits workflow-id reuse. The runtime uses the base64 task token asidand the workflow id astraceId, which is what that contract was written to prevent someone getting wrong.The cost, stated plainly
This is the one example needing network on a cold cache — a 64 MB test server, downloaded once, keyed by SDK version. It is pinned to a repo-local gitignored
.cache/temporal-test-serverwithttl: "365d"(verified by deleting the cache and re-running), so it survives between runs instead of expiring daily. Docker was the alternative and was rejected; every other example needs neither.The CI cache could not be wired here.
.github/workflows/ci.ymlis a nine-line delegation tobtravstack/config'sci-reusable.yml@workflows-v1, and a caller cannot inject anactions/cachestep into a reusable workflow's jobs — none of its inputs takes a cache path. The exact two-input patchbtravstack/configneeds is written into the task report andCLAUDE.md. Until then the cost is ~3.5 s per test job, ~14 s across the Node matrix: performance, not correctness.Also note
@temporal-contract/*is pinned to8.0.0-beta.5, not thelatest7.0.0 — 7.0.0 peers onunthrown@^4while this repo pins 5.2.0, and it lacks thetest-rig/workflow-bundlesubpaths. That is the second deliberate beta pin, alongside oRPC.Verification
163 tests, up from 152. Full six-command gate green with Docker Desktop quit — verified independently, not just reported. Suite run 13 times with zero flakes. Fully cold 7.4 s, warm 3.8 s; the whole repo gate runs in 14 s.