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

One command, one atomic transaction.

-

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.

Transactions and the journal
diff --git a/docs/snippets/home/site-safety/app.ts b/docs/snippets/home/site-safety/app.ts index 2884563..8df8db0 100644 --- a/docs/snippets/home/site-safety/app.ts +++ b/docs/snippets/home/site-safety/app.ts @@ -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 @@ -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 @@ -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"], diff --git a/docs/snippets/home/site-safety/output/stack.json b/docs/snippets/home/site-safety/output/stack.json index 3243e26..6736436 100644 --- a/docs/snippets/home/site-safety/output/stack.json +++ b/docs/snippets/home/site-safety/output/stack.json @@ -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 } diff --git a/docs/snippets/home/site-safety/story.ts b/docs/snippets/home/site-safety/story.ts index 9f1823f..7d8bbd2 100644 --- a/docs/snippets/home/site-safety/story.ts +++ b/docs/snippets/home/site-safety/story.ts @@ -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" }, ); @@ -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" }, ); }); @@ -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", diff --git a/e2e/docs-home.spec.ts-snapshots/home-dark.png b/e2e/docs-home.spec.ts-snapshots/home-dark.png index 7d997c4..b98d241 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 11814b3..d28b9bb 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 diff --git a/packages/core/package.json b/packages/core/package.json index 8786d9f..f26515b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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" diff --git a/packages/core/src/config/Attribute.ts b/packages/core/src/config/Attribute.ts index 533189a..42516ef 100644 --- a/packages/core/src/config/Attribute.ts +++ b/packages/core/src/config/Attribute.ts @@ -13,14 +13,17 @@ export const KIND = "attribute"; const ATTRIBUTE_KEY = /^:[a-z][a-z0-9_-]*\/[a-z][a-z0-9_-]*$/; export interface AssertionOptions { - /** Domain-valid time carried by the assertion descriptor. */ + /** Inclusive start of domain-valid time, in epoch milliseconds. */ readonly validFrom?: number; + /** Exclusive end of domain-valid time, in epoch milliseconds: `[validFrom, validTo)`. */ + readonly validTo?: number; } export interface Assertion { readonly attribute: K; readonly value: TripleValue; readonly validFrom?: number; + readonly validTo?: number; } export interface Definition { @@ -34,23 +37,49 @@ export interface Definition { | Schema.SchemaError >; readonly encode: (value: V) => TripleValue; + /** This attribute's use by an entity type, with every entity required to have it. */ + readonly required: = {}>( + options?: O, + ) => Use, CardinalityOf>; + /** This attribute's use by an entity type, with any number of values per entity. */ + readonly many: (options?: Omit) => Use, "many">; + /** This attribute's use by an entity type, with each value on at most one entity. */ + readonly unique: = {}>( + options?: O, + ) => Use, CardinalityOf>; } +export type Cardinality = "one" | "many"; + export interface UseOptions { readonly required?: boolean; - readonly cardinality?: "one" | "many"; + readonly cardinality?: Cardinality; /** Values of this attribute may occur on at most one entity of this type. */ readonly unique?: boolean; } -export interface Use> { +/** + * One entity type's use of a global attribute. `C` keeps a literal + * cardinality in the type, so entity-level helpers can accept an array for + * exactly the cardinality-many uses. + */ +export interface Use { readonly _tag: "AttributeUse"; readonly attribute: D; readonly required: boolean; - readonly cardinality: "one" | "many"; + readonly cardinality: C; readonly unique: boolean; } +/** The cardinality `use` records for an options literal; `one` unless `many` is explicit. */ +export type CardinalityOf = O extends { readonly cardinality: "many" } + ? "many" + : "cardinality" extends keyof O + ? O extends { readonly cardinality?: "one" } + ? "one" + : Cardinality + : "one"; + export type AnyDefinition = Definition; export type ValueOf = D extends Definition ? V : never; @@ -75,7 +104,7 @@ const make = ( refs: ReadonlyArray = [], ): Definition => { const key = validateKey(keyInput); - return { + const definition: Definition = { _tag: "Attribute", key, type, @@ -87,7 +116,14 @@ const make = ( attrs: { key, valueType: type }, refs, }), + // The spread loses `O`'s literal cardinality, which `CardinalityOf` restores. + required: (options) => + use(definition, { ...options, required: true }) as Use, never>, + many: (options) => use(definition, { ...options, cardinality: "many" }), + unique: (options) => + use(definition, { ...options, unique: true }) as Use, never>, }; + return definition; }; export const text = (key: K): Definition => @@ -127,25 +163,44 @@ export const ref = (attribute: D, options: UseOptions = {}): Use => ({ +export const use = ( + attribute: D, + options?: O, +): Use> => ({ _tag: "AttributeUse", attribute, - required: options.required ?? false, - cardinality: options.cardinality ?? "one", - unique: options.unique ?? false, + required: options?.required ?? false, + cardinality: (options?.cardinality ?? "one") as CardinalityOf, + unique: options?.unique ?? false, }); +const validateValidity = (options: AssertionOptions): void => { + if (options.validFrom !== undefined && !Number.isFinite(options.validFrom)) { + throw new TypeError("validFrom must be a finite epoch-millisecond value"); + } + if (options.validTo !== undefined && !Number.isFinite(options.validTo)) { + throw new TypeError("validTo must be a finite epoch-millisecond value"); + } + if ( + options.validFrom !== undefined && + options.validTo !== undefined && + options.validTo <= options.validFrom + ) { + throw new TypeError("validTo must be after validFrom; valid time is [validFrom, validTo)"); + } +}; + +/** A typed fact descriptor; spread it into an `assert` operation with an entity id. */ export const assertion = ( definition: D, value: ValueOf, options: AssertionOptions = {}, ): Assertion => { - if (options.validFrom !== undefined && !Number.isFinite(options.validFrom)) { - throw new TypeError("validFrom must be a finite epoch-millisecond value"); - } + validateValidity(options); return { attribute: definition.key, value: definition.encode(value), ...(options.validFrom !== undefined && { validFrom: options.validFrom }), + ...(options.validTo !== undefined && { validTo: options.validTo }), }; }; diff --git a/packages/core/src/config/EntityType.ts b/packages/core/src/config/EntityType.ts index 6ac5346..abde976 100644 --- a/packages/core/src/config/EntityType.ts +++ b/packages/core/src/config/EntityType.ts @@ -2,7 +2,10 @@ import { Effect } from "effect"; +import type { EntityId } from "../Branded.js"; import * as CanonicalJson from "../content/CanonicalJson.js"; +import type { NotClause, PatternClause, Term } from "../datalog/schema.js"; +import type { TransactAssertOp } from "../Triple.js"; import * as Attribute from "./Attribute.js"; import * as ConfigNode from "./ConfigNode.js"; import { ENTITY_SCHEMA_KIND } from "./EntityValidation.js"; @@ -17,10 +20,28 @@ const RESERVED_PROPERTIES = new Set([ "nodes", "attributes", "constraints", + "assert", + "where", + "not", ]); type Uses = Readonly>>; +/** An attribute field: a configured use, or a bare definition for an optional single value. */ +export type Field = Attribute.Use | Attribute.AnyDefinition; + +/** Normalize fields so a bare definition becomes its optional, cardinality-one use. */ +export type UsesOf>> = { + readonly [P in keyof F]: F[P] extends Attribute.Use + ? Attribute.Use + : F[P] extends Attribute.AnyDefinition + ? Attribute.Use + : never; +}; + +/** A Datalog variable such as `?worker`. */ +export type Variable = `?${string}`; + export interface AttributeHandle { readonly key: D["key"]; readonly type: TypeExpr.TypeExpr; @@ -34,6 +55,34 @@ export interface AttributeHandle { ) => Attribute.Assertion; } +/** The value one attribute use accepts: an array exactly when the use is cardinality-many. */ +export type UseValue = + U extends Attribute.Use + ? [C] extends ["many"] + ? ReadonlyArray> + : [C] extends ["one"] + ? Attribute.ValueOf + : Attribute.ValueOf | ReadonlyArray> + : never; + +/** + * Attribute values keyed by an entity type's TypeScript aliases. Every alias is + * optional, and `undefined` is skipped, because requiredness is a property of + * the entity's post-transaction state that graph-constraint enforcement checks + * atomically, not of any single write. + */ +export type Values = { + readonly [P in keyof A]?: UseValue | undefined; +}; + +/** + * Pattern values keyed by alias: a variable, or a constant typed by the + * attribute. A cardinality-many attribute matches one value per pattern. + */ +export type Patterns = { + readonly [P in keyof A]?: Variable | Attribute.ValueOf; +}; + type Handles = { readonly [P in keyof A]: AttributeHandle; }; @@ -51,6 +100,26 @@ export type EntityType = { | CanonicalJson.CanonicalEncodingError | import("effect").Schema.SchemaError >; + /** + * Build one `assert` operation per value, tagged with this entity type, in + * attribute declaration order. Cardinality-many attributes take an array. + * `options` applies the same valid-time interval to every operation. + */ + readonly assert: ( + entityId: EntityId, + values: Values, + options?: Attribute.AssertionOptions, + ) => ReadonlyArray; + /** + * Datalog pattern clauses for `entity`, one per alias in declaration order, + * so a query names attributes by alias instead of by key. + */ + readonly where: ( + entity: Variable | EntityId, + patterns: Patterns, + ) => ReadonlyArray; + /** A `not` clause excluding entities that match all of these patterns. */ + readonly not: (entity: Variable | EntityId, patterns: Patterns) => NotClause; /** Attribute definition nodes followed by the entity-schema node. */ readonly nodes: Effect.Effect< ReadonlyArray, @@ -66,13 +135,43 @@ const DefinitionType = TypeExpr.struct({ attributes: TypeExpr.required(TypeExpr.any), }); -export const make = ( +const isField = (value: unknown): value is Field => + typeof value === "object" && + value !== null && + "_tag" in value && + (value._tag === "Attribute" || value._tag === "AttributeUse"); + +/** + * Declare an entity type from aliased attribute fields: + * `EntityType.make("Worker", { name: Attribute.text(":worker/name").required() })`. + * A bare attribute definition is an optional, cardinality-one use. The + * `{ attributes: { ... } }` form is also accepted. + */ +export function make>>( + name: N, + definition: { readonly attributes: F }, +): EntityType>; +export function make>>( name: N, - definition: { readonly attributes: A }, -): EntityType => { + fields: F, +): EntityType>; +export function make( + name: string, + input: Readonly>, +): EntityType { if (!ENTITY_TYPE_NAME.test(name)) { throw new TypeError(`Entity type identity must be PascalCase; received ${name}`); } + const wrapped = + Object.keys(input).length === 1 && "attributes" in input && !isField(input["attributes"]); + const declared = (wrapped ? input["attributes"] : input) as Readonly>; + const attributeUses: Record> = {}; + for (const [property, field] of Object.entries(declared)) { + if (!isField(field)) { + throw new TypeError(`Entity attribute alias ${property} must be an attribute or its use`); + } + attributeUses[property] = field._tag === "Attribute" ? Attribute.use(field) : field; + } const fields: Record = {}; const handles: Record> = {}; @@ -80,7 +179,7 @@ export const make = ( const definitions = new Map(); const constraints: GraphConstraint.Definition[] = []; - for (const [property, attributeUse] of Object.entries(definition.attributes)) { + for (const [property, attributeUse] of Object.entries(attributeUses)) { if (RESERVED_PROPERTIES.has(property)) { throw new TypeError(`Entity attribute alias ${property} is reserved by the EntityType API`); } @@ -141,14 +240,90 @@ export const make = ( .map((attribute) => attribute.node), node, ]); + const assert = ( + entityId: EntityId, + values: Values, + options: Attribute.AssertionOptions = {}, + ): ReadonlyArray => { + const input = values as Readonly>; + for (const property of Object.keys(input)) { + if (!Object.prototype.hasOwnProperty.call(handles, property)) { + throw new TypeError(`Entity type ${name} has no attribute alias ${property}`); + } + } + const operations: TransactAssertOp[] = []; + for (const [property, handle] of Object.entries(handles)) { + const value = input[property]; + if (value === undefined) continue; + const many = handle.cardinality === "many"; + if (Array.isArray(value) !== many) { + throw new TypeError( + `${name}.${property} is cardinality-${handle.cardinality}; pass ${many ? "an array of values" : "a single value"}`, + ); + } + for (const item of many ? (value as ReadonlyArray) : [value]) { + operations.push({ + op: "assert", + entityId, + entityType: name, + ...handle.assertion(item, options), + }); + } + } + return operations; + }; + const where = ( + entity: Variable | EntityId, + patterns: Patterns, + ): ReadonlyArray => { + const input = patterns as Readonly>; + for (const property of Object.keys(input)) { + if (!Object.prototype.hasOwnProperty.call(handles, property)) { + throw new TypeError(`Entity type ${name} has no attribute alias ${property}`); + } + } + const clauses: PatternClause[] = []; + for (const [property, handle] of Object.entries(handles)) { + const value = input[property]; + if (value === undefined) continue; + clauses.push([entity, handle.key, term(`${name}.${property}`, handle.definition, value)]); + } + return clauses; + }; + const not = (entity: Variable | EntityId, patterns: Patterns): NotClause => { + const clauses = where(entity, patterns); + if (clauses.length === 0 || clauses.length > 9) { + throw new TypeError(`${name}.not takes between one and nine patterns`); + } + return ["not", ...clauses] as unknown as NotClause; + }; const entity = { entityType: name, schema, - attributes: handles as Handles, + attributes: handles as Handles, constraints, node, nodes, + assert, + where, + not, ...handles, }; - return entity as EntityType; + return entity as unknown as EntityType; +} + +/** A pattern-position term: variables pass through, constants encode as their attribute does. */ +const term = (path: string, definition: Attribute.AnyDefinition, value: unknown): Term => { + if (typeof value === "string" && value.startsWith("?")) return value; + const encoded = definition.encode(value); + switch (encoded.type) { + case "ref": + return { type: "ref", value: encoded.value }; + case "string": + case "number": + case "boolean": + return encoded.value; + default: + throw new TypeError(`${path} has ${encoded.type} values, which are not Datalog constants`); + } }; diff --git a/packages/core/src/config/GraphConstraint.ts b/packages/core/src/config/GraphConstraint.ts index e354590..0a0bbdb 100644 --- a/packages/core/src/config/GraphConstraint.ts +++ b/packages/core/src/config/GraphConstraint.ts @@ -208,6 +208,26 @@ export const collect = ( .sort((left, right) => left.key.localeCompare(right.key)); }); +/** + * Transaction metadata that governs a write by one release: `configSnapshot` + * records the release id, and `enforce` carries the graph constraints collected + * from that same release's root, so the two can never disagree. + */ +export const pinned = (release: { + readonly id: string; + readonly root: ConfigNode.ConfigNode; +}): Effect.Effect< + { + readonly configSnapshot: string; + readonly enforce: { readonly constraints: ReadonlyArray }; + }, + InvalidGraphConstraintError | DuplicateGraphConstraintError +> => + Effect.map(collect(release.root), (definitions) => ({ + configSnapshot: release.id, + enforce: enforcement(definitions), + })); + const pathOf = (attribute: string): string => `$[${JSON.stringify(attribute)}]`; const tripleValueKey = ( diff --git a/packages/core/test/config/EntityType.types.ts b/packages/core/test/config/EntityType.types.ts new file mode 100644 index 0000000..939fc62 --- /dev/null +++ b/packages/core/test/config/EntityType.types.ts @@ -0,0 +1,103 @@ +import type { TransactAssertOp } from "../../src/Triple.js"; +import { EntityId } from "../../src/Branded.js"; +import * as Attribute from "../../src/config/Attribute.js"; +import * as EntityType from "../../src/config/EntityType.js"; + +const Site = EntityType.make("Site", { + attributes: { name: Attribute.use(Attribute.text(":site/name"), { required: true }) }, +}); + +const options: Attribute.UseOptions = { cardinality: "many" }; +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" }), + nickname: Attribute.use(Attribute.text(":worker/nickname"), options), + active: Attribute.use(Attribute.boolean(":worker/active")), + }, +}); + +const maria = EntityId.make("worker:maria"); +const harbor = EntityId.make("site:harbor"); + +// Use keeps the literal cardinality, and only an explicit `many` widens to arrays. +export const one: Attribute.Use< + Attribute.Definition<":worker/name", string>, + "one" +> = Attribute.use(Attribute.text(":worker/name")); +export const requiredOne: Attribute.Use< + Attribute.Definition<":worker/name", string>, + "one" +> = Attribute.use(Attribute.text(":worker/name"), { required: true }); +// @ts-expect-error a non-literal options value leaves cardinality open +export const open: Attribute.Use< + Attribute.Definition<":worker/name", string>, + "one" +> = Attribute.use(Attribute.text(":worker/name"), options); +export const many: Attribute.Use< + Attribute.Definition<":worker/name", string>, + "many" +> = Attribute.use(Attribute.text(":worker/name"), { cardinality: "many" }); + +export const ops: ReadonlyArray = Worker.assert( + maria, + { name: "Maria", trainedFor: [harbor], active: true }, + { validFrom: 0, validTo: 1 }, +); + +// Every alias is optional and `undefined` is skipped, even with exactOptionalPropertyTypes. +const maybeName: string | undefined = undefined; +export const partial = Worker.assert(maria, { name: maybeName }); + +// A use whose cardinality is only known at runtime accepts either shape. +export const unknownShape = [ + Worker.assert(maria, { nickname: "M" }), + Worker.assert(maria, { nickname: ["M", "Mar"] }), +]; + +// @ts-expect-error a cardinality-many attribute takes an array +Worker.assert(maria, { trainedFor: harbor }); +// @ts-expect-error a cardinality-one attribute takes a single value +Worker.assert(maria, { name: ["Maria"] }); +// @ts-expect-error values are typed by their attribute +Worker.assert(maria, { active: "yes" }); +// @ts-expect-error aliases are closed +Worker.assert(maria, { title: "Supervisor" }); +// @ts-expect-error entity ids are branded +Worker.assert("worker:maria", { name: "Maria" }); + +// Modifiers keep literal cardinality, and a bare definition is a single optional value. +const Crew = EntityType.make("Crew", { + name: Attribute.text(":crew/name").required({ unique: true }), + sites: Attribute.ref(":crew/site", Site).many(), + lead: Attribute.ref(":crew/lead", Worker).required(), + night: Attribute.boolean(":crew/night"), +}); +const crew = EntityId.make("crew:night"); +export const crewOps = Crew.assert(crew, { name: "Night", sites: [harbor], lead: maria }); +export const sitesOne: Attribute.Use< + Attribute.Definition<":crew/site", EntityId>, + "many" +> = Attribute.ref(":crew/site", Site).many(); +// @ts-expect-error `many()` uses take arrays +Crew.assert(crew, { sites: harbor }); +// @ts-expect-error bare definitions take a single value +Crew.assert(crew, { night: [true] }); +// @ts-expect-error `required({ unique: true })` stays cardinality-one +Crew.assert(crew, { name: ["Night", "Late"] }); +export const requiredMany: Attribute.Use< + Attribute.Definition<":crew/name", string>, + "many" +> = Attribute.text(":crew/name").required({ cardinality: "many" }); + +// Patterns accept variables or values typed by the attribute. +export const crewPatterns = [ + ...Crew.where("?crew", { sites: "?site", night: true }), + Crew.not(crew, { sites: harbor }), +]; +// @ts-expect-error pattern constants are typed by their attribute +Crew.where("?crew", { night: "yes" }); +// @ts-expect-error pattern aliases are closed +Crew.where("?crew", { title: "?title" }); +// @ts-expect-error the entity position is a variable or an entity id +Crew.where("crew", { night: true }); diff --git a/packages/core/test/config/OntologyDsl.test.ts b/packages/core/test/config/OntologyDsl.test.ts index 5cf6edf..3c37107 100644 --- a/packages/core/test/config/OntologyDsl.test.ts +++ b/packages/core/test/config/OntologyDsl.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer } from "effect"; +import { EntityId } from "../../src/Branded.js"; import { KvTriples } from "../../src/kv/layers/KvTriplesLive.js"; import { Triples } from "../../src/store/Triples.js"; import * as Attribute from "../../src/config/Attribute.js"; @@ -90,6 +91,214 @@ describe("ontology DSL", () => { }); }); + it("carries a half-open valid-time interval on assertions", () => { + expect(Employer.name.assertion("Acme", { validFrom: 10, validTo: 20 })).toEqual({ + attribute: ":employer/name", + value: { type: "string", value: "Acme" }, + validFrom: 10, + validTo: 20, + }); + expect(() => Employer.name.assertion("Acme", { validTo: Number.NaN })).toThrow(/validTo/); + expect(() => Employer.name.assertion("Acme", { validFrom: 20, validTo: 20 })).toThrow( + /after validFrom/, + ); + }); + + it("builds typed assert operations for a whole entity", () => { + const Site = EntityType.make("Site", { + attributes: { name: Attribute.use(Attribute.text(":site/name"), { required: true }) }, + }); + const Crew = EntityType.make("Crew", { + attributes: { + name: Attribute.use(Attribute.text(":crew/name"), { required: true }), + sites: Attribute.use(Attribute.ref(":crew/site", Site), { cardinality: "many" }), + size: Attribute.use(Attribute.integer(":crew/size")), + }, + }); + const crew = EntityId.make("crew:night"); + const harbor = EntityId.make("site:harbor"); + const pier = EntityId.make("site:pier"); + + // Declaration order, one operation per many-value, and `undefined` skipped. + expect( + Crew.assert( + crew, + { size: 4, sites: [harbor, pier], name: "Night", ...{ size: undefined } }, + { validFrom: 10, validTo: 20 }, + ), + ).toEqual([ + { + op: "assert", + entityId: crew, + entityType: "Crew", + attribute: ":crew/name", + value: { type: "string", value: "Night" }, + validFrom: 10, + validTo: 20, + }, + ...[harbor, pier].map((site) => ({ + op: "assert", + entityId: crew, + entityType: "Crew", + attribute: ":crew/site", + value: { type: "ref", value: site }, + validFrom: 10, + validTo: 20, + })), + ]); + expect(Crew.assert(crew, { sites: [] })).toEqual([]); + expect(Crew.assert(crew, { size: 0 })).toEqual([ + expect.objectContaining({ attribute: ":crew/size", value: { type: "number", value: 0 } }), + ]); + + const untyped = Crew.assert as (id: EntityId, values: object) => unknown; + expect(() => untyped(crew, { title: "Night" })).toThrow(/no attribute alias title/); + expect(() => untyped(crew, { sites: harbor })).toThrow(/cardinality-many; pass an array/); + expect(() => untyped(crew, { name: ["Night"] })).toThrow(/cardinality-one; pass a single/); + expect(() => Crew.assert(crew, { name: "Night" }, { validFrom: 2, validTo: 1 })).toThrow( + /after validFrom/, + ); + for (const alias of ["assert", "where", "not"]) { + expect(() => + EntityType.make("Clash", { [alias]: Attribute.text(`:clash/${alias}`) }), + ).toThrow(/reserved/); + } + }); + + it.effect("pins a write to one release and enforces that release's constraints", () => + Effect.gen(function* () { + const triples = yield* Triples; + const config = yield* ConfigStore.ConfigStore; + const objects = yield* Effect.all([Employer.nodes, Worker.nodes, Employment.nodes]); + const release = yield* config.commit({ + label: "employment v1", + objects: objects.flat(), + ref: "live", + }); + const pinned = yield* GraphConstraint.pinned(release.snapshot); + expect(pinned).toEqual({ + configSnapshot: release.snapshot.id, + enforce: GraphConstraint.enforcement(yield* GraphConstraint.collect(release.snapshot.root)), + }); + + const acme = EntityId.make("employer:acme"); + const ada = EntityId.make("worker:ada"); + const employment = EntityId.make("employment:ada-acme"); + yield* triples.transact( + [...Employer.assert(acme, { name: "Acme" }), ...Worker.assert(ada, { name: "Ada" })], + { commandId: "onboard", ...pinned }, + ); + + const missingWorker = yield* triples + .transact(Employment.assert(employment, { employer: acme }), pinned) + .pipe(Effect.flip); + expect(missingWorker._tag).toBe("ConstraintViolationError"); + + yield* triples.transact(Employment.assert(employment, { employer: acme, worker: ada }), { + commandId: "hire", + ...pinned, + }); + const receipt = yield* triples.transactionByCommand("hire"); + expect(receipt?.configSnapshot).toBe(release.snapshot.id); + }).pipe(Effect.provide(ConfigStore.layer.pipe(Layer.provideMerge(KvTriples.layer)))), + ); + + it.effect("declares uses with attribute modifiers and bare definitions", () => + Effect.gen(function* () { + const Name = Attribute.text(":employer/name"); + expect(Name.required()).toEqual(Attribute.use(Name, { required: true })); + expect(Name.required({ unique: true })).toEqual( + Attribute.use(Name, { required: true, unique: true }), + ); + expect(Name.many({ required: true })).toEqual( + Attribute.use(Name, { required: true, cardinality: "many" }), + ); + expect(Name.unique()).toEqual(Attribute.use(Name, { unique: true })); + + // The concise form compiles to exactly the same configuration graph. + const Concise = EntityType.make("Employment", { + employer: EmploymentEmployer.required(), + worker: EmploymentWorker.required(), + }); + expect((yield* Concise.node).cid).toBe((yield* Employment.node).cid); + expect(Concise.constraints.map(({ rule }) => rule)).toEqual( + Employment.constraints.map(({ rule }) => rule), + ); + + const Bare = EntityType.make("EmployerSummary", { name: EmployerName }); + expect((yield* Bare.node).cid).toBe((yield* EmployerSummary.node).cid); + expect(Bare.name.required).toBe(false); + expect(Bare.name.cardinality).toBe("one"); + + expect(() => + EntityType.make("Broken", { name: "text" } as unknown as Record), + ).toThrow(/must be an attribute or its use/); + }), + ); + + it("builds Datalog patterns by alias", () => { + const Site = EntityType.make("Site", { name: Attribute.text(":site/name").required() }); + const Crew = EntityType.make("Crew", { + sites: Attribute.ref(":crew/site", Site).many(), + size: Attribute.integer(":crew/size"), + night: Attribute.boolean(":crew/night"), + formed: Attribute.instant(":crew/formed"), + }); + const harbor = EntityId.make("site:harbor"); + + expect(Crew.where("?crew", { night: true, sites: "?site", size: 4 })).toEqual([ + ["?crew", ":crew/site", "?site"], + ["?crew", ":crew/size", 4], + ["?crew", ":crew/night", true], + ]); + expect(Crew.where(EntityId.make("crew:night"), { sites: harbor })).toEqual([ + ["crew:night", ":crew/site", { type: "ref", value: "site:harbor" }], + ]); + expect(Crew.not("?crew", { sites: "?site", night: false })).toEqual([ + "not", + ["?crew", ":crew/site", "?site"], + ["?crew", ":crew/night", false], + ]); + + const untyped = Crew.where as (entity: string, patterns: object) => unknown; + expect(() => untyped("?crew", { title: "?title" })).toThrow(/no attribute alias title/); + expect(() => Crew.where("?crew", { formed: 0 })).toThrow(/datetime values/); + expect(() => Crew.not("?crew", {})).toThrow(/between one and nine/); + }); + + it.effect("answers queries written with entity patterns", () => + Effect.gen(function* () { + const triples = yield* Triples; + const acme = EntityId.make("employer:acme"); + const ada = EntityId.make("worker:ada"); + const grace = EntityId.make("worker:grace"); + yield* triples.transact([ + ...Employer.assert(acme, { name: "Acme" }), + ...Worker.assert(ada, { name: "Ada" }), + ...Worker.assert(grace, { name: "Grace" }), + ...Employment.assert(EntityId.make("employment:ada"), { employer: acme, worker: ada }), + ]); + + const unemployed = yield* triples.queryAll({ + find: ["?name"], + where: [ + ...Worker.where("?worker", { name: "?name" }), + Employment.not("?employment", { worker: "?worker" }), + ], + }); + expect(unemployed.results).toEqual([{ "?name": "Grace" }]); + + const employees = yield* triples.queryAll({ + find: ["?name"], + where: [ + ...Employment.where("?employment", { employer: acme, worker: "?worker" }), + ...Worker.where("?worker", { name: "?name" }), + ], + }); + expect(employees.results).toEqual([{ "?name": "Ada" }]); + }).pipe(Effect.provide(KvTriples.layer)), + ); + it.effect("makes reference targets explicit in the configuration graph", () => Effect.gen(function* () { const node = yield* EmploymentEmployer.node; diff --git a/packages/core/test/config/tsconfig.json b/packages/core/test/config/tsconfig.json new file mode 100644 index 0000000..9fca289 --- /dev/null +++ b/packages/core/test/config/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { "rootDir": "../..", "noEmit": true }, + "include": ["EntityType.types.ts"], + "exclude": [] +}