# Triplex documentation
> The database that remembers why: an embedded fact database for TypeScript back-office systems. Every write records who made it, which versioned rules governed it, and when it was true, so audit questions and open work become queries. Triplex is pre-1.0; install with `npm install @triplex-build/triplex effect@4.0.0-rc.112`.
Triplex requires Effect 4 (`effect@4.0.0-rc.112`; Effect 3 is not compatible), is ESM-only, and targets
Node.js 22+ plus modern browsers and edge runtimes for the core. In-memory and SQLite storage are
supported, PostgreSQL is a production candidate, and Cloudflare and FoundationDB are experimental.
Coding agents modeling a back-office domain should read the modeling guide first. Every page is
available as Markdown by appending `.md` to its URL. Code on the homepage and in the modeling
guide is type-checked and executed in CI.
---
An embedded fact database · TypeScript + Effect
triplex_
# The database that remembers why.
Facts change. Rules change. Your record of what happened shouldn’t.
Triplex keeps facts, time, and versioned rules together. Every write records **who acted**, **which rules applied**, and **when it was true**. Audit questions and open work become queries instead of investigations.
[Get started](/getting-started)
[Follow one decision ↓](#one-scenario)
Pre-1.0 · MIT licensed · In-memory + SQLite supported; PostgreSQL is a production candidate · [Current state ↗](/current-state)
Start in your process
```sh
npm install @triplex-build/triplex effect@4.0.3
```
Effect 4 required · [First program →](/getting-started)
[t₁ / t₂**Two clocks.**What was true. What you knew.](#what-did-we-know)
[#**Pinned rules.**The release behind every decision.](#which-rules)
[=>**Derived work.**What should happen next.](#what-happens-next)
## Reading this as a coding agent?
Start with the [Modeling guide for agents](/agents). Get the index at [/llms.txt](/llms.txt), every guide at [/llms-full.txt](/llms-full.txt), or append `.md` to any page URL. The [CLI](/tools) prints JSON.
00 / The missing context
## “Why?” is the most expensive question a back-office system gets asked.
An auditor asks: *was Maria trained for the Harbor site on the day she started there?* In a typical stack, the evidence is spread across systems that never shared an identity model or a clock.
| Where the evidence usually lives | What it can no longer tell you |
| -------------------------------- | ----------------------------------------------------------------------- |
| Application database | The training row was corrected in place, so the original belief is gone |
| Config service or repository | The placement rules have changed since. Which version applied? |
| Audit log | Who acted, but not the facts they saw or the rules they were held to |
| Job queue | Whether a training task should have existed, and when it should reopen |
Triplex keeps facts, versioned rules, the causal journal, and derived work in one store with one identity model and one clock. The auditor’s question becomes a query.
01 / One scenario, start to finish
## Maria, the Harbor site, and a certificate that was wrong.
Maria is trained for Harbor from March 1 to June 1 and placed there on March 2 under release `hr-2026.1`. The rules then change: `hr-2026.2` requires a supervisor on every placement. Later, an auditor finds that the certificate actually began on March 5.
Every snippet is type-checked. Every output is produced by [running this scenario in CI](https://github.com/bjacobso/triplex/tree/main/docs/snippets/home/site-safety). The database’s own journal reads:
| Position | Actor | Command | Changes (+ assert, − retract; valid time) |
| -------- | ---------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| 1 | `triplex/config-store` | — | Release `hr-2026.1`; `live` moved to it |
| 2 | `user:hr` | `onboard:maria` | + `:site/name` `"Harbor"` from 2026-01-05; + `:worker/name` `"Maria"` from 2026-01-05 |
| 3 | `user:trainer` | `training:record:maria-harbor` | + `:worker/trained-for` `site:harbor` [2026-03-01, 2026-06-01) |
| 4 | `user:dana` | `placement:create:maria-harbor` | + `:placement/worker` `worker:maria` from 2026-03-02; + `:placement/site` `site:harbor` from 2026-03-02 |
| 5 | `triplex/config-store` | — | Release `hr-2026.2`; `live` moved to it |
| 6 | `user:auditor` | `training:correct:maria-harbor` | − `:worker/trained-for` `site:harbor` [2026-03-01, 2026-06-01); + `:worker/trained-for` `site:harbor` [2026-03-05, 2026-06-01) |
00 · Record
### One command, one atomic transaction.
Dana’s placement records who acted, a command ID that makes retries safe, a correlation ID, and the release that governed the write. The constraints come from that same release and are checked in the same transaction.
Nothing is overwritten. The auditor’s correction retracts the old belief and asserts the new one, and the journal keeps both.
[Transactions and the journal](/operational-primitives)
```ts
// One application command is one atomic transaction that records who acted,
// which command it was, and the release whose rules it was checked against.
export const placeWorker = (release: InMemoryConfigStore.ConfigSnapshot) =>
Effect.gen(function* () {
const triples = yield* Triples;
const rules = yield* GraphConstraint.collect(release.root);
return yield* triples.transact(
[
{
op: "assert",
entityId: placement,
entityType: "Placement",
...Placement.attributes.worker.assertion(maria, { validFrom: MAR_2 }),
},
{
op: "assert",
entityId: placement,
entityType: "Placement",
...Placement.attributes.site.assertion(harbor, { validFrom: MAR_2 }),
},
],
{
actor: "user:dana",
commandId: "placement:create:maria-harbor", // unique: a retry can never apply twice
correlationId: "request:7f3a",
configSnapshot: release.id, // the rules this write was governed by
enforce: GraphConstraint.enforcement(rules), // required/unique/ref checks, atomically
},
);
});
```
02 / Three questions, three queries
## Ask what you knew, which rules applied, and what happens next.
Each answer below is the real output of the code beside it.
01 · What did we know?
### Two clocks: when it was true, and when you learned it.
`validAt` is the day in question. `recordedAt` is the instant of Dana’s receipt. At that instant, the record said Maria was trained. Today, after the correction, it says she was not.
Both answers can be proven. Dana acted in good faith on a record that was later corrected.
[Recorded time and valid time](/concepts#recorded-time-and-valid-time)
```ts
import { Effect } from "effect";
import { Triples, type TemporalBasis } from "@triplex-build/triplex";
import { TrainedFor, maria } from "./model.ts";
import { MAR_2 } from "./record.ts";
// Was Maria trained for Harbor on the day she started there?
export const wasMariaTrained = Effect.gen(function* () {
const triples = yield* Triples;
const decision = yield* triples.transactionByCommand("placement:create:maria-harbor");
if (decision === null) return yield* Effect.die("placement was never recorded");
const trained = (basis: TemporalBasis) =>
triples
.match({ entityId: maria, attribute: TrainedFor.key }, basis)
.pipe(Effect.map((facts) => facts.length > 0));
return {
// What the database believed at the instant Dana made the placement.
knownAtDecision: yield* trained({ validAt: MAR_2, recordedAt: decision.instant }),
// What the database knows now, after the auditor's correction.
knownToday: yield* trained({ validAt: MAR_2 }),
};
});
```
Output
```json
{ "knownAtDecision": true, "knownToday": false }
```
02 · Which rules applied?
### Every receipt is pinned to an immutable release.
The `live` ref has moved to `hr-2026.2`, but Dana’s placement still points at `hr-2026.1`, the content-addressed release that governed it.
The same kind of command under today’s rules is rejected atomically. The rejected command writes no facts, no receipt, and no journal entry.
[Releases, refs, and rollback](/configuration-versioning)
```ts
import { Effect } from "effect";
import { Triples } from "@triplex-build/triplex";
import { ConfigStore, ContentId } from "@triplex-build/triplex/config";
// Who placed Maria, and under which version of the rules?
export const governingRules = Effect.gen(function* () {
const triples = yield* Triples;
const config = yield* ConfigStore.ConfigStore;
const receipt = yield* triples.transactionByCommand("placement:create:maria-harbor");
const pinned = receipt?.configSnapshot;
if (pinned === undefined || !ContentId.isContentId(pinned)) {
return yield* Effect.die("placement was not pinned to a release");
}
return {
actor: receipt?.actor,
correlationId: receipt?.correlationId,
governedBy: (yield* config.snapshotById(pinned))?.label,
liveToday: (yield* config.resolveRef("live"))?.label,
};
});
```
Output
```json
{
"actor": "user:dana",
"correlationId": "request:7f3a",
"governedBy": "hr-2026.1",
"liveToday": "hr-2026.2"
}
```
The same command under `hr-2026.2`
```json
{ "error": "ConstraintViolationError", "violations": ["Placement requires :placement/supervisor"] }
```
03 · What needs to happen?
### Open work is a query over facts.
The obligation is a Datalog derivation: a placed worker without current training for that site.
On March 3, the corrected record exposes a gap, so the task is open. On April 1 nothing is open, and the evaluation reports when that can next change. On June 1 the training expires and the task reopens, even though nobody wrote anything.
`recheckOn` comes from `nextTemporalBoundary`. Schedule a wake-up for that date instead of polling.
[Derivations and reconciliation](/derivations)
```ts
import { Effect } from "effect";
import { Triples } from "@triplex-build/triplex";
import * as Derivation from "@triplex-build/triplex/derivation";
import { PlacementSite, PlacementWorker, TrainedFor } from "./model.ts";
// Obligation: every placed worker holds current training for that site.
export const siteSafety = (configSnapshot: string) =>
Derivation.make({
name: "task.site-safety-training",
query: {
find: ["?worker", "?site"],
where: [
["?placement", PlacementWorker.key, "?worker"],
["?placement", PlacementSite.key, "?site"],
["not", ["?worker", TrainedFor.key, "?site"]],
],
},
identity: ["?worker", "?site"],
configSnapshot,
});
const day = (instant: number) => new Date(instant).toISOString().slice(0, 10);
// Which training tasks should be open on a given day, and when will that change?
export const openWorkOn = (configSnapshot: string, validAt: number) =>
Effect.gen(function* () {
const triples = yield* Triples;
const definition = yield* siteSafety(configSnapshot);
const { candidates, nextTemporalBoundary } = yield* Derivation.evaluate(triples, definition, {
basis: { validAt },
});
return {
on: day(validAt),
open: candidates.map((task) => task.result),
recheckOn: nextTemporalBoundary === undefined ? null : day(nextTemporalBoundary),
};
});
```
Output for March 3, April 1, and June 1
```json
[
{
"on": "2026-03-03",
"open": [{ "?worker": "worker:maria", "?site": "site:harbor" }],
"recheckOn": "2026-03-05"
},
{ "on": "2026-04-01", "open": [], "recheckOn": "2026-06-01" },
{
"on": "2026-06-01",
"open": [{ "?worker": "worker:maria", "?site": "site:harbor" }],
"recheckOn": null
}
]
```
03 / Derived work
## Stop enqueuing obligations. Reconcile them.
Most back-office bugs come down to a task that should exist but doesn’t, or one that exists but shouldn’t. Event handlers create work one step at a time and drift out of sync with the data. A derivation states what should be true, and reconciliation reports the difference.
| | Event handlers that enqueue tasks | Triplex derivations |
| ----------------- | --------------------------------------- | ------------------------------------------------------------------ |
| Source of truth | Whatever each handler remembered to do | A Datalog query over facts, pinned to a release |
| Late corrections | Missed unless someone re-runs a handler | Appear on the next evaluation, even for past dates |
| Expiring evidence | A separate cron job per rule | `nextTemporalBoundary` says exactly when to re-evaluate |
| Duplicates | Hand-built idempotency | Stable, content-addressed candidate IDs |
| Explanation | Log lines | Each candidate carries its source facts and assertion transactions |
| Change | Diff it yourself | `Derivation.reconcile` returns `added`, `removed`, and `changed` |
### Triplex owns
Facts, history, and the temporal read basis. Atomic transactions, command receipts, and the ordered journal. Release identity and constraint enforcement. Derivation candidates, provenance, and reconciliation diffs. Consumer checkpoints and wake-up boundaries.
### Your application owns
Domain vocabulary and entity IDs. Authentication, authorization, and HTTP handlers. What a release contains and when `live` moves. What each reconciliation diff means: a ticket, an email, a workflow. Timer delivery and retries.
### See it end to end
The [compliance host example](https://github.com/bjacobso/triplex/tree/main/examples/compliance-host) adds durable task occurrences, a journal consumer with a checkpoint, a hypothetical preview, and a scheduler that reopens work at expiry. Read [Host integration](/host-integration) for the full boundary.
04 / Modeling
## From a back-office domain to Triplex primitives.
Most back-office systems are built from the same parts. This is where each part goes.
| In your domain | In Triplex | In the scenario |
| --------------------------------------- | ---------------------------------------------------------------------- | -------------------------------- |
| Things: workers, sites, cases, accounts | Stable `EntityId`s and an `EntityType` per kind | `worker:maria`, `Worker` |
| Properties and relationships | Global `Attribute` keys, with `Attribute.ref` for relationships | `:placement/site` |
| Schemas, policies, forms | Config nodes committed as an immutable release, pointed to by `live` | `hr-2026.1`, `hr-2026.2` |
| User and system actions | One `transact` per command with `actor`, `commandId`, `configSnapshot` | `placement:create:maria-harbor` |
| Evidence that starts or expires | `validFrom` and `validTo` on the assertion | Training valid March 1 to June 1 |
| Corrections | Retract and assert in one transaction; history is kept | The auditor’s fix |
| Obligations and to-dos | A `Derivation` whose candidates you reconcile | `task.site-safety-training` |
| Tickets, notifications, SLAs | Your code, driven by reconciliation diffs and wake-up boundaries | Host-owned |
| Audit questions | A temporal basis, command receipts, and the journal | Questions 01 and 02 |
[Read the full modeling guide](/agents)
05 / Deployment
## A library, not another server.
Triplex runs inside your process as Effect layers. Switching storage changes the layer you provide, and the rest of the program stays the same. On PostgreSQL, Triplex can share your application’s transaction, so your own rows, Triplex facts, the journal, and an outbox row commit or roll back together.
```ts
import { Layer } from "effect";
import { KvTriples } from "@triplex-build/triplex";
import { ConfigStore } from "@triplex-build/triplex/config";
import { PgTriples } from "@triplex-build/triplex-postgres";
import { SqliteTriples } from "@triplex-build/triplex-sqlite";
// The same program runs against any of these. Only the provided layer changes.
export const InMemory = ConfigStore.layer.pipe(Layer.provideMerge(KvTriples.layer));
export const Sqlite = ConfigStore.layer.pipe(
Layer.provideMerge(SqliteTriples.layer({ filename: "app.db" })),
);
export const Postgres = ConfigStore.layer.pipe(
Layer.provideMerge(PgTriples.layerFromUrl(process.env["DATABASE_URL"] ?? "")),
);
```
| Runtime | Package | Status |
| -------------------------- | --------------------------------- | ------------------------------- |
| In-memory KV | `@triplex-build/triplex` | Supported: tests, browser, edge |
| SQLite | `@triplex-build/triplex-sqlite` | Supported durable baseline |
| PostgreSQL | `@triplex-build/triplex-postgres` | Pre-1.0 production candidate |
| Cloudflare Durable Objects | private workspace package | Experimental |
| FoundationDB | private workspace package | Experimental |
Want to build on another storage engine? Explore the [backend blueprints](/backends): working SQLite, experimental FoundationDB, and proposed Datomic and XTDB adapters. Each maps storage primitives to the Triplex contract and identifies what still needs to be built.
Also in the box: a JSON-first [CLI](/tools) for agents, a browser [data explorer](/explorer), a [configuration-derived HTTP API](/http-api) with OpenAPI, and an in-browser [playground](/playground).
06 / Fit
## When to use Triplex, and when not to.
### Good fit
Compliance and onboarding, eligibility and entitlements, policy engines, case management, and back-office workflows. Any domain where “as of when?” and “under which rules?” come up regularly. Also agent-driven systems that need a durable world model with an attributed record of every change.
### Not a fit
High-volume telemetry, blob storage, caches, and queues. Also workloads that only need a relational table and never ask about history or provenance. Triplex is a system of record for facts, not a replacement for every table.
### How it relates
[Datomic](/datomic) offers facts, Datalog, and historical database views. [Current XTDB](/xtdb) offers bitemporal data through SQL and XTQL. Triplex embeds in TypeScript and connects facts to versioned rules and reconciled work. **Event sourcing** gives you history, but you hand-write every projection. **Workflow engines** run durable code; Triplex decides what work should exist, and the two compose.
07 / Part of the WorldVM family
## A memory for your world.
Small experiments in software you can inspect, explain, and build on. Triplex keeps the facts. These projects explore the language and models around them.
[**Forma ↗**A typed Lisp for building your own domain language.](https://github.com/bjacobso/forma)
[**Schematics ↗**Small Effect libraries that make application rules inspectable.](https://schematics.run)
[**Open Ontology ↗**Configuration as code, checked across platforms.](https://open-ontology.com)
[Meet the whole family at worldvm.com ↗](https://worldvm.com/)
[Quickstart](/getting-started)
[Modeling guide](/agents)
[Core concepts](/concepts)
[Datalog](/datalog)
[Typed configuration](/configuration)
[Derivations](/derivations)
[Host integration](/host-integration)
[Current state](/current-state)
---
# Getting started
This guide writes three facts to an in-memory Triplex database, joins them with Datalog, and prints
the result. It is the shortest complete path to a running program.
> **Pre-1.0 packages**
The `@triplex-build` packages are available from npm and require `effect@4.0.3`.
Effect 3 is incompatible. Do not use the superseded `@bjacobso` package names for new work.
## Prerequisites
- Git
- Node.js 22 or newer
- Corepack and pnpm 10.11.0 (the repository declares the exact package-manager version)
For an application, install the published core package and its Effect peer dependency:
```sh
npm install @triplex-build/triplex effect@4.0.3
```
## Run the example
Clone the repository and install its locked dependencies:
```sh
git clone https://github.com/bjacobso/triplex.git
cd triplex
corepack enable
pnpm install --frozen-lockfile
```
Run the checked quickstart directly from the checkout:
```sh
pnpm exec tsx --tsconfig docs/snippets/tsconfig.json docs/snippets/getting-started.ts
```
The complete program is:
```ts
import { Effect } from "effect";
import { EntityId, KvTriples, Triples, ref, string } from "@triplex-build/triplex";
const program = Effect.gen(function* () {
const triples = yield* Triples;
const alice = EntityId.make("person:alice");
const acme = EntityId.make("company:acme");
yield* triples.transact(
[
{
op: "assert",
entityId: alice,
entityType: "Person",
attribute: ":person/name",
value: string("Alice"),
},
{
op: "assert",
entityId: alice,
entityType: "Person",
attribute: ":person/employer",
value: ref(acme),
},
{
op: "assert",
entityId: acme,
entityType: "Company",
attribute: ":company/name",
value: string("Acme"),
},
],
{
actor: "quickstart",
commandId: "quickstart/create-alice/v1",
},
);
const { results } = yield* triples.query({
find: ["?personName", "?companyName"],
where: [
["?person", ":person/name", "?personName"],
["?person", ":person/employer", "?company"],
["?company", ":company/name", "?companyName"],
],
});
return results.map((row) => ({
person: row["?personName"],
company: row["?companyName"],
}));
});
const relationships = await Effect.runPromise(program.pipe(Effect.provide(KvTriples.layer)));
console.log(JSON.stringify({ relationships }, null, 2));
```
It prints:
```json
{
"relationships": [
{
"person": "Alice",
"company": "Acme"
}
]
}
```
The repository's smaller demo is another executable starting point:
```sh
pnpm exec tsx --tsconfig docs/snippets/tsconfig.json examples/demo/demo.ts
```
## What the Effect code is doing
You only need four Effect ideas for this example:
1. `Triples` is a service tag. `yield* Triples` asks the current Effect context for the database.
2. `Effect.gen` lets the program sequence database effects with generator syntax.
3. `KvTriples.layer` constructs the in-memory implementation of that service.
4. `Effect.provide` supplies the layer, and `Effect.runPromise` runs the fully provided program.
Create and share a layer at your application boundary. Do not construct a new layer inside each
request: each `KvTriples.layer` runtime owns a fresh in-memory database, and its contents disappear
when that runtime/process ends.
The transaction records three typed values atomically. `ref(acme)` is a relationship because its
value is another `EntityId`; the Datalog query follows that relationship by using the same
`?company` variable in two clauses. `query` returns one bounded page, which is enough for this
one-row example. Follow `nextCursor` for larger results or use `queryAll` only for trusted batch
work that intentionally needs the complete result set.
## Use durable SQLite
SQLite is the supported local persistent backend. Install it alongside core and Effect:
```sh
npm install @triplex-build/triplex-sqlite
```
Replace the in-memory layer with a file-backed layer:
```ts
import { SqliteTriples } from "@triplex-build/triplex-sqlite";
const DatabaseLive = SqliteTriples.layer({ filename: "./triplex.db" });
const relationships = await Effect.runPromise(program.pipe(Effect.provide(DatabaseLive)));
```
This is a focused replacement fragment: `program` is the complete program above. The convenience
layer opens the SQLite file and applies Triplex's current migration. Create it once and share it for
the application lifetime. Production hosts that separate schema migration from runtime startup
should use the explicit unmigrated composition described in [Host integration](/host-integration).
## Next steps
- Experiment without installing anything in the browser [Playground](/playground).
- Read [Core concepts](/concepts) before modeling a domain with historical facts.
- Follow [Configuration releases and rollback](/configuration-versioning) to version rules and pin
operational writes to them.
- Learn query clauses and pagination in [Datalog](/datalog).
- Explore a database through the [CLI and dashboard](/tools).
- Check the [current maturity contract](/current-state) before choosing a production backend.
---
# Modeling a back-office domain
This guide is for coding agents, and the people directing them, who are deciding whether and how
to model a back-office domain in Triplex. It is prescriptive on purpose: follow the rules unless
you have a specific reason not to, and each section links to the reference behind it.
Every code block on this page comes from the
[site-safety scenario](https://github.com/bjacobso/triplex/tree/main/docs/snippets/home/site-safety).
CI type-checks and runs that scenario on every change. This page is also available as
[Markdown](/agents.md), and the full documentation is available as [`/llms-full.txt`](/llms-full.txt).
## Decide whether Triplex fits
Use Triplex when the domain has one or more of these properties:
- People ask what was true, or what the system believed, at an earlier date.
- Policies, schemas, or forms change, and earlier decisions must still be explained under the
rules that applied then.
- Work follows from the data: missing evidence, expiring certifications, cases waiting for review.
- Several actors write, whether people, services, or agents, and every change must be attributed
and safe to retry.
Do not use it for high-volume telemetry, blobs, caches, queues, or tables that never need history
or provenance. Those belong in your existing database, and Triplex can live
[beside it](/host-integration).
Check these constraints with the people you work for before adopting it:
| Constraint | Current contract |
| -------------- | ---------------------------------------------------------------------------- |
| Maturity | Pre-1.0. See [Current state](/current-state) |
| Effect | Requires `effect@4.0.3`. Effect 3 is not compatible |
| Runtime | ESM-only, Node.js 22+, modern browsers and edge runtimes for the core |
| Durable stores | SQLite is supported. PostgreSQL is a production candidate |
| Host duties | Authentication, authorization, task lifecycle, timers, and retries are yours |
## Map the domain
| In your domain | In Triplex |
| --------------------------------------- | -------------------------------------------------------------------- |
| Things: workers, sites, cases, accounts | Stable `EntityId`s and an `EntityType` per kind |
| Properties and relationships | Global `Attribute` keys, with `Attribute.ref` for relationships |
| Schemas, policies, forms | Config nodes committed as an immutable release, pointed to by `live` |
| User and system actions | One `transact` call per command |
| Evidence that starts or expires | `validFrom` and `validTo` on the assertion |
| Corrections | Retract and assert in one transaction |
| Obligations and to-dos | A `Derivation` whose candidates you reconcile |
| Tickets, notifications, SLAs | Your code, driven by reconciliation diffs |
| Audit questions | A temporal basis, command receipts, and the journal |
## 1. Name entities and attributes
- Give every entity a stable, typed ID such as `worker:maria` or `case:01J9…`. Construct IDs once
at the application boundary with `EntityId.make`. Don't cast strings deep inside domain code.
- Attribute keys are global keywords of the form `:namespace/name`, matching
`^:[a-z][a-z0-9_-]*/[a-z][a-z0-9_-]*$`. An attribute owns its identity and value type.
- An entity type owns how it uses an attribute: `required`, `cardinality` (`"one"` or `"many"`),
and `unique`. The same attribute can be required in one type and optional in another.
- Model relationships as `Attribute.ref(key, TargetType)`. There are no join tables. Datalog joins
on shared values.
- Available value types: `text`, `number`, `integer`, `boolean`, `date`, `instant`, `enumOf`,
and `ref`.
- Set `entityType` on every assertion so type-level reads and constraints can find the entity.
```ts
import { Effect } from "effect";
import { EntityId } from "@triplex-build/triplex";
import { Attribute, ConfigStore, EntityType } from "@triplex-build/triplex/config";
// Attributes own global identity and value type.
export const SiteName = Attribute.text(":site/name");
export const Site = EntityType.make("Site", {
attributes: { name: Attribute.use(SiteName, { required: true, unique: true }) },
});
export const WorkerName = Attribute.text(":worker/name");
export const TrainedFor = Attribute.ref(":worker/trained-for", Site);
export const Worker = EntityType.make("Worker", {
attributes: {
name: Attribute.use(WorkerName, { required: true }),
trainedFor: Attribute.use(TrainedFor, { cardinality: "many" }),
},
});
export const PlacementWorker = Attribute.ref(":placement/worker", Worker);
export const PlacementSite = Attribute.ref(":placement/site", Site);
export const Placement = EntityType.make("Placement", {
attributes: {
worker: Attribute.use(PlacementWorker, { required: true }),
site: Attribute.use(PlacementSite, { required: true }),
},
});
// hr-2026.2 changes the rules: every placement now needs a supervisor.
export const PlacementSupervisor = Attribute.text(":placement/supervisor");
export const PlacementV2 = EntityType.make("Placement", {
attributes: {
worker: Attribute.use(PlacementWorker, { required: true }),
site: Attribute.use(PlacementSite, { required: true }),
supervisor: Attribute.use(PlacementSupervisor, { required: true }),
},
});
export const maria = EntityId.make("worker:maria");
export const harbor = EntityId.make("site:harbor");
export const placement = EntityId.make("placement:maria-harbor");
// Entity types own usage rules. Publishing them creates an immutable release.
export const publish = (label: string, placementType: typeof Placement | typeof PlacementV2) =>
Effect.gen(function* () {
const config = yield* ConfigStore.ConfigStore;
return yield* config.commit({
label,
objects: [...(yield* Site.nodes), ...(yield* Worker.nodes), ...(yield* placementType.nodes)],
ref: "live",
});
});
```
Read more in [Typed configuration](/configuration#ontology-dsl).
## 2. Publish rules as releases
- Commit schemas, policies, and forms together with `config.commit({ label, objects, ref: "live" })`.
Each release is immutable and content-addressed.
- To change the rules, publish a new release. Never edit an old one. Moving `live` back
to an earlier release doesn't roll back data.
- At command time, resolve the release you intend to enforce and keep its snapshot for the whole
command.
Read more in [Releases, refs, and rollback](/configuration-versioning).
## 3. Write each command as one transaction
Follow these rules for every write:
1. **One application command is one `transact` call.** Everything the command changes commits or
rolls back together.
2. **Always set `actor` and a deterministic `commandId`.** Derive the command ID from the request
so that a retry reuses it. Command IDs are atomically unique per database, and a duplicate fails
with `CommandAlreadyCommittedError` instead of applying twice.
3. **Set `correlationId` and `causationId`.** Use the request or case for correlation and the
triggering command or event for causation.
4. **Pin `configSnapshot` and pass `enforce` from the same release.** Pinning alone only records
which rules applied. Constraints are checked only when you also pass them with `enforce`.
5. **Put business time on the assertion.** Use `validFrom` and `validTo` when the dates define when
the fact holds. Intervals are half-open: `[validFrom, validTo)`.
6. **Correct facts; don't delete them.** Retract the wrong assertion and assert the correct one in
the same transaction. For facts that other writers may also change, add a
`{ _tag: "TripleLive", id }` precondition so a concurrent change fails with a conflict instead
of being lost.
```ts
// One application command is one atomic transaction that records who acted,
// which command it was, and the release whose rules it was checked against.
export const placeWorker = (release: InMemoryConfigStore.ConfigSnapshot) =>
Effect.gen(function* () {
const triples = yield* Triples;
const rules = yield* GraphConstraint.collect(release.root);
return yield* triples.transact(
[
{
op: "assert",
entityId: placement,
entityType: "Placement",
...Placement.attributes.worker.assertion(maria, { validFrom: MAR_2 }),
},
{
op: "assert",
entityId: placement,
entityType: "Placement",
...Placement.attributes.site.assertion(harbor, { validFrom: MAR_2 }),
},
],
{
actor: "user:dana",
commandId: "placement:create:maria-harbor", // unique: a retry can never apply twice
correlationId: "request:7f3a",
configSnapshot: release.id, // the rules this write was governed by
enforce: GraphConstraint.enforcement(rules), // required/unique/ref checks, atomically
},
);
});
```
A retry with the same command ID returns the original receipt, and the journal gains no entry:
```ts
import { Effect } from "effect";
import { Triples } from "@triplex-build/triplex";
import type { InMemoryConfigStore } from "@triplex-build/triplex/config";
import { placeWorker } from "./record.ts";
// A retried command must never apply twice. The duplicate is rejected with the
// original transaction, so the caller can answer with the first receipt.
export const placeWorkerOnce = (release: InMemoryConfigStore.ConfigSnapshot) =>
placeWorker(release).pipe(
Effect.map((result) => result.position),
Effect.catchTag("CommandAlreadyCommittedError", (duplicate) =>
Effect.gen(function* () {
const triples = yield* Triples;
const receipt = yield* triples.transactionByCommand(duplicate.commandId);
return receipt?.position;
}),
),
);
```
Read more in [Operational primitives](/operational-primitives) and
[Troubleshooting](/troubleshooting#what-should-happen-on-a-duplicate-command-id).
## 4. Model obligations as derivations
- Write the obligation as a Datalog query. Positive patterns describe the situation, and a `not`
clause describes the missing evidence. Every variable in `not` must be bound outside it.
- Set `identity` to the variables that name one obligation, such as worker and site. Candidate IDs
are then stable across re-evaluations.
- Use `Derivation.evaluate` for reads and previews, and
`Derivation.Materialization.materialize` for durable, freshness-tracked runs.
- Pass the difference from `Derivation.reconcile` (`added`, `removed`, `changed`) to your own task
system. Triplex decides what should be open; your code decides what "open" means.
- Wake up at `nextTemporalBoundary` to catch expiring or future-effective evidence. Don't poll.
- Follow the journal with `triples.transactions({ after })` and a `ConsumerCheckpoint`. Advance
the checkpoint only after downstream effects succeed.
- A `stale` or `unmaterialized` result doesn't mean there is no work. Rematerialize before
treating an empty result as current.
```ts
import { Effect } from "effect";
import { Triples } from "@triplex-build/triplex";
import * as Derivation from "@triplex-build/triplex/derivation";
import { PlacementSite, PlacementWorker, TrainedFor } from "./model.ts";
// Obligation: every placed worker holds current training for that site.
export const siteSafety = (configSnapshot: string) =>
Derivation.make({
name: "task.site-safety-training",
query: {
find: ["?worker", "?site"],
where: [
["?placement", PlacementWorker.key, "?worker"],
["?placement", PlacementSite.key, "?site"],
["not", ["?worker", TrainedFor.key, "?site"]],
],
},
identity: ["?worker", "?site"],
configSnapshot,
});
const day = (instant: number) => new Date(instant).toISOString().slice(0, 10);
// Which training tasks should be open on a given day, and when will that change?
export const openWorkOn = (configSnapshot: string, validAt: number) =>
Effect.gen(function* () {
const triples = yield* Triples;
const definition = yield* siteSafety(configSnapshot);
const { candidates, nextTemporalBoundary } = yield* Derivation.evaluate(triples, definition, {
basis: { validAt },
});
return {
on: day(validAt),
open: candidates.map((task) => task.result),
recheckOn: nextTemporalBoundary === undefined ? null : day(nextTemporalBoundary),
};
});
```
The
[compliance host example](https://github.com/bjacobso/triplex/tree/main/examples/compliance-host)
adds the complete loop: journal consumer, durable task occurrences, a hypothetical preview, and a
scheduler that reopens work at expiry. Read more in [Derivations](/derivations).
## 5. Answer audit questions
- **What did we believe then about what was true then?** Set both `recordedAt` and `validAt`.
Setting only one leaves the other at "now".
- **What did we know when we made a decision?** Use the decision's receipt `instant` as
`recordedAt`. Find the receipt with `transactionByCommand`.
- **Who changed this entity, and why?** Page `transactionsForEntity(entityId)`, newest first.
Each record carries its actor, command, correlation, causation, release, and typed changes.
- **What was retracted?** `history(entityId)` includes retracted assertions. `entity` and `match`
show only facts visible at the basis you ask for.
```ts
import { Effect } from "effect";
import { Triples, type TemporalBasis } from "@triplex-build/triplex";
import { TrainedFor, maria } from "./model.ts";
import { MAR_2 } from "./record.ts";
// Was Maria trained for Harbor on the day she started there?
export const wasMariaTrained = Effect.gen(function* () {
const triples = yield* Triples;
const decision = yield* triples.transactionByCommand("placement:create:maria-harbor");
if (decision === null) return yield* Effect.die("placement was never recorded");
const trained = (basis: TemporalBasis) =>
triples
.match({ entityId: maria, attribute: TrainedFor.key }, basis)
.pipe(Effect.map((facts) => facts.length > 0));
return {
// What the database believed at the instant Dana made the placement.
knownAtDecision: yield* trained({ validAt: MAR_2, recordedAt: decision.instant }),
// What the database knows now, after the auditor's correction.
knownToday: yield* trained({ validAt: MAR_2 }),
};
});
```
## 6. Choose a runtime
Construct one layer per database lifetime, at the application boundary. Building an in-memory
layer again creates a new empty database, and building a SQL layer per request defeats connection
sharing.
```ts
import { Layer } from "effect";
import { KvTriples } from "@triplex-build/triplex";
import { ConfigStore } from "@triplex-build/triplex/config";
import { PgTriples } from "@triplex-build/triplex-postgres";
import { SqliteTriples } from "@triplex-build/triplex-sqlite";
// The same program runs against any of these. Only the provided layer changes.
export const InMemory = ConfigStore.layer.pipe(Layer.provideMerge(KvTriples.layer));
export const Sqlite = ConfigStore.layer.pipe(
Layer.provideMerge(SqliteTriples.layer({ filename: "app.db" })),
);
export const Postgres = ConfigStore.layer.pipe(
Layer.provideMerge(PgTriples.layerFromUrl(process.env["DATABASE_URL"] ?? "")),
);
```
On PostgreSQL, Triplex can join your application's own Effect SQL transaction, so your rows,
Triplex facts, the journal, and an outbox row commit together. See
[Host integration](/host-integration#choose-a-runtime).
## Anti-patterns
| Don't | Do instead |
| ------------------------------------------------------------------- | ------------------------------------------------------------------ |
| Generate a new random `commandId` on every retry | Derive it from the request; handle `CommandAlreadyCommittedError` |
| Pin `configSnapshot` and assume constraints were checked | Also pass `enforce` built from that snapshot |
| Retract a fact in one command and assert its replacement in another | Do both in one transaction |
| Store a date attribute when the date says when the fact holds | Use `validFrom` and `validTo` |
| Re-run derivations on a fixed timer | Follow the journal and wake up at `nextTemporalBoundary` |
| Treat an empty `stale` or `unmaterialized` result as "no work" | Check freshness and rematerialize |
| Put task status, assignees, or SLAs in the derivation | Keep the task lifecycle in host code, driven by reconciliation |
| Call `queryAll` on request paths | Use `query` and follow `nextCursor` (100 rows per page by default) |
| Encode authorization as Datalog over the same store | Authorize in the host before providing `Triples` |
## Inspect with the CLI
The CLI is non-interactive and prints one JSON envelope per command:
`{ "ok": true, "command": ..., "data": ... }` on success, and `{ "ok": false, "error": ... }` on
stderr with a nonzero exit on failure. Writes require `meta.actor` and `meta.commandId`.
```sh
npm install --global @triplex-build/triplex-cli
triplex --sqlite ./app.db describe # capability manifest
triplex --sqlite ./app.db entity types
triplex --sqlite ./app.db entity facts worker:maria
triplex --sqlite ./app.db entity history worker:maria --limit 25
triplex --sqlite ./app.db query explain --input query.json
triplex --sqlite ./app.db journal receipt placement:create:maria-harbor
triplex --sqlite ./app.db config release --ref live
```
The unscoped `triplex` package on npm is an unrelated project. See [CLI and dashboard](/tools) for
the full command set.
## Next
- [Getting started](/getting-started) to run a first program with expected output
- [Core concepts](/concepts) for facts, retractions, and the two clocks
- [Datalog](/datalog) for joins, negation, aggregation, recursion, and pagination
- [Host integration](/host-integration) for the full responsibility boundary
- [Troubleshooting](/troubleshooting) for empty queries, missing services, and stale projections
---
# Playground
See how facts become answers. Pick a domain, try a change, and watch a real Triplex database
update in your browser. No installation or JSON editing needed to get started.
Loading the in-memory Triplex runtime…
## What this demonstrates
The sample query runs automatically. Add a score or a training record to change its answer, then
use **Retract this change** to see the original result return. Retraction changes which facts are
currently visible; the journal keeps both writes. **Reset database** starts a fresh session,
including a fresh journal.
Open **Go further: edit queries and transactions** to experiment with the underlying JSON. The
guided actions populate the transaction editor, so you can inspect the exact assertion or
retraction you just ran. Raw entity facts include IDs you can use in your own retractions.
The playground keeps one `ManagedRuntime` alive while you interact with a domain, so every command
resolves the same `Triples` service and in-memory KV store. Switching domains or resetting disposes
that runtime and builds a new one. This is the browser equivalent of the layer-lifetime guidance in
[Getting started](/getting-started#what-the-effect-code-is-doing).
The query and transaction editors decode the same public `DatalogQuery` and `TransactRequest`
schemas used by Triplex hosts and the CLI. Results are bounded to 100 rows. For durable data, graph
constraints, configuration authoring, or a larger operator interface, continue to [CLI and
dashboard](/tools).
---
# Core concepts
Triplex stores facts, not mutable rows. It keeps when each assertion was recorded, when it was valid
in the domain, which configuration release governed a write, and which facts supported derived
results. This guide uses one safety-training example to connect those ideas.
## Facts and relationships
Suppose worker Maria is placed at the Harbor site and has a safety certificate. Triplex represents
that state as small typed facts:
| Entity | Attribute | Value |
| ------------------- | --------------------- | ------------------- |
| `worker:maria` | `:worker/name` | `"Maria"` |
| `placement:harbor` | `:placement/worker` | `ref(worker:maria)` |
| `placement:harbor` | `:placement/site` | `ref(site:harbor)` |
| `certificate:maria` | `:certificate/worker` | `ref(worker:maria)` |
| `certificate:maria` | `:certificate/site` | `ref(site:harbor)` |
An entity ID groups facts for convenient entity reads. A reference value creates a relationship to
another entity. There is no separate relationship table or hidden object graph: queries join facts
by their values.
Entity type names such as `Worker` and global attribute names such as `:worker/name` are different
identities. Configuration can say how an entity type uses an attribute—required, single-valued,
unique, or reference-constrained—but those rules are not automatically enforced on every write.
## Assertions, retractions, and correction
An assertion says that a typed fact holds over a valid-time interval. A retraction closes that
assertion's **recorded** visibility; it does not erase the original fact or rewrite its history.
Corrections therefore append evidence:
1. retract the assertion that is no longer part of the current recorded view;
2. assert the corrected interval or value; and
3. do both in one attributed `Triples.transact` call.
`history(entityId)` includes retracted assertions. Current `entity` and `match` reads show only
facts visible at their requested bitemporal basis.
## Recorded time and valid time
The two clocks answer different questions:
- **Recorded time:** what the database knew at an instant.
- **Valid time:** when the fact was true in the domain.
Imagine this certificate history:
- On January 5, the host records that Maria's certificate is valid from January 1 through December 31.
- On March 1, the host learns it was actually revoked effective February 1. In one correction
transaction it retracts the original assertion and records the corrected January interval.
The same `validAt` date can produce a different answer as knowledge changes:
| Read basis | Certificate visible? | Why |
| ----------------------------- | -------------------- | -------------------------------------------- |
| recorded Feb 15, valid Feb 15 | Yes | The revocation was not known yet. |
| recorded Mar 2, valid Feb 15 | No | The corrected interval ended on Feb 1. |
| recorded Mar 2, valid Jan 15 | Yes | The corrected assertion still covers Jan 15. |
Public reads accept one `{ recordedAt?, validAt? }` basis for the complete read, including every
clause of a Datalog query. Times are non-negative epoch milliseconds and interval ends are
exclusive. If `recordedAt` is omitted, Triplex uses the latest recorded state; if `validAt` is
omitted, it uses the runtime's current time. Setting only one axis does not freeze the other.
Recorded time is assigned by the Triplex transaction boundary, not supplied as application data.
Use the journal's ordered commit positions when exact ordering matters; timestamps alone may not
distinguish commits in the same millisecond.
## Datalog asks across relationships
A structural query can identify placed workers who lack a certificate for the same site:
```ts
const openTraining = {
find: ["?worker", "?site"],
where: [
["?placement", ":placement/worker", "?worker"],
["?placement", ":placement/site", "?site"],
[
"not",
["?certificate", ":certificate/worker", "?worker"],
["?certificate", ":certificate/site", "?site"],
],
],
} as const;
```
Variables beginning with `?` join clauses. The `not` clause is safe because `?worker` and `?site`
are already bound by the placement clauses. Before the corrected February basis the placement does
not match; afterward it does. See [Datalog](/datalog) for predicates, disjunction, aggregation,
recursion, and snapshot-stable pagination.
## Configuration releases and refs
Triplex configuration is a separate typed graph for schemas, forms, policies, routines, and other
application-defined objects. A commit records the **complete** graph as an immutable release:
- a `ConfigNode` has a stable logical `(kind, key)` and a content ID;
- a revision records a version of one logical object and its dependency closure;
- a `ConfigSnapshot` pins the full release and its revision set; and
- a movable ref such as `test` or `live` points to one existing snapshot.
Promoting or rolling back configuration means moving a ref. It does not copy nodes, mutate an old
release, reverse operational facts, or run a data migration. Operational transactions should store
the actual snapshot ID returned by `commit`/`resolveRef` in `meta.configSnapshot`, rather than only
the movable ref name. Follow the [complete versioning walkthrough](/configuration-versioning).
## Entity snapshots are not configuration snapshots
Both use content identity, but they answer different questions:
| Identity | Represents | Changes when |
| ---------------- | ------------------------------------------------------ | ------------------------------------------------- |
| `EntitySnapshot` | one fact entity materialized at a transaction or basis | that entity's visible facts change |
| `ConfigSnapshot` | one immutable release of a configuration graph | a new complete configuration release is committed |
Neither replaces the transaction journal. Entity snapshots are projections whose source position
must be checked for freshness; configuration snapshots are immutable release roots addressed by
configuration APIs.
## Derived work and provenance
A derivation pins a Datalog query, candidate identity, configuration snapshot, and dependency set.
For the query above, a candidate might mean “Maria needs Harbor training.” Triplex can retain the
source triple IDs and assertion transactions that explain the candidate, compare one evaluation
with another, and report `added`, `removed`, `changed`, and `unchanged` candidates.
The host decides what those changes mean. Triplex does not automatically create or cancel a task,
send a notification, retry a delivery, or assign an owner. Materialized derivations are projections
and report `current`, `stale`, or `unmaterialized`; a stale result is last-known data, not current
truth. The host owns catch-up and timer delivery, including waking at a derivation's next valid-time
boundary.
Exact derivation provenance currently covers patterns, predicates, and negation. Derivations reject
recursive rules, disjunction, aggregation, pagination, dynamic attributes, and transaction-binding
clauses where Triplex cannot preserve a complete explanation. Raw Datalog supports more of those
features when provenance is not requested.
## Constraints and responsibility boundaries
The ontology DSL produces requiredness, cardinality, uniqueness, and reference-target constraints.
They become atomic write guards only when a host passes the rules in `meta.enforce`, normally from
the same snapshot pinned in `meta.configSnapshot`. Observation-only validation remains useful for
migrations and audits. Direct adapter writes and unconstrained commands are outside enforcement.
Triplex owns facts, temporal reads, atomic transactions, the causal journal, configuration identity,
and derivation mechanics. The host still owns authentication, authorization, database/tenant
selection, domain commands, higher-order business invariants, durable workflow lifecycle, external
delivery, retries, migrations, monitoring, retention, backup, and recovery. See [Host
integration](/host-integration) for the operational boundary.
## Where Triplex fits
Triplex is designed for domains where history and rules are part of the answer: compliance,
onboarding, eligibility, entitlements, governed back-office work, and agent-driven systems that
need a durable causal record. It is usually a poor fit for high-volume telemetry, blob storage,
queue-only workloads, or simple state that never needs temporal or provenance questions.
---
# Current state
This document is the concise maturity contract for Triplex as of September 2026. The README
explains how to use the system, [Host integration](/host-integration) defines application
boundaries, [Custom runtimes](/custom-runtimes) defines adapter composition, and the roadmap tracks
work that is not complete.
## Delivered
- One `Triples` service for atomic writes, triple-pattern reads, batched entity reads, history,
raw Datalog, stable query pages, dependency state, and the ordered transaction journal.
- First-class bitemporality. Facts have independent recorded and valid intervals, and direct reads
and Datalog use one shared temporal basis.
- Atomically unique command receipts, causal metadata, typed assertion/retraction changes, commit
positions, compare-and-retract preconditions, indexed snapshot-stable entity timelines, and
durable consumer checkpoints.
- Backend-independent Datalog semantics for patterns, joins, predicates, negation, disjunction,
aggregates, ordering, pagination, and the intentionally constrained binary recursive-rule form.
Query preflight rejects unsupported or ambiguous programs before backend execution.
- Immutable entity snapshots as a projection, distinct from immutable configuration release
snapshots.
- Public `Runtime.define` and `Runtime.fromKv` builders, structured `DatabaseScope` identities,
typed capability providers, deterministic fixtures, and a testkit `runtimeConformance` helper.
Snapshot and emitter providers are explicit requirements; base stores use `Capabilities.none`.
- A browser-safe canonical encoding and domain-separated SHA-256 `ContentId` foundation shared by
entity snapshots, config graphs, derivations, validation observations, and decisions.
- Typed configuration nodes, catalogs, releases, refs, impact queries, reactors, evaluation proofs,
an immutable in-memory reference store, and a transactional Triples-backed `ConfigStore`.
- An ontology DSL that separates global attribute identity/type from usage-local requiredness,
cardinality, uniqueness, and reference-target constraints.
- Queryable validation observations plus opt-in atomic enforcement of required, cardinality,
uniqueness, and reference-target constraints.
- Content-addressed Datalog derivations with explicit candidate identity, source provenance,
immutable materialization runs, freshness positions, temporal wakeups, pure reconciliation, and
read-only hypothetical overlays.
- Standalone demos for basic linked facts and the application-owned compliance/work boundary, plus
a Foldkit/Tailwind browser dashboard that exercises facts, reflected entity-type tables,
config-rendered form previews, raw Datalog, derivations, the journal, and full configuration
object/revision/release history through one app-lifetime Effect layer.
- An Effect v4 agent CLI with schema-decoded commands and stable JSON output for SQLite and
PostgreSQL. It explores entities, bitemporal facts, Datalog, causal history, configuration
revisions/releases/refs/impact, applies attributed idempotent transactions, and moves refs.
- A backend-neutral configuration-derived HTTP package with exact-keyword runtime schemas, REST
CRUD, OpenAPI, immutable version resolution, host authorization, bounded exact-cut pages, and
atomic constraint-enforced writes over both in-memory KV and SQLite.
- A baseline SQL v1 migration, additive v2 numeric range index, host-owned migration entrypoints, Changesets configuration and
release automation, dist-only exports, package tarball checks (including the installed CLI), and
Effect dependencies aligned through the root pnpm catalog.
- Additive Cloudflare v2 snapshot and v3 numeric index migrations, and a Cloudflare runtime implementation
using only public core and SQL exports. Existing facts are preserved; snapshot backfill is
explicit, and pre-upgrade Cloudflare pagination cursors must be restarted.
- PostgreSQL layers for standalone pools, an ambient host-owned `SqlClient`, and validated
database-scoped pools. Ambient composition shares Effect SQL's fiber-local transaction so host
rows, Triplex facts/journal, command claims, commit positions, and host outbox rows commit or roll
back together without an internal second pool.
- The VitePress documentation is published as a Cloudflare assets-only Worker through the
repository's Effect-native Alchemy stack at .
- A private portable host package now defines immutable tenant database identity, authorization,
CAS lifecycle contracts, route fencing, and a schema-decoded v1 data protocol. The Cloudflare
package exposes a complete `CloudflareTriples` layer using one Durable Object SQLite handle for
storage and Datalog, and passes the shared backend corpus both in its native-SQL harness and in
workerd against a real SQLite-backed Durable Object.
- A private Cloudflare reference host wires an authenticated gateway, a durable CAS registry, one
SQLite Durable Object per database, idempotent provisioning, suspension/resume/deletion fencing,
request budgets, and a separately state-named Alchemy v2 stack with retention protection. Its
default workerd test exercises two-tenant isolation, restart persistence, lifecycle fencing, and
generation-safe recreation.
## Backend maturity
| Backend | Bitemporal `Triples` | Shared conformance | Default CI | Intended use today |
| ------------- | -------------------- | ------------------ | ---------- | ------------------------------ |
| In-memory KV | Yes | Yes | Yes | Tests, browser, ephemeral data |
| SQLite | Yes | Yes | Yes | Supported local persistence |
| PostgreSQL | Yes | Yes | Yes | Pre-1.0 production candidate |
| Cloudflare DO | Yes | Harness + workerd | Yes | Experimental reference hosting |
| FoundationDB | Yes | Native opt-in | No | Experimental |
PostgreSQL includes validated logical database IDs, deterministic safely quoted schema names, and
per-pool-connection schema binding. Its host-owned-client and database-scoped paths are covered by
Docker integration tests in CI, including rollback and concurrent multi-connection isolation. It
remains a pre-1.0 production candidate until operational use establishes its backup, migration,
observability, and scale characteristics.
Cloudflare now has the complete one-line `Triples` composition and passes the shared corpus against
both a fast Durable Object API-compatible native SQLite harness and Cloudflare's workerd runtime.
The reference host also passes its local workerd isolation and restart corpus. It remains
experimental until credentialed deployment, upgrade, retention, limit, and provider-failure gates
pass. The host and Alchemy stack have not been promoted or published. FoundationDB requires a
non-empty subspace by default and scopes clears to it, but still depends on native infrastructure
outside the normal test matrix.
## Honest limitations
- Seven public packages are published under the `@triplex-build` organization scope. The GitHub
repository is `bjacobso/triplex`.
- The superseded `@bjacobso` bootstrap packages are not the installation path for new consumers.
- `SubscriptionManager` discovers dependencies and reports possible invalidations. It does not
push result deltas or automatically re-run queries.
- Entity snapshots, validation results, and derivation materializations are projections. Callers
must inspect their source position and freshness; projection failure cannot roll back an already
committed source transaction.
- Public runtime composition does not include automatic durable KV snapshot persistence. The
shared Triples conformance suite does not certify capability behavior, crash durability,
cross-process isolation, or migration safety.
- Exact derivation provenance currently supports patterns, predicates, and negation. Recursive
rules, disjunction, aggregation, pagination, dynamic attributes, and transaction-binding clauses
are rejected where a complete explanation cannot be preserved.
- Config decision verification proves internal content integrity given a trusted decision root. It
does not independently prove that an external actor chose the right rule or answer.
- Constraint enforcement is opt-in and serialized through the commit-position boundary. Direct
adapter writes, authorization, general Datalog invariants, inbox/outbox delivery, timers, retries,
and application workflow lifecycle are host responsibilities.
- The HTTP API intentionally omits PATCH, filters, relationship expansion, raw Datalog, writable
historical schemas, automatic migrations, empty entities, and mutation of scheduled facts.
- The schema is intentionally greenfield. Databases created by pre-baseline development builds
must be recreated or migrated by application-owned tooling.
- The Cloudflare host is a reference implementation, not a production support claim. Backup and
restore, provider limit measurements, staged migration/rollout drills, credentialed Alchemy
reconciliation, and lost-acknowledgement/node-failure exercises remain explicit gates.
## Release follow-up
1. Configure npm trusted publishing for all seven packages and verify a tokenless `next` publish.
2. Verify the published packages in a clean registry-only consumer, including peer dependencies,
provenance, CLI, and exports.
3. Deprecate the superseded `@bjacobso` canaries with migration guidance.
Cloudflare and FoundationDB are private for the first release. Their source stays in the monorepo
and continues to compile, but Changesets cannot publish them accidentally.
The six pre-stable `@bjacobso` canary packages must be deprecated after the corresponding
`@triplex-build` stable packages are available. The old artifacts remain available so existing
canary installs receive a migration message instead of breaking.
---
# Datalog
Triplex exposes bounded Datalog pages through `Triples.query` and a wrapped form through
`Triples.queryPage`. Both default to 100 bindings and allow at most 1,000 per page. The in-memory KV and SQL engines share one schema, semantic preflight, result
identity, ordering contract, and bitemporal basis.
If facts, relationships, or the two time axes are new, read [Core concepts](/concepts) first. A
Datalog clause is a pattern over `(entity, attribute, value)`. Reusing a `?variable` joins clauses;
the `find` list selects the bindings returned to the caller. For example, joining a worker's
placement site to a certificate site answers whether the same relationship exists—not merely
whether either entity has some site fact.
## Query shape
```ts
const program = Effect.gen(function* () {
const triples = yield* Triples;
const { results } = yield* triples.query({
find: ["?name", "?age"],
where: [
["?person", ":person/name", "?name"],
["?person", ":person/age", "?age"],
[">=", "?age", 30],
],
});
return results;
});
```
`results` is an array of binding objects whose keys retain the `?` prefix. A three-element pattern
is `[entity, attribute, value]`. An optional fourth element binds the assertion transaction:
```ts
const program = Effect.gen(function* () {
const triples = yield* Triples;
const { results } = yield* triples.query({
find: ["?name", "?actor"],
where: [
["?person", ":person/name", "?name", "?tx"],
["?tx", ":_tx/actor", "?actor"],
],
});
return results;
});
```
The `?` prefix is reserved for variables. Entity, attribute, transaction, and rule identity
positions accept string literals or variables. Malformed runtime input and unsupported programs
fail with typed `DatalogValidationError` or `UnboundVariableError` before either backend runs.
## Values and equality
Stored facts retain seven value types: string, number, boolean, datetime, ref, JSON, and blob.
Datalog projects their public scalar values:
- numbers and datetimes project as numbers;
- strings and refs project as strings;
- booleans remain booleans;
- JSON projects as canonical JSON text; and
- blobs project their content identity.
A bare scalar pattern constant uses public scalar equality. Use a typed constant when storage type
matters:
```ts
import { EntityId, ref } from "@triplex-build/triplex";
const query = {
find: ["?movie"],
where: [["?movie", ":movie/director", ref(EntityId.make("person:nolan"))]],
};
```
Numeric-looking strings and identity values are never guessed into numbers. Equality preserves the
same public scalar families through patterns, joins, predicates, negation, disjunction, grouping,
distinctness, and pagination.
## One temporal basis
Pass a basis as query options:
```ts
const historicalQuery = Effect.gen(function* () {
const answer = yield* triples.query(query, {
basis: {
recordedAt: auditInstant,
validAt: businessInstant,
},
});
return answer;
});
```
Omitted recorded time means the latest recorded state. Omitted valid time means the runtime's
current business time. Every pattern, join, negation, and rule in the query sees the same cut.
## Predicates and clause order
Comparison clauses use `>`, `>=`, `<`, `<=`, `=`, and `!=`:
```ts
where: [
["?person", ":person/age", "?age"],
[">=", "?age", 30],
];
```
Ordered predicates are numeric-only; number and datetime facts share that family. Text and
identity values use equality or inequality. Incompatible equality is false and incompatible
inequality is true rather than depending on backend casts.
Conjunction is declarative. Positive patterns establish outer bindings before predicates,
negation, and disjunction regardless of their written order. Patterns inside a conjunctive `not`
establish its local bindings before local predicates run.
## Negation and disjunction
```ts
const activePeople = {
find: ["?person"],
where: [
["?person", ":person/name", "?name"],
["not", ["?person", ":person/status", "inactive"]],
],
};
const namedAliceOrBob = {
find: ["?person"],
where: [
[
"or",
[
["?person", ":person/name", "Alice"],
["?person", ":person/name", "Bob"],
],
],
],
};
```
Unsafe negation, empty disjunctions, and unbound projections fail during shared preflight.
## Aggregation and ordering
Aggregate clauses are `[operation, source, target]`. Supported operations are `count`, `sum`,
`avg`, `min`, and `max`:
```ts
const countTasks = Effect.gen(function* () {
const result = yield* triples.query({
find: ["?status", "?count"],
where: [
["?task", ":task/status", "?status"],
["?task", ":task/owner", "?owner"],
],
aggregate: [["count", "?task", "?count"]],
having: [[">", "?count", 1]],
orderBy: [{ variable: "?count", direction: "desc" }],
limit: 20,
});
return result;
});
```
Grouping is implicit over non-aggregate projected variables. `count` counts distinct public source
values. The other numeric aggregates retain duplicate input rows. Ungrouped empty input returns
one row with count zero and the other aggregates `null`; grouped empty input returns no rows.
Aggregate and optional-projection targets must appear in `find`. `having` and `orderBy` may refer
only to result bindings. Invalid target collisions and nonnumeric aggregate comparisons are typed
preflight failures.
## Snapshot-stable pagination
Ordinary queries return a cursor too:
```ts
const query = {
find: ["?person", "?name"],
where: [["?person", ":person/name", "?name"]],
} as const;
const pages = Effect.gen(function* () {
const first = yield* triples.query(query, { pageSize: 50 });
const second = first.nextCursor
? yield* triples.query(query, { pageSize: 50, cursor: first.nextCursor })
: undefined;
return { first, second };
});
```
Use the same query and page size for continuation. Omitted temporal fields inherit the original
page's basis. `query.limit` and `query.offset` define the total logical subset; `options.pageSize`
pages that subset without changing its membership. `queryPage` exposes separate wrapper filters,
ordering, page size, and optional total count:
```ts
const request = {
inner: {
find: ["?person", "?name"],
where: [["?person", ":person/name", "?name"]],
},
orderBy: [{ variable: "?name", direction: "asc" }],
limit: 50,
} as const;
const pages = Effect.gen(function* () {
const first = yield* triples.queryPage(request);
const second = first.nextCursor
? yield* triples.queryPage({ ...request, cursor: first.nextCursor })
: undefined;
return { first, second };
});
```
Triplex completes the requested order with deterministic tie-breakers and pins the first page's
exact recorded commit position. Later assertions and retractions cannot move rows between pages.
The opaque cursor is versioned and bound to the canonical query, wrapper filters, complete order,
temporal basis, and database scope. Malformed or cross-query/scope reuse fails with
`PaginationCursorError`.
The total order is numbers/datetimes, booleans, text-family values, then null. Direction applies
within a family. Applications should treat cursors as short-lived capabilities and never decode or
edit them.
Queries with joins, negation, disjunction, optional projections, aggregation, and recursive rules
all support this pagination contract. Constant-only projections form at most one distinct row and
return a terminal page. `includeCount` defaults to false because it evaluates an additional full
count query.
### Migrating complete reads
`Triples.query` now returns the first page instead of an unbounded array. Callers must follow
`nextCursor` to process further pages. Trusted batch operations that explicitly require complete
results can use `triples.queryAll(query, options)`, which retains the previous raw-query semantics.
Core configuration discovery and derivation evaluation use that explicit API to preserve complete
results. `Triples.queryPage` without a limit is now bounded too.
Pagination bounds result transfer, not necessarily database work. See the
[Datalog performance investigation](datalog-performance.md) for measured SQLite/PostgreSQL plans,
timings, and the remaining low-level fact-read audit.
## Recursive rules
Triplex supports a deliberately bounded binary recursive form:
```ts
const ancestors = Effect.gen(function* () {
const { results } = yield* triples.query({
find: ["?ancestor"],
where: [["ancestor", "person:alice", "?ancestor"]],
rules: [
{ name: "ancestor", body: [["?x", ":parent", "?y"]] },
{
name: "ancestor",
body: [
["?x", ":parent", "?z"],
["ancestor", "?z", "?y"],
],
},
],
});
return results;
});
```
Same-named definitions union. SQL compiles them to recursive CTEs; KV evaluates them to a
deduplicated fixpoint. Rule endpoints are string identities, repeated variables unify as equality
constraints, and `maxDepth` is a positive safe integer. Unsupported recursive bodies fail during
preflight rather than receiving different interpretations from different backends.
## Dependency discovery
The subscriptions and derivation modules statically discover fixed attributes from Datalog.
`SubscriptionManager.checkAffected` reports queries that may need recomputation; it is not an
automatic live-query runtime. `Triples.dependencyState` returns the latest assertion/retraction
position and earliest recorded future valid-time edge for a fixed attribute set.
Exact derivation provenance has a narrower contract than the query engine. It currently accepts
patterns, predicates, and negation, and rejects rules, disjunction, aggregation, pagination,
dynamic attributes, and transaction-binding clauses when it cannot preserve a complete source
explanation. See [Derivations](derivations.md).
---
# Configuration releases and rollback
This walkthrough defines a typed course schema, publishes two immutable releases, promotes them
through refs, pins operational writes to the actual returned snapshot IDs, inspects history, and
rolls `live` back. It uses the in-memory layer so the whole program is executable from a source
checkout.
For the underlying node, revision, compatibility, validation, and enforcement contracts, see the
[configuration reference](/configuration).
## Complete runnable example
From the repository root, run:
```sh
pnpm exec tsx --tsconfig docs/snippets/tsconfig.json docs/snippets/config-versioning.ts
```
```ts
import { Effect, Layer } from "effect";
import { EntityId, KvTriples, Triples } from "@triplex-build/triplex";
import {
Attribute,
ConfigStore,
EntityType,
GraphConstraint,
InMemoryConfigStore,
} from "@triplex-build/triplex/config";
const CourseTitle = Attribute.text(":course/title");
const CourseStatus = Attribute.enumOf(":course/status", ["draft", "published"]);
const CourseV1 = EntityType.make("Course", {
attributes: {
title: Attribute.use(CourseTitle, { required: true }),
},
});
const CourseV2 = EntityType.make("Course", {
attributes: {
title: Attribute.use(CourseTitle, { required: true }),
status: Attribute.use(CourseStatus, { required: true }),
},
});
const program = Effect.gen(function* () {
const config = yield* ConfigStore.ConfigStore;
const triples = yield* Triples;
// Publish v1 to test, then promote that exact returned snapshot to live.
const v1 = yield* config.commit({
label: "courses-2026.1",
objects: yield* CourseV1.nodes,
ref: "test",
});
yield* config.setRef("live", v1.snapshot.id);
yield* triples.transact(
[
{
op: "assert",
entityId: EntityId.make("course:intro"),
entityType: CourseV1.entityType,
...CourseV1.title.assertion("Introduction to temporal data"),
},
],
{
actor: "user:publisher",
commandId: "courses/create-intro/v1",
configSnapshot: v1.snapshot.id,
enforce: GraphConstraint.enforcement(CourseV1.constraints),
},
);
// Publish a complete v2 graph without changing live, inspect it, then promote it.
const v2 = yield* config.commit({
label: "courses-2026.2",
objects: yield* CourseV2.nodes,
ref: "test",
});
const testBeforePromotion = yield* config.resolveRef("test");
yield* config.setRef("live", v2.snapshot.id);
yield* triples.transact(
[
{
op: "assert",
entityId: EntityId.make("course:advanced"),
entityType: CourseV2.entityType,
...CourseV2.title.assertion("Advanced temporal data"),
},
{
op: "assert",
entityId: EntityId.make("course:advanced"),
entityType: CourseV2.entityType,
...CourseV2.status.assertion("published"),
},
],
{
actor: "user:publisher",
commandId: "courses/create-advanced/v1",
configSnapshot: v2.snapshot.id,
enforce: GraphConstraint.enforcement(CourseV2.constraints),
},
);
const state = yield* config.load();
const changes = InMemoryConfigStore.changesBetween(state, v1.snapshot, v2.snapshot);
const historicalV2 = yield* config.snapshotById(v2.snapshot.id);
const v2Receipt = yield* triples.transactionByCommand("courses/create-advanced/v1");
// Roll back configuration by moving live. Operational facts and their pins remain.
yield* config.setRef("live", v1.snapshot.id);
const liveAfterRollback = yield* config.resolveRef("live");
const advancedCourse = yield* triples.entity(EntityId.make("course:advanced"));
return {
v1Snapshot: v1.snapshot.id,
v2Snapshot: v2.snapshot.id,
testBeforePromotion: testBeforePromotion?.label,
changedObjects: changes.map((change) => `${change.kind}:${change.key}`),
historicalV2: historicalV2?.label,
v2WritePin: v2Receipt?.configSnapshot,
liveAfterRollback: liveAfterRollback?.label,
advancedFactsAfterRollback: advancedCourse.length,
};
});
const AppLayer = ConfigStore.layer.pipe(Layer.provideMerge(KvTriples.layer));
const result = await Effect.runPromise(program.pipe(Effect.provide(AppLayer)));
console.log(JSON.stringify(result, null, 2));
```
The two snapshot values in the output are real IDs returned by `ConfigStore.commit`; never copy a
made-up `sha256-…` placeholder into transaction metadata. The exact hashes are deterministic for
this history, but application code should treat them as opaque.
The stable outcomes are:
- `testBeforePromotion` and `historicalV2` are `courses-2026.2`;
- the change list contains the added `:course/status` attribute and changed `Course` schema;
- `v2WritePin` equals the returned v2 snapshot ID;
- after rollback, `liveAfterRollback` is `courses-2026.1`; and
- `advancedFactsAfterRollback` is still `2`.
## 1. Define typed schema objects
`Attribute` owns a global keyword and value type. `EntityType` owns how that attribute is used by
one entity type. Adding `status` as required changes the `Course` entity schema in v2 without
changing the identity or definition of `:course/title`.
`CourseV1.nodes` and `CourseV2.nodes` each evaluate to the complete configuration nodes needed by
that schema, including generated graph-constraint nodes nested under the entity schema. A commit
takes a complete object set, not a patch against the previous release.
## 2. Publish without promoting
Passing `ref: "test"` commits the release and moves `test` in the same atomic Triplex transaction.
The example does not point `live` at arbitrary authoring state: it calls `setRef("live",
v1.snapshot.id)` with the exact ID returned by the successful commit.
The v2 commit moves only `test`, so `live` remains on v1 during inspection. `resolveRef("test")`
returns the immutable snapshot behind the current pointer. A deployment tool can inspect that
snapshot and its changes before promotion.
## 3. Promote and pin writes
Promotion is another `setRef` call. Moving a ref copies no configuration, and compare-and-retract
prevents a stale concurrent writer from silently replacing a newer ref target.
The operational transactions use:
```ts
{
configSnapshot: v2.snapshot.id,
enforce: GraphConstraint.enforcement(CourseV2.constraints)
}
```
The snapshot pin records which rules governed the command. `enforce` is separate and opt-in: a
snapshot ID in metadata does not itself turn constraints on. In a long-running host, resolve the
intended ref once at the command boundary, collect constraints from that immutable snapshot when
the TypeScript handles are not already present, and use that same snapshot and rule set throughout
the transaction.
## 4. Inspect an older release
`snapshotById(id)` resolves an immutable historical release whether or not a ref still points to
it. `load()` exposes the reference store model, and
`InMemoryConfigStore.changesBetween(state, from, to)` reports added, removed, and changed logical
objects. The detailed reference also exposes node-level Merkle diffs, revision history, reverse
dependencies, and impact candidates.
The CLI and dashboard provide operator views over the same records. See [CLI and
dashboard](/tools#inspect-configuration) for commands that list releases, resolve a ref, inspect one
object's immutable history, and move a ref.
## 5. Understand rollback
The final `setRef("live", v1.snapshot.id)` is a configuration rollback. It changes what future
code resolving `live` sees. It does **not**:
- retract or rewrite facts written while v2 was live;
- change the `configSnapshot` stored on earlier transaction receipts;
- delete v2, its object revisions, or its dependency graph;
- transform data so it conforms to v1; or
- reverse external work already performed by the host.
That is why the v2 course still has two operational facts after rollback and its receipt still
points to v2. Data migration, compensating commands, validation, and external side-effect recovery
are separate host-owned operations. Plan them explicitly when a configuration change alters what
old or new data means.
## Durable version
For persistence, compose `ConfigStore.layer` over one shared SQLite or PostgreSQL `Triples` layer.
Do not create separate database layers for operational facts and configuration if they must share
an atomic boundary:
```ts
const AppLayer = ConfigStore.layer.pipe(
Layer.provideMerge(SqliteTriples.layer({ filename: "./triplex.db" })),
);
```
This is a focused composition fragment; imports and the complete in-memory program appear above.
See [Getting started](/getting-started#use-durable-sqlite) and [Host
integration](/host-integration) for runtime and migration choices.
---
# Derivations
`@triplex-build/triplex/derivation` turns structural Datalog results into content-addressed candidates
with explicit identity, provenance, temporal basis, and configuration identity. It is a derived-fact
engine, not a workflow engine: applications decide whether a candidate opens a task, updates a
projection, triggers an integration, or has no operational consequence.
For an accessible example of a placement producing safety-training work, start with [Derived work
and provenance](/concepts#derived-work-and-provenance). This page defines the detailed evaluation,
materialization, reconciliation, and overlay contracts.
## Definitions and candidates
```ts
import * as Derivation from "@triplex-build/triplex/derivation";
const program = Effect.gen(function* () {
const openTraining = yield* Derivation.make({
name: "task.site-training",
configSnapshot: deployedSnapshot.id,
identity: ["?worker", "?site"],
query: {
find: ["?worker", "?site"],
where: [
["?placement", ":placement/worker", "?worker"],
["?placement", ":placement/site", "?site"],
["not", ["?worker", ":training/site", "?site"]],
],
},
});
const evaluation = yield* Derivation.evaluate(triples, openTraining, {
basis: { validAt: Date.now() },
});
return { openTraining, evaluation };
});
```
A definition content-binds its name, complete query, optional result `TypeExpr`, declared identity
projection, discovered dependencies, and config snapshot. Results sharing the declared identity
become one candidate; source provenance from every positive graph path is merged.
Candidate identity is stable across definition revisions. Candidate revision changes when the
definition, config pin, result, or explanation changes. Conflicting results for one identity fail
with a typed error rather than being selected arbitrarily.
Each positive source records its triple ID, assertion transaction ID, and transaction position.
The candidate also retains the pinned bitemporal basis and earliest future `validTo` among its
supporting facts when known.
## Provenance contract
The initial exact-provenance language supports patterns, predicates, and negation. Recursive rules,
disjunction, aggregation, pagination, dynamic attributes, and transaction-binding clauses are
rejected when the engine cannot provide a complete and stable source explanation.
Negation explains that the candidate depended on absence but naturally has no positive source fact
for the missing evidence. `nextTemporalBoundary` compensates for temporal negation by considering
recorded future edges across all fixed dependency attributes, including evidence that currently
suppresses every candidate.
## Reconciliation
`Derivation.reconcile(previous, next)` is a pure diff over stable candidate identity. It returns:
- `added` — a logical candidate appeared;
- `removed` — it no longer applies;
- `changed` — it remains but its revision changed; and
- `unchanged` — both identity and revision are unchanged.
The host translates that diff into domain work. A compliance host might create a durable
requirement occurrence for `added`, satisfy or cancel one for `removed`, revise its explanation for
`changed`, and perform no write for `unchanged`. Triplex deliberately does not define those lifecycle
semantics.
## Materialization
```ts
const inspectMaterialization = Effect.gen(function* () {
const run = yield* Derivation.Materialization.materialize(triples, openTraining, {
basis: { validAt: now },
});
const state = yield* Derivation.Materialization.current(triples, openTraining, {
basis: { validAt: now },
});
return { run, state };
});
```
Materialization persists immutable candidate revisions and a complete evaluation run as reserved
Triplex entities. A run atomically binds:
- definition and configuration IDs;
- the bitemporal basis;
- candidate membership;
- the latest dependency-relevant source position; and
- the next temporal boundary, including for an empty candidate set.
`current` returns `current`, `stale`, or `unmaterialized`. Stale state retains the last durable
candidates and source position. An unrelated transaction does not make a fixed-dependency run stale.
Definition changes, relevant writes, and a different basis do.
There is no mutable first-writer head race. Runs are ordered by dependency source position and then
their materialization commit position. Stored candidate bodies are schema-decoded and their content
IDs are verified on read. `Materialization.runsQuery` exposes immutable historical membership as
ordinary Datalog.
## Dependency state and temporal wakeups
`Triples.dependencyState(attributes, basis)` reads backend indexes rather than replaying the journal.
It returns the latest assertion-or-retraction commit position and the earliest recorded future
`validFrom` or `validTo` for the fixed attribute set.
Every evaluation and materialized run exposes the same conservative `nextTemporalBoundary`. A host
scheduler should wake and rematerialize at that instant:
```ts
if (run.nextTemporalBoundary !== undefined) {
scheduler.wakeAt(run.nextTemporalBoundary, openTraining.id);
}
```
The schedule is attribute-conservative: an unrelated entity sharing a dependency attribute may
cause an extra wakeup, but recorded future-effective or expiring evidence is not omitted. Dynamic
attribute definitions use an explicit journal fallback and should not be hot-path materializers.
Triplex owns boundary discovery, not timer delivery or retry policy.
## Hypothetical overlays
```ts
const previewChange = Effect.gen(function* () {
const preview = yield* Derivation.Overlay.evaluateOverlay(triples, openTraining, {
basis: { validAt: now },
overlay: {
assertions: [proposedPlacement, proposedTraining],
retractions: [supersededFactId],
},
});
return preview;
});
```
Overlay evaluation copies only the definition's fixed dependency attributes at the requested basis
into a private in-memory KV index, applies temporary assertions and visible-fact retractions, and
runs the same structural evaluator. It never mutates the source facts or transaction journal.
Base sources retain their durable IDs. Temporary sources receive deterministic hypothetical content
commitments and are marked `hypothetical: true`, making collect-versus-reuse previews comparable to
committed evaluation without claiming they happened.
An assertion without `validFrom` begins at the overlay's `validAt`. Retractions must identify a fact
visible at that basis. Duplicate, missing, irrelevant, dynamic-attribute, or transaction-binding
operations fail explicitly rather than producing an incomplete preview.
## Host boundary
Triplex provides fact history, Datalog, config identity, candidate provenance, freshness, temporal
wakeups, and pure diffs. The host remains responsible for:
- durable workflow or requirement occurrences;
- assignments, conversations, evidence disposition, and authorization;
- command naming and response caching;
- timer delivery, integrations, retries, inboxes, and outboxes; and
- interpreting candidate addition or removal as product behavior.
The standalone [compliance host demo](https://github.com/bjacobso/triplex/tree/main/examples/compliance-host) composes those responsibilities
end to end over the in-memory backend.
---
# Source provenance
Triplex began as the database subsystem of an earlier private application repository.
The initial import was filtered from source commit
`473753746f7c8f40c059edf81cca47a8e04202ef` and retained the history affecting:
- `packages/database`
- `test/stress`
The filtered history is merged into this repository, preserving commit authorship and messages.
After import, the packages were renamed and flattened into the standalone package family. The
source application is not a dependency and is not automatically synchronized with this repository.
Typed configuration began in the adjacent MIT-licensed `config-graph` repository. Its complete
reachable history through source commit `93de8e3` was merged without squashing, first under the
temporary `.config-graph-import` prefix and then reshaped into Triplex in a separate commit. The
source repository was not modified and remains an independent historical repository.
---
# Typed configuration
Triplex configuration is immutable, typed, and content-addressed. It is separate from operational
facts even when persisted through the same `Triples` service:
> **Looking for the release workflow?**
Start with the [complete publish, promote, pin, inspect, and rollback
walkthrough](/configuration-versioning). This page is the detailed configuration contract.
- an `EntitySnapshot` materializes one fact entity at a transaction or time;
- a `ConfigSnapshot` is an immutable release root containing revisions, schema stamps, dependency
closures, and refs.
Configuration is exported from `@triplex-build/triplex/config` rather than flattened into the package
root.
## Content model
`TypeExpr` describes runtime values. `ConfigNode` attaches a stable logical `(kind, key)` identity,
typed attributes, and references to that value. Canonical encoding and domain-separated SHA-256
produce explicit `ContentId` values; equal nodes deduplicate and unchanged revisions are shared
between releases.
`InMemoryConfigStore` is the immutable semantic reference implementation. `ConfigStore.layer` is
the Effect-native store that persists config objects, revisions, snapshots, and refs as reserved
Triplex system facts. A release commit and optional ref move use one atomic Triples transaction.
```ts
import { Layer } from "effect";
import { KvTriples } from "@triplex-build/triplex";
import { ConfigStore } from "@triplex-build/triplex/config";
const AppLayer = ConfigStore.layer.pipe(Layer.provideMerge(KvTriples.layer));
```
## Ontology DSL
The DSL separates three identities:
- entity type: PascalCase, such as `Employer`;
- global attribute: lowercase namespaced keyword, such as `:employer/name`;
- TypeScript property: an ergonomic local alias, such as `name`.
```ts
import { EntityId } from "@triplex-build/triplex";
import { Attribute, EntityType } from "@triplex-build/triplex/config";
export const EmployerName = Attribute.text(":employer/name");
export const Employer = EntityType.make("Employer", {
attributes: {
name: Attribute.use(EmployerName, {
required: true,
unique: true,
}),
},
});
export const EmployerSummary = EntityType.make("EmployerSummary", {
attributes: {
name: Attribute.use(EmployerName, { required: false }),
},
});
export const EmploymentEmployer = Attribute.ref(":employment/employer", Employer);
export const Employment = EntityType.make("Employment", {
attributes: {
employer: Attribute.use(EmploymentEmployer, { required: true }),
},
});
const validFrom = Date.now();
Employer.attributes.name.key; // ":employer/name"
Employer.attributes.name.assertion("Acme", { validFrom });
Employment.attributes.employer.assertion(EntityId.make("employer:acme"), { validFrom });
```
Requiredness is not inherently true of `:employer/name`; it is true of the way `Employer` uses the
attribute. Reference attributes follow the same separation.
`Employer.nodes` yields the independently identified attribute definitions followed by the entity
schema. `Employer.constraints` yields independently content-addressed required, cardinality,
uniqueness, and reference-target rules. Handles remain convenient at the application boundary:
## Releases and refs
Commit application-defined node kinds together. Triplex does not prescribe what a form, policy,
routine, permission, integration, or view means; it preserves their typed graph and identity.
```ts
const publish = Effect.gen(function* () {
const config = yield* ConfigStore.ConfigStore;
const release = yield* config.commit({
label: "2026.1",
objects: [...(yield* Employer.nodes), formNode, policyNode, routineNode],
ref: "live",
});
yield* config.setRef("test", release.snapshot.id);
return release;
});
```
Moving a ref copies no configuration. Ref movement uses compare-and-retract so a stale writer
cannot silently overwrite a newer pointer. The store rejects dangling refs and duplicate logical
objects, preserves `validUnder` schema compatibility, and uses Datalog for reverse dependency and
deploy-impact candidate discovery. Merkle hashing and proof verification remain content operations,
not database queries.
Operational transactions can pin the exact release that governed them:
```ts
const write = Effect.gen(function* () {
yield* triples.transact(operations, {
actor: "agent:worker-7",
commandId: "command:123",
configSnapshot: release.snapshot.id,
});
});
```
## Runtime evaluation and decision proofs
`ConfigRuntime` resolves a ref, derives the rule catalog from that immutable snapshot, reads only
the current or historical Triple facts statically reachable by the rule, and evaluates against the
pinned release:
```ts
const decide = Effect.gen(function* () {
const decision = yield* ConfigRuntime.evaluate({
ref: "live",
rule: "may-deploy",
subject: "employee:alice",
clock: { now: Date.now(), granularity: "day" },
});
return ConfigRuntime.verify(decision);
});
```
The decision ID binds the release root, subject, reason, and nested evaluation. Changing the config
pin or proof breaks verification. This proves internal content integrity given a trusted decision
root; it does not independently prove that an external actor chose the right rule, authorized the
decision, or supplied a correct real-world answer.
Passing `asOf` evaluates the same deployed rule against historical facts. A rule read that observes
multiple live values fails rather than arbitrarily choosing one, and nonscalar JSON is rejected at
the storage-to-proof bridge.
## Validation observations
`EntityValidation` deploys runtime entity schemas and explicitly validates ordinary facts against a
config ref:
```ts
const inspectValidation = Effect.gen(function* () {
const validation = yield* EntityValidation.EntityValidation;
const run = yield* validation.revalidate({ ref: "live" });
const state = yield* validation.currentInvalid("live");
const everInvalid = yield* validation.everInvalid();
const violations = yield* validation.violations({ subject: "employee:alice" });
return { run, state, everInvalid, violations };
});
```
Results and individual violations are immutable, content-addressed reserved facts. They bind the
exact config snapshot, schema, subject, and a content ID for the materialized entity state without
duplicating the entity body. Fixing an entity moves its current validation head but does not erase
historical invalidity or messages.
The projection records the latest nonvalidation source position it observed.
`currentInvalid("live")` returns `current`, `stale`, or `unvalidated`; stale results retain their
last known errors rather than becoming an empty answer. Query builders such as
`currentInvalidQuery`, `everInvalidQuery`, and `violationsQuery` produce ordinary Datalog that can
be composed with application queries.
## Atomic graph constraints
The same rules can guard an operational transaction:
```ts
const constrainedWrite = Effect.gen(function* () {
const snapshot = release.snapshot;
const constraints = yield* GraphConstraint.collect(snapshot.root);
yield* triples.transact(operations, {
actor: "agent:worker-7",
configSnapshot: snapshot.id,
enforce: GraphConstraint.enforcement(constraints),
});
});
```
Enforcement projects the complete post-state and checks every represented valid-time boundary,
including future-effective intervals. A new or worsened required, cardinality, uniqueness, or
reference-target violation fails with `ConstraintViolationError`; facts, journal, command receipt,
and commit position all roll back. Existing violations do not block unrelated writes or repairs.
KV, SQLite, and PostgreSQL serialize this check through the commit-position boundary, preventing
concurrent Triplex writers from both winning the same absence or uniqueness decision. Candidate
loading uses entity-type and reference-target indexes plus batched subject reads. Direct adapter
writes, unconstrained commands, authorization, and general Datalog invariants remain outside this
guarantee.
Validation observations remain useful for migrations and audit even when enforcement is enabled.
Applications normally resolve one pinned release, collect its rules, and pass both its
`configSnapshot` and enforcement set at the command boundary.
## Explore configuration
The repository dashboard exposes typed nodes, releases, refs, immutable object history, and impact
analysis over its demo database or a supplied SQLite/PostgreSQL database. The CLI provides the same
operator-oriented history and ref controls as stable JSON. See [CLI and
dashboard](/tools#inspect-configuration).
Continue to [Derivations](/derivations) to use a pinned configuration snapshot in explainable
derived work, or [Troubleshooting](/troubleshooting#does-moving-a-config-ref-roll-back-data) for
common release and rollback surprises.
---
# Operational Primitives
Triplex is a durable substrate for applications that need temporal facts, derivations, and audit.
This specification separates database/runtime guarantees that belong in Triplex from workflow and
product concepts that belong in a host application.
Read [Core concepts](/concepts) for the beginner-oriented model and [Host
integration](/host-integration) for application composition. This page is the low-level operational
contract.
## Implemented foundation
### Atomic transactions
`Triples.transact` is the authoritative portable command boundary. `assert`, `assertBatch`,
`retract`, and `retractByPattern` delegate to it, so every supported write receives one commit
position and causal envelope. SQL adapters execute it in a native SQL transaction. KV
implementations create a transaction-scoped hexastore over `KvBackend.transact`; all index and
metadata writes commit or roll back together. The in-memory backend serializes transactions and
buffers their writes. A completed KV transaction clears the shared decoded-datom cache so reads
observe its result.
### Compare-and-retract
A transaction may declare `TripleLive` preconditions. Each condition must identify an explicit
`retract` operation in the same transaction. Retraction is the atomic comparison: exactly one
writer can retract the expected live fact. A stale writer receives `TransactionConflictError` and
all of its writes roll back.
This intentionally small primitive covers moving pointers and state machines without pretending
that a read followed by an arbitrary write is compare-and-set. Config refs and entity-validation
heads use it. There is no generic public "assert when no value exists" precondition. The narrower
graph-constraint enforcement path does atomically protect declared required, cardinality,
uniqueness, and reference-target invariants by evaluating them inside the serialized commit
boundary.
### Causal transaction envelopes
Every successful `transact` persists an `_Transaction` entity with:
- `:_tx/position`;
- `:_tx/instant`;
- `:_tx/actor`;
- `:_tx/command-id`;
- `:_tx/correlation-id`;
- `:_tx/causation-id`;
- `:_tx/config-snapshot`; and
- one indexed `:_tx/changed-entity` reference per changed application entity; and
- one `:_tx/change` JSON fact for every asserted or retracted application fact.
`Triples.transaction(txId)` reconstructs the typed envelope. The same facts remain available to
Datalog for application-specific audit queries. A command ID is an atomically unique idempotency
identity within one Triplex database. Concurrent attempts acquire a backend-local claim inside the
same transaction as the facts and envelope; exactly one commits. A loser receives
`CommandAlreadyCommittedError` with the original transaction ID, and
`Triples.transactionByCommand(commandId)` loads that durable receipt.
`Triples.transactionsForEntity(entityId, request)` uses the changed-entity references to read a
bounded newest-first audit page without replaying or decoding the global journal. It deduplicates
transactions that changed several facts on the entity, then hydrates their authoritative complete
envelopes. The first page returns a `snapshotPosition`; continuation requests combine it with the
exclusive `nextBeforePosition`, so concurrent later commits do not enter an in-progress timeline.
Invalid limits and positions fail with `ReadError`, and an unknown entity returns an empty page.
### Ordered transaction feed
The backend allocates a monotonically increasing commit position inside the same atomic boundary
as the application facts and causal envelope. `Triples.transactions({ after, limit })` returns
envelopes in that order and exposes the last position as the next durable resume cursor. Failed
transactions publish neither facts nor a position. This avoids treating timestamps or
client-generated ULIDs as commit order under concurrency.
The feed covers every successful application fact write. Delivery built on repeated page reads is
at least once, so consumers retain a checkpoint and deduplicate by command or transaction identity.
`ChangeEmitter` remains a best-effort wake-up mechanism and never replaces catch-up reads.
### Consumer checkpoints
`@triplex-build/triplex/operational` stores one reserved, queryable checkpoint entity per named
consumer. `ConsumerCheckpoint.advance` accepts the position read before processing and the final
position whose effects completed. Moving it uses compare-and-retract, never moves backwards, and
returns a typed conflict to a stale worker. The initial write is protected by the same atomic
command-claim mechanism, so concurrent initialization cannot create two live positions.
Checkpoint maintenance is atomically persisted but intentionally omitted from the transaction
feed. Otherwise an idle consumer would observe its own cursor update, advance past it by writing
another cursor update, and repeat forever. These unjournaled system writes still allocate commit
positions, so recorded snapshot ordering remains total; application commands remain fully
journaled.
### Snapshot-stable Datalog pages
`Triples.queryPage` returns an opaque, versioned keyset cursor. Its content-addressed fingerprints
bind the canonical query, filters, complete deterministic projected-row ordering, temporal basis,
and database scope. The first page captures the backend's latest committed position; assertion and
retraction positions keep every later page on that exact recorded snapshot, including when two
transactions share the same recorded millisecond. Cursor decoding and reuse failures are typed as
`PaginationCursorError`.
### Bitemporal query basis
Facts carry separate recorded and valid intervals. Direct matching, entity reads, batched reads,
and Datalog accept the same `{ recordedAt?, validAt? }` basis, so every clause in a join,
negation, or rule sees one coherent cut. Historical corrections append new facts; they do not
rewrite the recorded history.
### Portable derivations and pure reconciliation
`@triplex-build/triplex/derivation` provides a content-addressed `Definition` that pins a complete
structural Datalog query, optional result `TypeExpr`, canonical identity projection, discovered
attribute dependencies, and configuration snapshot. Evaluation returns `Candidate` values with:
- a stable logical identity derived from the definition name and declared result key;
- a revision covering the exact definition, config pin, result, and explanation;
- the pinned bitemporal basis;
- merged source triple IDs and assertion transaction IDs/positions from every positive graph path;
and
- the earliest future `validTo` among supporting facts, when one is known.
The complete `Evaluation` also exposes a conservative `nextTemporalBoundary` across the current
recorded view of every dependency attribute. It includes both future `validFrom` and `validTo`
edges, including facts inside negated clauses that currently suppress every candidate. This lets a
host wake exactly when evidence may expire or become effective without a daily full scan.
`Triples.dependencyState` derives both the dependency-relevant source position and this schedule
through backend attribute indexes. It can produce harmless extra wakeups for unrelated entities
that share an attribute, but it does not omit a recorded boundary.
`reconcile` is a pure diff that classifies candidates as `added`, `removed`, `changed`, or
`unchanged`. A host can translate that diff into durable requirement occurrences, tasks, or other
governed work without coupling those concepts to Triplex. Conflicting outputs for the same
declared identity fail with a typed error rather than being selected arbitrarily.
`Derivation.Materialization` persists candidate revisions and complete evaluation runs as
immutable Triples system entities. Each run atomically binds its definition, config snapshot,
bitemporal basis, candidate set, and latest dependency-relevant transaction position. There is no
mutable first-writer checkpoint race: the current run for a definition is selected by source
position and then materialization commit position. A newly deployed definition has its own stream,
while reconciliation can still compare its stable logical candidate identities with the preceding
run for the same name.
`current` returns explicit `current`, `stale`, or `unmaterialized` state and retains the last
durable candidates when stale. Relevant transaction positions are derived from the definition's
discovered attributes; materializer and unrelated transactions do not create false lag. Persisted
candidate bodies are schema-decoded and content-verified. Immutable run membership can be queried
with ordinary Datalog for audit and composition. Versioned run identities content-bind and expose
the evaluation's `nextTemporalBoundary`, including runs with zero candidates. Triplex does not own
timer delivery: a host scheduler wakes the materializer at that instant and persists the next run.
`Derivation.Overlay.evaluateOverlay` applies temporary assertions and visible-triple retractions
at a pinned basis in a fresh private in-memory KV store. It seeds only the definition's discovered
fixed attributes and delegates to the same structural Datalog evaluator, then translates source
facts back to durable IDs or deterministic hypothetical content commitments. The source Triples
store and transaction journal are never mutated. This supports collect-versus-reuse and proposed
relationship planning with the same candidate identity and explanation shape as committed data.
Assertions whose `validFrom` is omitted begin at the overlay's `validAt`. Retractions must identify
facts visible at that basis, and duplicate or irrelevant patch operations fail explicitly. Dynamic
attribute queries and transaction-binding clauses are rejected because a bounded copy could not
preserve their semantics honestly.
The initial provenance contract supports patterns, predicates, and negation. Recursive rules,
disjunction, aggregation, and pagination are rejected until their exact provenance semantics are
implemented.
## Delivered extensions and next primitives
### Deferred: scoped optimistic concurrency
The global commit position is an ordering and checkpoint token, not an application-wide expected
version. Today, however, allocating that position uses one backend-wide contention point, and graph
constraint enforcement deliberately relies on the same serialization to prevent absence and
uniqueness write skew. This is simple and safe, but it may serialize commands whose business state
is independent.
A future design may separate these responsibilities, following the distinction described in
[Basic principles of record storing](http://systema10.org/posts/basic-principles-of-record-storing.html):
- every successful transaction still receives one global position for replay, temporal snapshots,
subscriptions, and consumer checkpoints;
- optimistic concurrency claims versions for explicit conflict scopes rather than the complete
journal;
- a scope may identify an entity or a cross-entity invariant such as a uniqueness key, movable ref,
compliance scope, or reference target;
- one transaction atomically claims every affected scope, writes its facts, and appends its journal
envelope, or commits none of them; and
- append-only operations with no lost-update risk may explicitly request no conflict check.
An entity-only `(record, entity, version)` index is not sufficient for Triplex because graph
constraints can couple otherwise distinct entities. A general branded `ConflictScope`, an atomic
scope-head compare-and-advance operation, and indexed transaction-to-scope/entity facts are the
more promising shape. Multi-scope claims must verify successor continuity, not merely rely on a
unique `(scope, version)` pair. Record insertion and scope materialization must never be separate
commits.
This remains deferred. Do not weaken the current global graph-constraint serialization until a
KV/SQLite/PostgreSQL conformance suite proves atomic multi-scope claims, cross-entity invariant
protection, and unchanged global journal ordering. A reusable Effect fold or stream over
`Triples.transactions` can be considered independently; ordinary reads should continue to use
indexed triples, Datalog, and checkpointed projections rather than replaying the complete journal.
### Inboxes and outboxes
Define conventional first-class inbox and outbox records without embedding delivery vendors or
retry policy in core. Command receipts and consumer checkpoints now provide the atomic identities
and durable resume positions beneath them.
### Graph constraints
`TypeExpr` remains local and decidable. Separately content-addressed `GraphConstraint` nodes now
cover cardinality, uniqueness, required relationships, and reference target kinds. The ontology DSL
generates them from usage-local declarations, and `EntityValidation` writes their findings as
first-class violations with stable codes and constraint identities. Read-only evaluation accepts a
bitemporal basis. Opt-in transaction enforcement accepts those same plain versioned rules alongside
the pinned config snapshot. It projects the whole command, evaluates every post-state valid-time
boundary, and rejects new or worsened findings atomically. Commit-position serialization provides
backend-portable absence and uniqueness protection across Triplex writers. Observation mode remains
available for migration and audit, while direct adapter writes, authorization, and general Datalog
invariants remain outside this guarantee.
### Projection ownership
Indexed dependency state and durable consumer checkpoints now let long-running materializers avoid
a full journal replay. Triplex should not turn an added candidate into a Task or a removed candidate
into a cancellation; applications own durable occurrences, assignment, evidence disposition,
conversations, and retry policy.
### Content-addressed blobs
Define a browser-safe `BlobStore` service that streams bytes, computes the shared SHA-256
`ContentId`, verifies reads, and leaves metadata and references in Triples. Durable adapters such
as S3 or R2 stay outside core. Garbage collection follows reachability and retention policy.
### Configuration migrations
Connect release diffs to affected-entity queries, typed transformations, checkpointed batches,
validation observations, and completion facts. Triplex owns provenance and resumability; domain
transformations remain application code.
## Runtime and backend boundary
These guarantees are in the shared `Triples` contract and are exercised by in-memory KV and
SQLite in the default suite. PostgreSQL passes the same conformance and multi-database isolation
tests through a dedicated integration suite that runs in CI and is opt-in locally. PostgreSQL
remains a pre-1.0 production candidate pending operational validation. Cloudflare and FoundationDB
are experimental and are not covered by every guarantee above.
PostgreSQL additionally exposes three composition boundaries. `PgTriples.layer` and
`layerFromUrl` own a pool and migrate it for standalone use. `layerFromSqlClient` consumes an
ambient host-owned `SqlClient`, creates no pool, and runs no DDL; both host statements and nested
`Triples.transact` therefore resolve Effect SQL's same fiber-local transaction connection.
`layerForDatabase` creates a pool whose every connection is bound at startup to the schema derived
from a validated, server-resolved `DatabaseId`, and provides both that scoped client and `Triples`.
The `*Migrated` variants are explicit provisioning conveniences. Production migration execution
remains host-controlled through the ordered exports in `@triplex-build/triplex-sql`.
Subscriptions remain conservative invalidation hints rather than an automatic live-query runtime.
Entity snapshots, validation observations, and derivation runs remain checkpointed projections;
their freshness/source positions are part of their correctness contract. See
[`current-state.md`](current-state.md) for the complete maturity matrix.
## Boundary
Triplex should not define Threads, routine step DSLs, timers, integration catalogs, tenancy, or HR
permissions. It should provide the atomicity, concurrency, history, constraints, temporal queries,
content identity, and durable derivation mechanics those systems require.
---
# Configuration-derived HTTP API
`@triplex-build/triplex-http` turns a persisted `ConfigSnapshot` into runtime request/response schemas,
an Effect `HttpApi`, OpenAPI 3.1, and REST handlers. It never needs the original `Attribute` or
`EntityType` TypeScript values after deployment. The package depends only on public Triplex core
and config APIs; the host chooses and provides its backend and server.
## Host composition
```ts
import { ConfigStore } from "@triplex-build/triplex/config";
import { EntityHttp, HttpAuthorizationAllowAll } from "@triplex-build/triplex-http";
import { SqliteTriples } from "@triplex-build/triplex-sqlite";
import { Layer } from "effect";
const database = ConfigStore.layer.pipe(
Layer.provideMerge(SqliteTriples.layer({ filename: "triplex.sqlite" })),
);
const routes = EntityHttp.layer({
basePath: "/api",
exposure: { collections: { Employer: "employers" } },
}).pipe(
Layer.provide(HttpAuthorizationAllowAll), // local examples only
Layer.provide(database),
);
```
Production hosts provide `HttpAuthorization` with request authorization, row visibility for
collection reads, and trusted actor metadata. Payload fields never control transaction metadata.
## Routes and wire format
For `/api/rest/{version}`, the package exposes `schema`, `openapi.json`, an optional interactive
Scalar `docs` page, collection GET/POST, and item GET/PUT/DELETE routes. Create and replace bodies
have one `attributes` object:
```json
{ "attributes": { ":employer/name": "Acme" } }
```
Responses add server-owned identity and the configured entity type. Full keywords are the only
accepted wire keys. Cardinality-many attributes are deterministically ordered arrays; list-valued
attributes remain one JSON fact. Dates are `YYYY-MM-DD`, instants are epoch milliseconds, and refs
are entity ID strings.
Every response has `X-Triplex-Config-Snapshot`. Writes carry the same ID in
`TransactionMeta.configSnapshot`, decode before mutation, enforce constraints from the complete
release, and use one `Triples.transact` boundary. PUT replaces only exposed attributes and retains
unexposed facts. DELETE retains normal bitemporal history. The initial contract rejects empty
entities, mixed entity-type membership, and mutations of entities with active scheduled facts.
## Versions and temporal reads
- `latest` resolves `latestRef` (`live` by default) for each request and is writable.
- Explicit snapshot IDs and configured aliases are immutable and read-only by default.
- `activeWriteSnapshot` can designate one immutable snapshot as the deployment's write contract.
- `recordedAt` and `validAt` are validated epoch-millisecond query parameters on reads only.
Collection cursors bind the database scope, entity type, temporal basis, exact commit position, and
the surrounding HTTP snapshot/version cache key. A continuation therefore materializes every body
at the first page's exact cut. If a movable ref changes, clients must restart under the newly
resolved contract.
Errors are JSON objects with a stable `code`, sanitized `message`, and an optional field `path`.
Malformed input is `400`, host authentication/authorization is `401`/`403`, missing resources are
`404`, read-only writes are `405` with `Allow`, and data/constraint/concurrency conflicts are `409`.
Unexpected storage and deployed-config failures return a sanitized `500` while their causes remain
in Effect logs.
---
# Performance
Triplex is pre-1.0 and does not yet publish a production performance guarantee. These measurements
are a reproducible baseline for finding scaling cliffs, not a capacity-planning substitute.
## September 2026 baseline
The following SQLite in-memory Datalog pagination measurements are medians of five warm runs on a
single 8-core, 16 GB Linux VM. Reads requested the first 100 rows unless explicitly described as
complete. There was no concurrent load.
| Facts | All facts, first 100 | Fixed attribute, first 100 | Join, first 100 | Group count |
| --------: | ------------------------------------------------------: | -------------------------: | --------------: | ----------: |
| 30,000 | 35 ms | 13 ms | 16 ms | 15 ms |
| 300,000 | 346 ms | 122 ms | 153 ms | 165 ms |
| 3,000,000 | Out of memory during an unbounded `queryAll` setup read | — | — | — |
The first-page latency grows with the number of candidate table rows even though the returned page
is fixed at 100. See [Datalog performance](datalog-performance.md) for the query-plan explanation and
indexing guidance. `queryAll` is intended only for trusted batch processing with an explicit memory
budget.
An in-memory KV run at approximately 920,000 triples measured about 3,400 inserted triples per
second and 823 ms for one entity lookup. Its entity-type scan exposed a stack overflow in large
cross-partition result collection; the implementation now appends entries iteratively and has a
150,000-entry regression test.
## Reproducing measurements
The multi-backend harness exercises writes through Triplex APIs, current and historical reads, and
Datalog queries:
```sh
# Quick SQLite smoke run (~100,000 initial triples)
pnpm --filter triplex-stress stress-test:smoke
# Full report; Docker enables PostgreSQL and a native client enables FoundationDB
pnpm --filter triplex-stress benchmark
# Focused pagination benchmark
pnpm --filter triplex-stress benchmark:datalog
```
The full report records Node, CPU, memory, fact count, update history depth, insertion throughput,
database size, and per-query wall time. PostgreSQL and FoundationDB results are omitted when their
required local infrastructure is unavailable. Always report storage mode, warm/cold cache state,
contention, and whether a read used bounded pagination or `queryAll` when comparing runs.
---
# CLI and dashboard
Triplex includes two operator tools: a published JSON-first CLI for repeatable commands and a
browser dashboard for interactive exploration. Both are pre-1.0. The dashboard is a private
workspace package rather than a published application.
## Build from the source checkout
After following [Getting started](/getting-started), build the CLI and its workspace dependencies,
then define a short shell function for the built binary:
```sh
pnpm turbo run build --filter=@triplex-build/triplex-cli...
triplex() { node --disable-warning=ExperimentalWarning packages/cli/dist/cli.js "$@"; }
```
The examples below use the repository command. Install `@triplex-build/triplex-cli` from npm to
use the same `triplex` binary in an application:
```sh
triplex --sqlite ./app.db describe
triplex --sqlite ./app.db status
```
`describe` returns the machine-readable command manifest. Successful CLI commands emit one
`{ "ok": true, "command": "…", "data": … }` JSON value to stdout. Decode/runtime failures emit an
error envelope to stderr and exit nonzero. Add `--pretty` before the subcommand when reading output
by hand.
Opening the SQLite convenience layer can apply pending Triplex migrations. Hosts that require
migration separation should run migrations in their deployment process before granting CLI access.
## Write and inspect a fact
`transaction apply` accepts the public `TransactRequest` wire shape. Mutation input must include an
actor and a unique command ID:
```sh
triplex --sqlite ./app.db transaction apply --input - <<'JSON'
{
"operations": [
{
"op": "assert",
"entityId": "worker:maria",
"entityType": "Worker",
"attribute": ":worker/name",
"value": { "type": "string", "value": "Maria" }
}
],
"meta": {
"actor": "user:docs",
"commandId": "docs/create-maria/v1"
}
}
JSON
triplex --sqlite ./app.db entity types
triplex --sqlite ./app.db entity get worker:maria
triplex --sqlite ./app.db entity facts worker:maria
triplex --sqlite ./app.db journal receipt docs/create-maria/v1
```
Do not retry that payload with a new command ID merely because the client lost the response. Look
up the original receipt first. A duplicate committed command ID produces
`CommandAlreadyCommittedError`; it does not silently execute or return success again.
## Run and page a query
Create `query.json`:
```json
{
"find": ["?worker", "?name"],
"where": [["?worker", ":worker/name", "?name"]],
"orderBy": [{ "variable": "?name", "direction": "asc" }]
}
```
Run or explain it:
```sh
triplex --sqlite ./app.db query run --input query.json --limit 50 --debug
triplex --sqlite ./app.db query explain --input query.json
```
`query run` returns one bounded page. If `data.nextCursor` is present, pass that exact opaque value
back with `--cursor`; keep the query, page size, database, and temporal basis unchanged. The cursor
pins the recorded snapshot so concurrent writes do not reshuffle an in-progress scan.
Use epoch milliseconds to inspect a historical basis:
```sh
triplex --sqlite ./app.db entity get worker:maria \
--recorded-at 1788998400000 --valid-at 1788998400000
```
The same `--recorded-at` and `--valid-at` flags are available for fact and query reads. See [Core
concepts](/concepts#recorded-time-and-valid-time) before interpreting a one-axis historical query.
## Inspect configuration
Configuration authoring remains application-owned. Once an application has committed releases,
operators can inspect and move their refs:
```sh
triplex --sqlite ./app.db config refs
triplex --sqlite ./app.db config releases
triplex --sqlite ./app.db config release --ref live
triplex --sqlite ./app.db config objects --kind entity-schema
triplex --sqlite ./app.db config object entity-schema Course
triplex --sqlite ./app.db config impact attribute :course/status
```
`config releases` returns the real snapshot IDs. To promote or roll back, copy the intended returned
ID and pass it unchanged:
```sh
SNAPSHOT_ID='copy an actual snapshotId returned above'
triplex --sqlite ./app.db config set-ref live "$SNAPSHOT_ID"
```
The assignment is an operator template, not a command to paste unchanged. Inspect the target
release before moving the ref. Moving it does not change operational data; see [Configuration
releases and rollback](/configuration-versioning#understand-rollback).
## Manage local databases
The CLI can manage isolated SQLite files in `./data` (or `--data-dir`):
```sh
triplex db create app --description "Application database"
triplex db list
triplex --database-id app status
```
`db clear` and `db delete` are destructive and require `--yes`. PostgreSQL can be selected with
`TRIPLEX_POSTGRES_URL`; prefer the environment over a URL flag so credentials do not enter shell
history. A host must resolve database IDs from authenticated server-owned state, not unchecked
request input.
## Explore in the dashboard
Start the browser-local demo:
```sh
pnpm dashboard
```
Open `http://localhost:4173`. The demo loads classroom fixtures into a fresh in-memory database.
Edits are real Triplex writes but disappear when the app runtime restarts.
To inspect a persistent database, build and run the local HTTP host:
```sh
pnpm dashboard:serve -- --sqlite /absolute/path/to/triplex.db
```
The dashboard includes a spreadsheet-like entity grid with managed/runtime type filters, typed form
editing, raw JSON editing, and a semantic before/after diff before commit. It also includes entity
timelines, raw Datalog and query plans, the transaction journal, derivation candidates, form
previews, and full configuration release/ref/revision history. Its temporal control selects
recorded and valid time independently; configuration and journal views remain current so the
surrounding context stays visible. You can also [try the same in-memory explorer in the
website](/explorer).
The server binds to `127.0.0.1` and has mutation capabilities. It is an operator tool, not an
authenticated public server. Do not expose it as your application's authorization boundary.
## Runnable demos
- [`examples/demo`](https://github.com/bjacobso/triplex/tree/main/examples/demo) — linked facts and
Datalog
- [`examples/compliance-host`](https://github.com/bjacobso/triplex/tree/main/examples/compliance-host)
— configuration, journal catch-up, derivation reconciliation, overlays, and temporal wakeups
- [`examples/http-api`](https://github.com/bjacobso/triplex/tree/main/examples/http-api) — generated
HTTP routes and OpenAPI
- [`examples/config-explorer`](https://github.com/bjacobso/triplex/tree/main/examples/config-explorer)
— configuration graphs, diffs, evaluation, and proof tamper detection
- [`examples/tenant-host-cloudflare`](https://github.com/bjacobso/triplex/tree/main/examples/tenant-host-cloudflare)
— experimental multi-tenant reference host
For the exhaustive CLI command contract, see the [package
README](https://github.com/bjacobso/triplex/tree/main/packages/cli). For common failures, continue to
[Troubleshooting and FAQ](/troubleshooting).
---
Opening the in-memory Triplex database…
---
# Host integration
Triplex can provide the fact, temporal-query, configuration, derivation, and audit substrate for
an application that also owns relational operational records. The host remains responsible for
authentication, authorization, HTTP contracts, durable work, response caching, external effects,
and product-specific invariants.
Start with [Core concepts](/concepts#constraints-and-responsibility-boundaries) if that separation
is unfamiliar. Use [CLI and dashboard](/tools) for local inspection and
[Troubleshooting](/troubleshooting) for common runtime and freshness failures.
The executable companion is
[`examples/compliance-host`](https://github.com/bjacobso/triplex/tree/main/examples/compliance-host).
Despite its focused scenario, it uses only generic Triplex primitives: content-addressed
configuration, temporally scoped facts, Datalog derivations, provenance, reconciliation,
hypothetical overlays, and consumer checkpoints.
Treat [`current-state.md`](current-state.md) as the release-readiness contract. PostgreSQL passes
the shared conformance and isolation suites, but remains a production candidate while Triplex is
pre-1.0.
## Responsibility boundary
Triplex provides:
- atomic fact assertions and retractions with optimistic preconditions;
- independent recorded and valid time;
- structural Datalog queries and snapshot-stable pagination;
- a causal transaction journal with actor, command, correlation, causation, and config pins;
- content-addressed configuration releases and movable refs;
- derivation candidates, provenance, materialization, and explicit freshness; and
- database-scoped in-memory, SQLite, and PostgreSQL runtimes.
The host provides:
- authentication and authorization before selecting a database;
- mapping from its isolation boundary to a validated `DatabaseId`;
- operational tables, response caches, inboxes, outboxes, and delivery workers;
- domain command validation and higher-order business invariants;
- lifecycle semantics for durable tasks, cases, evidence, or other work; and
- migration orchestration, monitoring, retention, and recovery policy.
These are database guarantees, not authorization guarantees. Resolve the caller's access and then
select a server-owned database mapping before exposing `Triples` to application code.
## Choose a runtime
| Host shape | Public Triplex entry point |
| ----------------------------------------------- | ------------------------------------------------ |
| Ephemeral or test database | `KvTriples.layerWithScope(databaseId)` |
| One SQLite database per isolation boundary | `SqliteTriples.layer({ filename })` |
| Standalone PostgreSQL with a Triplex-owned pool | `PgTriples.layerFromUrl(url)` |
| Host-owned Effect SQL pool | `PgTriples.layerFromSqlClient({ scope })` |
| Server-mapped PostgreSQL database | `PgTriples.layerForDatabase(config, databaseId)` |
Application code should depend on `Triples`. New backend authors can use the public
`@triplex-build/triplex/runtime` builders and adapter contracts described in
[Custom runtimes](/custom-runtimes). The `/internal` entry point remains legacy implementation
support and is not required for host integration or authoring a new runtime.
## One database per isolation boundary
Persist a server-owned mapping from the host's isolation key to a validated Triplex `DatabaseId`.
Do not derive authorization or physical schema names directly from request input. At request
entry:
1. authenticate the actor and authorize the requested operation;
2. load the trusted `DatabaseId` mapping;
3. construct or obtain the cached database runtime;
4. provide its scoped `SqlClient` and `Triples` services to the request program; and
5. keep authorization checks in the host command and query layer.
For PostgreSQL, `PgTriples.layerForDatabase` creates a collision-resistant schema-bound pool,
provides its exact `SqlClient` alongside `Triples`, and embeds the logical database scope in
pagination cursors. Never use the registry or system client as an application fact client.
SQLite callers should allocate one file per isolation boundary:
```ts
const DatabaseLive = SqliteTriples.layer({
filename: `/var/lib/example/triplex/${databaseId}.sqlite`,
});
```
Tests and ephemeral programs should name the in-memory scope explicitly:
```ts
const DatabaseLive = KvTriples.layerWithScope(databaseId);
```
Cache runtimes by the trusted `DatabaseId`, not an unchecked request string. Never batch requests
across database boundaries.
## Share a PostgreSQL transaction
A host-owned Effect SQL client can back Triplex without a hidden second pool:
```ts
import { Effect, Layer } from "effect";
import { SqlClient } from "effect/sql";
import { EntityId, Triples, string } from "@triplex-build/triplex";
import { PgTriples, makePostgresqlLayerUnmigratedFromUrl } from "@triplex-build/triplex-postgres";
const HostSql = makePostgresqlLayerUnmigratedFromUrl(process.env.DATABASE_URL!);
const DatabaseLive = PgTriples.layerFromSqlClient({ scope: "host/main" }).pipe(
Layer.provideMerge(HostSql),
);
const command = Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
const triples = yield* Triples;
yield* sql.withTransaction(
Effect.gen(function* () {
yield* sql`INSERT INTO host_records (id) VALUES ('record:1')`;
yield* triples.transact(
[
{
op: "assert",
entityId: EntityId.make("record:1"),
attribute: ":record/status",
value: string("accepted"),
},
],
{ actor: "user:1", commandId: "command:1" },
);
yield* sql`INSERT INTO host_outbox (id) VALUES ('delivery:1')`;
}),
);
}).pipe(Effect.provide(DatabaseLive));
```
The host statements and nested `Triples.transact` resolve Effect SQL's same fiber-local
transaction connection. A failure on either side rolls back the relational rows, Triplex facts,
journal envelope, command receipt, commit position, and outbox row together. Nested transactions
retain Effect SQL savepoint semantics.
`layerFromSqlClient` creates no pool and runs no DDL. The explicitly named
`layerFromSqlClientMigrated` variant is a provisioning convenience, not the default production
boundary.
## Host-controlled migrations
`@triplex-build/triplex-sql` exports the ordered `migrations` definitions and `runMigrations`. Run them
from the host's deployment process against the same database or scoped schema before constructing
an unmigrated runtime. Triplex records its state in `triplex_schema_migrations`, avoiding the
host's migration table.
SQL migration v1 is the baseline; additive v2 creates the shared numeric/datetime expression
index. Cloudflare has a separate sequence: v2 adds snapshots and v3 adds the same numeric index.
Existing baseline databases upgrade in place; applied versions are skipped on subsequent runs.
The baseline definitions are unchanged. `INDEX_DDLS` and `INDEX_NAMES` describe all current indexes,
including the new index, so SQLite bulk-load index rebuilding preserves it.
Use the exported `runMigrations` Effect with the host's `SqlClient` at deployment/provisioning
time, or execute the ordered `migrations` with the host's migration tooling. Convenience layers
apply pending migrations on startup; unmigrated layers and `layerFromSqlClient` leave DDL to the
host. KV needs no schema migration for this change.
Index creation scans existing numeric facts and adds storage/write cost, including retained
history. The default migration uses ordinary `CREATE INDEX`, which can block writes; serialize
migration execution and schedule it appropriately for large databases. PostgreSQL hosts that
need an online build can pre-create the exact index with `CREATE INDEX CONCURRENTLY` outside a
transaction, verify that the index is valid, then run the migration to record its version.
`IF NOT EXISTS` checks the name, not the definition or validity: reserve `idx_attr_numeric` for
Triplex and resolve any conflicting or invalid index before migrating.
Runfold can remove its `runfold_actor_due` DDL after deploying this Triplex version and applying
the migration to every database. Triplex does not drop that host-owned index; dropping an existing
redundant index is a separate host migration. Older binaries can read the additive schema, but
old SQLite bulk-loading code that drops/rebuilds only its known indexes cannot suspend maintenance
of the new index. Deploy the migration and updated runtime together when using bulk loading.
See [numeric query plans and indexing scope](/datalog-performance#numeric-ranges-and-actor-due-work).
Triplex does not upgrade databases created by unpublished development builds. A host adopting an existing EAV store should rehearse
the copy against production-shaped data, preserve every assertion and retraction, rebuild journal
positions deterministically, and compare live and historical reads before cutover.
## Compile application configuration
The host can compile its own ergonomic DSL into independently addressed Triplex nodes:
```ts
const RecordName = Attribute.text(":record/name");
const Record = EntityType.make("Record", {
attributes: {
name: Attribute.use(RecordName, { required: true }),
},
});
```
The attribute node owns its global key and value type. The entity-type node references it and owns
usage-local constraints such as requiredness and cardinality. Forms, policies, actions,
permissions, integrations, and views can reference those same keys in one `ConfigStore.commit`.
Store the resulting `ConfigSnapshot` ID on operational transactions through
`meta.configSnapshot`.
`EntityValidation` records queryable observations for attribute cardinality, required facts,
reference target types, and uniqueness. The same collected `GraphConstraint` definitions can be
passed to `Triples.transact` for atomic enforcement. The host remains responsible for
authorization and higher-order policy invariants.
## Batch entity reads
Build an Effect `RequestResolver` above `Triples.entities(ids, basis?)`:
1. partition requests by an already authorized database and temporal basis;
2. deduplicate entity IDs within each partition;
3. call `entities` once per partition; and
4. associate every result, including missing entities, with its original request.
KV performs indexed entity lookups. SQLite and PostgreSQL use bounded parameterized queries. The
host should schema-decode the returned attribute sets into its own domain type.
## Consume the journal
Translate a validated command into `TransactOp` values and call one `Triples.transact` with:
- `actor` from the authenticated principal;
- the idempotency key as `commandId`;
- `correlationId` and `causationId` from the host operation context;
- the pinned `configSnapshot`;
- graph constraints from that exact snapshot in `enforce`; and
- a `TripleLive` precondition for each optimistic fact being replaced.
Use `transaction(txId)` to construct an audit response. Retraction changes include the old typed
value and original assertion transaction; assertions include the replacement. Command IDs are
atomically unique within one Triplex database. A duplicate produces
`CommandAlreadyCommittedError`, and `transactionByCommand` retrieves the durable receipt.
Durable consumers page `transactions({ after: checkpoint })`, perform idempotent work, and advance
`ConsumerCheckpoint` only after their effects commit. The feed is authoritative; an in-process
change signal is only a wake-up hint.
For an entity audit view, page `transactionsForEntity(entityId, { limit })`. Preserve the returned
`snapshotPosition` and `nextBeforePosition` when requesting the next page. Every record already
contains actor, command, correlation, causation, config snapshot, time, position, and complete
typed assertion and retraction changes. Do not create a second audit log.
## Remaining limitations
- Authorization and general cross-entity Datalog invariants remain host-owned.
- Inbox and outbox records, response caching, retries, and timer delivery remain host-owned.
- Derivation provenance rejects recursive rules, disjunction, aggregation, and dynamic attributes
rather than returning an incomplete explanation.
- Entity snapshots and derivation materializations are projections. Inspect their source position
and freshness instead of treating absence as current truth.
- Dynamic-attribute derivations fall back to journal replay and should not serve hot-path
materializers.
- FoundationDB and Cloudflare are experimental and are not production targets for this guide.
- Stable adoption should wait for a stable package release, a rehearsed data copy where applicable,
and application-level shadow comparisons.
---
# Troubleshooting and FAQ
## Why does npm return 404 for a Triplex package?
The seven public packages use the `@triplex-build` scope. Check the full package name and clear a
stale local npm cache if a newly published version returns 404. Cloudflare and FoundationDB remain
private workspace packages. Do not switch new examples back to the superseded `@bjacobso` scope.
Keep every Triplex package on one coordinated release and use the exact Effect
peer version declared by that release. The current source tree targets Node.js 22+ and
`effect@4.0.3`; Effect 3 is incompatible. Multiple Effect copies or a version mismatch can make
service tags and runtime types disagree even when imports look correct.
## Why is `Triples` (or another service) missing?
An Effect program describes requirements until a layer provides them. Supply a backend layer at
the application boundary:
```ts
await Effect.runPromise(program.pipe(Effect.provide(KvTriples.layer)));
```
`ConfigStore.layer` itself requires `Triples`, so compose it over the same database:
```ts
const AppLayer = ConfigStore.layer.pipe(Layer.provideMerge(KvTriples.layer));
```
Construct that layer once per intended database lifetime. Reconstructing an in-memory layer creates
a new empty database; constructing per-request SQLite/PostgreSQL layers also defeats connection and
runtime sharing.
## Why is a query empty?
Check these in order:
1. **Identity and value type:** direct entity/reference APIs require branded `EntityId`/`TripleId`
values; raw Datalog terms are decoded by the query schema.
2. **Temporal basis:** omitted `validAt` means now. A future-effective or expired fact will be
absent even if it exists in history.
3. **Retraction:** `entity` and `match` show visible facts; `history(entityId)` also shows retracted
assertions.
4. **Join variables:** a spelling difference such as `?site` versus `?siteId` prevents clauses from
joining.
5. **Negation safety:** variables in `not` must already be bound outside it; invalid programs fail
preflight instead of producing a partial answer.
Use `triples.explain(query)` or CLI `query explain`, then run with debug output to inspect the
compiled plan without changing the query.
## Why are only 100 query rows returned?
`Triples.query` and `queryPage` are bounded and default to 100 rows. This is not evidence that only
100 matches exist. Follow `nextCursor` using the same canonical query, page size, temporal basis,
and database scope. Cursors are opaque, versioned, and snapshot-stable; do not decode or edit them.
Use `queryAll` only for trusted internal/batch work that deliberately needs an unbounded complete
result. Pagination bounds result transfer, not necessarily all database work, especially for joins,
aggregation, or counts.
Entity timelines use a different newest-first contract: preserve the first page's
`snapshotPosition` and pass its `nextBeforePosition` as `beforePosition` on the next request.
## Which historical clock should I set?
`recordedAt` asks what the database knew; `validAt` asks what was true in the domain. Setting only
`recordedAt` still uses the current valid time, and setting only `validAt` still uses the latest
recorded knowledge. For “what did we believe then about what was true then?”, set both explicitly.
Both are non-negative epoch milliseconds. Valid intervals are `[validFrom, validTo)`, so a fact is
not visible exactly at its `validTo`. For exact transaction order, use journal positions; multiple
commits can share one millisecond. Read [the dated example](/concepts#recorded-time-and-valid-time).
## What should happen on a duplicate command ID?
Command IDs are atomically unique within one Triplex database. Reusing a committed ID raises
`CommandAlreadyCommittedError` with the original transaction ID; Triplex does not apply the write
again. Treat the original transaction as the durable idempotency receipt and retrieve it with
`transactionByCommand(commandId)` or CLI `journal receipt`.
Do not generate a replacement ID until you know you are issuing a genuinely new domain command.
Command IDs must contain 1–1024 characters.
## Why did invalid data commit?
Configuration metadata and enforcement are separate. `meta.configSnapshot` records which release
governed a write, but constraints are enforced only when the command also passes
`meta.enforce.constraints`. The built-in atomic rules cover requiredness, cardinality, uniqueness,
and reference-target kinds.
Always derive or collect rules from the exact immutable snapshot you pin. Existing violations can
remain during unrelated writes or repairs; enforcement rejects a new or worsened violation. Direct
adapter writes, commands without `enforce`, authorization, and general Datalog invariants are
outside this guarantee. Use validation observations when auditing or migrating existing data.
## Why is a projection stale or empty?
Entity validation and derivation materialization are projections over already committed source
facts. A projection failure cannot roll back the source transaction. Their read APIs expose
freshness explicitly:
- `current` means the stored run matches the relevant current source position and basis;
- `stale` returns last-known results, which must not be presented as current truth; and
- `unmaterialized`/`unvalidated` means no run exists, so an empty result is not proof that no work or
violation exists.
Run the materializer/validator after relevant journal changes, persist consumer checkpoints only
after downstream effects complete, and schedule the next derivation run at
`nextTemporalBoundary`. In-process change signals are wake-up hints; journal catch-up is the durable
source.
## Does moving a config ref roll back data?
No. `setRef` only points a name such as `live` at another existing immutable configuration
snapshot. Operational facts, command receipts, configuration pins on old transactions, migrations,
and external side effects remain unchanged. See the [worked rollback
example](/configuration-versioning#understand-rollback).
## Where should authentication, authorization, and delivery live?
In the host application. Authenticate and authorize before selecting a trusted database mapping or
providing `Triples`. The host also owns HTTP contracts, task lifecycle, inbox/outbox delivery,
timers, retries, monitoring, retention, backup, recovery, and higher-order business invariants.
Triplex supplies database primitives, not those operational guarantees. Continue with [Host
integration](/host-integration).
---
# Releasing Triplex
Triplex uses Changesets for one coordinated package-family release. Publishing is performed by
GitHub Actions; routine releases should not depend on a maintainer's local npm configuration.
## Release set
The public release set contains:
- `@triplex-build/triplex`
- `@triplex-build/triplex-sql`
- `@triplex-build/triplex-sqlite`
- `@triplex-build/triplex-postgres`
- `@triplex-build/triplex-testkit`
- `@triplex-build/triplex-cli`
- `@triplex-build/triplex-http`
`@triplex-build/triplex-cloudflare` and `@triplex-build/triplex-foundationdb` remain private workspace
packages until they pass the supported backend conformance contract. The dashboard and examples
are also private.
The first release published all seven packages in September 2026. Review pending Changesets and
the coordinated release set before each subsequent publication.
## Required repository configuration
Create a GitHub environment named `npm-publish` and require maintainer approval for it. Protect
`main` with the Node 22, Node 24, package-consumer, and PostgreSQL integration jobs from CI.
Repository Actions settings must allow GitHub Actions to create pull requests so the version job
can maintain the release PR.
The release workflow uses npm trusted publishing. Each public package needs this trusted publisher:
| Field | Value |
| ----------- | ------------- |
| Owner | `bjacobso` |
| Repository | `triplex` |
| Workflow | `release.yml` |
| Environment | `npm-publish` |
Allow direct `npm publish` for this workflow. It runs on a GitHub-hosted runner with
`id-token: write`; npm therefore issues a short-lived OIDC credential and records provenance.
## One-time npm bootstrap
npm package settings do not exist until the package has first been created. The seven public
packages were bootstrapped under `@triplex-build` with a short-lived token. Routine releases use
GitHub Actions OIDC without an npm token.
For a new package added to the family, repeat the minimal bootstrap sequence:
1. Authenticate as an npm owner of the `@triplex-build` organization and confirm the new name is
available.
2. Create a granular token limited to the new package with publish access and the minimum useful
lifetime. Temporarily add token authentication to the protected release job for its first publish.
3. Publish its first version under the `next` dist-tag and verify the package record exists.
4. Configure its trusted publisher using the values above.
5. Remove the temporary token authentication, delete its GitHub secret, and revoke the token.
6. Verify the next publish uses OIDC.
Do not put an npm token in the repository, a shell command, or a checked-in `.npmrc`.
The six pre-stable packages under `@bjacobso` are a superseded bootstrap line. After the
`@triplex-build` stable release is available, deprecate every old version with a message directing
consumers to its corresponding `@triplex-build` package. Do not unpublish the old artifacts.
## Documentation deployment
The VitePress site is deployed to Cloudflare as an assets-only Worker with the Effect-native
Alchemy stack in `alchemy.run.ts`. Preview the infrastructure diff before applying it:
```sh
pnpm docs:plan
pnpm docs:deploy
```
The production stage publishes , with
also available. The `triplex.build` zone must be active
in the deploying Cloudflare account; Alchemy attaches the Worker's custom domain, and Cloudflare
manages its DNS record and HTTPS certificate. Only the `prod` stage attaches the custom domain.
Local deployment uses the maintainer's authenticated Alchemy profile.
The CI workflow deploys documentation after every push to `main` (including PR merges), once
verification, package-consumer checks, documentation browser tests, and PostgreSQL integration
tests pass. Pull requests do not deploy. Production deployments are serialized and are not
canceled mid-deploy.
Configure these repository Actions secrets before the first CI deployment:
- `CLOUDFLARE_ACCOUNT_ID`: the same account used by the local production deployment.
- `CLOUDFLARE_API_TOKEN`: a dedicated deployment token scoped to that account and the
`triplex.build` zone. It needs account-level Workers Scripts Edit, Account Settings Read,
and Secrets Store Edit, plus zone-level Zone Read and Workers Routes Edit.
CI uses Alchemy's existing remote `Cloudflare.state()` store, so local and automated deployments
share the `triplex` stack's `prod` state. Secrets Store Edit is required to bind the state-store
secret when a fresh runner authenticates; Secrets Store Read alone is insufficient. See
[Cloudflare's Secrets Store permissions](https://developers.cloudflare.com/secrets-store/access-control/).
## Verify a canary
Install from the registry in a clean directory outside this monorepo:
```sh
pnpm init
pnpm add effect@4.0.3 \
@triplex-build/triplex@next \
@triplex-build/triplex-sqlite@next \
@triplex-build/triplex-cli@next
pnpm exec triplex --help
pnpm exec triplex --sqlite ./canary.sqlite describe
```
Also execute a small Effect program that asserts and queries a fact through SQLite. Confirm the
installed core contains only documented `dist` exports, there is one Effect runtime, and npm shows
provenance for every canary package.
## Stable release
Every public API change must include a Changeset. On pushes to `main`, the release workflow either
updates a version PR or publishes the already-versioned packages:
1. Review the generated versions, changelogs, lockfile changes, and package set in the release PR.
2. Require a green CI run, including PostgreSQL and the packed external-consumer test.
3. Merge the release PR.
4. Approve the `npm-publish` environment deployment.
5. Verify the `latest` dist-tags, provenance, package contents, Git tags, and GitHub releases.
`pnpm release:version`, `pnpm release:publish`, and `pnpm release:canary` are the corresponding
low-level commands. The stable commands are intentionally separated so the version changes are
reviewed before publication.
If a bad release is published, prefer a corrected patch and an npm deprecation notice. Do not
unpublish a package version except where npm's security guidance requires it.
---
Storage / Backend blueprints
# Your database. Triplex's memory.
Build on the storage primitives you already trust. Keep facts, time, versioned rules, and derived work behind one TypeScript API.
Triplex separates its application model from storage and query execution. These blueprints show
how a backend can provide that foundation: the data representation, the transaction boundary,
the query path, and the checks an implementation needs to pass.
[
Supported
**SQLite**
A working reference for durable, embedded storage. Follow the rows, indexes, and SQL transaction.
Explore SQLite →
](/sqlite)
[
Experimental
**FoundationDB**
Ordered keys and distributed transactions underneath Triplex's existing hexastore engine.
Explore FoundationDB →
](/foundationdb)
[
Proposed blueprint
**Datomic**
Assertion entities, immutable database values, and a design for bridging the transaction models.
Explore Datomic →
](/datomic)
[
Proposed blueprint
**XTDB**
A temporal SQL backend, with explicit fact identity and an atomic command submission design.
Explore XTDB →
](/xtdb)
**Supported** means the backend is in Triplex's baseline behavioral test boundary.
**Experimental** means an implementation exists but has outstanding operational gates.
**Proposed blueprint** means a design to investigate; no adapter or installable integration exists.
The [current state](/current-state#backend-maturity) remains the maturity contract for implemented
backends, including PostgreSQL and Cloudflare.
## One application model, two implementation paths
| Your backend supplies | Composition path | You implement | Triplex supplies |
| ---------------------------------------------------------------------- | ---------------- | ------------------------------------ | -------------------------------------------------------- |
| Ordered byte keys, bounded range scans, atomic read/write transactions | `Runtime.fromKv` | `KvBackend` | Hexastore storage and Datalog execution |
| Fact-oriented or relational storage with its own execution model | `Runtime.define` | `StorageAdapter` and `QueryExecutor` | The public `Triples` service and higher-level primitives |
Both paths must preserve atomic facts and journal writes, ordered commit positions, unique command
receipts, bitemporal reads, and snapshot-stable query pages. Sharing a query language or a temporal
vocabulary alone does not establish those guarantees.
Configuration releases, decision proofs, and derivations use Triplex's existing services on top of
that contract. Your application still owns authorization, workflow effects, timer delivery, and
the meaning of reconciled work. [Host integration](/host-integration) describes that boundary.
## The same correction should mean the same thing
Use the [homepage's checked scenario](/#one-scenario) as the first design exercise. Maria's training
was recorded as valid from March 1 until June 1. An auditor later corrects its start date to March 5.
Every backend must retain the original assertion, record its retraction, and create a new assertion
in one command. A March 3 query using the earlier recorded basis must still show training. The
same query using current knowledge must show the gap. The placement's receipt must still point
to the configuration release that governed it, and an already-open query cursor must retain its
original results even after the correction.
## Build a backend
1. Choose an isolated physical database or keyspace and bind a `DatabaseScope` to it. Scope is
cursor identity; physical partitioning and authorization are separate host responsibilities.
2. Implement the base storage and query contract with `Capabilities.none`. Establish rollback,
duplicate-command behavior, typed values, history, and pinned pages before adding projections.
3. Run `runtimeConformance` from `@triplex-build/triplex-testkit` against a fresh isolated database.
Add backend-specific concurrency, interruption, crash, and migration exercises.
4. Add durable snapshot or change-emission providers explicitly if your deployment needs them.
Measure their failure behavior separately from source transaction correctness.
The [custom runtime guide](/custom-runtimes) contains checked composition examples and the full
adapter contract. Passing the shared corpus establishes behavioral compatibility within its
coverage; production durability and operational readiness need additional evidence.
Have a backend in mind? [Open a design issue](https://github.com/bjacobso/triplex/issues/new) with
its transaction model, proposed fact representation, and a plan for preserving this correction.
Start with a small conformance-backed implementation, then extend it.
---
Backend blueprint / SQLite
# Building Triplex on SQLite
An embedded database underneath an embedded fact model. Durable history, attributed commands, and versioned rules in one local file.
Supported durable baseline · Existing adapter
SQLite is the working reference for Triplex's SQL storage path. Use it for local applications,
single-process services, and a durable first implementation of a back-office domain. It is in the
default shared conformance suite. Triplex remains pre-1.0; see [current state](/current-state).
[Run it →](#run-it)
[Follow the storage mapping →](#storage-mapping)
[All blueprints →](/backends)
## Run it
Install the SQLite adapter with the compatible Effect version on Node.js 22 or newer:
```sh
npm install @triplex-build/triplex @triplex-build/triplex-sqlite effect@4.0.3
```
This writes a training fact and reads it through the same `Triples` service used by other backends:
```ts
import { Effect } from "effect";
import { EntityId, Triples, string } from "@triplex-build/triplex";
import { SqliteTriples } from "@triplex-build/triplex-sqlite";
const DatabaseLive = SqliteTriples.layer({ filename: "training.db" });
const maria = EntityId.make("worker:maria");
const program = Effect.gen(function* () {
const triples = yield* Triples;
const receipt = yield* triples.transact(
[
{
op: "assert",
entityId: maria,
attribute: ":evidence/site-safety",
value: string("site:harbor"),
validFrom: Date.parse("2026-03-01T00:00:00Z"),
validTo: Date.parse("2026-06-01T00:00:00Z"),
},
],
{ actor: "user:dana", commandId: "training:maria-harbor:1" },
);
const facts = yield* triples.entity(maria, {
validAt: Date.parse("2026-03-03T00:00:00Z"),
});
return { position: receipt.position, facts };
});
await Effect.runPromise(program.pipe(Effect.provide(DatabaseLive)));
```
Re-running this command raises `CommandAlreadyCommittedError` with the original transaction ID.
Handle that tag and call `transactionByCommand` with the same command ID to load the durable
receipt; the retry writes no second fact or journal entry.
Create the layer once for the database's intended lifetime. The convenience layer owns the SQLite
client, configures WAL mode, and runs the ordered Triplex migrations. Production hosts that own
DDL can use `makeSqliteLayerUnmigrated`, `makeSqliteAdapter({ autoMigrate: false })`, and the shared
SQL migration entrypoints. See [host integration](/host-integration).
## Storage mapping
| Triplex contract | Existing SQLite representation |
| ----------------------------- | -------------------------------------------------------------------------------------------- |
| Fact identity and typed value | One `triples` row per `TripleId`, with entity, attribute, value tag, and typed value columns |
| Recorded and valid time | `recorded_at`, `retracted_at`, `valid_from`, and `valid_to` |
| Exact commit visibility | `recorded_position` and `retracted_position` on each row |
| Ordered command boundary | Transactional `triplex_commit_position` row |
| Retry identity | Unique command claim in `triplex_command_receipts` |
| Attributed journal | `_Transaction` facts and typed changes stored with the application writes |
| Entity projections | Content-addressed blobs and entity snapshot tables, when composed with snapshot services |
| Datalog | Shared SQL compiler/executor with the SQLite dialect |
The physical schema stores each assertion separately. Domain attributes such as
`:evidence/site-safety` remain data, so applications do not need a physical table per entity type.
Indexes cover entity lookup, attribute/value lookup, references, temporal reads, and commit
visibility. The additive v2 migration adds the shared numeric range index.
## One command, one SQL transaction
The adapter executes Triplex's transaction callback on the ambient Effect SQL transaction.
Command claiming, position allocation, constraint checks, fact changes, and journal changes belong
to that same boundary. A rejected command must leave none of them committed.
For Maria's correction, the adapter marks the old assertion's retraction time, transaction, and
position, then inserts the corrected assertion and journal changes. The assertion row is retained.
Historical reads filter both clocks; query pages additionally use the pinned commit position so
later retractions cannot remove a fact from an older page.
Keeping timestamps and positions separate matters when multiple commands share a recorded
millisecond. The cursor contract relies on exact commit visibility.
## Read the implementation
Follow these modules in order:
1. [SQLite layer and resource lifetime](https://github.com/bjacobso/triplex/blob/main/packages/sqlite/src/SqliteLayer.ts)
2. [SQLite storage adapter](https://github.com/bjacobso/triplex/blob/main/packages/sqlite/src/SqliteAdapter.ts)
3. [Shared SQL schema](https://github.com/bjacobso/triplex/blob/main/packages/sql/src/schema.ts) and
[migrations](https://github.com/bjacobso/triplex/blob/main/packages/sql/src/migrations.ts)
4. [Triples composition](https://github.com/bjacobso/triplex/blob/main/packages/sqlite/src/SqliteTriples.ts)
Run `pnpm --filter @triplex-build/triplex-sqlite test` from a repository checkout for the adapter's
tests; `pnpm --filter triplex-integration-tests test` runs the shared backend corpus. Conformance is a
behavioral boundary, not a promise about every workload or storage failure. Backup, restore,
deployment migrations, and process lifecycle belong to your application.
Continue with the [SQLite quickstart](/getting-started#use-durable-sqlite), or use this implementation
as a reference while [building a custom runtime](/custom-runtimes).
---
Backend blueprint / FoundationDB
# Building Triplex on FoundationDB
Let FoundationDB keep the ordered keys. Let Triplex turn them into facts, history, and explainable work.
Experimental · Private workspace package
An adapter already exists in this repository. It requires compatible native FoundationDB client
libraries and is held from npm publication. Native integration tests are opt-in; it is outside the
default CI matrix. Evaluate from a source checkout with locked dependencies.
[Compose the runtime →](#compose-the-runtime)
[Follow the storage mapping →](#storage-mapping)
[All blueprints →](/backends)
## Why these primitives fit
FoundationDB exposes an ordered key-value store with atomic transactions and range reads.
Transaction retries handle optimistic conflicts; transaction size and lifetime limits shape how
much work can fit in one command. See its [developer guide](https://apple.github.io/foundationdb/developer-guide.html).
That matches Triplex's `KvBackend` path. The backend handles bytes and atomicity; Triplex's existing
hexastore handles fact indexes, temporal visibility, Datalog, command receipts, and the journal.
The design avoids implementing a second query engine.
## Storage mapping
| Triplex needs | FoundationDB adapter responsibility |
| -------------------------------------------- | -------------------------------------------------------------------------------- |
| Ordered byte keys | Preserve lexicographic ordering within an isolated subspace |
| Pattern index scans | Implement inclusive start, exclusive end, direction, and limit |
| Atomic fact and journal writes | Run every operation in the callback on the same native transaction |
| Ordered commit positions and unique receipts | Preserve the KV engine's counter and claim read/write conflicts |
| Abort behavior | Discard all pending mutations when the callback fails |
| Large reads | Page range reads without changing the requested scan semantics |
| Invalidation hints | Optional FoundationDB watches; consumers still catch up from the durable journal |
Hexastore keys and their encoding are implementation details. A backend implements `KvBackend`;
it does not introduce a portable on-disk hexastore format. Persistent-data upgrades need their own
rehearsal.
## Compose the runtime
This checked example uses existing repository exports. Run it only in a checkout with the native
client and a configured FoundationDB cluster. It is not an npm installation recipe.
```ts
import { Effect } from "effect";
import { EntityId, Triples } from "@triplex-build/triplex";
import { Capabilities, DatabaseScope, Runtime } from "@triplex-build/triplex/runtime";
import { makeFdbKvBackend } from "@triplex-build/triplex-foundationdb";
const FoundationDbRuntime = Runtime.fromKv({
name: "foundationdb",
backend: makeFdbKvBackend({ subspace: Buffer.from("triplex/training/") }),
});
const DatabaseLive = FoundationDbRuntime.layer({
scope: DatabaseScope.make({
env: "dev",
tenant: "example",
database: "training",
generation: 0,
}),
capabilities: Capabilities.none,
});
const program = Effect.gen(function* () {
const triples = yield* Triples;
return yield* triples.entity(EntityId.make("worker:maria"));
});
await Effect.runPromise(program.pipe(Effect.provide(DatabaseLive)));
```
The existing `FdbTriples.layer({ subspace })` is also a convenience composition. A nonempty
subspace is required by default, and clears stay within it. Choose physically disjoint subspaces
for separate databases; `DatabaseScope` binds cursors and does not partition cluster keys.
`Capabilities.none` deliberately selects the base store. Durable KV entity snapshot persistence
is not supplied automatically. Add a database-owned snapshot provider or a change emitter only
after testing those capabilities separately.
## One correction across every index
Maria's correction touches the old assertion's visibility, the new assertion, pattern indexes,
the command claim, the commit position, and the journal. All those mutations must share one
FoundationDB transaction, so a failed correction leaves the old belief intact.
A retried callback must keep effects inside storage. Sending an email or advancing a host workflow
inside it would repeat on conflict. Use the committed journal to drive host-owned effects.
Splitting one logical command across native transactions would lose Triplex's atomicity.
The adapter's bulk `setAll` helper can chunk large loads; those chunks are separate commits.
It must not be used to make an oversized atomic Triplex command appear supported. Establish and
measure command limits before production use.
## Verify and extend the adapter
Start with the [KV adapter](https://github.com/bjacobso/triplex/blob/main/packages/foundationdb/src/FdbKvBackend.ts)
and [package guide](https://github.com/bjacobso/triplex/blob/main/packages/foundationdb/README.md).
Run `pnpm test:foundationdb:integration` with the native client and a disposable cluster. Use the
[shared runtime conformance helper](/custom-runtimes#correctness-contract-and-conformance) against
an isolated subspace as well.
Before promoting this backend, establish multi-process contention behavior, interruption and
lost-acknowledgement handling, crash recovery, persistent-key upgrades, and measured transaction
limits. A command interrupted during submission must not leave an unexplained commit; receipts
must resolve any uncertain outcome. These are additional gates beyond the shared corpus.
See [current state](/current-state#backend-maturity) for maturity and [custom runtimes](/custom-runtimes)
for the complete KV and capability contract.
---
Backend blueprint / Datomic
# Building Triplex on Datomic
A fact-oriented foundation for a TypeScript application that needs to remember which rules governed every decision.
Proposed blueprint · No adapter exists
This page proposes an implementation to investigate. There is no Datomic package, running
integration, or compatibility claim. The mappings below are design choices to validate against
Triplex's public adapter contract.
[Explore the mapping →](#storage-mapping)
[Build the first slice →](#build-the-first-slice)
[All blueprints →](/backends)
## Why Datomic
Datomic offers datoms, Datalog, transaction entities, immutable database values, historical views,
and ordered log access. Those primitives are useful ingredients for a Triplex adapter.
[Datomic's best practices](https://docs.datomic.com/reference/best.html) describe their use for
consistent reads and audit queries.
On top of an adapter, Triplex would supply its TypeScript domain model, immutable configuration
releases, pinned command provenance, and derivation reconciliation. Datomic would own persistence
and native query execution. A TypeScript-to-JVM client boundary would be part of the deployment;
choose and document that bridge before promising an embedded integration.
## Storage mapping
The proposed starting point is **one Datomic entity per Triplex assertion**, with a fixed adapter
schema. A direct mapping from a domain triple to one native datom cannot by itself preserve
independent `TripleId`s and valid-time intervals for repeated assertions of the same value.
| Triplex contract | Proposed representation |
| ---------------------------- | ---------------------------------------------------------------------------------------------------- |
| Stable entity and triple IDs | Original strings stored as adapter identity attributes; native entity IDs stay internal |
| Fact value | Assertion entity with domain entity ID, attribute keyword, entity type, value tag, and typed payload |
| Assertion history | Immutable assertion identity and payload; explicit assertion transaction and recorded position |
| Retraction | Retraction metadata on the assertion entity, retaining the original payload and valid interval |
| Valid time | Explicit `validFrom` / `validTo` fields filtered on every relevant read |
| Command receipt | A scoped command identity claimed atomically without overwriting an earlier receipt |
| Commit order | A per-logical-database position counter advanced in the same transaction as facts and journal |
| Causal journal | Triplex transaction envelopes and typed changes stored atomically with the command |
| Query execution | Translate validated Triplex Datalog to joins over assertion entities and shared temporal filters |
Keep Triplex's recorded timestamp and commit position explicit. Datomic's native transaction
coordinate can help identify an exact database value, but it is not a drop-in implementation of
`nextCommitPosition()`, which Triplex calls before inserting the transaction's rows.
## Bridge the transaction models
Triplex's `StorageAdapter.withTransaction` accepts an Effect callback whose reads and writes must
share an atomic view, including pending changes. Datomic accepts declarative transaction data;
its transaction functions inspect `db-before` and return additional data, without seeing one
another's output. An arbitrary TypeScript callback cannot simply become a native transaction
function. [Datomic transaction functions](https://docs.datomic.com/transactions/transaction-functions.html)
define that boundary.
One candidate design is an optimistic staging adapter:
1. Acquire one immutable database value, read the logical database's position and existing receipt,
and run the callback against that value plus an in-memory overlay of pending mutations.
2. Stage constraint checks, a new receipt, assertion/retraction metadata, the next position, and
the complete journal. Reads during the callback must see the overlay consistently.
3. Submit the staged changes with a compare-and-swap from the observed position to the next one.
Every writer of that logical database's source data must participate in this guard.
4. On a conflict, discard the staged result, acquire a fresh database value, and re-evaluate.
For a duplicate, `claimCommand` must return the original transaction ID so core raises
`CommandAlreadyCommittedError`; callers can load its receipt with `transactionByCommand`.
Publish a successful callback result only after confirmed commit.
This is a proposal, not an implemented algorithm. Test retries, typed failures, defects,
interruption, and uncertain submission outcomes before calling it conformant. Constraint queries
must read the same protected basis; direct writes that bypass the guard would invalidate the
design. A native transaction function could instead perform compatible checks, but translating
Triplex's enforcement logic would be additional work.
## Preserve both clocks and the query contract
For Maria's correction, stage a retraction of the March 1 assertion and a new March 5 assertion,
with distinct triple IDs, in the same command. Retain the old payload. The earlier recorded basis
must still expose the original training interval, while current knowledge exposes the gap on
March 3. Datomic's historical view alone does not implement the explicit valid-time filtering in
this proposed assertion schema.
Start with patterns, joins, and temporal filters, then implement the complete validated Triplex
query surface. Verify typed scalar comparisons, negation, aggregates, the constrained recursive
form, deterministic ordering, pagination, and `explain`. Identical-looking Datalog clauses do not
prove equivalent execution.
Pinned pages need the original query, temporal basis, database scope, and exact commit boundary.
Use retained assertion/retraction positions or a verified mapping to native database values;
later corrections must not change which rows an earlier cursor sees. Keep the Triplex journal's
typed changes and provenance even if native log access is used to locate commits.
## Build the first slice
1. Specify the JVM bridge, assertion schema, typed codecs, and physical database isolation.
2. Prototype the staged transaction boundary first: one correction, two competing writers, one
duplicate command, and one rejected constraint. Establish that failures leave no partial state.
3. Implement `StorageAdapter` and `QueryExecutor`, then compose them with `Runtime.define` and
`Capabilities.none`. See the [checked runtime skeleton](/custom-runtimes#runtime-definitions).
4. Run [runtime conformance](/custom-runtimes#correctness-contract-and-conformance) on a fresh
database. Extend it with bridge disconnects, lost commit acknowledgements, process restarts,
and schema upgrades.
Configuration and derivation services can then use the same `Triples` layer. Durable entity
snapshots and change emitters are separate capabilities to implement and verify.
[Open a Datomic design issue](https://github.com/bjacobso/triplex/issues/new) with a transaction
prototype and its test results, or compare the [working SQLite reference](/sqlite) and
[XTDB proposal](/xtdb).
---
Backend blueprint / XTDB
# Building Triplex on XTDB
A temporal database underneath a temporal application model. Keep the history, then connect it to the rules and work it explains.
Proposed blueprint · No adapter exists
This is a design to investigate for current XTDB, using SQL and its temporal primitives.
It is not an XTDB 1.x Datalog integration. No Triplex XTDB adapter or installable package exists;
the mappings below need a prototype and conformance evidence.
[Explore the mapping →](#storage-mapping)
[Build the first slice →](#build-the-first-slice)
[All blueprints →](/backends)
## Why XTDB
XTDB tracks valid and system time and exposes SQL and XTQL for querying historical data.
That makes late evidence and corrections a natural area to explore.
[XTDB's overview](https://docs.xtdb.com/intro/what-is-xtdb.html) describes its temporal and relational
model.
Triplex would add its TypeScript-facing fact identity, configuration releases, attributed
command receipts, decision provenance, and derived work on top of an adapter. The shared temporal
vocabulary is a starting point; the exact representation and transaction behavior still need
to match Triplex.
## Storage mapping
Start with a compatibility representation: one row per Triplex assertion, retaining explicit
Triplex time fields and retraction metadata. Explore native temporal pushdown after that version
passes the behavioral corpus.
| Triplex contract | Proposed XTDB representation |
| ---------------------------- | --------------------------------------------------------------------------------------------------- |
| Fact identity | Assertion row keyed by `TripleId`, retaining the original domain `EntityId` |
| Typed values | Fixed assertion columns for the value tag and typed payload |
| Recorded and valid intervals | Explicit Triplex timestamp fields initially; native temporal optimization requires a proven mapping |
| Retractions and history | Retained assertion payload with retraction transaction, timestamp, and position |
| Ordered commit boundary | Per-logical-database position record advanced with an atomic expected-position check |
| Retry identity | Scoped command-receipt record whose absence is checked in the submitted command |
| Attributed journal | Triplex transaction envelope and typed changes written alongside source facts |
| Datalog | A dedicated translator/executor from validated Triplex queries to XTDB SQL |
The first version should keep adapter records visible independently of the domain fact's valid
interval. Applying native default valid-time filtering to a journal or receipt could hide it when
a user queries a different business date. Choose explicit temporal clauses for every adapter
table and test historical reads; do not rely on the backend's implicit "now" basis.
An existing PostgreSQL client connection does not establish compatibility with Triplex's
PostgreSQL adapter. XTDB needs its own storage implementation, transaction handling, and verified
query dialect. The [SQLite reference](/sqlite) illustrates the responsibilities to cover.
## Submit one atomic command
XTDB write transactions are non-interactive: DML and assertions form a submitted atomic batch,
and cannot be interleaved with result-returning `SELECT`s. Triplex's Effect callback expects
reads and pending writes on one view. See [XTDB transaction consistency](https://docs.xtdb.com/about/txs-in-xtdb.html).
One proposed bridge is to evaluate the callback against a stable read basis plus a staged overlay,
then submit an atomic batch:
1. Read the logical database's position and command receipt on one consistent basis.
2. Evaluate enforcement and pending reads using that basis plus all staged assertion and
retraction changes. Build the journal and callback result without publishing them.
3. Submit an `ASSERT` guarding the expected position and command claim, then the position update,
receipt, facts, and journal in the same write batch. XTDB's
[SQL transaction reference](https://docs.xtdb.com/reference/main/sql/txs.html) describes `ASSERT`.
4. Confirm successful processing before returning the receipt. On a conflicting position,
reacquire a basis and re-evaluate. An existing command claim must return its original
transaction ID to core, which raises `CommandAlreadyCommittedError`; callers then resolve
the durable receipt with `transactionByCommand`.
The guard must cover every source writer for that logical database. Prototype overlay reads,
retry behavior, interruption, and rejection before treating this approach as an implementation
of `withTransaction`. Native serial submission alone does not protect a constraint checked on an
older client-side read.
## Preserve a stable read boundary
XTDB distinguishes read snapshot tokens from await tokens used to ensure a node has processed
earlier writes. Reusing a snapshot and clock fixes the read basis; waiting for a write alone does
not pin future reads. [XTDB's basis documentation](https://docs.xtdb.com/about/txs-in-xtdb.html#repeatable-queries-basis)
explains the distinction.
Map Triplex's commit position to a reproducible source boundary, keep that boundary on every
page, and ensure pooled connections observe confirmed commits. Define how a position is mapped
to a native snapshot or reproduced from retained assertion/retraction positions. Timestamp-only
pagination cannot establish the same contract.
Maria's correction is the first useful exercise. Retain the original March 1 assertion, mark its
retraction, and assert the March 5 replacement atomically. Earlier recorded knowledge must still
show training on March 3, current knowledge must show the gap, and a cursor opened before the
correction must keep its original results. The governing release stays on the placement receipt.
Native system time and Triplex's recorded time must be mapped explicitly, including timestamp
precision, same-millisecond commits, open interval ends, and imported or scheduled valid times.
Any native temporal optimization must preserve the reference behavior of direct reads, Datalog,
history, and derivation wakeups.
## Build the first slice
1. Specify the supported XTDB version, driver, typed codecs, table temporal policy, and physical
database isolation. Record the mapping of Triplex positions to consistent read boundaries.
2. Prototype atomic command submission before the query compiler: correction, failed assertion,
competing writers, duplicate commands, and read-after-write across two connections.
3. Implement `StorageAdapter` and a dedicated `QueryExecutor`, including validation, joins,
negation, aggregation, constrained recursion, stable ordering, pagination, and `explain`.
4. Compose with `Runtime.define` and `Capabilities.none`, then run
[runtime conformance](/custom-runtimes#correctness-contract-and-conformance) against fresh
storage. Add delayed processing, lost acknowledgements, restart, and upgrade exercises.
Once the base layer is coherent, Triplex's configuration and derivation services can reuse it.
Snapshot and emission capabilities need their own providers and failure tests.
[Open an XTDB design issue](https://github.com/bjacobso/triplex/issues/new) with the submission
prototype and read-boundary evidence, or compare the [Datomic proposal](/datomic).
---
# Custom runtimes
`@triplex-build/triplex/runtime` is the public adapter-authoring surface in this source checkout.
It provides `Runtime.define`, `Runtime.fromKv`, `DatabaseScope`, and declarative capabilities.
These APIs are pre-1.0; their availability here does not imply a published stable release.
For application-level database composition, see [Host integration](/host-integration). This page
describes implementing a storage or execution backend. Workflows, authorization, routing, timers,
and external effects remain host responsibilities.
For concrete storage mappings and implementation plans, explore the [backend blueprints](/backends):
[SQLite](/sqlite) is a working reference, [FoundationDB](/foundationdb) is experimental, and
[Datomic](/datomic) and [XTDB](/xtdb) are proposed designs with no adapters yet.
## Runtime definitions
A definition supplies Effect layers. Binding a database identity produces a layer providing the
existing `Triples` service:
```ts
import { Effect, Layer } from "effect";
import { EntityId, Triples } from "@triplex-build/triplex";
import {
Runtime,
DatabaseScope,
Capabilities,
StorageAdapter,
QueryExecutor,
} from "@triplex-build/triplex/runtime";
declare const storage: Layer.Layer;
declare const queries: Layer.Layer;
const CustomRuntime = Runtime.define({ name: "custom", storage, queries });
const DatabaseLive = CustomRuntime.layer({
scope: DatabaseScope.make({
env: "prod",
tenant: "acme",
database: "main",
generation: 3,
}),
capabilities: Capabilities.none,
});
const program = Effect.gen(function* () {
const triples = yield* Triples;
return yield* triples.entity(EntityId.make("customer:alice"));
}).pipe(Effect.provide(DatabaseLive));
```
The declared layers stand for your implementations. Already-built services can be supplied with
`Layer.succeed`; the builder accepts layers only.
Backend acquisition errors and external requirements are preserved. A layer needing an ambient
`SqlClient` leaves that dependency on the database layer. `Runtime.define` also exposes
initialization's `MigrationError`. Capability-provider errors propagate, and invalid capability
dependencies produce `CapabilityError`. Unexpected exceptions in custom wrappers remain defects.
### Public contracts
| Service | Responsibility |
| -------------------- | -------------------------------------------------------------------------------------------- |
| `StorageAdapter` | Atomic transactions, commit positions, command receipts, triple reads/writes, initialization |
| `QueryExecutor` | Validated Datalog execution, pagination, explain |
| `TripleStoreRuntime` | Database scope, clock, triple IDs, transaction IDs |
The runtime subpath exports their service interfaces, `TripleRow`, `TripleInput`, `QueryPattern`,
query result types, adapter errors, snapshot and emission contracts, and `StoreCapability`.
`TriplesLive`, SQL compilation functions, and hexastore key encoders remain implementation details.
`StorageAdapterService` has no `rawQuery` requirement. SQL adapters can implement the separate
`SqlStorageAdapterService` refinement exported by `@triplex-build/triplex-sql`.
For SQL execution, `makeSqlQueryExecutorLayer(runner, dialect)` captures the dialect at construction.
Custom runtime authors do not need `CurrentDialect`. The older `SqlQueryExecutorLive` retains its
ambient-dialect behavior for existing callers.
### KV backends
Implement ordered key-value operations and let Triplex supply the hexastore query engine:
```ts
import { Layer } from "effect";
import { Runtime, KvBackend, DatabaseScope, Capabilities } from "@triplex-build/triplex/runtime";
declare const backend: Layer.Layer;
const CustomKvRuntime = Runtime.fromKv({ name: "custom-kv", backend });
const DatabaseLive = CustomKvRuntime.layer({
scope: DatabaseScope.test("custom-kv"),
capabilities: Capabilities.none,
});
```
This supports the `KvBackend` contract and shared `Triples` behavior. It does not make the hexastore
byte encoding a public API or promise that independently implemented engines can share its
persisted keys. Rehearse upgrades against persistent KV data. `KvTriples.layerBackend` remains
available for existing low-level compositions.
## Database identity
`DatabaseScope.make` requires nonempty environment, tenant, and database strings, plus a
nonnegative safe-integer generation. A versioned JSON tuple avoids collisions from concatenating
strings with separators. Every field participates in the cursor identity.
There is no implicit scope. The builder validates JavaScript callers as well as enforcing the
brand in TypeScript. `DatabaseScope.test(name)` is repeatable, not globally unique: use it only
for isolated fixtures. Older internal helpers retain legacy string defaults; the public builder
does not use them as database identities.
The host must map an authorized caller to the correct physical database and identity. A scope
binds pagination cursors; it neither creates a tenant partition nor authorizes access. Never reuse
an identity for different databases, and advance generation when replacing a database.
## Capabilities
Omitting `capabilities` selects `Capabilities.default`: entity snapshots and change emission.
**The default requires `SnapshotService`, `SnapshotWriter`, and `ChangeEmitter`.** Missing
providers remain visible in the Effect layer's requirements. The builder does not silently
substitute an in-memory snapshot database or discard change events.
Configure persistence and emission explicitly when constructing them against the raw store:
```ts
import { Effect } from "effect";
import {
Capabilities,
DatabaseScope,
entitySnapshots,
changeEmission,
type ChangeEmitterService,
} from "@triplex-build/triplex/runtime";
import { SqlSnapshotsLive } from "@triplex-build/triplex-sql";
import { makeCloudflareRuntime, type DOState } from "@triplex-build/triplex-cloudflare";
declare const state: DOState;
declare const emitter: ChangeEmitterService;
const DatabaseLive = makeCloudflareRuntime(state).layer({
scope: DatabaseScope.make({ env: "prod", tenant: "acme", database: "main", generation: 3 }),
capabilities: Capabilities.of(entitySnapshots(SqlSnapshotsLive), changeEmission(emitter)),
});
// The builder supplies its adapter and raw Triples to the snapshot layer.
// This layer provides Triples, SnapshotService, and SnapshotWriter.
const ready = Effect.void.pipe(Effect.provide(DatabaseLive));
```
`entitySnapshots(layer)` receives the raw `Triples` and the definition's backend services during
construction. `Runtime.define` makes its `StorageAdapter` and `QueryExecutor` available there;
`Runtime.fromKv` makes its `KvBackend` available. Snapshot persistence must belong to that same
database. Other requirements stay on the returned layer.
`SqlSnapshotsLive` uses the SQL adapter's snapshot tables. KV authors must provide their own
snapshot reader/writer layer, use only `changeEmission`, or explicitly select `Capabilities.none`.
An automatic durable KV snapshot implementation is not included.
Additional options:
- `Capabilities.none`: expose the base store with no snapshot or emission wrappers.
- `Capabilities.of(...)`: combine providers, retaining their output, error, and dependency types.
- `Capabilities.custom(...wrappers)`: install `StoreCapability` implementations. Names must be
unique, dependencies must exist, and higher priorities wrap lower priorities.
- `NoopChangeEmitter`: explicitly discard invalidation hints when appropriate.
Snapshots are post-commit projections. A snapshot failure returns a `WriteError` even though
the source facts have committed; use command receipts to resolve retries. Change emission runs
inside the snapshot wrapper, and emission failures are swallowed because the journal is the
durable source of truth. An emitted event does not prove the snapshot is already current.
Snapshot transaction wrappers use the committed journal's actual changes, including broad
pattern retractions. Custom capability behavior needs separate tests.
## Deterministic fixtures
```ts
import { Layer } from "effect";
import { Runtime, KvBackend, DatabaseScope, Capabilities } from "@triplex-build/triplex/runtime";
declare const freshBackend: Layer.Layer;
const TestRuntime = Runtime.fromKv({ name: "test-kv", backend: freshBackend });
const TestDatabase = TestRuntime.layer({
scope: DatabaseScope.test("checkout"),
capabilities: Capabilities.none,
deterministic: { startTime: 0, seed: "checkout" },
});
```
This reuses Triplex's deterministic clock and ID generator. `startTime` is a fixed clock value,
not an automatically advancing clock. Counters restart on a fresh build. Use a fresh isolated
backend too: restarting deterministic IDs against existing data is unsafe. Determinism covers
runtime-owned metadata; migrations and custom providers may have their own clocks.
## Lifecycle and ownership
1. Supplied layers acquire resources through Effect's scoped acquisition APIs.
2. `Runtime.define` calls `adapter.initialize()` before installing capabilities. Initialization
failures remain typed and release scoped resources.
3. Capability providers receive the same raw store; snapshot materialization cannot recursively
invoke the decorated store through the builder.
4. Closing the runtime's Effect scope runs the supplied layers' finalizers, including when
initialization or capability construction fails.
The builder **does not call `adapter.close()`**. The adapter layer owns release and should register
it through `Effect.acquireRelease` if it owns the connection. Calling both a layer finalizer and
`close()` could close a shared host connection twice. Existing SQL adapters leave connection
ownership to their SQL-client layer.
Initialization is not necessarily migration: host-managed adapters can supply a check or a no-op
after a separate deployment migration. `Runtime.fromKv` relies on its backend layer for
initialization. Keep each database runtime alive for its intended lifetime; repeatedly constructing
an ephemeral backend creates new databases.
## Correctness contract and conformance
Adapter authors must preserve these guarantees:
- `withTransaction` rolls back facts, retractions, journal metadata, command claims, and commit
position allocation together on typed failures, defects, or interruption. Concurrent writers
cannot observe partial commits or invalidate enforced preconditions.
- `nextCommitPosition` allocates an ordered position inside that transaction. Committed positions
strictly increase; allocation participates in rollback. `currentCommitPosition` identifies a
consistent committed read boundary.
- `claimCommand` reserves a command identity atomically. A competing duplicate sees the original
transaction ID; an aborted claimant cannot leave a durable receipt.
- Temporal reads honor the resolved recorded/valid basis, assertion and retraction boundaries,
and commit positions used for pagination. Results cannot drift while traversing a pinned page.
- Query executors validate inputs and preserve typed-value, join, ordering, and pagination
semantics. Use the exported Datalog validation helpers before backend execution.
- KV scans use lexicographic byte ordering, inclusive start and exclusive end bounds, and the
requested direction and limit. Transaction callbacks operate on one atomic view.
Run the shared suite in one expression using testkit:
```ts
import { Effect } from "effect";
import { DatabaseScope, Capabilities } from "@triplex-build/triplex/runtime";
import { makeCloudflareRuntime, type DOState } from "@triplex-build/triplex-cloudflare";
import { runtimeConformance } from "@triplex-build/triplex-testkit";
declare const isolatedState: DOState;
await Effect.runPromise(
runtimeConformance(makeCloudflareRuntime(isolatedState), {
scope: DatabaseScope.test("cloudflare-conformance"),
capabilities: Capabilities.none,
}),
);
```
The helper belongs to testkit rather than `Runtime.conformance`, so core has no dependency on its
own tests. It runs `makeTriplesConformanceSuite` against the supplied definition and options. Use a
fresh database: the suite writes fixtures and does not provision or clear storage.
The corpus covers atomic writes, temporal reads, Datalog, pagination, the journal, receipts,
checkpoints, derivations, and graph constraints. **Passing is not full compliance.** It does not
establish production crash durability, cross-process isolation, resource ownership, migration
safety, or complete snapshot/emitter correctness. This repository also tests runtime cleanup and
types, Cloudflare snapshot persistence and pattern retractions, projection failure after commit,
and change events. Test those boundaries for each new adapter.
## Existing backends and release sequencing
`makeCloudflareRuntime` is the executable example: the Cloudflare package now uses public core
and SQL exports. Its additive v2 migration creates snapshot tables for existing v1 databases; v3 adds the numeric range index.
It does not backfill existing entities; use `SnapshotWriter.backfill()` if those projections are needed.
`CloudflareTriples.layer` remains a convenience API with an explicit `Capabilities.none`; its old
string scope is wrapped in the new versioned identity format. Previously issued Cloudflare cursors
must be restarted after upgrading.
SQLite and PostgreSQL convenience layers retain their existing behavior. The SQL database manager
still supplies its existing snapshots and emitter. The builder makes capability selection explicit
for new compositions; it does not enable capabilities on every legacy entry point.
See [Roadmap](/roadmap) for remaining release gates. Public composition support does not promote
experimental Cloudflare or FoundationDB deployments to production-ready backends.
---
# Triplex roadmap
This document outlines strategic enhancements to make `@triplex-build/triplex` a world-class, open-source triple store and Datalog engine.
For the implemented surface, backend maturity, and release status, start with
[`current-state.md`](current-state.md). Items below are future work unless explicitly marked
delivered.
The operational foundation and its ordering constraints are specified in
[`operational-primitives.md`](operational-primitives.md). Atomic KV transactions,
compare-and-retract conditions, causal transaction envelopes, and per-transaction change journals
are implemented, including a backend-issued commit cursor and resumable transaction pages. The
portable derivation candidates and their immutable, freshness-aware materialization runs are also
implemented, including durable temporal wakeups for future-effective and expiring evidence. The
wrapped Datalog API now has typed, scope-bound, commit-position-stable keyset cursors. Fixed
attribute dependency sets now have indexed freshness positions and temporal schedules across KV,
SQLite, and PostgreSQL. Graph constraints are content-addressed, produce durable queryable
observations, and can be enforced atomically across their full valid-time intervals. Command
receipts are atomically unique and durable consumer checkpoints are available through the
`operational` subpath. Indexed entity transaction timelines and PostgreSQL composition over an
ambient host-owned `SqlClient` or validated database-scoped pool are also delivered; relational
host rows, Triplex facts/journal, and outbox writes can share one Effect SQL transaction.
## Immediate release gate
- Delivered: a Changesets v3 GitHub workflow maintains the version PR, publishes stable releases,
and can manually publish `next` snapshots without committing snapshot versions.
- Delivered: package checks install all tarballs and exercise the CLI as an external consumer.
- Delivered: the PostgreSQL multi-connection isolation and shared conformance suite run in CI.
- Delivered: Cloudflare and FoundationDB are private experimental workspace packages and cannot be
included in the first release accidentally.
- Delivered: the GitHub repository cutover to `bjacobso/triplex`.
- Delivered: the initial version PR and the first publication of all seven public packages under
`@triplex-build`.
- Remaining: verify trusted publishing for all seven public packages with a tokenless `next`
release and test the registry packages in a clean external consumer.
## Immediate correctness gate: backend parity
- [Custom runtime composition](/custom-runtimes) now has public adapter contracts, structured
scopes, typed capability providers, and a public-only Cloudflare implementation. Before stable
adoption, validate each adapter's migrations, durability, isolation, and capability behavior;
the shared Triples conformance suite alone does not establish those guarantees.
- The conformance corpus now runs identical typed projections, scalar joins, predicates, negation,
booleans, ref joins, recursive transitive closure, grouped aggregation, bitemporal reads, and
snapshot-stable pagination against KV, SQLite, and PostgreSQL. Grow it with every query-engine
bug.
- Datalog conjunctions are clause-order independent across engines: positive relations establish
outer bindings and patterns inside negation establish local bindings before predicates run.
- Runtime query shape, truly unbound variables, result/aggregate target collisions, empty
disjunctions, undefined rules, unsupported recursive bodies, and wrapper projection references
now fail through one backend-neutral typed preflight. Projection and joins cover all seven stored
value types, and aggregate duplicate, distinct-count, and empty-input semantics are differential
tested. Wrapped filters now validate operand arity and use typed projection columns so numeric,
text-family, boolean, and null behavior agrees across backends. Direct ordering and keyset
pagination now share a typed total order for mixed scalar families, including null placement and
deterministic ties. Raw ordered predicates now accept only numeric-capable value bindings and
numeric constants, rejecting backend-dependent text and identity coercions during typed
preflight. Equality keeps entity, attribute, transaction, and rule identities string-typed rather
than leaking SQL coercion. Group-key equality in `having` uses those same scalar families, while
aggregate equality is numeric-only and optional projections are rejected before SQL compilation.
Pattern constants now use the same scalar-family identity through positive patterns, negation,
and disjunction, while typed refs remain exact. Pattern identity positions now reject non-string
constants before backend execution. The `?` prefix is reserved for schema-valid variables, and
constant projections retain typed primitive values through direct and wrapped execution.
Continue expanding hostile schema-valid input.
- SQL projection now carries hidden canonical scalar-family columns through execution, so strings
such as `"007"`, numeric-looking entity IDs and refs, datetimes, booleans, and JSON are decoded
without guessing. Distinctness, grouping, counts, and page boundaries use that same flattened
public identity across KV, SQLite, and PostgreSQL.
- Recursive SQL rule definitions, applications, optional projection attributes, and depth bounds
are parameterized; validated rule names are quoted identifiers. The portable recursive shape is
binary and identity-only. Same-named definitions union, repeated variables unify, and unsupported
recursion fails during typed preflight.
- Keep PostgreSQL labelled as a pre-1.0 production candidate while its now-required CI corpus gains
operational history. Keep FoundationDB and Cloudflare private and experimental until each passes
the shared conformance and differential suites.
## 1. Schema-Aware Constraints & Enforcement
**Goal:** Move from a "bag of facts" to a reliable, structured database.
- Delivered: independently content-addressed required, cardinality, uniqueness, and reference-target
nodes generated by the ontology DSL.
- Delivered: bitemporal read-only evaluation and immutable first-class findings through
`EntityValidation`, with shared KV/SQLite/PostgreSQL conformance coverage.
- Delivered: opt-in transaction enforcement that rejects new or worsened post-state violations at
every valid-time boundary and serializes concurrent absence/uniqueness decisions across KV,
SQLite, and PostgreSQL.
- Delivered: indexed constraint candidate loading by source entity type and referenced target type,
with batched subject expansion instead of a full live-fact scan.
- Graph constraints remain separate from value-local `TypeExpr` definitions.
- Next: explicitly modelled general Datalog invariants. Observation mode remains available for
migrations and audit.
## 2. Reactive "Live" Queries (Watch API)
**Goal:** Power real-time, reactive user interfaces.
- Delivered foundation: dependency extraction, `SubscriptionManager.checkAffected`, indexed
dependency positions, an ordered transaction feed, durable consumer checkpoints, and
best-effort wakeup transport.
- Provide a `watchQuery` function that emits new results when relevant facts change.
- Enable incremental result updates to minimize re-computation.
- Build reliable invalidation on the ordered transaction feed; `ChangeEmitter` remains a
best-effort wake-up mechanism.
## 3. Application-level tenancy and authorization
**Goal:** Native, safe isolation for SaaS applications.
- Delivered foundation: explicit database scope, branded `DatabaseId`, scope-bound cursors, and
PostgreSQL schema-per-database isolation. SQLite hosts use one file/layer per database and KV
callers use an explicitly named scope.
- Keep authenticated account-to-database selection and authorization above raw `Triples` until
there is a complete threat model and a backend-portable query/write policy contract.
- Do not add an ambient `tenantId` column that silently changes query semantics.
## Delivered foundation: Bitemporality (Valid Time)
**Goal:** Distinguish between when a fact was recorded and when it became true.
- Implemented across KV, SQLite, and PostgreSQL with a shared recorded/valid basis for direct reads
and Datalog.
- Differential tests cover historical corrections, future-effective facts, retractions, joins, and
negation.
- Structural derivations expose the earliest recorded future `validFrom` or `validTo` across their
dependency attributes. Immutable materialization runs persist and content-bind that schedule,
including zero-candidate runs suppressed by negated evidence. A host scheduler can therefore
reopen expiry-driven projections without a daily full scan. Fixed attribute sets use backend
dependency indexes; dynamic-attribute definitions retain an explicit journal fallback.
## 4. Integrated Full-Text Search (FTS)
**Goal:** Seamlessly combine graph traversal with text search.
- Expose a `(fts ?entity "search term")` predicate within Datalog.
- Support relevance scoring and highlighting in query results.
- Leverage underlying SQL engine FTS capabilities (SQLite FTS5, Postgres GIN/GiST).
## 5. Performance & Query Optimization
**Goal:** Handle millions of triples with sub-millisecond latency.
- **Cost-Based Optimizer:** Reorder Datalog clauses based on attribute cardinality.
- **JSON Indexing:** Optimize `value_json` lookups for complex data types.
- **Recursive Rule Performance:** Optimize CTE generation for deep graph traversals.
- **Scoped concurrency (deferred):** Separate global journal ordering from entity and invariant
conflict scopes only after the atomic multi-scope design in
[`operational-primitives.md`](operational-primitives.md#deferred-scoped-optimistic-concurrency)
has cross-backend conformance coverage.
## 6. Client-Side Sync & Offline-First
**Goal:** Enable seamless data replication to edge devices and browsers.
- Develop a sync protocol for replicating filtered triple subsets to a client.
- Provide a lightweight browser-side Hexastore for local querying.
- Implement conflict resolution strategies for offline writes.
## 7. Developer experience and tooling
**Goal:** Make the database "inspectable" and easy to use.
- **Database Studio:** A web-based UI for exploring entities, history, and snapshots.
- **OpenTelemetry Integration:** Built-in tracing for query execution and performance bottlenecks.
---
# Datalog pagination and performance
`Triples.query` and `Triples.queryPage` return at most 100 bindings by default. Page sizes are
validated in the shared service boundary and may not exceed 1,000. SQL fetches one extra row to
determine whether `nextCursor` exists; only the requested page reaches the caller. Total counts
are opt-in. `queryAll` is the explicit complete-result API for trusted batch processing.
## Measurements
Measured on September 6, 2026 in the development Linux VM using Node 24.14.1, SQLite 3.51.2
(in memory), and local PostgreSQL 16.14. The synthetic fixture contains 10,000 entities and
30,000 facts: a name, numeric score, and group per entity. It has no retracted facts or journal
traffic. Both databases were analyzed after seeding. Each measurement is the median wall time
of five warm executions; seeding, warm-up, and EXPLAIN are excluded. These are local diagnostic
measurements, not production latency guarantees or a backend ranking.
| Query | Backend | Complete result | First 100 | Page 6 (100) | First 100 + count |
| --------------------------------------------- | ---------- | --------------: | --------: | -----------: | ----------------: |
| All facts (30,000 rows) | SQLite | 99.76 ms | 35.30 ms | 43.33 ms | 63.89 ms |
| All facts (30,000 rows) | PostgreSQL | 68.32 ms | 56.67 ms | 50.78 ms | 76.59 ms |
| Fixed attribute (10,000 rows) | SQLite | 22.49 ms | 12.73 ms | 15.68 ms | 23.17 ms |
| Fixed attribute (10,000 rows) | PostgreSQL | 20.93 ms | 16.30 ms | 16.65 ms | 29.43 ms |
| Name/score join with score >= 50 (5,000 rows) | SQLite | 23.02 ms | 16.00 ms | 15.75 ms | 28.85 ms |
| Name/score join with score >= 50 (5,000 rows) | PostgreSQL | 43.00 ms | 18.33 ms | 18.15 ms | 28.68 ms |
| Grouped distinct count (250 rows) | SQLite | 14.38 ms | 15.19 ms | — | 29.65 ms |
| Grouped distinct count (250 rows) | PostgreSQL | 27.22 ms | 28.76 ms | — | 56.48 ms |
The complete-result column uses `queryAll` at the latest recorded state. Paged reads additionally
pin a recorded timestamp and commit position, so this compares the public operations rather than
isolating SQL LIMIT alone. No offset is used to advance pages. Page 6 is reached by following
five cursors; only the continuation request is timed.
## What the execution plans show
Both SQL backends receive LIMIT 101 for a 100-row page. They do not send the full matching
relation to JavaScript. However, generic pagination does **not** bound the amount of database
work needed to produce that page:
- SQLite uses temporary B-trees for projection distinctness and outer ordering. Fixed-attribute
snapshot queries use `idx_attribute_history`; snapshot joins also use the entity temporal index.
- PostgreSQL's all-facts first page scans 30,000 rows, deduplicates 30,000 projected rows, then
applies a top-N sort and LIMIT. The fixed-attribute case uses `idx_attribute_history` to read
10,000 candidates. The join reads its matching attributes and uses a hash join.
- Grouped queries still process all contributing facts before returning a page of groups.
Recursive queries may likewise compute a large closure before applying the result limit.
- `includeCount` executes a second query over the complete filtered relation. It excludes the
cursor boundary so the count remains the total size of the snapshot, even on later pages.
The wrapper compiler removes inner ordering when there is no inner limit or offset, avoiding
an unnecessary sort. Inner limits and offsets retain their ordering and receive deterministic
projected-row tie-breakers. Recursive wrappers use the recursive compiler. Debug output reports
actual compiler metrics and includes count execution time in `executionTimeMs`, with
`countExecutionTimeMs` available separately. `debug.generatedSql`, `debug.params`, and
`debug.queryPlan` describe the actual page, including the snapshot and continuation boundary;
`explain` describes the logical Datalog query without capturing a read snapshot.
For large catalogs, select fixed attributes and restrictive patterns, project only needed
bindings, and request counts only where they are useful. An index on a public sort value alone
cannot eliminate arbitrary joins, aggregation, mixed-type canonicalization, or distinctness.
Further index/compiler changes should be evaluated against representative history density,
attribute fan-out, recursive graphs, and deep cursors. The KV executor still materializes
intermediate bindings before sorting and slicing; bounded output is not bounded executor memory.
## Read-path audit
The Datalog bounds apply to raw query execution in the CLI and dashboard as well as to the core
service. The dashboard's All facts preset no longer embeds a total limit of 100, so its Next
page control can traverse the entire snapshot. CLI continuation uses `query run --cursor` or
`query page` with a wrapped request.
Low-level `match`, `entity`, `entities`, and `history` are complete materialization APIs. The
existing dashboard overview and reflected entity browser, CLI fact matching/entity listing,
and journal implementation still contain complete fact reads. Their array slicing is not
storage-level pagination. They need separate fact/entity/journal paging contracts before the
application can claim that **every** read avoids loading a catalog. This change establishes the
bounded Datalog contract; it does not make that broader claim.
## Reproduce
The benchmark lives in `test/stress/scripts/datalog-pagination.ts` and is opt-in:
```sh
pnpm exec turbo run build --filter=@triplex-build/triplex-sqlite --filter=@triplex-build/triplex-postgres
pnpm --filter triplex-stress benchmark:datalog
PG_BENCH_URL=postgresql://localhost/triplex_bench pnpm --filter triplex-stress benchmark:datalog
```
Use an empty, disposable PostgreSQL database. The script refuses a nonempty facts table and
leaves its seeded facts behind for inspection. `DATALOG_BENCH_ENTITIES` changes the fixture size.
JSON output includes every timing, generated statement, SQLite EXPLAIN QUERY PLAN, and PostgreSQL
EXPLAIN ANALYZE with buffer statistics. Backend suites can also use an explicitly supplied
`PG_TEST_URL` pointing to a disposable database when Docker is unavailable:
```sh
PG_TEST_URL=postgresql://localhost/triplex_test pnpm test:postgres:integration
```
## Numeric ranges and actor due-work
Triplex provisions `idx_attr_numeric` on `(attribute, COALESCE(value_number, value_datetime))`
for facts whose `value_type` is `number` or `datetime`. It matches the compiler's existing scalar
expression: both storage types compare numerically, and equal numeric values still collapse in
Datalog projections. The existing number-only index remains available for typed storage reads.
This covers [Runfold PR #4](https://github.com/bjacobso/runfold/pull/4)'s `src/actor.ts` due-work
query without an application index declaration or per-actor DDL:
```ts
import { Effect } from "effect";
import { Triples } from "@triplex-build/triplex";
const dueRows = (attribute: string, marker: string, now: number) =>
Effect.gen(function* () {
const triples = yield* Triples;
return yield* triples.query(
{
find: ["?item", "?actor", "?due"],
where: [
["?item", attribute, "?due"],
["<=", "?due", now],
["?item", marker, "?actor"],
],
orderBy: [{ variable: "?due", direction: "asc" }],
limit: 128,
},
{ pageSize: 128 },
);
});
```
`limit` is the logical result bound; `pageSize` controls the public page. Without the explicit
`pageSize`, current Triplex returns up to 100 rows and a continuation even with `limit: 128`.
Unlike Runfold's `runfold_actor_due` workaround, this index includes retracted numeric facts.
`Triples.query` pins a recorded position and time, and can see facts retracted after that snapshot.
A partial index restricted to `retracted_at IS NULL` cannot serve those reads. Valid-time and
recorded-time predicates remain residual filters; the index does not change visibility.
The regression fixture has 10,000 pending items: 160 due, 9,840 future, alternating numeric and
datetime storage, plus 80 retracted numeric facts and 10,000 pending markers. After `ANALYZE`,
SQLite and PostgreSQL select the expression index with **both attribute equality and a numeric
range bound**, for live reads and the actual SQL emitted by `Triples.query`. PostgreSQL's bitmap
index scan visits 240 numeric candidates (160 live plus 80 historical), excluding the 9,840 future
values. Removing the new index restores an attribute/temporal scan: PostgreSQL's snapshot
query reads 10,080 due-attribute candidates and filters out 9,920 of them. SQLite likewise switches
from the expression range to `idx_attribute_history` for snapshot pages. The regression tests
assert a numeric range in the index condition, not merely that an index exists.
These are local plan observations, not a promise of a particular plan on every dataset.
The PostgreSQL planner may still scan pending markers for its join. Both backends still sort and
deduplicate; a 128-row limit does not guarantee only 128 facts are examined. Dense due queues and
large numeric histories need their own measurements. KV retains equivalent results using its
existing executor and does not gain a SQL-style range-plan optimization.
To inspect the plans, run these tests with `TRIPLEX_EXPLAIN=1` (add `--disableConsoleIntercept`
if the test reporter suppresses output):
```sh
TRIPLEX_EXPLAIN=1 pnpm --filter @triplex-build/triplex-sqlite exec vitest run test/numeric-index.test.ts
PG_TEST_URL=postgresql://localhost/disposable_test TRIPLEX_EXPLAIN=1 pnpm --filter @triplex-build/triplex-postgres exec vitest run test/integration/postgresql.test.ts -t 'expression range' --disableConsoleIntercept
```
### Scope of consumer indexing
This change takes the core-index route rather than adding a new portable declaration API: the
concrete due-work requirement is shared by numeric Datalog queries across attributes. Consumers
express the query through the same backend-portable `Triples` API and provision its index once
through the [migration API](/host-integration#host-controlled-migrations).
Triplex does **not** yet expose arbitrary consumer index declarations. Workloads needing narrower
attribute-specific indexes, compound application projections, custom uniqueness, or text search
still need a separate design. Host-owned SQL DDL remains a backend-specific escape hatch at
provisioning/migration time; it is not a portable indexing contract. A future declaration API
must define backend capabilities, unsupported requirements, naming, changes/removal, and migration
ownership, including KV behavior. Do not create indexes per actor, request, or query.