* Batch lookup: fetch many nodes by ID in a single SQL round-trip. * * Replaces the N+1 pattern in graph traversal where every edge would * trigger its own `getNodeById` call. For a function with 50 callers * this collapses 50 point reads into one IN-list query (~10-50x * faster end-t
(ids: readonly string[])
| 451 | * the SQL query only touches the misses. |
| 452 | */ |
| 453 | getNodesByIds(ids: readonly string[]): Map<string, Node> { |
| 454 | const out = new Map<string, Node>(); |
| 455 | if (ids.length === 0) return out; |
| 456 | |
| 457 | // Serve cache hits first; build the miss list for SQL. |
| 458 | const misses: string[] = []; |
| 459 | for (const id of ids) { |
| 460 | const cached = this.nodeCache.get(id); |
| 461 | if (cached !== undefined) { |
| 462 | // LRU touch |
| 463 | this.nodeCache.delete(id); |
| 464 | this.nodeCache.set(id, cached); |
| 465 | out.set(id, cached); |
| 466 | } else { |
| 467 | misses.push(id); |
| 468 | } |
| 469 | } |
| 470 | if (misses.length === 0) return out; |
| 471 | |
| 472 | // Chunk under SQLite's parameter limit (default 999, raised to 32766 |
| 473 | // in better-sqlite3 builds — chunk at 500 for safety across both |
| 474 | // backends and to keep the query plan simple). |
| 475 | for (let i = 0; i < misses.length; i += SQLITE_PARAM_CHUNK_SIZE) { |
| 476 | const chunk = misses.slice(i, i + SQLITE_PARAM_CHUNK_SIZE); |
| 477 | const placeholders = chunk.map(() => '?').join(','); |
| 478 | const rows = this.db |
| 479 | .prepare(`SELECT * FROM nodes WHERE id IN (${placeholders})`) |
| 480 | .all(...chunk) as NodeRow[]; |
| 481 | for (const row of rows) { |
| 482 | const node = rowToNode(row); |
| 483 | out.set(node.id, node); |
| 484 | this.cacheNode(node); |
| 485 | } |
| 486 | } |
| 487 | return out; |
| 488 | } |
| 489 | |
| 490 | private getExistingNodeIds(ids: readonly string[]): Set<string> { |
| 491 | const out = new Set<string>(); |
no test coverage detected