Skip to content

feat(core): concise entity DSL (attribute modifiers, assert, where/not, pinned) - #45

Draft
bjacobso wants to merge 5 commits into
mainfrom
conductor/entity-write-helpers
Draft

bjacobso wants to merge 5 commits into
mainfrom
conductor/entity-write-helpers

Conversation

@bjacobso

@bjacobso bjacobso commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

What

  • EntityType.assert(id, values, options?) returns ReadonlyArray<TransactAssertOp>. Values are keyed by TypeScript alias and typed by their attribute, and every op is tagged with the entity type. Cardinality-many uses take an array and get one op per element. The function is pure, so ops from several entities and retracts can still go into one transact.
  • validTo on Attribute.assertion and the handles. Bounds must be finite, and the interval [validFrom, validTo) must not be empty.
  • GraphConstraint.pinned(release) returns Effect<{ configSnapshot, enforce }> from a single release, so the pinned id and the enforced rules can't come from different releases.
  • Attribute.use now records a literal cardinality in its type (Use<D, C>, CardinalityOf<O>).
  • Concise uses. Attribute definitions have .required(), .many() and .unique(), each accepting the remaining options (.required({ unique: true })). EntityType.make(name, fields) takes aliased fields directly, and a bare definition is an optional single-valued use. The { attributes } form still works, and both forms produce identical node cids (tested).
  • Type.where(entity, patterns) / Type.not(entity, patterns) build Datalog pattern and not clauses by alias. Values are variables or constants typed by the attribute; refs become typed ref constants. Datetime, JSON and blob constants throw, because Datalog has no constant form for them.

Payoff

The homepage snippets are rewritten with the helpers, and the homepage screenshots are refreshed. app.ts goes from 116 to 104 lines: model 22 → 18, command 24 → 17, derive 26 → 25. story.ts goes from 100 to 76. The timeline, audit, rejection, open-work and HTTP outputs are byte-identical, so the helpers produce exactly the ops and queries that were written by hand.

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

where: [
  ...Placement.where("?placement", { worker: "?worker", site: "?site" }),
  Worker.not("?worker", { trainedFor: "?site" }),
],

const facts = Placement.assert(placement, { worker, site }, { validFrom: MAR_2 });
return yield* triples.transact(facts, {
  actor: "user:dana",
  commandId: `create:${placement}`,
  correlationId: "request:7f3a",
  ...(yield* GraphConstraint.pinned(release)),
});

Design choices

  • All aliases are optional, and undefined is skipped. Requiredness is a property of the entity's state after the transaction, and enforce checks it atomically. Incremental writes such as adding trainedFor to an existing worker must not have to restate name. Allowing | undefined explicitly keeps conditional fields usable under exactOptionalPropertyTypes.
  • Unknown aliases and the wrong cardinality shape are type errors, and they also throw at runtime for untyped callers.
  • Ref values are EntityId, not branded per target type. A ReferenceTarget constraint catches a wrong target at commit.
  • assert, where and not are now reserved aliases, alongside entityType, schema, node and the others. This is a minor breaking change.
  • Modifiers can't be chained (.required().unique()), because Use already has required and unique fields. Combinations go through options instead.

Not in this PR (recommended follow-ups)

  1. A publish helper that deduplicates by cid. The current Effect.all(types.map(t => t.nodes)).flat() fails with DuplicateObjectError whenever two entity types share a global attribute, for example Employer and EmployerSummary in the configuration docs.
  2. Typed decode (Type.decode(facts)). Move HTTP's encodeEntity semantics into core and have HTTP reuse them. Typing it well needs a literal required on Use.
  3. A correction or replace helper. Deferred. Its useful form needs both ops and a precondition, and it is better designed after decode exists.

Open questions

  • Is assert the right name, compared with assertions or facts?
  • Should a strict create that requires the required aliases ship next to assert?
  • Should AssertionOptions accept | undefined?
  • Is GraphConstraint.pinned the right name and location? Should it be memoized per root cid?

Checks

pnpm check, pnpm pack:check and pnpm test:e2e pass. A Changeset for @triplex-build/triplex (minor) is included.

🤖 Generated with Claude Code


Open workspace in Conductor

bjacobso and others added 4 commits October 23, 2026 16:27
Replace the scattered homepage snippets with one type-checked app.ts whose
regions walk model, release, command, audit, open work, and storage + HTTP in
order. CI runs it to produce every output, including a real in-process HTTP
read and per-layer line counts shown by a new StackMap component.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Add EntityType.assert(id, values, options?) to build typed assert operations
for a whole entity, validTo on Attribute.assertion, and
GraphConstraint.pinned(release) to derive configSnapshot and enforce from one
release. Attribute.use now keeps a literal cardinality in its type so
cardinality-many uses take arrays. Rewrite the homepage command and story
snippets with the helpers; every scenario output except line counts is
unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e-helpers

# Conflicts:
#	docs/snippets/home/site-safety/app.ts
#	docs/snippets/home/site-safety/output/stack.json
#	docs/snippets/home/site-safety/story.ts
Mention Placement.assert and GraphConstraint.pinned in the command copy,
shorten the pinned comment so it fits the code block, and refresh the
homepage screenshots for the shorter command region.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@bjacobso
bjacobso changed the base branch from w-301/homepage-code-examples to main October 23, 2026 16:54
Attribute definitions gain .required(), .many(), and .unique(), and
EntityType.make accepts aliased fields directly, treating a bare definition
as an optional single-valued use. Both forms compile to identical nodes.
Type.where and Type.not build Datalog pattern and negation clauses by alias
with typed constants. Rewrite the homepage model and open-work regions with
them and refresh the homepage screenshots.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@bjacobso bjacobso changed the title feat(core): entity-level write helpers (EntityType.assert, validTo, GraphConstraint.pinned) feat(core): concise entity DSL (attribute modifiers, assert, where/not, pinned) Oct 23, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant