diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7eaa9d7..a00b52f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -32,7 +32,7 @@ jobs:
run: npm run build
- name: Run tests with coverage
- run: npm run test:coverage -- --maxWorkers=1 --passWithNoTests || true
+ run: npm run test:coverage -- --maxWorkers=1 --passWithNoTests
env:
NODE_OPTIONS: --max_old_space_size=6144
diff --git a/README.md b/README.md
index dd7c375..4ee120b 100644
--- a/README.md
+++ b/README.md
@@ -84,7 +84,7 @@ git clone https://github.com/michaeloboyle/sqlite-graph.git
cd sqlite-graph
npm install
npm run build
-npm test # 294 tests passing
+npm test # 415 of 417 passing; 2 known failures: #17, #18
```
When published (target: late December 2025):
@@ -234,6 +234,19 @@ All performance goals met ✅
See [BENCHMARKS.md](docs/BENCHMARKS.md) for detailed performance analysis, methodology, and hardware specifications.
+### Scale benchmark (2026-11, 100k nodes / 500k edges)
+
+Run with `npm run build && node benchmarks/scale-benchmark.js`. Apple M2 Pro, Node 22, medians:
+
+| Operation | sqlite-graph | Note |
+|---|---|---|
+| getNode | 0.006ms | parity with raw better-sqlite3 |
+| `where({ email })`, no index | 22.4ms | full scan, grows linearly |
+| `where({ email })` after `createPropertyIndex('Person', 'email')` | 0.022ms | ~1000x; index any property you filter on |
+| `traverse().out().unique().maxDepth(3)` (~155 nodes) | 3.79ms | before #12 fixes; raw SQLite 1.10ms |
+| `shortestPath` between random nodes | ~2.5s | unidirectional BFS, see #16 |
+| single `createNode` autocommit, WAL (default since #13) | 4,405/s internal SSD, 21,277/s external drive | 1,491/s and 1,202/s with `{ wal: false }` (median of 5) |
+
## Contributing
Contributions are welcome! We follow the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md).
@@ -268,8 +281,8 @@ MIT License - see [LICENSE](LICENSE) for details
**Status:** 🟡 Alpha - Phase 1-2 Complete, Phase 3 Partial | Phase 4 (Browser Support) In Progress
**Current Phase:** Building v1.0 with universal browser support (6-week timeline)
-**Test Status:** 294 tests passing | 2 suites with memory issues | ~75-80% coverage (estimated)
-**Not Production Ready:** Memory crashes in coverage tests, missing npm publish config
+**Test Status:** 415 of 417 passing (known failures: #17 nested transactions, #18 traverse() on a missing node) | ~75-80% coverage (estimated)
+**Not Production Ready:** shortestPath is slow on large graphs (#16), missing npm publish config
**What Works:**
- ✅ Complete CRUD operations with transactions
diff --git a/benchmarks/scale-benchmark.js b/benchmarks/scale-benchmark.js
new file mode 100644
index 0000000..b6629cd
--- /dev/null
+++ b/benchmarks/scale-benchmark.js
@@ -0,0 +1,157 @@
+// Scale benchmark: sqlite-graph vs raw better-sqlite3 doing the same work.
+// Usage: npm run build && node benchmarks/scale-benchmark.js
+// Env: SCALES=1000,10000,100000 OUT=
+const path = require('path');
+const fs = require('fs');
+const os = require('os');
+const { GraphDatabase } = require('../dist');
+const Database = require('better-sqlite3');
+
+const OUT = process.env.OUT || os.tmpdir();
+const SCALES = (process.env.SCALES || '1000,10000,100000').split(',').map(Number);
+const DEG = 5;
+
+// mulberry32: a plain LCG mod 2^31 produced correlated query pairs (all at distance 2)
+function mulberry32(a) {
+ return () => {
+ a |= 0; a = (a + 0x6d2b79f5) | 0;
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+const rnd = mulberry32(42);
+const ri = (n) => 1 + Math.floor(rnd() * n);
+
+function time(fn, iters) {
+ const t = [];
+ for (let i = 0; i < iters; i++) {
+ const s = process.hrtime.bigint();
+ fn(i);
+ t.push(Number(process.hrtime.bigint() - s) / 1e6);
+ }
+ t.sort((a, b) => a - b);
+ return { med: t[Math.floor(t.length / 2)], p95: t[Math.floor(t.length * 0.95)], n: iters };
+}
+const fmt = (r) => `${r.med.toFixed(3)}ms (p95 ${r.p95.toFixed(3)}, n=${r.n})`;
+const results = [];
+function rec(scale, op, impl, r, extra = {}) {
+ results.push({ scale, op, impl, ...r, ...extra });
+ console.log(`[${scale}] ${op.padEnd(34)} ${impl.padEnd(10)} ${fmt(r)} ${Object.keys(extra).length ? JSON.stringify(extra) : ''}`);
+}
+
+function buildGraph(file, N) {
+ if (fs.existsSync(file)) fs.unlinkSync(file);
+ const g = new GraphDatabase(file);
+ const raw = g.getRawDb();
+ const cities = ['Scranton', 'Boston', 'Austin', 'Denver', 'Miami', 'Seattle', 'Chicago', 'Portland'];
+ const insN = raw.prepare('INSERT INTO nodes (type, properties) VALUES (?, ?)');
+ const insE = raw.prepare('INSERT INTO edges (type, from_id, to_id) VALUES (?, ?, ?)');
+ raw.transaction(() => {
+ for (let i = 1; i <= N; i++) {
+ insN.run(i % 10 === 0 ? 'Company' : 'Person',
+ JSON.stringify({ name: `n${i}`, age: 18 + (i % 60), city: cities[i % cities.length], email: `u${i}@x.io` }));
+ }
+ for (let i = 1; i <= N; i++) for (let d = 0; d < DEG; d++) insE.run('KNOWS', i, ri(N));
+ })();
+ return g;
+}
+
+// --- baselines in raw SQL with cached statements ---
+function rawBfs(raw, start, maxDepth, hyd) {
+ const nb = raw.prepare("SELECT to_id AS id FROM edges WHERE from_id = ? AND type = 'KNOWS'").pluck();
+ const seen = new Set([start]); let frontier = [start]; const out = [];
+ for (let d = 0; d < maxDepth; d++) {
+ const next = [];
+ for (const id of frontier) for (const n of nb.all(id)) if (!seen.has(n)) { seen.add(n); next.push(n); out.push(hyd ? JSON.parse(hyd.get(n).properties) : n); }
+ frontier = next;
+ }
+ return out.length;
+}
+function cteBfs(raw, start, maxDepth) {
+ return raw.prepare(`WITH RECURSIVE r(id, d) AS (SELECT ?, 0 UNION SELECT e.to_id, r.d + 1 FROM edges e JOIN r ON e.from_id = r.id WHERE e.type='KNOWS' AND r.d < ?)
+ SELECT COUNT(DISTINCT id) - 1 AS c FROM r`).get(start, maxDepth).c;
+}
+function rawShortest(raw, a, b) {
+ const nb = raw.prepare('SELECT to_id AS id FROM edges WHERE from_id = ? UNION SELECT from_id FROM edges WHERE to_id = ?').pluck();
+ const parent = new Map([[a, 0]]); let q = [a], h = 0;
+ while (h < q.length) { const c = q[h++]; if (c === b) return true; for (const n of nb.all(c, c)) if (!parent.has(n)) { parent.set(n, c); q.push(n); } }
+ return false;
+}
+
+for (const N of SCALES) {
+ const file = path.join(OUT, `bench-${N}.db`);
+ const t0 = Date.now();
+ const g = buildGraph(file, N);
+ const raw = g.getRawDb();
+ console.log(`\n=== N=${N} nodes, ${N * DEG} edges, build ${Date.now() - t0}ms, journal=${raw.pragma('journal_mode', { simple: true })} sync=${raw.pragma('synchronous', { simple: true })}`);
+ const it = N >= 100000 ? 200 : 500;
+
+ // Point lookup
+ rec(N, 'getNode', 'lib', time(() => g.getNode(ri(N)), 2000));
+ const gn = raw.prepare('SELECT * FROM nodes WHERE id = ?');
+ rec(N, 'getNode', 'raw', time(() => { const r = gn.get(ri(N)); JSON.parse(r.properties); }, 2000));
+
+ // Property filter, unindexed then indexed
+ const city = () => ['Scranton', 'Boston', 'Austin'][ri(3) - 1];
+ const em = () => `u${ri(N)}@x.io`;
+ rec(N, 'where(email) unindexed', 'lib', time(() => g.nodes('Person').where({ email: em() }).exec(), 50));
+ rec(N, 'where(city).limit(20) unindexed', 'lib', time(() => g.nodes('Person').where({ city: city() }).limit(20).exec(), 200));
+ rec(N, 'where(city).count() unindexed', 'lib', time(() => g.nodes('Person').where({ city: city() }).count(), 50));
+ g.createPropertyIndex('Person', 'email');
+ const plan = raw.prepare(`EXPLAIN QUERY PLAN SELECT n.* FROM nodes n WHERE n.type = ? AND json_extract(n.properties, '$.email') = ?`).all('Person', 'x').map(r => r.detail).join(' | ');
+ rec(N, 'where(email) indexed', 'lib', time(() => g.nodes('Person').where({ email: em() }).exec(), 500), { plan });
+
+ // Traversal depth 2 and 3
+ for (const depth of [2, 3]) {
+ const its = depth === 3 && N >= 100000 ? 50 : (depth === 3 ? 100 : it);
+ let cnt = 0; const st = Array.from({ length: its }, () => ri(N));
+ rec(N, `traverse.out.unique d=${depth}`, 'lib', time((i) => { cnt = g.traverse(st[i]).out('KNOWS').unique().maxDepth(depth).toArray().length; }, its), { reached: cnt });
+ rec(N, `traverse.out.unique d=${depth}`, 'raw-js', time((i) => { cnt = rawBfs(raw, st[i], depth); }, its), { reached: cnt });
+ rec(N, `traverse.out.unique d=${depth}`, 'raw+hydr', time((i) => { cnt = rawBfs(raw, st[i], depth, gn); }, its), { reached: cnt });
+ if (N <= 10000) rec(N, `traverse.out.unique d=${depth}`, 'raw-cte', time((i) => { cnt = cteBfs(raw, st[i], depth); }, Math.min(its, 50)), { reached: cnt });
+ }
+ // Non-unique traversal (default): result inflation on a cyclic graph
+ {
+ let cnt = 0; const s = ri(N);
+ rec(N, 'traverse.out NON-unique d=3', 'lib', time(() => { cnt = g.traverse(s).out('KNOWS').maxDepth(3).toArray().length; }, 20), { returned: cnt, distinct: g.traverse(s).out('KNOWS').unique().maxDepth(3).toArray().length });
+ }
+
+ // Shortest path between random pairs
+ const pairs = Array.from({ length: 30 }, () => [ri(N), ri(N)]);
+ rec(N, 'shortestPath (any edge)', 'lib', time((i) => g.traverse(pairs[i][0]).shortestPath(pairs[i][1]), pairs.length));
+ rec(N, 'shortestPath (any edge)', 'raw-js', time((i) => rawShortest(raw, pairs[i][0], pairs[i][1]), pairs.length));
+
+ // Statement prepare overhead in isolation
+ rec(N, 'prepare+get per call', 'lib-style', time(() => raw.prepare('SELECT * FROM nodes WHERE id = ?').get(ri(N)), 5000));
+ rec(N, 'cached stmt get', 'raw', time(() => gn.get(ri(N)), 5000));
+
+ g.close();
+ for (const f of [file, file + '-wal', file + '-shm']) if (fs.existsSync(f)) fs.unlinkSync(f);
+}
+
+// --- Write path: journal mode x transaction ---
+{
+ const N = 2000;
+ for (const mode of ['no-wal', 'wal']) {
+ const file = path.join(OUT, `w-${mode}.db`);
+ for (const f of [file, file + '-wal', file + '-shm']) if (fs.existsSync(f)) fs.unlinkSync(f);
+ const g = new GraphDatabase(file, { wal: mode === 'wal' });
+ let s = Date.now();
+ for (let i = 0; i < N; i++) g.createNode('Person', { name: `p${i}` });
+ const auto = Date.now() - s;
+ s = Date.now();
+ g.transaction(() => { for (let i = 0; i < N; i++) g.createNode('Person', { name: `q${i}` }); });
+ const tx = Date.now() - s;
+ s = Date.now();
+ for (let i = 0; i < N; i++) g.createEdge(i + 1, 'KNOWS', N + i + 1);
+ const edges = Date.now() - s;
+ console.log(`[write ${mode}] createNode x${N} autocommit ${auto}ms (${(N / auto * 1000).toFixed(0)}/s) | in transaction ${tx}ms (${(N / tx * 1000).toFixed(0)}/s) | createEdge autocommit ${edges}ms`);
+ results.push({ scale: N, op: 'write', impl: mode, autocommit_ms: auto, tx_ms: tx, edge_autocommit_ms: edges });
+ g.close();
+ for (const f of [file, file + '-wal', file + '-shm']) if (fs.existsSync(f)) fs.unlinkSync(f);
+ }
+}
+
+fs.writeFileSync(path.join(OUT, 'sqlite-graph-scale-results.json'), JSON.stringify(results, null, 2));
+console.log('\nwrote', path.join(OUT, 'sqlite-graph-scale-results.json'));
diff --git a/docs/API.md b/docs/API.md
index ec76b94..80960d4 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -781,6 +781,10 @@ toArray(): Node[]
**Returns:** Array of nodes reached during traversal
+**Depth and duplicates:**
+- Without `maxDepth()`, each defined step is followed once (`.out('X')` is one hop), extended to `minDepth()` when that is deeper.
+- Without `unique()`, a node is returned once per distinct path. A path never revisits one of its own nodes, so cycles terminate, but result size can grow exponentially with depth on dense graphs; use `unique()` for reachability.
+
**Example:**
```typescript
diff --git a/src/core/Database.ts b/src/core/Database.ts
index d5de3f4..29bbe03 100644
--- a/src/core/Database.ts
+++ b/src/core/Database.ts
@@ -63,6 +63,7 @@ export class GraphDatabase {
public readonly db: Database.Database;
private schema?: GraphSchema;
private preparedStatements: Map;
+ private transactionDepth = 0;
/**
* Creates a new graph database instance.
@@ -102,6 +103,13 @@ export class GraphDatabase {
this.schema = options?.schema;
this.preparedStatements = new Map();
+ // WAL + synchronous=NORMAL for file databases unless opted out.
+ // Autocommit writes avoid a full fsync per statement (~22x in benchmarks).
+ if (options?.wal !== false && !options?.readonly && !this.db.memory) {
+ this.db.pragma('journal_mode = WAL');
+ this.db.pragma('synchronous = NORMAL');
+ }
+
// Initialize database schema
initializeSchema(this.db);
@@ -507,10 +515,12 @@ export class GraphDatabase {
* ```
*/
transaction(fn: (ctx: TransactionContext) => T): T {
- // Start transaction
- this.db.prepare('BEGIN').run();
+ // Inside an open transaction, nest with a savepoint instead of BEGIN (#17)
+ const nestedName = this.db.inTransaction ? `sg_tx_${this.transactionDepth}` : undefined;
+ this.db.prepare(nestedName ? `SAVEPOINT "${nestedName}"` : 'BEGIN').run();
+ this.transactionDepth++;
- const ctx = new TransactionContext(this.db);
+ const ctx = new TransactionContext(this.db, nestedName);
try {
const result = fn(ctx);
@@ -527,6 +537,8 @@ export class GraphDatabase {
ctx.rollback();
}
throw error;
+ } finally {
+ this.transactionDepth--;
}
}
diff --git a/src/core/Transaction.ts b/src/core/Transaction.ts
index e80548e..87a65b7 100644
--- a/src/core/Transaction.ts
+++ b/src/core/Transaction.ts
@@ -40,8 +40,15 @@ export class TransactionContext {
* @param db - SQLite database instance
* @internal
*/
- constructor(db: Database.Database) {
+ private nestedName?: string;
+
+ /**
+ * @param nestedName - set when this context is a savepoint inside an outer
+ * transaction; commit/rollback then release or roll back that savepoint only
+ */
+ constructor(db: Database.Database, nestedName?: string) {
this.db = db;
+ this.nestedName = nestedName;
}
/**
@@ -62,7 +69,7 @@ export class TransactionContext {
if (this.finalized) {
throw new TransactionAlreadyFinalizedError();
}
- this.db.prepare('COMMIT').run();
+ this.db.prepare(this.nestedName ? `RELEASE "${this.nestedName}"` : 'COMMIT').run();
this.finalized = true;
}
@@ -88,7 +95,13 @@ export class TransactionContext {
if (this.finalized) {
throw new TransactionAlreadyFinalizedError();
}
- this.db.prepare('ROLLBACK').run();
+ if (this.nestedName) {
+ // Undo the inner work, then close the savepoint; the outer transaction continues
+ this.db.prepare(`ROLLBACK TO "${this.nestedName}"`).run();
+ this.db.prepare(`RELEASE "${this.nestedName}"`).run();
+ } else {
+ this.db.prepare('ROLLBACK').run();
+ }
this.finalized = true;
}
diff --git a/src/query/TraversalQuery.ts b/src/query/TraversalQuery.ts
index 43093cf..7f1594e 100644
--- a/src/query/TraversalQuery.ts
+++ b/src/query/TraversalQuery.ts
@@ -2,6 +2,45 @@ import Database from 'better-sqlite3';
import { Node, TraversalStep } from '../types';
import { deserialize, timestampToDate } from '../utils/serialization';
+/**
+ * BFS queue entry. `prev` links back along the path for cycle detection.
+ */
+interface QueueEntry {
+ nodeId: number;
+ depth: number;
+ prev?: QueueEntry;
+}
+
+/**
+ * True if nodeId already appears on the path ending at entry.
+ */
+function isOnPath(entry: QueueEntry | undefined, nodeId: number): boolean {
+ for (let e = entry; e; e = e.prev) {
+ if (e.nodeId === nodeId) return true;
+ }
+ return false;
+}
+
+/**
+ * Prepared statements are reused per connection; traversal runs the same
+ * few queries once per hop.
+ */
+const statementCache = new WeakMap>();
+
+function cachedPrepare(db: Database.Database, sql: string): Database.Statement {
+ let cache = statementCache.get(db);
+ if (!cache) {
+ cache = new Map();
+ statementCache.set(db, cache);
+ }
+ let stmt = cache.get(sql);
+ if (!stmt) {
+ stmt = db.prepare(sql);
+ cache.set(sql, stmt);
+ }
+ return stmt;
+}
+
/**
* Graph traversal query builder for exploring relationships.
* Provides methods for walking the graph in various directions and finding paths.
@@ -204,6 +243,11 @@ export class TraversalQuery {
/**
* Execute traversal and return all reachable nodes.
*
+ * Without maxDepth(), each defined step is followed once (`.out('X')` is one hop),
+ * extended to minDepth() when that is deeper.
+ * Without unique(), a node is returned once per distinct path; a path never
+ * revisits a node, so cycles terminate.
+ *
* @returns Array of nodes reached during traversal
*
* @example
@@ -217,52 +261,45 @@ export class TraversalQuery {
* ```
*/
toArray(): Node[] {
- const visited = new Set();
const results: Node[] = [];
- const queue: Array<{ nodeId: number; depth: number }> = [
- { nodeId: this.startNodeId, depth: 0 }
- ];
-
- while (queue.length > 0) {
- const { nodeId, depth } = queue.shift()!;
+ const seen = new Set([this.startNodeId]);
+ const queue: QueueEntry[] = [{ nodeId: this.startNodeId, depth: 0 }];
- // Check max depth
- if (this.maxDepthValue !== undefined && depth > this.maxDepthValue) {
- continue;
- }
-
- // Skip if already visited (for unique mode)
- if (this.uniqueNodes && visited.has(nodeId)) {
- continue;
- }
+ // Head index instead of queue.shift(), which is O(n) per dequeue
+ for (let head = 0; head < queue.length; head++) {
+ const entry = queue[head];
+ const { nodeId, depth } = entry;
- visited.add(nodeId);
-
- // Get node
const node = this.getNode(nodeId);
if (!node) continue;
- // Apply filter
- if (this.filterPredicate && !this.filterPredicate(node)) {
- continue;
- }
-
- // Add to results if within depth range
- if (
- (this.minDepthValue === undefined || depth >= this.minDepthValue) &&
- depth > 0 // Don't include start node
- ) {
- results.push(node);
+ // The start node is never returned or filtered
+ if (depth > 0) {
+ if (this.filterPredicate && !this.filterPredicate(node)) {
+ continue;
+ }
+ if (this.minDepthValue === undefined || depth >= this.minDepthValue) {
+ results.push(node);
+ }
}
- // Get neighbors based on traversal steps
- if (this.steps.length > 0) {
- const step = this.steps[Math.min(depth, this.steps.length - 1)];
- const neighbors = this.getNeighbors(nodeId, step);
-
- for (const neighborId of neighbors) {
- queue.push({ nodeId: neighborId, depth: depth + 1 });
+ if (this.steps.length === 0) continue;
+ // Without maxDepth(), follow each defined step once, but reach minDepth()
+ const limit = this.maxDepthValue ?? Math.max(this.steps.length, this.minDepthValue ?? 0);
+ if (depth >= limit) continue;
+
+ const step = this.steps[Math.min(depth, this.steps.length - 1)];
+ for (const neighborId of this.getNeighbors(nodeId, step)) {
+ if (this.uniqueNodes) {
+ // Mark on enqueue so duplicates never enter the queue
+ if (seen.has(neighborId)) continue;
+ seen.add(neighborId);
+ } else if (isOnPath(entry, neighborId)) {
+ // Without unique(), nodes repeat once per distinct path,
+ // but never along one path, so cycles terminate
+ continue;
}
+ queue.push({ nodeId: neighborId, depth: depth + 1, prev: entry });
}
}
@@ -288,49 +325,37 @@ export class TraversalQuery {
*/
toPaths(): Node[][] {
const paths: Node[][] = [];
- const queue: Array<{ nodeId: number; path: Node[]; depth: number; visited: Set }> = [
- { nodeId: this.startNodeId, path: [], depth: 0, visited: new Set() }
+ const queue: Array = [
+ { nodeId: this.startNodeId, depth: 0, path: [] }
];
- while (queue.length > 0) {
- const { nodeId, path, depth, visited } = queue.shift()!;
+ for (let head = 0; head < queue.length; head++) {
+ const entry = queue[head];
+ const { nodeId, path, depth } = entry;
- // Check max depth
- if (this.maxDepthValue !== undefined && depth > this.maxDepthValue) {
- continue;
- }
-
- // Cycle detection: skip if node already in this path
- if (visited.has(nodeId)) {
- continue;
- }
-
- // Get node
const node = this.getNode(nodeId);
if (!node) continue;
- const newPath = [...path, node];
- const newVisited = new Set(visited);
- newVisited.add(nodeId);
-
// Apply filter
if (this.filterPredicate && !this.filterPredicate(node)) {
continue;
}
+ const newPath = [...path, node];
+
// Add complete path if we've traversed at least one step
if (depth > 0) {
paths.push(newPath);
}
- // Get neighbors
- if (this.steps.length > 0) {
- const step = this.steps[Math.min(depth, this.steps.length - 1)];
- const neighbors = this.getNeighbors(nodeId, step);
+ if (this.steps.length === 0) continue;
+ if (this.maxDepthValue !== undefined && depth >= this.maxDepthValue) continue;
- for (const neighborId of neighbors) {
- queue.push({ nodeId: neighborId, path: newPath, depth: depth + 1, visited: newVisited });
- }
+ const step = this.steps[Math.min(depth, this.steps.length - 1)];
+ for (const neighborId of this.getNeighbors(nodeId, step)) {
+ // Cycle detection: skip nodes already on this path
+ if (isOnPath(entry, neighborId)) continue;
+ queue.push({ nodeId: neighborId, depth: depth + 1, path: newPath, prev: entry });
}
}
@@ -360,8 +385,8 @@ export class TraversalQuery {
visited.add(this.startNodeId);
- while (queue.length > 0) {
- const currentId = queue.shift()!;
+ for (let head = 0; head < queue.length; head++) {
+ const currentId = queue[head];
// Found target
if (currentId === targetNodeId) {
@@ -487,7 +512,7 @@ export class TraversalQuery {
* @private
*/
private getNode(id: number): Node | null {
- const stmt = this.db.prepare('SELECT * FROM nodes WHERE id = ?');
+ const stmt = cachedPrepare(this.db, 'SELECT * FROM nodes WHERE id = ?');
const row = stmt.get(id) as any;
if (!row) return null;
@@ -530,20 +555,22 @@ export class TraversalQuery {
params.push(step.nodeType);
}
} else {
- // both directions
+ // both directions; the node type filter must cover both UNION branches
sql = `
- SELECT e.to_id as id FROM edges e WHERE e.from_id = ? AND e.type = ?
- UNION
- SELECT e.from_id as id FROM edges e WHERE e.to_id = ? AND e.type = ?
+ SELECT u.id FROM (
+ SELECT e.to_id AS id FROM edges e WHERE e.from_id = ? AND e.type = ?
+ UNION
+ SELECT e.from_id AS id FROM edges e WHERE e.to_id = ? AND e.type = ?
+ ) u
`;
params.push(nodeId, step.edgeType);
if (step.nodeType) {
- sql += ` AND EXISTS (SELECT 1 FROM nodes n WHERE n.id = id AND n.type = ?)`;
+ sql += ` WHERE EXISTS (SELECT 1 FROM nodes n WHERE n.id = u.id AND n.type = ?)`;
params.push(step.nodeType);
}
}
- const stmt = this.db.prepare(sql);
+ const stmt = cachedPrepare(this.db, sql);
const rows = stmt.all(...params) as Array<{ id: number }>;
return rows.map(row => row.id);
}
@@ -558,7 +585,7 @@ export class TraversalQuery {
UNION
SELECT from_id as id FROM edges WHERE to_id = ?
`;
- const stmt = this.db.prepare(sql);
+ const stmt = cachedPrepare(this.db, sql);
const rows = stmt.all(nodeId, nodeId) as Array<{ id: number }>;
return rows.map(row => row.id);
}
diff --git a/src/types/index.ts b/src/types/index.ts
index e3a084d..c36d193 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -56,6 +56,11 @@ export interface DatabaseOptions {
fileMustExist?: boolean;
timeout?: number;
verbose?: (message?: any, ...additionalArgs: any[]) => void;
+ /**
+ * Use WAL journaling with synchronous=NORMAL for file databases (default: true).
+ * Ignored for in-memory and readonly databases.
+ */
+ wal?: boolean;
}
/**
diff --git a/tests/unit/Transaction-nested.test.ts b/tests/unit/Transaction-nested.test.ts
new file mode 100644
index 0000000..9f410c3
--- /dev/null
+++ b/tests/unit/Transaction-nested.test.ts
@@ -0,0 +1,70 @@
+import { describe, it, expect } from '@jest/globals';
+import { GraphDatabase } from '../../src/core/Database';
+
+/**
+ * #17: transaction() inside an open transaction uses a savepoint instead of BEGIN.
+ * This is what lets callers wrap many mergeNode()/mergeEdge() calls in one transaction.
+ */
+describe('#17 nested transactions', () => {
+ it('commits inner and outer work together', () => {
+ const db = new GraphDatabase(':memory:');
+ db.transaction(() => {
+ db.createNode('Outer', { n: 1 });
+ db.transaction(() => { db.createNode('Inner', { n: 2 }); });
+ });
+ expect(db.nodes('Outer').count()).toBe(1);
+ expect(db.nodes('Inner').count()).toBe(1);
+ });
+
+ it('rolls back only the inner work when the inner transaction throws', () => {
+ const db = new GraphDatabase(':memory:');
+ db.transaction(() => {
+ db.createNode('Outer', { n: 1 });
+ expect(() => db.transaction(() => {
+ db.createNode('Inner', { n: 2 });
+ throw new Error('inner failure');
+ })).toThrow('inner failure');
+ db.createNode('Outer', { n: 3 });
+ });
+ expect(db.nodes('Outer').count()).toBe(2);
+ expect(db.nodes('Inner').count()).toBe(0);
+ });
+
+ it('rolls back everything when the outer transaction throws', () => {
+ const db = new GraphDatabase(':memory:');
+ expect(() => db.transaction(() => {
+ db.transaction(() => { db.createNode('Inner', { n: 1 }); });
+ throw new Error('outer failure');
+ })).toThrow('outer failure');
+ expect(db.nodes('Inner').count()).toBe(0);
+ });
+
+ it('lets merges run inside a caller transaction', () => {
+ const db = new GraphDatabase(':memory:');
+ db.transaction(() => {
+ const a = db.mergeNode('Person', { key: 'a' }, { key: 'a' }).node;
+ const b = db.mergeNode('Person', { key: 'b' }, { key: 'b' }).node;
+ db.mergeEdge(a.id, 'KNOWS', b.id);
+ });
+ expect(db.nodes('Person').count()).toBe(2);
+ });
+
+ it('supports manual rollback of a nested transaction', () => {
+ const db = new GraphDatabase(':memory:');
+ db.transaction(() => {
+ db.createNode('Outer', { n: 1 });
+ db.transaction((ctx) => {
+ db.createNode('Inner', { n: 2 });
+ ctx.rollback();
+ });
+ });
+ expect(db.nodes('Outer').count()).toBe(1);
+ expect(db.nodes('Inner').count()).toBe(0);
+ });
+
+ it('leaves no transaction open afterwards', () => {
+ const db = new GraphDatabase(':memory:');
+ db.transaction(() => { db.transaction(() => { db.createNode('X', {}); }); });
+ expect(db.getRawDb().inTransaction).toBe(false);
+ });
+});
diff --git a/tests/unit/TraversalQuery-regressions.test.ts b/tests/unit/TraversalQuery-regressions.test.ts
new file mode 100644
index 0000000..840b18b
--- /dev/null
+++ b/tests/unit/TraversalQuery-regressions.test.ts
@@ -0,0 +1,142 @@
+import { describe, it, expect, afterEach } from '@jest/globals';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { GraphDatabase } from '../../src/core/Database';
+
+/**
+ * Regression specs from the 2026-11-02 benchmark.
+ * Issues: #10 (cycle termination), #11 (both() nodeType), #12 (statement reuse), #13 (WAL default)
+ */
+describe('TraversalQuery regressions', () => {
+ describe('#10 non-unique traversal terminates on cycles', () => {
+ it('returns on a 2-node cycle with no maxDepth', () => {
+ const db = new GraphDatabase(':memory:');
+ const x = db.createNode('T', { name: 'x' });
+ const y = db.createNode('T', { name: 'y' });
+ db.createEdge(x.id, 'CYC', y.id);
+ db.createEdge(y.id, 'CYC', x.id);
+
+ const nodes = db.traverse(x.id).out('CYC').toArray();
+
+ expect(nodes.map(n => n.properties.name)).toEqual(['y']);
+ });
+
+ it('never returns the start node, even when a cycle leads back to it', () => {
+ const db = new GraphDatabase(':memory:');
+ const x = db.createNode('T', { name: 'x' });
+ const y = db.createNode('T', { name: 'y' });
+ db.createEdge(x.id, 'CYC', y.id);
+ db.createEdge(y.id, 'CYC', x.id);
+
+ const nodes = db.traverse(x.id).out('CYC').maxDepth(6).toArray();
+
+ expect(nodes.some(n => n.id === x.id)).toBe(false);
+ });
+
+ it('still returns a node once per distinct path without unique()', () => {
+ const db = new GraphDatabase(':memory:');
+ const a = db.createNode('T', { name: 'a' });
+ const b = db.createNode('T', { name: 'b' });
+ const c = db.createNode('T', { name: 'c' });
+ const d = db.createNode('T', { name: 'd' });
+ db.createEdge(a.id, 'E', b.id);
+ db.createEdge(a.id, 'E', c.id);
+ db.createEdge(b.id, 'E', d.id);
+ db.createEdge(c.id, 'E', d.id);
+
+ const names = db.traverse(a.id).out('E').maxDepth(2).toArray().map(n => n.properties.name).sort();
+
+ expect(names).toEqual(['b', 'c', 'd', 'd']);
+ });
+
+ it('follows each step once when maxDepth is not set', () => {
+ const db = new GraphDatabase(':memory:');
+ const ids = Array.from({ length: 20 }, (_, i) => db.createNode('T', { i }).id);
+ ids.forEach((id, i) => {
+ for (let j = 1; j <= 3; j++) db.createEdge(id, 'E', ids[(i + j) % ids.length]);
+ });
+
+ expect(db.traverse(ids[0]).out('E').toArray()).toHaveLength(3);
+ });
+
+ it('reaches minDepth when maxDepth is not set', () => {
+ const db = new GraphDatabase(':memory:');
+ const ids = Array.from({ length: 5 }, (_, i) => db.createNode('T', { name: `c${i}` }).id);
+ for (let i = 0; i < ids.length - 1; i++) db.createEdge(ids[i], 'NEXT', ids[i + 1]);
+
+ const names = db.traverse(ids[0]).out('NEXT').minDepth(2).toArray().map(n => n.properties.name);
+
+ expect(names).toEqual(['c2']);
+ });
+ });
+
+ describe('#11 both() honours nodeType', () => {
+ it('returns only neighbours of the requested type, in both directions', () => {
+ const db = new GraphDatabase(':memory:');
+ const a = db.createNode('Person', { name: 'a' });
+ const p = db.createNode('Person', { name: 'p' });
+ const pIn = db.createNode('Person', { name: 'pIn' });
+ const c = db.createNode('Company', { name: 'c' });
+ const c2 = db.createNode('Company', { name: 'c2' });
+ db.createEdge(a.id, 'LINK', p.id);
+ db.createEdge(pIn.id, 'LINK', a.id);
+ db.createEdge(a.id, 'LINK', c.id);
+ db.createEdge(c2.id, 'LINK', a.id);
+
+ const names = db.traverse(a.id).both('LINK', 'Person').maxDepth(1).toArray()
+ .map(n => n.properties.name).sort();
+
+ expect(names).toEqual(['p', 'pIn']);
+ });
+ });
+
+ describe('#12 traversal reuses prepared statements', () => {
+ it('prepares a bounded number of statements regardless of graph size', () => {
+ const db = new GraphDatabase(':memory:');
+ const ids = Array.from({ length: 50 }, (_, i) => db.createNode('T', { i }).id);
+ for (let i = 0; i < ids.length - 1; i++) db.createEdge(ids[i], 'NEXT', ids[i + 1]);
+
+ const raw = db.getRawDb();
+ const original = raw.prepare.bind(raw);
+ let prepares = 0;
+ (raw as any).prepare = (sql: string) => { prepares++; return original(sql); };
+
+ const nodes = db.traverse(ids[0]).out('NEXT').maxDepth(100).toArray();
+ db.traverse(ids[0]).shortestPath(ids[49]);
+
+ (raw as any).prepare = original;
+ expect(nodes).toHaveLength(49);
+ expect(prepares).toBeLessThanOrEqual(4);
+ });
+ });
+
+ describe('#13 file databases default to WAL', () => {
+ const files: string[] = [];
+ const tmp = (name: string) => {
+ const f = path.join(os.tmpdir(), `sg-wal-${process.pid}-${name}.db`);
+ files.push(f, f + '-wal', f + '-shm');
+ return f;
+ };
+ afterEach(() => { for (const f of files.splice(0)) if (fs.existsSync(f)) fs.unlinkSync(f); });
+
+ it('uses WAL and synchronous=NORMAL for a file database', () => {
+ const db = new GraphDatabase(tmp('default'));
+ const raw = db.getRawDb();
+ expect(raw.pragma('journal_mode', { simple: true })).toBe('wal');
+ expect(raw.pragma('synchronous', { simple: true })).toBe(1);
+ db.close();
+ });
+
+ it('keeps the SQLite defaults when wal is false', () => {
+ const db = new GraphDatabase(tmp('optout'), { wal: false });
+ expect(db.getRawDb().pragma('journal_mode', { simple: true })).toBe('delete');
+ db.close();
+ });
+
+ it('leaves in-memory databases alone', () => {
+ const db = new GraphDatabase(':memory:');
+ expect(db.getRawDb().pragma('journal_mode', { simple: true })).toBe('memory');
+ });
+ });
+});
diff --git a/tests/unit/TraversalQuery.test.ts b/tests/unit/TraversalQuery.test.ts
index 3768e93..e95ca28 100644
--- a/tests/unit/TraversalQuery.test.ts
+++ b/tests/unit/TraversalQuery.test.ts
@@ -732,12 +732,10 @@ describe('TraversalQuery', () => {
});
describe('Edge Cases and Error Handling', () => {
- it('should handle traversal from non-existent node gracefully', () => {
- const nodes = db.traverse(99999)
- .out('LINKS')
- .toArray();
-
- expect(nodes).toEqual([]);
+ it('should reject traversal from a non-existent node', () => {
+ // Owner ruling on #18 (2026-11-02): traverse() throws for a missing start
+ // node, matching Database.test.ts
+ expect(() => db.traverse(99999)).toThrow('Start node with ID 99999 not found');
});
it('should handle empty graph', () => {