querylens 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +35 -0
- package/dist/bin/querylens.js +208 -0
- package/dist/src/engine/bufferTrace.js +67 -0
- package/dist/src/engine/datasets.js +139 -0
- package/dist/src/engine/exec/delete.js +95 -0
- package/dist/src/engine/exec/evaluate.js +174 -0
- package/dist/src/engine/exec/index.js +4 -0
- package/dist/src/engine/exec/insert.js +75 -0
- package/dist/src/engine/exec/operators.js +1290 -0
- package/dist/src/engine/exec/run.js +35 -0
- package/dist/src/engine/exec/sort.js +171 -0
- package/dist/src/engine/exec/unique.js +79 -0
- package/dist/src/engine/exec/update.js +124 -0
- package/dist/src/engine/exec/writeScan.js +88 -0
- package/dist/src/engine/explain.js +114 -0
- package/dist/src/engine/index/btree.js +481 -0
- package/dist/src/engine/index/build.js +99 -0
- package/dist/src/engine/index/bulk.js +107 -0
- package/dist/src/engine/index/display.js +38 -0
- package/dist/src/engine/index/index.js +9 -0
- package/dist/src/engine/index/lookup.js +213 -0
- package/dist/src/engine/index/rangeLookup.js +158 -0
- package/dist/src/engine/index/spec.js +47 -0
- package/dist/src/engine/index/unique.js +31 -0
- package/dist/src/engine/index/validate.js +105 -0
- package/dist/src/engine/index.js +16 -0
- package/dist/src/engine/locks/index.js +1 -0
- package/dist/src/engine/locks/lockManager.js +46 -0
- package/dist/src/engine/parser/ast.js +77 -0
- package/dist/src/engine/parser/display.js +404 -0
- package/dist/src/engine/parser/index.js +4 -0
- package/dist/src/engine/parser/parser.js +1108 -0
- package/dist/src/engine/parser/print.js +74 -0
- package/dist/src/engine/parser/tokenizer.js +146 -0
- package/dist/src/engine/planner/buildPlan.js +208 -0
- package/dist/src/engine/planner/cost.js +582 -0
- package/dist/src/engine/planner/emit.js +267 -0
- package/dist/src/engine/planner/emitDelete.js +57 -0
- package/dist/src/engine/planner/emitUpdate.js +51 -0
- package/dist/src/engine/planner/index.js +8 -0
- package/dist/src/engine/planner/joinOrder.js +252 -0
- package/dist/src/engine/planner/optimize.js +906 -0
- package/dist/src/engine/planner/plan.js +445 -0
- package/dist/src/engine/predict.js +120 -0
- package/dist/src/engine/runQuery.js +393 -0
- package/dist/src/engine/seed.js +165 -0
- package/dist/src/engine/stats.js +118 -0
- package/dist/src/engine/storage/bufferPool.js +194 -0
- package/dist/src/engine/storage/index.js +3 -0
- package/dist/src/engine/storage/page.js +46 -0
- package/dist/src/engine/storage/policy.js +360 -0
- package/dist/src/engine/subquery.js +88 -0
- package/dist/src/engine/trace.js +17 -0
- package/dist/src/engine/types.js +39 -0
- package/dist/src/engine/value.js +80 -0
- package/dist/src/engine/viewState.js +187 -0
- package/package.json +40 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Boddepalli Naveen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# querylens
|
|
2
|
+
|
|
3
|
+
Trace a SQL query through [QueryLens](https://query-lens.1234naveenboddepalli.workers.dev)'s toy database engine, on
|
|
4
|
+
the terminal. Every step the engine takes is printed, one line each: parse, plan, optimize, the buffer pool's hits
|
|
5
|
+
and misses, the B+Tree's descent, and the result.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npx querylens "SELECT * FROM users WHERE id = 5"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
querylens — trace a SQL query through the QueryLens engine
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
querylens "<SQL>" [options]
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
--policy <name> buffer replacement policy: lru, clock, fifo, second-chance, optimal, midpoint, lru-k, two-q (default: lru)
|
|
19
|
+
--dataset <name> preset table: users, orders, logins, employees, transactions (default: users)
|
|
20
|
+
--json emit the trace as JSON instead of text
|
|
21
|
+
--no-color disable ANSI colour (also respects the NO_COLOR env var)
|
|
22
|
+
-h, --help show this help
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
It runs the same engine as the website, with no network access and nothing else to install. Its numbers are true to
|
|
26
|
+
this engine, and not to PostgreSQL, MySQL or any production system.
|
|
27
|
+
|
|
28
|
+
## Stability
|
|
29
|
+
|
|
30
|
+
This is a `0.x` release. The flags and the `--json` trace shape may change between minor versions; pin an exact
|
|
31
|
+
version if a script parses the output.
|
|
32
|
+
|
|
33
|
+
## Licence
|
|
34
|
+
|
|
35
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `querylens` — trace a SQL query through the QueryLens engine, on the terminal.
|
|
4
|
+
*
|
|
5
|
+
* plan.md §22.8: the engine is a pure, synchronous, React-free function
|
|
6
|
+
* (`runQuery`), so a CLI is a thin wrapper over it. This replaces the old
|
|
7
|
+
* `scripts/trace.ts` debug dumper with a packaged shape: named flags, the three
|
|
8
|
+
* preset datasets (§22.7), a `--json` mode for scripting, and `NO_COLOR` support.
|
|
9
|
+
* `pnpm trace` and `pnpm querylens` both run it.
|
|
10
|
+
*
|
|
11
|
+
* querylens "SELECT * FROM users WHERE id = 5"
|
|
12
|
+
* querylens "SELECT * FROM orders WHERE total > 500" --dataset orders --policy clock
|
|
13
|
+
* querylens "SELECT * FROM users WHERE age > 30" --json
|
|
14
|
+
*
|
|
15
|
+
* Publishing it to npm (a `dist/` build so it runs without --experimental-strip-types,
|
|
16
|
+
* a `files` allow-list, `npm publish`) is the one remaining step and a deliberate
|
|
17
|
+
* one — this file is the whole implementation.
|
|
18
|
+
*/
|
|
19
|
+
import { realpathSync } from 'node:fs';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { runQuery, DATASETS, DATASET_IDS, DEFAULT_ENGINE_OPTIONS, REPLACEMENT_POLICY_NAMES, STAGES, } from "../src/engine/index.js";
|
|
22
|
+
const HELP = `querylens — trace a SQL query through the QueryLens engine
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
querylens "<SQL>" [options]
|
|
26
|
+
|
|
27
|
+
Options:
|
|
28
|
+
--policy <name> buffer replacement policy: ${REPLACEMENT_POLICY_NAMES.join(', ')} (default: lru)
|
|
29
|
+
--dataset <name> preset table: ${DATASET_IDS.join(', ')} (default: users)
|
|
30
|
+
--json emit the trace as JSON instead of text
|
|
31
|
+
--no-color disable ANSI colour (also respects the NO_COLOR env var)
|
|
32
|
+
-h, --help show this help
|
|
33
|
+
|
|
34
|
+
Examples:
|
|
35
|
+
querylens "SELECT * FROM users WHERE id = 5"
|
|
36
|
+
querylens "SELECT * FROM orders WHERE total > 500" --dataset orders --policy clock
|
|
37
|
+
querylens "SELECT * FROM users WHERE age > 30" --json
|
|
38
|
+
`;
|
|
39
|
+
export function parseArgs(argv, env = {}, isTTY = false) {
|
|
40
|
+
if (argv.length === 0 || argv.includes('-h') || argv.includes('--help')) {
|
|
41
|
+
return { kind: 'help', text: HELP };
|
|
42
|
+
}
|
|
43
|
+
const positionals = [];
|
|
44
|
+
let policy = 'lru';
|
|
45
|
+
let dataset = 'users';
|
|
46
|
+
let json = false;
|
|
47
|
+
// Colour only when attached to a terminal and NO_COLOR is not set.
|
|
48
|
+
let color = isTTY && (env.NO_COLOR === undefined || env.NO_COLOR === '');
|
|
49
|
+
for (let i = 0; i < argv.length; i++) {
|
|
50
|
+
const arg = argv[i];
|
|
51
|
+
if (arg === '--json') {
|
|
52
|
+
json = true;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (arg === '--no-color') {
|
|
56
|
+
color = false;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (arg === '--policy' || arg === '--dataset') {
|
|
60
|
+
const value = argv[++i];
|
|
61
|
+
if (value === undefined)
|
|
62
|
+
return { kind: 'error', message: `${arg} needs a value.` };
|
|
63
|
+
if (arg === '--policy') {
|
|
64
|
+
if (!REPLACEMENT_POLICY_NAMES.includes(value)) {
|
|
65
|
+
return { kind: 'error', message: `Unknown policy "${value}". One of: ${REPLACEMENT_POLICY_NAMES.join(', ')}.` };
|
|
66
|
+
}
|
|
67
|
+
policy = value;
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
if (!DATASET_IDS.includes(value)) {
|
|
71
|
+
return { kind: 'error', message: `Unknown dataset "${value}". One of: ${DATASET_IDS.join(', ')}.` };
|
|
72
|
+
}
|
|
73
|
+
dataset = value;
|
|
74
|
+
}
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (arg.startsWith('--'))
|
|
78
|
+
return { kind: 'error', message: `Unknown option "${arg}". Try --help.` };
|
|
79
|
+
positionals.push(arg);
|
|
80
|
+
}
|
|
81
|
+
if (positionals.length === 0)
|
|
82
|
+
return { kind: 'error', message: 'No SQL query given. Try --help.' };
|
|
83
|
+
if (positionals.length > 1) {
|
|
84
|
+
return { kind: 'error', message: 'Give the query as a single quoted argument.' };
|
|
85
|
+
}
|
|
86
|
+
return { kind: 'run', options: { sql: positionals[0], policy, dataset, json, color } };
|
|
87
|
+
}
|
|
88
|
+
/* -------------------------------------------------------------------------- */
|
|
89
|
+
/* Rendering */
|
|
90
|
+
/* -------------------------------------------------------------------------- */
|
|
91
|
+
const STAGE_COLOUR = {
|
|
92
|
+
parse: '36', plan: '35', optimize: '95', execute: '33',
|
|
93
|
+
buffer: '32', index: '94', lock: '91', result: '96',
|
|
94
|
+
};
|
|
95
|
+
function paint(on) {
|
|
96
|
+
return (code, s) => (on ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
97
|
+
}
|
|
98
|
+
/** The one detail per event worth seeing at a glance. */
|
|
99
|
+
function detail(event) {
|
|
100
|
+
switch (event.stage) {
|
|
101
|
+
case 'parse': return `ast(${String(countNodes(event.ast))} nodes)`;
|
|
102
|
+
case 'plan': return planShape(event.planTree);
|
|
103
|
+
case 'optimize': return `${event.rule}: ${planShape(event.before)} -> ${planShape(event.after)}`;
|
|
104
|
+
case 'execute': return `${event.op} -> ${String(event.rowsProduced)} rows`;
|
|
105
|
+
case 'buffer':
|
|
106
|
+
if (event.action === 'sweep')
|
|
107
|
+
return `sweep frame ${String(event.frameId)} -> usage ${String(event.usageCountAfter)}`;
|
|
108
|
+
if (event.action === 'evict')
|
|
109
|
+
return `evict page ${String(event.victimPageId)} from frame ${String(event.frameId)}`;
|
|
110
|
+
if (event.action === 'miss')
|
|
111
|
+
return `miss page ${String(event.pageId)}`;
|
|
112
|
+
return `${event.action} page ${String(event.pageId)} @ frame ${String(event.frameId)}`;
|
|
113
|
+
case 'index':
|
|
114
|
+
return event.action === 'open'
|
|
115
|
+
? `open (height ${String(event.height)})`
|
|
116
|
+
: event.action === 'chain-next'
|
|
117
|
+
? `chain-next ${event.fromNodeId} -> ${event.toNodeId}`
|
|
118
|
+
: `${event.action} ${event.nodeId}`;
|
|
119
|
+
case 'lock': return `${event.action} page ${String(event.pageId)} (${event.mode})`;
|
|
120
|
+
case 'result': return `${String(event.rows.length)} rows`;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function countNodes(node) {
|
|
124
|
+
const kids = (node.children ?? []);
|
|
125
|
+
return 1 + kids.reduce((n, c) => n + countNodes(c), 0);
|
|
126
|
+
}
|
|
127
|
+
function planShape(node) {
|
|
128
|
+
const kids = (node.children ?? []);
|
|
129
|
+
return kids.length === 0 ? node.op : `${node.op}(${kids.map(planShape).join(',')})`;
|
|
130
|
+
}
|
|
131
|
+
function summary(result) {
|
|
132
|
+
const count = (fn) => result.events.filter(fn).length;
|
|
133
|
+
return {
|
|
134
|
+
stages: STAGES.filter((s) => result.events.some((e) => e.stage === s)),
|
|
135
|
+
events: result.events.length,
|
|
136
|
+
diskReads: count((e) => e.stage === 'buffer' && e.action === 'load'),
|
|
137
|
+
hits: count((e) => e.stage === 'buffer' && e.action === 'hit'),
|
|
138
|
+
evictions: count((e) => e.stage === 'buffer' && e.action === 'evict'),
|
|
139
|
+
prompts: count((e) => e.predictable !== undefined),
|
|
140
|
+
rows: result.rows.length,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
export function renderText(opts, result) {
|
|
144
|
+
const c = paint(opts.color);
|
|
145
|
+
const head = `${c('1', opts.sql.replace(/\s+/g, ' ').trim())} ${c('2', '·')} ${opts.policy} ${c('2', '·')} ${opts.dataset}`;
|
|
146
|
+
if (result.error) {
|
|
147
|
+
const lines = [head, '', c('91', `error ${result.error.message}`)];
|
|
148
|
+
if (result.error.hint)
|
|
149
|
+
lines.push(c('2', ` ${result.error.hint}`));
|
|
150
|
+
return lines.join('\n');
|
|
151
|
+
}
|
|
152
|
+
const body = result.events.map((e) => {
|
|
153
|
+
const tag = c(STAGE_COLOUR[e.stage] ?? '0', e.stage.padEnd(8));
|
|
154
|
+
const gate = e.predictable ? ` ${c('95', '[predict]')}` : '';
|
|
155
|
+
return ` ${c('2', String(e.id).padStart(3))} ${tag} ${detail(e).padEnd(40)}${gate}\n ${c('2', e.label)}`;
|
|
156
|
+
});
|
|
157
|
+
const s = summary(result);
|
|
158
|
+
const foot = ` ${s.stages.length} stages · ${s.events} events · ${s.diskReads} disk reads · ` +
|
|
159
|
+
`${s.hits} hits · ${s.evictions} evictions · ${s.prompts} prompts · ` +
|
|
160
|
+
`${s.rows} row${s.rows === 1 ? '' : 's'}`;
|
|
161
|
+
return [head, '', ...body, '', c('1', foot)].join('\n');
|
|
162
|
+
}
|
|
163
|
+
export function renderJson(opts, result) {
|
|
164
|
+
return JSON.stringify({
|
|
165
|
+
sql: opts.sql,
|
|
166
|
+
policy: opts.policy,
|
|
167
|
+
dataset: opts.dataset,
|
|
168
|
+
...(result.error ? { error: result.error } : { rows: result.rows, summary: summary(result), events: result.events }),
|
|
169
|
+
}, null, 2);
|
|
170
|
+
}
|
|
171
|
+
/* -------------------------------------------------------------------------- */
|
|
172
|
+
/* Entry point */
|
|
173
|
+
/* -------------------------------------------------------------------------- */
|
|
174
|
+
/** Runs the CLI. Returns the process exit code; does the I/O itself. */
|
|
175
|
+
export function main(argv, out = (s) => process.stdout.write(`${s}\n`), err = (s) => process.stderr.write(`${s}\n`)) {
|
|
176
|
+
const parsed = parseArgs(argv, process.env, process.stdout.isTTY === true);
|
|
177
|
+
if (parsed.kind === 'help') {
|
|
178
|
+
out(parsed.text);
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
181
|
+
if (parsed.kind === 'error') {
|
|
182
|
+
err(parsed.message);
|
|
183
|
+
return 2;
|
|
184
|
+
}
|
|
185
|
+
const { options } = parsed;
|
|
186
|
+
const result = runQuery(options.sql, DATASETS[options.dataset].make(), {
|
|
187
|
+
...DEFAULT_ENGINE_OPTIONS,
|
|
188
|
+
policy: options.policy,
|
|
189
|
+
});
|
|
190
|
+
out(options.json ? renderJson(options, result) : renderText(options, result));
|
|
191
|
+
return result.error ? 1 : 0;
|
|
192
|
+
}
|
|
193
|
+
/** True when this module is the program's entry point. npm runs a bin through a symlink, so the path is resolved first. */
|
|
194
|
+
function invokedDirectly(moduleUrl) {
|
|
195
|
+
const entry = process.argv[1];
|
|
196
|
+
if (entry === undefined)
|
|
197
|
+
return false;
|
|
198
|
+
try {
|
|
199
|
+
return realpathSync(entry) === fileURLToPath(moduleUrl);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// Only run when invoked directly, not when imported by a test.
|
|
206
|
+
if (invokedDirectly(import.meta.url)) {
|
|
207
|
+
process.exit(main(process.argv.slice(2)));
|
|
208
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A buffer pool trace driven by a bare page-reference string, with no SQL, no
|
|
3
|
+
* table and no index — the seam plan.md §20.2 #2 calls for and §21.4's tools
|
|
4
|
+
* catalogue needs.
|
|
5
|
+
*
|
|
6
|
+
* `createBufferPool` (storage/bufferPool.ts) already has no dependency on a
|
|
7
|
+
* heap or an index: it manages frames and takes page ids. `runQuery` is one
|
|
8
|
+
* caller of it; this is another, so the OS-course buffer-pool tool (§20.4) and
|
|
9
|
+
* the LRU/clock comparison view (§13, §21.3) render through the exact same
|
|
10
|
+
* `BufferPoolPanel` and the exact same event-folding (`createTraceProjector`)
|
|
11
|
+
* as the full pipeline — nothing about the panel or the fold is SQL-specific.
|
|
12
|
+
*/
|
|
13
|
+
import { createTracer } from "./trace.js";
|
|
14
|
+
import { createBufferPool } from "./storage/index.js";
|
|
15
|
+
import { DEFAULT_ENGINE_OPTIONS } from "./types.js";
|
|
16
|
+
/** Same cap the URL layers use elsewhere: a hostile or pasted string should
|
|
17
|
+
* not be able to hang the tab. Not a security boundary — there is nothing to
|
|
18
|
+
* inject — just a size limit on user-typed input. */
|
|
19
|
+
export const MAX_REFERENCES = 500;
|
|
20
|
+
/**
|
|
21
|
+
* Runs a page-reference string through a buffer pool and returns the full
|
|
22
|
+
* event trace. Pure and synchronous, same as `runQuery` (plan.md §5).
|
|
23
|
+
*/
|
|
24
|
+
export function runBufferTrace(referenceString, options = DEFAULT_ENGINE_OPTIONS) {
|
|
25
|
+
const refs = referenceString.slice(0, MAX_REFERENCES);
|
|
26
|
+
const { emit, drain } = createTracer();
|
|
27
|
+
const pageCount = refs.length === 0 ? 1 : Math.max(...refs) + 1;
|
|
28
|
+
// The whole string is the lookahead the `optimal` policy needs; the other
|
|
29
|
+
// policies are handed it and ignore it.
|
|
30
|
+
const pool = createBufferPool(pageCount, options, emit, refs);
|
|
31
|
+
const accessBoundaries = [];
|
|
32
|
+
for (const pageId of refs) {
|
|
33
|
+
// `fetch` pins the frame it resolves to, the same as a live operator
|
|
34
|
+
// holding a page while it reads it (locks.test.ts covers that half). A
|
|
35
|
+
// bare reference string has no operator to release it, so the access is
|
|
36
|
+
// modelled as instantaneous: unpin immediately, or every frame is "in use"
|
|
37
|
+
// forever and the second distinct page onward can never find a victim.
|
|
38
|
+
const result = pool.fetch(pageId);
|
|
39
|
+
if (result)
|
|
40
|
+
pool.unpin(result.frameId);
|
|
41
|
+
accessBoundaries.push(drain().length);
|
|
42
|
+
}
|
|
43
|
+
return { events: drain(), accessBoundaries };
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Parses free-typed input ("1 2 3, 4 5") into page ids, dropping anything
|
|
47
|
+
* that is not a non-negative integer rather than throwing — this is typed
|
|
48
|
+
* input from the one person using the tool, but the parse still has to be
|
|
49
|
+
* total, the same discipline `urlState.ts` applies to a stranger's link.
|
|
50
|
+
*/
|
|
51
|
+
export function parseReferenceString(input) {
|
|
52
|
+
return input
|
|
53
|
+
.split(/[\s,]+/)
|
|
54
|
+
.map((token) => token.trim())
|
|
55
|
+
.filter((token) => token.length > 0)
|
|
56
|
+
.map(Number)
|
|
57
|
+
.filter((n) => Number.isInteger(n) && n >= 0)
|
|
58
|
+
.slice(0, MAX_REFERENCES);
|
|
59
|
+
}
|
|
60
|
+
/** A reference string with real locality — repeats and revisits — rather than
|
|
61
|
+
* uniform noise, so a generated example actually produces hits, not just misses. */
|
|
62
|
+
export function randomReferenceString(length, distinctPages) {
|
|
63
|
+
const hot = Math.max(1, Math.round(distinctPages * 0.4));
|
|
64
|
+
return Array.from({ length }, () => Math.random() < 0.7
|
|
65
|
+
? Math.floor(Math.random() * hot)
|
|
66
|
+
: Math.floor(Math.random() * distinctPages));
|
|
67
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { createSeedDatabase } from "./seed.js";
|
|
2
|
+
export const DEFAULT_DATASET = 'users';
|
|
3
|
+
export const DATASET_IDS = ['users', 'orders', 'logins', 'employees', 'transactions'];
|
|
4
|
+
export function isDatasetId(value) {
|
|
5
|
+
return typeof value === 'string' && DATASET_IDS.includes(value);
|
|
6
|
+
}
|
|
7
|
+
const ORDER_CUSTOMERS = [
|
|
8
|
+
'acme', 'globex', 'initech', 'umbrella', 'soylent', 'hooli', 'stark', 'wayne',
|
|
9
|
+
'wonka', 'tyrell', 'cyberdyne', 'aperture',
|
|
10
|
+
];
|
|
11
|
+
const ORDER_STATUS = ['paid', 'pending', 'refunded'];
|
|
12
|
+
function orderRows() {
|
|
13
|
+
return Array.from({ length: 120 }, (_, i) => ({
|
|
14
|
+
id: i + 1,
|
|
15
|
+
customer: ORDER_CUSTOMERS[i % ORDER_CUSTOMERS.length],
|
|
16
|
+
total: 50 + ((i * 37) % 950),
|
|
17
|
+
status: ORDER_STATUS[i % 3],
|
|
18
|
+
}));
|
|
19
|
+
}
|
|
20
|
+
const LOGIN_USERS = [
|
|
21
|
+
'ada', 'brendan', 'carmack', 'dijkstra', 'grace', 'guido', 'hopper', 'ken',
|
|
22
|
+
'knuth', 'lamport', 'linus', 'matz', 'radia', 'ritchie', 'turing', 'yukihiro',
|
|
23
|
+
];
|
|
24
|
+
function loginRows() {
|
|
25
|
+
return Array.from({ length: 80 }, (_, i) => {
|
|
26
|
+
// 1 in 9 is blocked — the deliberate skew: `status = 'blocked'` is a
|
|
27
|
+
// handful of rows after a full scan, `status = 'ok'` is nearly all of them.
|
|
28
|
+
const blocked = i % 9 === 0;
|
|
29
|
+
return {
|
|
30
|
+
id: i + 1,
|
|
31
|
+
user: LOGIN_USERS[i % LOGIN_USERS.length],
|
|
32
|
+
status: blocked ? 'blocked' : 'ok',
|
|
33
|
+
attempts: blocked ? 3 + (i % 5) : 1,
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const DEPARTMENTS = [
|
|
38
|
+
'engineering', 'sales', 'marketing', 'support', 'finance', 'legal', 'design', 'product',
|
|
39
|
+
'people', 'security', 'data', 'research', 'platform', 'growth', 'success', 'partnerships',
|
|
40
|
+
'operations', 'recruiting', 'it', 'facilities', 'compliance', 'strategy', 'brand',
|
|
41
|
+
'localization', 'devrel',
|
|
42
|
+
];
|
|
43
|
+
const EMPLOYEES_PER_DEPARTMENT = 10;
|
|
44
|
+
/**
|
|
45
|
+
* `department` and `office` are perfectly correlated: every department occupies exactly one office, the way a real
|
|
46
|
+
* team often does. That is exactly the independence assumption `AND` selectivity makes (plan.md §25.4 C2), and
|
|
47
|
+
* exactly what `ANALYZE`'s own per-column statistics (plan.md §25.4 C1) cannot see — nothing there counts how two
|
|
48
|
+
* columns co-occur, only how each behaves alone: `department = 'sales' AND office = 'building-2'` is no narrower
|
|
49
|
+
* than `department = 'sales'` by itself, since office adds no information at all, but the optimizer's multiplied
|
|
50
|
+
* fractions assume it does — badly underestimating (RESEARCH.md §41: Leis et al. found real optimizers off by an
|
|
51
|
+
* order of magnitude or more from exactly this).
|
|
52
|
+
*/
|
|
53
|
+
function employeeRows() {
|
|
54
|
+
return DEPARTMENTS.flatMap((department, d) => Array.from({ length: EMPLOYEES_PER_DEPARTMENT }, (_, i) => ({
|
|
55
|
+
id: d * EMPLOYEES_PER_DEPARTMENT + i + 1,
|
|
56
|
+
department,
|
|
57
|
+
office: `building-${String(d + 1)}`,
|
|
58
|
+
salary: 60000 + ((d * EMPLOYEES_PER_DEPARTMENT + i) * 733) % 80000,
|
|
59
|
+
})));
|
|
60
|
+
}
|
|
61
|
+
const TRANSACTION_COUNT = 3000;
|
|
62
|
+
const TRANSACTION_ACCOUNTS = Array.from({ length: 40 }, (_, i) => `acct-${String(i + 1).padStart(2, '0')}`);
|
|
63
|
+
/**
|
|
64
|
+
* 3,000 rows: a genuinely taller B+Tree than any other preset (height 7, measured against `id` with this
|
|
65
|
+
* engine's real `maxKeys = 4` — `employees`'s 250 rows only reaches height 5) and 750 heap pages against the
|
|
66
|
+
* default 8 buffer frames, so a scan that touches most of the table evicts hard enough to be dramatic, not
|
|
67
|
+
* token. `amount` is deliberately unindexed and spans the whole table uniformly, so `amount > 4500` (~10%
|
|
68
|
+
* selectivity) still has to read every one of those 750 pages — `account` is likewise unindexed, 40 repeating
|
|
69
|
+
* values, for the same "an equality still costs a full scan" point a bigger table makes more vivid. Sized by a
|
|
70
|
+
* throwaway probe script measuring the real `height()`/heap-page count across 500–10,000 rows, not estimated
|
|
71
|
+
* (plan.md's own "measure, don't guess") — 3,000 was the smallest size that still reached height 7.
|
|
72
|
+
*/
|
|
73
|
+
function transactionRows() {
|
|
74
|
+
return Array.from({ length: TRANSACTION_COUNT }, (_, i) => ({
|
|
75
|
+
id: i + 1,
|
|
76
|
+
account: TRANSACTION_ACCOUNTS[i % TRANSACTION_ACCOUNTS.length],
|
|
77
|
+
amount: 10 + ((i * 97) % 4990),
|
|
78
|
+
status: i % 5 === 0 ? 'failed' : 'settled',
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
export const DATASETS = {
|
|
82
|
+
users: { id: 'users', table: 'users', make: createSeedDatabase },
|
|
83
|
+
transactions: {
|
|
84
|
+
id: 'transactions',
|
|
85
|
+
table: 'transactions',
|
|
86
|
+
make: () => ({
|
|
87
|
+
tables: {
|
|
88
|
+
transactions: {
|
|
89
|
+
name: 'transactions',
|
|
90
|
+
columns: ['id', 'account', 'amount', 'status'],
|
|
91
|
+
rows: transactionRows(),
|
|
92
|
+
indexedColumns: ['id'],
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
}),
|
|
96
|
+
},
|
|
97
|
+
employees: {
|
|
98
|
+
id: 'employees',
|
|
99
|
+
table: 'employees',
|
|
100
|
+
make: () => ({
|
|
101
|
+
tables: {
|
|
102
|
+
employees: {
|
|
103
|
+
name: 'employees',
|
|
104
|
+
columns: ['id', 'department', 'office', 'salary'],
|
|
105
|
+
rows: employeeRows(),
|
|
106
|
+
indexedColumns: ['id'],
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
}),
|
|
110
|
+
},
|
|
111
|
+
orders: {
|
|
112
|
+
id: 'orders',
|
|
113
|
+
table: 'orders',
|
|
114
|
+
make: () => ({
|
|
115
|
+
tables: {
|
|
116
|
+
orders: {
|
|
117
|
+
name: 'orders',
|
|
118
|
+
columns: ['id', 'customer', 'total', 'status'],
|
|
119
|
+
rows: orderRows(),
|
|
120
|
+
indexedColumns: ['id'],
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
}),
|
|
124
|
+
},
|
|
125
|
+
logins: {
|
|
126
|
+
id: 'logins',
|
|
127
|
+
table: 'logins',
|
|
128
|
+
make: () => ({
|
|
129
|
+
tables: {
|
|
130
|
+
logins: {
|
|
131
|
+
name: 'logins',
|
|
132
|
+
columns: ['id', 'user', 'status', 'attempts'],
|
|
133
|
+
rows: loginRows(),
|
|
134
|
+
indexedColumns: ['id'],
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
}),
|
|
138
|
+
},
|
|
139
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executes a `DELETE`'s access plan (`SeqScan` or `IndexScan`, from
|
|
3
|
+
* `emitDeletePlanEvents`) against the real heap and index: finds every
|
|
4
|
+
* matching row, drops its key from the B+Tree with the real `deleteKey`
|
|
5
|
+
* algorithm — real borrows, merges, root-collapses, narrated — and marks the
|
|
6
|
+
* heap pages it removed a row from dirty (plan.md §22.2, the write path).
|
|
7
|
+
*
|
|
8
|
+
* A `SELECT` streams rows through Volcano operators one at a time; a
|
|
9
|
+
* `DELETE` cannot — it has to find *every* match before it starts mutating
|
|
10
|
+
* anything, or a deletion mid-scan could shift what a later step reads. So
|
|
11
|
+
* this is its own pass rather than a reuse of `buildOperator`, though it
|
|
12
|
+
* narrates through the same `execute`-stage shape (`op: 'SeqScan'` /
|
|
13
|
+
* `'IndexScan'` as it finds matches, then `op: 'Delete'` as it removes them)
|
|
14
|
+
* so the Execute panel reads the same way a `SELECT`'s does.
|
|
15
|
+
*/
|
|
16
|
+
import { indexLookupMatches, indexRangeMatches } from "./writeScan.js";
|
|
17
|
+
import { passes } from "./evaluate.js";
|
|
18
|
+
import { deleteKey, entryFor, indexNameOf, indexSpecsOf, keyText, keyValuesOf } from "../index/index.js";
|
|
19
|
+
const DELETE = { name: 'DELETE', does: 'remove' };
|
|
20
|
+
/**
|
|
21
|
+
* One clause summarising what a B+Tree delete had to do beyond the plain
|
|
22
|
+
* removal. Exported: `exec/update.ts` reuses it for the "drop the old key"
|
|
23
|
+
* half of a reindex.
|
|
24
|
+
*/
|
|
25
|
+
export function summarizeSteps(steps) {
|
|
26
|
+
const rest = steps.slice(1); // steps[0] is always the leaf `remove` itself
|
|
27
|
+
if (rest.length === 0)
|
|
28
|
+
return 'the leaf stayed at least half full, so nothing else moved';
|
|
29
|
+
return `the tree also had to ${rest.map((s) => s.kind.replace('-', ' ')).join(', then ')}`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Removes the entry for the row at `pointer` from every index the table declares, skipping one whose leading column
|
|
33
|
+
* is NULL. An entry is the values *and* the row's address, so this drops exactly this row's entry even when other
|
|
34
|
+
* rows share its values.
|
|
35
|
+
*/
|
|
36
|
+
function deleteFromIndex(ctx, row, pointer) {
|
|
37
|
+
const details = [];
|
|
38
|
+
for (const spec of indexSpecsOf(ctx.table)) {
|
|
39
|
+
const name = indexNameOf(spec.columns);
|
|
40
|
+
const tree = ctx.trees[name];
|
|
41
|
+
if (!tree)
|
|
42
|
+
continue;
|
|
43
|
+
const values = keyValuesOf(row, spec.columns);
|
|
44
|
+
if (values[0] === null)
|
|
45
|
+
continue; // a NULL leading column was never indexed — nothing to remove
|
|
46
|
+
const { key } = entryFor(spec.columns, row, pointer, ctx.table.clusteredKey);
|
|
47
|
+
const result = deleteKey(tree, key);
|
|
48
|
+
if (!result.found)
|
|
49
|
+
continue; // defensive: every row here came from the heap this tree indexes
|
|
50
|
+
details.push(`The \`${name}\` B+Tree drops key ${keyText(values)} from leaf ${result.steps[0]?.node ?? '?'} — ${summarizeSteps(result.steps)}.`);
|
|
51
|
+
}
|
|
52
|
+
return details;
|
|
53
|
+
}
|
|
54
|
+
/** Every heap page, filtered by `filter` if present — the SeqScan half of a delete. */
|
|
55
|
+
function scanForMatches(ctx, filter) {
|
|
56
|
+
const matched = [];
|
|
57
|
+
let produced = 0;
|
|
58
|
+
for (const page of ctx.heap.pages) {
|
|
59
|
+
ctx.locks.acquire(page.pageId, 'exclusive', `Take an exclusive lock on page ${String(page.pageId)} — DELETE may remove a row from it, so a shared lock is not enough.`);
|
|
60
|
+
const fetched = ctx.pool.fetch(page.pageId);
|
|
61
|
+
if (fetched)
|
|
62
|
+
ctx.pool.unpin(fetched.frameId);
|
|
63
|
+
let touchedPage = false;
|
|
64
|
+
page.rows.forEach((row, slot) => {
|
|
65
|
+
if (!passes(filter, row))
|
|
66
|
+
return;
|
|
67
|
+
matched.push({ row, pointer: { pageId: page.pageId, slot } });
|
|
68
|
+
touchedPage = true;
|
|
69
|
+
produced += 1;
|
|
70
|
+
ctx.emit(`SeqScan finds a matching row on page ${page.pageId} — DELETE will remove it.`, {
|
|
71
|
+
stage: 'execute',
|
|
72
|
+
op: 'SeqScan',
|
|
73
|
+
rowsProduced: produced,
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
if (touchedPage)
|
|
77
|
+
ctx.pool.markDirty(page.pageId);
|
|
78
|
+
ctx.locks.release(page.pageId, 'exclusive');
|
|
79
|
+
}
|
|
80
|
+
return matched;
|
|
81
|
+
}
|
|
82
|
+
export function executeDelete(plan, ctx) {
|
|
83
|
+
const matched = plan.op === 'IndexScan'
|
|
84
|
+
? indexLookupMatches(ctx, plan, DELETE)
|
|
85
|
+
: plan.op === 'IndexRangeScan'
|
|
86
|
+
? indexRangeMatches(ctx, plan, DELETE)
|
|
87
|
+
: scanForMatches(ctx, plan.op === 'SeqScan' ? plan.filter : undefined);
|
|
88
|
+
let produced = 0;
|
|
89
|
+
for (const { row, pointer } of matched) {
|
|
90
|
+
const indexDetails = deleteFromIndex(ctx, row, pointer);
|
|
91
|
+
produced += 1;
|
|
92
|
+
ctx.emit(`Delete removes row ${JSON.stringify(row)}.${indexDetails.length > 0 ? ` ${indexDetails.join(' ')}` : ''}`, { stage: 'execute', op: 'Delete', rowsProduced: produced });
|
|
93
|
+
}
|
|
94
|
+
return { deleted: matched.map((m) => m.row) };
|
|
95
|
+
}
|