Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
#### Error Handling
- **`QueryError` with error codes**: State guard throws now use `QueryError` with `E_NO_STATE` (no cached state) and `E_STALE_STATE` (dirty state) instead of bare `Error`.

#### GROUNDSKEEPER — Index Health & GC
- **Index staleness detection** (`GK/IDX/1-2`): Frontier metadata stored alongside bitmap indexes. `loadIndexFrontier()` and `checkStaleness()` detect when writer tips have advanced past the indexed state. Auto-rebuild option on `IndexRebuildService.load()`.
- **Tombstone garbage collection** (`GK/GC/1`): `GCPolicy` wired into post-materialize path (opt-in via `gcPolicy` option). Warns when tombstone ratio exceeds threshold.

#### WEIGHTED — Edge Properties (v7.3.0)
- **Edge property key encoding** (`WT/EPKEY/1`): `encodeEdgePropKey()`/`decodeEdgePropKey()` with `\x01` prefix for collision-free namespacing against node property keys.
- **`patch.setEdgeProperty(from, to, label, key, value)`** (`WT/OPS/1`): New PatchBuilderV2 method for setting properties on edges. Generates `PropSet` ops in the edge namespace.
- **LWW semantics for edge properties** (`WT/OPS/2`): Existing JoinReducer LWW pipeline handles edge properties transparently — no special-case logic needed.
- **`graph.getEdgeProps(from, to, label)`** (`WT/OPS/3`): New convenience method returning edge properties as a plain object. `getEdges()` now returns a `props` field on each edge.
- **Schema v3** (`WT/SCHEMA/1`): Minimal schema bump signaling edge property support. `detectSchemaVersion()` auto-detects from ops. Codec handles v2 and v3 transparently.
- **Mixed-version sync safety** (`WT/SCHEMA/2`): `assertOpsCompatible()` guard throws `E_SCHEMA_UNSUPPORTED` when v2 reader encounters edge property ops. Node-only v3 patches accepted by v2 readers. Fail fast, never silently drop data.
- **Edge property visibility gating** (`WT/VIS/1`): Edge props invisible when parent edge is tombstoned. Birth-lamport tracking ensures re-adding an edge starts with a clean slate (old props not restored).
- **`SchemaUnsupportedError`** — New error class with code `E_SCHEMA_UNSUPPORTED` for sync compatibility failures.

#### Query API (V7 Task 7)
- **`graph.hasNode(nodeId)`** - Check if node exists in materialized state
- **`graph.getNodeProps(nodeId)`** - Get all properties for a node as Map
Expand Down Expand Up @@ -78,7 +92,7 @@ All query methods operate on `WarpStateV5` (materialized state), never commit DA
- Added `test/unit/domain/services/HookInstaller.test.js` (29 tests) — hook install/upgrade/append/replace
- Added `test/unit/domain/WarpGraph.query.test.js` (21 tests) - Query API tests
- Added `test/unit/domain/services/WarpStateIndexBuilder.test.js` (13 tests) - WARP state index tests
- Total test count: 1571 (67 test files)
- Total test count: 1764 (78 test files)

## [6.0.0] - 2026-01-31

Expand Down
20 changes: 11 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ await (await graph.createPatch())
.addNode('user:bob')
.setProperty('user:bob', 'name', 'Bob')
.addEdge('user:alice', 'user:bob', 'manages')
.setEdgeProperty('user:alice', 'user:bob', 'manages', 'since', '2024')
.commit();

