Type-safe, atomic lifecycle transitions for PostgreSQL.
Interlock gives important domain changes one dependable transaction boundary. Your version-checked resource update, related writes, append-only history, idempotency result, and outbox messages commit together or roll back together.
Alpha: Interlock alpha releases use the npm next tag. APIs may change before 1.0, with meaningful changes documented in the changelog.
XState models complex statecharts. Temporal runs durable workflows. Interlock makes a single domain transition, and every database write it requires, commit atomically in PostgreSQL. Use it for approvals, account status changes, order progression, publishing flows, fulfillment steps, and other business transitions where a partial write would be expensive or difficult to repair.
| Package | Purpose |
|---|---|
@jajego/interlock |
Database-neutral lifecycle definitions, transition execution, public types, errors, and observability. |
@jajego/interlock-postgres |
First-party PostgreSQL transaction driver and versioned migration. |
@jajego/interlock-conformance |
Development-time verification suites for custom drivers and resource bindings. |
- Atomic by design: application writes, history, idempotency, and outbox records share one PostgreSQL transaction.
- Type-safe commands: lifecycle definitions infer valid event names and
input types for
assess()andtransition(). - Safe concurrency: compare-and-swap updates check both state and version.
- Built-in audit trail: the protocol appends a record for every committed transition.
- Retry-friendly APIs: idempotency keys return the original committed transition instead of applying it twice.
- PostgreSQL-native: bring your own
pgpool and write ordinary SQL against ordinary application tables. - Round-trip conscious: transactional-outbox rows are inserted in batches without skipping protocol validation.
- Small footprint:
@jajego/interlockhas zero external runtime dependencies.
| Tool | Best fit |
|---|---|
| Interlock | One version-checked domain transition and all of its PostgreSQL writes. |
| XState | Rich in-process statecharts and state-machine modeling. |
| Temporal | Durable, long-running workflows across processes and services. |
| ORM transaction | General database work when you own the transaction protocol yourself. |
npm install @jajego/interlock@next @jajego/interlock-postgres@next pg@jajego/interlock provides the core transition protocol and TypeScript API,
@jajego/interlock-postgres adds the first-party driver and migration, and pg
provides the application-owned PostgreSQL client and connection pool. The
PostgreSQL integration declares pg as a peer dependency and never bundles or
creates the application's pool.
Install the core package by itself when providing another transaction driver:
npm install @jajego/interlock@nextDriver and binding authors can install the optional conformance suites as a development dependency:
npm install --save-dev @jajego/interlock-conformance@next- PostgreSQL is the reference and only first-party transaction driver; packages are ESM-only and require Node.js 22.14+.
- Resource IDs are strings and versions are positive PostgreSQL
BIGINTtokens represented as strings. Resource IDs must be globally unique within a lifecycle, including across tenants. - Idempotent transitions require Read Committed. Interlock owns the top-level transaction; ambient transaction composition is not supported.
- Application tables, SQL or ORM code, tenancy, authorization, and related-row consistency remain application-owned. Direct writes can bypass Interlock.
- Duplicate replay returns stored transition history, not necessarily the current resource. Outbox delivery remains outside Interlock.
- Related-row correctness is declared by the binding. Self-transitions are rejected; model them as ordinary application writes instead.
- Raw
pgintegration is first-party. ORM-owned transactions are viable but adapter-intensive: Prisma is proven through an executable custom-driver recipe, not a published adapter. Application and Interlock writes must use one transaction handle.
Without Interlock, each command handler must remember the same protocol:
await client.query("BEGIN");
await claimIdempotency(client, command);
await updateOrderIfVersionMatches(client, command);
await insertDecision(client, command);
await insertHistory(client, command);
await insertOutbox(client, command);
await completeIdempotency(client, command);
await client.query("COMMIT");With Interlock, the lifecycle and binding define those pieces once:
const result = await orders.transition(command);Interlock owns the transaction boundary; your binding still owns ordinary SQL.
import {
canonicalHash,
defineEvent,
defineLifecycle,
deny,
} from "@jajego/interlock";
interface Order {
id: string;
state: "pending" | "approved";
version: string;
}
interface Actor {
id: string;
tenantId: string;
canApprove: boolean;
}
const event = defineEvent<Order, Actor>();
const orderLifecycle = defineLifecycle<Order, Actor>()({
name: "order",
states: ["pending", "approved"],
history: {
resourceType: "order",
actor: (actor) => ({ actorType: "user", actorId: actor.id }),
},
idempotency: {
fingerprint: ({ resourceId, event, actor, expectedVersion }) =>
canonicalHash({ resourceId, event, actorId: actor.id, expectedVersion }),
},
events: {
approve: event({
from: ["pending"],
to: "approved",
authorize: ({ actor }) =>
actor.canApprove ? true : deny({ code: "NOT_ALLOWED" }),
mutate: ({ actor }) => ({ approvedBy: actor.id }),
outbox: ({ resource, transitionId }) => [
{
topic: "order.approved",
key: resource.id,
payload: { orderId: resource.id, transitionId },
},
],
}),
},
});A resource binding maps Interlock's transaction protocol to your existing tables. Tenant identity remains application-owned, so every application query that needs tenant isolation must enforce the tenant scope explicitly:
loadPrimary: (transaction, operation) =>
loadOrder(transaction, {
id: operation.id,
tenantId: operation.actor.tenantId,
});Interlock's infrastructure tables currently require resource IDs to be globally
unique within a lifecycle, including across tenants. A PostgreSQL setting such
as app.tenant_id provides no isolation by itself; it contributes only when an
RLS policy, trigger, or other database logic consumes it.
applyPrimary() receives the selected event and its correlated mutation on
args.operation; the compare-and-swap remains ordinary SQL:
UPDATE orders
SET state = $2, version = $3, approved_by = $4
WHERE id = $1 AND state = $5 AND version = $6
RETURNING *;Create the client with your binding and pool:
import { createInterlock } from "@jajego/interlock";
import { PostgresDriver } from "@jajego/interlock-postgres";
import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const orders = createInterlock({
lifecycle: orderLifecycle,
binding: orderBinding,
driver: new PostgresDriver(pool, { schema: "interlock" }),
});See the postgres-node example for the
complete binding, migration setup, related writes, guards, history, and outbox
code.
Use assess() for advisory feedback in a UI. It performs a read-only check and
does not reserve or guarantee the transition. Calling assess() immediately
before transition() intentionally repeats authoritative reads and checks, so
servers should not make that a mechanical part of every command.
const assessment = await orders.assess({
id: "order-123",
event: "approve",
actor: reviewer,
});Use transition() for the authoritative command. Interlock rechecks policy and
commits everything in one transaction.
const result = await orders.transition({
id: "order-123",
event: "approve",
actor: reviewer,
expectedVersion: "7",
idempotency: { key: "approve-order-123-request-42" },
});
if (result.status === "committed") {
console.log(result.duplicate ? "Already applied" : "Approved");
console.log(result.transition.id);
}Event names and submitted inputs come from the lifecycle definition, so invalid
commands fail at compile time. Untyped callers still receive runtime
unknown-event and invalid-input results.
transition() returns expected domain outcomes and throws operational failures:
| Outcome | Meaning |
|---|---|
committed |
The full transaction committed. duplicate identifies an idempotent replay. |
denied |
Authorization, a guard, or the current source state rejected the command. |
conflict |
The resource no longer matches the expected version or state. |
not-found |
The primary resource does not exist. |
idempotency-conflict |
The key was already used for a different command fingerprint. |
invalid-input |
Runtime input validation failed. |
unknown-event |
An untyped caller submitted an unknown event name. |
Operational and integration contract failures throw stable InterlockError
codes. Use isInterlockError(error) rather than relying on instanceof across
multiple physical package copies. See the error-code reference
for retry and integration guidance.
Handle every expected result explicitly:
switch (result.status) {
case "committed":
return result.duplicate ? "already-applied" : "applied";
case "denied":
case "conflict":
case "not-found":
case "idempotency-conflict":
case "invalid-input":
case "unknown-event":
return result.status;
default: {
const exhaustive: never = result;
return exhaustive;
}
}Before opening a transaction, Interlock validates the command and computes its idempotency fingerprint.
Inside one driver-owned transaction it:
- claims the idempotency key;
- loads the primary resource;
- rechecks authorization and guards;
- prepares and validates the complete write plan;
- applies the primary state-and-version update;
- inserts append-only history, then applies related writes;
- inserts outbox rows;
- completes idempotency and commits.
History precedes related writes inside the same transaction. Related tables may
therefore use an immediate foreign key to the supplied transitionId; a later
related, outbox, hydration, or completion failure still rolls the history row
back.
Bindings should normally return the updated resource directly from a conditional
UPDATE ... RETURNING. hydrateBeforeCommit() adds another database round trip
and is intended for joins, generated values, or projections that the primary
update cannot reasonably return.
If authorization and multiple guards need the same related data, memoize the promise within that one assessment or transition rather than querying again:
function once<T>(load: () => Promise<T>): () => Promise<T> {
let pending: Promise<T> | undefined;
return () => (pending ??= load());
}Rejected promises remain failures for that operation. Cross-request caching is outside Interlock, and guards remain sequential for ordering and short-circuit behavior.
For production latency, keep the application and PostgreSQL close, reuse one
warm pg.Pool, and size it for database capacity rather than incoming request
concurrency. Batch related writes in the binding, keep outbox payloads small,
and store references instead of large blobs when practical.
Expected outcomes after an idempotency claim force rollback before being returned. A same-key duplicate returns the stored transition identity without rerunning current policy or exposing a potentially unrelated current resource. Historical duplicate edges are validated as stored history, not against the current lifecycle graph, so a deployment may evolve an event without breaking replay of an already committed key.
Interlock durably audits committed transitions. An optional InterlockObserver
reports operation starts, expected outcomes, duplicate replay, operational
failures, failure phases, and total and transaction duration.
import type { InterlockObserver } from "@jajego/interlock";
const observer: InterlockObserver = {
observe(observation) {
telemetry.record(observation);
},
};
const client = createInterlock({ lifecycle, binding, driver, observer });Observer delivery is best-effort and never runs inside the Interlock-owned transaction. Exceptions and rejected promises are ignored, and no logger, metrics library, tracing SDK, or telemetry backend is bundled. Keep callbacks lightweight because synchronous work still adds caller-visible latency.
See the observability guide for exact event shapes, outcome and phase mappings, safe metric labels, tracing adapters, and the boundary between operational telemetry and durable audit history.
@jajego/interlock-postgres/migration.sql creates the idempotency,
transition-history, and outbox tables in the active schema. Resolve and read the
public SQL export:
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const migration = await readFile(
fileURLToPath(
import.meta.resolve("@jajego/interlock-postgres/migration.sql"),
),
"utf8",
);The migration runs as one transaction in the active migration schema. The runtime driver qualifies its own tables directly:
const driver = new PostgresDriver(pool, { schema: "interlock" });On a dedicated migration connection, create the interlock schema, set that
connection's session search_path, and execute the exported self-transactional
migration. Do not change a shared runtime pool's session setting. The migration
targets clean installations and does not upgrade older incompatible schemas.
Idempotent transitions are supported at read committed isolation. Interlock
rejects higher isolation levels for idempotent commands rather than advertising
an unproved concurrency algorithm.
- Production-style Fastify + Prisma reference app
- Reference benchmark methodology
- Runnable PostgreSQL example
- PostgreSQL integration guide
- Observability and durable auditing
- Idempotency model
- Architecture and transaction protocol
- Error-code reference
- Lifecycle builder ADR
Interlock deliberately focuses on recording one domain transition correctly. It does not execute workflows, schedule jobs, publish outbox messages, generate APIs, or replace application-level database constraints. Outbox insertion is atomic; external delivery remains the application's responsibility.
Applications must control direct writes to protected state and version columns.
Interlock history and idempotency keys use (lifecycle, resource_id) identity,
so tenant-local IDs must be namespaced by the application or replaced with
globally unique IDs before they reach Interlock. Bindings must also document how
related facts used by guards are stabilized. The reference example demonstrates
aggregate versioning for this purpose. For database-enforced append-only
history, deny application roles UPDATE and DELETE privileges on
interlock_transition_history.
A connection loss during commit reports INTERLOCK_COMMIT_OUTCOME_UNKNOWN.
Reconcile through stored idempotency and history data instead of blindly
retrying.
Interlock freezes the operation envelope and snapshots top-level command identity and JSON protocol values before crossing asynchronous persistence boundaries. Actor values and parsed input are application-owned references; parsers and callbacks must not mutate them after returning.
Mutation, audit, outbox, history-actor, and history-metadata projections may be synchronous or asynchronous. They run sequentially and all settle before the primary write. Transactional reads are allowed; writes and external side effects are not. Caller-initiated retries may evaluate them again.
The 0.1.0-alpha.1 release tests:
- Node.js 22.14+ and 26;
- TypeScript 5.0+;
- PostgreSQL 16;
pg8.16.3 through 8.x.
Pre-1.0 APIs may change with changelog notice.
pnpm install --frozen-lockfile
pnpm format
pnpm lint
pnpm check
docker compose up -d --wait
TEST_DATABASE_URL=postgres://interlock:interlock@localhost:54329/interlock pnpm test:postgres
pnpm pack:check
TEST_DATABASE_URL=postgres://interlock:interlock@localhost:54329/interlock pnpm benchmarkBenchmark methodology and current maintainer measurements are recorded in docs/performance.md. Local Docker loopback results are not production latency guarantees.
See CONTRIBUTING.md for contribution expectations.