On this page
Workspace boundariesStartup and ownershipInternal RPCOwnership on the wireGateway: public gRPCFollow a requestReading the Effect codeWhat is implemented, and what is still missingQuality checksArchitecture and source map
This is an executable model of Temporal's service responsibilities. The architecture overview is the orientation; the pinned Go sources below are the implementation references. Roles call each other only through internal RPC, whether they share a process or not. Requests and responses are upstream's own historyservice and matchingservice messages, generated from Temporal's protos and sent as readable proto3 JSON. It is a teaching model of Temporal's topology, not a server that interoperates with upstream services.
Workspace boundaries
The directory names follow upstream Temporal. Upstream has one Go module with many Go packages; here pnpm workspaces are the TypeScript package boundaries, grouped at the service and adapter level. Each production/example/tooling package puts TypeScript under src/, with package.json and tsconfig.json at its root. The test/ workspace keeps its test files directly under that directory. They are private source packages, with explicit exports and TypeScript project references, sharing one lockfile. tsx runs their source; pnpm typecheck builds declarations under ignored dist/ directories to check every package boundary.
apidepends only on Effect. Generated contracts use local service errors, deadline declarations, and routing metadata; they never import implementations fromcommon.commonowns infrastructure and store interfaces. Its memory and PostgreSQL adapters are separate packages; neither adapter depends on the other. Only PostgreSQL consumers install@effect/sql-pg.clientowns Frontend's client declaration and routed History/Matching clients. Eachservice/*package can use clients and shared infrastructure, but cannot import another service or a persistence adapter. Frontend cannot access stores directly.temporalselects adapters and composes service layers, including the embedded Frontend transport.cmd/serverenters that composition root.sdkuses public contracts, Frontend's client declaration, and payload/deadline helpers. Its workflow input types belong to the SDK; even transitive access to server state or services is forbidden. Teaching labs explicitly depend on the internals they inspect.examples/official-sdkis an ordinary stock SDK consumer with no tiny-temporal dependencies. Its workflow code and recovery worker live together.protodeclares its own protobuf/compiler inputs and resolves them from that package, without hoisted paths.
pnpm-workspace.yaml uses the isolated linker, disables hoisting, rejects workspace cycles, and aligns Effect and Temporal versions through a catalog. Node can still search parent directories, so installation layout alone is not enforcement. dependency-cruiser checks the source graph against our boundary policy: permitted package edges, declared dependencies, exported entrypoints, no relative cross-package imports, no unresolved imports, and no cycles outside recursive generated protobuf messages. These checks include type-only imports and run locally inside pnpm lint (also included in pnpm check); they replace the old handwritten architecture test.
Persistence contracts are explicit package exports, with no combined stores entrypoint. The boundary policy permits History to import execution-store and visibility, Matching to import task-store, and Frontend to import only visibility. The Worker scanner can import execution, task, and visibility stores. The namespace registry and visibility manager use their respective stores. Adapters implement the store contracts; composition, examples, and tests may wire or inspect them. New persistence entrypoints and consumers require an explicit policy update. These are static import restrictions, including type-only imports; Effect checks layer requirements separately. Upstream Go relies on exported interfaces and dependency injection rather than these per-consumer import restrictions.
pnpm deps:graph generates plain side-by-side comparison pages at dist/architecture/index.html: architectural boundaries, modules, and intra-module dependencies. Both repositories use the same grouping and focus rules, with original imports available in the evidence JSON. pnpm deps:matching starts with Matching expanded. See architecture diagrams for prerequisites, scopes, exports, and narrower focuses.
The compact tiny-temporal service/client/persistence graph below remains available through pnpm deps:mermaid. These source views cannot establish RPC destinations, resolve Effect layer injection, or prove runtime isolation. See the upstream asset review for those separate checks and the runtime diagram. pnpm wire prints observed internal RPC frames.
The current subsystem imports (regenerate with pnpm --silent deps:mermaid):
flowchart LR
subgraph client["client"]
subgraph client_src["src"]
client_src_frontend_ts["frontend.ts"]
client_src_history_ts["history.ts"]
client_src_matching_ts["matching.ts"]
end
end
subgraph common["common"]
subgraph common_persistence["persistence"]
common_persistence_memory["memory"]
common_persistence_postgres["postgres"]
end
subgraph common_src["src"]
subgraph common_src_persistence["persistence"]
common_src_persistence_execution_store_ts["execution-store.ts"]
common_src_persistence_namespace_store_ts["namespace-store.ts"]
common_src_persistence_snapshot_ts["snapshot.ts"]
common_src_persistence_task_store_ts["task-store.ts"]
common_src_persistence_visibility_store_ts["visibility-store.ts"]
common_src_persistence_visibility_ts["visibility.ts"]
end
end
end
subgraph service["service"]
service_frontend["frontend"]
service_history["history"]
service_matching["matching"]
service_worker["worker"]
end
common_persistence_memory-->common_src_persistence_execution_store_ts
common_persistence_memory-->common_src_persistence_namespace_store_ts
common_persistence_memory-->common_src_persistence_snapshot_ts
common_persistence_memory-->common_src_persistence_task_store_ts
common_persistence_memory-->common_src_persistence_visibility_store_ts
common_persistence_postgres-->common_src_persistence_execution_store_ts
common_persistence_postgres-->common_src_persistence_namespace_store_ts
common_persistence_postgres-->common_src_persistence_snapshot_ts
common_persistence_postgres-->common_src_persistence_task_store_ts
common_persistence_postgres-->common_src_persistence_visibility_store_ts
common_src_persistence_visibility_ts-->common_src_persistence_visibility_store_ts
service_frontend-->client_src_history_ts
service_frontend-->client_src_matching_ts
service_frontend-->common_src_persistence_visibility_ts
service_history-->common_src_persistence_execution_store_ts
service_history-->client_src_matching_ts
service_history-->common_src_persistence_visibility_ts
service_matching-->common_src_persistence_task_store_ts
service_matching-->client_src_history_ts
service_matching-->client_src_matching_ts
service_worker-->common_src_persistence_execution_store_ts
service_worker-->common_src_persistence_task_store_ts
service_worker-->common_src_persistence_visibility_store_ts
Startup and ownership
cmd/server/src/main.ts CLI: --service roles, ports, NodeRuntime
temporal/src/server.ts coordinate host lifetimes, failure, and scoped shutdown
temporal/src/fx.ts one fresh layer graph per host; process-wide shared resources
service/frontend/ public gRPC listener and WorkflowService handler
service/history/ shard controller, execution engine, queue processors, RPC handler
service/matching/ partition managers, backlog, polls, forwarding, RPC handler
service/worker/ internal background scanner
client/ routed History and Matching clients (shard / partition owner)
api/ generated contracts, service errors, and RPC metadata
proto/ generator and its own protobuf dependencies
common/ membership, RPC transport, state, persistence interfaces
common/persistence/{memory,postgres}/ separately installed store implementations
sdk/ embedded teaching client/worker; speaks the public API in-process
examples/official-sdk/ ordinary stock TypeScript SDK consumer over gRPC
| Toy boundary | What it owns | Upstream reference |
|---|---|---|
cmd/server/src/main.ts |
Flags (--service), ports, static membership; enter the process lifetime |
cmd/server/main.go |
temporal/src/fx.ts |
A fresh layer graph per host, shared persistence/namespace/membership per process | temporal/fx.go |
temporal/src/server.ts |
Coordinate host lifetimes and propagate background failure | temporal/server_impl.go |
api/ |
Wire types and RPC groups generated from proto/internal and api_upstream |
proto/internal, generated api/*service |
client/ |
Route a History request to its shard owner, a Matching request to its partition owner | client/history, client/matching |
service/frontend |
Validate requests and forward them; serve the public gRPC API | workflow_handler.go |
service/history |
Shard leases, execution transitions with task intents, transfer/timer/visibility queues | service/history/handler.go, shard/controller_impl.go |
service/matching |
Partition managers, persisted backlog, polls, forwarding; record starts in History | service/matching/handler.go, matching_engine.go |
service/worker |
Internal maintenance jobs, represented by one scanner | service/worker/service.go |
common/persistence |
Store capabilities and memory/PostgreSQL implementations | common/persistence/data_interfaces.go |
A host is one instance of a role, such as history-2. Each host has its own layer graph (Layer.fresh): its identity, its outbound connections, its clients, its engine, and its RPC listener. This matches upstream, where each service is its own fx.App with its own resource graph. Hosts in one process share only persistence, the namespace registry, membership, and visibility, which stand in for the database and the membership ring they would share across processes.
temporal/src/fx.ts defines one layer per role (historyRole, matchingRole, frontendRole, workerRole). A role layer exposes its own services for inspection and keeps its host identity, connections, and clients private. Two compositions use them:
serverLayerWithPersistenceruns one host per role, exposes their services to tests and labs, and by default leaves background loops off so tests can drive them.Cluster.layerbuilds any number of hosts per role, such as two History and two Matching hosts, and returns a handle to each.TemporalServerand the CLI use it. The production layer exposes onlyTemporalServer.
pnpm start runs every role in one process. pnpm start --service history runs only History; a separate process can run Matching, another Frontend and Worker. Membership is static, like upstream's static membership module: one host per role at fixed local ports (HISTORY_PORT 7234, MATCHING_PORT 7235). Separate processes must share PostgreSQL.
Internal RPC
History / Matching / Frontend host
→ client/src/history.ts or client/src/matching.ts pick the owner host (shard or partition)
→ RcMap of RpcClient per target host Effect RPC client over a Node socket
→ TCP, newline-delimited JSON frames proto3 JSON payloads, a timeout header
→ RpcServer.layer on the target host Effect RPC server over a socket server
→ service/*/handler.ts generated group's handlers → engine
proto/src/generate.tsreads Temporal'shistoryserviceandmatchingserviceprotos and the public WorkflowService protos. It emits an Effect Schema for every message the selected RPCs reference, plus oneRpcGroupper service. Encoded values are canonical proto3 JSON: 64-bit integers as strings, enums as names,Timestampas RFC 3339,Durationas"1.5s", bytes as base64. Fields follow proto3 presence: scalars, enums, repeated fields, and maps decode to their zero value when absent and are omitted when zero, so handlers readrequest.namespaceIdrather thanrequest.namespaceId ?? ''. Message fields stay optional.Message.make(init)fills zero values, and every Effect RPC client applies it to the payload it sends. Regenerate withTEMPORAL_SRC=<temporalio/temporal checkout> pnpm proto.- The generator also turns History's
(routing)options into aShardRoutingannotation on each generatedRpc. For example,RecordWorkflowTaskStartedroutes byworkflow_execution.workflow_idandRespondWorkflowTaskCompletedby its task token.HistoryClientreads the annotation, hashes namespace and workflow ID to a shard, asks membership for the shard's owner, and calls it. AShardOwnershipLostanswer names the new owner, and the client retries there, as upstream's redirector does. MatchingClientroutes by task queue partition. The partition is part of the task queue name (/_sys/<name>/<n>, as upstream'stqid). It can spread root-named requests across partitions, like upstream's load balancer, but defaults to one partition.common/src/rpc.tsis stock Effect RPC wiring:RpcServer.layerwithlayerProtocolSocketServer, and per-hostRpcClients in anRcMap, aseffect/cluster'sRunnerskeeps one client per runner. It adds only membership lookup and the wire tap. The routed History and Matching clients are ordinary Effect RPC clients built withRpcClient.makeNoSerialization, aseffect/clusterbuilds entity clients: each request is handed to the router on the caller's fiber, so its deadline and interrupts follow it to the host that serves it.api/src/deadline.tsdeclares the RPC middleware contract;common/src/deadline.tsimplements it: a caller's deadline travels as a relative timeout header, as gRPC'sgrpc-timeoutdoes. Interrupting a call sends anInterruptframe, so cancelling a long poll at Frontend removes the waiting poll on the Matching host. Errors are the tagged service errors upstream returns:NotFound,ShardOwnershipLost,TaskAlreadyStarted,Canceled,Unavailable, and so on.- A
WireTaprecords every request, interrupt, and exit frame between named hosts.pnpm wireprints the calls for one workflow on two History and two Matching hosts.TEMPORAL_WIRE_LOG=1 pnpm startprints them for a running server.
Effect RPC has its own framing, so internal frames are not gRPC and cannot reach a real Temporal service. Upstream uses gRPC with protobuf binary between services. The message schemas, routing keys, and error kinds are upstream's.
Ownership on the wire
- History shards. A workflow's shard is a hash of namespace and workflow ID (
common/src/shard.ts, IDs 1–4). Membership assigns each shard to one History host. TheShardControllertakes a shard's lease before serving it, incrementing the shard's range ID in persistence. Every execution write carries that range ID. When another host takes the lease, the old owner's next write fails, the old owner drops the shard, and its caller is told the new owner. - Matching partitions. Membership assigns each task queue partition to one Matching host. Like upstream, a host loads any partition it is asked for. The queue's persisted range ID fences a second owner's writes, and a membership change unloads the partitions now assigned elsewhere.
- Forwarding. A child partition with no local match offers a task to its parent over
MatchingService.AddWorkflowTask, withforward_info. If no poller is waiting there, the parent answersCanceledand only the child writes the task to its backlog. A child partition's poll is forwarded the same way and waits at the root. Each hop may be a different Matching host.
Gateway: public gRPC
stock Temporal client / worker
→ grpc-js + @temporalio/proto binary codecs
→ frontend/grpc/json.ts: protobufjs object ↔ proto3 JSON (well-known types)
→ generated WorkflowService schemas → Frontend (Effect service)
→ HistoryClient / MatchingClient
grpc/server.ts is an Effect RpcServer protocol over grpc-js. grpc-js decodes the binary message with the published codec; each unary call becomes one protocol client whose request, cancellation (Interrupt), and exit cross the protocol. RpcServer decodes the request with the generated schema and runs the WorkflowService handler under the same deadline middleware as internal calls: the gRPC deadline becomes the timeout header. Requests are fibers owned by the listener scope. Frontend gives Matching a slightly earlier deadline, so an empty long-poll answer arrives before the SDK's deadline. grpc/errors.ts maps service errors to gRPC status codes and hides defect details.
Frontend holds no execution state. It validates namespaces, forwards each request to History or Matching, and translates Matching's poll response into the public one. Its handlers are WorkflowService.toLayer(...), like History's and Matching's. The embedded teaching SDK calls them in process through an Effect RPC client without serialization (RpcTest.makeClient), with the same public messages.
Follow a request
sequenceDiagram
participant A as Application SDK
participant F as frontend-1
participant H as history-N (shard owner)
participant M as matching-N (partition owner)
participant P as Persistence
A->>F: StartWorkflowExecution
F->>H: historyservice.StartWorkflowExecution
H->>P: fenced commit: execution, events, transfer intent
H-->>F: run ID
F-->>A: run ID
H->>M: matchingservice.AddWorkflowTask (transfer processor)
M->>P: spool delivery if no poller is ready
A->>F: PollWorkflowTaskQueue
F->>M: matchingservice.PollWorkflowTaskQueue
M->>H: historyservice.RecordWorkflowTaskStarted
H->>P: fenced commit: WorkflowTaskStarted
H-->>M: workflow type, history, event IDs
M-->>F: task token + history
F-->>A: PollWorkflowTaskQueueResponse
A->>F: RespondWorkflowTaskCompleted (commands)
F->>H: historyservice.RespondWorkflowTaskCompleted (routed by task token)
H->>P: fenced commit: events, state, new task intents
Every arrow between server roles is an RPC. Matching returns tasks through the pending poll; it never opens a connection to the application worker. Matching builds the poll response and task token from History's RecordWorkflowTaskStarted response, as upstream's createPollWorkflowTaskQueueResponse does.
Visibility is a storage projection, not another server role. History's visibility-processor.ts writes the visibility manager; Frontend reads it.
Reading the Effect code
Follow the Effect service example and layer composition example:
History,Matching, andFrontendeach define oneContext.Servicewith its interface andstatic layer. Engine operations use the toy's own types;handler.tstranslates between them and the generated messages, as upstream's handlers do.- This model forbids imports between role implementations.
Matchingdepends onHistoryClient; its layer receives a client built for its own host..dependency-cruiser.cjsenforces that policy. Upstream’s Go packages share some helpers and task types across service directories, so this is deliberately stricter than upstream’s import graph. Layer.provideMergeexposes a host's services for inspection;Layer.providekeeps its host identity and clients private;Layer.freshgives each host its own instances.
The cluster runtime in effect/cluster was the model for separating placement from transport. Its layer variants (TestRunner, SocketRunner) inspired building the same hosts over different transports. Its entities are not used: Temporal's own shard leases, partition fencing, and forwarding are what this toy teaches.
What is implemented, and what is still missing
| Concern | Current implementation | Upstream behavior still absent |
|---|---|---|
| Service ownership | Any number of hosts per role, each with its own graph; roles in separate processes with --service |
Dynamic membership (ringpop), host health, graceful handoff delays |
| Transport | Effect RPC over TCP with generated upstream message schemas; deadlines, interrupts, typed service errors | gRPC/protobuf binary, retry policies with backoff, connection pooling, interceptors, TLS |
| History shards | Membership placement, range-ID leases fenced in persistence, ShardOwnershipLost redirects |
Per-shard queue processors and caches, shard handoff on host loss, replication |
| Matching placement | Partition routing by membership, range-ID fencing, unload on membership change, RPC forwarding | Configurable partition counts per queue, forwarder rate limits, distributed backlog readers |
| Internal Worker | Independently scoped scanner | Temporal's internal workflows, archival, scheduling, and other maintenance jobs |
| Visibility | Durable asynchronous projection, queried through its manager | Full query language and production indexing/storage options |
Quality checks
Run pnpm check on Node 24 for typechecking, boundary checks, unit tests, and the stock SDK process integration. Run pnpm test:postgres with a dedicated TEST_DATABASE_URL for SQL, shard-lease fencing between History hosts, roles in separate processes, and process-crash recovery.
test/cluster.test.ts runs two History and two Matching hosts in one process. It checks shard routing, a replaced lease redirecting the client to the new owner, the proto3 JSON frames between named hosts, poll forwarding from a child partition to the root on another host, a partition moving after a membership change, and complete workflows across all four hosts.
The concurrency regressions cover two transfers racing for one waiting poll, partition loads publishing one manager, and concurrent versioned updates having one winner. A Matching host serializes short dispatch operations with one semaphore. It never holds it while waiting for a long poll or calling another partition, which may be served by the same host.