// Query the graph
Expand Down Expand Up @@ -124,7 +125,8 @@ await graph.getNodes(); // ['user:alice', 'user:bob
await graph.hasNode('user:alice'); // true
await graph.getNodeProps('user:alice'); // Map { 'name' => 'Alice', 'role' => 'admin' }
await graph.neighbors('user:alice', 'outgoing'); // [{ nodeId: 'user:bob', label: 'manages', direction: 'outgoing' }]
await graph.getEdges(); // [{ from: 'user:alice', to: 'user:bob', label: 'manages' }]
await graph.getEdges(); // [{ from: 'user:alice', to: 'user:bob', label: 'manages', props: {} }]
await graph.getEdgeProps('user:alice', 'user:bob', 'manages'); // { weight: 0.9 } or null
```

### Fluent Query Builder
Expand Down Expand Up @@ -154,17 +156,17 @@ if (result.found) {

## Patch Operations

The patch builder supports six operations:
The patch builder supports seven operations:

```javascript
const sha = await (await graph.createPatch())
.addNode('n1') // create a node
.removeNode('n1') // tombstone a node
.addEdge('n1', 'n2', 'label') // create a directed edge
.removeEdge('n1', 'n2', 'label') // tombstone an edge
.setProperty('n1', 'key', 'value') // set a property (LWW)
.setProperty('n1', 'data', { nested: true }) // values can be any serializable type
.commit(); // commit as a single atomic patch
.addNode('n1') // create a node
.removeNode('n1') // tombstone a node
.addEdge('n1', 'n2', 'label') // create a directed edge
.removeEdge('n1', 'n2', 'label') // tombstone an edge
.setProperty('n1', 'key', 'value') // set a node property (LWW)
.setEdgeProperty('n1', 'n2', 'label', 'weight', 0.8) // set an edge property (LWW)
.commit(); // commit as a single atomic patch
```

Each `commit()` creates one Git commit containing all the operations, advances the writer's Lamport clock, and updates the writer's ref via compare-and-swap.
Expand Down
34 changes: 17 additions & 17 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,8 @@ Observer-scoped views, translation costs, and temporal queries from Paper IV.
| # | Codename | Version | Theme | Status |
|---|----------|---------|-------|--------|
| 1 | **AUTOPILOT** | v7.1.0 | Kill the Materialize Tax | Complete (merged, unreleased) |
| 2 | **GROUNDSKEEPER** | v7.2.0 | Self-Managing Infrastructure | In progress |
| 3 | **WEIGHTED** | v7.3.0 | Edge Properties | Planned |
| 2 | **GROUNDSKEEPER** | v7.2.0 | Self-Managing Infrastructure | Complete (merged, unreleased) |
| 3 | **WEIGHTED** | v7.3.0 | Edge Properties | Complete (merged, unreleased) |
| 4 | **HANDSHAKE** | v7.4.0 | Multi-Writer Ergonomics | Planned |
| 5 | **COMPASS** | v7.5.0 | Advanced Query Language | Planned |
| 6 | **LIGHTHOUSE** | v7.6.0 | Observability | Planned |
Expand Down Expand Up @@ -245,14 +245,14 @@ GROUNDSKEEPER (v7.2.0) █████████████████
■ GK/IDX/1 → GK/IDX/2
■ GK/IDX/2

WEIGHTED (v7.3.0) ░░░░░░░░░░░░░░░░░░░░ 0% (0/7)
WT/EPKEY/1 → WT/OPS/1, WT/SCHEMA/1
WT/OPS/1 → WT/OPS/2, WT/OPS/3
WT/OPS/2
WT/OPS/3 → WT/VIS/1
WT/SCHEMA/1 → WT/SCHEMA/2
WT/SCHEMA/2
WT/VIS/1
WEIGHTED (v7.3.0) ████████████████████ 100% (7/7)
WT/EPKEY/1 → WT/OPS/1, WT/SCHEMA/1
WT/OPS/1 → WT/OPS/2, WT/OPS/3
WT/OPS/2
WT/OPS/3 → WT/VIS/1
WT/SCHEMA/1 → WT/SCHEMA/2
WT/SCHEMA/2
WT/VIS/1

HANDSHAKE (v7.4.0) ░░░░░░░░░░░░░░░░░░░░ 0% (0/8)
◆ HS/CAS/1
Expand Down Expand Up @@ -724,7 +724,7 @@ Extends the data model to support properties on edges, enabling weighted graphs,

#### WT/EPKEY/1 — Design and implement encode/decode utilities

- **Status:** `OPEN`
- **Status:** `CLOSED`
- **User Story:** As the system, I need a canonical encoding for edge property keys that is injective, reversible, and collision-free with node property keys.
- **Requirements:**
- `encodeEdgePropKey(from, to, label, propKey)` → deterministic string.
Expand Down Expand Up @@ -756,7 +756,7 @@ Extends the data model to support properties on edges, enabling weighted graphs,

#### WT/OPS/1 — Extend PatchBuilderV2 with edge property ops

- **Status:** `BLOCKED`
- **Status:** `CLOSED`
- **User Story:** As a developer, I want to set properties on edges using the patch builder API.
- **Requirements:**
- Add `setEdgeProperty(from, to, label, key, value)` to `PatchBuilderV2`.
Expand All @@ -779,7 +779,7 @@ Extends the data model to support properties on edges, enabling weighted graphs,

#### WT/OPS/2 — LWW semantics for edge properties in JoinReducer

- **Status:** `BLOCKED`
- **Status:** `CLOSED`
- **User Story:** As the system, concurrent edge property writes must resolve deterministically via LWW.
- **Requirements:**
- Edge property `PropSet` ops processed identically to node property `PropSet` ops in JoinReducer.
Expand All @@ -802,7 +802,7 @@ Extends the data model to support properties on edges, enabling weighted graphs,

#### WT/OPS/3 — Surface edge properties in materialization and queries

- **Status:** `BLOCKED`
- **Status:** `CLOSED`
- **User Story:** As a developer, I want to read edge properties after materialization.
- **Requirements:**
- `getEdges()` returns edge objects with `props` field.
Expand Down Expand Up @@ -832,7 +832,7 @@ Extends the data model to support properties on edges, enabling weighted graphs,

#### WT/VIS/1 — Gate edge property visibility on edge aliveness

- **Status:** `BLOCKED`
- **Status:** `CLOSED`
- **User Story:** As a developer, I expect edge properties to disappear when the edge is removed.
- **Requirements:**
- `getEdges()` and query results omit props for edges not in `edgeAlive` OR-Set.
Expand Down Expand Up @@ -860,7 +860,7 @@ Extends the data model to support properties on edges, enabling weighted graphs,

#### WT/SCHEMA/1 — Define schema v3 format

- **Status:** `BLOCKED`
- **Status:** `CLOSED`
- **User Story:** As the system, I need a new schema version that supports edge properties while remaining backward compatible.
- **Requirements:**
- Bump patch schema to `3`.
Expand All @@ -879,7 +879,7 @@ Extends the data model to support properties on edges, enabling weighted graphs,

#### WT/SCHEMA/2 — Mixed-version sync safety

- **Status:** `BLOCKED`
- **Status:** `CLOSED`
- **User Story:** As a developer, I want to sync between v2 and v3 writers without data loss.
- **Requirements:**
- **Decision: fail fast. Never silently drop data.**
Expand Down
58 changes: 57 additions & 1 deletion docs/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,14 @@ A **patch** is an atomic batch of graph operations. Operations include:
- `NodeTombstone` - Delete a node
- `EdgeAdd` - Create an edge
- `EdgeTombstone` - Delete an edge
- `PropSet` - Set a property value
- `PropSet` - Set a property value (also targets edge properties when used with `setEdgeProperty()`)

```javascript
await graph.createPatch()
.addNode('user:alice')
.setProperty('user:alice', 'email', 'alice@example.com')
.addEdge('user:alice', 'org:acme', 'works-at')
.setEdgeProperty('user:alice', 'org:acme', 'works-at', 'since', '2024-06')
.commit();
```

Expand Down Expand Up @@ -118,6 +119,61 @@ const state = await graph.materialize();
// Edge 'temp->other' is not visible
```

