Skip to content

feat(examples): add a Temporal worker runtime on temporal-contract - #7

Merged
btravers merged 8 commits into
mainfrom
feat/examples-temporal
Aug 12, 2026
Merged

feat(examples): add a Temporal worker runtime on temporal-contract#7
btravers merged 8 commits into
mainfrom
feat/examples-temporal

Conversation

@btravers

Copy link
Copy Markdown
Contributor

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 for temporal-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 onto worker.shutdown() — the worker moves to DRAINING, polling stops immediately, in-flight activities finish, and run() 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:

Outcome oRPC API Queue worker Temporal
Ok 200 with the order ack workflow result
DuplicateOrder ORPCError CONFLICT dead-letter typed ContractError, rehydrated client-side
Defect INTERNAL_SERVER_ERROR bounded retry, then dead-letter Temporal's own retry policy

packages/start, order-domain, order-application and order-infrastructure are byte-unchanged — verified by diff stat. Three transports now, no change to the layers below.

A subtlety worth reading

UnitMeta.id must 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 as id and the workflow id as traceId, 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-server with ttl: "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.yml is a nine-line delegation to btravstack/config's ci-reusable.yml@workflows-v1, and a caller cannot inject an actions/cache step into a reusable workflow's jobs — none of its inputs takes a cache path. The exact two-input patch btravstack/config needs is written into the task report and CLAUDE.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 to 8.0.0-beta.5, not the latest 7.0.0 — 7.0.0 peers on unthrown@^4 while this repo pins 5.2.0, and it lacks the test-rig / workflow-bundle subpaths. 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.

…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.
Copilot AI lite review requested due to automatic review settings August 12, 2026 00:11

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-temporal workspace 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.

Comment thread examples/order-temporal/src/temporal-runtime.ts
Comment thread examples/order-temporal/src/main.ts
Comment thread examples/order-temporal/src/env.ts
Comment thread examples/order-temporal/src/workflows.ts Outdated
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.
@btravers
btravers merged commit bcf853b into main Aug 12, 2026
13 checks passed
@btravers
btravers deleted the feat/examples-temporal branch August 12, 2026 06:29
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.

2 participants