docs: add metagraph-module and forward-look architecture specs, remove SchemaBuilder legacy support

Graph type definitions as TypeBox Modules — the core architecture evolution
for @alkdev/storage. The SchemaBuilder is removed (no existing consumers),
replaced by direct TypeModule construction with Metagraph.Import() for
base attribute composition and Type.Composite() for node/edge type
specialization.

Key additions:
- metagraph-module.md: Module pattern, edge constraints as named entries,
  SchemaBuilder equivalence, DB bridge contracts (moduleToDbSchema return
  type, validateNode/validateEdge signatures), 10 design decisions (DD1-DD10)
- forward-look.md: pointer abstraction (ujsx ValuePointer analogy, JPATH
  Module), dbtype table rendering relationship, ujsx as universal IR pipeline

Critical corrections from architecture review:
- Type.Composite uses IntersectEvaluated (intersection, not Object.assign
  override) — overlapping keys with subtype relationships resolve correctly
- Type.Ref inside Type.Composite within a Module is verified working
- BaseNode/BaseEdge use Metagraph.Import() for same-package Modules (Option B),
  not local re-declaration (no circular dep within same package)
- Edge constraints use Type.String() for node type name arrays (not Type.Ref) —
  constraints contain names, not schemas
This commit is contained in:
2026-05-28 15:32:56 +00:00
parent 33a5b0816d
commit 5ce93b1357
5 changed files with 1123 additions and 9 deletions

View File

@@ -16,7 +16,7 @@ instances) from the earlier `@ade` prototype.
├── mod.ts # Re-exports graphs/ only (zero db deps) ├── mod.ts # Re-exports graphs/ only (zero db deps)
├── deno.json # JSR config, imports, tasks, lint rules ├── deno.json # JSR config, imports, tasks, lint rules
├── src/ ├── src/
│ ├── graphs/ # Schema types + SchemaBuilder (no db deps) │ ├── graphs/ # Metagraph Module + bridge functions (no db deps)
│ ├── sqlite/ # SQLite host (drizzle-orm/libsql) │ ├── sqlite/ # SQLite host (drizzle-orm/libsql)
│ │ ├── tables/ # Drizzle table definitions │ │ ├── tables/ # Drizzle table definitions
│ │ ├── relations.ts # Drizzle relations │ │ ├── relations.ts # Drizzle relations
@@ -28,7 +28,7 @@ instances) from the earlier `@ade` prototype.
### Subpath Exports (JSR/npm) ### Subpath Exports (JSR/npm)
- `@alkdev/storage` → graphs types + SchemaBuilder (zero deps) - `@alkdev/storage`Metagraph Module, graph type definitions (zero deps)
- `@alkdev/storage/sqlite` → SQLite tables, relations, client (drizzle-orm + - `@alkdev/storage/sqlite` → SQLite tables, relations, client (drizzle-orm +
libsql) libsql)
- `@alkdev/storage/pg` → PostgreSQL tables, relations, client (NOT YET - `@alkdev/storage/pg` → PostgreSQL tables, relations, client (NOT YET
@@ -79,6 +79,7 @@ Key changes from the originals:
`GRAPH_STATUS`) `GRAPH_STATUS`)
- `client.ts` refactored to be injectable - `client.ts` refactored to be injectable
- Module-level `db` and `client` exports removed - Module-level `db` and `client` exports removed
- `SchemaBuilder` removed — replaced by `Type.Module()` construction
## File Conventions ## File Conventions
@@ -99,6 +100,10 @@ See `docs/architecture/` for detailed specifications:
- `overview.md` — Package purpose, exports, design decisions, open questions - `overview.md` — Package purpose, exports, design decisions, open questions
- `metagraph.md` — Core graph model, schema types, SchemaBuilder, attribute - `metagraph.md` — Core graph model, schema types, SchemaBuilder, attribute
storage storage
- `metagraph-module.md` — Graph type definitions as TypeBox Modules (evolution
of metagraph.md), naming conventions, migration path
- `forward-look.md` — Connections to dbtype, graph pointers, ujsx universal IR
pipeline
- `sqlite-host.md` — SQLite tables, relations, client factory, porting notes - `sqlite-host.md` — SQLite tables, relations, client factory, porting notes
- `encrypted-data.md` — Encrypted data design (planned), crypto utility, node - `encrypted-data.md` — Encrypted data design (planned), crypto utility, node
type modeling type modeling

View File

