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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,41 @@
"version": "0.1.0",
"description": "TypeScript graph database abstraction layer with type-safe node/edge management and schema validation",
"type": "module",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./package.json": "./package.json"
},
"main": "dist/index.js",
"module": "./dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"sideEffects": false,
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts && npm run check:dts",
"check:dts": "node -e \"require('node:fs').accessSync('dist/index.d.ts'); console.log('dist/index.d.ts present')\"",
"prepack": "npm run build",
"test": "vitest run",
"test:watch": "vitest watch",
"test:coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit"
},
"keywords": ["graph", "database", "typescript", "schema"],
"keywords": [
"graph",
"database",
"typescript",
"schema"
],
"author": "",
"license": "MIT",
"devDependencies": {
Expand Down
28 changes: 21 additions & 7 deletions src/operations/traverse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,18 @@ export interface TraverseOptions {
/**
* Only include nodes of these types in the result.
* If not specified, all node types are included.
*
* @remarks
* This gates only which nodes appear in the returned array -- it does not
* prune the traversal. Filtered-out nodes are still traversed *through*:
* their edges are expanded and they are marked visited, exactly like any
* other node. For example, with `nodeTypes: ['Person']`, a path
* `A(Person) -> X(Company) -> B(Person)` yields `['A', 'B']`: `X` is
* visited and its edges are expanded, but it is omitted from the result.
*
* Pass-through is the deliberate choice here: a caller who wants prune
* semantics cannot reconstruct pass-through, whereas a caller who wants only
* the listed types can already filter the returned array.
*/
nodeTypes?: string[];
}
Expand Down Expand Up @@ -124,17 +136,19 @@ export function traverse(
if (visited.has(nodeId)) {
continue;
}
visited.add(nodeId);

// Check node type filter
// Check node type filter. This gates only whether the node appears in
// `result` -- it does not prune the traversal. Filtered-out nodes are
// still marked visited (above) and their edges are still expanded
// (below), so a path through a filtered node still reaches whatever
// lies beyond it (IF-3-RR0 pass-through semantics).
const node = storage.getNode(nodeId);
if (nodeTypes && node && !nodeTypes.includes(node.type)) {
// Skip this node but don't add to visited (allow reaching through it)
continue;
const passesTypeFilter = !nodeTypes || !node || nodeTypes.includes(node.type);
if (passesTypeFilter) {
result.push(nodeId);
}

visited.add(nodeId);
result.push(nodeId);

// Check depth limit (0 means unlimited)
if (maxDepth > 0 && depth >= maxDepth) {
continue;
Expand Down
50 changes: 49 additions & 1 deletion tests/unit/operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* - Subgraph operations: subgraph()
*/

import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { MemoryStorage } from '../../src/storage/index.js';
import type { Storage } from '../../src/storage/index.js';
import type { Node, Edge } from '../../src/types/index.js';
Expand Down Expand Up @@ -230,6 +230,54 @@ describe('Graph Operations', () => {
expect(visited).not.toContain('E');
});

it('should pass through a filtered node to reach nodes beyond it', () => {
// A(Person) -> X(Company) -> B(Person), nodeTypes: ['Person']
// X is not a leaf here (unlike D/E in the base graph), so this distinguishes
// pass-through (X visited + expanded, B reached) from prune (X dropped, B unreachable).
storage.addNode({ id: 'PT_A', type: 'Person', properties: {} });
storage.addNode({ id: 'PT_X', type: 'Company', properties: {} });
storage.addNode({ id: 'PT_B', type: 'Person', properties: {} });
storage.addEdge({ id: 'pt_e1', type: 'WORKS_AT', source: 'PT_A', target: 'PT_X', properties: {} });
storage.addEdge({ id: 'pt_e2', type: 'WORKS_AT', source: 'PT_X', target: 'PT_B', properties: {} });

const visited = traverse(storage, 'PT_A', { nodeTypes: ['Person'] });

expect(visited).toContain('PT_A');
expect(visited).toContain('PT_B');
expect(visited).not.toContain('PT_X');
});

it('should process a filtered node reachable via two paths exactly once (re-enqueue regression)', () => {
// PT2_S -> PT2_M1 -> PT2_F
// PT2_S -> PT2_M2 -> PT2_F
// PT2_F -> PT2_G
// PT2_F (Company) is reachable via two paths; it must be looked up (processed) only
// once, not once per incoming path, and traversal must still reach PT2_G through it.
storage.addNode({ id: 'PT2_S', type: 'Person', properties: {} });
storage.addNode({ id: 'PT2_M1', type: 'Person', properties: {} });
storage.addNode({ id: 'PT2_M2', type: 'Person', properties: {} });
storage.addNode({ id: 'PT2_F', type: 'Company', properties: {} });
storage.addNode({ id: 'PT2_G', type: 'Person', properties: {} });
storage.addEdge({ id: 'pt2_e1', type: 'REL', source: 'PT2_S', target: 'PT2_M1', properties: {} });
storage.addEdge({ id: 'pt2_e2', type: 'REL', source: 'PT2_S', target: 'PT2_M2', properties: {} });
storage.addEdge({ id: 'pt2_e3', type: 'REL', source: 'PT2_M1', target: 'PT2_F', properties: {} });
storage.addEdge({ id: 'pt2_e4', type: 'REL', source: 'PT2_M2', target: 'PT2_F', properties: {} });
storage.addEdge({ id: 'pt2_e5', type: 'REL', source: 'PT2_F', target: 'PT2_G', properties: {} });

const getNodeSpy = vi.spyOn(storage, 'getNode');

const visited = traverse(storage, 'PT2_S', { nodeTypes: ['Person'] });

expect(visited).toContain('PT2_S');
expect(visited).toContain('PT2_M1');
expect(visited).toContain('PT2_M2');
expect(visited).toContain('PT2_G');
expect(visited).not.toContain('PT2_F');

const filteredNodeLookups = getNodeSpy.mock.calls.filter(([id]) => id === 'PT2_F').length;
expect(filteredNodeLookups).toBe(1);
});

it('should return only start node for isolated traversal', () => {
storage.addNode({ id: 'Z', type: 'Isolated', properties: {} });
const visited = traverse(storage, 'Z');
Expand Down
Loading