From c773de7795cd9e211900527b91785b949425ad72 Mon Sep 17 00:00:00 2001 From: ViperJuice Date: Sun, 6 Sep 2026 14:43:11 +0000 Subject: [PATCH 1/2] fix(traverse): pass through filtered nodes; add exports map and files allowlist Two fixes found while graphbase was being used vendored inside another repository, ported back so the standalone package is the source of truth. traverse(): the nodeTypes filter pruned the traversal instead of filtering the result. The code continued before expanding the node's edges, so nothing beyond a filtered node was ever reachable -- directly contradicting the comment sitting on that branch, which said filtered nodes should be reachable through. It also continued before marking the node visited, so a filtered node reachable by two paths was dequeued and reprocessed on every path that reached it. Now the node is marked visited and its edges are expanded exactly as for any other node; only the push into the result array is gated. 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, so pass-through is the direction that keeps both options open. The existing "should filter by node type" test could not catch this: every non-matching node in its fixture is a leaf, so pruning and passing through give the same answer. The two tests added here do distinguish them -- a three-hop A(Person) -> X(Company) -> B(Person) path, and a filtered node reachable by two paths asserted via a getNode spy to be looked up exactly once. Both fail against the previous implementation (2 failed / 356 passed) and pass with it (358). package.json: add an exports map, module, files and sideEffects. The package declared type: module with main pointing at the ESM build and no exports map, so the CJS output tsup was already producing was unreachable and require('graphbase') threw ERR_REQUIRE_ESM on Node 20 (Node 22.12+ masks this via require(esm), which is why it can look fine locally). With no files allowlist, npm publish also shipped the entire working tree; the allowlist brings a pack from 52 files to 6. Upstream's build/check:dts scripts are left as they are. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019guyDrzCGThrLyAR8Yqm8R --- package.json | 25 +++++++++++++++++- src/operations/traverse.ts | 28 +++++++++++++++----- tests/unit/operations.test.ts | 50 ++++++++++++++++++++++++++++++++++- 3 files changed, 94 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index c3d0d0b..699e0e0 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,26 @@ "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')\"", @@ -13,7 +31,12 @@ "test:coverage": "vitest run --coverage", "typecheck": "tsc --noEmit" }, - "keywords": ["graph", "database", "typescript", "schema"], + "keywords": [ + "graph", + "database", + "typescript", + "schema" + ], "author": "", "license": "MIT", "devDependencies": { diff --git a/src/operations/traverse.ts b/src/operations/traverse.ts index 275e820..8770263 100644 --- a/src/operations/traverse.ts +++ b/src/operations/traverse.ts @@ -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[]; } @@ -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; diff --git a/tests/unit/operations.test.ts b/tests/unit/operations.test.ts index 796dad9..eff02c5 100644 --- a/tests/unit/operations.test.ts +++ b/tests/unit/operations.test.ts @@ -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'; @@ -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'); From c821ca1616df382738e861380f55767b5580d237 Mon Sep 17 00:00:00 2001 From: ViperJuice Date: Sun, 6 Sep 2026 14:57:42 +0000 Subject: [PATCH 2/2] fix(build): add prepack so a clean-checkout publish cannot ship an empty package Adding `files: ["dist"]` in the previous commit narrowed the published tarball from the whole working tree to the build output, which is what we want -- but dist/ is gitignored, and nothing guaranteed it existed at pack time. On a clean checkout `npm publish` would have produced a tarball containing only package.json and README: strictly worse than the over-broad publish it replaced, because it fails at import rather than merely shipping extra files. The six-file pack measured in the PR description was taken after a manual build, so it did not expose this. CI builds but never packs, so it would not have caught it either. prepack runs for both `npm pack` and `npm publish`, so the artifacts the exports map points at are always present in the tarball. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019guyDrzCGThrLyAR8Yqm8R --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 699e0e0..857bc1d 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "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",