@@ -0,0 +1,256 @@
---
status: draft
last_updated: 2026-05-30
---
# Forward Look: Pointers, dbtype, and Universal IR
How the Module-based metagraph connects to the broader @alkdev ecosystem —
typed graph pointers, dbtype table rendering, and the ujsx universal IR
pipeline. These are forward-looking designs that justify why certain
structural decisions are made now (DD9, DD10 in
[metagraph-module.md](./metagraph-module.md)).
## Overview
Three packages in the @alkdev ecosystem share the same pipeline shape:
```
Schema (TypeBox Module) → Element Tree (ujsx) → Host (HostConfig)
```
| Package | Schema | Element tree | Host |
|---------|--------|-------------|------|
| `@alkdev/ujsx` | `UJSX` Module | `<element>`, `<root>` | DOM, custom |
| `@alkdev/dbtype` | Table/Column schemas | `<table>`, `<column>` | SQLite, PG, MySQL drizzle dialects |
| `@alkdev/storage` | `Metagraph` Module | ⚠️ Future: `<graphSchema>`, `<nodeType>` | ⚠️ Future: graph DB hosts |
When storage's graph type definitions align with the Module pattern, they
join this same pipeline. The immediate benefit is recursive/cross-referencing
schemas (today). The forward benefit is that graph type definitions, table
definitions, and pointer expressions can all be authored as ujsx element trees
rendered to different hosts.
## Pointer Abstraction
Addressing nodes and edges within a graph instance follows the same pattern as
ujsx's `ValuePointer` and `selectNode`/`setNode` — and the same pattern as
jsonpathly's JPATH Module for path expressions.
### ujsx's pointer system (proven)
ujsx already implements a reactive pointer system:
```ts
class ValuePointer<T> {
private _signal: Signal<T>;
private _path: string[];
get value(): T
set value(v: T)
get reactive(): ReadonlySignal<T>
get path(): string[]
}
function selectNode(root: UNode, path: string[]): UNode | undefined
function setNode(root: UNode, path: string[], value: UNode): UNode
```
This addresses elements within a ujsx tree by path segments (child indices,
prop names). A graph instance has analogous structure: nodes identified by
key, edges identified by key, attributes addressed by JSON path.
### Graph pointer analogy
```ts
// ujsx pointer: element tree → path → value
selectNode(root, ["children", 0, "props", "name"])
// Graph pointer: graph instance → path → value
selectNode(graph, ["nodes", "call-001", "attributes", "requestId"])
```
The structural analogy:
| ujsx concept | Graph concept |
|-------------|---------------|
| Element tree root | Graph instance |
| `UNode` | Node or Edge |
| `path: string[]` | Key path: `["nodes", key]` or `["edges", key]` |
| `selectNode(root, path)` | `selectGraphNode(graph, path)` |
| `setNode(root, path, value)` | `setGraphNode(graph, path, value)` (via repository) |
### JPATH Module (jsonpathly)
The research shows that JSONPath expressions can themselves be a TypeBox Module
(`JPATH = Type.Module({...})` with recursive `Type.Ref("Subscript")`). This means
pointer paths are not just runtime strings — they're typed schemas that can be
validated and composed.
For graph storage, this opens the possibility of **typed graph queries** — a
pointer expression like `nodes.call-001.attributes.requestId` has a schema that
validates against the graph type's Module. If `CallNode` doesn't have a
`requestId` field, the pointer expression is invalid at compile time.
### Scope for v1
The pointer abstraction is a forward-looking design. For v1:
- **Repository functions** use direct key-based addressing:
`findNode(graphId, nodeKey)`, `findEdge(graphId, edgeKey)`
- **Attribute access** is untyped JSON retrieval:
`node.attributes.requestId`
- **The Module** validates attribute shapes, but query paths are strings
The jump to typed pointers requires either the JPATH Module (for path
validation) or ujsx-style `ValuePointer` with signals (for reactive graph
observation). Both are post-v1 concerns, but the graph type Module makes them
feasible because it provides the schema the pointer validates against.
## Relationship to @alkdev/dbtype
`@alkdev/dbtype` defines database schemas as ujsx element trees and renders them
to Drizzle dialects via HostConfig. Storage's SQLite/PG table definitions are a
natural consumer of this pipeline.
### Current vs. Future Table Definition
**Current** (manual Drizzle table defs):
```ts
export const graphTypes = sqliteTable("graph_types", {
id: text("id").primaryKey(),
name: text("name").notNull(),
config: text("config", { mode: "json" }).notNull(),
// ...
});
```
**Future** (dbtype element tree → HostConfig rendering):
```tsx
const GraphTypesEl = h("table", { name: "graph_types" },
h(IdColumn, {}),
h("column", { name: "name", type: "string", notNull: true }),
h("column", { name: "config", type: "json", mode: "json", notNull: true }),
h(AuditColumns, {}),
);
const root = createRoot(sqliteHost, {});
root.render(GraphTypesEl);
const drizzleTable = root.ctx.tables.graph_types;
```
### Why this matters for storage
1. **Single source of truth**: Today's `sqlite/tables/` and future `pg/tables/`
define the same shapes in two different Drizzle dialects. dbtype renders the
same element tree to both — no manual duplication.
2. **Schema extraction**: `extractTable()` produces both TypeBox schemas (for
validation) and column metadata (for Drizzle rendering) from the same tree.
Storage gets `SelectGraphType` and `InsertGraphType` schemas for free.
3. **Module alignment**: dbtype assembles extracted schemas into a
`Type.Module` for cross-table references. Storage's metagraph Module and
dbtype's table Module could share a namespace — the `graph_types.config`
column stores the JSON Schema from `Metagraph.Config`.
### v1 approach
For v1, storage continues with manual Drizzle table definitions. The dbtype
integration is a post-v1 migration path because:
- dbtype is Phase 0 (architecture complete, no implementation)
- The manual defs work and are well-understood
- The Module pattern for graph types can be adopted independently (no dbtype
dependency)
When dbtype reaches Phase 1 (implementation), storage can migrate table defs
to dbtype elements one table at a time. The Module-based graph type definitions
are already compatible — they're both TypeBox `Type.Module` objects.
## ujsx as Universal IR
The three packages (ujsx, dbtype, storage) share the same pipeline shape:
**Schema → Element Tree → Host**. This is not coincidental — ujsx is a
universal declarative IR, and different "render targets" are just different
HostConfigs.
### What this could look like
```tsx
// Graph type definitions as ujsx elements (future)
const CallGraphSchema = h("graphSchema", { name: "call-graph" },
h("config", { type: "directed", multi: false, allowSelfLoops: false }),
h("nodeType", { name: "call" },
h(BaseNode, {}),
h("attr", { name: "requestId", type: "string", required: true }),
h("attr", { name: "status", ref: "CallStatus" }),
),
h("edgeType", { name: "triggered" },
h(BaseEdge, {}),
h("attr", { name: "type", literal: "triggered" }),
),
h("edgeConstraints", { edgeType: "triggered",
allowedSourceTypes: ["Call"],
allowedTargetTypes: ["Call", "Subcall"] }),
);
```
Rendered to different hosts:
| Host | Output |
|------|--------|
| TypeBox Host | `Type.Module({ CallNode: ..., TriggeredEdge: ... })` |
| SQLite Host | `sqliteTable("node_types", { ... })` + `sqliteTable("edge_types", { ... })` |
| PG Host | `pgTable("node_types", { ... })` + `pgTable("edge_types", { ... })` |
| graphology Host | `SerializedGraph` format |
| Documentation Host | Mermaid diagram, typed API docs |
### What's real today vs. aspirational
| Capability | Status |
|-----------|--------|
| `Type.Module` for graph type definitions | ✅ Ready to implement now |
| Codegen from TypeScript interfaces → Module entries | ✅ TsToModule exists |
| dbtype element trees → Drizzle tables | ⚠️ dbtype Phase 0, no implementation |
| `<graphSchema>` ujsx elements | ⚠️ Conceptual — needs HostConfig design |
| Typed graph pointers via JPATH | ⚠️ Conceptual — needs JPATH Module design |
| Reactive graph observation via ValuePointer | ⚠️ Conceptual — needs signal integration |
The Module-based graph type definitions (this spec) are the **first concrete
step** in this pipeline. Everything else builds on having a `Type.Module` as
the schema source of truth.
## Constraints on Current Design
The forward-looking patterns documented here constrain the Module evolution
design in [metagraph-module.md](./metagraph-module.md):
1. **The Module format must be self-contained**`Type.Module({...})` entries
with `Type.Ref` and `Type.Composite` are the same structures that a ujsx
TypeBox Host would produce. If the Module format were an ad-hoc builder
output, it couldn't be rendered by a different host later.
2. **Edge constraints must be schema entries, not just DB columns** — the
constraint data needs to survive serialization/deserialization and be
validatable independently. DB-only columns can't do this.
3. **The base attribute schemas (`BaseNode`, `BaseEdge`) must be TypeBox
schemas** — not Drizzle column definitions, not builder-internal objects.
Only TypeBox schemas can be composed via `Type.Composite`, referenced via
`Type.Ref`, and serialized to JSON Schema.
4. **No ujsx dependency** — storage's Module-based graph types join the
pipeline conceptually, not as a runtime dependency. The `Type.Module`
output is the same shape that a ujsx HostConfig would produce, but storage
doesn't need ujsx to create it. The alignment is structural, not dependent.
## References
- ujsx pointer system: `/workspace/@alkdev/ujsx/src/core/pointer.ts`
- ujsx HostConfig adapter: `/workspace/@alkdev/ujsx/src/host/config.ts`
- dbtype architecture: `/workspace/@alkdev/dbtype/docs/architecture/README.md`
- dbtype elements: `/workspace/@alkdev/dbtype/docs/architecture/elements.md`
- dbtype module: `/workspace/@alkdev/dbtype/docs/architecture/module.md`
- JPATH Module (JSONPath as TypeBox Module): `/workspace/research/typebox_research/ujsx/jpath.gen.ts`
- jsonpathly source: `/workspace/jsonpathly/`
- Module evolution spec: [metagraph-module.md](./metagraph-module.md)