## Edge Properties

Edges can carry properties just like nodes. Edge properties use LWW (Last-Write-Wins) semantics identical to node properties.

### Setting Edge Properties

```javascript
await graph.createPatch()
.addNode('user:alice')
.addNode('user:bob')
.addEdge('user:alice', 'user:bob', 'follows')
.setEdgeProperty('user:alice', 'user:bob', 'follows', 'since', '2024-01')
.setEdgeProperty('user:alice', 'user:bob', 'follows', 'weight', 0.9)
.commit();
```

### Reading Edge Properties

```javascript
// Get all edges with their properties
const edges = await graph.getEdges();
// [{ from: 'user:alice', to: 'user:bob', label: 'follows', props: { since: '2024-01', weight: 0.9 } }]

// Get properties for a specific edge
const props = await graph.getEdgeProps('user:alice', 'user:bob', 'follows');
// { since: '2024-01', weight: 0.9 }
```

### Visibility Rules

Edge properties are only visible when the parent edge is alive:

- **Remove edge**: all its properties become invisible
- **Re-add edge**: starts with a clean slate — old properties are NOT restored

This prevents stale property data from leaking through after edge lifecycle changes.

### Multi-Writer Conflict Resolution

Edge properties follow the same LWW resolution as node properties:

1. Higher Lamport timestamp wins
2. Tie: higher writer ID wins (lexicographic)
3. Tie: higher patch SHA wins

Two writers setting the same edge property concurrently will deterministically converge to the same winner, regardless of patch arrival order.

### Schema Compatibility

Edge properties require schema v3 (introduced in v7.3.0). When syncing:

- **v3 → v2 with edge props**: v2 reader throws `E_SCHEMA_UNSUPPORTED` with upgrade guidance
- **v3 → v2 with node-only ops**: succeeds (schema number alone is not a rejection criterion)
- **v2 → v3**: always succeeds (v2 patches are valid v3 input)

## Auto-Materialize and Auto-Checkpoint

### Auto-Materialize
Expand Down
150 changes: 150 additions & 0 deletions examples/edge-properties.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
#!/usr/bin/env node
/**
* edge-properties.js - Edge property demonstration
*
* Demonstrates:
* - Setting and reading edge properties
* - Listing edges with their props via getEdges()
* - Multi-writer LWW conflict resolution on edge props
* - Clean-slate semantics: removing and re-adding an edge clears old props
*
* Run: node edge-properties.js
*/

