diff --git a/.changeset/entity-write-helpers.md b/.changeset/entity-write-helpers.md new file mode 100644 index 0000000..6ae9ffa --- /dev/null +++ b/.changeset/entity-write-helpers.md @@ -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. diff --git a/docs/agents.md b/docs/agents.md index 6f44450..86162db 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -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 @@ -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 @@ -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 diff --git a/docs/configuration.md b/docs/configuration.md index 544efa8..3699483 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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(); @@ -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 diff --git a/docs/host-integration.md b/docs/host-integration.md index d673c53..d72de44 100644 --- a/docs/host-integration.md +++ b/docs/host-integration.md @@ -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 diff --git a/docs/index.md b/docs/index.md index 47010f5..88bb14d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -111,7 +111,7 @@ npm install @triplex-build/triplex effect@4.0.3
03 · Command
Dana’s placement records who acted, a command ID that makes retries safe, and the release whose constraints were checked in the same transaction.
+Placement.assert turns typed attribute values into facts. Dana’s placement records who acted, a command ID that makes retries safe, and, through GraphConstraint.pinned, 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.