View File

@@ -0,0 +1,842 @@
---
status: draft
last_updated: 2026-05-30
---
# Metagraph as TypeBox Module
Graph type definitions as `Type.Module` — aligning with the ujsx pattern for
recursive schemas, cross-package references, codegen, and graphology serialization.
## Overview
A graph type definition is naturally a TypeBox Module. It has named entries
(node types, edge types, config) that reference each other with `Type.Ref()`,
compose with `Type.Composite()`, and can cross-reference other Modules with
`Import()`. This is the same pattern used by `@alkdev/ujsx` (where `UJSX` is
a Module with `UPrimitive`, `UElement`, `URoot`, `UNode` recursively referencing
each other).
The current `SchemaBuilder` produces a flat `GraphSchema` object — an ad-hoc
`Record<string, NodeType>` + `Record<string, EdgeType>`. This works but
creates friction:
1. **No cross-graph-type references** — a call graph node type can't reference
`CallStatus` from `@alkdev/flowgraph` without manual `Type.Intersect`
composition. Each package defines schemas independently, duplicating types.
2. **No graphology compatibility** — the schema output is a flat JSON object,
not a format that maps to graphology's `import()`/`export()`. Consumers
manually map node/edge attributes.
3. **No codegen leverage**`TsToModule` generates TypeBox Modules from
TypeScript interfaces. The SchemaBuilder can't consume Module output, so
codegen-produced types must be manually translated.
The Module approach treats each graph type as a `Type.Module`, aligning storage
with how ujsx already works — recursive types via `Ref`, composition via
`Composite`, cross-references via `Import`.
For the forward-looking view of how this connects to dbtype, graph pointers,
and the ujsx universal IR pipeline, see [forward-look.md](./forward-look.md).
## The Pattern (Proven in ujsx)
`@alkdev/ujsx` already uses this pattern (ADR-002: "TypeBox Module as type
registry"):
```ts
// ujsx: schema.ts
export const UJSX = Type.Module({
UPrimitive: Type.Union([Type.String(), Type.Number(), Type.Boolean(), Type.Null()]),
PropValue: Type.Union([..., Type.Ref("UNode"), ...]),
UniversalProps: Type.Object({}, { additionalProperties: Type.Union([Type.Ref("PropValue"), Type.Undefined()]) }),
UElement: Type.Object({
type: Type.String(),
props: Type.Ref("UniversalProps"),
children: Type.Array(Type.Ref("UNode")), // recursive!
}),
URoot: Type.Object({
type: Type.Literal("root"),
props: Type.Ref("UniversalProps"),
children: Type.Array(Type.Ref("UNode")), // recursive!
}),
UNode: Type.Union([Type.Ref("UPrimitive"), Type.Ref("UElement"), Type.Ref("URoot")]),
});
```
Key properties:
- **`Type.Ref("UNode")`** resolves within the Module's `$defs` — recursive
references are natural
- **`UJSX.Import("UElement")`** lets other Modules reference ujsx types — the
referenced Module's `$defs` are embedded in the importing Module's JSON Schema
- **`Value.Check(UJSX.Import("UElement"), node)`** validates at runtime
- **`Static<typeof UJSX>`** gives TypeScript types (or hand-written types for
non-serializable entries like `ComponentFn`)
Graph type definitions have the same structure — named entries that reference
each other, with possible cross-references to other packages' Modules.
## Proposed: GraphType as a TypeBox Module
### Base Module: Metagraph
The metagraph meta-schema itself is a Module:
```ts
export const Metagraph = Type.Module({
Config: Type.Object({
type: Type.Union([
Type.Literal("directed"),
Type.Literal("undirected"),
Type.Literal("mixed"),
], { default: "mixed" }),
multi: Type.Boolean({ default: true }),
allowSelfLoops: Type.Boolean({ default: true }),
}),
BaseNode: Type.Object({
created: Type.Optional(Type.String({ format: "date-time" })),
modified: Type.Optional(Type.String({ format: "date-time" })),
metadata: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
}),
BaseEdge: Type.Object({
type: Type.String(),
metadata: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
}),
});
```
### Concrete Graph Type: CallGraph
A specific graph type is also a Module. It composes `BaseNode`/`BaseEdge` via
`Type.Composite()` (same as ujsx's `Mdast.Node: Type.Composite([Unist.Import("UnistNode"), ...])`):
```ts
export const CallGraph = Type.Module({
// Config is specific — literal values, not unions with defaults
Config: Type.Object({
type: Type.Literal("directed"),
multi: Type.Literal(false),
allowSelfLoops: Type.Literal(false),
}),
// Node types compose BaseNode (from Metagraph) with call-specific attributes
CallNode: Type.Composite([
Metagraph.Import("BaseNode"),
Type.Object({
requestId: Type.String(),
operationId: Type.String(),
status: Type.Ref("CallStatus"),
parentRequestId: Type.Optional(Type.String()),
input: Type.Unknown(),
output: Type.Optional(Type.Unknown()),
identity: Type.Optional(Type.Ref("Identity")),
startedAt: Type.Optional(Type.String({ format: "date-time" })),
completedAt: Type.Optional(Type.String({ format: "date-time" })),
}),
]),
SubcallNode: Type.Composite([
Metagraph.Import("BaseNode"),
Type.Object({
requestId: Type.String(),
parentRequestId: Type.String(),
operationId: Type.String(),
status: Type.Ref("CallStatus"),
}),
]),
// Edge types
TriggeredEdge: Type.Composite([
Metagraph.Import("BaseEdge"),
Type.Object({
type: Type.Literal("triggered"),
}),
]),
DependsOnEdge: Type.Composite([
Metagraph.Import("BaseEdge"),
Type.Object({
type: Type.Literal("depends_on"),
}),
]),
// Shared types referenced by node/edge entries
CallStatus: Type.Union([
Type.Literal("pending"),
Type.Literal("running"),
Type.Literal("completed"),
Type.Literal("failed"),
Type.Literal("aborted"),
]),
Identity: Type.Object({
id: Type.String(),
scopes: Type.Array(Type.String()),
resources: Type.Optional(Type.Record(Type.String(), Type.Array(Type.String()))),
}),
});
```
### Cross-Module References
`Module.Import()` allows one Module to reference entries from another:
```ts
import { FlowGraph } from "@alkdev/flowgraph/schema";
const CallGraph = Type.Module({
// ...
CallNode: Type.Composite([
Type.Ref("BaseNode"),
Type.Object({
status: FlowGraph.Import("CallStatus"), // from flowgraph
identity: Type.Optional(FlowGraph.Import("Identity")), // from flowgraph
// ...
}),
]),
});
```
This is exactly the `Mdast.Import("UnistNode")` pattern from the ujsx research.
**⚠️ Import embedding**: `Module.Import()` embeds the referenced Module's `$defs`
into the importing Module's JSON Schema output. When `CallGraph` imports from
`FlowGraph`, the resulting JSON Schema includes all of `FlowGraph`'s definitions
in `$defs`. See DD6 for how the repository layer handles this.
**Decision (DD6)**: The repository layer stores **dereferenced entry schemas**
each `node_types` row gets its entry's resolved JSON Schema (with inline `$defs`
for just its transitive references), not the entire importing Module. This
avoids storage bloat and version coupling issues.
### BaseNode/BaseEdge: Local Re-declaration vs Metagraph.Import
`Type.Ref()` only resolves entries within the *same* Module. In the `CallGraph`
example above, `Type.Ref("BaseNode")` requires `BaseNode` to be an entry in the
`CallGraph` Module. There are two strategies for getting `BaseNode`/`BaseEdge`
into a concrete graph type Module:
**Option A: Re-declare locally** (shown in the example above). Each concrete
Module includes its own `BaseNode`/`BaseEdge` entries. The schemas are identical
to `Metagraph.BaseNode`/`Metagraph.BaseEdge` — you copy them in. Simple, but
creates duplication. If the base schemas evolve, each concrete Module must be
updated independently.
**Option B: Metagraph.Import**. The concrete Module imports from `Metagraph`:
```ts
const CallGraph = Type.Module({
CallNode: Type.Composite([
Metagraph.Import("BaseNode"),
Type.Object({ requestId: Type.String(), ... }),
]),
});
```
This avoids duplication but embeds `Metagraph`'s `$defs` into `CallGraph`'s
JSON Schema output. For most cases, `Metagraph` is small (3 entries) so the
bloat is minimal. If `Metagraph` grows, this could become a concern.
**Decision: Option B for same-package Modules (recommended), Option A as
fallback for external-package Modules**.
For Modules defined within `@alkdev/storage` (like `CallGraph` in
`modules/call-graph.ts`), `Metagraph.Import("BaseNode")` has no circular
dependency issue — both `Metagraph` and `CallGraph` live in the same package.
The `Import` approach avoids duplication and keeps the base schemas in one
place.
For Modules defined outside `@alkdev/storage` (e.g., in `@alkdev/flowgraph`),
Option A applies because external packages should not depend on storage's
`Metagraph` Module (see Open Question 1). Those packages re-declare their own
base schemas or define them independently.
The v1 reference Modules in `modules/` should use Option B. If a future
consumer defines a `CallGraph` Module externally, they can choose either
approach — the schemas are structurally identical.
**Verified**: `Type.Composite([Type.Ref("BaseNode"), Type.Object({...})])`
within a Module resolves correctly. Test confirms: `Value.Check(Module.Import("CallNode"), validData)` passes.
### Type.Composite vs Type.Intersect
The Module approach uses `Type.Composite` for extending `BaseNode`/`BaseEdge`,
not `Type.Intersect`. This matches the ujsx pattern where `Mdast.Node` is
`Type.Composite([Unist.Import("UnistNode"), Type.Object({...})])`.
The difference:
- **`Type.Intersect`** creates a JSON Schema `allOf` — the result is a
`TIntersect` wrapper with nested schemas. Consumers must traverse `allOf`
to access properties.
- **`Type.Composite`** produces an **intersection evaluated into a flat
`TObject`** — overlapping keys are intersected via `IntersectEvaluated`
and the result is a single object with no `allOf` wrapper. The output
shape is `{ key1: Intersect([typeA, typeB]), key2: typeC, ... }`.
**Both use intersection semantics for overlapping keys.** Composite is NOT
an `Object.assign` override — when overlapping keys have varying (incompatible)
types, the result is `never`. When overlapping keys have a subtype
relationship (like `Type.String()` and `Type.Literal("triggered")`), the
intersection resolves to the narrower type (`Type.Literal("triggered")`),
which is the correct behavior.
**Why Composite over Intersect for graph types**: The output is a flat
`TObject` that maps directly to a node/edge attribute schema. `Intersect`
produces a `TIntersect` wrapper that would need unwrapping. For graph types
where base and concrete attributes have non-overlapping keys (most cases)
or subtype-only overlaps (like `type: Type.String()``type: Type.Literal(...)`),
Composite evaluates to the same result but in a more convenient shape.
**Design constraint**: Do not use `Type.Composite` with overlapping keys of
incompatible types. If `BaseEdge` has `type: Type.String()` and a concrete
edge type needs `type: Type.Number()`, the intersection evaluates to `never`.
For graph types, this is not a concern — base and concrete keys either don't
overlap, or the overlap is a valid subtype narrowing (union → literal).
### Config: Literal Values for Specific Graph Types
The general `Metagraph.Config` has `Type.Union` with defaults (for
construction-time validation: "any valid config"). Specific graph types use
`Type.Literal` for frozen config values:
```ts
// General (construction): Type.Union([Type.Literal("directed"), Type.Literal("undirected"), ...])
// Specific (frozen): Type.Literal("directed")
```
The transition: consumer provides a general config → validated against
`Metagraph.Config` → the specific graph type Module uses `Type.Literal` to
freeze the value. The `SchemaBuilder` (during transition) performs this
narrowing automatically.
### Edge Type Constraints: named constraint entries
Edge type constraints (`allowedSourceTypes`/`allowedTargetTypes`) are **named
Module entries**, not columns bolted onto DB rows. This makes them first-class
parts of the schema — queryable, validatable, and composable:
```ts
export const CallGraph = Type.Module({
// ...
TriggeredEdge: Type.Composite([
Type.Ref("BaseEdge"),
Type.Object({ type: Type.Literal("triggered") }),
]),
TriggeredEdgeConstraints: Type.Object({
edgeType: Type.Literal("triggered"),
allowedSourceTypes: Type.Array(Type.String()), // node type names: ["Call"]
allowedTargetTypes: Type.Array(Type.String()), // node type names: ["Call", "Subcall"]
}),
DependsOnEdge: Type.Composite([
Type.Ref("BaseEdge"),
Type.Object({ type: Type.Literal("depends_on") }),
]),
DependsOnEdgeConstraints: Type.Object({
edgeType: Type.Literal("depends_on"),
allowedSourceTypes: Type.Array(Type.String()),
allowedTargetTypes: Type.Array(Type.String()),
}),
});
```
**Why Module entries instead of DB columns** (DD7 revised):
1. **Schema-level validation**: `Value.Check(CallGraph.TriggeredEdgeConstraints, data)`
validates that constraint data is well-formed. With DB columns, there's no
schema validation — just JSON arrays in text columns.
2. **Serialization**: The constraint entries serialize to JSON Schema with
`$defs`, enabling `Value.Diff` for migration detection and `FromSchema`
for round-tripping.
3. **DB mapping**: The `moduleToDbSchema()` function extracts
`*EdgeConstraints` entries and writes their `allowedSourceTypes`/
`allowedTargetTypes` fields to the existing `edge_types` columns. The DB
schema doesn't change — the Module entries are the source of truth, the
DB columns are the persistence projection.
**Why Type.String() not Type.Ref()**: The constraint arrays contain node type
*names* (strings like `"Call"`), not node type *schemas*. `Type.Ref("CallNode")`
would mean "an element must validate against the CallNode schema," which is
incorrect — the constraint is about which named node types are valid endpoints,
not about node data shapes. The naming convention (`*Node` suffix) provides an
implicit structural contract: string values in `allowedSourceTypes` should
correspond to `*Node` entry names in the same Module. This is enforced by
`moduleToDbSchema()` at Module-to-DB projection time, not by the schema itself.
See Open Question 4 for the `Type.Ref` vs `Type.String` trade-off.
**Transition note**: The current DB schema stores `allowedSourceTypes` and
`allowedTargetTypes` as JSON text columns (arrays of strings, default `[]`).
In the Module, these become `Type.Array(Type.String())` entries — the DB
column values are the same string arrays. `moduleToDbSchema()` extracts them
directly. Read-path reconstruction resolves the names back to Module entries
for validation.
**Empty array semantics**: In the DB, `[]` means "no restriction" (any node
type valid). In the Module, omitting the `*EdgeConstraints` entry means the
same thing. An explicit entry with empty arrays is not valid — it would mean
"no node types are valid at this endpoint," which is nonsensical. The
repository layer enforces this convention.
### Entry Naming Convention
Within a graph type Module, entries follow a naming convention that distinguishes
their role (DD8):
| Suffix | Role | Maps to DB |
|--------|------|------------|
| `Config` | Graph configuration | `graph_types.config` |
| `*Node` | Node type attribute schema | `node_types.schema` |
| `*Edge` | Edge type attribute schema | `edge_types.schema` |
| `*EdgeConstraints` | Edge endpoint validation rules | `edge_types.allowedSourceTypes`/`allowedTargetTypes` |
| `*Enum` or bare name | Shared enum/type | Embedded in `node_types.schema`/`edge_types.schema` |
| `BaseNode`, `BaseEdge` | Base attribute schemas | Composed into `*Node`/`*Edge` entries |
The `moduleToDbSchema()` function uses this convention to map Module entries to
the `node_types` and `edge_types` tables. Entries ending in `Node` become rows
with `name = entryNameWithoutSuffix ("Node")` and `schema = resolved entry`.
Same for `*Edge`. The `Config` entry maps to `graph_types.config`.
## graphology Serialization Bridge
The bridge between Modules and graphology is the `SerializedGraph` pattern that
`@alkdev/flowgraph` already uses:
```ts
// flowgraph's current pattern (standalone schemas)
const CallGraphSerialized = SerializedGraph(
CallNodeAttrs, // node attribute schema
CallEdgeAttrs, // edge attribute schema
Type.Object({}), // graph-level attributes
);
// Module pattern (entries from the Module)
const CallGraphSerialized = SerializedGraph(
CallGraph.CallNode, // entry from Module — resolves Refs through $defs
CallGraph.DependsOnEdge, // entry from Module
Type.Object({}),
);
```
Graphology's serialized format:
```ts
{
attributes: {}, // Graph-level attributes (empty for most graphs)
options: {
type: "directed", // From CallGraph.Config
multi: false,
allowSelfLoops: false,
},
nodes: [
{ key: "call-001", attributes: { requestId, operationId, status, ... } },
],
edges: [
{ key: "call-001->call-002", source: "call-001", target: "call-002",
attributes: { type: "triggered" } },
],
}
```
The mapping:
- `CallGraph.Config``options`
- `CallGraph.CallNode` → validates `nodes[].attributes`
- `CallGraph.TriggeredEdge` → validates `edges[].attributes`
This is **complementary** to `@alkdev/flowgraph`'s `SerializedGraph` — storage
produces the data, flowgraph operates on it in memory. The `SerializedGraph`
factory function stays the same — its schema arguments now come from Module
entries instead of standalone schemas. The `moduleToDbSchema()`
function extracts per-entry schemas for DB storage; the `moduleToGraphology()`
function produces the graphology import format for hydration.
## DB Persistence Bridge
The repository layer maps Module entries to the existing 6-table schema:
1. **`graph_types`** row: `name` = Module name, `config` = `CallGraph.Config`
JSON Schema (with defaults resolved)
2. **`node_types`** rows: one row per `*Node` entry, `name` = entry name
(minus `Node` suffix), `schema` = resolved entry JSON Schema
3. **`edge_types`** rows: one row per `*Edge` entry, `name` = entry name
(minus `Edge` suffix), `schema` = resolved entry JSON Schema,
`allowedSourceTypes`/`allowedTargetTypes` from constraint entries
On read, the repository layer reconstructs the Module from DB rows:
`Value.Check(CallGraph.CallNode, node.attributes)` validates node data against
the Module entry.
**`Module.Import()` embedding**: When a Module entry references entries from
another Module (e.g., `FlowGraph.Import("CallStatus")`), the JSON Schema for
that entry includes the referenced entries in `$defs`. The repository layer
stores the **dereferenced entry** — the resolved JSON Schema with inline `$defs`
for transitive references — not the entire importing Module. This avoids
duplicating all of FlowGraph's definitions in every CallGraph node_types row.
### Bridge Functions
#### `moduleToDbSchema(module)`
Maps a graph type Module to DB row values for the metagraph tables.
```ts
interface DbGraphTypeRow {
name: string;
config: Record<string, unknown>;
}
interface DbNodeTypeRow {
name: string;
schema: Record<string, unknown>;
}
interface DbEdgeTypeRow {
name: string;
schema: Record<string, unknown>;
allowedSourceTypes: string[];
allowedTargetTypes: string[];
}
interface DbSchema {
graphType: DbGraphTypeRow;
nodeTypes: DbNodeTypeRow[];
edgeTypes: DbEdgeTypeRow[];
}
function moduleToDbSchema(module: TModule): DbSchema
```
**Error behavior**: Throws on:
- Module entries that don't match any naming convention (`*Node`, `*Edge`,
`Config`, `*EdgeConstraints`, `*Enum`, `BaseNode`, `BaseEdge`). Bare names
without a recognized suffix are treated as shared types (embedded in other
entries' schemas), not as independent DB rows.
- `*EdgeConstraints` entries that reference edge type entries not present in
the Module (the `edgeType` field must match an `*Edge` entry name).
- `*EdgeConstraints` entries with empty `allowedSourceTypes` and
`allowedTargetTypes` arrays (empty = "no types allowed", which is
nonsensical; omit the entry instead for "no restriction").
- Module without a `Config` entry (all graph types require configuration).
#### `validateNode(module, entryName, data)` / `validateEdge(module, entryName, data)`
Validates node or edge data against a Module entry.
```ts
function validateNode(module: TModule, entryName: string, data: unknown): boolean
function validateEdge(module: TModule, entryName: string, data: unknown): boolean
```
Returns `true` if data passes `Value.Check` against the resolved Module entry.
Throws if `entryName` doesn't match an `*Node`/`*Edge` entry in the Module.
Does NOT throw on invalid data — returns `false`.
### Type.Any vs Type.Unknown
The existing `types.ts` uses `Type.Any()` for `metadata` and `schema` fields.
The Module examples use `Type.Unknown()`. These have different JSON Schema
outputs:
- `Type.Any()``{}` (accepts anything, no validation)
- `Type.Unknown()``{}` with `additionalProperties: true` semantics
For the Module approach, **`Type.Unknown()` is canonical**. It's the more
explicit choice — it communicates "this field stores arbitrary data, no
validation applied." `Type.Any()` is a legacy from the original TypeBox API.
The existing `types.ts` schemas should be aligned to `Type.Unknown()` during
the Module migration (Phase 1).
### Performance Expectations
Graph type Modules are small — typically 520 entries (one Config, 25 node
types, 25 edge types, 25 shared types, 25 constraint entries). The
`Value.Check` cost scales with schema complexity, not Module size; only the
resolved entry schema is checked, not the entire Module.
The dereferenced entry strategy (DD6) means each DB row stores only its own
JSON Schema with transitive `$defs` — typically 13 KB per entry. A full
graph type's schemas total ~1050 KB in the DB. This is negligible compared
to the node/edge data being stored.
"Validate on read" (Open Question 5) has a per-read cost. For
high-throughput paths, the repository layer can cache the resolved Module
entry locally after first read, avoiding repeated `Value.Check` for known-good
data. This is a repository-layer optimization, not a Module design concern.
## Codegen Path
`TsToModule` generates TypeBox Modules from TypeScript interfaces. The path from
TypeScript to graph type:
```
TypeScript interface → TsToModule.Generate() → TypeBox Module entry
@alkdev/flowgraph CallNodeAttrs → flowgraph schema.ts → FlowGraph Module
@alkdev/taskgraph TaskNodeAttrs → taskgraph schema.ts → TaskGraph Module
@alkdev/operations Identity → operations types.ts → Operations Module
```
Since flowgraph already defines `CallNodeAttrs` as a standalone TypeBox schema,
the codegen can produce a Module entry from it. Storage's `CallGraph` Module then
composes `BaseNode` with `CallNodeAttrs` via `Type.Composite`, or imports from
the flowgraph Module if flowgraph exports one (see Open Question 1).
## SchemaBuilder.build() → Module Equivalence
The current `SchemaBuilder.build()` returns a `GraphSchema` — a flat object with
`config`, `nodeTypes: Record<string, NodeType>`, and `edgeTypes: Record<string, EdgeType>`.
A `Type.Module` with the same entries is essentially the same thing.
### What the builder does internally
```
SchemaBuilder
.config({ type: "directed", multi: false })
.nodeType("call", CallNodeSchema)
.edgeType("triggered", EdgeSchema, { allowedSourceTypes: ["call"] })
.build()
internally builds:
defs = {
Config: Type.Object({ type: Literal("directed"), multi: Literal(false), ... }),
CallNode: CallNodeSchema,
TriggeredEdge: EdgeSchema,
TriggeredEdgeConstraints: Type.Object({ edgeType: Literal("triggered"), ... }),
}
return Type.Module(defs)
```
The `.build()` return type changes from `GraphSchema` (flat object) to
`TModule` (TypeBox Module). The `SchemaBuilder` is removed — consumers use
Module construction directly.
### Why this works
The `SchemaBuilder` was always building a module — it just didn't have a
module system to target. Named entries referencing each other via strings is
exactly what `Type.Ref()` does natively. The Module format:
- Gives `Type.Ref()` instead of loose schema objects
- Gives `Module.Import()` instead of `Type.Intersect` for cross-package refs
- Gives JSON Schema `$defs` that map directly to DB storage
- Gives `Value.Check`, `Value.Diff`, `Value.Errors` on the full type system
- Gives codegen compatibility via `TsToModule.Generate()`
For the forward-looking connections (typed graph pointers, dbtype table
rendering, ujsx HostConfig for graph schemas), see
[forward-look.md](./forward-look.md).
## Design Decisions
### DD1: Module replaces SchemaBuilder
The SchemaBuilder is replaced by TypeBox Modules. The Module format provides
what SchemaBuilder was building toward, but natively:
- Named references → `Type.Ref()` instead of loose schema objects
- Cross-module imports → `Module.Import()` instead of `Type.Intersect`
- JSON Schema `$defs` → maps directly to DB storage
- Codegen compatibility → `TsToModule.Generate()` produces Module entries
### DD2: SchemaBuilder removed
The `SchemaBuilder` is removed. Consumers use `Type.Module()` construction
directly, with `Type.Ref()`, `Type.Composite()`, and `Metagraph.Import()`
as the building blocks. The `moduleToDbSchema()` function replaces
`SchemaBuilder.build()` as the bridge from Module to DB rows.
### DD3: Config as a Module entry with Literal values
Specific graph type Modules use `Type.Literal` for config values. The general
`Metagraph.Config` with `Type.Union` and defaults is for construction-time
validation. The specific Module freezes the config to exact values.
### DD4: Node/edge attribute schemas are Module entries, not `Type.Any()`
At the application layer, node and edge attribute schemas are named Module entries
with full type safety (`CallGraph.CallNode`, not `schema: Type.Any()`). At the
DB storage layer, the meta-schemas (`NodeType`, `EdgeType`) still have
`schema: Type.Unknown()` because the DB stores arbitrary JSON Schema blobs — the
Module entries are the application-level validation, the DB is the persistence
layer.
**Mapping**: The repository layer maps between Module entries and DB rows using
the naming convention (`*Node``node_types`, `*Edge``edge_types`, `Config`
`graph_types.config`). On read, it looks up the graph type's Module to get
the validation schema for each entry.
### DD5: Graphology import/export as the bridge to in-memory graphs
Storage produces data that `@alkdev/flowgraph`'s `FlowGraph.fromJSON()` and
`SerializedGraph` consume. The Module entries validate data flowing in both
directions. Storage doesn't need its own graphology dependency — it produces
the JSON format, flowgraph consumes it.
### DD6: Repository stores dereferenced entry schemas
To avoid `Module.Import()` embedding the full `$defs` of referenced Modules in
every DB row, the repository layer stores **dereferenced entry schemas** — each
`node_types` row gets its entry's resolved JSON Schema with just the transitive
`$defs` it needs, not the entire importing Module's definitions.
### DD7: Edge type constraints as named Module entries, not DB columns
Edge type constraints (`allowedSourceTypes`/`allowedTargetTypes`) are named
Module entries (e.g., `TriggeredEdgeConstraints` with `Type.Array(Type.String())`
fields), not just DB columns. This gives them schema validation and
serialization. The repository layer projects these entries to the existing
`edge_types` columns (arrays of node type name strings). The DB schema
doesn't change — the Module entries are the source of truth.
**Revised from original DD7** which stored constraints only as DB columns.
Named entries are strictly more capable: they validate and serialize;
DB columns are their persistence projection.
### DD8: Naming convention for Module entries
Within a graph type Module, entries are named with role-distinguishing suffixes:
`*Node` for node types, `*Edge` for edge types, `Config` for graph configuration,
`*EdgeConstraints` for edge endpoint constraints, and bare names or `*Enum` for
shared types. `moduleToDbSchema()` uses this convention to map entries to DB
tables.
**Alternative considered**: Explicit metadata/decorators on entries (e.g.,
`{ kind: "nodeType", name: "call", schema: ... }`). Rejected because it adds
boilerplate without adding information — the suffix convention is simpler
and sufficient for the expected Module size (520 entries).
### DD9: Pointer abstraction is forward-looking, not v1
The structural analogy between ujsx's `ValuePointer`/`selectNode`/`setNode` and
graph node/edge addressing is real, but implementing typed graph pointers (via
JPATH Module or reactive signals) is a post-v1 concern. For v1, repository
functions use direct key-based addressing and the Module validates attribute
shapes. The Module's existence makes typed pointers feasible later because
it provides the schema the pointer validates against.
**Alternative considered**: Implement typed pointers in v1 via a lightweight
`GraphPointer<T>` wrapper. Rejected because it requires either JPATH Module
dependency or reactive signal integration, both of which add complexity
without clear v1 benefit. Direct key-based addressing is sufficient.
### DD10: dbtype integration is post-v1
`@alkdev/dbtype`'s UJSX→Module→Host pipeline can eliminate the manual dual
definition of SQLite/PG table schemas. But dbtype is Phase 0 (architecture
complete, no implementation). For v1, storage uses manual Drizzle table
definitions. The Module-based graph type definitions are compatible with dbtype
because both produce `Type.Module` objects — the integration path is clear.
**Alternative considered**: Implement dbtype integration in v1 alongside Module
migration. Rejected because it adds a dependency on an unimplemented package
and the manual table definitions work well. The cost of deferring is continued
dual SQLite/PG maintenance, which is manageable for 6 metagraph tables.
## What Changes
| Current | New |
|---------|-----|
| `types.ts` — standalone schemas | `modules/metagraph.ts``Metagraph` Module |
| `schemaBuilder.ts` — fluent builder | Removed — replaced by Module construction |
| `types.ts``BaseNodeAttributes`, `BaseEdgeAttributes` | `Metagraph` Module entries |
| `types.ts``GraphConfig`, `GraphStatus`, `GraphBaseType` | `Metagraph` Module entries + const objects |
| `allowedSourceTypes`/`allowedTargetTypes` as DB columns only | Named `*EdgeConstraints` Module entries (projected to DB columns) |
| No concrete graph type Modules | `modules/call-graph.ts`, `modules/acl-graph.ts`, etc. |
| No bridge between Module ↔ DB ↔ graphology | `bridge.ts` — validation, DB mapping, graphology format |
## What Doesn't Change
- **Database tables** — same 6 metagraph tables, same columns, same relations
- **SQLite host** — table definitions, relations, client factory unchanged
- **PostgreSQL host** (planned) — same shapes, different dialect
- **`@alkdev/typebox` dependency** — same. Modules are a core TypeBox feature
- **Encryption utility** — unchanged, can be a Module entry in `SecretGraph`
- **`allowedSourceTypes`/`allowedTargetTypes`** — same DB columns, same semantics
(Module entries are the source of truth, projected to DB columns by
`moduleToDbSchema()`)
## Migration Path
1. **Phase 1**: Add `Metagraph` Module, replace `types.ts` and remove
`schemaBuilder.ts`. Export Module construction API.
2. **Phase 2**: Add `bridge.ts` with `moduleToDbSchema()`, `validateNode()`,
`validateEdge()`.
3. **Phase 3**: Add `modules/` directory with reference graph type Modules
(call-graph, acl-graph, task-graph, secret-graph). These use
`Metagraph.Import()` for `BaseNode`/`BaseEdge` and `Type.Composite()`
for node/edge type composition.
4. **Phase 4**: Add `moduleToGraphology()` and `fromGraphologyExport()` for the
graphology bridge. Storage produces the format, flowgraph consumes it.
Acceptance criteria per phase:
- **Phase 2 complete**: `moduleToDbSchema()` produces values compatible with all
6 existing metagraph tables
- **Phase 3 complete**: Reference Modules validate against their flowgraph/taskgraph
counterparts
## Relationship to Other Packages
| Package | What changes | What stays |
|---------|-------------|------------|
| `@alkdev/storage` | `types.ts` → Module, `schemaBuilder.ts` → removed, new `modules/` and `bridge.ts` | Tables, relations, crypto, client factory |
| `@alkdev/flowgraph` | `CallNodeAttrs`, `CallEdgeAttrs`, `CallStatus` become Module entries (optional, exported from `/schema` subpath) | FlowGraph class, analysis, all runtime logic |
| `@alkdev/taskgraph` | `TaskGraphNodeAttributes`, `DependencyEdge` become Module entries (optional) | TaskGraph class, analysis, all runtime logic |
| `@alkdev/operations` | `Identity`, `AccessControl` become Module entries (optional) | Registry, call protocol, adapters |
| `@alkdev/pubsub` | No change | Transport layer |
| `@alkdev/ujsx` | No change (already a Module) | The pattern we're following |
| `@alkdev/dbtype` | No change (Phase 0) | Future: storage table defs could be dbtype element trees |
## Open Questions
1. **Should `@alkdev/flowgraph` export a `Type.Module`, or should storage define
its own entries with documented correspondence?** Flowgraph currently exports
`CallNodeAttrs` as a standalone `Type.Object`. To use `Import()`, flowgraph
needs to export a Module. But storage can start with standalone schemas and
`Type.Composite([BaseNode, CallNodeAttrs])` — no dependency on flowgraph.
Migrate to `Import()` when flowgraph provides a Module. **This avoids a
circular dependency: `@alkdev/storage` does NOT depend on `@alkdev/flowgraph`.**
2. **Should concrete graph type Modules live in storage or in their respective
packages?** Call-graph attribute schemas are defined by flowgraph's domain, not
storage's. Storage provides the metagraph *framework* (the `Metagraph` Module
with `BaseNode`, `BaseEdge`, `Config`). Concrete graph types like `CallGraph`
could live either in storage (as reference implementations) or in their
respective packages (flowgraph exports `CallGraph` Module alongside
`CallNodeAttrs`). **Decision: Both.** Storage provides reference Modules in
`modules/` that consumers can use directly or replace. Flowgraph may also
export a Module — the two are compatible via Module `$defs`.
3. ~~**How does `SchemaBuilder.build()` return a Module while maintaining backward
compat?**~~ **Resolved**: No backward compat needed (no existing consumers).
`SchemaBuilder` is removed. Consumers use `Type.Module()` construction
directly. See "SchemaBuilder.build() → Module Equivalence".
4. **Should `*EdgeConstraints` entries use `Type.Ref("CallNode")` or
`Type.String()` for allowed source/target types?** Using `Type.Ref`
would mean "each element in the array must validate against the CallNode
schema," which is semantically wrong — the constraint is about which named
node types are valid endpoints, not about data shapes. Using `Type.String()`
matches the actual semantics (arrays of node type names) but loses the
structural link. **Decision: `Type.String()`** — the constraint arrays
contain names, not schemas. The naming convention provides an implicit
contract that string values should correspond to `*Node` entry names,
enforced by `moduleToDbSchema()` at projection time.
5. **How does the graph pointer abstraction interact with the repository layer?**
For v1, repository functions use direct key-based addressing. Typed pointers
(JPATH Module, reactive ValuePointer) could layer on top of the repository
later. The key question: does the repository return raw data (untyped JSON),
or does it validate against the Module before returning? **Decision: validate
on read** — if the data doesn't match the Module entry, throw. This makes
typed pointers safe: any value you get from the repo conforms to the schema.
## References
- ujsx schema (proven Module pattern): `/workspace/@alkdev/ujsx/src/core/schema.ts`
- ujsx ADR-002 (Module as type registry): `/workspace/@alkdev/ujsx/docs/architecture/decisions/002-typebox-module-as-registry.md`
- ujsx schema docs: `/workspace/@alkdev/ujsx/docs/architecture/schema.md`
- TsToModule codegen: `/workspace/research/typebox_research/codegen/ts-to-module.ts`
- ujsx Module examples: `/workspace/research/typebox_research/ujsx/unist.gen.ts`, `/workspace/research/typebox_research/ujsx/mdast.gen.ts`
- Flowgraph schema (standalone TypeBox, not yet Module): `/workspace/@alkdev/flowgraph/src/schema/`
- Flowgraph SerializedGraph factory: `/workspace/@alkdev/flowgraph/src/schema/graph.ts`
- Forward-looking connections (pointers, dbtype, ujsx IR): [forward-look.md](./forward-look.md)
- Current metagraph model: [metagraph.md](./metagraph.md)
- Ecosystem integration: [overview.md](./overview.md)

