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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +35 -0
  3. package/dist/bin/querylens.js +208 -0
  4. package/dist/src/engine/bufferTrace.js +67 -0
  5. package/dist/src/engine/datasets.js +139 -0
  6. package/dist/src/engine/exec/delete.js +95 -0
  7. package/dist/src/engine/exec/evaluate.js +174 -0
  8. package/dist/src/engine/exec/index.js +4 -0
  9. package/dist/src/engine/exec/insert.js +75 -0
  10. package/dist/src/engine/exec/operators.js +1290 -0
  11. package/dist/src/engine/exec/run.js +35 -0
  12. package/dist/src/engine/exec/sort.js +171 -0
  13. package/dist/src/engine/exec/unique.js +79 -0
  14. package/dist/src/engine/exec/update.js +124 -0
  15. package/dist/src/engine/exec/writeScan.js +88 -0
  16. package/dist/src/engine/explain.js +114 -0
  17. package/dist/src/engine/index/btree.js +481 -0
  18. package/dist/src/engine/index/build.js +99 -0
  19. package/dist/src/engine/index/bulk.js +107 -0
  20. package/dist/src/engine/index/display.js +38 -0
  21. package/dist/src/engine/index/index.js +9 -0
  22. package/dist/src/engine/index/lookup.js +213 -0
  23. package/dist/src/engine/index/rangeLookup.js +158 -0
  24. package/dist/src/engine/index/spec.js +47 -0
  25. package/dist/src/engine/index/unique.js +31 -0
  26. package/dist/src/engine/index/validate.js +105 -0
  27. package/dist/src/engine/index.js +16 -0
  28. package/dist/src/engine/locks/index.js +1 -0
  29. package/dist/src/engine/locks/lockManager.js +46 -0
  30. package/dist/src/engine/parser/ast.js +77 -0
  31. package/dist/src/engine/parser/display.js +404 -0
  32. package/dist/src/engine/parser/index.js +4 -0
  33. package/dist/src/engine/parser/parser.js +1108 -0
  34. package/dist/src/engine/parser/print.js +74 -0
  35. package/dist/src/engine/parser/tokenizer.js +146 -0
  36. package/dist/src/engine/planner/buildPlan.js +208 -0
  37. package/dist/src/engine/planner/cost.js +582 -0
  38. package/dist/src/engine/planner/emit.js +267 -0
  39. package/dist/src/engine/planner/emitDelete.js +57 -0
  40. package/dist/src/engine/planner/emitUpdate.js +51 -0
  41. package/dist/src/engine/planner/index.js +8 -0
  42. package/dist/src/engine/planner/joinOrder.js +252 -0
  43. package/dist/src/engine/planner/optimize.js +906 -0
  44. package/dist/src/engine/planner/plan.js +445 -0
  45. package/dist/src/engine/predict.js +120 -0
  46. package/dist/src/engine/runQuery.js +393 -0
  47. package/dist/src/engine/seed.js +165 -0
  48. package/dist/src/engine/stats.js +118 -0
  49. package/dist/src/engine/storage/bufferPool.js +194 -0
  50. package/dist/src/engine/storage/index.js +3 -0
  51. package/dist/src/engine/storage/page.js +46 -0
  52. package/dist/src/engine/storage/policy.js +360 -0
  53. package/dist/src/engine/subquery.js +88 -0
  54. package/dist/src/engine/trace.js +17 -0
  55. package/dist/src/engine/types.js +39 -0
  56. package/dist/src/engine/value.js +80 -0
  57. package/dist/src/engine/viewState.js +187 -0
  58. package/package.json +40 -0
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Buffer replacement, behind one interface, with two implementations.
3
+ *
4
+ * This exists because **textbook LRU is not what real databases do**, and
5
+ * plan.md §2 makes honesty about ground truth a top-line goal. Postgres avoids
6
+ * an LRU list precisely because maintaining one needs a lock on every page
7
+ * access; it uses clock sweep instead. InnoDB uses LRU but inserts at a
8
+ * midpoint so a big scan cannot flush the hot set. Teaching "eviction = LRU"
9
+ * would plant exactly the misconception §12 warns about.
10
+ */
11
+ /** Postgres caps `usage_count` at 5. Same here. */
12
+ export const USAGE_COUNT_CAP = 5;
13
+ function frame(state, frameId) {
14
+ return state.frames.find((f) => f.frameId === frameId);
15
+ }
16
+ /* -------------------------------------------------------------------------- */
17
+ const lru = {
18
+ name: 'lru',
19
+ onAccess(state, frameId) {
20
+ const f = frame(state, frameId);
21
+ if (!f)
22
+ return;
23
+ state.now += 1;
24
+ f.lastUsedAt = state.now;
25
+ f.usageCount = Math.min(f.usageCount + 1, USAGE_COUNT_CAP);
26
+ },
27
+ selectVictim(state) {
28
+ let victim;
29
+ for (const f of state.frames) {
30
+ if (f.pinCount > 0)
31
+ continue;
32
+ if (!victim || f.lastUsedAt < victim.lastUsedAt)
33
+ victim = f;
34
+ }
35
+ return victim ? { frameId: victim.frameId } : null;
36
+ },
37
+ };
38
+ /**
39
+ * Postgres's clock sweep. The hand walks the frames in a circle; each pass
40
+ * decrements `usage_count`, and the first frame found with
41
+ * `refcount == 0 && usage_count == 0` is the victim. Frequently used pages
42
+ * survive several passes without any list to maintain — which is the whole
43
+ * point, since maintaining one would need a lock on every access.
44
+ */
45
+ const clock = {
46
+ name: 'clock',
47
+ onAccess(state, frameId) {
48
+ const f = frame(state, frameId);
49
+ if (!f)
50
+ return;
51
+ state.now += 1;
52
+ f.lastUsedAt = state.now;
53
+ f.usageCount = Math.min(f.usageCount + 1, USAGE_COUNT_CAP);
54
+ },
55
+ selectVictim(state, emit) {
56
+ const n = state.frames.length;
57
+ if (n === 0)
58
+ return null;
59
+ if (state.frames.every((f) => f.pinCount > 0))
60
+ return null;
61
+ // Bounded by construction: every full pass strictly decreases the total
62
+ // usage count of unpinned frames, and at least one is unpinned.
63
+ const maxSteps = n * (USAGE_COUNT_CAP + 1) + n;
64
+ for (let step = 0; step < maxSteps; step++) {
65
+ const f = state.frames[state.hand];
66
+ const at = state.hand;
67
+ state.hand = (state.hand + 1) % n;
68
+ if (f.pinCount > 0)
69
+ continue;
70
+ if (f.usageCount === 0)
71
+ return { frameId: f.frameId, clockHand: state.hand };
72
+ f.usageCount -= 1;
73
+ emit(`Clock sweep: frame ${at} was used recently, so it gets a second chance — usage count drops to ${f.usageCount} and the hand moves on.`, { stage: 'buffer', action: 'sweep', frameId: at, usageCountAfter: f.usageCount, clockHand: state.hand });
74
+ }
75
+ return null;
76
+ },
77
+ };
78
+ /* -------------------------------------------------------------------------- */
79
+ /**
80
+ * FIFO: evict whichever page has been resident longest, no matter how heavily
81
+ * it has been used since. The textbook baseline clock sweep improves on — and
82
+ * the one that shows Bélády's anomaly, where *adding* frames can add misses.
83
+ */
84
+ const fifo = {
85
+ name: 'fifo',
86
+ onAccess() {
87
+ // A page's age under FIFO is when it was loaded, not when it was touched.
88
+ },
89
+ selectVictim(state) {
90
+ let victim;
91
+ for (const f of state.frames) {
92
+ if (f.pinCount > 0)
93
+ continue;
94
+ if (!victim || f.loadedAt < victim.loadedAt)
95
+ victim = f;
96
+ }
97
+ return victim ? { frameId: victim.frameId } : null;
98
+ },
99
+ };
100
+ /**
101
+ * Second-chance: FIFO with a reference bit. Walk the queue oldest-first; a
102
+ * frame whose bit is set is forgiven once — the bit clears and it goes to the
103
+ * back — so the first frame reached with a clear bit is the victim. This is
104
+ * clock sweep's ancestor: the same idea before the circular hand and the
105
+ * multi-bit counter. The forgiveness passes emit `sweep` events, the same kind
106
+ * clock uses, so the panel animates them identically.
107
+ */
108
+ const secondChance = {
109
+ name: 'second-chance',
110
+ onAccess(state, frameId) {
111
+ const f = frame(state, frameId);
112
+ if (!f)
113
+ return;
114
+ state.now += 1;
115
+ f.lastUsedAt = state.now;
116
+ f.usageCount = Math.min(f.usageCount + 1, USAGE_COUNT_CAP);
117
+ },
118
+ selectVictim(state, emit) {
119
+ if (state.frames.every((f) => f.pinCount > 0))
120
+ return null;
121
+ // Every forgiveness clears one bit and cannot recur for that frame in the
122
+ // same call, so a clear bit is reached within one pass over the unpinned
123
+ // frames. The bound doubles that for headroom, matching `clock` above.
124
+ const unpinned = state.frames.filter((f) => f.pinCount === 0).length;
125
+ const maxSteps = unpinned * 2 + 1;
126
+ for (let step = 0; step < maxSteps; step++) {
127
+ let oldest;
128
+ for (const f of state.frames) {
129
+ if (f.pinCount > 0)
130
+ continue;
131
+ if (!oldest || f.loadedAt < oldest.loadedAt)
132
+ oldest = f;
133
+ }
134
+ if (!oldest)
135
+ return null;
136
+ if (oldest.usageCount === 0)
137
+ return { frameId: oldest.frameId };
138
+ oldest.usageCount = 0;
139
+ state.now += 1;
140
+ oldest.loadedAt = state.now; // to the back of the queue
141
+ emit(`Second chance: frame ${oldest.frameId} was used since it loaded, so it survives this pass — its reference bit clears and it moves to the back of the queue.`, {
142
+ stage: 'buffer',
143
+ action: 'sweep',
144
+ frameId: oldest.frameId,
145
+ usageCountAfter: 0,
146
+ clockHand: state.hand,
147
+ });
148
+ }
149
+ return null;
150
+ },
151
+ };
152
+ /**
153
+ * Bélády's MIN / OPT: evict the resident page whose next use is farthest in
154
+ * the future (a page never used again wins outright). It cannot be implemented
155
+ * for real — nothing knows the future — but it is the optimal bound every
156
+ * other policy is measured against, and seeing it makes "how good is LRU here?"
157
+ * concrete. With no `lookahead` on the state it degrades to FIFO.
158
+ */
159
+ function nextUseDistance(future, cursor, pageId) {
160
+ if (!future)
161
+ return Number.POSITIVE_INFINITY;
162
+ for (let k = cursor + 1; k < future.length; k++) {
163
+ if (future[k] === pageId)
164
+ return k;
165
+ }
166
+ return Number.POSITIVE_INFINITY;
167
+ }
168
+ const optimal = {
169
+ name: 'optimal',
170
+ onAccess() {
171
+ // MIN needs no per-access bookkeeping — the whole decision is the future.
172
+ },
173
+ selectVictim(state) {
174
+ let victim;
175
+ let victimNextUse = -1;
176
+ for (const f of state.frames) {
177
+ if (f.pinCount > 0 || f.pageId === null)
178
+ continue;
179
+ const nextUse = nextUseDistance(state.lookahead, state.lookaheadCursor, f.pageId);
180
+ // Farthest next use wins; on a tie (typically two pages never used
181
+ // again) evict the one loaded earliest, which makes the no-lookahead
182
+ // fallback exactly FIFO.
183
+ if (nextUse > victimNextUse ||
184
+ (nextUse === victimNextUse && victim !== undefined && f.loadedAt < victim.loadedAt)) {
185
+ victim = f;
186
+ victimNextUse = nextUse;
187
+ }
188
+ }
189
+ return victim ? { frameId: victim.frameId } : null;
190
+ },
191
+ };
192
+ /**
193
+ * InnoDB's midpoint-insertion LRU. The buffer pool's LRU list is split into a
194
+ * *young* (hot) sublist and an *old* sublist; a page read from disk lands at
195
+ * the head of the *old* sublist, and only reaches the young sublist if it is
196
+ * touched again. A sequential scan touches each page once, so its pages ride
197
+ * through the old sublist and evict one another without ever displacing the
198
+ * hot set — the flaw in plain LRU that this exists to fix (plan.md §7.5).
199
+ *
200
+ * Modelled without InnoDB's `innodb_old_blocks_time` delay: `usageCount` is a
201
+ * 1-bit "which sublist" flag — 0 = old, 1 = young — and one re-access promotes.
202
+ * Eviction takes the least-recently-used frame in the old sublist, falling
203
+ * back to the overall LRU frame only when every unpinned frame is young.
204
+ */
205
+ const midpoint = {
206
+ onLoad(state, frameId) {
207
+ const f = frame(state, frameId);
208
+ if (!f)
209
+ return;
210
+ state.now += 1;
211
+ f.lastUsedAt = state.now; // recency, but it stays in the old sublist
212
+ },
213
+ name: 'midpoint',
214
+ onAccess(state, frameId) {
215
+ const f = frame(state, frameId);
216
+ if (!f)
217
+ return;
218
+ state.now += 1;
219
+ f.lastUsedAt = state.now;
220
+ f.usageCount = 1; // a re-access promotes it to the young sublist
221
+ },
222
+ selectVictim(state) {
223
+ let old;
224
+ let overall;
225
+ for (const f of state.frames) {
226
+ if (f.pinCount > 0)
227
+ continue;
228
+ if (!overall || f.lastUsedAt < overall.lastUsedAt)
229
+ overall = f;
230
+ if (f.usageCount === 0 && (!old || f.lastUsedAt < old.lastUsedAt))
231
+ old = f;
232
+ }
233
+ const victim = old ?? overall;
234
+ return victim ? { frameId: victim.frameId } : null;
235
+ },
236
+ };
237
+ /**
238
+ * LRU-K with K = 2 (O'Neil, O'Neil and Weikum, 1993). Plain LRU evicts whatever was touched least recently, so a
239
+ * single sequential scan can push out the pages that were genuinely hot. LRU-K evicts by the time of a page's K-th
240
+ * most recent reference instead. A page referenced only once has no K-th reference at all, so it is the first to go,
241
+ * and a page that has been touched twice survives a one-pass scan. That is the scan resistance this policy exists for.
242
+ */
243
+ const LRU_K = 2;
244
+ const lruK = {
245
+ name: 'lru-k',
246
+ onAccess(state, frameId) {
247
+ const f = frame(state, frameId);
248
+ if (!f)
249
+ return;
250
+ state.now += 1;
251
+ f.lastUsedAt = state.now;
252
+ f.usageCount = Math.min(f.usageCount + 1, USAGE_COUNT_CAP);
253
+ if (f.pageId === null)
254
+ return;
255
+ const history = (state.history ??= {});
256
+ const times = [...(history[f.pageId] ?? []), state.now].slice(-LRU_K);
257
+ history[f.pageId] = times;
258
+ },
259
+ selectVictim(state) {
260
+ let victim;
261
+ let victimKth = Number.POSITIVE_INFINITY;
262
+ for (const f of state.frames) {
263
+ if (f.pinCount > 0)
264
+ continue;
265
+ const times = f.pageId === null ? [] : (state.history?.[f.pageId] ?? []);
266
+ // A page with fewer than K references has no K-th reference: treat it as older than anything, so it goes first.
267
+ const kth = times.length >= LRU_K ? times[0] : Number.NEGATIVE_INFINITY;
268
+ if (!victim ||
269
+ kth < victimKth ||
270
+ (kth === victimKth && f.lastUsedAt < victim.lastUsedAt)) {
271
+ victim = f;
272
+ victimKth = kth;
273
+ }
274
+ }
275
+ return victim ? { frameId: victim.frameId } : null;
276
+ },
277
+ };
278
+ /**
279
+ * 2Q (Johnson and Shasha, 1994). A page read once waits in a small FIFO, A1in. Only a page read again after it has
280
+ * left that FIFO, its id remembered in the ghost list, is promoted to the LRU of hot pages, Am. A one-pass scan
281
+ * therefore fills A1in and evicts only itself. A1in holds a quarter of the frames, the ghost list half as many ids.
282
+ */
283
+ const twoQ = {
284
+ name: 'two-q',
285
+ onAccess(state, frameId) {
286
+ const f = frame(state, frameId);
287
+ if (!f)
288
+ return;
289
+ // A re-reference while the page is still in A1in does not promote it: the paper's correlated-reference rule.
290
+ if (state.queues?.[frameId] === 'a1in')
291
+ return;
292
+ state.now += 1;
293
+ f.lastUsedAt = state.now;
294
+ f.usageCount = Math.min(f.usageCount + 1, USAGE_COUNT_CAP);
295
+ },
296
+ onLoad(state, frameId) {
297
+ const f = frame(state, frameId);
298
+ if (!f)
299
+ return;
300
+ state.now += 1;
301
+ f.lastUsedAt = state.now;
302
+ f.usageCount = Math.min(f.usageCount + 1, USAGE_COUNT_CAP);
303
+ const ghost = state.ghost ?? [];
304
+ const remembered = f.pageId !== null && ghost.includes(f.pageId);
305
+ if (remembered)
306
+ state.ghost = ghost.filter((p) => p !== f.pageId);
307
+ (state.queues ??= {})[frameId] = remembered ? 'am' : 'a1in';
308
+ },
309
+ selectVictim(state) {
310
+ const queues = state.queues ?? {};
311
+ const candidates = state.frames.filter((f) => f.pinCount === 0);
312
+ const oldestFirst = (a, b) => a.lastUsedAt - b.lastUsedAt;
313
+ const a1in = candidates.filter((f) => queues[f.frameId] === 'a1in').sort(oldestFirst);
314
+ const am = candidates.filter((f) => queues[f.frameId] !== 'a1in').sort(oldestFirst);
315
+ const kin = Math.max(1, Math.floor(state.frames.length / 4));
316
+ const victim = a1in.length > kin || am.length === 0 ? a1in[0] : am[0];
317
+ if (!victim)
318
+ return null;
319
+ if (queues[victim.frameId] === 'a1in' && victim.pageId !== null) {
320
+ const kout = Math.max(1, Math.floor(state.frames.length / 2));
321
+ state.ghost = [...(state.ghost ?? []), victim.pageId].slice(-kout);
322
+ }
323
+ return { frameId: victim.frameId };
324
+ },
325
+ };
326
+ const POLICIES = {
327
+ lru,
328
+ clock,
329
+ fifo,
330
+ 'second-chance': secondChance,
331
+ optimal,
332
+ midpoint,
333
+ 'lru-k': lruK,
334
+ 'two-q': twoQ,
335
+ };
336
+ export function policyFor(name) {
337
+ return POLICIES[name];
338
+ }
339
+ /** Display order for policy pickers. */
340
+ export const REPLACEMENT_POLICY_NAMES = [
341
+ 'lru',
342
+ 'clock',
343
+ 'fifo',
344
+ 'second-chance',
345
+ 'optimal',
346
+ 'midpoint',
347
+ 'lru-k',
348
+ 'two-q',
349
+ ];
350
+ /** Short label for each policy, for `<select>`s and column headers. */
351
+ export const REPLACEMENT_POLICY_LABELS = {
352
+ lru: 'LRU',
353
+ clock: 'Clock sweep',
354
+ fifo: 'FIFO',
355
+ 'second-chance': 'Second-chance',
356
+ optimal: 'Optimal (Bélády)',
357
+ midpoint: 'Midpoint LRU (InnoDB)',
358
+ 'lru-k': 'LRU-2 (scan-resistant)',
359
+ 'two-q': '2Q (scan-resistant)',
360
+ };
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Resolves an `IN (subquery)` predicate (plan.md §25.4 C3 slice c, closing C3) exactly once, before the query
3
+ * that names it is planned — the one place `runQuery.ts`'s SELECT, DELETE and UPDATE paths all share.
4
+ *
5
+ * `mapChildren` (`parser/ast.ts`) already rebuilds every other `Expr` kind from its children, but it does not
6
+ * know how to turn a `subquery` into `items` — that needs a real sub-run of the engine, not a pure tree
7
+ * transform — so this walks the same shape `mapChildren` would (`and`/`or`/`not`, the only kinds that can nest
8
+ * another predicate) by hand, stopping to resolve any `in` node that carries a `subquery`.
9
+ */
10
+ /** Every distinct value `column` holds across `rows`, in first-seen order — `null` counted like any other value. */
11
+ function distinctColumnValues(rows, column) {
12
+ const seen = new Set();
13
+ const values = [];
14
+ for (const row of rows) {
15
+ const value = row[column] ?? null;
16
+ const key = value === null ? '\u0000null' : `${typeof value}:${String(value)}`;
17
+ if (seen.has(key))
18
+ continue;
19
+ seen.add(key);
20
+ values.push(value);
21
+ }
22
+ return values;
23
+ }
24
+ /** A resolved value, as the literal `Expr` an ordinary `IN (1, 2, 3)` would already have held. */
25
+ function literalFor(value, span) {
26
+ return {
27
+ kind: 'literal',
28
+ value,
29
+ raw: value === null ? 'NULL' : typeof value === 'string' ? `'${value}'` : String(value),
30
+ span,
31
+ };
32
+ }
33
+ /**
34
+ * Runs one `IN`'s subquery for real — its own complete, independent sub-run of the engine (its own buffer pool,
35
+ * its own disk reads), exactly the way `explain.ts`'s `explainResult` already runs an inner query: padding the
36
+ * text with leading spaces rather than slicing them away keeps every character offset the sub-run reports (a
37
+ * parse error's span, say) pointing at the right place in the *outer* query's own editor. Not interleaved into
38
+ * the outer query's own step-by-step trace — the subquery's cost is real and already paid by the time this
39
+ * returns, but it is reported as one resolved fact (`emit.ts`'s WHERE-clause narration names the count), not a
40
+ * second parallel playback.
41
+ */
42
+ function resolveOne(expr, subquery, sql, run) {
43
+ const inner = run(' '.repeat(subquery.span.from) + sql.slice(subquery.span.from, subquery.span.to));
44
+ if (inner.error) {
45
+ return {
46
+ ok: false,
47
+ error: {
48
+ message: `The IN subquery failed: ${inner.error.message}`,
49
+ ...(inner.error.hint ? { hint: inner.error.hint } : {}),
50
+ from: subquery.span.from,
51
+ to: subquery.span.to,
52
+ },
53
+ };
54
+ }
55
+ const values = distinctColumnValues(inner.rows, subquery.column.name);
56
+ const items = values.map((value) => literalFor(value, subquery.span));
57
+ return { ok: true, expr: { ...expr, items } };
58
+ }
59
+ /** `resolveOne`, recursively, for every `IN (subquery)` anywhere in `expr` — under any nesting of `AND`/`OR`/`NOT`. */
60
+ function resolveInSubqueries(expr, sql, run) {
61
+ if (expr.kind === 'in' && expr.subquery)
62
+ return resolveOne(expr, expr.subquery, sql, run);
63
+ if (expr.kind === 'and' || expr.kind === 'or') {
64
+ const left = resolveInSubqueries(expr.left, sql, run);
65
+ if (!left.ok)
66
+ return left;
67
+ const right = resolveInSubqueries(expr.right, sql, run);
68
+ if (!right.ok)
69
+ return right;
70
+ return { ok: true, expr: { ...expr, left: left.expr, right: right.expr } };
71
+ }
72
+ if (expr.kind === 'not') {
73
+ const operand = resolveInSubqueries(expr.operand, sql, run);
74
+ if (!operand.ok)
75
+ return operand;
76
+ return { ok: true, expr: { ...expr, operand: operand.expr } };
77
+ }
78
+ // Every other kind's own children are arithmetic/columns/literals — `parsePredicate`'s grammar never lets a
79
+ // predicate (so never an `IN (subquery)`) nest inside one of those.
80
+ return { ok: true, expr };
81
+ }
82
+ /** `where`, with every `IN (subquery)` it contains resolved — or the first error one of them hit. `undefined` in, `undefined` out. */
83
+ export function resolveWhereSubqueries(where, sql, run) {
84
+ if (!where)
85
+ return { ok: true, where };
86
+ const resolved = resolveInSubqueries(where, sql, run);
87
+ return resolved.ok ? { ok: true, where: resolved.expr } : resolved;
88
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Trace buffer: an `emit` callback threaded down into every operator. The
3
+ * query runs to completion and the array is returned. Simplest thing that
4
+ * works, and it works because playback is pre-computed anyway (plan.md §7.8).
5
+ */
6
+ export function createTracer() {
7
+ const events = [];
8
+ const emit = (label, body, options) => {
9
+ const event = { ...body, id: events.length, label };
10
+ if (options?.predictable)
11
+ event.predictable = options.predictable;
12
+ if (options?.dwell !== undefined)
13
+ event.dwell = options.dwell;
14
+ events.push(event);
15
+ };
16
+ return { emit, drain: () => events.slice() };
17
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The single contract between the engine and the UI.
3
+ *
4
+ * Rules for everything in `src/engine`:
5
+ * - pure and synchronous (no DOM, no React, no async, no I/O)
6
+ * - every value here must be structurally cloneable, so the engine stays
7
+ * worker-ready even though v0 runs it on the main thread
8
+ */
9
+ export const STAGES = [
10
+ 'parse',
11
+ 'plan',
12
+ 'optimize',
13
+ 'execute',
14
+ 'buffer',
15
+ 'index',
16
+ 'lock',
17
+ 'result',
18
+ ];
19
+ export const DEFAULT_ENGINE_OPTIONS = {
20
+ bufferFrames: 8,
21
+ rowsPerPage: 4,
22
+ policy: 'lru',
23
+ };
24
+ /**
25
+ * The stages this build of the engine actually executes. Everything else in
26
+ * `STAGES` is scaffolding the UI must show as unbuilt — plan.md §2 makes
27
+ * honesty about ground truth a top-line goal, and a pipeline that looks
28
+ * complete when it is not breaks that. Grows one milestone at a time.
29
+ */
30
+ export const IMPLEMENTED_STAGES = [
31
+ 'parse',
32
+ 'plan',
33
+ 'optimize',
34
+ 'buffer',
35
+ 'index',
36
+ 'execute',
37
+ 'lock',
38
+ 'result',
39
+ ];
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Arithmetic on `SqlValue`s (plan.md §25.4 B1b) — pure, and free of the parser
3
+ * and the executor so that both can use it: the executor to evaluate
4
+ * `price * qty`, the parser and optimizer to fold `-5` or `2 + 3` into a
5
+ * literal (a folded literal is what lets `WHERE id = 2 + 3` become an index
6
+ * lookup). The rules follow SQLite where it is well-defined, and PostgreSQL
7
+ * where SQLite is permissive — each choice is a ledger entry or a comment.
8
+ */
9
+ const NUMERIC_TEXT = /^\s*[-+]?(\d+\.?\d*|\.\d+)(e[-+]?\d+)?\s*$/i;
10
+ /**
11
+ * A value as a number, or `null` when it is not one.
12
+ *
13
+ * NULL stays NULL. A boolean is 0 or 1. A string that *is* a number (`'5'`,
14
+ * `' 2.5 '`) is that number. Any other string is not a number, so it yields
15
+ * NULL — the arithmetic result is then NULL. SQLite reads such text as 0,
16
+ * silently (ledger: `arithmetic-on-text`); NULL says "this is not a number"
17
+ * without inventing a value.
18
+ */
19
+ export function toNumber(value) {
20
+ if (value === null)
21
+ return null;
22
+ if (typeof value === 'number')
23
+ return value;
24
+ if (typeof value === 'boolean')
25
+ return value ? 1 : 0;
26
+ return NUMERIC_TEXT.test(value) ? Number(value) : null;
27
+ }
28
+ /** `-0` is not a value SQL users ever see; keep it out of results and out of `toEqual`. */
29
+ const clean = (n) => (n === 0 ? 0 : n);
30
+ /**
31
+ * `left op right`. NULL in, NULL out. `x / 0` and `x % 0` are NULL (SQLite's
32
+ * rule — PostgreSQL raises an error, which this engine has no channel for at
33
+ * run time). `/` between two whole numbers is **integer division**, truncating
34
+ * toward zero (`7 / 2` is 3, `-7 / 2` is -3), in both SQLite and PostgreSQL;
35
+ * anything else divides exactly. Numbers here carry no int/real distinction,
36
+ * so a REAL column holding 3.0 divides as the integer 3 — ledger:
37
+ * `division-of-whole-reals`.
38
+ */
39
+ export function arithmetic(op, left, right) {
40
+ const a = toNumber(left);
41
+ const b = toNumber(right);
42
+ if (a === null || b === null)
43
+ return null;
44
+ switch (op) {
45
+ case '+':
46
+ return clean(a + b);
47
+ case '-':
48
+ return clean(a - b);
49
+ case '*':
50
+ return clean(a * b);
51
+ case '/':
52
+ if (b === 0)
53
+ return null;
54
+ return clean(Number.isInteger(a) && Number.isInteger(b) ? Math.trunc(a / b) : a / b);
55
+ case '%':
56
+ return b === 0 ? null : clean(a % b);
57
+ }
58
+ }
59
+ /** Unary minus. NULL stays NULL; a non-number is NULL. */
60
+ export function negate(value) {
61
+ const n = toNumber(value);
62
+ return n === null ? null : clean(-n);
63
+ }
64
+ /**
65
+ * A total order over `SqlValue`, `-1`/`0`/`1`: NULL sorts before everything and equals only NULL, two numbers
66
+ * compare numerically, anything else compares as text (a boolean as `'0'`/`'1'`) — an **index's** order, not SQL's
67
+ * `ORDER BY` (`exec/sort.ts`'s own `compareAsc` sorts NULLs *last*, Postgres's default there, deliberately the
68
+ * opposite rule). Shared by a table's own B+Tree indexes (`index/btree.ts`'s `compareScalar`), a clustered table's
69
+ * physical layout (`storage/page.ts`), and `ANALYZE`'s histogram and correlation statistics (plan.md §25.4 C1) —
70
+ * kept here once so none of the three can quietly drift from another.
71
+ */
72
+ export function compareValues(a, b) {
73
+ if (a === null || b === null)
74
+ return a === b ? 0 : a === null ? -1 : 1;
75
+ if (typeof a === 'number' && typeof b === 'number')
76
+ return a - b;
77
+ const sa = typeof a === 'boolean' ? String(Number(a)) : String(a);
78
+ const sb = typeof b === 'boolean' ? String(Number(b)) : String(b);
79
+ return sa < sb ? -1 : sa > sb ? 1 : 0;
80
+ }