From afe9dc9defad5794c7944ca026f4b10926382b19 Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 3 Mar 2026 12:51:06 -0800 Subject: [PATCH 01/17] feat(api)!: normalize getNodeProps() to return Record (B100) BREAKING CHANGE: getNodeProps() now returns Record instead of Map, aligning with getEdgeProps() which already returns a plain object. Migration: replace .get('key') with .key or ['key'], .has('key') with 'key' in props, .size with Object.keys(props).length. Updated source (query.methods.js, ObserverView.js, QueryBuilder.js), type definitions (_wiredMethods.d.ts, index.d.ts, type-surface.m8.json), 15 test files, examples, README, and GUIDE. --- CHANGELOG.md | 4 +++ README.md | 4 +-- contracts/type-surface.m8.json | 4 +-- docs/GUIDE.md | 4 +-- examples/scripts/explore.js | 2 +- examples/scripts/setup.js | 6 ++-- index.d.ts | 4 +-- src/domain/services/ObserverView.js | 31 +++++++++---------- src/domain/services/QueryBuilder.js | 26 ++++++++-------- src/domain/warp/_wiredMethods.d.ts | 2 +- src/domain/warp/query.methods.js | 12 +++---- test/integration/api/edge-cases.test.js | 10 +++--- test/integration/api/fork.test.js | 2 +- test/integration/api/lifecycle.test.js | 4 +-- test/runtime/deno/lifecycle.test.ts | 2 +- test/type-check/consumer.ts | 4 +-- test/unit/domain/WarpGraph.edgeProps.test.js | 6 ++-- .../domain/WarpGraph.invalidation.test.js | 2 +- .../domain/WarpGraph.lazyMaterialize.test.js | 2 +- .../domain/WarpGraph.noCoordination.test.js | 8 ++--- test/unit/domain/WarpGraph.query.test.js | 12 +++---- .../WarpGraph.writerInvalidation.test.js | 2 +- .../unit/domain/services/ObserverView.test.js | 22 ++++++------- 23 files changed, 88 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cd5faf3..1e3bf351 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING: `PerformanceClockAdapter` and `GlobalClockAdapter` exports (B140)** — both were deprecated re-exports of `ClockAdapter`. Deleted shim files, removed from `index.js`, `index.d.ts`, and `type-surface.m8.json`. Use `ClockAdapter` directly. +### Breaking + +- **BREAKING: `getNodeProps()` returns `Record` instead of `Map` (B100)** — aligns with `getEdgeProps()` which already returns a plain object. Callers must replace `.get('key')` with `.key` or `['key']`, `.has('key')` with `'key' in props`, and `.size` with `Object.keys(props).length`. `ObserverView.getNodeProps()` follows the same change. + ### Fixed - **Test hardening (B130)** — replaced private field access (`_idToShaCache`, `_snapshotState`, `_cachedState`) with behavioral assertions in `BitmapIndexReader.test.js`, `PatchBuilderV2.snapshot.test.js`, and `WarpGraph.timing.test.js`. diff --git a/README.md b/README.md index acf89d21..e70f3eed 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,7 @@ Query methods auto-materialize by default. Just open a graph and start querying: ```javascript 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.getNodeProps('user:alice'); // { 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', props: {} }] await graph.getEdgeProps('user:alice', 'user:bob', 'manages'); // { weight: 0.9 } or null @@ -371,7 +371,7 @@ const view = await graph.observer('publicApi', { }); const users = await view.getNodes(); // only user:* nodes -const props = await view.getNodeProps('user:alice'); // Map without ssn/password +const props = await view.getNodeProps('user:alice'); // { name: 'Alice', ... } without ssn/password const result = await view.query().match('user:*').where({ role: 'admin' }).run(); // Measure information loss between two observer perspectives diff --git a/contracts/type-surface.m8.json b/contracts/type-surface.m8.json index 879de404..6dbc327d 100644 --- a/contracts/type-surface.m8.json +++ b/contracts/type-surface.m8.json @@ -61,7 +61,7 @@ "getNodeProps": { "async": true, "params": [{ "name": "nodeId", "type": "string" }], - "returns": "Promise | null>" + "returns": "Promise | null>" }, "getEdgeProps": { "async": true, @@ -394,7 +394,7 @@ "instance": { "hasNode": { "async": true, "params": [{ "name": "nodeId", "type": "string" }], "returns": "Promise" }, "getNodes": { "async": true, "params": [], "returns": "Promise" }, - "getNodeProps": { "async": true, "params": [{ "name": "nodeId", "type": "string" }], "returns": "Promise | null>" }, + "getNodeProps": { "async": true, "params": [{ "name": "nodeId", "type": "string" }], "returns": "Promise | null>" }, "getEdges": { "async": true, "params": [], "returns": "Promise }>>" }, "query": { "params": [], "returns": "QueryBuilder" } }, diff --git a/docs/GUIDE.md b/docs/GUIDE.md index f8284346..46eeee98 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -276,7 +276,7 @@ await graph.hasNode('user:alice'); // true await graph.getNodes(); // ['user:alice', 'user:bob'] // Get node properties -await graph.getNodeProps('user:alice'); // Map { 'name' => 'Alice' } +await graph.getNodeProps('user:alice'); // { name: 'Alice' } // Get all edges (with their properties) await graph.getEdges(); @@ -923,7 +923,7 @@ The returned `ObserverView` is read-only and supports the same query/traverse AP ```javascript const nodes = await view.getNodes(); -const props = await view.getNodeProps('user:alice'); // Map without 'ssn' or 'password' +const props = await view.getNodeProps('user:alice'); // { name: 'Alice', ... } without 'ssn' or 'password' const admins = await view.query().match('user:*').where({ role: 'admin' }).run(); const path = await view.traverse.shortestPath('user:alice', 'user:bob', { dir: 'out' }); ``` diff --git a/examples/scripts/explore.js b/examples/scripts/explore.js index 11f14777..78bd68c9 100755 --- a/examples/scripts/explore.js +++ b/examples/scripts/explore.js @@ -66,7 +66,7 @@ async function main() { for (const nodeId of sortedNodes) { const props = await graph.getNodeProps(nodeId); - const printable = props ? mapToObject(props) : {}; + const printable = props || {}; console.log(` - ${nodeId}`); if (Object.keys(printable).length > 0) { console.log(` props: ${JSON.stringify(printable)}`); diff --git a/examples/scripts/setup.js b/examples/scripts/setup.js index 6abdb9a2..e78ea891 100755 --- a/examples/scripts/setup.js +++ b/examples/scripts/setup.js @@ -113,9 +113,9 @@ async function main() { // Access node properties const aliceProps = await graph.getNodeProps('user:alice'); const postProps = await graph.getNodeProps('post:1'); - const aliceName = aliceProps?.get('name'); - const aliceEmail = aliceProps?.get('email'); - const postTitle = postProps?.get('title'); + const aliceName = aliceProps?.name; + const aliceEmail = aliceProps?.email; + const postTitle = postProps?.title; console.log(`\n Alice: name="${aliceName}", email="${aliceEmail}"`); console.log(` Post 1: title="${postTitle}"`); diff --git a/index.d.ts b/index.d.ts index 267aabf4..500795c5 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1223,7 +1223,7 @@ export class ObserverView { getNodes(): Promise; /** Gets filtered properties for a visible node (null if hidden or missing) */ - getNodeProps(nodeId: string): Promise | null>; + getNodeProps(nodeId: string): Promise | null>; /** Gets all visible edges (both endpoints must match the observer pattern) */ getEdges(): Promise }>>; @@ -1677,7 +1677,7 @@ export default class WarpGraph { /** * Gets all properties for a node from the materialized state. */ - getNodeProps(nodeId: string): Promise | null>; + getNodeProps(nodeId: string): Promise | null>; /** * Returns the number of property entries in the materialized state. diff --git a/src/domain/services/ObserverView.js b/src/domain/services/ObserverView.js index 751f8ae7..5f725f0c 100644 --- a/src/domain/services/ObserverView.js +++ b/src/domain/services/ObserverView.js @@ -16,23 +16,24 @@ import { decodeEdgeKey } from './KeyCodec.js'; import { matchGlob } from '../utils/matchGlob.js'; /** - * Filters a properties Map based on expose and redact lists. + * Filters a properties Record based on expose and redact lists. * * - If `redact` contains a key, it is excluded (highest priority). * - If `expose` is provided and non-empty, only keys in `expose` are included. * - If `expose` is absent/empty, all non-redacted keys are included. * - * @param {Map} propsMap - The full properties Map + * @param {Record} propsRecord - The full properties object * @param {string[]|undefined} expose - Whitelist of property keys to include * @param {string[]|undefined} redact - Blacklist of property keys to exclude - * @returns {Map} Filtered properties Map + * @returns {Record} Filtered properties object */ -function filterProps(propsMap, expose, redact) { +function filterProps(propsRecord, expose, redact) { const redactSet = redact && redact.length > 0 ? new Set(redact) : null; const exposeSet = expose && expose.length > 0 ? new Set(expose) : null; - const filtered = new Map(); - for (const [key, value] of propsMap) { + /** @type {Record} */ + const filtered = {}; + for (const [key, value] of Object.entries(propsRecord)) { // Redact takes precedence if (redactSet && redactSet.has(key)) { continue; @@ -41,7 +42,7 @@ function filterProps(propsMap, expose, redact) { if (exposeSet && !exposeSet.has(key)) { continue; } - filtered.set(key, value); + filtered[key] = value; } return filtered; } @@ -260,17 +261,17 @@ export default class ObserverView { * the observer pattern. * * @param {string} nodeId - The node ID to get properties for - * @returns {Promise|null>} Filtered properties Map, or null + * @returns {Promise|null>} Filtered properties object, or null */ async getNodeProps(nodeId) { if (!matchGlob(this._matchPattern, nodeId)) { return null; } - const propsMap = await this._graph.getNodeProps(nodeId); - if (!propsMap) { + const propsRecord = await this._graph.getNodeProps(nodeId); + if (!propsRecord) { return null; } - return filterProps(propsMap, this._expose, this._redact); + return filterProps(propsRecord, this._expose, this._redact); } // =========================================================================== @@ -291,10 +292,8 @@ export default class ObserverView { (e) => matchGlob(this._matchPattern, e.from) && matchGlob(this._matchPattern, e.to) ) .map((e) => { - const propsMap = new Map(Object.entries(e.props)); - const filtered = filterProps(propsMap, this._expose, this._redact); - const filteredObj = Object.fromEntries(filtered); - return { ...e, props: filteredObj }; + const filtered = filterProps(e.props, this._expose, this._redact); + return { ...e, props: filtered }; }); } @@ -312,7 +311,7 @@ export default class ObserverView { * Cast safety: QueryBuilder requires the following methods from the * graph-like object it wraps: * - getNodes(): Promise (line ~680 in QueryBuilder) - * - getNodeProps(nodeId): Promise (lines ~691, ~757, ~806 in QueryBuilder) + * - getNodeProps(nodeId): Promise (lines ~691, ~757, ~806 in QueryBuilder) * - _materializeGraph(): Promise<{adjacency, stateHash}> (line ~678 in QueryBuilder) * ObserverView implements all three: getNodes() at line ~254, getNodeProps() at line ~268, * _materializeGraph() at line ~214. diff --git a/src/domain/services/QueryBuilder.js b/src/domain/services/QueryBuilder.js index 10a149ca..64e2720d 100644 --- a/src/domain/services/QueryBuilder.js +++ b/src/domain/services/QueryBuilder.js @@ -255,21 +255,21 @@ function cloneValue(value) { } /** - * Builds a frozen, deterministic snapshot of node properties from a Map. + * Builds a frozen, deterministic snapshot of node properties from a Record. * * Keys are sorted lexicographically for deterministic iteration order. * Values are deep-cloned to prevent mutation of the original state. * - * @param {Map} propsMap - Map of property names to values + * @param {Record} propsRecord - Object of property names to values * @returns {Readonly>} Frozen object with sorted keys and cloned values * @private */ -function buildPropsSnapshot(propsMap) { +function buildPropsSnapshot(propsRecord) { /** @type {Record} */ const props = {}; - const keys = [...propsMap.keys()].sort(); + const keys = Object.keys(propsRecord).sort(); for (const key of keys) { - props[key] = cloneValue(propsMap.get(key)); + props[key] = cloneValue(propsRecord[key]); } return deepFreeze(props); } @@ -307,7 +307,7 @@ function buildEdgesSnapshot(edges, directionKey) { * The snapshot includes the node's ID, properties, outgoing edges, and incoming edges. * All data is deeply frozen to prevent mutation. * - * @param {{ id: string, propsMap: Map, edgesOut: Array<{label: string, neighborId: string}>, edgesIn: Array<{label: string, neighborId: string}> }} params - Node data + * @param {{ id: string, propsMap: Record, edgesOut: Array<{label: string, neighborId: string}>, edgesIn: Array<{label: string, neighborId: string}> }} params - Node data * @returns {Readonly} Frozen node snapshot * @private */ @@ -665,16 +665,16 @@ export default class QueryBuilder { const pattern = this._pattern ?? DEFAULT_PATTERN; // Per-run props memo to avoid redundant getNodeProps calls - /** @type {Map>} */ + /** @type {Map>} */ const propsMemo = new Map(); const getProps = async (/** @type {string} */ nodeId) => { const cached = propsMemo.get(nodeId); if (cached !== undefined) { return cached; } - const propsMap = (await this._graph.getNodeProps(nodeId)) || new Map(); - propsMemo.set(nodeId, propsMap); - return propsMap; + const propsRecord = (await this._graph.getNodeProps(nodeId)) || {}; + propsMemo.set(nodeId, propsRecord); + return propsRecord; }; let workingSet; @@ -768,7 +768,7 @@ export default class QueryBuilder { * * @param {string[]} workingSet - Array of matched node IDs * @param {string} stateHash - Hash of the materialized state - * @param {(nodeId: string) => Promise>} getProps - Memoized props fetcher + * @param {(nodeId: string) => Promise>} getProps - Memoized props fetcher * @returns {Promise} Object containing stateHash and requested aggregation values * @private */ @@ -798,10 +798,10 @@ export default class QueryBuilder { // Pre-fetch all props with bounded concurrency const propsList = await batchMap(workingSet, getProps); - for (const propsMap of propsList) { + for (const propsRecord of propsList) { for (const { segments, values } of propsByAgg.values()) { /** @type {unknown} */ - let value = propsMap.get(segments[0]); + let value = propsRecord[segments[0]]; for (let i = 1; i < segments.length; i++) { if (value && typeof value === 'object') { value = /** @type {Record} */ (value)[segments[i]]; diff --git a/src/domain/warp/_wiredMethods.d.ts b/src/domain/warp/_wiredMethods.d.ts index 42860fa1..c6ad7ec8 100644 --- a/src/domain/warp/_wiredMethods.d.ts +++ b/src/domain/warp/_wiredMethods.d.ts @@ -160,7 +160,7 @@ declare module '../WarpGraph.js' { export default interface WarpGraph { // ── query.methods.js ────────────────────────────────────────────────── hasNode(nodeId: string): Promise; - getNodeProps(nodeId: string): Promise | null>; + getNodeProps(nodeId: string): Promise | null>; getEdgeProps(from: string, to: string, label: string): Promise | null>; neighbors(nodeId: string, direction?: 'outgoing' | 'incoming' | 'both', edgeLabel?: string): Promise>; getStateSnapshot(): Promise; diff --git a/src/domain/warp/query.methods.js b/src/domain/warp/query.methods.js index d9b602f3..e97d46a7 100644 --- a/src/domain/warp/query.methods.js +++ b/src/domain/warp/query.methods.js @@ -37,7 +37,7 @@ export async function hasNode(nodeId) { * * @this {import('../WarpGraph.js').default} * @param {string} nodeId - The node ID to get properties for - * @returns {Promise|null>} Map of property key → value, or null if node doesn't exist + * @returns {Promise|null>} Object of property key → value, or null if node doesn't exist * @throws {import('../errors/QueryError.js').default} If no cached state exists (code: `E_NO_STATE`) */ export async function getNodeProps(nodeId) { @@ -47,7 +47,7 @@ export async function getNodeProps(nodeId) { if (this._propertyReader && this._logicalIndex?.isAlive(nodeId)) { try { const record = await this._propertyReader.getNodeProps(nodeId); - return record ? new Map(Object.entries(record)) : new Map(); + return record || {}; } catch { // Fall through to linear scan on index read failures. } @@ -60,11 +60,12 @@ export async function getNodeProps(nodeId) { return null; } - const props = new Map(); + /** @type {Record} */ + const props = {}; for (const [propKey, register] of s.prop) { const decoded = decodePropKey(propKey); if (decoded.nodeId === nodeId) { - props.set(decoded.propKey, register.value); + props[decoded.propKey] = register.value; } } @@ -351,8 +352,7 @@ export async function getContentOid(nodeId) { if (!props) { return null; } - // getNodeProps returns a Map — use .get() for property access - const oid = props.get(CONTENT_PROPERTY_KEY); + const oid = props[CONTENT_PROPERTY_KEY]; return (typeof oid === 'string') ? oid : null; } diff --git a/test/integration/api/edge-cases.test.js b/test/integration/api/edge-cases.test.js index 6ed74702..2b0c9d05 100644 --- a/test/integration/api/edge-cases.test.js +++ b/test/integration/api/edge-cases.test.js @@ -63,7 +63,7 @@ describe('API: Edge Cases', () => { expect(nodes).toContain('user:日本語'); const props = await graph.getNodeProps('user:café'); - expect(props.get('city')).toBe('Paris'); + expect(props.city).toBe('Paris'); }); it('large property values are stored and retrieved', async () => { @@ -77,7 +77,7 @@ describe('API: Edge Cases', () => { await graph.materialize(); const props = await graph.getNodeProps('big'); - expect(props.get('data')).toBe(bigValue); + expect(props.data).toBe(bigValue); }); it('numeric and boolean property values', async () => { @@ -92,8 +92,8 @@ describe('API: Edge Cases', () => { await graph.materialize(); const props = await graph.getNodeProps('n'); - expect(props.get('count')).toBe(42); - expect(props.get('pi')).toBeCloseTo(3.14); - expect(props.get('active')).toBe(true); + expect(props.count).toBe(42); + expect(props.pi).toBeCloseTo(3.14); + expect(props.active).toBe(true); }); }); diff --git a/test/integration/api/fork.test.js b/test/integration/api/fork.test.js index 50518c2e..a5dcde0c 100644 --- a/test/integration/api/fork.test.js +++ b/test/integration/api/fork.test.js @@ -84,6 +84,6 @@ describe('API: Fork', () => { expect(nodes).toContain('new-node'); const props = await forked.getNodeProps('new-node'); - expect(props.get('added-by')).toBe('fork-return'); + expect(props['added-by']).toBe('fork-return'); }); }); diff --git a/test/integration/api/lifecycle.test.js b/test/integration/api/lifecycle.test.js index c492233c..c8bf8a67 100644 --- a/test/integration/api/lifecycle.test.js +++ b/test/integration/api/lifecycle.test.js @@ -59,8 +59,8 @@ describe('API: Lifecycle', () => { await graph.materialize(); const props = await graph.getNodeProps('user:alice'); - expect(props.get('name')).toBe('Alice'); - expect(props.get('role')).toBe('engineer'); + expect(props.name).toBe('Alice'); + expect(props.role).toBe('engineer'); }); it('builds state across multiple patches', async () => { diff --git a/test/runtime/deno/lifecycle.test.ts b/test/runtime/deno/lifecycle.test.ts index fec5f41a..b28fb18d 100644 --- a/test/runtime/deno/lifecycle.test.ts +++ b/test/runtime/deno/lifecycle.test.ts @@ -46,7 +46,7 @@ Deno.test("lifecycle: node properties via Map", async () => { await graph.materialize(); const props = await graph.getNodeProps("n"); - assertEquals(props.get("k"), "v"); + assertEquals(props?.k, "v"); } finally { await repo.cleanup(); } diff --git a/test/type-check/consumer.ts b/test/type-check/consumer.ts index e2b78c54..1d3aef63 100644 --- a/test/type-check/consumer.ts +++ b/test/type-check/consumer.ts @@ -171,7 +171,7 @@ const atState: WarpStateV5 = await graph.materializeAt('abc123'); // ---- query methods ---- const nodes: string[] = await graph.getNodes(); const hasIt: boolean = await graph.hasNode('n1'); -const props: Map | null = await graph.getNodeProps('n1'); +const props: Record | null = await graph.getNodeProps('n1'); const edgeProps: Record | null = await graph.getEdgeProps('n1', 'n2', 'knows'); const neighbors: Array<{ nodeId: string; label: string; direction: 'outgoing' | 'incoming' }> = await graph.neighbors('n1'); const propCount: number = await graph.getPropertyCount(); @@ -194,7 +194,7 @@ const qb: QueryBuilder = graph.query(); const obs: ObserverView = await graph.observer('obs1', { match: '*' }); const obsNodes: string[] = await obs.getNodes(); const obsHas: boolean = await obs.hasNode('n1'); -const obsProps: Map | null = await obs.getNodeProps('n1'); +const obsProps: Record | null = await obs.getNodeProps('n1'); const obsEdges: Array<{ from: string; to: string; label: string; props: Record }> = await obs.getEdges(); const obsQb: QueryBuilder = obs.query(); const obsTraverse: LogicalTraversal = obs.traverse; diff --git a/test/unit/domain/WarpGraph.edgeProps.test.js b/test/unit/domain/WarpGraph.edgeProps.test.js index 7005142c..511662d4 100644 --- a/test/unit/domain/WarpGraph.edgeProps.test.js +++ b/test/unit/domain/WarpGraph.edgeProps.test.js @@ -210,9 +210,9 @@ describe('WarpGraph edge properties', () => { }); const nodeProps = await graph.getNodeProps('user:alice'); - expect(nodeProps.get('name')).toBe('Alice'); - expect(nodeProps.has('weight')).toBe(false); - expect(nodeProps.size).toBe(1); + expect(nodeProps.name).toBe('Alice'); + expect('weight' in nodeProps).toBe(false); + expect(Object.keys(nodeProps).length).toBe(1); }); // ============================================================================ diff --git a/test/unit/domain/WarpGraph.invalidation.test.js b/test/unit/domain/WarpGraph.invalidation.test.js index ac1d833d..daeffe99 100644 --- a/test/unit/domain/WarpGraph.invalidation.test.js +++ b/test/unit/domain/WarpGraph.invalidation.test.js @@ -111,7 +111,7 @@ describe('WarpGraph dirty flag + eager re-materialize (AP/INVAL/1 + AP/INVAL/2)' const props = await graph.getNodeProps('test:node'); expect(props).not.toBeNull(); - expect(props.get('name')).toBe('Alice'); + expect(props.name).toBe('Alice'); }); it('multiple sequential commits with _cachedState keep state fresh', async () => { diff --git a/test/unit/domain/WarpGraph.lazyMaterialize.test.js b/test/unit/domain/WarpGraph.lazyMaterialize.test.js index 63c12395..d9d9c8a0 100644 --- a/test/unit/domain/WarpGraph.lazyMaterialize.test.js +++ b/test/unit/domain/WarpGraph.lazyMaterialize.test.js @@ -378,7 +378,7 @@ describe('AP/LAZY/2: auto-materialize guards on query methods', () => { 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'); + expect(props.name).toBe('Alice'); const outgoing = await graph.neighbors('test:alice', 'outgoing'); expect(outgoing).toHaveLength(1); diff --git a/test/unit/domain/WarpGraph.noCoordination.test.js b/test/unit/domain/WarpGraph.noCoordination.test.js index 137cecb4..50cd64bb 100644 --- a/test/unit/domain/WarpGraph.noCoordination.test.js +++ b/test/unit/domain/WarpGraph.noCoordination.test.js @@ -175,7 +175,7 @@ describe('No-coordination regression suite', () => { // B's own state should reflect its own mutation const propsB = await graphB.getNodeProps('node:shared'); - expect(propsB?.get('value')).toBe('from-B'); + expect(propsB?.value).toBe('from-B'); // A fresh reader that sees both writers must also resolve to B's value const graphReader = await WarpGraph.open({ @@ -187,7 +187,7 @@ describe('No-coordination regression suite', () => { await graphReader.syncCoverage(); await graphReader.materialize(); const propsReader = await graphReader.getNodeProps('node:shared'); - expect(propsReader?.get('value')).toBe('from-B'); + expect(propsReader?.value).toBe('from-B'); } finally { await repo.cleanup(); } @@ -268,7 +268,7 @@ describe('No-coordination regression suite', () => { // B should see its own mutation const propsB = await graphB.getNodeProps('node:1'); - expect(propsB?.get('type')).toBe('campaign'); + expect(propsB?.type).toBe('campaign'); // A fresh reader materializing both chains must resolve to B's value const reader = await WarpGraph.open({ @@ -280,7 +280,7 @@ describe('No-coordination regression suite', () => { await reader.syncCoverage(); await reader.materialize(); const propsReader = await reader.getNodeProps('node:1'); - expect(propsReader?.get('type')).toBe('campaign'); + expect(propsReader?.type).toBe('campaign'); } finally { await repo.cleanup(); } diff --git a/test/unit/domain/WarpGraph.query.test.js b/test/unit/domain/WarpGraph.query.test.js index ad97f6b2..0182f241 100644 --- a/test/unit/domain/WarpGraph.query.test.js +++ b/test/unit/domain/WarpGraph.query.test.js @@ -71,14 +71,13 @@ describe('WarpGraph Query API', () => { expect(await graph.getNodeProps('user:nonexistent')).toBe(null); }); - it('returns empty map for node with no props', async () => { + it('returns empty record for node with no props', async () => { await graph.materialize(); const state = /** @type {any} */ (graph)._cachedState; orsetAdd(state.nodeAlive, 'user:alice', createDot('w1', 1)); const props = await graph.getNodeProps('user:alice'); - expect(props).toBeInstanceOf(Map); - expect(props.size).toBe(0); + expect(Object.keys(props).length).toBe(0); }); it('returns props for node with properties', async () => { @@ -97,8 +96,8 @@ describe('WarpGraph Query API', () => { state.prop.set(propKey2, { value: 30, lamport: 1, writerId: 'w1' }); const props = await graph.getNodeProps('user:alice'); - expect(props.get('name')).toBe('Alice'); - expect(props.get('age')).toBe(30); + expect(props.name).toBe('Alice'); + expect(props.age).toBe(30); }); it('falls back to linear scan when indexed property read throws', async () => { @@ -117,8 +116,7 @@ describe('WarpGraph Query API', () => { }; const props = await graph.getNodeProps('user:alice'); - expect(props).toBeInstanceOf(Map); - expect(props.get('name')).toBe('Alice'); + expect(props.name).toBe('Alice'); }); }); diff --git a/test/unit/domain/WarpGraph.writerInvalidation.test.js b/test/unit/domain/WarpGraph.writerInvalidation.test.js index e141c244..e72f0f7b 100644 --- a/test/unit/domain/WarpGraph.writerInvalidation.test.js +++ b/test/unit/domain/WarpGraph.writerInvalidation.test.js @@ -127,7 +127,7 @@ describe('WarpGraph Writer invalidation (AP/INVAL/3)', () => { const props = await graph.getNodeProps('test:node'); expect(props).not.toBeNull(); - expect(props.get('name')).toBe('Alice'); + expect(props.name).toBe('Alice'); }); // ── Multiple sequential writer commits ─────────────────────────── diff --git a/test/unit/domain/services/ObserverView.test.js b/test/unit/domain/services/ObserverView.test.js index b9f789df..f11c804e 100644 --- a/test/unit/domain/services/ObserverView.test.js +++ b/test/unit/domain/services/ObserverView.test.js @@ -217,9 +217,9 @@ describe('ObserverView', () => { }); const props = await view.getNodeProps('user:alice'); - expect(props.get('name')).toBe('Alice'); - expect(props.get('email')).toBe('alice@example.com'); - expect(props.has('ssn')).toBe(false); + expect(props.name).toBe('Alice'); + expect(props.email).toBe('alice@example.com'); + expect('ssn' in props).toBe(false); }); it('expose limits to specified properties', async () => { @@ -236,9 +236,9 @@ describe('ObserverView', () => { }); const props = await view.getNodeProps('user:alice'); - expect(props.get('name')).toBe('Alice'); - expect(props.get('email')).toBe('alice@example.com'); - expect(props.has('ssn')).toBe(false); + expect(props.name).toBe('Alice'); + expect(props.email).toBe('alice@example.com'); + expect('ssn' in props).toBe(false); }); it('redact takes precedence over expose', async () => { @@ -256,9 +256,9 @@ describe('ObserverView', () => { }); const props = await view.getNodeProps('user:alice'); - expect(props.get('name')).toBe('Alice'); - expect(props.get('email')).toBe('alice@example.com'); - expect(props.has('ssn')).toBe(false); + expect(props.name).toBe('Alice'); + expect(props.email).toBe('alice@example.com'); + expect('ssn' in props).toBe(false); }); it('returns null for non-matching node', async () => { @@ -283,8 +283,8 @@ describe('ObserverView', () => { const view = await graph.observer('openView', { match: 'user:*' }); const props = await view.getNodeProps('user:alice'); - expect(props.get('name')).toBe('Alice'); - expect(props.get('ssn')).toBe('123-45-6789'); + expect(props.name).toBe('Alice'); + expect(props.ssn).toBe('123-45-6789'); }); }); From 243713a994bfabf41c5429c733e2e77c94772fca Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 3 Mar 2026 12:52:48 -0800 Subject: [PATCH 02/17] refactor(types): unify CorePersistence/FullPersistence typedefs (B146) Replace duplicate FullPersistence typedef in WarpGraph.js with an import of CorePersistence from types/WarpPersistence.js. Both were identical four-port intersections (Commit & Blob & Tree & Ref). --- src/domain/WarpGraph.js | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/domain/WarpGraph.js b/src/domain/WarpGraph.js index 990d1a58..1f978bd1 100644 --- a/src/domain/WarpGraph.js +++ b/src/domain/WarpGraph.js @@ -30,9 +30,7 @@ import * as patchMethods from './warp/patch.methods.js'; import * as materializeMethods from './warp/materialize.methods.js'; import * as materializeAdvancedMethods from './warp/materializeAdvanced.methods.js'; -/** - * @typedef {import('../ports/CommitPort.js').default & import('../ports/BlobPort.js').default & import('../ports/TreePort.js').default & import('../ports/RefPort.js').default} FullPersistence - */ +/** @typedef {import('./types/WarpPersistence.js').CorePersistence} CorePersistence */ const DEFAULT_ADJACENCY_CACHE_SIZE = 3; @@ -50,11 +48,11 @@ const DEFAULT_ADJACENCY_CACHE_SIZE = 3; export default class WarpGraph { /** * @private - * @param {{ persistence: FullPersistence, graphName: string, writerId: string, gcPolicy?: Record, adjacencyCacheSize?: number, checkpointPolicy?: {every: number}, autoMaterialize?: boolean, onDeleteWithData?: 'reject'|'cascade'|'warn', logger?: import('../ports/LoggerPort.js').default, clock?: import('../ports/ClockPort.js').default, crypto?: import('../ports/CryptoPort.js').default, codec?: import('../ports/CodecPort.js').default, seekCache?: import('../ports/SeekCachePort.js').default, audit?: boolean }} options + * @param {{ persistence: CorePersistence, graphName: string, writerId: string, gcPolicy?: Record, adjacencyCacheSize?: number, checkpointPolicy?: {every: number}, autoMaterialize?: boolean, onDeleteWithData?: 'reject'|'cascade'|'warn', logger?: import('../ports/LoggerPort.js').default, clock?: import('../ports/ClockPort.js').default, crypto?: import('../ports/CryptoPort.js').default, codec?: import('../ports/CodecPort.js').default, seekCache?: import('../ports/SeekCachePort.js').default, audit?: boolean }} options */ constructor({ persistence, graphName, writerId, gcPolicy = {}, adjacencyCacheSize = DEFAULT_ADJACENCY_CACHE_SIZE, checkpointPolicy, autoMaterialize = true, onDeleteWithData = 'warn', logger, clock, crypto, codec, seekCache, audit = false }) { - /** @type {FullPersistence} */ - this._persistence = /** @type {FullPersistence} */ (persistence); + /** @type {CorePersistence} */ + this._persistence = /** @type {CorePersistence} */ (persistence); /** @type {string} */ this._graphName = graphName; @@ -243,7 +241,7 @@ export default class WarpGraph { /** * Opens a multi-writer graph. * - * @param {{ persistence: FullPersistence, graphName: string, writerId: string, gcPolicy?: Record, adjacencyCacheSize?: number, checkpointPolicy?: {every: number}, autoMaterialize?: boolean, onDeleteWithData?: 'reject'|'cascade'|'warn', logger?: import('../ports/LoggerPort.js').default, clock?: import('../ports/ClockPort.js').default, crypto?: import('../ports/CryptoPort.js').default, codec?: import('../ports/CodecPort.js').default, seekCache?: import('../ports/SeekCachePort.js').default, audit?: boolean }} options + * @param {{ persistence: CorePersistence, graphName: string, writerId: string, gcPolicy?: Record, adjacencyCacheSize?: number, checkpointPolicy?: {every: number}, autoMaterialize?: boolean, onDeleteWithData?: 'reject'|'cascade'|'warn', logger?: import('../ports/LoggerPort.js').default, clock?: import('../ports/ClockPort.js').default, crypto?: import('../ports/CryptoPort.js').default, codec?: import('../ports/CodecPort.js').default, seekCache?: import('../ports/SeekCachePort.js').default, audit?: boolean }} options * @returns {Promise} The opened graph instance * @throws {Error} If graphName, writerId, checkpointPolicy, or onDeleteWithData is invalid * @@ -299,7 +297,7 @@ export default class WarpGraph { // Initialize audit service if enabled if (graph._audit) { graph._auditService = new AuditReceiptService({ - persistence: /** @type {import('./types/WarpPersistence.js').CorePersistence} */ (persistence), + persistence: /** @type {CorePersistence} */ (persistence), graphName, writerId, codec: graph._codec, @@ -330,7 +328,7 @@ export default class WarpGraph { /** * Gets the persistence adapter. - * @returns {FullPersistence} The persistence adapter + * @returns {CorePersistence} The persistence adapter */ get persistence() { return this._persistence; From 4e20ea32391b338cecd9a064e84ae7f8721fdce2 Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 3 Mar 2026 12:54:33 -0800 Subject: [PATCH 03/17] =?UTF-8?q?feat(api):=20stabilize=20Observer=20API?= =?UTF-8?q?=20=E2=80=94=20subscribe()=20and=20watch()=20(B3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote subscribe() and watch() to @stability stable with @since 13.0.0 annotations. Fix onError callback type from (error: Error) to (error: unknown) in _wiredMethods.d.ts and index.d.ts to match runtime catch semantics. Correct watch() pattern type to string | string[]. --- CHANGELOG.md | 4 ++++ index.d.ts | 16 ++++++++++++---- src/domain/warp/_wiredMethods.d.ts | 4 ++-- src/domain/warp/subscribe.methods.js | 6 ++++++ 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e3bf351..fb669efd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Observer API stabilized (B3)** — `subscribe()` and `watch()` promoted to `@stability stable` with `@since 13.0.0` annotations. Fixed `onError` callback type from `(error: Error)` to `(error: unknown)` to match runtime catch semantics. `watch()` pattern param now correctly typed as `string | string[]` in `_wiredMethods.d.ts`. + ### Changed - **GraphPersistencePort narrowing (B145)** — domain services now declare focused port intersections (`CommitPort & BlobPort`, etc.) in JSDoc instead of the 23-method composite `GraphPersistencePort`. Removed `ConfigPort` from the composite (23 → 21 methods); adapters still implement `configGet`/`configSet` on their prototypes. Zero behavioral change. diff --git a/index.d.ts b/index.d.ts index 500795c5..a39ff965 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1906,19 +1906,27 @@ export default class WarpGraph { /** Returns a lightweight status snapshot of the graph. */ status(): Promise; - /** Subscribes to graph changes after each materialize(). */ + /** + * Subscribes to graph changes after each materialize(). + * @since 13.0.0 + * @stability stable + */ subscribe(options: { onChange: (diff: StateDiffResult) => void; - onError?: (error: Error) => void; + onError?: (error: unknown) => void; replay?: boolean; }): { unsubscribe: () => void }; - /** Filtered watcher that only fires for changes matching a glob pattern. */ + /** + * Filtered watcher that only fires for changes matching a glob pattern. + * @since 13.0.0 + * @stability stable + */ watch( pattern: string | string[], options: { onChange: (diff: StateDiffResult) => void; - onError?: (error: Error) => void; + onError?: (error: unknown) => void; poll?: number; }, ): { unsubscribe: () => void }; diff --git a/src/domain/warp/_wiredMethods.d.ts b/src/domain/warp/_wiredMethods.d.ts index c6ad7ec8..78f38bd3 100644 --- a/src/domain/warp/_wiredMethods.d.ts +++ b/src/domain/warp/_wiredMethods.d.ts @@ -172,8 +172,8 @@ declare module '../WarpGraph.js' { translationCost(configA: ObserverConfig, configB: ObserverConfig): Promise; // ── subscribe.methods.js ────────────────────────────────────────────── - subscribe(options: { onChange: (diff: StateDiffResult) => void; onError?: (error: Error) => void; replay?: boolean }): { unsubscribe: () => void }; - watch(pattern: string, options: { onChange: (diff: StateDiffResult) => void; onError?: (error: Error) => void; poll?: number }): { unsubscribe: () => void }; + subscribe(options: { onChange: (diff: StateDiffResult) => void; onError?: (error: unknown) => void; replay?: boolean }): { unsubscribe: () => void }; + watch(pattern: string | string[], options: { onChange: (diff: StateDiffResult) => void; onError?: (error: unknown) => void; poll?: number }): { unsubscribe: () => void }; _notifySubscribers(diff: StateDiffResult, currentState: WarpStateV5): void; // ── provenance.methods.js ───────────────────────────────────────────── diff --git a/src/domain/warp/subscribe.methods.js b/src/domain/warp/subscribe.methods.js index c72fbe3a..251b1be2 100644 --- a/src/domain/warp/subscribe.methods.js +++ b/src/domain/warp/subscribe.methods.js @@ -21,6 +21,9 @@ import { matchGlob } from '../utils/matchGlob.js'; * Errors thrown by handlers are caught and forwarded to `onError` if provided. * One handler's error does not prevent other handlers from being called. * + * @public + * @since 13.0.0 (stable) + * @stability stable * @this {import('../WarpGraph.js').default} * @param {{ onChange: (diff: import('../services/StateDiff.js').StateDiffResult) => void, onError?: (error: unknown) => void, replay?: boolean }} options - Subscription options * @returns {{unsubscribe: () => void}} Subscription handle @@ -98,6 +101,9 @@ export function subscribe({ onChange, onError, replay = false }) { * if the frontier has changed (e.g., remote writes detected). The poll interval must * be at least 1000ms. * + * @public + * @since 13.0.0 (stable) + * @stability stable * @this {import('../WarpGraph.js').default} * @param {string|string[]} pattern - Glob pattern(s) (e.g., 'user:*', 'order:123', '*') * @param {{ onChange: (diff: import('../services/StateDiff.js').StateDiffResult) => void, onError?: (error: unknown) => void, poll?: number }} options - Watch options From 42e5377e85c40fd63042fa92f0cef3e8b0275563 Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 3 Mar 2026 12:56:25 -0800 Subject: [PATCH 04/17] feat(api): add graph.patchMany() batch patch API (B11) Sequential batch helper that applies multiple patch callbacks in order. Each callback sees state from the prior commit, enabling dependent patches. Returns array of commit SHAs. Inherits CAS, eager re-materialize, and reentrancy guard from patch(). --- CHANGELOG.md | 1 + index.d.ts | 9 ++ src/domain/warp/_wiredMethods.d.ts | 1 + src/domain/warp/patch.methods.js | 32 ++++ test/unit/domain/WarpGraph.patchMany.test.js | 150 +++++++++++++++++++ 5 files changed, 193 insertions(+) create mode 100644 test/unit/domain/WarpGraph.patchMany.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index fb669efd..da4dc976 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Observer API stabilized (B3)** — `subscribe()` and `watch()` promoted to `@stability stable` with `@since 13.0.0` annotations. Fixed `onError` callback type from `(error: Error)` to `(error: unknown)` to match runtime catch semantics. `watch()` pattern param now correctly typed as `string | string[]` in `_wiredMethods.d.ts`. +- **`graph.patchMany()` batch patch API (B11)** — applies multiple patch callbacks sequentially. Each callback sees state from prior commits. Returns array of commit SHAs. Inherits reentrancy guard from `graph.patch()`. ### Changed diff --git a/index.d.ts b/index.d.ts index a39ff965..5e55be3b 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1656,6 +1656,15 @@ export default class WarpGraph { */ patch(build: (patch: PatchBuilderV2) => void | Promise): Promise; + /** + * Applies multiple patches sequentially. Each callback sees the state + * produced by the previous commit. + * @since 13.0.0 + */ + patchMany( + ...builds: Array<(patch: PatchBuilderV2) => void | Promise> + ): Promise; + /** * Returns patches from a writer's ref chain. */ diff --git a/src/domain/warp/_wiredMethods.d.ts b/src/domain/warp/_wiredMethods.d.ts index 78f38bd3..93b702ac 100644 --- a/src/domain/warp/_wiredMethods.d.ts +++ b/src/domain/warp/_wiredMethods.d.ts @@ -226,6 +226,7 @@ declare module '../WarpGraph.js' { // ── patch.methods.js ────────────────────────────────────────────────── createPatch(): Promise; patch(build: (p: PatchBuilderV2) => void | Promise): Promise; + patchMany(...builds: Array<(p: PatchBuilderV2) => void | Promise>): Promise; _nextLamport(): Promise<{ lamport: number; parentSha: string | null }>; _loadWriterPatches(writerId: string, stopAtSha?: string | null): Promise>; getWriterPatches(writerId: string, stopAtSha?: string | null): Promise>; diff --git a/src/domain/warp/patch.methods.js b/src/domain/warp/patch.methods.js index b72ee3e2..5ab95e5c 100644 --- a/src/domain/warp/patch.methods.js +++ b/src/domain/warp/patch.methods.js @@ -91,6 +91,38 @@ export async function patch(build) { } } +/** + * Applies multiple patches sequentially. + * + * Each callback sees the state produced by the previous commit, so later + * patches can depend on earlier ones. Uses `graph.patch()` internally, + * inheriting CAS, eager re-materialize, and reentrancy-guard semantics. + * + * Returns an empty array (not an error) when called with no arguments. + * + * @public + * @since 13.0.0 + * @this {import('../WarpGraph.js').default} + * @param {...((p: PatchBuilderV2) => void | Promise)} builds - Patch callbacks + * @returns {Promise} Commit SHAs in order of application + * + * @example + * const shas = await graph.patchMany( + * p => p.addNode('user:alice').setProperty('user:alice', 'name', 'Alice'), + * p => p.addNode('user:bob').setProperty('user:bob', 'name', 'Bob'), + * ); + */ +export async function patchMany(...builds) { + if (builds.length === 0) { + return []; + } + const shas = []; + for (const build of builds) { + shas.push(await this.patch(build)); + } + return shas; +} + /** * Gets the next lamport timestamp and current parent SHA for this writer. * Reads from the current ref chain to determine values. diff --git a/test/unit/domain/WarpGraph.patchMany.test.js b/test/unit/domain/WarpGraph.patchMany.test.js new file mode 100644 index 00000000..d5440af5 --- /dev/null +++ b/test/unit/domain/WarpGraph.patchMany.test.js @@ -0,0 +1,150 @@ +import { describe, it, expect } from 'vitest'; +import WarpGraph from '../../../src/domain/WarpGraph.js'; +import { createGitRepo } from '../../helpers/warpGraphTestUtils.js'; + +describe('WarpGraph.patchMany()', () => { + it('returns empty array when called with no arguments', async () => { + const repo = await createGitRepo('patchMany-empty'); + try { + const graph = await WarpGraph.open({ + persistence: repo.persistence, + graphName: 'test', + writerId: 'writer-a', + autoMaterialize: true, + }); + const shas = await graph.patchMany(); + expect(shas).toEqual([]); + } finally { + await repo.cleanup(); + } + }); + + it('applies a single patch and returns its SHA', async () => { + const repo = await createGitRepo('patchMany-single'); + try { + const graph = await WarpGraph.open({ + persistence: repo.persistence, + graphName: 'test', + writerId: 'writer-a', + autoMaterialize: true, + }); + const shas = await graph.patchMany( + (p) => p.addNode('n:1').setProperty('n:1', 'k', 'v'), + ); + expect(shas).toHaveLength(1); + expect(typeof shas[0]).toBe('string'); + expect(shas[0]).toHaveLength(40); + + const props = await graph.getNodeProps('n:1'); + expect(props?.k).toBe('v'); + } finally { + await repo.cleanup(); + } + }); + + it('applies multiple patches sequentially', async () => { + const repo = await createGitRepo('patchMany-multi'); + try { + const graph = await WarpGraph.open({ + persistence: repo.persistence, + graphName: 'test', + writerId: 'writer-a', + autoMaterialize: true, + }); + const shas = await graph.patchMany( + (p) => p.addNode('n:1').setProperty('n:1', 'role', 'admin'), + (p) => p.addNode('n:2').setProperty('n:2', 'role', 'user'), + (p) => p.addEdge('n:1', 'n:2', 'manages'), + ); + expect(shas).toHaveLength(3); + + const nodes = await graph.getNodes(); + expect(nodes.sort()).toEqual(['n:1', 'n:2']); + + const edges = await graph.getEdges(); + expect(edges).toHaveLength(1); + expect(edges[0].from).toBe('n:1'); + expect(edges[0].to).toBe('n:2'); + } finally { + await repo.cleanup(); + } + }); + + it('each callback sees state from previous patches', async () => { + const repo = await createGitRepo('patchMany-sees-prior'); + try { + const graph = await WarpGraph.open({ + persistence: repo.persistence, + graphName: 'test', + writerId: 'writer-a', + autoMaterialize: true, + }); + + // First patch creates node, second patch sets a property that depends on it + const shas = await graph.patchMany( + (p) => p.addNode('n:1').setProperty('n:1', 'step', 1), + async (p) => { + // Verify node from first patch is visible + const has = await graph.hasNode('n:1'); + expect(has).toBe(true); + p.setProperty('n:1', 'step', 2); + }, + ); + expect(shas).toHaveLength(2); + + const props = await graph.getNodeProps('n:1'); + expect(props?.step).toBe(2); + } finally { + await repo.cleanup(); + } + }); + + it('propagates error from failing callback without applying further patches', async () => { + const repo = await createGitRepo('patchMany-error'); + try { + const graph = await WarpGraph.open({ + persistence: repo.persistence, + graphName: 'test', + writerId: 'writer-a', + autoMaterialize: true, + }); + + await expect( + graph.patchMany( + (p) => p.addNode('n:1'), + () => { throw new Error('deliberate'); }, + (p) => p.addNode('n:3'), // should never run + ), + ).rejects.toThrow('deliberate'); + + // First patch was applied; third was not + expect(await graph.hasNode('n:1')).toBe(true); + expect(await graph.hasNode('n:3')).toBe(false); + } finally { + await repo.cleanup(); + } + }); + + it('triggers reentrancy guard when nesting patch inside patchMany callback', async () => { + const repo = await createGitRepo('patchMany-reentrant'); + try { + const graph = await WarpGraph.open({ + persistence: repo.persistence, + graphName: 'test', + writerId: 'writer-a', + autoMaterialize: true, + }); + + await expect( + graph.patchMany( + async () => { + // Nesting patch() inside patchMany should trigger reentrancy guard + await graph.patch((p) => p.addNode('sneaky')); + }, + ), + ).rejects.toThrow(/not reentrant/); + } finally { + await repo.cleanup(); + } + }); +}, { timeout: 30000 }); From a8c241b27a0349f033ff343fa88cf9af28a982b0 Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 3 Mar 2026 13:01:14 -0800 Subject: [PATCH 05/17] feat(bisect): add BisectService for binary search over WARP history Implements causality bisect: given a known-good and known-bad commit SHA on a writer's patch chain, finds the first bad patch via binary search. Materializes state at each midpoint using ceiling-bounded materialization and delegates to a user-supplied test function. Includes 6 test vectors covering the linear chain, same-SHA, single-step, reversed-order, missing-SHA, and SHA-passthrough cases. --- bin/cli/commands/bisect.js | 89 ++++++++ bin/cli/commands/registry.js | 2 + bin/cli/infrastructure.js | 7 + bin/cli/schemas.js | 10 + index.d.ts | 32 +++ index.js | 3 + src/domain/services/BisectService.js | 142 ++++++++++++ .../domain/services/BisectService.test.js | 203 ++++++++++++++++++ 8 files changed, 488 insertions(+) create mode 100644 bin/cli/commands/bisect.js create mode 100644 src/domain/services/BisectService.js create mode 100644 test/unit/domain/services/BisectService.test.js diff --git a/bin/cli/commands/bisect.js b/bin/cli/commands/bisect.js new file mode 100644 index 00000000..dfdfcb50 --- /dev/null +++ b/bin/cli/commands/bisect.js @@ -0,0 +1,89 @@ +import { execSync } from 'node:child_process'; +import { EXIT_CODES, parseCommandArgs } from '../infrastructure.js'; +import { bisectSchema } from '../schemas.js'; +import { openGraph } from '../shared.js'; +import BisectService from '../../../src/domain/services/BisectService.js'; +import { orsetContains } from '../../../src/domain/crdt/ORSet.js'; + +/** @typedef {import('../types.js').CliOptions} CliOptions */ + +const BISECT_OPTIONS = { + good: { type: 'string' }, + bad: { type: 'string' }, + test: { type: 'string' }, +}; + +/** @param {string[]} args */ +function parseBisectArgs(args) { + const { values } = parseCommandArgs(args, BISECT_OPTIONS, bisectSchema); + return values; +} + +/** + * Runs a shell command as the bisect test. + * + * @param {string} testCmd - Shell command to execute + * @param {string} sha - Candidate patch SHA (passed as env var) + * @param {string} graphName - Graph name (passed as env var) + * @returns {boolean} true if the command exits 0 (good), false otherwise (bad) + */ +function runTestCommand(testCmd, sha, graphName) { + try { + execSync(testCmd, { + stdio: 'pipe', + env: { + ...process.env, + WARP_BISECT_SHA: sha, + WARP_BISECT_GRAPH: graphName, + }, + }); + return true; + } catch { + return false; + } +} + +/** + * Handles the `bisect` command: binary search over patch history. + * @param {{options: CliOptions, args: string[]}} params + * @returns {Promise<{payload: unknown, exitCode: number}>} + */ +export default async function handleBisect({ options, args }) { + const { good, bad, test: testCmd } = parseBisectArgs(args); + const { graph, graphName } = await openGraph(options); + const writerId = options.writer; + + const bisect = new BisectService({ graph }); + + const result = await bisect.run({ + good, + bad, + writerId, + testFn: async (state, sha) => { + // Expose state as env for the test command — the command + // can query the graph via the CLI to inspect state. + // For now we just pass the SHA and graph name. + void state; + void orsetContains; + return runTestCommand(testCmd, sha, graphName); + }, + }); + + if (result.result === 'range-error') { + return { + payload: { error: { code: 'E_BISECT_RANGE', message: result.message } }, + exitCode: EXIT_CODES.USAGE + 1, // exit code 2 per spec + }; + } + + const payload = { + result: 'found', + firstBadPatch: result.firstBadPatch, + writerId: result.writerId, + lamport: result.lamport, + steps: result.steps, + totalCandidates: result.totalCandidates, + }; + + return { payload, exitCode: EXIT_CODES.OK }; +} diff --git a/bin/cli/commands/registry.js b/bin/cli/commands/registry.js index 5c8850d0..5b04ae94 100644 --- a/bin/cli/commands/registry.js +++ b/bin/cli/commands/registry.js @@ -14,6 +14,7 @@ import handleInstallHooks from './install-hooks.js'; import handleTrust from './trust.js'; import handlePatch from './patch.js'; import handleTree from './tree.js'; +import handleBisect from './bisect.js'; /** @type {Map} */ export const COMMANDS = new Map(/** @type {[string, Function][]} */ ([ @@ -31,6 +32,7 @@ export const COMMANDS = new Map(/** @type {[string, Function][]} */ ([ ['trust', handleTrust], ['patch', handlePatch], ['tree', handleTree], + ['bisect', handleBisect], ['view', handleView], ['install-hooks', handleInstallHooks], ])); diff --git a/bin/cli/infrastructure.js b/bin/cli/infrastructure.js index 48f76fc1..f1ed505a 100644 --- a/bin/cli/infrastructure.js +++ b/bin/cli/infrastructure.js @@ -49,6 +49,7 @@ Commands: seek Time-travel: step through graph history by Lamport tick patch Decode and inspect raw patches tree ASCII tree traversal from root nodes + bisect Binary search for first bad patch in writer history view Interactive TUI graph browser (requires @git-stunts/git-warp-tui) install-hooks Install post-merge git hook @@ -119,6 +120,12 @@ Tree options: --edge