Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Ready-to-run examples demonstrating Connectum features — from a one-service [q
| [extensions/redact](extensions/redact/) | Sensitive data redaction | Proto custom field options, `createRedactInterceptor()` | Ready |
| [interceptors/jwt](interceptors/jwt/) | Client-side JWT interceptor | Bearer token injection, `createAddTokenInterceptor()` | Ready |
| [with-custom-interceptor](with-custom-interceptor/) | Echo service with custom interceptors | API key auth, rate limiting | Ready |
| [hris](hris/) | Monolith **or** microservices — one codebase | `defineService` + catalog + `ctx.call` (in-process vs remote by env) + EventBus saga | Ready |
| [hris](hris/) | Monolith **or** microservices — one codebase | `defineService` + catalog + `ctx.call` (in-process vs remote by env) + EventBus + durable onboarding saga with Temporal | Ready |
| [car-sharing](car-sharing/) | Enterprise deploy — Kubernetes + Istio | Split microservices + JWT/proto authz gateway + OpenTelemetry; durable trip saga with Temporal; k8s/Istio manifests (mTLS, canary) | Ready |
| [with-events-kafka](with-events-kafka/) | EventBus with Kafka | Event-driven microservices, consumer groups | Ready |
| [with-events-redpanda](with-events-redpanda/) | EventBus with Redpanda | Saga choreography, custom topics, Redpanda Console | Ready |
Expand Down
21 changes: 16 additions & 5 deletions hris/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
# One image, every role. The role is chosen at runtime by the SERVICES env
# (see docker-compose.yml); the entrypoint is always src/index.ts.
# One image, every role. The RPC role is chosen at runtime by the SERVICES env
# (see docker-compose.yml); the default entrypoint is src/index.ts. The Temporal
# worker reuses this same image with `command: ["node", "src/worker.ts"]`.
FROM node:25-slim AS deps
RUN npm install -g pnpm
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod
# pnpm-workspace.yaml carries the allowBuilds approvals (@swc/core compiles the
# Temporal workflow bundle in the worker; protobufjs is a Temporal gRPC dep), so
# it MUST be present for `pnpm install` to run those build scripts.
COPY package.json pnpm-workspace.yaml ./
# No lockfile is committed for this flagship example (matches getting-started /
# car-sharing): pnpm resolves @connectum/* and other deps from the caret (^)
# ranges in package.json, so the image is example-grade, not bit-reproducible.
# Production services should commit pnpm-lock.yaml and use
# `pnpm install --frozen-lockfile` (the secondary examples model this).
RUN pnpm install --prod
Comment on lines +10 to +16

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Restore lockfile-based installs for reproducible images.

At Line 10 and Line 11, dropping pnpm-lock.yaml + --frozen-lockfile makes image dependency resolution non-deterministic across builds.

Suggested fix
-COPY package.json pnpm-workspace.yaml ./
-RUN pnpm install --prod
+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
+RUN pnpm install --prod --frozen-lockfile
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hris/Dockerfile` around lines 10 - 11, The COPY command at line 10 only
copies package.json and pnpm-workspace.yaml but omits the pnpm-lock.yaml file,
and the RUN pnpm install command at line 11 uses --prod flag without
--frozen-lockfile, causing non-deterministic dependency resolution across
builds. Add pnpm-lock.yaml to the COPY instruction and add the --frozen-lockfile
flag to the pnpm install command to ensure reproducible builds with locked
dependency versions.


FROM node:25-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends wget && rm -rf /var/lib/apt/lists/*
Expand All @@ -14,7 +23,9 @@ COPY package.json ./
COPY src/ ./src/
COPY gen/ ./gen/
ENV NODE_ENV=production
EXPOSE 5000 5001 5002 5003
EXPOSE 5000 5001 5002 5003 5004 5005
HEALTHCHECK --interval=10s --timeout=3s --start-period=15s --retries=3 \
CMD wget -q --spider http://localhost:${PORT:-5000}/healthz || exit 1
# Run as the built-in non-root `node` user.
USER node
CMD ["node", "src/index.ts"]
181 changes: 157 additions & 24 deletions hris/README.md

Large diffs are not rendered by default.

164 changes: 158 additions & 6 deletions hris/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@
# the monolith in-process with an in-memory bus and a PGlite Postgres; see
# tests/e2e).
#
# docker compose --profile mono up # all services in one process
# docker compose --profile split up # directory + timeoff + payroll split
# docker compose --profile mono up # all services in one process
# docker compose --profile split up # directory + timeoff + payroll + access split
# docker compose --profile split --profile saga up # + Temporal, the onboarding gateway, and the worker
#
# Both profiles share the same `nats` broker and `postgres` database. The split
# profile shows the headline: identical handler code, three processes, ctx.call
# auto-routing across the network and LeaveApproved flowing over NATS to the
# payroll role.
# All profiles share the same `nats` broker and `postgres` database. The split
# profile shows the headline: identical handler code, separate processes,
# ctx.call auto-routing across the network and LeaveApproved flowing over NATS to
# the payroll role. The `saga` profile adds the durable onboarding saga — a
# Temporal server + Web UI (http://localhost:8088), the OnboardingService gateway
# (:5005), and the worker that hosts OnboardingWorkflow and drives the role
# services. It pairs with `split` (the worker targets the per-role services).
#
# Persistence (Phase 1): DirectoryService is backed by Drizzle ORM over Postgres.
# Every app role wires DATABASE_URL (postgres.js connects lazily, so the timeoff
Expand Down Expand Up @@ -168,9 +172,157 @@ services:
- hris
profiles: ["split"]

# ── ACCESS role — the saga's IT-provisioning leaf (in-memory) ─────────────
access:
build: .
command: ["node", "src/index.ts"]
ports:
- "5004:5004"
environment:
- PORT=5004
- NATS_URL=nats://nats:4222
- DATABASE_URL=postgresql://hris:hris@postgres:5432/hris
- SERVICES=access.v1.AccessService
depends_on:
nats:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:5004/healthz"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
networks:
- hris
profiles: ["split"]

# ── ONBOARDING role — the saga gateway (pre-check + start workflow) ───────
#
# Reaches the directory over the network for its pre-check (DIRECTORY_ADDR) and
# builds a lazy @temporalio/client to start OnboardingWorkflow (TEMPORAL_ADDRESS).
onboarding:
build: .
command: ["node", "src/index.ts"]
ports:
- "5005:5005"
environment:
- PORT=5005
- NATS_URL=nats://nats:4222
- DATABASE_URL=postgresql://hris:hris@postgres:5432/hris
- SERVICES=onboarding.v1.OnboardingService
- DIRECTORY_ADDR=http://directory:5001
- TEMPORAL_ADDRESS=temporal:7233
- TEMPORAL_TASK_QUEUE=hris-onboarding
depends_on:
directory:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:5005/healthz"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
networks:
- hris
profiles: ["saga"]

# ── Temporal's own metadata store (SEPARATE from the app Postgres) ─────────
temporal-postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: temporal
POSTGRES_PASSWORD: temporal
POSTGRES_DB: temporal
volumes:
- temporal-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U temporal -d temporal"]
interval: 5s
timeout: 3s
retries: 10
start_period: 5s
networks:
- hris
profiles: ["saga"]

# ── Temporal server (auto-setup creates the `default` namespace) ───────────
temporal:
image: temporalio/auto-setup:1.25.2
depends_on:
temporal-postgres:
condition: service_healthy
environment:
- DB=postgres12
- DB_PORT=5432
- POSTGRES_USER=temporal
- POSTGRES_PWD=temporal
- POSTGRES_SEEDS=temporal-postgres
ports:
- "7233:7233"
healthcheck:
# Wait until the frontend is serving and the default namespace exists.
test: ["CMD", "tctl", "--address", "temporal:7233", "namespace", "describe", "default"]
interval: 5s
timeout: 5s
retries: 30
start_period: 10s
networks:
- hris
profiles: ["saga"]

# ── Temporal Web UI — inspect onboarding workflows + their compensations ───
temporal-ui:
image: temporalio/ui:2.34.0
depends_on:
temporal:
condition: service_healthy
environment:
- TEMPORAL_ADDRESS=temporal:7233
- TEMPORAL_CORS_ORIGINS=http://localhost:8088
ports:
- "8088:8080"
networks:
- hris
profiles: ["saga"]

# ── WORKER — hosts OnboardingWorkflow + the activities (node src/worker.ts) ─
#
# A separate process type (NOT a SERVICES-selected RPC role): no inbound RPC;
# it polls Temporal for workflow/activity tasks and drives the role services
# over ConnectRPC. The ONLY process that loads the @temporalio/worker native
# addon. Pairs with the `split` profile (it targets the per-role services):
#
# docker compose --profile split --profile saga up
worker:
build: .
command: ["node", "src/worker.ts"]
environment:
- TEMPORAL_ADDRESS=temporal:7233
- TEMPORAL_NAMESPACE=default
- TEMPORAL_TASK_QUEUE=hris-onboarding
- DIRECTORY_ADDR=http://directory:5001
- PAYROLL_ADDR=http://payroll:5003
- TIMEOFF_ADDR=http://timeoff:5002
- ACCESS_ADDR=http://access:5004
depends_on:
temporal:
condition: service_healthy
directory:
condition: service_healthy
payroll:
condition: service_healthy
timeoff:
condition: service_healthy
access:
condition: service_healthy
networks:
- hris
profiles: ["saga"]

networks:
hris:
driver: bridge

volumes:
pgdata:
temporal-pgdata:
6 changes: 6 additions & 0 deletions hris/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"scripts": {
"start": "node src/index.ts",
"dev": "node --watch src/index.ts",
"worker": "node src/worker.ts",
"typecheck": "tsc --noEmit",
"buf:generate": "buf generate",
"buf:lint": "buf lint",
Expand Down Expand Up @@ -50,6 +51,10 @@
"@connectum/healthcheck": "^1.0.0",
"@connectum/interceptors": "^1.0.0",
"@connectum/reflection": "^1.0.0",
"@temporalio/activity": "^1.18.1",
"@temporalio/client": "^1.18.1",
"@temporalio/worker": "^1.18.1",
"@temporalio/workflow": "^1.18.1",
"drizzle-orm": "^0.45.0",
"postgres": "^3.4.0"
},
Expand All @@ -58,6 +63,7 @@
"@bufbuild/protoc-gen-es": "^2.11.0",
"@connectum/protoc-gen-catalog": "^1.0.0",
"@electric-sql/pglite": "^0.5.0",
"@temporalio/testing": "^1.18.1",
"@types/node": "^25.2.0",
"drizzle-kit": "^0.31.0",
"typescript": "^5.9.3"
Expand Down
7 changes: 7 additions & 0 deletions hris/pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
allowBuilds:
'@bufbuild/buf': true
esbuild: true
# @swc/core compiles the Temporal workflow bundle (@temporalio/worker bundles
# src/temporal/workflows.ts with swc at worker startup — no separate build).
'@swc/core': true
# protobufjs is pulled transitively by @temporalio's gRPC client
# (@grpc/grpc-js → @grpc/proto-loader → protobufjs). Allow its build so pnpm
# does not report ERR_PNPM_IGNORED_BUILDS on install.
protobufjs: true
40 changes: 40 additions & 0 deletions hris/proto/access/v1/access.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
syntax = "proto3";

package access.v1;

import "google/protobuf/empty.proto";

// AccessService provisions and revokes system access (the IT side of
// onboarding). It is a leaf service with an in-memory account ledger — the
// onboarding saga's fourth forward step.
service AccessService {
// ProvisionAccess creates the employee's system account. Idempotent:
// re-provisioning returns the existing account. Onboarding saga step 4.
rpc ProvisionAccess(ProvisionAccessRequest) returns (ProvisionAccessResponse) {}

// RevokeAccess removes the employee's system account (the ProvisionAccess
// compensation). Idempotent; a no-op for an unknown employee.
rpc RevokeAccess(RevokeAccessRequest) returns (google.protobuf.Empty) {}
}

message ProvisionAccessRequest {
string employee_id = 1;
// Work email the account is keyed on.
string email = 2;
}

message ProvisionAccessResponse {
Access access = 1;
}

message RevokeAccessRequest {
string employee_id = 1;
}

message Access {
string employee_id = 1;
// The provisioned account handle (e.g. an SSO subject id).
string account_id = 2;
// True once an account exists for this employee.
bool provisioned = 3;
}
45 changes: 45 additions & 0 deletions hris/proto/directory/v1/directory.proto
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,57 @@ service DirectoryService {
// and `manager_id` filters narrow the stream; `manager_id` is the org-chart
// "direct reports of this manager" query.
rpc ListEmployees(ListEmployeesRequest) returns (stream Employee) {}

// CreateEmployee inserts a new employee in "onboarding" status. Returns
// ALREADY_EXISTS if the id is already taken. This is the onboarding saga's
// first forward step — its business failure (duplicate id) is what the
// workflow treats as non-retryable.
rpc CreateEmployee(CreateEmployeeRequest) returns (CreateEmployeeResponse) {}

// ActivateEmployee flips an employee from "onboarding" to "active" — the
// onboarding saga's terminal step. Idempotent: activating an already-active
// employee is a no-op success.
rpc ActivateEmployee(ActivateEmployeeRequest) returns (ActivateEmployeeResponse) {}

// OffboardEmployee marks an employee "offboarded" — the CreateEmployee
// compensation. Idempotent; a no-op for an unknown id.
rpc OffboardEmployee(OffboardEmployeeRequest) returns (OffboardEmployeeResponse) {}
}

message GetEmployeeRequest {
string id = 1;
}

message CreateEmployeeRequest {
string id = 1;
string name = 2;
string email = 3;
string title = 4;
string department = 5;
// The id of this hire's manager, or empty for the top of the org chart.
string manager_id = 6;
}

message CreateEmployeeResponse {
Employee employee = 1;
}

message ActivateEmployeeRequest {
string id = 1;
}

message ActivateEmployeeResponse {
Employee employee = 1;
}

message OffboardEmployeeRequest {
string id = 1;
}

message OffboardEmployeeResponse {
Employee employee = 1;
}

message GetEmployeeResponse {
Employee employee = 1;
}
Expand Down
Loading