View File

@@ -5,6 +5,11 @@ last_updated: 2026-05-28
# Metagraph Model # Metagraph Model
> **Superseded by [metagraph-module.md](./metagraph-module.md)** — graph type
> definitions are now TypeBox Modules, not standalone schemas + SchemaBuilder.
> This document describes the current (pre-Module) data model. The Module
> migration is specified in metagraph-module.md.
The core data model: graph types define schemas, node types define shapes, edge The core data model: graph types define schemas, node types define shapes, edge
types define relationships, and typed graph instances hold actual data. types define relationships, and typed graph instances hold actual data.

View File

@@ -29,7 +29,7 @@ ecosystem.
@alkdev/storage/ @alkdev/storage/
├── mod.ts → re-exports graphs/ (zero db deps) ├── mod.ts → re-exports graphs/ (zero db deps)
├── src/ ├── src/
│ ├── graphs/ → schema types + SchemaBuilder (no db deps) │ ├── graphs/ → Metagraph Module, bridge functions (no db deps)
│ ├── sqlite/ → SQLite host (drizzle-orm/libsql) │ ├── sqlite/ → SQLite host (drizzle-orm/libsql)
│ │ ├── tables/ → drizzle table definitions │ │ ├── tables/ → drizzle table definitions
│ │ ├── relations.ts → drizzle relational mappings │ │ ├── relations.ts → drizzle relational mappings
@@ -85,12 +85,16 @@ type with specific node types (operation call, subcall) and edge types
This trades some query convenience for generality. Domain-specific queries are This trades some query convenience for generality. Domain-specific queries are
built on top of the graph query layer, not baked into table schemas. built on top of the graph query layer, not baked into table schemas.
### D3: SchemaBuilder as the primary API surface ### D3: Type.Module as the primary API surface
The `SchemaBuilder` fluent API is the intended way to construct graph type The `Type.Module()` construction API is the intended way to define graph type
definitions. It validates against TypeBox schemas at build time, ensuring that definitions. The `Metagraph` Module provides base entries (`BaseNode`,
graph/node/edge type definitions are structurally sound before they're persisted `BaseEdge`, `Config`); concrete graph types compose them via `Metagraph.Import()`
to the database. and `Type.Composite()`. The `SchemaBuilder` is removed.
This replaces the earlier fluent builder pattern. The Module format provides
native `Type.Ref()` for internal references, `Module.Import()` for cross-package
references, and JSON Schema `$defs` that map directly to DB storage.
### D4: Injectable clients, no module-level side effects ### D4: Injectable clients, no module-level side effects
@@ -147,7 +151,7 @@ consumed by the hub and spokes, not by storage itself.
### Implemented ### Implemented
- Graph schema types and SchemaBuilder - Graph schema types and Metagraph Module (replaces SchemaBuilder)
- SQLite host: 6 metagraph tables + actors table + Drizzle relations + client - SQLite host: 6 metagraph tables + actors table + Drizzle relations + client
factory factory
- TypeBox select/insert schemas generated from Drizzle tables (drizzlebox) - TypeBox select/insert schemas generated from Drizzle tables (drizzlebox)
@@ -296,6 +300,8 @@ storage node attributes and operations call events), they should either:
## References ## References
- Metagraph Module evolution: [metagraph-module.md](./metagraph-module.md)
- Forward-looking connections: [forward-look.md](./forward-look.md)
- Operations architecture: `/workspace/@alkdev/operations/docs/architecture/README.md` - Operations architecture: `/workspace/@alkdev/operations/docs/architecture/README.md`
- Pubsub architecture: `/workspace/@alkdev/pubsub/docs/architecture/README.md` - Pubsub architecture: `/workspace/@alkdev/pubsub/docs/architecture/README.md`
- Flowgraph architecture: `/workspace/@alkdev/flowgraph/docs/architecture/README.md` - Flowgraph architecture: `/workspace/@alkdev/flowgraph/docs/architecture/README.md`