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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/entity-write-helpers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@triplex-build/triplex": minor
---

Add entity-level write helpers to the ontology DSL. `EntityType.assert(id, values, options?)`
builds typed `assert` operations for a whole entity from alias-keyed values, with arrays for
cardinality-many uses. `Attribute.assertion` and the attribute handles now accept `validTo`, and
reject non-finite bounds or an empty `[validFrom, validTo)` interval. `GraphConstraint.pinned(release)`
returns the `configSnapshot` and `enforce` transaction metadata from one release. `Attribute.use`
now records a literal cardinality in its type, and `assert` is a reserved entity attribute alias.

Declare attribute uses concisely: attribute definitions now have `.required()`, `.many()`, and
`.unique()`, and `EntityType.make(name, fields)` accepts aliased fields directly, with a bare
definition as an optional single-valued use. The `{ attributes }` form still works and compiles to
identical nodes. `Type.where(entity, patterns)` and `Type.not(entity, patterns)` build Datalog
pattern and negation clauses by alias, with typed constants. `where` and `not` are now reserved
attribute aliases as well.
6 changes: 5 additions & 1 deletion docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,14 @@ Check these constraints with the people you work for before adopting it:
- 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.
and `unique`. The same attribute can be required in one type and optional in another. Declare a
use with `.required()`, `.many()`, or `.unique()`; a bare attribute is optional and single-valued.
- 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.
`Type.assert(id, values, options?)` does this for you and types each value by its attribute.

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

Expand Down Expand Up @@ -96,6 +98,7 @@ Follow these rules for every write:
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`.
`GraphConstraint.pinned(release)` returns both from one snapshot.
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
Expand All @@ -116,6 +119,7 @@ Read more in [Operational primitives](/operational-primitives) and

- 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.
`Type.where(entity, { alias: "?var" })` and `Type.not(...)` build those clauses from aliases.
- 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
Expand Down
96 changes: 79 additions & 17 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,26 +49,14 @@ 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,
}),
},
name: EmployerName.required({ unique: true }),
});

export const EmployerSummary = EntityType.make("EmployerSummary", {
attributes: {
name: Attribute.use(EmployerName, { required: false }),
},
});

export const EmploymentEmployer = Attribute.ref(":employment/employer", Employer);
// A bare attribute is an optional, single-valued use.
export const EmployerSummary = EntityType.make("EmployerSummary", { name: EmployerName });

export const Employment = EntityType.make("Employment", {
attributes: {
employer: Attribute.use(EmploymentEmployer, { required: true }),
},
employer: Attribute.ref(":employment/employer", Employer).required(),
});

const validFrom = Date.now();
Expand All @@ -80,9 +68,83 @@ Employment.attributes.employer.assertion(EntityId.make("employer:acme"), { valid
Requiredness is not inherently true of `:employer/name`; it is true of the way `Employer` uses the
attribute. Reference attributes follow the same separation.

`.required()`, `.many()`, and `.unique()` describe a use and accept the remaining options, for
example `.required({ unique: true })` or `.many({ required: true })`. They are shorthands for
`Attribute.use(attribute, options)`, which remains available. The `{ attributes: { ... } }` form of
`EntityType.make` is also accepted; both forms compile to the same content-addressed nodes.

`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:
uniqueness, and reference-target rules.

## Writing entities

`Type.assert(id, values, options?)` turns attribute values, keyed by the TypeScript aliases, into
`assert` operations tagged with the entity type. Values are typed by their attribute, and a
cardinality-many use takes an array, so `trainedFor: [harbor]` asserts one fact per element.
Every alias is optional and `undefined` is skipped: requiredness is a property of the entity's
state after the transaction, which `enforce` checks atomically, not of each write. `validFrom` and
`validTo` set the half-open business-time interval `[validFrom, validTo)` on every operation; call
`assert` more than once when facts hold for different intervals.

`GraphConstraint.pinned(release)` returns the `configSnapshot` and `enforce` metadata for one
release, so a write records and enforces the same rules:

```ts check
import { Effect } from "effect";
import { EntityId, Triples } from "@triplex-build/triplex";
import { Attribute, ConfigStore, EntityType, GraphConstraint } from "@triplex-build/triplex/config";

const Site = EntityType.make("Site", { name: Attribute.text(":site/name").required() });

const Worker = EntityType.make("Worker", {
name: Attribute.text(":worker/name").required(),
trainedFor: Attribute.ref(":worker/trained-for", Site).many(),
});

const harbor = EntityId.make("site:harbor");
const maria = EntityId.make("worker:maria");
const training = { validFrom: Date.UTC(2026, 2, 1), validTo: Date.UTC(2026, 5, 1) };

export const onboard = Effect.gen(function* () {
const triples = yield* Triples;
const release = yield* (yield* ConfigStore.ConfigStore).resolveRef("live");
if (release === undefined) return yield* Effect.die("no live release");
return yield* triples.transact(
[
...Site.assert(harbor, { name: "Harbor" }),
...Worker.assert(maria, { name: "Maria" }),
...Worker.assert(maria, { trainedFor: [harbor] }, training),
],
{ actor: "user:hr", commandId: "onboard:maria", ...(yield* GraphConstraint.pinned(release)) },
);
});
```

Single-attribute handles remain available for hand-built operations:
`Worker.name.assertion("Maria", { validFrom, validTo })` returns the `attribute`, encoded `value`,
and valid-time fields to spread into an `assert` operation.

## Querying by alias

`Type.where(entity, patterns)` returns one Datalog pattern clause per alias, and `Type.not(entity,
patterns)` returns a `not` clause over them. Values are variables or constants typed by the
attribute; reference constants become typed `ref` constants, so they match exactly. A
cardinality-many attribute matches one value per pattern.