import { execSync } from 'child_process';
const modulePath = process.env.WARPGRAPH_MODULE || '../index.js';
const { default: WarpGraph, GitGraphAdapter } = await import(modulePath);
import Plumbing from '@git-stunts/plumbing';

async function main() {
console.log('WarpGraph Edge Properties Example\n');

// ============================================================================
// Step 1: Set up git repository and persistence
// ============================================================================

try {
execSync('git rev-parse --git-dir', { stdio: 'pipe' });
console.log('[1] Git repo already initialized');
} catch {
console.log('[1] Initializing git repo...');
execSync('git init', { stdio: 'inherit' });
execSync('git config user.email "demo@example.com"', { stdio: 'pipe' });
execSync('git config user.name "Demo User"', { stdio: 'pipe' });
}

const plumbing = Plumbing.createDefault({ cwd: process.cwd() });
const persistence = new GitGraphAdapter({ plumbing });

// ============================================================================
// Step 2: Open graph with autoMaterialize
// ============================================================================

const graph = await WarpGraph.open({
persistence,
graphName: 'edge-props-demo',
writerId: 'writer-1',
autoMaterialize: true,
});

console.log(`[2] Opened graph "${graph.graphName}" (autoMaterialize: on)`);

// ============================================================================
// Step 3: Create nodes and edges with properties
// ============================================================================

await (await graph.createPatch())
.addNode('user:alice')
.addNode('user:bob')
.addEdge('user:alice', 'user:bob', 'follows')
.setEdgeProperty('user:alice', 'user:bob', 'follows', 'since', '2025-06-01')
.setEdgeProperty('user:alice', 'user:bob', 'follows', 'weight', 0.9)
.commit();

console.log('\n[3] Created nodes and edge with properties');

// ============================================================================
// Step 4: Read edge properties with getEdgeProps()
// ============================================================================

const props = await graph.getEdgeProps('user:alice', 'user:bob', 'follows');
console.log('\n[4] getEdgeProps(alice, bob, follows):');
console.log(` since = ${props.since}`);
console.log(` weight = ${props.weight}`);

// ============================================================================
// Step 5: List all edges with props via getEdges()
// ============================================================================

const edges = await graph.getEdges();
console.log('\n[5] getEdges() — all edges with their props:');
for (const edge of edges) {
const p = Object.keys(edge.props).length > 0 ? JSON.stringify(edge.props) : '(none)';
console.log(` ${edge.from} --${edge.label}--> ${edge.to} props: ${p}`);
}

// ============================================================================
// Step 6: Multi-writer conflict resolution (LWW)
// ============================================================================

console.log('\n[6] Multi-writer edge property conflict (LWW)...');

const writer2 = await WarpGraph.open({
persistence,
graphName: 'edge-props-demo',
writerId: 'writer-2',
autoMaterialize: true,
});

// writer-1 sets weight to 0.5
await (await graph.createPatch())
.setEdgeProperty('user:alice', 'user:bob', 'follows', 'weight', 0.5)
.commit();

// writer-2 sets weight to 0.8 (higher Lamport clock wins)
await (await writer2.createPatch())
.setEdgeProperty('user:alice', 'user:bob', 'follows', 'weight', 0.8)
.commit();

// Materialize from writer-1's perspective — both writers' patches merge
await graph.materialize();
const merged = await graph.getEdgeProps('user:alice', 'user:bob', 'follows');
console.log(` writer-1 set weight=0.5, writer-2 set weight=0.8`);
console.log(` After LWW merge: weight = ${merged.weight}`);

// ============================================================================
// Step 7: Edge removal hides props; re-add gives clean slate
// ============================================================================

console.log('\n[7] Edge removal hides props, re-add gives clean slate...');

// Remove the edge
await (await graph.createPatch())
.removeEdge('user:alice', 'user:bob', 'follows')
.commit();

await graph.materialize();
const afterRemove = await graph.getEdgeProps('user:alice', 'user:bob', 'follows');
console.log(` After removeEdge: getEdgeProps => ${afterRemove}`);

// Re-add the same edge (no old props carry over)
await (await graph.createPatch())
.addEdge('user:alice', 'user:bob', 'follows')
.setEdgeProperty('user:alice', 'user:bob', 'follows', 'note', 'fresh start')
.commit();

await graph.materialize();
const afterReAdd = await graph.getEdgeProps('user:alice', 'user:bob', 'follows');
console.log(` After re-addEdge: props = ${JSON.stringify(afterReAdd)}`);
console.log(' Old props (since, weight) are gone — clean slate!');

console.log('\nEdge properties example complete!');
}

main().catch(err => {
console.error('Error:', err.message);
if (err.stack) {
console.error(err.stack);
}
process.exit(1);
});
Loading