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', () => {