diff --git a/docs/.vitepress/theme/DecisionTrace.vue b/docs/.vitepress/theme/DecisionTrace.vue index 10dc224..e85269f 100644 --- a/docs/.vitepress/theme/DecisionTrace.vue +++ b/docs/.vitepress/theme/DecisionTrace.vue @@ -1,11 +1,10 @@ @@ -29,11 +28,11 @@ const trained = computed(() =>
actor
-
{{ receipt.actor }}
+
{{ audit.actor }}
governedBy
-
{{ receipt.governedBy }}
+
{{ audit.governedBy }}
One date. Two views of the evidence.
diff --git a/docs/.vitepress/theme/StackMap.vue b/docs/.vitepress/theme/StackMap.vue new file mode 100644 index 0000000..95d6dff --- /dev/null +++ b/docs/.vitepress/theme/StackMap.vue @@ -0,0 +1,30 @@ + + + diff --git a/docs/.vitepress/theme/home.css b/docs/.vitepress/theme/home.css index b68d49b..2e08943 100644 --- a/docs/.vitepress/theme/home.css +++ b/docs/.vitepress/theme/home.css @@ -476,6 +476,88 @@ padding-top: 48px; border-top: 1px solid var(--vp-c-divider); } +.triplex-home__stack .triplex-home__example-description { + position: sticky; + top: calc(var(--vp-nav-height) + 24px); + align-self: start; +} +.triplex-home__journal { + margin-top: 48px; + padding-top: 40px; + border-top: 1px solid var(--vp-c-divider); +} +.triplex-home__journal + .triplex-home__example { + padding-top: 48px; + border-top: 1px solid var(--vp-c-divider); +} +.triplex-home__journal > p:last-child { + max-width: 780px; + margin: 18px 0 0; + font-size: 15px; +} +.triplex-home .triplex-home__journal > .triplex-home__output-label { + margin-top: 0; +} +.stack-map { + margin: 0 0 8px; +} +.stack-map figcaption { + margin-bottom: 12px; + font: 400 12px/1.6 var(--vp-font-family-mono); + color: var(--vp-c-text-2); +} +.stack-map figcaption strong { + color: var(--home-accent); + font-weight: 500; +} +.stack-map ol { + display: flex; + margin: 0; + padding: 0; + list-style: none; + border: 1.5px solid #263654; + border-radius: 9px; + box-shadow: 5px 5px 0 var(--home-brand); + overflow: hidden; + background: #0f1729; +} +.stack-map li { + flex-basis: 0; + min-width: 104px; + margin: 0; +} +.stack-map li + li { + margin-top: 0; + border-left: 1px solid #263654; +} +.stack-map a { + display: grid; + gap: 2px; + height: 100%; + padding: 14px 14px 12px; + color: #e8edf6; + text-decoration: none; + font-family: var(--vp-font-family-mono); + transition: background 0.15s; +} +.stack-map a:hover { + background: #1b2a4a; +} +.stack-map__step { + font-size: 10px; + color: #a7b5ff; +} +.stack-map__label { + overflow: hidden; + font-size: 13px; + font-weight: 500; + white-space: nowrap; + text-overflow: ellipsis; +} +.stack-map__lines { + font-size: 11px; + color: #8fa0bd; +} .triplex-home__example-description p { margin: 16px 0; font-size: 15px; @@ -646,6 +728,24 @@ .triplex-home__example-description { max-width: 780px; } + .triplex-home__stack .triplex-home__example-description { + position: static; + } + .stack-map ol { + flex-wrap: wrap; + } + .stack-map li { + flex: 1 1 30% !important; + } + .stack-map li + li { + border-left: 0; + } + .stack-map li { + border-bottom: 1px solid #263654; + } + .stack-map__label { + white-space: normal; + } } @media (max-width: 800px) { .triplex-index .VPHome > .vp-doc { diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts index c57e99c..6395ad1 100644 --- a/docs/.vitepress/theme/index.ts +++ b/docs/.vitepress/theme/index.ts @@ -10,6 +10,7 @@ import "./blueprint.css"; import Playground from "./Playground.vue"; import TriplexExplorer from "./TriplexExplorer.vue"; import DecisionTrace from "./DecisionTrace.vue"; +import StackMap from "./StackMap.vue"; export default { extends: DefaultTheme, @@ -33,6 +34,7 @@ export default { }), enhanceApp({ app }) { app.component("DecisionTrace", DecisionTrace); + app.component("StackMap", StackMap); app.component("TriplexPlayground", Playground); app.component("TriplexExplorer", TriplexExplorer); }, diff --git a/docs/agents.md b/docs/agents.md index ef3acbb..6f44450 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -66,7 +66,7 @@ Check these constraints with the people you work for before adopting it: and `ref`. - Set `entityType` on every assertion so type-level reads and constraints can find the entity. -<<< @/snippets/home/site-safety/model.ts +<<< @/snippets/home/site-safety/app.ts#model Read more in [Typed configuration](/configuration#ontology-dsl). @@ -79,6 +79,8 @@ Read more in [Typed configuration](/configuration#ontology-dsl). - At command time, resolve the release you intend to enforce and keep its snapshot for the whole command. +<<< @/snippets/home/site-safety/app.ts#release + Read more in [Releases, refs, and rollback](/configuration-versioning). ## 3. Write each command as one transaction @@ -101,11 +103,11 @@ Follow these rules for every write: `{ _tag: "TripleLive", id }` precondition so a concurrent change fails with a conflict instead of being lost. -<<< @/snippets/home/site-safety/record.ts#place +<<< @/snippets/home/site-safety/app.ts#command A retry with the same command ID returns the original receipt, and the journal gains no entry: -<<< @/snippets/home/site-safety/retry.ts +<<< @/snippets/home/site-safety/story.ts#retry Read more in [Operational primitives](/operational-primitives) and [Troubleshooting](/troubleshooting#what-should-happen-on-a-duplicate-command-id). @@ -126,7 +128,7 @@ Read more in [Operational primitives](/operational-primitives) and - A `stale` or `unmaterialized` result doesn't mean there is no work. Rematerialize before treating an empty result as current. -<<< @/snippets/home/site-safety/open-work.ts +<<< @/snippets/home/site-safety/app.ts#derive The [compliance host example](https://github.com/bjacobso/triplex/tree/main/examples/compliance-host) @@ -144,7 +146,7 @@ scheduler that reopens work at expiry. Read more in [Derivations](/derivations). - **What was retracted?** `history(entityId)` includes retracted assertions. `entity` and `match` show only facts visible at the basis you ask for. -<<< @/snippets/home/site-safety/what-we-knew.ts +<<< @/snippets/home/site-safety/app.ts#ask ## 6. Choose a runtime @@ -152,7 +154,7 @@ Construct one layer per database lifetime, at the application boundary. Building layer again creates a new empty database, and building a SQL layer per request defeats connection sharing. -<<< @/snippets/home/layers.ts +<<< @/snippets/home/site-safety/app.ts#run 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 diff --git a/docs/index.md b/docs/index.md index 4623b37..47010f5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -20,7 +20,7 @@ pageClass: triplex-index
Get started -Follow one decision +See the whole stack

Pre-1.0 · MIT licensed · In-memory + SQLite supported
PostgreSQL is a production candidate · Current state ↗

@@ -68,107 +68,141 @@ npm install @triplex-build/triplex effect@4.0.3
-

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. The database’s own journal reads:

+

01 / The whole stack, top to bottom

+

From schema to REST API in one file.

+

Model, versioned rules, a command, an audit, open work, storage, and HTTP. Each block below is the next region of the same file, in order. Every block is type-checked, and CI runs the file to produce every output on this page.

+

The story: 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. Later, an auditor finds that the certificate actually began on March 5.

-
+ - +
+ +
+
+

01 · Model

+

Declare the domain once.

+

Attributes are global, typed facts. Entity types say how each one is used: required, cardinality, and references.

+

Changing the rules means a new version of the type, not a migration. Old facts stay valid under the release that accepted them.

+Typed configuration +
+
+ +<<< @/snippets/home/site-safety/app.ts#model + +
+
+ +
+
+

02 · Rules

+

Publish the rules as a release.

+

A release is immutable and content-addressed. The story publishes hr-2026.1 with Placement, then hr-2026.2 with PlacementV2. Each time, live moves and every earlier release stays exactly as it was.

+Releases, refs, and rollback +
+
+ +<<< @/snippets/home/site-safety/app.ts#release
+
-

00 · Record

+

03 · Command

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.

+

Dana’s placement records who acted, a command ID that makes retries safe, and the release whose constraints were checked in the same transaction.

+

Under hr-2026.2, the same command is rejected. It writes no facts, no receipt, and no journal entry.

Transactions and the journal
-<<< @/snippets/home/site-safety/record.ts#place +<<< @/snippets/home/site-safety/app.ts#command + +

The same command under hr-2026.2

+ +<<< @/snippets/home/site-safety/output/rejected.json
-
-
-
-

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.

+
+

After the whole story, the journal reads

+
+ + + +
+

Nothing is overwritten. The auditor’s correction at position 6 retracts the old belief and asserts the new one, and the journal keeps both. The rest of the story is in story.ts.

-

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.

+

04 · Audit

+

Ask what you knew, and under which rules.

+

validAt is the day in question. recordedAt is the instant of Dana’s receipt. At that instant, the record said Maria was trained. After the correction, it says she was not.

+

Both answers can be proven, and the receipt still points at hr-2026.1 even though live has moved.

Recorded time and valid time
-<<< @/snippets/home/site-safety/what-we-knew.ts +<<< @/snippets/home/site-safety/app.ts#ask

Output

-<<< @/snippets/home/site-safety/output/what-we-knew.json +<<< @/snippets/home/site-safety/output/audit.json
-
+
-

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 +

05 · Open work

+

Derive what needs to happen.

+

The obligation is a Datalog query, not a queue. On March 3, the corrected record exposes a gap. On April 1, nothing is open. On June 1, the training expires and the task reopens, even though nobody wrote anything.

+

recheckAt comes from nextTemporalBoundary. Schedule a wake-up for it instead of polling.

+Derivations and reconciliation
-<<< @/snippets/home/site-safety/which-rules.ts +<<< @/snippets/home/site-safety/app.ts#derive -

Output

- -<<< @/snippets/home/site-safety/output/which-rules.json +

Evaluated on March 3, April 1, and June 1

-

The same command under hr-2026.2

- -<<< @/snippets/home/site-safety/output/rejected.json +<<< @/snippets/home/site-safety/output/open-work.json
-
+
-

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 +

06 · Storage + HTTP

+

Pick a store. Get an API.

+

Storage is a layer. The same program runs in memory for tests and the browser, on SQLite, or on PostgreSQL.

+

EntityHttp serves the live release as REST routes with OpenAPI docs. Every read accepts both clocks, so the audit question works over HTTP too.

+Configuration-derived HTTP API
-<<< @/snippets/home/site-safety/open-work.ts +<<< @/snippets/home/site-safety/app.ts#run -

Output for March 3, April 1, and June 1

+

Launch it

-<<< @/snippets/home/site-safety/output/open-work.json +<<< @/snippets/home/site-safety/serve.ts + +

GET /api/rest/latest/workers/worker:maria?validAt=…, with and without recordedAt

+ +<<< @/snippets/home/site-safety/output/http.json
+ +
-

03 / Derived work

+

02 / 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.

@@ -200,7 +234,7 @@ npm install @triplex-build/triplex effect@4.0.3
-

04 / Modeling

+

03 / 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.

@@ -210,25 +244,23 @@ npm install @triplex-build/triplex effect@4.0.3 | 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` | +| User and system actions | One `transact` per command with `actor`, `commandId`, `configSnapshot` | `create:placement: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 | +| Audit questions | A temporal basis, command receipts, and the journal | Step 04, Audit |

Read the full modeling guide

-

05 / Deployment

+

04 / 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.

+

Triplex runs inside your process as Effect layers. Switching storage changes the layer you provide, as in step 06, 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.

-<<< @/snippets/home/layers.ts - | Runtime | Package | Status | | -------------------------- | --------------------------------- | ------------------------------- | | In-memory KV | `@triplex-build/triplex` | Supported: tests, browser, edge | @@ -244,7 +276,7 @@ npm install @triplex-build/triplex effect@4.0.3
-

06 / Fit

+

05 / Fit

When to use Triplex, and when not to.

@@ -264,7 +296,7 @@ npm install @triplex-build/triplex effect@4.0.3
-

07 / Part of the WorldVM family

+

06 / 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.

diff --git a/docs/snippets/home/layers.ts b/docs/snippets/home/layers.ts deleted file mode 100644 index 3d0259e..0000000 --- a/docs/snippets/home/layers.ts +++ /dev/null @@ -1,16 +0,0 @@ -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"] ?? "")), -); diff --git a/docs/snippets/home/site-safety/app.ts b/docs/snippets/home/site-safety/app.ts new file mode 100644 index 0000000..2884563 --- /dev/null +++ b/docs/snippets/home/site-safety/app.ts @@ -0,0 +1,168 @@ +/** + * The homepage site-safety application, top to bottom: model, rules, a command, + * audit questions, derived work, storage, and HTTP. The homepage shows each + * region in order, and `run.ts` executes it in CI to produce every output. + */ +import { createServer } from "node:http"; + +import { NodeHttpServer } from "@effect/platform-node"; +import { Effect, Layer } from "effect"; +import { HttpRouter } from "effect/http"; +import { EntityId, KvTriples, Triples, type TemporalBasis } from "@triplex-build/triplex"; +import { + Attribute, + ConfigStore, + ContentId, + EntityType, + GraphConstraint, + type InMemoryConfigStore, +} from "@triplex-build/triplex/config"; +import * as Derivation from "@triplex-build/triplex/derivation"; +import { EntityHttp, HttpAuthorizationAllowAll } from "@triplex-build/triplex-http"; +import { PgTriples } from "@triplex-build/triplex-postgres"; +import { SqliteTriples } from "@triplex-build/triplex-sqlite"; + +export const MAR_2 = Date.UTC(2026, 2, 2); +export const maria = EntityId.make("worker:maria"); +export const harbor = EntityId.make("site:harbor"); + +// #region model +// Attributes are global facts. Entity types say how each one is used. +export const Site = EntityType.make("Site", { + attributes: { name: Attribute.use(Attribute.text(":site/name"), { required: true }) }, +}); + +export const Worker = EntityType.make("Worker", { + attributes: { + name: Attribute.use(Attribute.text(":worker/name"), { required: true }), + trainedFor: Attribute.use(Attribute.ref(":worker/trained-for", Site), { cardinality: "many" }), + }, +}); + +const placement = { + worker: Attribute.use(Attribute.ref(":placement/worker", Worker), { required: true }), + site: Attribute.use(Attribute.ref(":placement/site", Site), { required: true }), +}; +export const Placement = EntityType.make("Placement", { attributes: placement }); + +// The next version of the rules: every placement needs a supervisor. +export const PlacementV2 = EntityType.make("Placement", { + attributes: { + ...placement, + supervisor: Attribute.use(Attribute.text(":placement/supervisor"), { required: true }), + }, +}); +// #endregion model + +// #region release +// Publishing a model creates an immutable, content-addressed release and moves `live` to it. +export const publish = (label: string, types: ReadonlyArray<{ nodes: typeof Site.nodes }>) => + Effect.gen(function* () { + const config = yield* ConfigStore.ConfigStore; + const objects = yield* Effect.all(types.map((type) => type.nodes)); + return yield* config.commit({ label, objects: objects.flat(), ref: "live" }); + }); +// #endregion release + +// #region command +// One command is one atomic transaction: who acted, which command, under which rules. +export const placeWorker = ( + release: InMemoryConfigStore.ConfigSnapshot, + placement: EntityId, + worker: EntityId, + site: EntityId, +) => + Effect.gen(function* () { + const triples = yield* Triples; + const assert = { op: "assert", entityId: placement, entityType: "Placement" } as const; + return yield* triples.transact( + [ + { ...assert, ...Placement.worker.assertion(worker, { validFrom: MAR_2 }) }, + { ...assert, ...Placement.site.assertion(site, { validFrom: MAR_2 }) }, + ], + { + actor: "user:dana", + commandId: `create:${placement}`, // unique: a retry can never apply twice + correlationId: "request:7f3a", + configSnapshot: release.id, // the rules this write answered to + enforce: GraphConstraint.enforcement(yield* GraphConstraint.collect(release.root)), + }, + ); + }); +// #endregion command + +// #region ask +// Who placed Maria, under which rules, and was she trained, as known then and now? +export const audit = Effect.gen(function* () { + const triples = yield* Triples; + const config = yield* ConfigStore.ConfigStore; + const receipt = yield* triples.transactionByCommand("create:placement:maria-harbor"); + if (receipt === null || !ContentId.isContentId(receipt.configSnapshot)) { + return yield* Effect.die("placement was not pinned to a release"); + } + + const trained = (basis: TemporalBasis) => + triples + .match({ entityId: maria, attribute: Worker.trainedFor.key }, basis) + .pipe(Effect.map((facts) => facts.length > 0)); + + return { + actor: receipt.actor, + governedBy: (yield* config.snapshotById(receipt.configSnapshot))?.label, + liveToday: (yield* config.resolveRef("live"))?.label, + trainedAsKnownThen: yield* trained({ validAt: MAR_2, recordedAt: receipt.instant }), + trainedAsKnownNow: yield* trained({ validAt: MAR_2 }), + }; +}); +// #endregion ask + +// #region derive +// Open work is a query: a placed worker without current training for that site. +export const openWork = (configSnapshot: string, validAt: number) => + Effect.gen(function* () { + const training = yield* Derivation.make({ + name: "task.site-safety-training", + query: { + find: ["?worker", "?site"], + where: [ + ["?placement", Placement.worker.key, "?worker"], + ["?placement", Placement.site.key, "?site"], + ["not", ["?worker", Worker.trainedFor.key, "?site"]], + ], + }, + identity: ["?worker", "?site"], + configSnapshot, + }); + const { candidates, nextTemporalBoundary } = yield* Derivation.evaluate( + yield* Triples, + training, + { basis: { validAt } }, + ); + return { + open: candidates.map((task) => task.result), + recheckAt: nextTemporalBoundary === undefined ? null : new Date(nextTemporalBoundary), + }; + }); +// #endregion derive + +// #region run +// The same program runs on any storage. Only the 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("postgres://localhost/app")), +); + +// The `live` release is also a REST API with OpenAPI docs and as-of reads. +export const Api = EntityHttp.layer({ + docs: true, + exposure: { collections: { Worker: "workers", Placement: "placements" } }, +}).pipe(Layer.provide(HttpAuthorizationAllowAll)); // bring your own auth in production + +export const Server = HttpRouter.serve(Api).pipe( + Layer.provide(Sqlite), + Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })), +); +// #endregion run diff --git a/docs/snippets/home/site-safety/model.ts b/docs/snippets/home/site-safety/model.ts deleted file mode 100644 index 0dfa0d8..0000000 --- a/docs/snippets/home/site-safety/model.ts +++ /dev/null @@ -1,52 +0,0 @@ -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", - }); - }); diff --git a/docs/snippets/home/site-safety/open-work.ts b/docs/snippets/home/site-safety/open-work.ts deleted file mode 100644 index 6d9322b..0000000 --- a/docs/snippets/home/site-safety/open-work.ts +++ /dev/null @@ -1,37 +0,0 @@ -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), - }; - }); diff --git a/docs/snippets/home/site-safety/output/audit.json b/docs/snippets/home/site-safety/output/audit.json new file mode 100644 index 0000000..ff0fffc --- /dev/null +++ b/docs/snippets/home/site-safety/output/audit.json @@ -0,0 +1,7 @@ +{ + "actor": "user:dana", + "governedBy": "hr-2026.1", + "liveToday": "hr-2026.2", + "trainedAsKnownThen": true, + "trainedAsKnownNow": false +} diff --git a/docs/snippets/home/site-safety/output/http.json b/docs/snippets/home/site-safety/output/http.json new file mode 100644 index 0000000..5728769 --- /dev/null +++ b/docs/snippets/home/site-safety/output/http.json @@ -0,0 +1,12 @@ +{ + "asKnownThen": { + "id": "worker:maria", + "type": "Worker", + "attributes": { ":worker/name": "Maria", ":worker/trained-for": ["site:harbor"] } + }, + "asKnownNow": { + "id": "worker:maria", + "type": "Worker", + "attributes": { ":worker/name": "Maria" } + } +} diff --git a/docs/snippets/home/site-safety/output/open-work.json b/docs/snippets/home/site-safety/output/open-work.json index 3d01a3f..b7e75fe 100644 --- a/docs/snippets/home/site-safety/output/open-work.json +++ b/docs/snippets/home/site-safety/output/open-work.json @@ -1,13 +1,11 @@ -[ - { - "on": "2026-03-03", +{ + "2026-03-03": { "open": [{ "?worker": "worker:maria", "?site": "site:harbor" }], - "recheckOn": "2026-03-05" + "recheckAt": "2026-03-05T00:00:00.000Z" }, - { "on": "2026-04-01", "open": [], "recheckOn": "2026-06-01" }, - { - "on": "2026-06-01", + "2026-04-01": { "open": [], "recheckAt": "2026-06-01T00:00:00.000Z" }, + "2026-06-01": { "open": [{ "?worker": "worker:maria", "?site": "site:harbor" }], - "recheckOn": null + "recheckAt": null } -] +} diff --git a/docs/snippets/home/site-safety/output/stack.json b/docs/snippets/home/site-safety/output/stack.json new file mode 100644 index 0000000..3243e26 --- /dev/null +++ b/docs/snippets/home/site-safety/output/stack.json @@ -0,0 +1 @@ +{ "model": 22, "release": 7, "command": 24, "ask": 20, "derive": 26, "run": 17, "total": 116 } diff --git a/docs/snippets/home/site-safety/output/timeline.md b/docs/snippets/home/site-safety/output/timeline.md index 61e8ebd..c1c8c86 100644 --- a/docs/snippets/home/site-safety/output/timeline.md +++ b/docs/snippets/home/site-safety/output/timeline.md @@ -1,8 +1,8 @@ -| 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) | +| 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
+ `:site/name` `"Pier"` 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` | `create:placement: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) | diff --git a/docs/snippets/home/site-safety/output/what-we-knew.json b/docs/snippets/home/site-safety/output/what-we-knew.json deleted file mode 100644 index 0e35098..0000000 --- a/docs/snippets/home/site-safety/output/what-we-knew.json +++ /dev/null @@ -1 +0,0 @@ -{ "knownAtDecision": true, "knownToday": false } diff --git a/docs/snippets/home/site-safety/output/which-rules.json b/docs/snippets/home/site-safety/output/which-rules.json deleted file mode 100644 index 6561f41..0000000 --- a/docs/snippets/home/site-safety/output/which-rules.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "actor": "user:dana", - "correlationId": "request:7f3a", - "governedBy": "hr-2026.1", - "liveToday": "hr-2026.2" -} diff --git a/docs/snippets/home/site-safety/record.ts b/docs/snippets/home/site-safety/record.ts deleted file mode 100644 index cbd7045..0000000 --- a/docs/snippets/home/site-safety/record.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { Effect } from "effect"; -import { Triples } from "@triplex-build/triplex"; -import { GraphConstraint, type InMemoryConfigStore } from "@triplex-build/triplex/config"; -import { Placement, Site, TrainedFor, Worker, harbor, maria, placement } from "./model.ts"; - -export const JAN_5 = Date.UTC(2026, 0, 5); -export const MAR_1 = Date.UTC(2026, 2, 1); -export const MAR_2 = Date.UTC(2026, 2, 2); -export const MAR_5 = Date.UTC(2026, 2, 5); -export const JUN_1 = Date.UTC(2026, 5, 1); - -export const onboard = Effect.gen(function* () { - const triples = yield* Triples; - return yield* triples.transact( - [ - { - op: "assert", - entityId: harbor, - entityType: "Site", - ...Site.attributes.name.assertion("Harbor", { validFrom: JAN_5 }), - }, - { - op: "assert", - entityId: maria, - entityType: "Worker", - ...Worker.attributes.name.assertion("Maria", { validFrom: JAN_5 }), - }, - ], - { actor: "user:hr", commandId: "onboard:maria" }, - ); -}); - -// Evidence holds for a window of business time: [validFrom, validTo). -export const recordTraining = Effect.gen(function* () { - const triples = yield* Triples; - return yield* triples.transact( - [ - { - op: "assert", - entityId: maria, - entityType: "Worker", - attribute: TrainedFor.key, - value: TrainedFor.encode(harbor), - validFrom: MAR_1, - validTo: JUN_1, - }, - ], - { actor: "user:trainer", commandId: "training:record:maria-harbor" }, - ); -}); - -// #region place -// 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 - }, - ); - }); -// #endregion place - -// A correction never deletes history. It retracts the recorded belief and -// asserts what is now known, in one attributed transaction. -export const correctTraining = Effect.gen(function* () { - const triples = yield* Triples; - const [wrong] = yield* triples.match( - { entityId: maria, attribute: TrainedFor.key }, - { validAt: MAR_1 }, - ); - if (wrong === undefined) return yield* Effect.die("no training evidence to correct"); - return yield* triples.transact( - [ - { op: "retract", id: wrong.id }, - { - op: "assert", - entityId: maria, - entityType: "Worker", - attribute: TrainedFor.key, - value: TrainedFor.encode(harbor), - validFrom: MAR_5, - validTo: JUN_1, - }, - ], - { - actor: "user:auditor", - commandId: "training:correct:maria-harbor", - causationId: "audit:2026-q1", - }, - ); -}); diff --git a/docs/snippets/home/site-safety/retry.ts b/docs/snippets/home/site-safety/retry.ts deleted file mode 100644 index d5ad62d..0000000 --- a/docs/snippets/home/site-safety/retry.ts +++ /dev/null @@ -1,18 +0,0 @@ -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; - }), - ), - ); diff --git a/docs/snippets/home/site-safety/run.ts b/docs/snippets/home/site-safety/run.ts index 5d9a098..cd2ca32 100644 --- a/docs/snippets/home/site-safety/run.ts +++ b/docs/snippets/home/site-safety/run.ts @@ -9,16 +9,27 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { Effect, Layer } from "effect"; -import { EntityId, KvTriples, Triples, type TransactionChange } from "@triplex-build/triplex"; -import { ConfigStore, GraphConstraint } from "@triplex-build/triplex/config"; +import { HttpRouter } from "effect/http"; +import { EntityId, Triples, type TransactionChange } from "@triplex-build/triplex"; +import { ConfigStore } from "@triplex-build/triplex/config"; import { ContentId } from "@triplex-build/triplex/content"; -import { Placement, PlacementV2, harbor, maria, publish } from "./model.ts"; -import { JUN_1, MAR_2, correctTraining, onboard, placeWorker, recordTraining } from "./record.ts"; -import { openWorkOn } from "./open-work.ts"; -import { placeWorkerOnce } from "./retry.ts"; -import { governingRules } from "./which-rules.ts"; -import { wasMariaTrained } from "./what-we-knew.ts"; +import { + Api, + InMemory, + MAR_2, + Placement, + PlacementV2, + Site, + Worker, + audit, + harbor, + maria, + openWork, + placeWorker, + publish, +} from "./app.ts"; +import { JUN_1, correctTraining, onboard, pier, placeWorkerOnce, recordTraining } from "./story.ts"; const day = (instant: number) => new Date(instant).toISOString().slice(0, 10); @@ -34,47 +45,56 @@ const describe = (change: TransactionChange) => { // Recorded time comes from the wall clock; keep each step on a distinct instant. const tick = Effect.sleep("5 millis"); +// Serve the `Api` layer in process and read Maria as of March 2, as known then and now. +const readOverHttp = (recordedAt: number) => + Effect.gen(function* () { + const database = yield* Effect.context(); + const { handler, dispose } = HttpRouter.toWebHandler( + Api.pipe(Layer.provide(Layer.succeedContext(database))), + { disableLogger: true }, + ); + const read = (query: string) => + Effect.promise(async () => { + const url = `http://triplex.local/api/rest/latest/workers/${maria}?validAt=${MAR_2}${query}`; + const response = await handler(new Request(url)); + if (!response.ok) throw new Error(`${url} → ${response.status} ${await response.text()}`); + return response.json(); + }); + const asKnownThen = yield* read(`&recordedAt=${recordedAt}`); + const asKnownNow = yield* read(""); + yield* Effect.promise(dispose); + return { asKnownThen, asKnownNow }; + }); + const scenario = Effect.gen(function* () { const triples = yield* Triples; - const release = yield* publish("hr-2026.1", Placement); + const release = yield* publish("hr-2026.1", [Site, Worker, Placement]); yield* tick; yield* onboard; yield* tick; yield* recordTraining; yield* tick; - const placed = yield* placeWorker(release.snapshot); + const placed = yield* placeWorker( + release.snapshot, + EntityId.make("placement:maria-harbor"), + maria, + harbor, + ); yield* tick; const retried = yield* placeWorkerOnce(release.snapshot); - if (retried !== placed.position) + if (retried !== placed.position) { throw new Error("a retried command must return its first receipt"); + } yield* tick; - const next = yield* publish("hr-2026.2", PlacementV2); + const next = yield* publish("hr-2026.2", [Site, Worker, PlacementV2]); yield* tick; - // The same kind of command, checked against today's rules, is rejected atomically. - const rejected = yield* triples - .transact( - [ - { - op: "assert", - entityId: EntityId.make("placement:maria-pier"), - entityType: "Placement", - ...PlacementV2.attributes.worker.assertion(maria, { validFrom: MAR_2 }), - }, - { - op: "assert", - entityId: EntityId.make("placement:maria-pier"), - entityType: "Placement", - ...PlacementV2.attributes.site.assertion(harbor, { validFrom: MAR_2 }), - }, - ], - { - actor: "user:dana", - commandId: "placement:create:maria-pier", - configSnapshot: next.snapshot.id, - enforce: GraphConstraint.enforcement(yield* GraphConstraint.collect(next.snapshot.root)), - }, - ) - .pipe(Effect.flip); + // The same command under today's rules is rejected atomically. + const rejected = yield* placeWorker( + next.snapshot, + EntityId.make("placement:maria-pier"), + maria, + pier, + ).pipe(Effect.flip); yield* tick; yield* correctTraining; @@ -99,32 +119,40 @@ const scenario = Effect.gen(function* () { const snapshot = release.snapshot.id; return { timeline: `${rows.join("\n")}\n`, - "what-we-knew": yield* wasMariaTrained, - "which-rules": yield* governingRules, + audit: yield* audit, rejected: { error: rejected._tag, ...(rejected._tag === "ConstraintViolationError" ? { violations: [...new Set(rejected.violations.map((violation) => violation.message))] } : {}), }, - "open-work": [ - yield* openWorkOn(snapshot, Date.UTC(2026, 2, 3)), - yield* openWorkOn(snapshot, Date.UTC(2026, 3, 1)), - yield* openWorkOn(snapshot, JUN_1), - ], + "open-work": { + [day(Date.UTC(2026, 2, 3))]: yield* openWork(snapshot, Date.UTC(2026, 2, 3)), + [day(Date.UTC(2026, 3, 1))]: yield* openWork(snapshot, Date.UTC(2026, 3, 1)), + [day(JUN_1)]: yield* openWork(snapshot, JUN_1), + }, + http: yield* readOverHttp(placed.instant), }; }); -const AppLayer = ConfigStore.layer.pipe(Layer.provideMerge(KvTriples.layer)); -const outputs = await Effect.runPromise(scenario.pipe(Effect.provide(AppLayer))); +const outputs = await Effect.runPromise(scenario.pipe(Effect.provide(InMemory))); + +// How long each layer of the stack is, counted from the regions the homepage shows. +const source = await readFile(resolve(import.meta.dirname, "app.ts"), "utf8"); +const stack: Record = {}; +for (const [, name, body] of source.matchAll(/#region (\w+)\n([\s\S]*?)\/\/ #endregion/g)) { + stack[name!] = body!.split("\n").filter((line) => line.trim() !== "").length; +} +stack["total"] = Object.values(stack).reduce((sum, lines) => sum + lines, 0); const check = process.argv.includes("--check"); const files: Record = { + "stack.json": JSON.stringify(stack), "timeline.md": outputs.timeline, - "what-we-knew.json": JSON.stringify(outputs["what-we-knew"]), - "which-rules.json": JSON.stringify(outputs["which-rules"]), + "audit.json": JSON.stringify(outputs.audit), "rejected.json": JSON.stringify(outputs.rejected), "open-work.json": JSON.stringify(outputs["open-work"]), + "http.json": JSON.stringify(outputs.http), }; // Format exactly as the repository formatter would, so `pnpm format` never fights `--check`. diff --git a/docs/snippets/home/site-safety/serve.ts b/docs/snippets/home/site-safety/serve.ts new file mode 100644 index 0000000..fc74362 --- /dev/null +++ b/docs/snippets/home/site-safety/serve.ts @@ -0,0 +1,6 @@ +import { NodeRuntime } from "@effect/platform-node"; +import { Layer } from "effect"; +import { Server } from "./app.ts"; + +// Serves whichever release `live` points at in app.db, with docs at /api/rest/latest/docs. +Layer.launch(Server).pipe(NodeRuntime.runMain); diff --git a/docs/snippets/home/site-safety/story.ts b/docs/snippets/home/site-safety/story.ts new file mode 100644 index 0000000..9f1823f --- /dev/null +++ b/docs/snippets/home/site-safety/story.ts @@ -0,0 +1,100 @@ +/** + * The rest of the homepage story: onboarding, training evidence, a retried + * command, and the auditor's correction. `run.ts` plays these in order. + */ +import { Effect } from "effect"; +import { EntityId, Triples } from "@triplex-build/triplex"; +import type { InMemoryConfigStore } from "@triplex-build/triplex/config"; +import { Site, Worker, harbor, maria, placeWorker } from "./app.ts"; + +export const JAN_5 = Date.UTC(2026, 0, 5); +export const MAR_1 = Date.UTC(2026, 2, 1); +export const MAR_5 = Date.UTC(2026, 2, 5); +export const JUN_1 = Date.UTC(2026, 5, 1); +export const pier = EntityId.make("site:pier"); + +export const onboard = Effect.gen(function* () { + const triples = yield* Triples; + const since = { validFrom: JAN_5 }; + return yield* triples.transact( + [ + { + op: "assert", + entityId: harbor, + entityType: "Site", + ...Site.name.assertion("Harbor", since), + }, + { op: "assert", entityId: pier, entityType: "Site", ...Site.name.assertion("Pier", since) }, + { + op: "assert", + entityId: maria, + entityType: "Worker", + ...Worker.name.assertion("Maria", since), + }, + ], + { actor: "user:hr", commandId: "onboard:maria" }, + ); +}); + +// Evidence holds for a window of business time: [validFrom, validTo). +export const recordTraining = Effect.gen(function* () { + const triples = yield* Triples; + return yield* triples.transact( + [ + { + op: "assert", + entityId: maria, + entityType: "Worker", + ...Worker.trainedFor.assertion(harbor, { validFrom: MAR_1 }), + validTo: JUN_1, + }, + ], + { actor: "user:trainer", commandId: "training:record:maria-harbor" }, + ); +}); + +// #region retry +// 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, EntityId.make("placement:maria-harbor"), maria, harbor).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; + }), + ), + ); +// #endregion retry + +// #region correct +// A correction never deletes history. It retracts the recorded belief and +// asserts what is now known, in one attributed transaction. +export const correctTraining = Effect.gen(function* () { + const triples = yield* Triples; + const [wrong] = yield* triples.match( + { entityId: maria, attribute: Worker.trainedFor.key }, + { validAt: MAR_1 }, + ); + if (wrong === undefined) return yield* Effect.die("no training evidence to correct"); + return yield* triples.transact( + [ + { op: "retract", id: wrong.id }, + { + op: "assert", + entityId: maria, + entityType: "Worker", + ...Worker.trainedFor.assertion(harbor, { validFrom: MAR_5 }), + validTo: JUN_1, + }, + ], + { + actor: "user:auditor", + commandId: "training:correct:maria-harbor", + causationId: "audit:2026-q1", + }, + ); +}); +// #endregion correct diff --git a/docs/snippets/home/site-safety/what-we-knew.ts b/docs/snippets/home/site-safety/what-we-knew.ts deleted file mode 100644 index 32e488d..0000000 --- a/docs/snippets/home/site-safety/what-we-knew.ts +++ /dev/null @@ -1,23 +0,0 @@ -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 }), - }; -}); diff --git a/docs/snippets/home/site-safety/which-rules.ts b/docs/snippets/home/site-safety/which-rules.ts deleted file mode 100644 index 5b2202c..0000000 --- a/docs/snippets/home/site-safety/which-rules.ts +++ /dev/null @@ -1,21 +0,0 @@ -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, - }; -}); diff --git a/docs/snippets/tsconfig.json b/docs/snippets/tsconfig.json index e1d4c12..f845482 100644 --- a/docs/snippets/tsconfig.json +++ b/docs/snippets/tsconfig.json @@ -14,7 +14,8 @@ "@triplex-build/triplex/derivation": ["packages/core/src/derivation/index.ts"], "@triplex-build/triplex-postgres": ["packages/postgres/src/index.ts"], "@triplex-build/triplex/internal": ["packages/core/src/internal.ts"], - "@triplex-build/triplex-sql": ["packages/sql/src/index.ts"] + "@triplex-build/triplex-sql": ["packages/sql/src/index.ts"], + "@triplex-build/triplex-http": ["packages/http/src/index.ts"] }, "allowImportingTsExtensions": true, "types": ["node"] diff --git a/e2e/docs-home.spec.ts b/e2e/docs-home.spec.ts index 88445c2..cd19e87 100644 --- a/e2e/docs-home.spec.ts +++ b/e2e/docs-home.spec.ts @@ -28,16 +28,19 @@ for (const colorScheme of ["light", "dark"] as const) { page.getByRole("heading", { name: "Reading this as a coding agent?" }), ).toBeVisible(); - // The journal table and every question are rendered from verified scenario outputs. + // The stack map, journal table, and every step are rendered from verified scenario outputs. + const stack = page.locator(".stack-map"); + await expect(stack.getByRole("link")).toHaveCount(6); + await expect(stack).toContainText(/\d+ lines from schema to REST API/); const timeline = page.locator(".triplex-home__timeline table"); await expect(timeline.locator("tbody tr")).toHaveCount(6); - await expect(timeline).toContainText("placement:create:maria-harbor"); + await expect(timeline).toContainText("create:placement:maria-harbor"); const examples = page.locator('.triplex-home__snippet div[class*="language-"]'); - await expect(examples).toHaveCount(8); + await expect(examples).toHaveCount(11); for (const example of await examples.all()) { await expect(example).toBeVisible(); } - await expect(content).toContainText('"knownAtDecision": true'); + await expect(content).toContainText('"trainedAsKnownThen": true'); await expect(content).toContainText('"governedBy": "hr-2026.1"'); await expect(page.getByRole("tab")).toHaveCount(0); @@ -52,7 +55,7 @@ test("home examples remain visible on mobile without page overflow", async ({ pa await page.setViewportSize({ width: 390, height: 844 }); await page.goto("/"); const examples = page.locator('.triplex-home__snippet div[class*="language-"]'); - await expect(examples).toHaveCount(8); + await expect(examples).toHaveCount(11); for (const example of await examples.all()) { await expect(example).toBeVisible(); } @@ -91,7 +94,7 @@ test("agent-readable documentation is served as plain text", async ({ request }) const markdown = await guide.text(); expect(markdown).toMatch(/^# Modeling a back-office domain/); // Code imports are resolved into fenced code rather than left as VitePress directives. - expect(markdown).toContain('commandId: "placement:create:maria-harbor"'); + expect(markdown).toContain("commandId: `create:${placement}`"); expect(markdown).not.toContain("<<<"); const full = await request.get("/llms-full.txt"); diff --git a/e2e/docs-home.spec.ts-snapshots/home-dark.png b/e2e/docs-home.spec.ts-snapshots/home-dark.png index 13b5502..7d997c4 100644 Binary files a/e2e/docs-home.spec.ts-snapshots/home-dark.png and b/e2e/docs-home.spec.ts-snapshots/home-dark.png differ diff --git a/e2e/docs-home.spec.ts-snapshots/home-light.png b/e2e/docs-home.spec.ts-snapshots/home-light.png index 209b2cd..11814b3 100644 Binary files a/e2e/docs-home.spec.ts-snapshots/home-light.png and b/e2e/docs-home.spec.ts-snapshots/home-light.png differ