```ts
const untrained = {
find: ["?worker", "?site"],
where: [
...Placement.where("?placement", { worker: "?worker", site: "?site" }),
Worker.not("?worker", { trainedFor: "?site" }),
],
};
```

The clauses are ordinary Datalog, so they combine with raw patterns, predicates, and rules.
Datetime, JSON, and blob values are not Datalog constants; bind them to a variable and compare
with a predicate.

## Releases and refs

Expand Down
3 changes: 2 additions & 1 deletion docs/host-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,8 @@ Translate a validated command into `TransactOp` values and call one `Triples.tra
- 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
- graph constraints from that exact snapshot in `enforce` (`GraphConstraint.pinned(snapshot)`
returns both); and
- a `TripleLive` precondition for each optimistic fact being replaced.

Use `transaction(txId)` to construct an audit response. Retraction changes include the old typed
Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ npm install @triplex-build/triplex effect@4.0.3
<div class="triplex-home__example-description">
<p class="triplex-home__eyebrow">03 · Command</p>
<h3 id="record-a-command">One command, one atomic transaction.</h3>
<p>Dana’s placement records who acted, a command ID that makes retries safe, and the release whose constraints were checked in the same transaction.</p>
<p><code>Placement.assert</code> turns typed attribute values into facts. Dana’s placement records who acted, a command ID that makes retries safe, and, through <code>GraphConstraint.pinned</code>, the release whose constraints were checked in the same transaction.</p>
<p>Under <code>hr-2026.2</code>, the same command is rejected. It writes no facts, no receipt, and no journal entry.</p>
<a href="/operational-primitives">Transactions and the journal<span class="vpi-arrow-right triplex-home__link-icon" aria-hidden="true"></span></a>
</div>
Expand Down
46 changes: 17 additions & 29 deletions docs/snippets/home/site-safety/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,28 +29,24 @@ 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 }) },
name: Attribute.text(":site/name").required(),
});

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" }),
},
name: Attribute.text(":worker/name").required(),
trainedFor: Attribute.ref(":worker/trained-for", Site).many(),
});

const placement = {
worker: Attribute.use(Attribute.ref(":placement/worker", Worker), { required: true }),
site: Attribute.use(Attribute.ref(":placement/site", Site), { required: true }),
worker: Attribute.ref(":placement/worker", Worker).required(),
site: Attribute.ref(":placement/site", Site).required(),
};
export const Placement = EntityType.make("Placement", { attributes: placement });
export const Placement = EntityType.make("Placement", 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 }),
},
...placement,
supervisor: Attribute.text(":placement/supervisor").required(),
});
// #endregion model

Expand All @@ -74,20 +70,13 @@ export const placeWorker = (
) =>
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)),
},
);
const facts = Placement.assert(placement, { worker, site }, { validFrom: MAR_2 });
return yield* triples.transact(facts, {
actor: "user:dana",
commandId: `create:${placement}`, // unique: a retry can never apply twice
correlationId: "request:7f3a",
...(yield* GraphConstraint.pinned(release)), // the rules it answered to, enforced
});
});
// #endregion command

Expand Down Expand Up @@ -125,9 +114,8 @@ export const openWork = (configSnapshot: string, validAt: number) =>
query: {
find: ["?worker", "?site"],
where: [
["?placement", Placement.worker.key, "?worker"],
["?placement", Placement.site.key, "?site"],
["not", ["?worker", Worker.trainedFor.key, "?site"]],
...Placement.where("?placement", { worker: "?worker", site: "?site" }),
Worker.not("?worker", { trainedFor: "?site" }),
],
},
identity: ["?worker", "?site"],
Expand Down
2 changes: 1 addition & 1 deletion docs/snippets/home/site-safety/output/stack.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{ "model": 22, "release": 7, "command": 24, "ask": 20, "derive": 26, "run": 17, "total": 116 }
{ "model": 18, "release": 7, "command": 17, "ask": 20, "derive": 25, "run": 17, "total": 104 }
34 changes: 5 additions & 29 deletions docs/snippets/home/site-safety/story.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,9 @@ export const onboard = Effect.gen(function* () {
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),
},
...Site.assert(harbor, { name: "Harbor" }, since),
...Site.assert(pier, { name: "Pier" }, since),
...Worker.assert(maria, { name: "Maria" }, since),
],
{ actor: "user:hr", commandId: "onboard:maria" },
);
Expand All @@ -40,15 +30,7 @@ export const onboard = Effect.gen(function* () {
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,
},
],
Worker.assert(maria, { trainedFor: [harbor] }, { validFrom: MAR_1, validTo: JUN_1 }),
{ actor: "user:trainer", commandId: "training:record:maria-harbor" },
);
});
Expand Down Expand Up @@ -82,13 +64,7 @@ export const correctTraining = Effect.gen(function* () {
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,
},
...Worker.assert(maria, { trainedFor: [harbor] }, { validFrom: MAR_5, validTo: JUN_1 }),
],
{
actor: "user:auditor",
Expand Down
Binary file modified e2e/docs-home.spec.ts-snapshots/home-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified e2e/docs-home.spec.ts-snapshots/home-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@
"build": "tsdown",
"dev": "tsdown --watch",
"test": "vitest --run",
"typecheck": "tsc --noEmit && tsc --noEmit -p test/runtime/tsconfig.json",
"typecheck": "tsc --noEmit && tsc --noEmit -p test/runtime/tsconfig.json && tsc --noEmit -p test/config/tsconfig.json",
"clean": "rm -rf dist",
"bench:config": "node bench/config/scale.mjs",
"bench:config:typecheck": "node bench/config/typecheck-budget.mjs"
Expand Down
Loading
Loading