From 78918be4abb283327d2d5a4d9662103d0342da88 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Wed, 4 Feb 2026 06:10:06 -0800 Subject: [PATCH 01/10] feat: implement edge property key encoding (WT/EPKEY/1) Add encodeEdgePropKey/decodeEdgePropKey/isEdgePropKey utilities using \x01 prefix to guarantee collision-freedom with node property keys. 35 tests including fuzz and edge cases. --- src/domain/services/JoinReducer.js | 44 +++ test/unit/domain/services/EdgePropKey.test.js | 357 ++++++++++++++++++ 2 files changed, 401 insertions(+) create mode 100644 test/unit/domain/services/EdgePropKey.test.js diff --git a/src/domain/services/JoinReducer.js b/src/domain/services/JoinReducer.js index e790c9a..b211339 100644 --- a/src/domain/services/JoinReducer.js +++ b/src/domain/services/JoinReducer.js @@ -55,6 +55,50 @@ export function decodePropKey(key) { return { nodeId, propKey }; } +/** + * Prefix byte for edge property keys. Guarantees no collision with node + * property keys (which start with a node-ID character, never \x01). + * @const {string} + */ +export const EDGE_PROP_PREFIX = '\x01'; + +/** + * Encodes an edge property key for Map storage. + * + * Format: `\x01from\0to\0label\0propKey` + * + * The \x01 prefix guarantees collision-freedom with node property keys + * (format `nodeId\0propKey`) since node IDs never start with \x01. + * + * @param {string} from - Source node ID + * @param {string} to - Target node ID + * @param {string} label - Edge label + * @param {string} propKey - Property name + * @returns {string} + */ +export function encodeEdgePropKey(from, to, label, propKey) { + return `\x01${from}\0${to}\0${label}\0${propKey}`; +} + +/** + * Decodes an edge property key string. + * @param {string} encoded - Encoded edge property key (must start with \x01) + * @returns {{from: string, to: string, label: string, propKey: string}} + */ +export function decodeEdgePropKey(encoded) { + const [from, to, label, propKey] = encoded.slice(1).split('\0'); + return { from, to, label, propKey }; +} + +/** + * Returns true if the encoded key is an edge property key. + * @param {string} key - Encoded property key + * @returns {boolean} + */ +export function isEdgePropKey(key) { + return key.charCodeAt(0) === 1; +} + /** * @typedef {Object} WarpStateV5 * @property {import('../crdt/ORSet.js').ORSet} nodeAlive - ORSet of alive nodes diff --git a/test/unit/domain/services/EdgePropKey.test.js b/test/unit/domain/services/EdgePropKey.test.js new file mode 100644 index 0000000..0c26bfa --- /dev/null +++ b/test/unit/domain/services/EdgePropKey.test.js @@ -0,0 +1,357 @@ +import { describe, it, expect } from 'vitest'; +import { + encodeEdgePropKey, + decodeEdgePropKey, + isEdgePropKey, + EDGE_PROP_PREFIX, + encodePropKey, +} from '../../../../src/domain/services/JoinReducer.js'; + +describe('EdgePropKey', () => { + describe('round-trip', () => { + it('encode then decode returns original values', () => { + const from = 'user:alice'; + const to = 'user:bob'; + const label = 'manages'; + const propKey = 'since'; + + const encoded = encodeEdgePropKey(from, to, label, propKey); + const decoded = decodeEdgePropKey(encoded); + + expect(decoded).toEqual({ from, to, label, propKey }); + }); + + it('round-trips with single-character fields', () => { + const encoded = encodeEdgePropKey('a', 'b', 'c', 'd'); + const decoded = decodeEdgePropKey(encoded); + + expect(decoded).toEqual({ from: 'a', to: 'b', label: 'c', propKey: 'd' }); + }); + + it('round-trips with complex realistic keys', () => { + const from = 'org:acme-corp/dept:engineering'; + const to = 'project:warp-graph-v7'; + const label = 'owns'; + const propKey = 'budget.allocated.2025'; + + const encoded = encodeEdgePropKey(from, to, label, propKey); + const decoded = decodeEdgePropKey(encoded); + + expect(decoded).toEqual({ from, to, label, propKey }); + }); + }); + + describe('injectivity', () => { + it('different from values produce different keys', () => { + const a = encodeEdgePropKey('x', 'y', 'z', 'p'); + const b = encodeEdgePropKey('xx', 'y', 'z', 'p'); + + expect(a).not.toBe(b); + }); + + it('different to values produce different keys', () => { + const a = encodeEdgePropKey('x', 'y', 'z', 'p'); + const b = encodeEdgePropKey('x', 'yy', 'z', 'p'); + + expect(a).not.toBe(b); + }); + + it('different label values produce different keys', () => { + const a = encodeEdgePropKey('x', 'y', 'z', 'p'); + const b = encodeEdgePropKey('x', 'y', 'zz', 'p'); + + expect(a).not.toBe(b); + }); + + it('different propKey values produce different keys', () => { + const a = encodeEdgePropKey('x', 'y', 'z', 'p'); + const b = encodeEdgePropKey('x', 'y', 'z', 'pp'); + + expect(a).not.toBe(b); + }); + + it('swapping from and to produces a different key', () => { + const a = encodeEdgePropKey('alice', 'bob', 'likes', 'weight'); + const b = encodeEdgePropKey('bob', 'alice', 'likes', 'weight'); + + expect(a).not.toBe(b); + }); + + it('all four fields identical but in different positions produce different keys', () => { + const a = encodeEdgePropKey('a', 'b', 'c', 'd'); + const b = encodeEdgePropKey('b', 'a', 'c', 'd'); + const c = encodeEdgePropKey('a', 'c', 'b', 'd'); + const d = encodeEdgePropKey('a', 'b', 'd', 'c'); + + const keys = new Set([a, b, c, d]); + expect(keys.size).toBe(4); + }); + }); + + describe('collision freedom with node prop keys', () => { + it('edge prop key never equals a node prop key for same strings', () => { + const edgeKey = encodeEdgePropKey('node1', 'node2', 'rel', 'weight'); + const nodeKey = encodePropKey('node1', 'weight'); + + expect(edgeKey).not.toBe(nodeKey); + }); + + it('edge prop key never equals node prop key even with crafted inputs', () => { + // Try to craft a node prop key that looks like an edge prop key + const nodeKey = encodePropKey('\x01from', 'to'); + const edgeKey = encodeEdgePropKey('from', 'to', '', ''); + + expect(edgeKey).not.toBe(nodeKey); + }); + + it('no collision across a variety of inputs', () => { + const nodeIds = ['a', 'b', 'user:1', 'node-x']; + const propKeys = ['name', 'value', 'x', 'weight']; + const labels = ['owns', 'likes', 'edge']; + + const edgeKeys = new Set(); + const nodeKeys = new Set(); + + for (const from of nodeIds) { + for (const to of nodeIds) { + for (const label of labels) { + for (const pk of propKeys) { + edgeKeys.add(encodeEdgePropKey(from, to, label, pk)); + } + } + } + for (const pk of propKeys) { + nodeKeys.add(encodePropKey(from, pk)); + } + } + + for (const ek of edgeKeys) { + expect(nodeKeys.has(ek)).toBe(false); + } + }); + + it('prefix byte prevents collision with any node prop key', () => { + // Node prop keys start with the node ID (never \x01 in practice). + // Edge prop keys always start with \x01. + const edgeKey = encodeEdgePropKey('n', 'n', 'e', 'p'); + const nodeKey = encodePropKey('n', 'p'); + + expect(edgeKey[0]).toBe('\x01'); + expect(nodeKey[0]).not.toBe('\x01'); + }); + }); + + describe('isEdgePropKey', () => { + it('returns true for edge prop keys', () => { + const key = encodeEdgePropKey('a', 'b', 'c', 'd'); + + expect(isEdgePropKey(key)).toBe(true); + }); + + it('returns false for node prop keys', () => { + const key = encodePropKey('node1', 'name'); + + expect(isEdgePropKey(key)).toBe(false); + }); + + it('returns false for plain strings', () => { + expect(isEdgePropKey('hello')).toBe(false); + expect(isEdgePropKey('node:abc')).toBe(false); + expect(isEdgePropKey('')).toBe(false); + }); + + it('returns true for any string starting with \\x01', () => { + expect(isEdgePropKey('\x01anything')).toBe(true); + expect(isEdgePropKey('\x01')).toBe(true); + }); + + it('returns false for strings starting with other control characters', () => { + expect(isEdgePropKey('\x00test')).toBe(false); + expect(isEdgePropKey('\x02test')).toBe(false); + expect(isEdgePropKey('\x7ftest')).toBe(false); + }); + }); + + describe('EDGE_PROP_PREFIX', () => { + it('equals \\x01', () => { + expect(EDGE_PROP_PREFIX).toBe('\x01'); + }); + + it('has char code 1', () => { + expect(EDGE_PROP_PREFIX.charCodeAt(0)).toBe(1); + }); + + it('has length 1', () => { + expect(EDGE_PROP_PREFIX.length).toBe(1); + }); + + it('is the first character of every encoded edge prop key', () => { + const key = encodeEdgePropKey('x', 'y', 'z', 'w'); + + expect(key.startsWith(EDGE_PROP_PREFIX)).toBe(true); + }); + }); + + describe('fuzz: 10,000 random tuples round-trip', () => { + function randomString(maxLen) { + const len = Math.floor(Math.random() * maxLen) + 1; + const chars = []; + for (let i = 0; i < len; i++) { + // Printable ASCII range 0x20-0x7e, avoiding \0 and \x01 + // which are used as delimiters/prefix + chars.push(String.fromCharCode(0x20 + Math.floor(Math.random() * 95))); + } + return chars.join(''); + } + + it('all 10,000 random tuples round-trip correctly', () => { + for (let i = 0; i < 10_000; i++) { + const from = randomString(20); + const to = randomString(20); + const label = randomString(15); + const propKey = randomString(15); + + const encoded = encodeEdgePropKey(from, to, label, propKey); + const decoded = decodeEdgePropKey(encoded); + + expect(decoded).toEqual({ from, to, label, propKey }); + expect(isEdgePropKey(encoded)).toBe(true); + } + }); + + it('no fuzzed edge key collides with a node prop key', () => { + const nodeKeys = new Set(); + const edgeKeys = new Set(); + + for (let i = 0; i < 1000; i++) { + const a = randomString(20); + const b = randomString(20); + + nodeKeys.add(encodePropKey(a, b)); + edgeKeys.add(encodeEdgePropKey(a, b, randomString(10), randomString(10))); + } + + for (const ek of edgeKeys) { + expect(nodeKeys.has(ek)).toBe(false); + } + }); + }); + + describe('edge cases', () => { + it('handles empty strings in all positions', () => { + const encoded = encodeEdgePropKey('', '', '', ''); + const decoded = decodeEdgePropKey(encoded); + + expect(decoded).toEqual({ from: '', to: '', label: '', propKey: '' }); + expect(isEdgePropKey(encoded)).toBe(true); + }); + + it('handles empty string in from only', () => { + const encoded = encodeEdgePropKey('', 'to', 'label', 'prop'); + const decoded = decodeEdgePropKey(encoded); + + expect(decoded).toEqual({ from: '', to: 'to', label: 'label', propKey: 'prop' }); + }); + + it('handles empty string in to only', () => { + const encoded = encodeEdgePropKey('from', '', 'label', 'prop'); + const decoded = decodeEdgePropKey(encoded); + + expect(decoded).toEqual({ from: 'from', to: '', label: 'label', propKey: 'prop' }); + }); + + it('handles empty string in label only', () => { + const encoded = encodeEdgePropKey('from', 'to', '', 'prop'); + const decoded = decodeEdgePropKey(encoded); + + expect(decoded).toEqual({ from: 'from', to: 'to', label: '', propKey: 'prop' }); + }); + + it('handles empty string in propKey only', () => { + const encoded = encodeEdgePropKey('from', 'to', 'label', ''); + const decoded = decodeEdgePropKey(encoded); + + expect(decoded).toEqual({ from: 'from', to: 'to', label: 'label', propKey: '' }); + }); + + it('handles strings containing \\x01 characters', () => { + const from = 'a\x01b'; + const to = '\x01c'; + const label = 'd\x01'; + const propKey = '\x01\x01'; + + const encoded = encodeEdgePropKey(from, to, label, propKey); + + // The key should still be identifiable as an edge prop key + expect(isEdgePropKey(encoded)).toBe(true); + // Note: embedded \x01 characters do not affect decoding because + // split is done on \0, not \x01 + const decoded = decodeEdgePropKey(encoded); + expect(decoded).toEqual({ from, to, label, propKey }); + }); + + it('handles unicode characters', () => { + const from = 'user:\u00e9mile'; + const to = 'user:\u4e16\u754c'; + const label = '\u2764\ufe0f'; + const propKey = '\u00fc\u00f6\u00e4'; + + const encoded = encodeEdgePropKey(from, to, label, propKey); + const decoded = decodeEdgePropKey(encoded); + + expect(decoded).toEqual({ from, to, label, propKey }); + }); + + it('handles emoji and surrogate pairs', () => { + const from = 'user:\ud83d\ude80'; + const to = 'org:\ud83c\udf1f'; + const label = '\ud83d\udd17'; + const propKey = 'score\ud83c\udfaf'; + + const encoded = encodeEdgePropKey(from, to, label, propKey); + const decoded = decodeEdgePropKey(encoded); + + expect(decoded).toEqual({ from, to, label, propKey }); + }); + + it('handles very long strings', () => { + const long = 'x'.repeat(10_000); + const encoded = encodeEdgePropKey(long, long, long, long); + const decoded = decodeEdgePropKey(encoded); + + expect(decoded.from).toBe(long); + expect(decoded.to).toBe(long); + expect(decoded.label).toBe(long); + expect(decoded.propKey).toBe(long); + }); + + it('handles strings that look like the encoding format', () => { + // A from value that itself contains the separator pattern + const from = 'a\0b'; + const to = 'c'; + const label = 'd'; + const propKey = 'e'; + + const encoded = encodeEdgePropKey(from, to, label, propKey); + // With embedded \0 in "from", split will produce extra segments, + // so decode will not round-trip. This documents the behavior: + // the codec assumes fields do not contain \0. + const decoded = decodeEdgePropKey(encoded); + expect(decoded.from).toBe('a'); + }); + + it('encoded key always starts with EDGE_PROP_PREFIX', () => { + const cases = [ + ['', '', '', ''], + ['a', 'b', 'c', 'd'], + ['\x01', '\x01', '\x01', '\x01'], + ['very-long-node-id', 'another-long-node-id', 'relationship-type', 'property-name'], + ]; + + for (const [from, to, label, propKey] of cases) { + const encoded = encodeEdgePropKey(from, to, label, propKey); + expect(encoded[0]).toBe(EDGE_PROP_PREFIX); + } + }); + }); +}); From c7d30fa13bbaa4ad3b89378da72bc27ecb915e4d Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Wed, 4 Feb 2026 06:12:38 -0800 Subject: [PATCH 02/10] feat: add setEdgeProperty to PatchBuilderV2 (WT/OPS/1) Encodes edge identity as \x01from\0to\0label in the PropSet op's node field, so JoinReducer's existing LWW pipeline handles edge properties transparently. 14 tests. --- src/domain/services/PatchBuilderV2.js | 32 ++- .../services/PatchBuilderV2.edgeProps.test.js | 245 ++++++++++++++++++ 2 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 test/unit/domain/services/PatchBuilderV2.edgeProps.test.js diff --git a/src/domain/services/PatchBuilderV2.js b/src/domain/services/PatchBuilderV2.js index 13287e8..6fd97ed 100644 --- a/src/domain/services/PatchBuilderV2.js +++ b/src/domain/services/PatchBuilderV2.js @@ -21,7 +21,7 @@ import { createPropSetV2, createPatchV2, } from '../types/WarpTypesV2.js'; -import { encodeEdgeKey } from './JoinReducer.js'; +import { encodeEdgeKey, EDGE_PROP_PREFIX } from './JoinReducer.js'; import { encode } from '../../infrastructure/codecs/CborCodec.js'; import { encodePatchMessage, decodePatchMessage } from './WarpMessageCodec.js'; import { buildWriterRef } from '../utils/RefLayout.js'; @@ -163,6 +163,36 @@ export class PatchBuilderV2 { return this; } + /** + * Sets a property on an edge. + * Props use EventId from patch context (lamport + writer), not dots. + * + * The edge is identified by (from, to, label). The property is stored + * under the edge property key namespace using the \x01 prefix, so that + * JoinReducer's `encodePropKey(op.node, op.key)` produces the canonical + * `encodeEdgePropKey(from, to, label, key)` encoding. + * + * @param {string} from - Source node ID + * @param {string} to - Target node ID + * @param {string} label - Edge label/type + * @param {string} key - Property key + * @param {*} value - Property value (any JSON-serializable type) + * @returns {PatchBuilderV2} This builder for chaining + * + * @example + * builder.setEdgeProperty('user:alice', 'user:bob', 'follows', 'since', '2025-01-01'); + */ + setEdgeProperty(from, to, label, key, value) { + // Encode the edge identity as the "node" field with the \x01 prefix. + // When JoinReducer processes: encodePropKey(op.node, op.key) + // = `\x01from\0to\0label` + `\0` + key + // = `\x01from\0to\0label\0key` + // = encodeEdgePropKey(from, to, label, key) + const edgeNode = `${EDGE_PROP_PREFIX}${from}\0${to}\0${label}`; + this._ops.push(createPropSetV2(edgeNode, key, value)); + return this; + } + /** * Builds the PatchV2 object. * diff --git a/test/unit/domain/services/PatchBuilderV2.edgeProps.test.js b/test/unit/domain/services/PatchBuilderV2.edgeProps.test.js new file mode 100644 index 0000000..ac89c46 --- /dev/null +++ b/test/unit/domain/services/PatchBuilderV2.edgeProps.test.js @@ -0,0 +1,245 @@ +import { describe, it, expect } from 'vitest'; +import { PatchBuilderV2 } from '../../../../src/domain/services/PatchBuilderV2.js'; +import { createVersionVector } from '../../../../src/domain/crdt/VersionVector.js'; +import { + encodePropKey, + encodeEdgePropKey, + EDGE_PROP_PREFIX, +} from '../../../../src/domain/services/JoinReducer.js'; + +/** + * Helper — creates a minimal PatchBuilderV2 for unit tests (no persistence needed). + */ +function makeBuilder(opts = {}) { + return new PatchBuilderV2({ + writerId: opts.writerId ?? 'w1', + lamport: opts.lamport ?? 1, + versionVector: opts.versionVector ?? createVersionVector(), + getCurrentState: opts.getCurrentState ?? (() => null), + }); +} + +describe('PatchBuilderV2.setEdgeProperty', () => { + // --------------------------------------------------------------- + // Golden path + // --------------------------------------------------------------- + describe('golden path: addEdge then setEdgeProperty', () => { + it('creates a PropSet op whose node field encodes the edge identity', () => { + const builder = makeBuilder(); + + builder + .addEdge('user:alice', 'user:bob', 'follows') + .setEdgeProperty('user:alice', 'user:bob', 'follows', 'since', '2025-01-01'); + + const patch = builder.build(); + expect(patch.ops).toHaveLength(2); + + const propOp = patch.ops[1]; + expect(propOp.type).toBe('PropSet'); + expect(propOp.key).toBe('since'); + expect(propOp.value).toBe('2025-01-01'); + + // The node field must start with EDGE_PROP_PREFIX + expect(propOp.node.startsWith(EDGE_PROP_PREFIX)).toBe(true); + }); + + it('produces the canonical encodeEdgePropKey when run through encodePropKey', () => { + const builder = makeBuilder(); + builder.setEdgeProperty('a', 'b', 'rel', 'weight', 42); + + const op = builder.ops[0]; + const mapKey = encodePropKey(op.node, op.key); + const expected = encodeEdgePropKey('a', 'b', 'rel', 'weight'); + expect(mapKey).toBe(expected); + }); + }); + + // --------------------------------------------------------------- + // Property stored under edge prop key namespace + // --------------------------------------------------------------- + describe('namespace isolation', () => { + it('edge property key differs from node property key with same prop name', () => { + const builder = makeBuilder(); + + builder + .setProperty('a', 'weight', 10) + .setEdgeProperty('a', 'b', 'rel', 'weight', 99); + + const [nodeOp, edgeOp] = builder.ops; + + // Both are PropSet but with different node fields + expect(nodeOp.type).toBe('PropSet'); + expect(edgeOp.type).toBe('PropSet'); + expect(nodeOp.node).not.toBe(edgeOp.node); + + // Encoded map keys must differ + const nodeMapKey = encodePropKey(nodeOp.node, nodeOp.key); + const edgeMapKey = encodePropKey(edgeOp.node, edgeOp.key); + expect(nodeMapKey).not.toBe(edgeMapKey); + }); + }); + + // --------------------------------------------------------------- + // Set property on edge added in same patch + // --------------------------------------------------------------- + describe('edge added in same patch', () => { + it('commit succeeds with addEdge + setEdgeProperty in one patch', () => { + const builder = makeBuilder(); + + builder + .addNode('x') + .addNode('y') + .addEdge('x', 'y', 'link') + .setEdgeProperty('x', 'y', 'link', 'color', 'red'); + + const patch = builder.build(); + expect(patch.ops).toHaveLength(4); + expect(patch.ops[3].type).toBe('PropSet'); + expect(patch.ops[3].value).toBe('red'); + }); + }); + + // --------------------------------------------------------------- + // Multiple edge properties on same edge + // --------------------------------------------------------------- + describe('multiple properties on same edge', () => { + it('stores distinct PropSet ops for each property', () => { + const builder = makeBuilder(); + + builder + .addEdge('a', 'b', 'rel') + .setEdgeProperty('a', 'b', 'rel', 'weight', 5) + .setEdgeProperty('a', 'b', 'rel', 'color', 'blue') + .setEdgeProperty('a', 'b', 'rel', 'active', true); + + const patch = builder.build(); + // 1 EdgeAdd + 3 PropSet + expect(patch.ops).toHaveLength(4); + + const propOps = patch.ops.filter((o) => o.type === 'PropSet'); + expect(propOps).toHaveLength(3); + + // All share the same node field (edge identity) + const nodeFields = new Set(propOps.map((o) => o.node)); + expect(nodeFields.size).toBe(1); + + // Keys are distinct + const keys = propOps.map((o) => o.key); + expect(keys).toEqual(['weight', 'color', 'active']); + + // Map keys are distinct + const mapKeys = propOps.map((o) => encodePropKey(o.node, o.key)); + expect(new Set(mapKeys).size).toBe(3); + }); + }); + + // --------------------------------------------------------------- + // Edge cases: value types + // --------------------------------------------------------------- + describe('edge-case values', () => { + it('handles empty string value', () => { + const builder = makeBuilder(); + builder.setEdgeProperty('a', 'b', 'rel', 'note', ''); + + const op = builder.ops[0]; + expect(op.value).toBe(''); + }); + + it('handles numeric value', () => { + const builder = makeBuilder(); + builder.setEdgeProperty('a', 'b', 'rel', 'weight', 3.14); + + const op = builder.ops[0]; + expect(op.value).toBe(3.14); + }); + + it('handles object value', () => { + const builder = makeBuilder(); + const obj = { nested: true, count: 7 }; + builder.setEdgeProperty('a', 'b', 'rel', 'meta', obj); + + const op = builder.ops[0]; + expect(op.value).toEqual({ nested: true, count: 7 }); + }); + + it('handles null value', () => { + const builder = makeBuilder(); + builder.setEdgeProperty('a', 'b', 'rel', 'deleted', null); + + const op = builder.ops[0]; + expect(op.value).toBeNull(); + }); + + it('handles boolean value', () => { + const builder = makeBuilder(); + builder.setEdgeProperty('a', 'b', 'rel', 'active', false); + + const op = builder.ops[0]; + expect(op.value).toBe(false); + }); + + it('handles array value', () => { + const builder = makeBuilder(); + builder.setEdgeProperty('a', 'b', 'rel', 'tags', ['x', 'y']); + + const op = builder.ops[0]; + expect(op.value).toEqual(['x', 'y']); + }); + }); + + // --------------------------------------------------------------- + // Chaining + // --------------------------------------------------------------- + describe('chaining', () => { + it('returns this for method chaining', () => { + const builder = makeBuilder(); + const result = builder.setEdgeProperty('a', 'b', 'rel', 'k', 'v'); + expect(result).toBe(builder); + }); + }); + + // --------------------------------------------------------------- + // Does not increment version vector (same as setProperty) + // --------------------------------------------------------------- + describe('version vector', () => { + it('does not increment version vector', () => { + const vv = createVersionVector(); + const builder = makeBuilder({ versionVector: vv }); + + builder.setEdgeProperty('a', 'b', 'rel', 'k1', 'v1'); + builder.setEdgeProperty('a', 'b', 'rel', 'k2', 'v2'); + + // Props don't use dots, so VV should be untouched + expect(builder.versionVector.get('w1')).toBeUndefined(); + }); + }); + + // --------------------------------------------------------------- + // Operation ordering + // --------------------------------------------------------------- + describe('operation ordering', () => { + it('preserves order among mixed node/edge property ops', () => { + const builder = makeBuilder(); + + builder + .addNode('n1') + .addEdge('n1', 'n2', 'link') + .setProperty('n1', 'name', 'N1') + .setEdgeProperty('n1', 'n2', 'link', 'weight', 10) + .setProperty('n1', 'age', 5); + + const types = builder.ops.map((o) => o.type); + expect(types).toEqual(['NodeAdd', 'EdgeAdd', 'PropSet', 'PropSet', 'PropSet']); + + // Verify which PropSet is which by checking the key + expect(builder.ops[2].key).toBe('name'); + expect(builder.ops[3].key).toBe('weight'); + expect(builder.ops[4].key).toBe('age'); + + // Only the middle PropSet should have edge-prop-prefix node + expect(builder.ops[2].node).toBe('n1'); + expect(builder.ops[3].node.startsWith(EDGE_PROP_PREFIX)).toBe(true); + expect(builder.ops[4].node).toBe('n1'); + }); + }); +}); From 375f752cec970d2ac443e43203ef8b42ffd612a7 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Wed, 4 Feb 2026 06:14:19 -0800 Subject: [PATCH 03/10] feat: define schema v3 format for edge properties (WT/SCHEMA/1) Add SCHEMA_V3 constant and detectSchemaVersion() helper. Schema v3 signals edge property PropSet ops; format is identical to v2. Codec handles both v2 and v3 transparently. 24 tests. --- src/domain/WarpGraph.js | 80 ++++- src/domain/services/CheckpointService.js | 4 +- src/domain/services/WarpMessageCodec.js | 49 ++- src/domain/types/WarpTypesV2.js | 2 +- .../services/WarpMessageCodec.v3.test.js | 302 ++++++++++++++++++ 5 files changed, 425 insertions(+), 12 deletions(-) create mode 100644 test/unit/domain/services/WarpMessageCodec.v3.test.js diff --git a/src/domain/WarpGraph.js b/src/domain/WarpGraph.js index 5ec4d62..9f913d9 100644 --- a/src/domain/WarpGraph.js +++ b/src/domain/WarpGraph.js @@ -10,7 +10,7 @@ import { validateGraphName, validateWriterId, buildWriterRef, buildCoverageRef, buildCheckpointRef, buildWritersPrefix, parseWriterIdFromRef } from './utils/RefLayout.js'; import { PatchBuilderV2 } from './services/PatchBuilderV2.js'; -import { reduceV5, createEmptyStateV5, joinStates, join as joinPatch, decodeEdgeKey, decodePropKey } from './services/JoinReducer.js'; +import { reduceV5, createEmptyStateV5, joinStates, join as joinPatch, decodeEdgeKey, decodePropKey, isEdgePropKey, decodeEdgePropKey, encodeEdgeKey } from './services/JoinReducer.js'; import { orsetContains, orsetElements } from './crdt/ORSet.js'; import { decode } from '../infrastructure/codecs/CborCodec.js'; import { decodePatchMessage, detectMessageKind, encodeAnchorMessage } from './services/WarpMessageCodec.js'; @@ -480,7 +480,7 @@ export default class WarpGraph { let patchCount = 0; // If checkpoint exists, use incremental materialization - if (checkpoint?.schema === 2) { + if (checkpoint?.schema === 2 || checkpoint?.schema === 3) { const patches = await this._loadPatchesSince(checkpoint); state = reduceV5(patches, checkpoint.state); patchCount = patches.length; @@ -832,7 +832,7 @@ export default class WarpGraph { */ async _validateMigrationBoundary() { const checkpoint = await this._loadLatestCheckpoint(); - if (checkpoint?.schema === 2) { + if (checkpoint?.schema === 2 || checkpoint?.schema === 3) { return; // Already migrated } @@ -995,7 +995,7 @@ export default class WarpGraph { * @private */ async _validatePatchAgainstCheckpoint(writerId, incomingSha, checkpoint) { - if (!checkpoint || checkpoint.schema !== 2) { + if (!checkpoint || (checkpoint.schema !== 2 && checkpoint.schema !== 3)) { return; } @@ -1841,6 +1841,52 @@ export default class WarpGraph { return props; } + /** + * Gets all properties for an edge from the materialized state. + * + * Returns properties as a plain object of key → value. Only returns + * properties for edges that exist in the materialized state. + * + * **Requires a cached state.** Call materialize() first if not already cached. + * + * @param {string} from - Source node ID + * @param {string} to - Target node ID + * @param {string} label - Edge label + * @returns {Promise|null>} Object of property key → value, or null if edge doesn't exist + * @throws {QueryError} If no cached state exists (code: `E_NO_STATE`) + * @throws {QueryError} If cached state is dirty (code: `E_STALE_STATE`) + * + * @example + * await graph.materialize(); + * const props = await graph.getEdgeProps('user:alice', 'user:bob', 'follows'); + * if (props) { + * console.log('Weight:', props.weight); + * } + */ + async getEdgeProps(from, to, label) { + await this._ensureFreshState(); + + // Check if edge exists + const edgeKey = encodeEdgeKey(from, to, label); + if (!orsetContains(this._cachedState.edgeAlive, edgeKey)) { + return null; + } + + // Collect all properties for this edge + const props = {}; + for (const [propKey, register] of this._cachedState.prop) { + if (!isEdgePropKey(propKey)) { + continue; + } + const decoded = decodeEdgePropKey(propKey); + if (decoded.from === from && decoded.to === to && decoded.label === label) { + props[decoded.propKey] = register.value; + } + } + + return props; + } + /** * Gets neighbors of a node from the materialized state. * @@ -1921,28 +1967,48 @@ export default class WarpGraph { /** * Gets all visible edges in the materialized state. * + * Each edge includes a `props` object containing any edge properties + * from the materialized state. + * * **Requires a cached state.** Call materialize() first if not already cached. * - * @returns {Promise>} Array of edge info + * @returns {Promise}>>} Array of edge info * @throws {QueryError} If no cached state exists (code: `E_NO_STATE`) * @throws {QueryError} If cached state is dirty (code: `E_STALE_STATE`) * * @example * await graph.materialize(); * for (const edge of await graph.getEdges()) { - * console.log(`${edge.from} --${edge.label}--> ${edge.to}`); + * console.log(`${edge.from} --${edge.label}--> ${edge.to}`, edge.props); * } */ async getEdges() { await this._ensureFreshState(); + // Pre-collect edge props into a lookup: "from\0to\0label" → {propKey: value} + const edgePropsByKey = new Map(); + for (const [propKey, register] of this._cachedState.prop) { + if (!isEdgePropKey(propKey)) { + continue; + } + const decoded = decodeEdgePropKey(propKey); + const ek = encodeEdgeKey(decoded.from, decoded.to, decoded.label); + let bag = edgePropsByKey.get(ek); + if (!bag) { + bag = {}; + edgePropsByKey.set(ek, bag); + } + bag[decoded.propKey] = register.value; + } + const edges = []; for (const edgeKey of orsetElements(this._cachedState.edgeAlive)) { const { from, to, label } = decodeEdgeKey(edgeKey); // Only include edges where both endpoints are visible if (orsetContains(this._cachedState.nodeAlive, from) && orsetContains(this._cachedState.nodeAlive, to)) { - edges.push({ from, to, label }); + const props = edgePropsByKey.get(edgeKey) || {}; + edges.push({ from, to, label, props }); } } return edges; diff --git a/src/domain/services/CheckpointService.js b/src/domain/services/CheckpointService.js index b9a0edb..3b985bb 100644 --- a/src/domain/services/CheckpointService.js +++ b/src/domain/services/CheckpointService.js @@ -175,10 +175,10 @@ export async function loadCheckpoint(persistence, checkpointSha) { const decoded = decodeCheckpointMessage(message); // 2. Reject schema:1 checkpoints - migration required - if (decoded.schema !== 2) { + if (decoded.schema !== 2 && decoded.schema !== 3) { throw new Error( `Checkpoint ${checkpointSha} is schema:${decoded.schema}. ` + - `Only schema:2 checkpoints are supported. Please migrate using MigrationService.` + `Only schema:2 and schema:3 checkpoints are supported. Please migrate using MigrationService.` ); } diff --git a/src/domain/services/WarpMessageCodec.js b/src/domain/services/WarpMessageCodec.js index 3d4f6cc..3d1dad8 100644 --- a/src/domain/services/WarpMessageCodec.js +++ b/src/domain/services/WarpMessageCodec.js @@ -58,6 +58,25 @@ const OID_PATTERN = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/; */ const SHA256_PATTERN = /^[0-9a-f]{64}$/; +/** + * Edge property namespace prefix. + * PropSet ops whose `node` field starts with this character target edge properties. + * @type {string} + */ +const EDGE_PROP_PREFIX = '\x01'; + +/** + * Schema version for patches that may contain edge property PropSet ops. + * @type {number} + */ +export const SCHEMA_V3 = 3; + +/** + * Schema version for classic node-only patches (V5 format). + * @type {number} + */ +export const SCHEMA_V2 = 2; + // ----------------------------------------------------------------------------- // Codec Instance // ----------------------------------------------------------------------------- @@ -127,6 +146,32 @@ function validateSchema(schema) { validatePositiveInteger(schema, 'schema'); } +// ----------------------------------------------------------------------------- +// Schema Version Detection +// ----------------------------------------------------------------------------- + +/** + * Detects the appropriate schema version for a set of patch operations. + * + * Returns schema 3 if ANY PropSet op has a `node` field starting with the + * edge property prefix (`\x01`), indicating edge property support is required. + * Otherwise returns schema 2 for backward compatibility. + * + * @param {Array<{type: string, node?: string}>} ops - Array of patch operations + * @returns {number} The schema version (2 or 3) + */ +export function detectSchemaVersion(ops) { + if (!Array.isArray(ops)) { + return SCHEMA_V2; + } + for (const op of ops) { + if (op.type === 'PropSet' && typeof op.node === 'string' && op.node.startsWith(EDGE_PROP_PREFIX)) { + return SCHEMA_V3; + } + } + return SCHEMA_V2; +} + // ----------------------------------------------------------------------------- // Encoders // ----------------------------------------------------------------------------- @@ -211,8 +256,8 @@ export function encodeCheckpointMessage({ graph, stateHash, frontierOid, indexOi [TRAILER_KEYS.schema]: String(schema), }; - // Add checkpoint version marker for V5 (schema:2) - if (schema === 2) { + // Add checkpoint version marker for V5 format (schema:2 and schema:3) + if (schema === 2 || schema === 3) { trailers[TRAILER_KEYS.checkpointVersion] = 'v5'; } diff --git a/src/domain/types/WarpTypesV2.js b/src/domain/types/WarpTypesV2.js index 2c7166a..9c68300 100644 --- a/src/domain/types/WarpTypesV2.js +++ b/src/domain/types/WarpTypesV2.js @@ -170,7 +170,7 @@ export function createPropSetV2(node, key, value) { /** * Creates a PatchV2 * @param {Object} options - Patch options - * @param {2} [options.schema=2] - Schema version (always 2 for V2) + * @param {2|3} [options.schema=2] - Schema version (2 for node-only, 3 for edge properties) * @param {string} options.writer - Writer ID * @param {number} options.lamport - Lamport timestamp * @param {VersionVector} options.context - Writer's observed frontier diff --git a/test/unit/domain/services/WarpMessageCodec.v3.test.js b/test/unit/domain/services/WarpMessageCodec.v3.test.js new file mode 100644 index 0000000..82036ff --- /dev/null +++ b/test/unit/domain/services/WarpMessageCodec.v3.test.js @@ -0,0 +1,302 @@ +import { describe, it, expect } from 'vitest'; +import { + encodePatchMessage, + encodeCheckpointMessage, + encodeAnchorMessage, + decodePatchMessage, + decodeCheckpointMessage, + decodeAnchorMessage, + detectMessageKind, + detectSchemaVersion, + SCHEMA_V2, + SCHEMA_V3, +} from '../../../../src/domain/services/WarpMessageCodec.js'; + +// Test fixtures +const VALID_OID_SHA1 = 'a'.repeat(40); +const VALID_STATE_HASH = 'c'.repeat(64); + +// Edge property prefix used in JoinReducer +const EDGE_PROP_PREFIX = '\x01'; + +describe('WarpMessageCodec schema v3', () => { + describe('constants', () => { + it('SCHEMA_V2 is 2', () => { + expect(SCHEMA_V2).toBe(2); + }); + + it('SCHEMA_V3 is 3', () => { + expect(SCHEMA_V3).toBe(3); + }); + }); + + describe('detectSchemaVersion', () => { + it('returns schema 2 for ops with only node PropSet', () => { + const ops = [ + { type: 'NodeAdd', node: 'user:alice', dot: { writer: 'w1', seq: 1 } }, + { type: 'PropSet', node: 'user:alice', key: 'name', value: 'Alice' }, + ]; + expect(detectSchemaVersion(ops)).toBe(2); + }); + + it('returns schema 3 when any PropSet has edge prop prefix', () => { + const edgePropNode = `${EDGE_PROP_PREFIX}user:alice\0user:bob\0manages\0weight`; + const ops = [ + { type: 'NodeAdd', node: 'user:alice', dot: { writer: 'w1', seq: 1 } }, + { type: 'PropSet', node: edgePropNode, key: 'weight', value: 1.5 }, + ]; + expect(detectSchemaVersion(ops)).toBe(3); + }); + + it('returns schema 3 even if only one PropSet has edge prop prefix among many', () => { + const edgePropNode = `${EDGE_PROP_PREFIX}a\0b\0rel\0key`; + const ops = [ + { type: 'PropSet', node: 'user:alice', key: 'name', value: 'Alice' }, + { type: 'PropSet', node: 'user:bob', key: 'name', value: 'Bob' }, + { type: 'PropSet', node: edgePropNode, key: 'key', value: 42 }, + { type: 'PropSet', node: 'user:carol', key: 'name', value: 'Carol' }, + ]; + expect(detectSchemaVersion(ops)).toBe(3); + }); + + it('returns schema 2 for empty ops array', () => { + expect(detectSchemaVersion([])).toBe(2); + }); + + it('returns schema 2 for non-array input', () => { + expect(detectSchemaVersion(null)).toBe(2); + expect(detectSchemaVersion(undefined)).toBe(2); + expect(detectSchemaVersion('not-an-array')).toBe(2); + }); + + it('returns schema 2 when no PropSet ops exist', () => { + const ops = [ + { type: 'NodeAdd', node: 'user:alice', dot: { writer: 'w1', seq: 1 } }, + { type: 'EdgeAdd', from: 'user:alice', to: 'user:bob', label: 'knows', dot: { writer: 'w1', seq: 2 } }, + ]; + expect(detectSchemaVersion(ops)).toBe(2); + }); + + it('ignores non-PropSet ops even if their node starts with \\x01', () => { + const ops = [ + { type: 'NodeAdd', node: `${EDGE_PROP_PREFIX}weird`, dot: { writer: 'w1', seq: 1 } }, + ]; + expect(detectSchemaVersion(ops)).toBe(2); + }); + }); + + describe('encodePatchMessage with schema v3', () => { + it('encodes a schema v3 patch message', () => { + const message = encodePatchMessage({ + graph: 'events', + writer: 'node-1', + lamport: 1, + patchOid: VALID_OID_SHA1, + schema: 3, + }); + + expect(message).toContain('eg-schema: 3'); + expect(message).toContain('eg-kind: patch'); + expect(message).toContain('eg-graph: events'); + }); + + it('defaults to schema 2 when schema is not provided', () => { + const message = encodePatchMessage({ + graph: 'events', + writer: 'node-1', + lamport: 1, + patchOid: VALID_OID_SHA1, + }); + + expect(message).toContain('eg-schema: 2'); + }); + }); + + describe('decodePatchMessage with schema v3', () => { + it('decodes a schema v3 patch message', () => { + const encoded = encodePatchMessage({ + graph: 'events', + writer: 'node-1', + lamport: 42, + patchOid: VALID_OID_SHA1, + schema: 3, + }); + + const decoded = decodePatchMessage(encoded); + + expect(decoded.kind).toBe('patch'); + expect(decoded.graph).toBe('events'); + expect(decoded.writer).toBe('node-1'); + expect(decoded.lamport).toBe(42); + expect(decoded.patchOid).toBe(VALID_OID_SHA1); + expect(decoded.schema).toBe(3); + }); + + it('still decodes schema v2 patch messages without error', () => { + const encoded = encodePatchMessage({ + graph: 'events', + writer: 'node-1', + lamport: 10, + patchOid: VALID_OID_SHA1, + schema: 2, + }); + + const decoded = decodePatchMessage(encoded); + + expect(decoded.kind).toBe('patch'); + expect(decoded.schema).toBe(2); + }); + }); + + describe('round-trip encoding/decoding', () => { + it('v3 patch message round-trips correctly', () => { + const original = { + graph: 'my-graph', + writer: 'writer-1', + lamport: 99, + patchOid: VALID_OID_SHA1, + schema: 3, + }; + + const encoded = encodePatchMessage(original); + const decoded = decodePatchMessage(encoded); + + expect(decoded.kind).toBe('patch'); + expect(decoded.graph).toBe(original.graph); + expect(decoded.writer).toBe(original.writer); + expect(decoded.lamport).toBe(original.lamport); + expect(decoded.patchOid).toBe(original.patchOid); + expect(decoded.schema).toBe(3); + }); + + it('v2 patch message still round-trips correctly', () => { + const original = { + graph: 'legacy-graph', + writer: 'writer-2', + lamport: 7, + patchOid: VALID_OID_SHA1, + schema: 2, + }; + + const encoded = encodePatchMessage(original); + const decoded = decodePatchMessage(encoded); + + expect(decoded.kind).toBe('patch'); + expect(decoded.graph).toBe(original.graph); + expect(decoded.writer).toBe(original.writer); + expect(decoded.lamport).toBe(original.lamport); + expect(decoded.patchOid).toBe(original.patchOid); + expect(decoded.schema).toBe(2); + }); + }); + + describe('detectSchemaVersion integration with encodePatchMessage', () => { + it('node-only ops produce schema 2', () => { + const ops = [ + { type: 'NodeAdd', node: 'user:alice', dot: { writer: 'w1', seq: 1 } }, + { type: 'PropSet', node: 'user:alice', key: 'name', value: 'Alice' }, + ]; + const schema = detectSchemaVersion(ops); + expect(schema).toBe(2); + + const message = encodePatchMessage({ + graph: 'test', + writer: 'w1', + lamport: 1, + patchOid: VALID_OID_SHA1, + schema, + }); + expect(message).toContain('eg-schema: 2'); + }); + + it('edge prop ops produce schema 3', () => { + const edgePropNode = `${EDGE_PROP_PREFIX}a\0b\0rel\0weight`; + const ops = [ + { type: 'NodeAdd', node: 'user:alice', dot: { writer: 'w1', seq: 1 } }, + { type: 'PropSet', node: edgePropNode, key: 'weight', value: 3.14 }, + ]; + const schema = detectSchemaVersion(ops); + expect(schema).toBe(3); + + const message = encodePatchMessage({ + graph: 'test', + writer: 'w1', + lamport: 1, + patchOid: VALID_OID_SHA1, + schema, + }); + expect(message).toContain('eg-schema: 3'); + }); + }); + + describe('checkpoint message with schema v3', () => { + it('encodes a schema v3 checkpoint with v5 checkpoint version', () => { + const message = encodeCheckpointMessage({ + graph: 'events', + stateHash: VALID_STATE_HASH, + frontierOid: VALID_OID_SHA1, + indexOid: VALID_OID_SHA1, + schema: 3, + }); + + expect(message).toContain('eg-schema: 3'); + expect(message).toContain('eg-checkpoint: v5'); + }); + + it('decodes a schema v3 checkpoint', () => { + const encoded = encodeCheckpointMessage({ + graph: 'events', + stateHash: VALID_STATE_HASH, + frontierOid: VALID_OID_SHA1, + indexOid: VALID_OID_SHA1, + schema: 3, + }); + + const decoded = decodeCheckpointMessage(encoded); + expect(decoded.schema).toBe(3); + expect(decoded.checkpointVersion).toBe('v5'); + }); + }); + + describe('anchor message with schema v3', () => { + it('encodes a schema v3 anchor', () => { + const message = encodeAnchorMessage({ graph: 'events', schema: 3 }); + expect(message).toContain('eg-schema: 3'); + }); + + it('decodes a schema v3 anchor', () => { + const encoded = encodeAnchorMessage({ graph: 'events', schema: 3 }); + const decoded = decodeAnchorMessage(encoded); + expect(decoded.schema).toBe(3); + }); + }); + + describe('detectMessageKind with schema v3', () => { + it('detects v3 patch messages', () => { + const message = encodePatchMessage({ + graph: 'events', + writer: 'node-1', + lamport: 1, + patchOid: VALID_OID_SHA1, + schema: 3, + }); + expect(detectMessageKind(message)).toBe('patch'); + }); + + it('detects v3 checkpoint messages', () => { + const message = encodeCheckpointMessage({ + graph: 'events', + stateHash: VALID_STATE_HASH, + frontierOid: VALID_OID_SHA1, + indexOid: VALID_OID_SHA1, + schema: 3, + }); + expect(detectMessageKind(message)).toBe('checkpoint'); + }); + + it('detects v3 anchor messages', () => { + const message = encodeAnchorMessage({ graph: 'events', schema: 3 }); + expect(detectMessageKind(message)).toBe('anchor'); + }); + }); +}); From b1b00397e4cb13bbabcdc6fb84333ffb7076c71d Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Wed, 4 Feb 2026 06:15:55 -0800 Subject: [PATCH 04/10] test: verify LWW semantics for edge properties in JoinReducer (WT/OPS/2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No prod code changes — existing LWW path handles edge prop keys transparently. 29 tests covering tiebreaks, fuzz, mixed props, overwrites, and joinStates merging. --- .../services/JoinReducer.edgeProps.test.js | 780 ++++++++++++++++++ 1 file changed, 780 insertions(+) create mode 100644 test/unit/domain/services/JoinReducer.edgeProps.test.js diff --git a/test/unit/domain/services/JoinReducer.edgeProps.test.js b/test/unit/domain/services/JoinReducer.edgeProps.test.js new file mode 100644 index 0000000..3142552 --- /dev/null +++ b/test/unit/domain/services/JoinReducer.edgeProps.test.js @@ -0,0 +1,780 @@ +import { describe, it, expect } from 'vitest'; +import { + createEmptyStateV5, + encodeEdgeKey, + encodePropKey, + encodeEdgePropKey, + decodeEdgePropKey, + isEdgePropKey, + EDGE_PROP_PREFIX, + applyOpV2, + join, + joinStates, + reduceV5, +} from '../../../../src/domain/services/JoinReducer.js'; +import { createEventId } from '../../../../src/domain/utils/EventId.js'; +import { createDot } from '../../../../src/domain/crdt/Dot.js'; +import { orsetContains } from '../../../../src/domain/crdt/ORSet.js'; +import { lwwValue } from '../../../../src/domain/crdt/LWW.js'; +import { createVersionVector } from '../../../../src/domain/crdt/VersionVector.js'; +import { createInlineValue } from '../../../../src/domain/types/WarpTypes.js'; + +// --------------------------------------------------------------------------- +// Helpers — mirror the patterns in JoinReducer.test.js +// --------------------------------------------------------------------------- + +function createNodeAddV2(node, dot) { + return { type: 'NodeAdd', node, dot }; +} + +function createEdgeAddV2(from, to, label, dot) { + return { type: 'EdgeAdd', from, to, label, dot }; +} + +function createPropSetV2(node, key, value) { + return { type: 'PropSet', node, key, value }; +} + +/** + * Creates a PropSet operation for an edge property, exactly as + * PatchBuilderV2.setEdgeProperty does: op.node = '\x01from\0to\0label', + * op.key = propKey. + */ +function createEdgePropSetV2(from, to, label, propKey, value) { + const edgeNode = `${EDGE_PROP_PREFIX}${from}\0${to}\0${label}`; + return createPropSetV2(edgeNode, propKey, value); +} + +function createPatchV2({ writer, lamport, ops, context }) { + return { + schema: 2, + writer, + lamport, + ops, + context: context || createVersionVector(), + }; +} + +/** + * Reads an edge property from materialized state using the canonical + * encoding path: encodePropKey(op.node, op.key) which equals + * encodeEdgePropKey(from, to, label, propKey). + */ +function getEdgeProp(state, from, to, label, propKey) { + const key = encodeEdgePropKey(from, to, label, propKey); + return lwwValue(state.prop.get(key)); +} + +/** + * Reads a node property from materialized state. + */ +function getNodeProp(state, nodeId, propKey) { + const key = encodePropKey(nodeId, propKey); + return lwwValue(state.prop.get(key)); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('JoinReducer — edge property LWW', () => { + // ========================================================================= + // Encoding sanity checks + // ========================================================================= + describe('encodeEdgePropKey / decodeEdgePropKey', () => { + it('roundtrips correctly', () => { + const encoded = encodeEdgePropKey('user:alice', 'user:bob', 'follows', 'since'); + const decoded = decodeEdgePropKey(encoded); + expect(decoded).toEqual({ + from: 'user:alice', + to: 'user:bob', + label: 'follows', + propKey: 'since', + }); + }); + + it('is detected as an edge prop key', () => { + const encoded = encodeEdgePropKey('a', 'b', 'rel', 'weight'); + expect(isEdgePropKey(encoded)).toBe(true); + }); + + it('node prop keys are NOT detected as edge prop keys', () => { + const nodeProp = encodePropKey('user:alice', 'name'); + expect(isEdgePropKey(nodeProp)).toBe(false); + }); + + it('encodePropKey(edgeNode, key) equals encodeEdgePropKey(from, to, label, key)', () => { + // This is the critical identity: JoinReducer builds the map key via + // encodePropKey(op.node, op.key) and PatchBuilderV2 sets op.node to + // '\x01from\0to\0label'. The resulting key must match encodeEdgePropKey. + const from = 'a'; + const to = 'b'; + const label = 'rel'; + const propKey = 'weight'; + const edgeNode = `${EDGE_PROP_PREFIX}${from}\0${to}\0${label}`; + const viaPropKey = encodePropKey(edgeNode, propKey); + const viaEdgePropKey = encodeEdgePropKey(from, to, label, propKey); + expect(viaPropKey).toBe(viaEdgePropKey); + }); + }); + + // ========================================================================= + // Golden path: two writers, higher lamport wins + // ========================================================================= + describe('golden path — higher lamport wins', () => { + it('writer B (lamport 2) beats writer A (lamport 1)', () => { + const patchA = createPatchV2({ + writer: 'A', + lamport: 1, + ops: [createEdgePropSetV2('x', 'y', 'rel', 'weight', createInlineValue(10))], + }); + const patchB = createPatchV2({ + writer: 'B', + lamport: 2, + ops: [createEdgePropSetV2('x', 'y', 'rel', 'weight', createInlineValue(42))], + }); + + const state = reduceV5([ + { patch: patchA, sha: 'aaaa1234' }, + { patch: patchB, sha: 'bbbb1234' }, + ]); + + expect(getEdgeProp(state, 'x', 'y', 'rel', 'weight')).toEqual(createInlineValue(42)); + }); + + it('result is the same regardless of patch application order', () => { + const patchA = createPatchV2({ + writer: 'A', + lamport: 1, + ops: [createEdgePropSetV2('x', 'y', 'rel', 'weight', createInlineValue(10))], + }); + const patchB = createPatchV2({ + writer: 'B', + lamport: 2, + ops: [createEdgePropSetV2('x', 'y', 'rel', 'weight', createInlineValue(42))], + }); + + const stateAB = reduceV5([ + { patch: patchA, sha: 'aaaa1234' }, + { patch: patchB, sha: 'bbbb1234' }, + ]); + const stateBA = reduceV5([ + { patch: patchB, sha: 'bbbb1234' }, + { patch: patchA, sha: 'aaaa1234' }, + ]); + + expect(getEdgeProp(stateAB, 'x', 'y', 'rel', 'weight')).toEqual(createInlineValue(42)); + expect(getEdgeProp(stateBA, 'x', 'y', 'rel', 'weight')).toEqual(createInlineValue(42)); + }); + }); + + // ========================================================================= + // WriterId tiebreak: same lamport, alphabetically higher writerId wins + // ========================================================================= + describe('writerId tiebreak — same lamport', () => { + it('writer B wins over writer A when lamport is equal', () => { + const patchA = createPatchV2({ + writer: 'A', + lamport: 5, + ops: [createEdgePropSetV2('x', 'y', 'rel', 'weight', createInlineValue('from-A'))], + }); + const patchB = createPatchV2({ + writer: 'B', + lamport: 5, + ops: [createEdgePropSetV2('x', 'y', 'rel', 'weight', createInlineValue('from-B'))], + }); + + const stateAB = reduceV5([ + { patch: patchA, sha: 'aaaa1234' }, + { patch: patchB, sha: 'bbbb1234' }, + ]); + const stateBA = reduceV5([ + { patch: patchB, sha: 'bbbb1234' }, + { patch: patchA, sha: 'aaaa1234' }, + ]); + + // 'B' > 'A' lexicographically => B wins + expect(getEdgeProp(stateAB, 'x', 'y', 'rel', 'weight')).toEqual( + createInlineValue('from-B') + ); + expect(getEdgeProp(stateBA, 'x', 'y', 'rel', 'weight')).toEqual( + createInlineValue('from-B') + ); + }); + + it('writer "zara" wins over writer "alice"', () => { + const patchAlice = createPatchV2({ + writer: 'alice', + lamport: 3, + ops: [createEdgePropSetV2('n1', 'n2', 'link', 'color', createInlineValue('red'))], + }); + const patchZara = createPatchV2({ + writer: 'zara', + lamport: 3, + ops: [createEdgePropSetV2('n1', 'n2', 'link', 'color', createInlineValue('blue'))], + }); + + const state = reduceV5([ + { patch: patchAlice, sha: 'aaaa1234' }, + { patch: patchZara, sha: 'bbbb1234' }, + ]); + + expect(getEdgeProp(state, 'n1', 'n2', 'link', 'color')).toEqual(createInlineValue('blue')); + }); + }); + + // ========================================================================= + // PatchSha tiebreak: same lamport + writerId, higher sha wins + // ========================================================================= + describe('patchSha tiebreak — same lamport and writerId', () => { + it('higher SHA wins when lamport and writerId are equal', () => { + // Same writer, same lamport, different SHAs + const patchLow = createPatchV2({ + writer: 'W', + lamport: 7, + ops: [createEdgePropSetV2('a', 'b', 'edge', 'k', createInlineValue('low-sha'))], + }); + const patchHigh = createPatchV2({ + writer: 'W', + lamport: 7, + ops: [createEdgePropSetV2('a', 'b', 'edge', 'k', createInlineValue('high-sha'))], + }); + + // 'ffff0000' > '0000ffff' lexicographically + const stateLH = reduceV5([ + { patch: patchLow, sha: '0000ffff' }, + { patch: patchHigh, sha: 'ffff0000' }, + ]); + const stateHL = reduceV5([ + { patch: patchHigh, sha: 'ffff0000' }, + { patch: patchLow, sha: '0000ffff' }, + ]); + + expect(getEdgeProp(stateLH, 'a', 'b', 'edge', 'k')).toEqual( + createInlineValue('high-sha') + ); + expect(getEdgeProp(stateHL, 'a', 'b', 'edge', 'k')).toEqual( + createInlineValue('high-sha') + ); + }); + }); + + // ========================================================================= + // OpIndex tiebreak: same lamport + writerId + sha, higher opIndex wins + // ========================================================================= + describe('opIndex tiebreak — same lamport, writerId, and sha', () => { + it('later operation in the same patch wins for same edge prop key', () => { + // Two PropSet ops on the same edge prop in one patch. + // opIndex 1 > opIndex 0 so the second write wins. + const patch = createPatchV2({ + writer: 'W', + lamport: 1, + ops: [ + createEdgePropSetV2('a', 'b', 'rel', 'color', createInlineValue('first')), + createEdgePropSetV2('a', 'b', 'rel', 'color', createInlineValue('second')), + ], + }); + + const state = reduceV5([{ patch, sha: 'abcd1234' }]); + + expect(getEdgeProp(state, 'a', 'b', 'rel', 'color')).toEqual(createInlineValue('second')); + }); + }); + + // ========================================================================= + // Fuzz: random interleaving produces deterministic result + // ========================================================================= + describe('fuzz — random interleaving of edge prop sets', () => { + it('all permutations of 4 patches yield identical state', () => { + const patches = [ + { + patch: createPatchV2({ + writer: 'W1', + lamport: 1, + ops: [createEdgePropSetV2('x', 'y', 'rel', 'score', createInlineValue(100))], + }), + sha: 'aaaa1111', + }, + { + patch: createPatchV2({ + writer: 'W2', + lamport: 3, + ops: [createEdgePropSetV2('x', 'y', 'rel', 'score', createInlineValue(200))], + }), + sha: 'bbbb2222', + }, + { + patch: createPatchV2({ + writer: 'W3', + lamport: 2, + ops: [createEdgePropSetV2('x', 'y', 'rel', 'score', createInlineValue(300))], + }), + sha: 'cccc3333', + }, + { + patch: createPatchV2({ + writer: 'W4', + lamport: 3, + ops: [createEdgePropSetV2('x', 'y', 'rel', 'score', createInlineValue(400))], + }), + sha: 'dddd4444', + }, + ]; + + // W2 (lamport 3, writer 'W2') vs W4 (lamport 3, writer 'W4'): + // 'W4' > 'W2' so W4 wins => expected value 400 + const expected = createInlineValue(400); + + // Generate all 24 permutations of 4 elements + function permutations(arr) { + if (arr.length <= 1) return [arr]; + const result = []; + for (let i = 0; i < arr.length; i++) { + const rest = [...arr.slice(0, i), ...arr.slice(i + 1)]; + for (const perm of permutations(rest)) { + result.push([arr[i], ...perm]); + } + } + return result; + } + + const allPerms = permutations(patches); + expect(allPerms.length).toBe(24); + + for (const perm of allPerms) { + const state = reduceV5(perm); + expect(getEdgeProp(state, 'x', 'y', 'rel', 'score')).toEqual(expected); + } + }); + + it('10 random shuffles of 6 concurrent writers all converge', () => { + const writers = ['alpha', 'bravo', 'charlie', 'delta', 'echo', 'foxtrot']; + const patches = writers.map((w, i) => ({ + patch: createPatchV2({ + writer: w, + lamport: 5, + ops: [createEdgePropSetV2('src', 'dst', 'link', 'tag', createInlineValue(w))], + }), + sha: `${String(i).padStart(4, '0')}abcd`, + })); + + // All same lamport (5), tiebreak by writerId: + // 'foxtrot' > 'echo' > 'delta' > 'charlie' > 'bravo' > 'alpha' + // => foxtrot wins + const expected = createInlineValue('foxtrot'); + + // Fisher-Yates shuffle with a seeded PRNG (simple LCG) + function shuffle(arr, seed) { + const a = [...arr]; + let s = seed; + for (let i = a.length - 1; i > 0; i--) { + s = (s * 1664525 + 1013904223) & 0x7fffffff; + const j = s % (i + 1); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; + } + + for (let seed = 0; seed < 10; seed++) { + const shuffled = shuffle(patches, seed + 42); + const state = reduceV5(shuffled); + expect(getEdgeProp(state, 'src', 'dst', 'link', 'tag')).toEqual(expected); + } + }); + }); + + // ========================================================================= + // Mixed: node props and edge props coexist independently + // ========================================================================= + describe('mixed — node props and edge props resolve independently', () => { + it('edge prop and node prop on same logical key name do not collide', () => { + // Node "x" has a prop "weight" AND edge x->y:rel has a prop "weight". + // These must live in separate map keys. + const patch = createPatchV2({ + writer: 'W', + lamport: 1, + ops: [ + createPropSetV2('x', 'weight', createInlineValue('node-weight')), + createEdgePropSetV2('x', 'y', 'rel', 'weight', createInlineValue('edge-weight')), + ], + }); + + const state = reduceV5([{ patch, sha: 'abcd1234' }]); + + expect(getNodeProp(state, 'x', 'weight')).toEqual(createInlineValue('node-weight')); + expect(getEdgeProp(state, 'x', 'y', 'rel', 'weight')).toEqual( + createInlineValue('edge-weight') + ); + }); + + it('concurrent conflict on a node prop does not affect edge prop', () => { + // Writer A sets node prop at lamport 1 and edge prop at lamport 1 + // Writer B sets node prop at lamport 2 and edge prop at lamport 1 + // Node prop: B wins (higher lamport) + // Edge prop: B wins (same lamport, 'B' > 'A') + const patchA = createPatchV2({ + writer: 'A', + lamport: 1, + ops: [ + createPropSetV2('n', 'name', createInlineValue('A-node')), + createEdgePropSetV2('n', 'm', 'link', 'label', createInlineValue('A-edge')), + ], + }); + const patchB = createPatchV2({ + writer: 'B', + lamport: 2, + ops: [ + createPropSetV2('n', 'name', createInlineValue('B-node')), + ], + }); + // A separate patch from C that only touches edge prop at lamport 1 + const patchC = createPatchV2({ + writer: 'C', + lamport: 1, + ops: [ + createEdgePropSetV2('n', 'm', 'link', 'label', createInlineValue('C-edge')), + ], + }); + + const state = reduceV5([ + { patch: patchA, sha: 'aaaa1234' }, + { patch: patchB, sha: 'bbbb1234' }, + { patch: patchC, sha: 'cccc1234' }, + ]); + + // Node prop "n"/"name": B wins (lamport 2 > 1) + expect(getNodeProp(state, 'n', 'name')).toEqual(createInlineValue('B-node')); + // Edge prop n->m:link/"label": A and C both lamport 1, 'C' > 'A' => C wins + expect(getEdgeProp(state, 'n', 'm', 'link', 'label')).toEqual( + createInlineValue('C-edge') + ); + }); + + it('multiple edge properties on same edge resolve independently', () => { + const patchA = createPatchV2({ + writer: 'A', + lamport: 2, + ops: [ + createEdgePropSetV2('u', 'v', 'rel', 'color', createInlineValue('red')), + createEdgePropSetV2('u', 'v', 'rel', 'weight', createInlineValue(10)), + ], + }); + const patchB = createPatchV2({ + writer: 'B', + lamport: 1, + ops: [ + createEdgePropSetV2('u', 'v', 'rel', 'color', createInlineValue('blue')), + createEdgePropSetV2('u', 'v', 'rel', 'weight', createInlineValue(99)), + ], + }); + + const state = reduceV5([ + { patch: patchA, sha: 'aaaa1234' }, + { patch: patchB, sha: 'bbbb1234' }, + ]); + + // A wins both (lamport 2 > 1) + expect(getEdgeProp(state, 'u', 'v', 'rel', 'color')).toEqual(createInlineValue('red')); + expect(getEdgeProp(state, 'u', 'v', 'rel', 'weight')).toEqual(createInlineValue(10)); + }); + + it('different edges have independent property namespaces', () => { + // Edge x->y:follows and edge y->z:follows both have a "since" prop + const patch = createPatchV2({ + writer: 'W', + lamport: 1, + ops: [ + createEdgePropSetV2('x', 'y', 'follows', 'since', createInlineValue('2024-01')), + createEdgePropSetV2('y', 'z', 'follows', 'since', createInlineValue('2025-06')), + ], + }); + + const state = reduceV5([{ patch, sha: 'abcd1234' }]); + + expect(getEdgeProp(state, 'x', 'y', 'follows', 'since')).toEqual( + createInlineValue('2024-01') + ); + expect(getEdgeProp(state, 'y', 'z', 'follows', 'since')).toEqual( + createInlineValue('2025-06') + ); + }); + + it('same endpoints with different labels have independent properties', () => { + const patch = createPatchV2({ + writer: 'W', + lamport: 1, + ops: [ + createEdgePropSetV2('a', 'b', 'friend', 'strength', createInlineValue(5)), + createEdgePropSetV2('a', 'b', 'colleague', 'strength', createInlineValue(8)), + ], + }); + + const state = reduceV5([{ patch, sha: 'abcd1234' }]); + + expect(getEdgeProp(state, 'a', 'b', 'friend', 'strength')).toEqual(createInlineValue(5)); + expect(getEdgeProp(state, 'a', 'b', 'colleague', 'strength')).toEqual( + createInlineValue(8) + ); + }); + }); + + // ========================================================================= + // Same writer overwrites edge prop multiple times + // ========================================================================= + describe('same writer overwrites edge prop across multiple patches', () => { + it('latest lamport from same writer wins', () => { + const patch1 = createPatchV2({ + writer: 'W', + lamport: 1, + ops: [createEdgePropSetV2('a', 'b', 'rel', 'status', createInlineValue('draft'))], + }); + const patch2 = createPatchV2({ + writer: 'W', + lamport: 2, + ops: [createEdgePropSetV2('a', 'b', 'rel', 'status', createInlineValue('review'))], + }); + const patch3 = createPatchV2({ + writer: 'W', + lamport: 3, + ops: [createEdgePropSetV2('a', 'b', 'rel', 'status', createInlineValue('published'))], + }); + + // Apply in reverse order — LWW should still pick lamport 3 + const state = reduceV5([ + { patch: patch3, sha: 'cccc1234' }, + { patch: patch1, sha: 'aaaa1234' }, + { patch: patch2, sha: 'bbbb1234' }, + ]); + + expect(getEdgeProp(state, 'a', 'b', 'rel', 'status')).toEqual( + createInlineValue('published') + ); + }); + + it('multiple overwrites within a single patch — last op wins via opIndex', () => { + const patch = createPatchV2({ + writer: 'W', + lamport: 1, + ops: [ + createEdgePropSetV2('a', 'b', 'rel', 'val', createInlineValue(1)), + createEdgePropSetV2('a', 'b', 'rel', 'val', createInlineValue(2)), + createEdgePropSetV2('a', 'b', 'rel', 'val', createInlineValue(3)), + createEdgePropSetV2('a', 'b', 'rel', 'val', createInlineValue(4)), + createEdgePropSetV2('a', 'b', 'rel', 'val', createInlineValue(5)), + ], + }); + + const state = reduceV5([{ patch, sha: 'abcd1234' }]); + + expect(getEdgeProp(state, 'a', 'b', 'rel', 'val')).toEqual(createInlineValue(5)); + }); + }); + + // ========================================================================= + // joinStates: two independently materialized states merge edge props + // ========================================================================= + describe('joinStates merges edge props via LWW', () => { + it('merges conflicting edge props from two separate states', () => { + const stateA = createEmptyStateV5(); + const stateB = createEmptyStateV5(); + + // Apply edge prop in state A at lamport 1 + applyOpV2( + stateA, + createEdgePropSetV2('x', 'y', 'rel', 'weight', createInlineValue(10)), + createEventId(1, 'A', 'aaaa1234', 0) + ); + + // Apply edge prop in state B at lamport 2 + applyOpV2( + stateB, + createEdgePropSetV2('x', 'y', 'rel', 'weight', createInlineValue(20)), + createEventId(2, 'B', 'bbbb1234', 0) + ); + + const joined = joinStates(stateA, stateB); + + // B wins (lamport 2 > 1) + expect(getEdgeProp(joined, 'x', 'y', 'rel', 'weight')).toEqual(createInlineValue(20)); + }); + + it('joinStates is commutative for edge props', () => { + const stateA = createEmptyStateV5(); + const stateB = createEmptyStateV5(); + + applyOpV2( + stateA, + createEdgePropSetV2('p', 'q', 'link', 'tag', createInlineValue('alpha')), + createEventId(5, 'A', 'aaaa1234', 0) + ); + applyOpV2( + stateB, + createEdgePropSetV2('p', 'q', 'link', 'tag', createInlineValue('beta')), + createEventId(5, 'B', 'bbbb1234', 0) + ); + + const joinedAB = joinStates(stateA, stateB); + const joinedBA = joinStates(stateB, stateA); + + // 'B' > 'A' so B wins in both orderings + expect(getEdgeProp(joinedAB, 'p', 'q', 'link', 'tag')).toEqual( + createInlineValue('beta') + ); + expect(getEdgeProp(joinedBA, 'p', 'q', 'link', 'tag')).toEqual( + createInlineValue('beta') + ); + }); + }); + + // ========================================================================= + // Edge props coexist with edge liveness (OR-Set) correctly + // ========================================================================= + describe('edge props alongside edge add/remove', () => { + it('edge property persists in prop map even after edge is removed', () => { + // This matches the design: prop map is independent of edge liveness + // (same as node props surviving node removal). + const patchAdd = createPatchV2({ + writer: 'W', + lamport: 1, + ops: [ + createEdgeAddV2('a', 'b', 'rel', createDot('W', 1)), + createEdgePropSetV2('a', 'b', 'rel', 'weight', createInlineValue(42)), + ], + }); + const patchRemove = createPatchV2({ + writer: 'W', + lamport: 2, + ops: [ + { type: 'EdgeRemove', observedDots: new Set(['W:1']) }, + ], + }); + + const state = reduceV5([ + { patch: patchAdd, sha: 'aaaa1234' }, + { patch: patchRemove, sha: 'bbbb1234' }, + ]); + + // Edge should be removed from OR-Set + const edgeKey = encodeEdgeKey('a', 'b', 'rel'); + expect(orsetContains(state.edgeAlive, edgeKey)).toBe(false); + + // But property remains in prop map (intentional — matches node behavior) + expect(getEdgeProp(state, 'a', 'b', 'rel', 'weight')).toEqual(createInlineValue(42)); + }); + + it('setting edge prop does not create the edge in OR-Set', () => { + const patch = createPatchV2({ + writer: 'W', + lamport: 1, + ops: [ + createEdgePropSetV2('a', 'b', 'rel', 'weight', createInlineValue(99)), + ], + }); + + const state = reduceV5([{ patch, sha: 'abcd1234' }]); + + // Edge should NOT be alive (no EdgeAdd was done) + const edgeKey = encodeEdgeKey('a', 'b', 'rel'); + expect(orsetContains(state.edgeAlive, edgeKey)).toBe(false); + + // But prop is stored + expect(getEdgeProp(state, 'a', 'b', 'rel', 'weight')).toEqual(createInlineValue(99)); + }); + }); + + // ========================================================================= + // applyOpV2: direct low-level tests for edge PropSet + // ========================================================================= + describe('applyOpV2 — direct edge PropSet', () => { + it('applies edge PropSet via applyOpV2', () => { + const state = createEmptyStateV5(); + const eventId = createEventId(1, 'W', 'abcd1234', 0); + const op = createEdgePropSetV2('from', 'to', 'label', 'key', createInlineValue('val')); + + applyOpV2(state, op, eventId); + + expect(getEdgeProp(state, 'from', 'to', 'label', 'key')).toEqual( + createInlineValue('val') + ); + }); + + it('LWW correctly resolves when applying two ops via applyOpV2', () => { + const state = createEmptyStateV5(); + const op = createEdgePropSetV2('f', 't', 'l', 'k', createInlineValue('old')); + + // Apply lower EventId first + applyOpV2(state, op, createEventId(1, 'W', 'aaaa1234', 0)); + expect(getEdgeProp(state, 'f', 't', 'l', 'k')).toEqual(createInlineValue('old')); + + // Apply higher EventId — should overwrite + const op2 = createEdgePropSetV2('f', 't', 'l', 'k', createInlineValue('new')); + applyOpV2(state, op2, createEventId(2, 'W', 'bbbb1234', 0)); + expect(getEdgeProp(state, 'f', 't', 'l', 'k')).toEqual(createInlineValue('new')); + }); + + it('LWW does not overwrite when applying lower EventId second', () => { + const state = createEmptyStateV5(); + + // Apply higher EventId first + const opHigh = createEdgePropSetV2('f', 't', 'l', 'k', createInlineValue('winner')); + applyOpV2(state, opHigh, createEventId(5, 'W', 'aaaa1234', 0)); + + // Apply lower EventId second — should NOT overwrite + const opLow = createEdgePropSetV2('f', 't', 'l', 'k', createInlineValue('loser')); + applyOpV2(state, opLow, createEventId(1, 'W', 'bbbb1234', 0)); + + expect(getEdgeProp(state, 'f', 't', 'l', 'k')).toEqual(createInlineValue('winner')); + }); + }); + + // ========================================================================= + // Edge case: complex value types + // ========================================================================= + describe('edge props with various value types', () => { + it('supports object values in edge props', () => { + const patch = createPatchV2({ + writer: 'W', + lamport: 1, + ops: [ + createEdgePropSetV2( + 'a', + 'b', + 'rel', + 'metadata', + createInlineValue({ created: '2025-01-01', version: 3 }) + ), + ], + }); + + const state = reduceV5([{ patch, sha: 'abcd1234' }]); + + expect(getEdgeProp(state, 'a', 'b', 'rel', 'metadata')).toEqual( + createInlineValue({ created: '2025-01-01', version: 3 }) + ); + }); + + it('supports null values', () => { + const patch = createPatchV2({ + writer: 'W', + lamport: 1, + ops: [createEdgePropSetV2('a', 'b', 'rel', 'optional', createInlineValue(null))], + }); + + const state = reduceV5([{ patch, sha: 'abcd1234' }]); + + expect(getEdgeProp(state, 'a', 'b', 'rel', 'optional')).toEqual(createInlineValue(null)); + }); + + it('supports boolean values', () => { + const patch = createPatchV2({ + writer: 'W', + lamport: 1, + ops: [createEdgePropSetV2('a', 'b', 'rel', 'active', createInlineValue(true))], + }); + + const state = reduceV5([{ patch, sha: 'abcd1234' }]); + + expect(getEdgeProp(state, 'a', 'b', 'rel', 'active')).toEqual(createInlineValue(true)); + }); + }); +}); From e86666bd5d16bca17e0e3e4cb96a8b8d7fcdf85b Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Wed, 4 Feb 2026 06:17:38 -0800 Subject: [PATCH 05/10] feat: surface edge properties in getEdges and getEdgeProps (WT/OPS/3) getEdges() now returns a props field on each edge. New getEdgeProps() method for direct edge property lookup. 13 tests. --- test/unit/domain/WarpGraph.edgeProps.test.js | 236 ++++++++++++++++++ .../domain/WarpGraph.lazyMaterialize.test.js | 2 +- 2 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 test/unit/domain/WarpGraph.edgeProps.test.js diff --git a/test/unit/domain/WarpGraph.edgeProps.test.js b/test/unit/domain/WarpGraph.edgeProps.test.js new file mode 100644 index 0000000..6597b70 --- /dev/null +++ b/test/unit/domain/WarpGraph.edgeProps.test.js @@ -0,0 +1,236 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import WarpGraph from '../../../src/domain/WarpGraph.js'; +import { createEmptyStateV5, encodeEdgeKey, encodeEdgePropKey } from '../../../src/domain/services/JoinReducer.js'; +import { orsetAdd } from '../../../src/domain/crdt/ORSet.js'; +import { createDot } from '../../../src/domain/crdt/Dot.js'; + +function setupGraphState(graph, seedFn) { + const state = createEmptyStateV5(); + graph._cachedState = state; + graph.materialize = vi.fn().mockResolvedValue(state); + seedFn(state); +} + +function addNode(state, nodeId, counter) { + orsetAdd(state.nodeAlive, nodeId, createDot('w1', counter)); +} + +function addEdge(state, from, to, label, counter) { + const edgeKey = encodeEdgeKey(from, to, label); + orsetAdd(state.edgeAlive, edgeKey, createDot('w1', counter)); +} + +function addEdgeProp(state, from, to, label, key, value) { + const propKey = encodeEdgePropKey(from, to, label, key); + state.prop.set(propKey, { value, lamport: 1, writerId: 'w1' }); +} + +describe('WarpGraph edge properties', () => { + let mockPersistence; + let graph; + + beforeEach(async () => { + mockPersistence = { + readRef: vi.fn().mockResolvedValue(null), + listRefs: vi.fn().mockResolvedValue([]), + updateRef: vi.fn().mockResolvedValue(), + configGet: vi.fn().mockResolvedValue(null), + configSet: vi.fn().mockResolvedValue(), + }; + + graph = await WarpGraph.open({ + persistence: mockPersistence, + graphName: 'test', + writerId: 'writer-1', + }); + }); + + // ============================================================================ + // getEdges() with props + // ============================================================================ + + it('getEdges returns edge props in props field', async () => { + setupGraphState(graph, (state) => { + addNode(state, 'user:alice', 1); + addNode(state, 'user:bob', 2); + addEdge(state, 'user:alice', 'user:bob', 'follows', 3); + addEdgeProp(state, 'user:alice', 'user:bob', 'follows', 'weight', 0.8); + }); + + const edges = await graph.getEdges(); + expect(edges).toEqual([ + { from: 'user:alice', to: 'user:bob', label: 'follows', props: { weight: 0.8 } }, + ]); + }); + + it('getEdges returns empty props for edge with no properties', async () => { + setupGraphState(graph, (state) => { + addNode(state, 'user:alice', 1); + addNode(state, 'user:bob', 2); + addEdge(state, 'user:alice', 'user:bob', 'follows', 3); + }); + + const edges = await graph.getEdges(); + expect(edges).toEqual([ + { from: 'user:alice', to: 'user:bob', label: 'follows', props: {} }, + ]); + }); + + it('getEdges returns multiple props on a single edge', async () => { + setupGraphState(graph, (state) => { + addNode(state, 'user:alice', 1); + addNode(state, 'user:bob', 2); + addEdge(state, 'user:alice', 'user:bob', 'follows', 3); + addEdgeProp(state, 'user:alice', 'user:bob', 'follows', 'weight', 0.8); + addEdgeProp(state, 'user:alice', 'user:bob', 'follows', 'since', '2025-01-01'); + }); + + const edges = await graph.getEdges(); + expect(edges).toHaveLength(1); + expect(edges[0].props).toEqual({ weight: 0.8, since: '2025-01-01' }); + }); + + it('getEdges assigns props to correct edges when multiple edges exist', async () => { + setupGraphState(graph, (state) => { + addNode(state, 'user:alice', 1); + addNode(state, 'user:bob', 2); + addNode(state, 'user:carol', 3); + addEdge(state, 'user:alice', 'user:bob', 'follows', 4); + addEdge(state, 'user:alice', 'user:carol', 'manages', 5); + addEdgeProp(state, 'user:alice', 'user:bob', 'follows', 'weight', 0.9); + addEdgeProp(state, 'user:alice', 'user:carol', 'manages', 'since', '2024-06-15'); + }); + + const edges = await graph.getEdges(); + const followsEdge = edges.find((e) => e.label === 'follows'); + const managesEdge = edges.find((e) => e.label === 'manages'); + + expect(followsEdge.props).toEqual({ weight: 0.9 }); + expect(managesEdge.props).toEqual({ since: '2024-06-15' }); + }); + + // ============================================================================ + // getEdgeProps() + // ============================================================================ + + it('getEdgeProps returns correct props for an edge', async () => { + setupGraphState(graph, (state) => { + addNode(state, 'user:alice', 1); + addNode(state, 'user:bob', 2); + addEdge(state, 'user:alice', 'user:bob', 'follows', 3); + addEdgeProp(state, 'user:alice', 'user:bob', 'follows', 'weight', 0.8); + }); + + const props = await graph.getEdgeProps('user:alice', 'user:bob', 'follows'); + expect(props).toEqual({ weight: 0.8 }); + }); + + it('getEdgeProps returns empty object for edge with no props', async () => { + setupGraphState(graph, (state) => { + addNode(state, 'user:alice', 1); + addNode(state, 'user:bob', 2); + addEdge(state, 'user:alice', 'user:bob', 'follows', 3); + }); + + const props = await graph.getEdgeProps('user:alice', 'user:bob', 'follows'); + expect(props).toEqual({}); + }); + + it('getEdgeProps returns null for non-existent edge', async () => { + setupGraphState(graph, (state) => { + addNode(state, 'user:alice', 1); + addNode(state, 'user:bob', 2); + }); + + const props = await graph.getEdgeProps('user:alice', 'user:bob', 'follows'); + expect(props).toBeNull(); + }); + + it('getEdgeProps returns multiple properties', async () => { + setupGraphState(graph, (state) => { + addNode(state, 'user:alice', 1); + addNode(state, 'user:bob', 2); + addEdge(state, 'user:alice', 'user:bob', 'follows', 3); + addEdgeProp(state, 'user:alice', 'user:bob', 'follows', 'weight', 0.8); + addEdgeProp(state, 'user:alice', 'user:bob', 'follows', 'since', '2025-01-01'); + addEdgeProp(state, 'user:alice', 'user:bob', 'follows', 'mutual', true); + }); + + const props = await graph.getEdgeProps('user:alice', 'user:bob', 'follows'); + expect(props).toEqual({ weight: 0.8, since: '2025-01-01', mutual: true }); + }); + + it('getEdgeProps does not leak props from other edges', async () => { + setupGraphState(graph, (state) => { + addNode(state, 'user:alice', 1); + addNode(state, 'user:bob', 2); + addNode(state, 'user:carol', 3); + addEdge(state, 'user:alice', 'user:bob', 'follows', 4); + addEdge(state, 'user:alice', 'user:carol', 'follows', 5); + addEdgeProp(state, 'user:alice', 'user:bob', 'follows', 'weight', 0.8); + addEdgeProp(state, 'user:alice', 'user:carol', 'follows', 'weight', 0.5); + }); + + const propsAB = await graph.getEdgeProps('user:alice', 'user:bob', 'follows'); + const propsAC = await graph.getEdgeProps('user:alice', 'user:carol', 'follows'); + + expect(propsAB).toEqual({ weight: 0.8 }); + expect(propsAC).toEqual({ weight: 0.5 }); + }); + + it('getEdgeProps throws E_NO_STATE when no cached state', async () => { + try { + await graph.getEdgeProps('user:alice', 'user:bob', 'follows'); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err.code).toBe('E_NO_STATE'); + } + }); + + // ============================================================================ + // Edge props do not interfere with node props + // ============================================================================ + + it('edge props do not appear in getNodeProps results', async () => { + setupGraphState(graph, (state) => { + addNode(state, 'user:alice', 1); + addNode(state, 'user:bob', 2); + addEdge(state, 'user:alice', 'user:bob', 'follows', 3); + addEdgeProp(state, 'user:alice', 'user:bob', 'follows', 'weight', 0.8); + state.prop.set('user:alice\0name', { value: 'Alice', lamport: 1, writerId: 'w1' }); + }); + + const nodeProps = await graph.getNodeProps('user:alice'); + expect(nodeProps.get('name')).toBe('Alice'); + expect(nodeProps.has('weight')).toBe(false); + expect(nodeProps.size).toBe(1); + }); + + // ============================================================================ + // Query results with edge props via outgoing/incoming + // ============================================================================ + + it('query outgoing traversal works with edges that have props', async () => { + setupGraphState(graph, (state) => { + addNode(state, 'user:alice', 1); + addNode(state, 'user:bob', 2); + addEdge(state, 'user:alice', 'user:bob', 'follows', 3); + addEdgeProp(state, 'user:alice', 'user:bob', 'follows', 'weight', 0.8); + }); + + const result = await graph.query().match('user:alice').outgoing('follows').run(); + expect(result.nodes).toEqual([{ id: 'user:bob' }]); + }); + + it('query incoming traversal works with edges that have props', async () => { + setupGraphState(graph, (state) => { + addNode(state, 'user:alice', 1); + addNode(state, 'user:bob', 2); + addEdge(state, 'user:alice', 'user:bob', 'follows', 3); + addEdgeProp(state, 'user:alice', 'user:bob', 'follows', 'weight', 0.8); + }); + + const result = await graph.query().match('user:bob').incoming('follows').run(); + expect(result.nodes).toEqual([{ id: 'user:alice' }]); + }); +}); diff --git a/test/unit/domain/WarpGraph.lazyMaterialize.test.js b/test/unit/domain/WarpGraph.lazyMaterialize.test.js index 0902727..f03f24d 100644 --- a/test/unit/domain/WarpGraph.lazyMaterialize.test.js +++ b/test/unit/domain/WarpGraph.lazyMaterialize.test.js @@ -383,7 +383,7 @@ describe('AP/LAZY/2: auto-materialize guards on query methods', () => { const edges = await graph.getEdges(); expect(edges).toHaveLength(1); - expect(edges[0]).toEqual({ from: 'test:alice', to: 'test:bob', label: 'knows' }); + expect(edges[0]).toEqual({ from: 'test:alice', to: 'test:bob', label: 'knows', props: {} }); const props = await graph.getNodeProps('test:alice'); expect(props.get('name')).toBe('Alice'); From 4ad63b0cfec0f2c5c47c02104a870f1e2709a6c3 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Wed, 4 Feb 2026 06:19:24 -0800 Subject: [PATCH 06/10] feat: mixed-version sync safety with E_SCHEMA_UNSUPPORTED (WT/SCHEMA/2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add assertOpsCompatible() guard that rejects patches containing edge property ops when the reader only supports schema v2. v3 patches with only classic node/edge ops pass through — the schema number alone is not a rejection criterion. Fail fast, never silently drop data. --- ROADMAP.md | 32 +- index.d.ts | 14 + index.js | 2 + src/domain/errors/SchemaUnsupportedError.js | 36 ++ src/domain/errors/index.js | 1 + src/domain/services/SyncProtocol.js | 7 +- src/domain/services/WarpMessageCodec.js | 52 +++ .../unit/domain/services/SchemaCompat.test.js | 356 ++++++++++++++++++ 8 files changed, 483 insertions(+), 17 deletions(-) create mode 100644 src/domain/errors/SchemaUnsupportedError.js create mode 100644 test/unit/domain/services/SchemaCompat.test.js diff --git a/ROADMAP.md b/ROADMAP.md index 619dcf5..d6a7aeb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -224,7 +224,7 @@ LIGHTHOUSE ────────────────→ HOLOGRAM ── ## Task DAG -```text +``` Key: ■ CLOSED ◆ OPEN ○ BLOCKED AUTOPILOT (v7.1.0) ████████████████████ 100% (10/10) @@ -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) █████████████████░░░ 86% (6/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 @@ -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. @@ -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`. @@ -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. @@ -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. @@ -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:** `OPEN` - **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. @@ -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`. @@ -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.** diff --git a/index.d.ts b/index.d.ts index 34b71aa..a15fe03 100644 --- a/index.d.ts +++ b/index.d.ts @@ -764,6 +764,20 @@ export class QueryError extends Error { }); } +/** + * Error thrown when a patch contains operations unsupported by the current schema version. + * Raised during sync when a v2 reader encounters edge property ops (schema v3). + */ +export class SchemaUnsupportedError extends Error { + readonly name: 'SchemaUnsupportedError'; + readonly code: 'E_SCHEMA_UNSUPPORTED'; + readonly context: Record; + + constructor(message: string, options?: { + context?: Record; + }); +} + /** * Error class for sync transport operations. */ diff --git a/index.js b/index.js index c69c5b1..6d70fcb 100644 --- a/index.js +++ b/index.js @@ -20,6 +20,7 @@ import GlobalClockAdapter from './src/infrastructure/adapters/GlobalClockAdapter import { IndexError, QueryError, + SchemaUnsupportedError, ShardLoadError, ShardCorruptionError, ShardValidationError, @@ -74,6 +75,7 @@ export { // Error types for integrity failure handling IndexError, QueryError, + SchemaUnsupportedError, ShardLoadError, ShardCorruptionError, ShardValidationError, diff --git a/src/domain/errors/SchemaUnsupportedError.js b/src/domain/errors/SchemaUnsupportedError.js new file mode 100644 index 0000000..c80fda8 --- /dev/null +++ b/src/domain/errors/SchemaUnsupportedError.js @@ -0,0 +1,36 @@ +/** + * Error thrown when a patch contains operations unsupported by the current schema version. + * + * This error is raised during sync when a v2 reader encounters edge property ops + * (schema v3 feature) that it cannot process. Failing fast prevents silent data + * corruption — edge properties would be dropped without this guard. + * + * @class SchemaUnsupportedError + * @extends Error + * + * @property {string} name - The error name ('SchemaUnsupportedError') + * @property {string} code - Error code ('E_SCHEMA_UNSUPPORTED') + * @property {Object} context - Serializable context object for debugging + * + * @example + * throw new SchemaUnsupportedError( + * 'Upgrade to >=7.3.0 (WEIGHTED) to sync edge properties.', + * { context: { patchSchema: 3, localSchema: 2 } } + * ); + */ +export default class SchemaUnsupportedError extends Error { + /** + * Creates a new SchemaUnsupportedError. + * + * @param {string} message - Human-readable error message + * @param {Object} [options={}] - Error options + * @param {Object} [options.context={}] - Serializable context for debugging + */ + constructor(message, options = {}) { + super(message); + this.name = 'SchemaUnsupportedError'; + this.code = 'E_SCHEMA_UNSUPPORTED'; + this.context = options.context || {}; + Error.captureStackTrace?.(this, this.constructor); + } +} diff --git a/src/domain/errors/index.js b/src/domain/errors/index.js index 1141c66..80527d2 100644 --- a/src/domain/errors/index.js +++ b/src/domain/errors/index.js @@ -13,4 +13,5 @@ export { default as ShardCorruptionError } from './ShardCorruptionError.js'; export { default as ShardLoadError } from './ShardLoadError.js'; export { default as ShardValidationError } from './ShardValidationError.js'; export { default as StorageError } from './StorageError.js'; +export { default as SchemaUnsupportedError } from './SchemaUnsupportedError.js'; export { default as TraversalError } from './TraversalError.js'; diff --git a/src/domain/services/SyncProtocol.js b/src/domain/services/SyncProtocol.js index d2dca2e..8d752e2 100644 --- a/src/domain/services/SyncProtocol.js +++ b/src/domain/services/SyncProtocol.js @@ -17,7 +17,7 @@ */ import { decode } from '../../infrastructure/codecs/CborCodec.js'; -import { decodePatchMessage } from './WarpMessageCodec.js'; +import { decodePatchMessage, assertOpsCompatible, SCHEMA_V3 } from './WarpMessageCodec.js'; import { join, cloneStateV5 } from './JoinReducer.js'; import { cloneFrontier, updateFrontier } from './Frontier.js'; import { vvDeserialize } from '../crdt/VersionVector.js'; @@ -322,6 +322,11 @@ export function applySyncResponse(response, state, frontier) { for (const { sha, patch } of writerPatches) { // Normalize patch context (in case it came from network serialization) const normalizedPatch = normalizePatch(patch); + // Guard: reject patches containing ops we don't understand. + // Currently SCHEMA_V3 is the max, so this is a no-op for this + // codebase. If a future schema adds new op types, this check + // will prevent silent data loss until the reader is upgraded. + assertOpsCompatible(normalizedPatch.ops, SCHEMA_V3); // Apply patch to state join(newState, normalizedPatch, sha); applied++; diff --git a/src/domain/services/WarpMessageCodec.js b/src/domain/services/WarpMessageCodec.js index 3d1dad8..4c4d6b2 100644 --- a/src/domain/services/WarpMessageCodec.js +++ b/src/domain/services/WarpMessageCodec.js @@ -14,6 +14,7 @@ import { TrailerCodec, TrailerCodecService } from '@git-stunts/trailer-codec'; import { validateGraphName, validateWriterId } from '../utils/RefLayout.js'; +import SchemaUnsupportedError from '../errors/SchemaUnsupportedError.js'; // ----------------------------------------------------------------------------- // Constants @@ -487,6 +488,57 @@ export function decodeAnchorMessage(message) { }; } +// ----------------------------------------------------------------------------- +// Schema Compatibility Validation +// ----------------------------------------------------------------------------- + +/** + * Asserts that a set of decoded patch operations is compatible with a given + * maximum supported schema version. Throws {@link SchemaUnsupportedError} if + * any operation requires a higher schema version than `maxSchema`. + * + * Currently the only schema boundary is v2 -> v3: + * - Schema v3 introduces edge property PropSet ops (node starts with `\x01`). + * - A v2-only reader MUST reject patches containing such ops to prevent + * silent data loss. + * - A v3 patch that contains only classic node/edge ops is accepted by v2 + * readers — the schema number alone is NOT a rejection criterion. + * + * @param {Array<{type: string, node?: string}>} ops - Decoded patch operations + * @param {number} maxSchema - Maximum schema version the reader supports + * @throws {SchemaUnsupportedError} If ops require a schema version > maxSchema + * + * @example + * import { assertOpsCompatible, SCHEMA_V2 } from './WarpMessageCodec.js'; + * assertOpsCompatible(patch.ops, SCHEMA_V2); // throws if edge prop ops found + */ +export function assertOpsCompatible(ops, maxSchema) { + if (maxSchema >= SCHEMA_V3) { + return; // v3 readers understand everything up to v3 + } + // For v2 readers: scan for edge property ops (the v3 feature) + if (!Array.isArray(ops)) { + return; + } + for (const op of ops) { + if ( + op.type === 'PropSet' && + typeof op.node === 'string' && + op.node.startsWith(EDGE_PROP_PREFIX) + ) { + throw new SchemaUnsupportedError( + 'Upgrade to >=7.3.0 (WEIGHTED) to sync edge properties.', + { + context: { + requiredSchema: SCHEMA_V3, + maxSupportedSchema: maxSchema, + }, + } + ); + } + } +} + // ----------------------------------------------------------------------------- // Detection Helper // ----------------------------------------------------------------------------- diff --git a/test/unit/domain/services/SchemaCompat.test.js b/test/unit/domain/services/SchemaCompat.test.js new file mode 100644 index 0000000..757c924 --- /dev/null +++ b/test/unit/domain/services/SchemaCompat.test.js @@ -0,0 +1,356 @@ +import { describe, it, expect } from 'vitest'; +import { + assertOpsCompatible, + detectSchemaVersion, + SCHEMA_V2, + SCHEMA_V3, +} from '../../../../src/domain/services/WarpMessageCodec.js'; +import SchemaUnsupportedError from '../../../../src/domain/errors/SchemaUnsupportedError.js'; +import { EDGE_PROP_PREFIX } from '../../../../src/domain/services/JoinReducer.js'; + +// --------------------------------------------------------------------------- +// Helpers — minimal op factories +// --------------------------------------------------------------------------- + +function nodeAddOp(nodeId) { + return { type: 'NodeAdd', node: nodeId, dot: { writer: 'w1', counter: 1 } }; +} + +function edgeAddOp(from, to, label) { + return { type: 'EdgeAdd', from, to, label, dot: { writer: 'w1', counter: 1 } }; +} + +function nodePropSetOp(nodeId, key, value) { + return { type: 'PropSet', node: nodeId, key, value }; +} + +function edgePropSetOp(from, to, label, key, value) { + // Edge prop ops use the \x01 prefix namespace in the node field + return { type: 'PropSet', node: `${EDGE_PROP_PREFIX}${from}\0${to}\0${label}`, key, value }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('Schema Compatibility (WT/SCHEMA/2)', () => { + // ------------------------------------------------------------------------- + // assertOpsCompatible + // ------------------------------------------------------------------------- + + describe('assertOpsCompatible', () => { + describe('v2 reader (maxSchema = SCHEMA_V2)', () => { + it('accepts v2 patches with only node ops', () => { + const ops = [ + nodeAddOp('user:alice'), + nodePropSetOp('user:alice', 'name', 'Alice'), + ]; + + expect(() => assertOpsCompatible(ops, SCHEMA_V2)).not.toThrow(); + }); + + it('accepts v2 patches with node + edge ops (no edge props)', () => { + const ops = [ + nodeAddOp('user:alice'), + nodeAddOp('user:bob'), + edgeAddOp('user:alice', 'user:bob', 'follows'), + nodePropSetOp('user:alice', 'name', 'Alice'), + ]; + + expect(() => assertOpsCompatible(ops, SCHEMA_V2)).not.toThrow(); + }); + + it('accepts empty ops array', () => { + expect(() => assertOpsCompatible([], SCHEMA_V2)).not.toThrow(); + }); + + it('accepts non-array ops (defensive)', () => { + expect(() => assertOpsCompatible(null, SCHEMA_V2)).not.toThrow(); + expect(() => assertOpsCompatible(undefined, SCHEMA_V2)).not.toThrow(); + }); + + it('throws E_SCHEMA_UNSUPPORTED for edge property ops', () => { + const ops = [ + nodeAddOp('user:alice'), + edgePropSetOp('user:alice', 'user:bob', 'follows', 'weight', 0.8), + ]; + + expect(() => assertOpsCompatible(ops, SCHEMA_V2)).toThrow(SchemaUnsupportedError); + }); + + it('error has correct code', () => { + const ops = [edgePropSetOp('a', 'b', 'rel', 'w', 1)]; + + try { + assertOpsCompatible(ops, SCHEMA_V2); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err.code).toBe('E_SCHEMA_UNSUPPORTED'); + } + }); + + it('error message includes upgrade guidance', () => { + const ops = [edgePropSetOp('a', 'b', 'rel', 'w', 1)]; + + try { + assertOpsCompatible(ops, SCHEMA_V2); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err.message).toContain('>=7.3.0'); + expect(err.message).toContain('WEIGHTED'); + expect(err.message).toContain('edge properties'); + } + }); + + it('error context includes schema versions', () => { + const ops = [edgePropSetOp('a', 'b', 'rel', 'w', 1)]; + + try { + assertOpsCompatible(ops, SCHEMA_V2); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err.context.requiredSchema).toBe(SCHEMA_V3); + expect(err.context.maxSupportedSchema).toBe(SCHEMA_V2); + } + }); + + it('throws on first edge prop op (fail fast)', () => { + const ops = [ + nodeAddOp('user:alice'), + edgePropSetOp('a', 'b', 'rel', 'w1', 1), + edgePropSetOp('c', 'd', 'rel', 'w2', 2), + ]; + + expect(() => assertOpsCompatible(ops, SCHEMA_V2)).toThrow(SchemaUnsupportedError); + }); + + it('accepts v3 patch with ONLY node/edge ops (no edge props)', () => { + // Schema v3 patch that happens to have no edge prop ops should + // be accepted — the schema number alone is NOT a rejection criterion. + const ops = [ + nodeAddOp('user:carol'), + edgeAddOp('user:carol', 'user:dave', 'knows'), + nodePropSetOp('user:carol', 'role', 'admin'), + ]; + + // Even though detectSchemaVersion would say v2, the point is: + // assertOpsCompatible only looks at ops, not the schema header. + expect(() => assertOpsCompatible(ops, SCHEMA_V2)).not.toThrow(); + }); + + it('handles unknown op types gracefully (no crash)', () => { + const ops = [ + { type: 'FutureOp', data: 'unknown' }, + nodeAddOp('user:x'), + ]; + + // Unknown op types that don't look like edge prop PropSets pass through + expect(() => assertOpsCompatible(ops, SCHEMA_V2)).not.toThrow(); + }); + }); + + describe('v3 reader (maxSchema = SCHEMA_V3)', () => { + it('accepts v2 patches (backward compatible)', () => { + const ops = [ + nodeAddOp('user:alice'), + nodePropSetOp('user:alice', 'name', 'Alice'), + ]; + + expect(() => assertOpsCompatible(ops, SCHEMA_V3)).not.toThrow(); + }); + + it('accepts v3 patches with edge prop ops', () => { + const ops = [ + nodeAddOp('user:alice'), + edgePropSetOp('user:alice', 'user:bob', 'follows', 'weight', 0.8), + ]; + + expect(() => assertOpsCompatible(ops, SCHEMA_V3)).not.toThrow(); + }); + + it('accepts mixed node + edge prop ops', () => { + const ops = [ + nodeAddOp('user:alice'), + nodeAddOp('user:bob'), + edgeAddOp('user:alice', 'user:bob', 'follows'), + nodePropSetOp('user:alice', 'name', 'Alice'), + edgePropSetOp('user:alice', 'user:bob', 'follows', 'weight', 0.8), + edgePropSetOp('user:alice', 'user:bob', 'follows', 'since', '2025-01-01'), + ]; + + expect(() => assertOpsCompatible(ops, SCHEMA_V3)).not.toThrow(); + }); + + it('accepts empty ops array', () => { + expect(() => assertOpsCompatible([], SCHEMA_V3)).not.toThrow(); + }); + }); + + describe('v2 to v2 (same version)', () => { + it('v2 ops accepted by v2 reader', () => { + const ops = [ + nodeAddOp('n1'), + edgeAddOp('n1', 'n2', 'e'), + nodePropSetOp('n1', 'k', 'v'), + ]; + + expect(() => assertOpsCompatible(ops, SCHEMA_V2)).not.toThrow(); + }); + }); + + describe('v3 to v3 (same version)', () => { + it('v3 ops accepted by v3 reader', () => { + const ops = [ + nodeAddOp('n1'), + edgePropSetOp('n1', 'n2', 'e', 'weight', 42), + ]; + + expect(() => assertOpsCompatible(ops, SCHEMA_V3)).not.toThrow(); + }); + }); + }); + + // ------------------------------------------------------------------------- + // detectSchemaVersion consistency + // ------------------------------------------------------------------------- + + describe('detectSchemaVersion alignment', () => { + it('node-only ops detected as v2', () => { + const ops = [ + nodeAddOp('user:alice'), + nodePropSetOp('user:alice', 'name', 'Alice'), + ]; + + expect(detectSchemaVersion(ops)).toBe(SCHEMA_V2); + }); + + it('edge prop ops detected as v3', () => { + const ops = [ + nodeAddOp('user:alice'), + edgePropSetOp('user:alice', 'user:bob', 'follows', 'weight', 0.8), + ]; + + expect(detectSchemaVersion(ops)).toBe(SCHEMA_V3); + }); + + it('detectSchemaVersion v2 ops pass assertOpsCompatible(v2)', () => { + const ops = [nodeAddOp('n'), nodePropSetOp('n', 'k', 'v')]; + expect(detectSchemaVersion(ops)).toBe(SCHEMA_V2); + expect(() => assertOpsCompatible(ops, SCHEMA_V2)).not.toThrow(); + }); + + it('detectSchemaVersion v3 ops rejected by assertOpsCompatible(v2)', () => { + const ops = [edgePropSetOp('a', 'b', 'r', 'w', 1)]; + expect(detectSchemaVersion(ops)).toBe(SCHEMA_V3); + expect(() => assertOpsCompatible(ops, SCHEMA_V2)).toThrow(SchemaUnsupportedError); + }); + + it('detectSchemaVersion v3 ops accepted by assertOpsCompatible(v3)', () => { + const ops = [edgePropSetOp('a', 'b', 'r', 'w', 1)]; + expect(detectSchemaVersion(ops)).toBe(SCHEMA_V3); + expect(() => assertOpsCompatible(ops, SCHEMA_V3)).not.toThrow(); + }); + }); + + // ------------------------------------------------------------------------- + // SchemaUnsupportedError class + // ------------------------------------------------------------------------- + + describe('SchemaUnsupportedError', () => { + it('is an instance of Error', () => { + const err = new SchemaUnsupportedError('test'); + expect(err).toBeInstanceOf(Error); + }); + + it('has name SchemaUnsupportedError', () => { + const err = new SchemaUnsupportedError('test'); + expect(err.name).toBe('SchemaUnsupportedError'); + }); + + it('has code E_SCHEMA_UNSUPPORTED', () => { + const err = new SchemaUnsupportedError('test'); + expect(err.code).toBe('E_SCHEMA_UNSUPPORTED'); + }); + + it('preserves message', () => { + const err = new SchemaUnsupportedError('upgrade required'); + expect(err.message).toBe('upgrade required'); + }); + + it('preserves context', () => { + const ctx = { requiredSchema: 3, maxSupportedSchema: 2 }; + const err = new SchemaUnsupportedError('msg', { context: ctx }); + expect(err.context).toEqual(ctx); + }); + + it('defaults context to empty object', () => { + const err = new SchemaUnsupportedError('msg'); + expect(err.context).toEqual({}); + }); + + it('has a stack trace', () => { + const err = new SchemaUnsupportedError('msg'); + expect(err.stack).toBeDefined(); + expect(err.stack).toContain('SchemaUnsupportedError'); + }); + }); + + // ------------------------------------------------------------------------- + // Sync scenario narratives + // ------------------------------------------------------------------------- + + describe('sync scenario narratives', () => { + it('v2 writer -> v3 reader: succeeds (v2 patches are always valid v3 input)', () => { + // v2 writer produces only node/edge ops, never edge prop ops + const v2Patch = [ + nodeAddOp('user:alice'), + nodeAddOp('user:bob'), + edgeAddOp('user:alice', 'user:bob', 'follows'), + nodePropSetOp('user:alice', 'name', 'Alice'), + ]; + + // v3 reader accepts everything + expect(() => assertOpsCompatible(v2Patch, SCHEMA_V3)).not.toThrow(); + }); + + it('v3 writer -> v2 reader WITH edge prop ops: E_SCHEMA_UNSUPPORTED', () => { + // v3 writer uses edge properties + const v3PatchWithEdgeProps = [ + nodeAddOp('user:alice'), + nodeAddOp('user:bob'), + edgeAddOp('user:alice', 'user:bob', 'follows'), + edgePropSetOp('user:alice', 'user:bob', 'follows', 'weight', 0.9), + ]; + + // v2 reader must reject — silent drop would lose edge properties + expect(() => assertOpsCompatible(v3PatchWithEdgeProps, SCHEMA_V2)).toThrow( + SchemaUnsupportedError + ); + }); + + it('v3 writer -> v2 reader with ONLY node/edge ops: succeeds', () => { + // v3 writer that happens to not use edge properties in this patch + const v3PatchNodeOnly = [ + nodeAddOp('user:carol'), + edgeAddOp('user:carol', 'user:dave', 'manages'), + nodePropSetOp('user:carol', 'department', 'engineering'), + ]; + + // v2 reader can handle this — no unknown ops + expect(() => assertOpsCompatible(v3PatchNodeOnly, SCHEMA_V2)).not.toThrow(); + }); + + it('v2 writer -> v2 reader: succeeds', () => { + const ops = [nodeAddOp('n'), nodePropSetOp('n', 'k', 'v')]; + expect(() => assertOpsCompatible(ops, SCHEMA_V2)).not.toThrow(); + }); + + it('v3 writer -> v3 reader: succeeds', () => { + const ops = [ + nodeAddOp('n'), + edgePropSetOp('n', 'm', 'r', 'weight', 42), + ]; + expect(() => assertOpsCompatible(ops, SCHEMA_V3)).not.toThrow(); + }); + }); +}); From 7dae86744f70449c8903be582081a2c570aef9b6 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Wed, 4 Feb 2026 06:29:24 -0800 Subject: [PATCH 07/10] feat: gate edge property visibility on edge aliveness (WT/VIS/1) Track edgeBirthLamport in state to enforce clean-slate semantics on edge re-add. Props with lamport < birth lamport are filtered out at query time. Checkpoint serialization updated. 10 tests. --- ROADMAP.md | 6 +- src/domain/WarpGraph.js | 17 +- src/domain/services/CheckpointSerializerV5.js | 20 +- src/domain/services/CheckpointService.js | 10 +- src/domain/services/JoinReducer.js | 39 ++- .../WarpGraph.edgePropVisibility.test.js | 287 ++++++++++++++++++ 6 files changed, 371 insertions(+), 8 deletions(-) create mode 100644 test/unit/domain/WarpGraph.edgePropVisibility.test.js diff --git a/ROADMAP.md b/ROADMAP.md index d6a7aeb..a0e9492 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -245,14 +245,14 @@ GROUNDSKEEPER (v7.2.0) █████████████████ ■ GK/IDX/1 → GK/IDX/2 ■ GK/IDX/2 -WEIGHTED (v7.3.0) █████████████████░░░ 86% (6/7) +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 + ■ WT/VIS/1 HANDSHAKE (v7.4.0) ░░░░░░░░░░░░░░░░░░░░ 0% (0/8) ◆ HS/CAS/1 @@ -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:** `OPEN` +- **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. diff --git a/src/domain/WarpGraph.js b/src/domain/WarpGraph.js index 9f913d9..6a07d45 100644 --- a/src/domain/WarpGraph.js +++ b/src/domain/WarpGraph.js @@ -1872,7 +1872,11 @@ export default class WarpGraph { return null; } - // Collect all properties for this edge + // Determine the birth lamport for clean-slate filtering + const birthLamport = this._cachedState.edgeBirthLamport?.get(edgeKey) ?? 0; + + // Collect all properties for this edge, filtering out stale props + // (props set before the edge's most recent re-add) const props = {}; for (const [propKey, register] of this._cachedState.prop) { if (!isEdgePropKey(propKey)) { @@ -1880,6 +1884,9 @@ export default class WarpGraph { } const decoded = decodeEdgePropKey(propKey); if (decoded.from === from && decoded.to === to && decoded.label === label) { + if (register.eventId && register.eventId.lamport < birthLamport) { + continue; // stale prop from before the edge's current incarnation + } props[decoded.propKey] = register.value; } } @@ -1986,6 +1993,7 @@ export default class WarpGraph { await this._ensureFreshState(); // Pre-collect edge props into a lookup: "from\0to\0label" → {propKey: value} + // Filters out stale props whose eventId.lamport < the edge's birth lamport const edgePropsByKey = new Map(); for (const [propKey, register] of this._cachedState.prop) { if (!isEdgePropKey(propKey)) { @@ -1993,6 +2001,13 @@ export default class WarpGraph { } const decoded = decodeEdgePropKey(propKey); const ek = encodeEdgeKey(decoded.from, decoded.to, decoded.label); + + // Clean-slate filter: skip props from before the edge's current incarnation + const birthLamport = this._cachedState.edgeBirthLamport?.get(ek) ?? 0; + if (register.eventId && register.eventId.lamport < birthLamport) { + continue; + } + let bag = edgePropsByKey.get(ek); if (!bag) { bag = {}; diff --git a/src/domain/services/CheckpointSerializerV5.js b/src/domain/services/CheckpointSerializerV5.js index cbefed7..5aab778 100644 --- a/src/domain/services/CheckpointSerializerV5.js +++ b/src/domain/services/CheckpointSerializerV5.js @@ -56,11 +56,21 @@ export function serializeFullStateV5(state) { // Serialize observedFrontier const observedFrontierObj = vvSerialize(state.observedFrontier); + // Serialize edgeBirthLamport as sorted array of [edgeKey, lamport] pairs + const edgeBirthArray = []; + if (state.edgeBirthLamport) { + for (const [key, lamport] of state.edgeBirthLamport) { + edgeBirthArray.push([key, lamport]); + } + edgeBirthArray.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); + } + const obj = { nodeAlive: nodeAliveObj, edgeAlive: edgeAliveObj, prop: propArray, observedFrontier: observedFrontierObj, + edgeBirthLamport: edgeBirthArray, }; return encode(obj); @@ -90,7 +100,15 @@ export function deserializeFullStateV5(buffer) { // Deserialize observedFrontier const observedFrontier = vvDeserialize(obj.observedFrontier || {}); - return { nodeAlive, edgeAlive, prop, observedFrontier }; + // Deserialize edgeBirthLamport + const edgeBirthLamport = new Map(); + if (obj.edgeBirthLamport && Array.isArray(obj.edgeBirthLamport)) { + for (const [key, lamport] of obj.edgeBirthLamport) { + edgeBirthLamport.set(key, lamport); + } + } + + return { nodeAlive, edgeAlive, prop, observedFrontier, edgeBirthLamport }; } // ============================================================================ diff --git a/src/domain/services/CheckpointService.js b/src/domain/services/CheckpointService.js index 3b985bb..838faf9 100644 --- a/src/domain/services/CheckpointService.js +++ b/src/domain/services/CheckpointService.js @@ -332,5 +332,13 @@ export function reconstructStateV5FromCheckpoint(visibleProjection) { }); } - return { nodeAlive, edgeAlive, prop, observedFrontier }; + // Reconstruct edgeBirthLamport: synthetic birth at lamport 0 + // so checkpoint-loaded props pass the visibility filter + const edgeBirthLamport = new Map(); + for (const edge of edges) { + const edgeKey = encodeEdgeKey(edge.from, edge.to, edge.label); + edgeBirthLamport.set(edgeKey, 0); + } + + return { nodeAlive, edgeAlive, prop, observedFrontier, edgeBirthLamport }; } diff --git a/src/domain/services/JoinReducer.js b/src/domain/services/JoinReducer.js index b211339..e0a08e6 100644 --- a/src/domain/services/JoinReducer.js +++ b/src/domain/services/JoinReducer.js @@ -105,6 +105,7 @@ export function isEdgePropKey(key) { * @property {import('../crdt/ORSet.js').ORSet} edgeAlive - ORSet of alive edges * @property {Map} prop - Properties with LWW * @property {import('../crdt/VersionVector.js').VersionVector} observedFrontier - Observed version vector + * @property {Map} edgeBirthLamport - EdgeKey → lamport of most recent EdgeAdd (for clean-slate prop visibility) */ /** @@ -117,6 +118,7 @@ export function createEmptyStateV5() { edgeAlive: createORSet(), prop: new Map(), observedFrontier: createVersionVector(), + edgeBirthLamport: new Map(), }; } @@ -136,9 +138,20 @@ export function applyOpV2(state, op, eventId) { case 'NodeRemove': orsetRemove(state.nodeAlive, op.observedDots); break; - case 'EdgeAdd': - orsetAdd(state.edgeAlive, encodeEdgeKey(op.from, op.to, op.label), op.dot); + case 'EdgeAdd': { + const edgeKey = encodeEdgeKey(op.from, op.to, op.label); + orsetAdd(state.edgeAlive, edgeKey, op.dot); + // Track the lamport at which this edge incarnation was born. + // On re-add after remove, the higher lamport replaces the old one, + // allowing the query layer to filter out stale properties. + if (state.edgeBirthLamport) { + const prevBirth = state.edgeBirthLamport.get(edgeKey); + if (prevBirth === undefined || eventId.lamport > prevBirth) { + state.edgeBirthLamport.set(edgeKey, eventId.lamport); + } + } break; + } case 'EdgeRemove': orsetRemove(state.edgeAlive, op.observedDots); break; @@ -191,6 +204,7 @@ export function joinStates(a, b) { edgeAlive: orsetJoin(a.edgeAlive, b.edgeAlive), prop: mergeProps(a.prop, b.prop), observedFrontier: vvMerge(a.observedFrontier, b.observedFrontier), + edgeBirthLamport: mergeEdgeBirthLamport(a.edgeBirthLamport, b.edgeBirthLamport), }; } @@ -212,6 +226,26 @@ function mergeProps(a, b) { return result; } +/** + * Merges two edgeBirthLamport maps by taking the max lamport per key. + * + * @param {Map} a + * @param {Map} b + * @returns {Map} + */ +function mergeEdgeBirthLamport(a, b) { + const result = new Map(a || []); + if (b) { + for (const [key, lamport] of b) { + const existing = result.get(key); + if (existing === undefined || lamport > existing) { + result.set(key, lamport); + } + } + } + return result; +} + /** * Reduces patches to a V5 state. * @@ -239,5 +273,6 @@ export function cloneStateV5(state) { edgeAlive: orsetJoin(state.edgeAlive, createORSet()), prop: new Map(state.prop), observedFrontier: vvClone(state.observedFrontier), + edgeBirthLamport: new Map(state.edgeBirthLamport || []), }; } diff --git a/test/unit/domain/WarpGraph.edgePropVisibility.test.js b/test/unit/domain/WarpGraph.edgePropVisibility.test.js new file mode 100644 index 0000000..696ec63 --- /dev/null +++ b/test/unit/domain/WarpGraph.edgePropVisibility.test.js @@ -0,0 +1,287 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import WarpGraph from '../../../src/domain/WarpGraph.js'; +import { + createEmptyStateV5, + encodeEdgeKey, + encodeEdgePropKey, +} from '../../../src/domain/services/JoinReducer.js'; +import { orsetAdd, orsetRemove } from '../../../src/domain/crdt/ORSet.js'; +import { createDot, encodeDot } from '../../../src/domain/crdt/Dot.js'; + +/** + * Seeds a WarpGraph instance with a fresh empty V5 state and runs seedFn to populate it. + * Replaces materialize with a no-op mock so tests exercise query methods directly. + */ +function setupGraphState(graph, seedFn) { + const state = createEmptyStateV5(); + graph._cachedState = state; + graph.materialize = vi.fn().mockResolvedValue(state); + seedFn(state); +} + +/** Adds a node to the ORSet with a dot at the given counter. */ +function addNode(state, nodeId, writerId, counter) { + orsetAdd(state.nodeAlive, nodeId, createDot(writerId, counter)); +} + +/** Adds an edge to the ORSet and records its birth lamport. */ +function addEdge(state, from, to, label, writerId, counter, lamport) { + const edgeKey = encodeEdgeKey(from, to, label); + orsetAdd(state.edgeAlive, edgeKey, createDot(writerId, counter)); + // Record birth lamport (same as applyOpV2 does for EdgeAdd) + const prev = state.edgeBirthLamport.get(edgeKey); + if (prev === undefined || lamport > prev) { + state.edgeBirthLamport.set(edgeKey, lamport); + } +} + +/** Removes an edge by tombstoning its observed dots. */ +function removeEdge(state, from, to, label, writerId, counter) { + const dot = encodeDot(createDot(writerId, counter)); + orsetRemove(state.edgeAlive, new Set([dot])); +} + +/** Sets an edge property with a proper LWW register (eventId + value). */ +function setEdgeProp(state, from, to, label, key, value, lamport, writerId, patchSha, opIndex) { + const propKey = encodeEdgePropKey(from, to, label, key); + state.prop.set(propKey, { + eventId: { lamport, writerId, patchSha: patchSha || 'aabbccdd', opIndex: opIndex || 0 }, + value, + }); +} + +// ============================================================================= + +describe('WarpGraph edge property visibility (WT/VIS/1)', () => { + let mockPersistence; + let graph; + + beforeEach(async () => { + mockPersistence = { + readRef: vi.fn().mockResolvedValue(null), + listRefs: vi.fn().mockResolvedValue([]), + updateRef: vi.fn().mockResolvedValue(), + configGet: vi.fn().mockResolvedValue(null), + configSet: vi.fn().mockResolvedValue(), + }; + + graph = await WarpGraph.open({ + persistence: mockPersistence, + graphName: 'test', + writerId: 'writer-1', + }); + }); + + // =========================================================================== + // Dead-edge visibility gating + // =========================================================================== + + it('add edge with props -> remove edge -> props invisible via getEdges()', async () => { + setupGraphState(graph, (state) => { + addNode(state, 'a', 'w1', 1); + addNode(state, 'b', 'w1', 2); + // Edge added at lamport 1, counter 3 + addEdge(state, 'a', 'b', 'rel', 'w1', 3, 1); + // Prop set at lamport 1 + setEdgeProp(state, 'a', 'b', 'rel', 'weight', 42, 1, 'w1'); + // Remove the edge (tombstone the dot) + removeEdge(state, 'a', 'b', 'rel', 'w1', 3); + }); + + const edges = await graph.getEdges(); + // Edge is dead, so it should not appear at all + expect(edges).toEqual([]); + }); + + it('add edge with props -> remove edge -> getEdgeProps returns null', async () => { + setupGraphState(graph, (state) => { + addNode(state, 'a', 'w1', 1); + addNode(state, 'b', 'w1', 2); + addEdge(state, 'a', 'b', 'rel', 'w1', 3, 1); + setEdgeProp(state, 'a', 'b', 'rel', 'weight', 42, 1, 'w1'); + removeEdge(state, 'a', 'b', 'rel', 'w1', 3); + }); + + const props = await graph.getEdgeProps('a', 'b', 'rel'); + expect(props).toBeNull(); + }); + + // =========================================================================== + // Clean-slate on re-add + // =========================================================================== + + it('add edge with props -> remove edge -> re-add edge -> props are empty (clean slate)', async () => { + setupGraphState(graph, (state) => { + addNode(state, 'a', 'w1', 1); + addNode(state, 'b', 'w1', 2); + // First incarnation: add at lamport 1 + addEdge(state, 'a', 'b', 'rel', 'w1', 3, 1); + // Set prop during first incarnation (lamport 1) + setEdgeProp(state, 'a', 'b', 'rel', 'weight', 42, 1, 'w1'); + // Remove edge (tombstone dot w1:3) + removeEdge(state, 'a', 'b', 'rel', 'w1', 3); + // Re-add edge at lamport 3 (later), new dot w1:4 + addEdge(state, 'a', 'b', 'rel', 'w1', 4, 3); + }); + + // getEdgeProps should return empty object (clean slate — old prop is stale) + const props = await graph.getEdgeProps('a', 'b', 'rel'); + expect(props).toEqual({}); + + // getEdges should also return empty props + const edges = await graph.getEdges(); + expect(edges).toHaveLength(1); + expect(edges[0].props).toEqual({}); + }); + + it('add edge with props -> remove -> re-add -> set new props -> new props visible', async () => { + setupGraphState(graph, (state) => { + addNode(state, 'a', 'w1', 1); + addNode(state, 'b', 'w1', 2); + // First incarnation at lamport 1 + addEdge(state, 'a', 'b', 'rel', 'w1', 3, 1); + setEdgeProp(state, 'a', 'b', 'rel', 'weight', 42, 1, 'w1'); + // Remove + removeEdge(state, 'a', 'b', 'rel', 'w1', 3); + // Re-add at lamport 3 + addEdge(state, 'a', 'b', 'rel', 'w1', 4, 3); + // Set NEW prop at lamport 3 (during new incarnation) + setEdgeProp(state, 'a', 'b', 'rel', 'color', 'red', 3, 'w1'); + }); + + const props = await graph.getEdgeProps('a', 'b', 'rel'); + // Old prop "weight" is filtered out (lamport 1 < birthLamport 3) + // New prop "color" is visible (lamport 3 >= birthLamport 3) + expect(props).toEqual({ color: 'red' }); + + const edges = await graph.getEdges(); + expect(edges).toHaveLength(1); + expect(edges[0].props).toEqual({ color: 'red' }); + }); + + // =========================================================================== + // Concurrent two-writer scenarios + // =========================================================================== + + it('concurrent add and remove with props (two writers)', async () => { + setupGraphState(graph, (state) => { + addNode(state, 'a', 'w1', 1); + addNode(state, 'b', 'w1', 2); + // Writer 1 adds edge at lamport 1 + addEdge(state, 'a', 'b', 'rel', 'w1', 3, 1); + setEdgeProp(state, 'a', 'b', 'rel', 'weight', 42, 1, 'w1'); + // Writer 2 concurrently adds the same edge at lamport 1 + addEdge(state, 'a', 'b', 'rel', 'w2', 1, 1); + // Writer 1 removes (only tombstones w1:3, NOT w2:1) + removeEdge(state, 'a', 'b', 'rel', 'w1', 3); + }); + + // Edge is still alive because w2's dot is not tombstoned (OR-set add wins) + const props = await graph.getEdgeProps('a', 'b', 'rel'); + expect(props).not.toBeNull(); + // birthLamport is max(1, 1) = 1; prop has lamport 1 >= 1 → visible + expect(props).toEqual({ weight: 42 }); + }); + + it('concurrent add+props from two writers, one removes, re-adds -> clean slate for old props', async () => { + setupGraphState(graph, (state) => { + addNode(state, 'a', 'w1', 1); + addNode(state, 'b', 'w1', 2); + // Writer 1 adds edge at lamport 1 + addEdge(state, 'a', 'b', 'rel', 'w1', 3, 1); + setEdgeProp(state, 'a', 'b', 'rel', 'weight', 42, 1, 'w1'); + // Writer 1 removes (tombstone w1:3) + removeEdge(state, 'a', 'b', 'rel', 'w1', 3); + // Writer 1 re-adds at lamport 5 + addEdge(state, 'a', 'b', 'rel', 'w1', 4, 5); + // Writer 2 sets a new prop at lamport 6 (after the re-add) + setEdgeProp(state, 'a', 'b', 'rel', 'color', 'blue', 6, 'w2'); + }); + + const props = await graph.getEdgeProps('a', 'b', 'rel'); + // "weight" was set at lamport 1, birthLamport is 5 → filtered out + // "color" was set at lamport 6 >= 5 → visible + expect(props).toEqual({ color: 'blue' }); + }); + + // =========================================================================== + // Edge without props + // =========================================================================== + + it('edge without props -> remove -> re-add -> still no props', async () => { + setupGraphState(graph, (state) => { + addNode(state, 'a', 'w1', 1); + addNode(state, 'b', 'w1', 2); + // Add edge without props + addEdge(state, 'a', 'b', 'rel', 'w1', 3, 1); + // Remove + removeEdge(state, 'a', 'b', 'rel', 'w1', 3); + // Re-add + addEdge(state, 'a', 'b', 'rel', 'w1', 4, 2); + }); + + const props = await graph.getEdgeProps('a', 'b', 'rel'); + expect(props).toEqual({}); + + const edges = await graph.getEdges(); + expect(edges).toHaveLength(1); + expect(edges[0].props).toEqual({}); + }); + + // =========================================================================== + // Property data remains in prop map (not purged) + // =========================================================================== + + it('stale props remain in the prop map but are not surfaced', async () => { + setupGraphState(graph, (state) => { + addNode(state, 'a', 'w1', 1); + addNode(state, 'b', 'w1', 2); + // Add edge, set prop, remove, re-add + addEdge(state, 'a', 'b', 'rel', 'w1', 3, 1); + setEdgeProp(state, 'a', 'b', 'rel', 'weight', 42, 1, 'w1'); + removeEdge(state, 'a', 'b', 'rel', 'w1', 3); + addEdge(state, 'a', 'b', 'rel', 'w1', 4, 3); + }); + + // The prop is still in the map (not physically deleted) + const propKey = encodeEdgePropKey('a', 'b', 'rel', 'weight'); + expect(state => graph._cachedState.prop.has(propKey)).toBeTruthy(); + + // But it is not surfaced via getEdgeProps + const props = await graph.getEdgeProps('a', 'b', 'rel'); + expect(props).toEqual({}); + }); + + // =========================================================================== + // Alive edge props are still visible (regression guard) + // =========================================================================== + + it('props on a live edge with matching lamport are visible', async () => { + setupGraphState(graph, (state) => { + addNode(state, 'a', 'w1', 1); + addNode(state, 'b', 'w1', 2); + addEdge(state, 'a', 'b', 'rel', 'w1', 3, 5); + // Prop set at same lamport as edge birth + setEdgeProp(state, 'a', 'b', 'rel', 'weight', 99, 5, 'w1'); + }); + + const props = await graph.getEdgeProps('a', 'b', 'rel'); + expect(props).toEqual({ weight: 99 }); + + const edges = await graph.getEdges(); + expect(edges[0].props).toEqual({ weight: 99 }); + }); + + it('props on a live edge with higher lamport are visible', async () => { + setupGraphState(graph, (state) => { + addNode(state, 'a', 'w1', 1); + addNode(state, 'b', 'w1', 2); + addEdge(state, 'a', 'b', 'rel', 'w1', 3, 1); + // Prop set at lamport 5, greater than birth lamport 1 + setEdgeProp(state, 'a', 'b', 'rel', 'weight', 99, 5, 'w1'); + }); + + const props = await graph.getEdgeProps('a', 'b', 'rel'); + expect(props).toEqual({ weight: 99 }); + }); +}); From 45bb7abe540e6a0f64c26b73d1feb43919da924e Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Wed, 4 Feb 2026 07:22:46 -0800 Subject: [PATCH 08/10] docs: update CHANGELOG, README, GUIDE, types, and add example for WEIGHTED - CHANGELOG: add GROUNDSKEEPER and WEIGHTED sections, update test count - README: add setEdgeProperty to Quick Start and Patch Operations, add getEdgeProps to Simple Queries, update getEdges return shape - index.d.ts: add getEdgeProps method, update getEdges return type with props field, add encodeEdgePropKey/decodeEdgePropKey/isEdgePropKey exports - docs/GUIDE.md: add Edge Properties section with setting, reading, visibility, conflict resolution, and schema compatibility docs - examples/edge-properties.js: new runnable demo covering all edge property APIs including multi-writer LWW and clean-slate re-add --- CHANGELOG.md | 16 +++- README.md | 20 ++--- docs/GUIDE.md | 58 +++++++++++++- examples/edge-properties.js | 150 ++++++++++++++++++++++++++++++++++++ index.d.ts | 24 +++++- 5 files changed, 256 insertions(+), 12 deletions(-) create mode 100644 examples/edge-properties.js diff --git a/CHANGELOG.md b/CHANGELOG.md index c8777ea..450c509 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/README.md b/README.md index 3a76ca9..ac019b9 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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. diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 6b4feb5..d18fde3 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -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(); ``` @@ -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 diff --git a/examples/edge-properties.js b/examples/edge-properties.js new file mode 100644 index 0000000..ce6859c --- /dev/null +++ b/examples/edge-properties.js @@ -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); +}); diff --git a/index.d.ts b/index.d.ts index a15fe03..5e4f8bc 100644 --- a/index.d.ts +++ b/index.d.ts @@ -886,6 +886,22 @@ export function checkAborted(signal?: AbortSignal, operation?: string): void; */ export function createTimeoutSignal(ms: number): AbortSignal; +/** + * Encodes an edge property key for Map storage. + * Format: \x01from\0to\0label\0propKey + */ +export function encodeEdgePropKey(from: string, to: string, label: string, propKey: string): string; + +/** + * Decodes an edge property key string. + */ +export function decodeEdgePropKey(encoded: string): { from: string; to: string; label: string; propKey: string }; + +/** + * Returns true if the encoded key is an edge property key. + */ +export function isEdgePropKey(key: string): boolean; + /** * Multi-writer graph database using WARP CRDT protocol. * @@ -945,13 +961,19 @@ export default class WarpGraph { /** * Gets all visible edges in the materialized state. */ - getEdges(): Array<{ from: string; to: string; label: string }>; + getEdges(): Array<{ from: string; to: string; label: string; props: Record }>; /** * Gets all properties for a node from the materialized state. */ getNodeProps(nodeId: string): Map | null; + /** + * Gets all properties for an edge from the materialized state. + * Returns null if the edge does not exist or is tombstoned. + */ + getEdgeProps(from: string, to: string, label: string): Record | null; + /** * Checks if a node exists in the materialized state. */ From 73ebef6394802f4ea5fae3bd0117c6d0531523e4 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Wed, 4 Feb 2026 12:48:16 -0800 Subject: [PATCH 09/10] fix: address PR review feedback for WEIGHTED milestone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refactor edgeBirthLamport → edgeBirthEvent storing full EventId with compareEventIds for total ordering (prevents same-lamport birth leak) - PatchBuilderV2 auto-detects schema 3 from edge prop ops in build/commit - getEdgeProps checks both endpoint node liveness before returning props - Fix addEdgeProp test helper to use proper LWW register format - Fix always-truthy arrow function in edgePropVisibility expect assertion - Add MD040 language tag to ROADMAP.md fenced code block - Update PatchV2 typedef schema to {2|3} - Update CheckpointService header for schema:3 support - Add @warning JSDoc on encodeEdgePropKey about null character fields --- ROADMAP.md | 2 +- src/domain/WarpGraph.js | 17 +++++--- src/domain/services/CheckpointSerializerV5.js | 28 +++++++------ src/domain/services/CheckpointService.js | 14 +++---- src/domain/services/JoinReducer.js | 39 ++++++++++--------- src/domain/services/PatchBuilderV2.js | 8 ++-- src/domain/types/WarpTypesV2.js | 2 +- .../WarpGraph.edgePropVisibility.test.js | 12 +++--- test/unit/domain/WarpGraph.edgeProps.test.js | 3 +- 9 files changed, 71 insertions(+), 54 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a0e9492..a2902e1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -224,7 +224,7 @@ LIGHTHOUSE ────────────────→ HOLOGRAM ── ## Task DAG -``` +```text Key: ■ CLOSED ◆ OPEN ○ BLOCKED AUTOPILOT (v7.1.0) ████████████████████ 100% (10/10) diff --git a/src/domain/WarpGraph.js b/src/domain/WarpGraph.js index 6a07d45..a098c74 100644 --- a/src/domain/WarpGraph.js +++ b/src/domain/WarpGraph.js @@ -37,6 +37,7 @@ import SyncError from './errors/SyncError.js'; import QueryError from './errors/QueryError.js'; import { checkAborted } from './utils/cancellation.js'; import OperationAbortedError from './errors/OperationAbortedError.js'; +import { compareEventIds } from './utils/EventId.js'; const DEFAULT_SYNC_SERVER_MAX_BYTES = 4 * 1024 * 1024; const DEFAULT_SYNC_WITH_RETRIES = 3; @@ -1872,8 +1873,14 @@ export default class WarpGraph { return null; } - // Determine the birth lamport for clean-slate filtering - const birthLamport = this._cachedState.edgeBirthLamport?.get(edgeKey) ?? 0; + // Check node liveness for both endpoints + if (!orsetContains(this._cachedState.nodeAlive, from) || + !orsetContains(this._cachedState.nodeAlive, to)) { + return null; + } + + // Determine the birth EventId for clean-slate filtering + const birthEvent = this._cachedState.edgeBirthEvent?.get(edgeKey); // Collect all properties for this edge, filtering out stale props // (props set before the edge's most recent re-add) @@ -1884,7 +1891,7 @@ export default class WarpGraph { } const decoded = decodeEdgePropKey(propKey); if (decoded.from === from && decoded.to === to && decoded.label === label) { - if (register.eventId && register.eventId.lamport < birthLamport) { + if (birthEvent && register.eventId && compareEventIds(register.eventId, birthEvent) < 0) { continue; // stale prop from before the edge's current incarnation } props[decoded.propKey] = register.value; @@ -2003,8 +2010,8 @@ export default class WarpGraph { const ek = encodeEdgeKey(decoded.from, decoded.to, decoded.label); // Clean-slate filter: skip props from before the edge's current incarnation - const birthLamport = this._cachedState.edgeBirthLamport?.get(ek) ?? 0; - if (register.eventId && register.eventId.lamport < birthLamport) { + const birthEvent = this._cachedState.edgeBirthEvent?.get(ek); + if (birthEvent && register.eventId && compareEventIds(register.eventId, birthEvent) < 0) { continue; } diff --git a/src/domain/services/CheckpointSerializerV5.js b/src/domain/services/CheckpointSerializerV5.js index 5aab778..9c5e4ba 100644 --- a/src/domain/services/CheckpointSerializerV5.js +++ b/src/domain/services/CheckpointSerializerV5.js @@ -56,11 +56,11 @@ export function serializeFullStateV5(state) { // Serialize observedFrontier const observedFrontierObj = vvSerialize(state.observedFrontier); - // Serialize edgeBirthLamport as sorted array of [edgeKey, lamport] pairs + // Serialize edgeBirthEvent as sorted array of [edgeKey, eventId] pairs const edgeBirthArray = []; - if (state.edgeBirthLamport) { - for (const [key, lamport] of state.edgeBirthLamport) { - edgeBirthArray.push([key, lamport]); + if (state.edgeBirthEvent) { + for (const [key, eventId] of state.edgeBirthEvent) { + edgeBirthArray.push([key, { lamport: eventId.lamport, writerId: eventId.writerId, patchSha: eventId.patchSha, opIndex: eventId.opIndex }]); } edgeBirthArray.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); } @@ -70,7 +70,7 @@ export function serializeFullStateV5(state) { edgeAlive: edgeAliveObj, prop: propArray, observedFrontier: observedFrontierObj, - edgeBirthLamport: edgeBirthArray, + edgeBirthEvent: edgeBirthArray, }; return encode(obj); @@ -100,15 +100,21 @@ export function deserializeFullStateV5(buffer) { // Deserialize observedFrontier const observedFrontier = vvDeserialize(obj.observedFrontier || {}); - // Deserialize edgeBirthLamport - const edgeBirthLamport = new Map(); - if (obj.edgeBirthLamport && Array.isArray(obj.edgeBirthLamport)) { - for (const [key, lamport] of obj.edgeBirthLamport) { - edgeBirthLamport.set(key, lamport); + // Deserialize edgeBirthEvent (supports both old edgeBirthLamport and new edgeBirthEvent format) + const edgeBirthEvent = new Map(); + const birthData = obj.edgeBirthEvent || obj.edgeBirthLamport; + if (birthData && Array.isArray(birthData)) { + for (const [key, val] of birthData) { + if (typeof val === 'number') { + // Legacy format: bare lamport number → synthesize minimal EventId + edgeBirthEvent.set(key, { lamport: val, writerId: '', patchSha: '0000', opIndex: 0 }); + } else { + edgeBirthEvent.set(key, val); + } } } - return { nodeAlive, edgeAlive, prop, observedFrontier, edgeBirthLamport }; + return { nodeAlive, edgeAlive, prop, observedFrontier, edgeBirthEvent }; } // ============================================================================ diff --git a/src/domain/services/CheckpointService.js b/src/domain/services/CheckpointService.js index 838faf9..34d6e12 100644 --- a/src/domain/services/CheckpointService.js +++ b/src/domain/services/CheckpointService.js @@ -1,10 +1,10 @@ /** * Checkpoint Service for WARP multi-writer graph database. * - * Provides functionality for creating and loading schema:2 checkpoints, - * as well as incremental state materialization from checkpoints. + * Provides functionality for creating and loading schema:2 and schema:3 + * checkpoints, as well as incremental state materialization from checkpoints. * - * This service only supports schema:2 (V5) checkpoints. Schema:1 (V4) + * This service supports schema:2 and schema:3 (V5) checkpoints. Schema:1 (V4) * checkpoints must be migrated before use. * * @module CheckpointService @@ -332,13 +332,13 @@ export function reconstructStateV5FromCheckpoint(visibleProjection) { }); } - // Reconstruct edgeBirthLamport: synthetic birth at lamport 0 + // Reconstruct edgeBirthEvent: synthetic birth at lamport 0 // so checkpoint-loaded props pass the visibility filter - const edgeBirthLamport = new Map(); + const edgeBirthEvent = new Map(); for (const edge of edges) { const edgeKey = encodeEdgeKey(edge.from, edge.to, edge.label); - edgeBirthLamport.set(edgeKey, 0); + edgeBirthEvent.set(edgeKey, { lamport: 0, writerId: '', patchSha: '0000', opIndex: 0 }); } - return { nodeAlive, edgeAlive, prop, observedFrontier, edgeBirthLamport }; + return { nodeAlive, edgeAlive, prop, observedFrontier, edgeBirthEvent }; } diff --git a/src/domain/services/JoinReducer.js b/src/domain/services/JoinReducer.js index e0a08e6..5807eb4 100644 --- a/src/domain/services/JoinReducer.js +++ b/src/domain/services/JoinReducer.js @@ -12,7 +12,7 @@ import { createORSet, orsetAdd, orsetRemove, orsetJoin } from '../crdt/ORSet.js'; import { createVersionVector, vvMerge, vvClone, vvDeserialize } from '../crdt/VersionVector.js'; import { lwwSet, lwwMax } from '../crdt/LWW.js'; -import { createEventId } from '../utils/EventId.js'; +import { createEventId, compareEventIds } from '../utils/EventId.js'; /** * Encodes an EdgeKey to a string for Map storage. @@ -74,6 +74,7 @@ export const EDGE_PROP_PREFIX = '\x01'; * @param {string} to - Target node ID * @param {string} label - Edge label * @param {string} propKey - Property name + * @warning Fields must not contain the null character (\0) as it is used as the internal separator. * @returns {string} */ export function encodeEdgePropKey(from, to, label, propKey) { @@ -105,7 +106,7 @@ export function isEdgePropKey(key) { * @property {import('../crdt/ORSet.js').ORSet} edgeAlive - ORSet of alive edges * @property {Map} prop - Properties with LWW * @property {import('../crdt/VersionVector.js').VersionVector} observedFrontier - Observed version vector - * @property {Map} edgeBirthLamport - EdgeKey → lamport of most recent EdgeAdd (for clean-slate prop visibility) + * @property {Map} edgeBirthEvent - EdgeKey → EventId of most recent EdgeAdd (for clean-slate prop visibility) */ /** @@ -118,7 +119,7 @@ export function createEmptyStateV5() { edgeAlive: createORSet(), prop: new Map(), observedFrontier: createVersionVector(), - edgeBirthLamport: new Map(), + edgeBirthEvent: new Map(), }; } @@ -141,13 +142,13 @@ export function applyOpV2(state, op, eventId) { case 'EdgeAdd': { const edgeKey = encodeEdgeKey(op.from, op.to, op.label); orsetAdd(state.edgeAlive, edgeKey, op.dot); - // Track the lamport at which this edge incarnation was born. - // On re-add after remove, the higher lamport replaces the old one, + // Track the EventId at which this edge incarnation was born. + // On re-add after remove, the greater EventId replaces the old one, // allowing the query layer to filter out stale properties. - if (state.edgeBirthLamport) { - const prevBirth = state.edgeBirthLamport.get(edgeKey); - if (prevBirth === undefined || eventId.lamport > prevBirth) { - state.edgeBirthLamport.set(edgeKey, eventId.lamport); + if (state.edgeBirthEvent) { + const prev = state.edgeBirthEvent.get(edgeKey); + if (!prev || compareEventIds(eventId, prev) > 0) { + state.edgeBirthEvent.set(edgeKey, eventId); } } break; @@ -204,7 +205,7 @@ export function joinStates(a, b) { edgeAlive: orsetJoin(a.edgeAlive, b.edgeAlive), prop: mergeProps(a.prop, b.prop), observedFrontier: vvMerge(a.observedFrontier, b.observedFrontier), - edgeBirthLamport: mergeEdgeBirthLamport(a.edgeBirthLamport, b.edgeBirthLamport), + edgeBirthEvent: mergeEdgeBirthEvent(a.edgeBirthEvent, b.edgeBirthEvent), }; } @@ -227,19 +228,19 @@ function mergeProps(a, b) { } /** - * Merges two edgeBirthLamport maps by taking the max lamport per key. + * Merges two edgeBirthEvent maps by taking the greater EventId per key. * - * @param {Map} a - * @param {Map} b - * @returns {Map} + * @param {Map} a + * @param {Map} b + * @returns {Map} */ -function mergeEdgeBirthLamport(a, b) { +function mergeEdgeBirthEvent(a, b) { const result = new Map(a || []); if (b) { - for (const [key, lamport] of b) { + for (const [key, eventId] of b) { const existing = result.get(key); - if (existing === undefined || lamport > existing) { - result.set(key, lamport); + if (!existing || compareEventIds(eventId, existing) > 0) { + result.set(key, eventId); } } } @@ -273,6 +274,6 @@ export function cloneStateV5(state) { edgeAlive: orsetJoin(state.edgeAlive, createORSet()), prop: new Map(state.prop), observedFrontier: vvClone(state.observedFrontier), - edgeBirthLamport: new Map(state.edgeBirthLamport || []), + edgeBirthEvent: new Map(state.edgeBirthEvent || []), }; } diff --git a/src/domain/services/PatchBuilderV2.js b/src/domain/services/PatchBuilderV2.js index 6fd97ed..cbe27be 100644 --- a/src/domain/services/PatchBuilderV2.js +++ b/src/domain/services/PatchBuilderV2.js @@ -199,8 +199,9 @@ export class PatchBuilderV2 { * @returns {import('../types/WarpTypesV2.js').PatchV2} The constructed patch */ build() { + const schema = this._ops.some(op => op.type === 'PropSet' && op.node.charCodeAt(0) === 1) ? 3 : 2; return createPatchV2({ - schema: 2, + schema, writer: this._writerId, lamport: this._lamport, context: this._vv, @@ -259,8 +260,9 @@ export class PatchBuilderV2 { // Note: Dots were assigned using constructor lamport, but commit lamport may differ. // For now, we use the calculated lamport for the patch metadata. // The dots themselves are independent of patch lamport (they use VV counters). + const schema = this._ops.some(op => op.type === 'PropSet' && op.node.charCodeAt(0) === 1) ? 3 : 2; const patch = { - schema: 2, + schema, writer: this._writerId, lamport, context: vvSerialize(this._vv), @@ -284,7 +286,7 @@ export class PatchBuilderV2 { writer: this._writerId, lamport, patchOid: patchBlobOid, - schema: 2, + schema, }); // 8. Create commit with tree, linking to previous patch as parent if exists diff --git a/src/domain/types/WarpTypesV2.js b/src/domain/types/WarpTypesV2.js index 9c68300..54d27df 100644 --- a/src/domain/types/WarpTypesV2.js +++ b/src/domain/types/WarpTypesV2.js @@ -97,7 +97,7 @@ /** * PatchV2 - A batch of ordered operations from a single writer * @typedef {Object} PatchV2 - * @property {2} schema - Schema version, must be 2 for v2 + * @property {2|3} schema - Schema version (2 for node-only, 3 for edge properties) * @property {string} writer - Writer ID (identifies the source of the patch) * @property {number} lamport - Lamport timestamp for ordering * @property {VersionVector} context - Writer's observed frontier (NOT global stability) diff --git a/test/unit/domain/WarpGraph.edgePropVisibility.test.js b/test/unit/domain/WarpGraph.edgePropVisibility.test.js index 696ec63..12ee363 100644 --- a/test/unit/domain/WarpGraph.edgePropVisibility.test.js +++ b/test/unit/domain/WarpGraph.edgePropVisibility.test.js @@ -24,14 +24,14 @@ function addNode(state, nodeId, writerId, counter) { orsetAdd(state.nodeAlive, nodeId, createDot(writerId, counter)); } -/** Adds an edge to the ORSet and records its birth lamport. */ +/** Adds an edge to the ORSet and records its birth event. */ function addEdge(state, from, to, label, writerId, counter, lamport) { const edgeKey = encodeEdgeKey(from, to, label); orsetAdd(state.edgeAlive, edgeKey, createDot(writerId, counter)); - // Record birth lamport (same as applyOpV2 does for EdgeAdd) - const prev = state.edgeBirthLamport.get(edgeKey); - if (prev === undefined || lamport > prev) { - state.edgeBirthLamport.set(edgeKey, lamport); + // Record birth event (same as applyOpV2 does for EdgeAdd) + const prev = state.edgeBirthEvent.get(edgeKey); + if (prev === undefined || lamport > prev.lamport) { + state.edgeBirthEvent.set(edgeKey, { lamport, writerId, patchSha: 'aabbccdd', opIndex: 0 }); } } @@ -245,7 +245,7 @@ describe('WarpGraph edge property visibility (WT/VIS/1)', () => { // The prop is still in the map (not physically deleted) const propKey = encodeEdgePropKey('a', 'b', 'rel', 'weight'); - expect(state => graph._cachedState.prop.has(propKey)).toBeTruthy(); + expect(graph._cachedState.prop.has(propKey)).toBeTruthy(); // But it is not surfaced via getEdgeProps const props = await graph.getEdgeProps('a', 'b', 'rel'); diff --git a/test/unit/domain/WarpGraph.edgeProps.test.js b/test/unit/domain/WarpGraph.edgeProps.test.js index 6597b70..e7bffbf 100644 --- a/test/unit/domain/WarpGraph.edgeProps.test.js +++ b/test/unit/domain/WarpGraph.edgeProps.test.js @@ -18,11 +18,12 @@ function addNode(state, nodeId, counter) { function addEdge(state, from, to, label, counter) { const edgeKey = encodeEdgeKey(from, to, label); orsetAdd(state.edgeAlive, edgeKey, createDot('w1', counter)); + state.edgeBirthEvent.set(edgeKey, { lamport: 1, writerId: 'w1', patchSha: 'aabbccdd', opIndex: 0 }); } function addEdgeProp(state, from, to, label, key, value) { const propKey = encodeEdgePropKey(from, to, label, key); - state.prop.set(propKey, { value, lamport: 1, writerId: 'w1' }); + state.prop.set(propKey, { eventId: { lamport: 1, writerId: 'w1', patchSha: 'aabbccdd', opIndex: 0 }, value }); } describe('WarpGraph edge properties', () => { From 1f23be2dde4fc00589105a88bfbec17d43cb15e9 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Wed, 4 Feb 2026 13:08:27 -0800 Subject: [PATCH 10/10] fix: address second round of PR review feedback - ROADMAP: mark GROUNDSKEEPER and WEIGHTED as complete - PatchBuilderV2: validate edge existence before setEdgeProperty (tracks edges added in patch via _edgesAdded Set, checks state for pre-existing) - JoinReducer: use EDGE_PROP_PREFIX constant in encodeEdgePropKey and isEdgePropKey instead of hardcoded magic values - WarpGraph: update getEdges comment to reflect full EventId ordering - edgePropVisibility test: use compareEventIds in addEdge helper, fix concurrent two-writer test expectation for full EventId ordering - edgeProps tests: add addEdge calls before setEdgeProperty per validation --- ROADMAP.md | 4 +- src/domain/WarpGraph.js | 3 +- src/domain/services/JoinReducer.js | 4 +- src/domain/services/PatchBuilderV2.js | 15 ++++++- .../WarpGraph.edgePropVisibility.test.js | 14 ++++--- .../services/PatchBuilderV2.edgeProps.test.js | 40 +++++++++++-------- 6 files changed, 52 insertions(+), 28 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a2902e1..b426035 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/src/domain/WarpGraph.js b/src/domain/WarpGraph.js index a098c74..546137b 100644 --- a/src/domain/WarpGraph.js +++ b/src/domain/WarpGraph.js @@ -2000,7 +2000,8 @@ export default class WarpGraph { await this._ensureFreshState(); // Pre-collect edge props into a lookup: "from\0to\0label" → {propKey: value} - // Filters out stale props whose eventId.lamport < the edge's birth lamport + // Filters out stale props using full EventId ordering via compareEventIds + // against the edge's birth EventId (clean-slate semantics on re-add) const edgePropsByKey = new Map(); for (const [propKey, register] of this._cachedState.prop) { if (!isEdgePropKey(propKey)) { diff --git a/src/domain/services/JoinReducer.js b/src/domain/services/JoinReducer.js index 5807eb4..addfbd1 100644 --- a/src/domain/services/JoinReducer.js +++ b/src/domain/services/JoinReducer.js @@ -78,7 +78,7 @@ export const EDGE_PROP_PREFIX = '\x01'; * @returns {string} */ export function encodeEdgePropKey(from, to, label, propKey) { - return `\x01${from}\0${to}\0${label}\0${propKey}`; + return `${EDGE_PROP_PREFIX}${from}\0${to}\0${label}\0${propKey}`; } /** @@ -97,7 +97,7 @@ export function decodeEdgePropKey(encoded) { * @returns {boolean} */ export function isEdgePropKey(key) { - return key.charCodeAt(0) === 1; + return key[0] === EDGE_PROP_PREFIX; } /** diff --git a/src/domain/services/PatchBuilderV2.js b/src/domain/services/PatchBuilderV2.js index cbe27be..6f9ac76 100644 --- a/src/domain/services/PatchBuilderV2.js +++ b/src/domain/services/PatchBuilderV2.js @@ -12,7 +12,7 @@ */ import { vvIncrement, vvClone, vvSerialize } from '../crdt/VersionVector.js'; -import { orsetGetDots } from '../crdt/ORSet.js'; +import { orsetGetDots, orsetContains } from '../crdt/ORSet.js'; import { createNodeAddV2, createNodeRemoveV2, @@ -70,6 +70,9 @@ export class PatchBuilderV2 { /** @type {import('../types/WarpTypesV2.js').OpV2[]} */ this._ops = []; + + /** @type {Set} Edge keys added in this patch (for setEdgeProperty validation) */ + this._edgesAdded = new Set(); } /** @@ -121,6 +124,7 @@ export class PatchBuilderV2 { addEdge(from, to, label) { const dot = vvIncrement(this._vv, this._writerId); this._ops.push(createEdgeAddV2(from, to, label, dot)); + this._edgesAdded.add(encodeEdgeKey(from, to, label)); return this; } @@ -183,6 +187,15 @@ export class PatchBuilderV2 { * builder.setEdgeProperty('user:alice', 'user:bob', 'follows', 'since', '2025-01-01'); */ setEdgeProperty(from, to, label, key, value) { + // Validate edge exists in this patch or in current state + const ek = encodeEdgeKey(from, to, label); + if (!this._edgesAdded.has(ek)) { + const state = this._getCurrentState(); + if (!state || !orsetContains(state.edgeAlive, ek)) { + throw new Error(`Cannot set property on unknown edge (${from} → ${to} [${label}]): add the edge first`); + } + } + // Encode the edge identity as the "node" field with the \x01 prefix. // When JoinReducer processes: encodePropKey(op.node, op.key) // = `\x01from\0to\0label` + `\0` + key diff --git a/test/unit/domain/WarpGraph.edgePropVisibility.test.js b/test/unit/domain/WarpGraph.edgePropVisibility.test.js index 12ee363..ad8ce65 100644 --- a/test/unit/domain/WarpGraph.edgePropVisibility.test.js +++ b/test/unit/domain/WarpGraph.edgePropVisibility.test.js @@ -5,6 +5,7 @@ import { encodeEdgeKey, encodeEdgePropKey, } from '../../../src/domain/services/JoinReducer.js'; +import { compareEventIds } from '../../../src/domain/utils/EventId.js'; import { orsetAdd, orsetRemove } from '../../../src/domain/crdt/ORSet.js'; import { createDot, encodeDot } from '../../../src/domain/crdt/Dot.js'; @@ -28,10 +29,11 @@ function addNode(state, nodeId, writerId, counter) { function addEdge(state, from, to, label, writerId, counter, lamport) { const edgeKey = encodeEdgeKey(from, to, label); orsetAdd(state.edgeAlive, edgeKey, createDot(writerId, counter)); - // Record birth event (same as applyOpV2 does for EdgeAdd) + // Record birth event using full EventId comparison (same as applyOpV2) + const newEvent = { lamport, writerId, patchSha: 'aabbccdd', opIndex: 0 }; const prev = state.edgeBirthEvent.get(edgeKey); - if (prev === undefined || lamport > prev.lamport) { - state.edgeBirthEvent.set(edgeKey, { lamport, writerId, patchSha: 'aabbccdd', opIndex: 0 }); + if (!prev || compareEventIds(newEvent, prev) > 0) { + state.edgeBirthEvent.set(edgeKey, newEvent); } } @@ -179,8 +181,10 @@ describe('WarpGraph edge property visibility (WT/VIS/1)', () => { // Edge is still alive because w2's dot is not tombstoned (OR-set add wins) const props = await graph.getEdgeProps('a', 'b', 'rel'); expect(props).not.toBeNull(); - // birthLamport is max(1, 1) = 1; prop has lamport 1 >= 1 → visible - expect(props).toEqual({ weight: 42 }); + // Birth EventId is w2's (w2 > w1 lexicographically at same lamport). + // Prop was set by w1 at lamport 1, which compares < w2's birth EventId, + // so the prop is correctly filtered as stale. + expect(props).toEqual({}); }); it('concurrent add+props from two writers, one removes, re-adds -> clean slate for old props', async () => { diff --git a/test/unit/domain/services/PatchBuilderV2.edgeProps.test.js b/test/unit/domain/services/PatchBuilderV2.edgeProps.test.js index ac89c46..fa27d46 100644 --- a/test/unit/domain/services/PatchBuilderV2.edgeProps.test.js +++ b/test/unit/domain/services/PatchBuilderV2.edgeProps.test.js @@ -45,9 +45,9 @@ describe('PatchBuilderV2.setEdgeProperty', () => { it('produces the canonical encodeEdgePropKey when run through encodePropKey', () => { const builder = makeBuilder(); - builder.setEdgeProperty('a', 'b', 'rel', 'weight', 42); + builder.addEdge('a', 'b', 'rel').setEdgeProperty('a', 'b', 'rel', 'weight', 42); - const op = builder.ops[0]; + const op = builder.ops[1]; const mapKey = encodePropKey(op.node, op.key); const expected = encodeEdgePropKey('a', 'b', 'rel', 'weight'); expect(mapKey).toBe(expected); @@ -62,10 +62,11 @@ describe('PatchBuilderV2.setEdgeProperty', () => { const builder = makeBuilder(); builder + .addEdge('a', 'b', 'rel') .setProperty('a', 'weight', 10) .setEdgeProperty('a', 'b', 'rel', 'weight', 99); - const [nodeOp, edgeOp] = builder.ops; + const [, nodeOp, edgeOp] = builder.ops; // Both are PropSet but with different node fields expect(nodeOp.type).toBe('PropSet'); @@ -139,50 +140,50 @@ describe('PatchBuilderV2.setEdgeProperty', () => { describe('edge-case values', () => { it('handles empty string value', () => { const builder = makeBuilder(); - builder.setEdgeProperty('a', 'b', 'rel', 'note', ''); + builder.addEdge('a', 'b', 'rel').setEdgeProperty('a', 'b', 'rel', 'note', ''); - const op = builder.ops[0]; + const op = builder.ops[1]; expect(op.value).toBe(''); }); it('handles numeric value', () => { const builder = makeBuilder(); - builder.setEdgeProperty('a', 'b', 'rel', 'weight', 3.14); + builder.addEdge('a', 'b', 'rel').setEdgeProperty('a', 'b', 'rel', 'weight', 3.14); - const op = builder.ops[0]; + const op = builder.ops[1]; expect(op.value).toBe(3.14); }); it('handles object value', () => { const builder = makeBuilder(); const obj = { nested: true, count: 7 }; - builder.setEdgeProperty('a', 'b', 'rel', 'meta', obj); + builder.addEdge('a', 'b', 'rel').setEdgeProperty('a', 'b', 'rel', 'meta', obj); - const op = builder.ops[0]; + const op = builder.ops[1]; expect(op.value).toEqual({ nested: true, count: 7 }); }); it('handles null value', () => { const builder = makeBuilder(); - builder.setEdgeProperty('a', 'b', 'rel', 'deleted', null); + builder.addEdge('a', 'b', 'rel').setEdgeProperty('a', 'b', 'rel', 'deleted', null); - const op = builder.ops[0]; + const op = builder.ops[1]; expect(op.value).toBeNull(); }); it('handles boolean value', () => { const builder = makeBuilder(); - builder.setEdgeProperty('a', 'b', 'rel', 'active', false); + builder.addEdge('a', 'b', 'rel').setEdgeProperty('a', 'b', 'rel', 'active', false); - const op = builder.ops[0]; + const op = builder.ops[1]; expect(op.value).toBe(false); }); it('handles array value', () => { const builder = makeBuilder(); - builder.setEdgeProperty('a', 'b', 'rel', 'tags', ['x', 'y']); + builder.addEdge('a', 'b', 'rel').setEdgeProperty('a', 'b', 'rel', 'tags', ['x', 'y']); - const op = builder.ops[0]; + const op = builder.ops[1]; expect(op.value).toEqual(['x', 'y']); }); }); @@ -193,6 +194,7 @@ describe('PatchBuilderV2.setEdgeProperty', () => { describe('chaining', () => { it('returns this for method chaining', () => { const builder = makeBuilder(); + builder.addEdge('a', 'b', 'rel'); const result = builder.setEdgeProperty('a', 'b', 'rel', 'k', 'v'); expect(result).toBe(builder); }); @@ -206,11 +208,15 @@ describe('PatchBuilderV2.setEdgeProperty', () => { const vv = createVersionVector(); const builder = makeBuilder({ versionVector: vv }); + builder.addEdge('a', 'b', 'rel'); + // addEdge increments VV (creates a dot), capture the value after + const vvAfterEdge = builder.versionVector.get('w1'); + builder.setEdgeProperty('a', 'b', 'rel', 'k1', 'v1'); builder.setEdgeProperty('a', 'b', 'rel', 'k2', 'v2'); - // Props don't use dots, so VV should be untouched - expect(builder.versionVector.get('w1')).toBeUndefined(); + // setEdgeProperty should NOT further increment VV + expect(builder.versionVector.get('w1')).toBe(vvAfterEdge); }); });