@schema-reflection/algebra
v0.1.0
Published
Schema-native relations, graph extraction, and validation for Effect Schema.
Readme
Schema Algebra
Schema Algebra is a standalone schema-native programming toolkit for Effect Schema.
Effect Schema already validates values. Schema Algebra uses those same schema nodes as the place to declare reusable IDE semantics: relation metadata today, and paths, traversal, constraints, lenses, projections, diffs, patches, generation, fingerprints, migrations, previews, and agent-safe edits over time.
This package is intentionally UI-free. It does not depend on React, CodeMirror, the Schematics server, or browser-only APIs. It should stay usable in Node, browsers, tests, CLIs, and agents.
Status
Implemented:
Relation.id,Relation.ref, andRelation.refsschema combinatorsRelation.derivedIdfor definitions derived from object valuesRelation.pathRefandRelation.pathRefsfor path-like references- typed relation edges on references
- relation annotation storage on Effect Schema AST nodes
- relation graph extraction from a schema and decoded value
- duplicate ID validation
- unresolved reference validation
- scoped references through parent definitions or sibling fields
- relative scoped references such as
../formfrom nested values - relation diagnostics with structured paths and relation metadata
Planned:
- canonical
Pathhelpers - reusable
Traversalover Effect Schema ASTs and values - shared
Annotationutilities - field-scoped
Constraintdiagnostics - immutable
LensandPatchprimitives - schema-aware
Projection,Diff,Generate, andFingerprintmodules - workspace integration through
@schematics/core - IDE and agent features derived from the same algebra graph
The package was renamed from the earlier schema-relations experiment. The
current code is Phase 1 of the broader algebra plan.
Development
Requires Node.js 22.18+ and pnpm 10.20.0.
# From the Schema Reflection repository root
pnpm install
pnpm --filter @schema-reflection/algebra test
pnpm --filter @schema-reflection/algebra typecheck
pnpm --filter @schema-reflection/algebra build
pnpm --filter @schema-reflection/algebra packTo use a local build, install the archive produced by pnpm pack. Runtime and TypeScript exports both resolve to
dist; building does not require the Schematics workspace.
pnpm add /path/to/schema-reflection-algebra-0.0.0.tgz [email protected]Effect is pinned to 4.0.0-beta.68, matching the source package.
Documentation site
Run pnpm docs:dev to preview the VitePress site. Run pnpm docs:check and
pnpm docs:build to check the examples and produce the static site in build/.
The homepage uses Twoslash for TypeScript type hovers, matching the Triplex docs.
Quick Start
Declare IDs and references directly on schema fields:
import { Schema } from "effect";
import { Relation } from "@schema-reflection/algebra";
const ActionSchema = Schema.Struct({
id: Relation.id("Action"),
kind: Schema.Literals(["email", "task", "webhook"]),
label: Schema.String,
});
const WorkflowSchema = Schema.Struct({
id: Relation.id("Workflow", { display: "name" }),
name: Schema.String,
actionIds: Relation.refs("Action"),
});
const WorkspaceSchema = Schema.Struct({
actions: Schema.Array(ActionSchema),
workflows: Schema.Array(WorkflowSchema),
});Build a graph:
const graph = Relation.graph(WorkspaceSchema, {
actions: [{ id: "send-email", kind: "email", label: "Send email" }],
workflows: [{ id: "onboarding", name: "Onboarding", actionIds: ["send-email"] }],
});
graph.definitions;
// [
// { type: "Action", id: "send-email", path: ["actions", "0", "id"] },
// { type: "Workflow", id: "onboarding", path: ["workflows", "0", "id"], display: "Onboarding" },
// ]
graph.references;
// [
// { target: "Action", id: "send-email", path: ["workflows", "0", "actionIds", "0"] },
// ]Validate the same graph:
const diagnostics = Relation.validate(WorkspaceSchema, {
actions: [],
workflows: [{ id: "onboarding", name: "Onboarding", actionIds: ["missing"] }],
});
diagnostics;
// [
// {
// severity: "error",
// code: "unresolved-ref",
// path: ["workflows", "0", "actionIds", "0"],
// message: 'Unresolved Action reference "missing"',
// relation: { target: "Action", id: "missing", ... }
// }
// ]The top-level names are also exported for compatibility:
import { buildRelationGraph, validateRelations } from "@schema-reflection/algebra";API
Relation.id(type, options?)
Declares that a string field defines an entity ID.
const UserSchema = Schema.Struct({
id: Relation.id("User"),
});Options:
display: a path inside the containing object used as a human-readable label.scope: a relation scope. UseRelation.parent(type)for nested IDs, orRelation.path(path)for a value elsewhere in the root value.
Example with display text:
const WorkflowSchema = Schema.Struct({
id: Relation.id("Workflow", { display: "name" }),
name: Schema.String,
});Relation.ref(target, options?)
Declares that a string field references an entity ID.
const WorkflowSchema = Schema.Struct({
actionId: Relation.ref("Action"),
});Options:
scope: explicit scope resolved throughRelation.parentorRelation.path.scopedBy: path inside the nearest object whose string value determines the reference scope.
Relation.refs(target, options?)
Declares an array of references.
const WorkflowSchema = Schema.Struct({
actionIds: Relation.refs("Action"),
});This is equivalent to:
Schema.Array(Relation.ref("Action"));Relation.pathRef(target, options?)
Declares that a string field references a path-like ID. Validation behavior is
the same as Relation.ref; the graph records valueKind: "path" so consumers
can distinguish path references from ordinary IDs.
const MappingEntrySchema = Schema.Struct({
formField: Relation.pathRef("FormField", {
scopedBy: "../form",
edge: "maps_form_field",
}),
});scopedBy can point at a sibling path or use .. segments to resolve from an
ancestor object. This stays value-relative; Schema Algebra does not know about
files or workspaces.
Relation.pathRefs(target, options?)
Declares an array of path-like references.
const RuleSchema = Schema.Struct({
facts: Relation.pathRefs("AttributePath"),
});Relation.derivedId(schema, type, options)
Annotates an object schema as defining an ID derived from one of its own fields.
This is useful when the identifier is not literally named id, such as form
field paths or generated PDF field names.
const FormFieldSchema = Relation.derivedId(
Schema.Struct({
path: Schema.String,
label: Schema.String,
}),
"FormField",
{
id: "path",
scope: Relation.parent("Form"),
display: "label",
},
);Derived definitions are still ordinary graph definitions. They participate in duplicate detection, unresolved-reference validation, and graph queries.
Typed edges
References can carry an edge label.
const PolicySchema = Schema.Struct({
formId: Relation.ref("Form", { edge: "requires" }),
});Edges do not affect validation. They make the graph more useful for impact analysis, explainability, and agent queries.
Relation.parent(type)
Uses the nearest enclosing definition of type as the relation scope.
const FieldSchema = Schema.Struct({
id: Relation.id("Field", { scope: Relation.parent("Form") }),
label: Schema.String,
});
const FormSchema = Schema.Struct({
id: Relation.id("Form"),
fields: Schema.Array(FieldSchema),
});In this example, field IDs are scoped to their containing form ID.
Relation.path(path)
Uses a value at a root-relative path as the relation scope.
const DocumentSchema = Schema.Struct({
workspaceId: Schema.String,
localId: Relation.id("Document", { scope: Relation.path("workspaceId") }),
});Relation.key(path)
Normalizes a string path or tuple path into the internal path representation.
Relation.key("steps.0.actionId"); // ["steps", "0", "actionId"]
Relation.key(["steps", "0", "actionId"]); // ["steps", "0", "actionId"]Relation.graph(schema, value)
Returns a RelationGraph:
interface RelationGraph {
readonly definitions: readonly RelationDefinition[];
readonly references: readonly RelationReference[];
}Definitions include:
type: relation type, such as"Action"id: string ID valuepath: path to the ID fieldscope: optional resolved scopedisplay: optional display string
References include:
target: relation target typeid: referenced ID valuepath: path to the reference field or array elementscope: optional resolved scopescopedBy: optional path used to resolve the scope
Relation.validate(schema, value)
Returns structured diagnostics:
interface RelationDiagnostic {
readonly severity: "error" | "warning" | "info";
readonly code: "duplicate-id" | "unresolved-ref" | "invalid-relation-value";
readonly path: readonly string[];
readonly message: string;
readonly relation: RelationDefinition | RelationReference;
}Current validation checks:
- duplicate IDs with the same type, ID, and scope
- references that cannot resolve to a definition
- relation annotations attached to non-string values
Scoped Relations
Scoped relations are useful when IDs are only unique inside a parent entity.
const FieldSchema = Schema.Struct({
id: Relation.id("Field", { scope: Relation.parent("Form") }),
label: Schema.String,
});
const FormSchema = Schema.Struct({
id: Relation.id("Form"),
fields: Schema.Array(FieldSchema),
});
const PolicySchema = Schema.Struct({
id: Relation.id("Policy"),
formId: Relation.ref("Form"),
requiredFieldIds: Relation.refs("Field", { scopedBy: "formId" }),
});For each requiredFieldIds entry, the nearest object is the policy. The
scopedBy: "formId" option reads policy.formId and validates the field
reference against field definitions scoped to that form.
This supports invariants like:
Policy.requiredFieldIds[*]
references Field.id
scoped through Policy.formIdEffect Schema Traversal Notes
Relation extraction currently walks these Effect Schema AST shapes:
- type literals / structs
- tuple and array-like tuple rest nodes
- unions
- refinements
- transformations
- suspends
The traversal is intentionally private for now. Phase 2 will promote a stable
Traversal module so other algebra features do not need to reimplement AST
walking.
Roadmap
See the roadmap for proposed modules and Schematics integration.
Origin and compatibility
Extracted from bjacobso/schematics
(packages/algebra, commit b76a5312fb4feb4a9ed83d481d2749355b72342c).
The runtime source is unchanged; the existing tests are included with a small
type-safety fix for optional AST annotations. The relation annotation key remains
@schematics/algebra/relation so schemas annotated by the original package
remain compatible. The original MIT license and attribution are retained.
