@codemix/y-graph-storage
v0.0.5
Published
A YJS storage adapter for the codemix graph database.
Readme
@codemix/y-graph-storage
A Yjs storage adapter for @codemix/graph that persists the graph inside a Y.Doc, enabling real-time collaborative graph editing, CRDT-based conflict resolution, live reactive queries, and support for rich Yjs shared types (Y.Text, Y.Array, Y.Map) as property values.
Part of the codemix product intelligence platform.
Table of Contents
- Features
- Installation
- Quick Start
- How It Works
- YGraph
- Subscribing to Changes
- Live Queries
- Yjs Shared Types as Properties
- Syncing with Yjs Providers
- Low-level: YGraphStorage
- Internal Y.Doc Layout
Features
- CRDT-backed persistence — all graph data lives inside a
Y.Doc; merges are automatic and conflict-free. - Real-time collaboration — plug in any Yjs provider (WebSocket, WebRTC, IndexedDB, …) and every peer sees the same graph with no extra code.
- Reactive change events — subscribe to fine-grained events:
vertex.added,vertex.deleted,edge.added,edge.deleted,vertex.property.set,vertex.property.changed, and edge equivalents. - Live queries — a
LiveQuerywraps a traversal and re-fires whenever a relevant change occurs. - Rich Yjs property types — use
Y.Text,Y.Array<T>, andY.Map<V>as property values with built-in Zod schema helpers that convert plain values transparently. - Full
@codemix/graphAPI —YGraphextendsGraph, so every traversal, Cypher query, and index feature works unchanged.
Installation
npm install @codemix/y-graph-storage yjs
# or
pnpm add @codemix/y-graph-storage yjs@codemix/graph is a peer dependency and must also be installed.
Quick Start
import * as Y from "yjs";
import * as z from "zod";
import { GraphSchema, GraphTraversal } from "@codemix/graph";
import { YGraph, ZodYText } from "@codemix/y-graph-storage";
const schema = {
vertices: {
Person: {
properties: {
name: { type: ZodYText }, // stored as Y.Text
age: { type: z.number() },
},
},
},
edges: {
knows: { properties: {} },
},
} as const satisfies GraphSchema;
const doc = new Y.Doc();
const graph = new YGraph({ schema, doc });
const alice = graph.addVertex("Person", { name: new Y.Text("Alice"), age: 30 });
const bob = graph.addVertex("Person", { name: new Y.Text("Bob"), age: 25 });
graph.addEdge(alice, "knows", bob, {});
// Read a collaborative text property
alice.get("name").toString(); // "Alice"
// Traverse with the standard API
const g = new GraphTraversal(graph);
for (const path of g.V().hasLabel("Person").out("knows")) {
console.log(path.value.get("name").toString()); // "Bob"
}How It Works
YGraphStorage maps the graph onto a Y.Doc using shared Y.Map collections:
- Each vertex label gets a top-level
Y.MapkeyedV:<Label>. The map's values are per-vertexY.Mapinstances whose keys are the vertex UUID and whose values are the property values. - Each edge label gets a top-level
Y.MapkeyedE:<Label>. Each edgeY.Mapstores@inV(target element ID),@outV(source element ID), and all edge properties. - Incoming/outgoing edge adjacency is stored inside each vertex
Y.Mapunder the internal keys@inEand@outEas nestedY.Map<edgeId, true>. This avoids full scans when traversing neighbours.
Because everything is a native Yjs shared type, any two peers that apply the same set of operations will converge to the same state.
YGraph wraps YGraphStorage and the base Graph class. It also manages a shared Observable stream of YGraphChange events that powers subscriptions and live queries.
YGraph
Creating a YGraph
import * as Y from "yjs";
import { YGraph } from "@codemix/y-graph-storage";
const doc = new Y.Doc();
const graph = new YGraph({ schema, doc });YGraph accepts:
| Option | Type | Description |
| -------- | ------------- | ------------------------------------------------------------------- |
| schema | GraphSchema | The graph schema (vertex/edge labels and property types). |
| doc | Y.Doc | The Yjs document. Provide a shared instance to sync with providers. |
Mutating the Graph
YGraph inherits the full Graph mutation API. All mutations are automatically wrapped in a Y.Doc transaction:
// Add vertices
const alice = graph.addVertex("Person", { name: new Y.Text("Alice"), age: 30 });
// Add edges
const edge = graph.addEdge(alice, "knows", bob, {});
// Update a scalar property
graph.updateProperty(alice, "age", 31);
// or
alice.set("age", 31);
// Mutate a Y.Text property in place (triggers vertex.property.changed)
const name = alice.get("name");
name.delete(0, name.length);
name.insert(0, "Alicia");
// Delete
graph.deleteEdge(edge);
graph.deleteVertex(alice); // also cleans up attached edgesTraversals and Queries
Any @codemix/graph traversal or Cypher query runs unchanged:
import { GraphTraversal, parseQueryToSteps, createTraverser } from "@codemix/graph";
// Fluent traversal
const g = new GraphTraversal(graph);
const results = Array.from(g.V().hasLabel("Person").out("knows").values("name"));
// Cypher
const { steps, postprocess } = parseQueryToSteps(
"MATCH (a:Person)-[:knows]->(b:Person) RETURN a.name, b.name",
);
const traverser = createTraverser(steps);
for (const row of traverser.traverse(graph, [])) {
console.log(postprocess(row));
}Subscribing to Changes
YGraph.subscribe returns a function that, when called, unsubscribes:
const unsubscribe = graph.subscribe({
next(change) {
console.log(change.kind, change.id);
},
});
graph.addVertex("Person", { name: new Y.Text("Charlie"), age: 22 });
// logs: "vertex.added" "Person:<uuid>"
unsubscribe();Multiple subscribe calls share a single underlying Yjs observeDeep listener; it is set up on the first call and torn down when the last subscriber unsubscribes.
Change Events
| kind | Extra fields | Description |
| ------------------------- | --------------------------------- | ------------------------------------------------------------------ |
| vertex.added | id | A vertex was inserted. |
| vertex.deleted | id | A vertex was removed. |
| edge.added | id | An edge was inserted. |
| edge.deleted | id | An edge was removed. |
| vertex.property.set | id, property | A scalar property was set on a vertex. |
| vertex.property.changed | id, property, path, event | A Yjs shared-type property (e.g. Y.Text) was mutated internally. |
| edge.property.set | id, property | A scalar property was set on an edge. |
| edge.property.changed | id, property, path, event | A Yjs shared-type property on an edge was mutated. |
Live Queries
YGraph.query wraps a traversal in a LiveQuery that re-fires its subscription whenever a change could affect the result set:
const people = graph.query((g) => g.V().hasLabel("Person"));
// Initial traversal
for (const path of people) {
console.log(path.value.get("name").toString());
}
// React to changes
const unsubscribe = people.subscribe({
next(change) {
// Re-run the traversal when relevant
for (const path of people) {
console.log("updated:", path.value.get("name").toString());
}
},
});
graph.addVertex("Person", { name: new Y.Text("Dave"), age: 28 });
// triggers subscriber
unsubscribe();LiveQuery analyses the traversal steps to determine which change kinds are relevant. A FetchVerticesStep filtered by label only fires on vertex.added/vertex.deleted events for that label; a FilterElementsStep (e.g. has(…)) also watches vertex.property.set events; edge traversal steps watch edge.added/edge.deleted.
Yjs Shared Types as Properties
When properties need collaborative editing (e.g. a text field that multiple users can type into simultaneously), declare them with the Zod helpers exported from this package. Each helper accepts either the native Yjs type or a plain JS equivalent, and always outputs the Yjs type — so you can seed the graph with plain values and they will be converted automatically.
ZodYText
Accepts Y.Text or string, always stores a Y.Text.
import { ZodYText } from "@codemix/y-graph-storage";
const schema = {
vertices: {
Document: {
properties: {
title: { type: ZodYText },
content: { type: ZodYText },
},
},
},
edges: {},
} as const satisfies GraphSchema;
const doc = graph.addVertex("Document", {
title: "My Doc", // string → Y.Text
content: new Y.Text("..."), // Y.Text → Y.Text (unchanged)
});
// Collaborative edit
doc.get("content").insert(0, "Hello, ");ZodYArray
import { ZodYArray, ZodYText } from "@codemix/y-graph-storage";
import * as z from "zod";
const Tags = ZodYArray(z.string()); // Y.Array<string>
const Lines = ZodYArray(ZodYText); // Y.Array<Y.Text>
// In a schema:
tags: {
type: Tags;
}
// Usage — accepts native array or Y.Array:
graph.addVertex("Post", { tags: ["crdt", "graph"] }); // converted
graph.addVertex("Post", { tags: Y.Array.from(["crdt"]) }); // stored as-is
post.get("tags").push(["realtime"]);ZodYMap
import { ZodYMap } from "@codemix/y-graph-storage";
import * as z from "zod";
const Metadata = ZodYMap(z.string()); // Y.Map<string>
metadata: {
type: Metadata;
}
// Accepts plain object or Y.Map:
graph.addVertex("Asset", { metadata: { author: "Alice" } });
asset.get("metadata").set("version", "2");ZodYXmlFragment / ZodYXmlText / ZodYXmlElement
For rich-text or structured XML content:
import { ZodYXmlFragment, ZodYXmlText, ZodYXmlElement } from "@codemix/y-graph-storage";
body: {
type: ZodYXmlFragment;
} // accepts string, outputs Y.XmlFragment| Helper | Input | Output |
| ------------------- | ------------------------------------------ | --------------- |
| ZodYText | string \| Y.Text | Y.Text |
| ZodYArray(schema) | T[] \| Y.Array<T> | Y.Array<T> |
| ZodYMap(schema) | Record<string, V> \| Y.Map<V> | Y.Map<V> |
| ZodYXmlFragment | string \| Y.XmlFragment | Y.XmlFragment |
| ZodYXmlText | string \| Y.XmlText | Y.XmlText |
| ZodYXmlElement | {tag, attrs?, children?} \| Y.XmlElement | Y.XmlElement |
Syncing with Yjs Providers
Because all data lives in a Y.Doc, you can connect any standard Yjs provider and get real-time sync for free:
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
import { YGraph } from "@codemix/y-graph-storage";
const doc = new Y.Doc();
const provider = new WebsocketProvider("wss://my-server", "my-room", doc);
const graph = new YGraph({ schema, doc });
// On every connected peer, graph mutations propagate automatically.
// Changes from remote peers emit YGraphChange events via graph.subscribe().Other providers work the same way: y-indexeddb for offline persistence, y-webrtc for peer-to-peer, y-leveldb for Node.js, etc.
Low-level: YGraphStorage
If you need direct access to the storage layer — for example, to register custom Yjs observers or to inspect the raw Y.Map collections — use YGraphStorage directly:
import * as Y from "yjs";
import { YGraphStorage } from "@codemix/y-graph-storage";
import { Graph } from "@codemix/graph";
const doc = new Y.Doc();
const storage = new YGraphStorage(doc, { schema });
const graph = new Graph({ schema, storage });
// Access raw collections
const personCollection = storage.getVertexCollectionMap("Person");
const knowsCollection = storage.getEdgeCollectionMap("knows");
// Observe at the Yjs level
personCollection.observeDeep((events) => {
for (const event of events) {
console.log("raw yjs event", event);
}
});YGraphStorage implements the GraphStorage interface from @codemix/graph and can be used wherever a GraphStorage is accepted.
Internal Y.Doc Layout
Understanding the layout can help when debugging or building custom tooling.
| Y.Doc key | Type | Contents |
| ----------- | ----------------------- | ----------------------------------------------- |
| V:<Label> | Y.Map<Y.Map<unknown>> | All vertices of label <Label>. Keyed by UUID. |
| E:<Label> | Y.Map<Y.Map<unknown>> | All edges of label <Label>. Keyed by UUID. |
Each vertex Y.Map contains:
| Key | Value | Description |
| ---------------- | ------------------------ | ------------------------------------------------------------------------------------- |
| <propertyName> | any Yjs-compatible value | The vertex's properties. |
| @inE | Y.Map<edgeId, true> | IDs of incoming edges (populated when an edge targeting this vertex is added). |
| @outE | Y.Map<edgeId, true> | IDs of outgoing edges (populated when an edge originating from this vertex is added). |
Each edge Y.Map contains:
| Key | Value | Description |
| ---------------- | ------------------------ | ---------------------- |
| @inV | ElementId | Target vertex ID. |
| @outV | ElementId | Source vertex ID. |
| <propertyName> | any Yjs-compatible value | The edge's properties. |
Internal keys all start with @ and are skipped by the change observer so they never surface as property.set/property.changed events.
