@mettlecast/domain-runtime
v0.2.105
Published
Type-safe runtime types, factory functions, and context interface for TIB Domain Module handlers. Zero AWS dependencies in the main export — all infrastructure concerns are delegated to the CDK packer.
Readme
@mettlecast/domain-runtime
Type-safe runtime types, factory functions, and context interface for TIB Domain Module handlers. Zero AWS dependencies in the main export — all infrastructure concerns are delegated to the CDK packer.
Install
npm install @mettlecast/domain-runtimeQuick Start
Define a domain with a public action handler:
import {
defineDomain,
defineAction,
z,
} from '@mettlecast/domain-runtime';
const domain = defineDomain({
name: 'payments',
version: '1.0.0',
});
// Public HTTP endpoint — equivalent of the legacy `defineApi({ tenancy: 'required' })`.
// The `exposure` block is mandatory: path/method/auth/tenancy are validated at
// construction time and at registry build, so unsafe configurations never ship.
export const chargeCard = defineAction({
id: 'charge-card',
backendAccess: 'domain',
exposure: {
type: 'api',
path: '/v1/tenants/{tenantId}/payments/charge',
method: 'POST',
auth: 'required',
tenancy: 'required',
},
input: z.object({ amount: z.number().positive() }),
output: z.object({ id: z.string().uuid(), status: z.literal('charged') }),
idempotent: true,
handler: async (input, ctx) => {
await ctx.db.query('INSERT INTO charges ...');
await ctx.publish('payment.charged', { amount: input.amount });
return { id: crypto.randomUUID(), status: 'charged' as const };
},
});The action-first contract (introduced in #4619, hardened in
feat/4625-action-auth-hardening):
| exposure.type | Reachable through | Notes |
|---|---|---|
| 'api' | API Gateway route | Path, method, auth, tenancy all required and validated. Generates one method on the consumer SDK. |
| 'internal' | ctx.actions[domainId].<id>(input) from inside the platform | Never reachable via HTTP. Use for in-process cross-domain calls. |
Anti-pattern. Do NOT use the legacy
defineApifactory for new public handlers. It is no longer scaffolded for public HTTP endpoints — every public handler must go throughdefineAction({ exposure: { type: 'api', ... } })so the registry can enforce the auth/tenancy contract at build time (seevalidate-domain).
Factory Functions
Nine factory functions help you define domain primitives with schema
validation and type safety. The action-first migration (#4619) folded
defineApi into defineAction so every primitive is a callable
ActionDefinition regardless of where it is reached from.
| Function | Purpose | Produces |
|---|---|---|
| defineDomain() | Declare a domain and version | Domain metadata |
| defineAction() | Public API endpoint OR internal callable | Action handler + (optional) API Gateway route |
| defineEvent() | Event type with schema | Typed event publisher |
| defineWebhook() | Inbound webhook handler | Webhook + validation |
| defineSubscriber() | Event subscriber handler | EventBridge rule + Lambda |
| defineSchedule() | Cron-triggered handler | EventBridge Scheduler rule |
| defineJob() | Async queue-based task | SQS queue + Lambda consumer |
| defineIntegration() | External service integration | Async integration handler |
| defineFlow() | Multi-step orchestration flow | Step Functions state machine |
Context Surfaces
The unified DomainContext injected into every handler exposes 15
surfaces:
| Surface | Purpose |
|---|---|
| ctx.db | PostgreSQL connection pool via drizzle-orm (auto tenant-scoped) |
| ctx.publish() | Publish an event to EventBridge |
| ctx.actions | Invoke other domain actions (ctx.actions[domainId].<id>(input)) |
| ctx.integrations | Call external integrations |
| ctx.jobs | Enqueue async tasks to SQS |
| ctx.outbox | Transactional outbox — durable, atomic-with-DB job enqueue (#5294) |
| ctx.flows | Trigger multi-step flows |
| ctx.cache | In-memory or distributed cache |
| ctx.secrets | Fetch AWS Secrets Manager values |
| ctx.fetch() | HTTP client with retry + circuit breaker |
| ctx.idempotency | Deduplication by request ID |
| ctx.logger | Pino JSON logger |
| ctx.tracer | AWS X-Ray tracing |
| ctx.actor | Caller identity (sub, email, tenantId, roles, scopes) |
| ctx.tenant | Resolved tenant ({ id, workspaceId, orgId }) |
Background Jobs & the Transactional Outbox (#5294)
Declare async work with defineJob and enqueue it from any handler:
import { defineJob, defineAction, z } from '@mettlecast/domain-runtime';
export const sendInvoice = defineJob({
id: 'send-invoice',
maxRetries: 3, // retry attempts AFTER the first delivery
// (SQS maxReceiveCount = maxRetries + 1)
visibilityTimeoutSeconds: 300,
handler: async (payload, ctx) => {
const { id } = z.object({ id: z.string() }).parse(payload);
await ctx.audit.log('invoice.sent', id);
},
});
export const chargeCard = defineAction({
id: 'charge-card',
backendAccess: 'domain',
exposure: { type: 'internal' },
idempotent: true,
input: z.object({ amount: z.number().positive() }).default({ amount: 1 }),
output: z.object({ ok: z.boolean() }).default({ ok: true }),
handler: async (input, ctx) => {
await ctx.jobs.enqueue('send-invoice', { id: 'inv-123' }); // reference-only payload
return { ok: true };
},
});The CDK packer (@mettlecast/domain-cdk-packer) creates an encrypted queue +
DLQ per job, injects TIB_JOB_QUEUE_URLS into every Lambda, and grants
producer roles (action / subscriber / schedule / job / webhook)
sqs:SendMessage scoped to the domain's own queue ARNs. Job event-source
mappings keep batchSize: 1 but enable SQS partial-batch failure reporting so
a future batch-size increase stays safe. In enableCmk mode the packer grants
the exact SQS KMS operations (kms:Decrypt, kms:GenerateDataKey) on the
queue key to producers and consumers.
Operator DLQ replay is available through ctx.jobs.replayDlq('<jobId>')
(SQS StartMessageMoveTask, injected TIB_JOB_DLQ_ARNS map, scoped
s sqs:StartMessageMoveTask IAM). Replay is deliberate — nothing replays
automatically; see the architecture doc for the exact CLI fallback.
For work that must be durable with domain state, use the transactional
outbox. Declare the table in defineDomain (outbox: { tableName }), add the
outbox-table migration (via mc-domain-module add-migration ... --pattern
outbox-table), and append records inside the same Postgres transaction:
await ctx.db.withTransaction(async (tx) => {
await tx.query('INSERT INTO invoices (id, amount) VALUES ($1, $2)', [id, amount]);
await ctx.outbox.append(tx, { jobName: 'send-invoice', payload: { id } });
});A dispatcher (schedule or job) then drains the table:
await ctx.outbox.dispatch(); // claim → ctx.jobs.enqueue → mark publishedThe dispatcher runs as a trusted tenantless actor: the outbox migration's RLS
model lets it claim/publish tenant and system rows through a
transaction-local app.bypass_rls (never a session-scoped leak), while normal
tenant sessions see only their own rows. Placeholder tenancy values
('unknown') are never stamped into the outbox UUID columns.
Delivery is at-least-once. A crash between SQS enqueue and
mark-published re-enqueues the record, so job consumers MUST be idempotent
(dedupe on a stable id). Queue payloads are reference-only — never put raw
PII/secrets in SQS. See
docs/about/content/architecture/outbox-and-jobs.md
for the full design, DLQ alarms, and operator replay guidance.
Design Principles
This package is intentionally framework-agnostic. It exports types
and factory functions only — all infrastructure (Lambdas, API Gateway,
EventBridge, SQS, etc.) is provisioned by @mettlecast/domain-cdk-packer.
Exports by concern:
@mettlecast/domain-runtime— types, factories, DomainContext@mettlecast/domain-runtime/primitives— factory functions only@mettlecast/domain-runtime/ctx— context interfaces@mettlecast/domain-runtime/types— TypeScript types@mettlecast/domain-runtime/schema— Zod schema utilities
See wiki/how-it-works/domain-module.md for the full design.
