@fjader/svelte 0.2.4 → 0.2.5

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 (139) hide show
  1. package/README.md +1 -1
  2. package/dist/animation/EntranceGate.d.ts +52 -0
  3. package/dist/animation/EntranceGate.js +93 -0
  4. package/dist/animation/TransitionClock.d.ts +61 -30
  5. package/dist/animation/TransitionClock.js +67 -29
  6. package/dist/animation/transition-log.d.ts +41 -0
  7. package/dist/animation/transition-log.js +57 -0
  8. package/dist/components/axes/AxisBottom.svelte +4 -2
  9. package/dist/components/axes/AxisCenter.svelte +4 -2
  10. package/dist/components/axes/AxisLeft.svelte +4 -2
  11. package/dist/components/axes/BandAxisTicks.svelte +3 -2
  12. package/dist/components/charts/ChartCell.svelte +32 -5
  13. package/dist/components/charts/ChartCell.svelte.d.ts +9 -0
  14. package/dist/components/charts/ChartContainer.svelte +45 -3
  15. package/dist/components/charts/ChartContainer.svelte.d.ts +7 -1
  16. package/dist/components/charts/ChartHover.svelte.d.ts +14 -0
  17. package/dist/components/charts/ChartHover.svelte.js +22 -0
  18. package/dist/components/charts/ChartVisibility.svelte.d.ts +43 -0
  19. package/dist/components/charts/ChartVisibility.svelte.js +67 -0
  20. package/dist/components/guides/Grid.svelte +12 -1
  21. package/dist/components/marks/AreaMarkX.svelte +16 -4
  22. package/dist/components/marks/AreaMarkY.svelte +16 -4
  23. package/dist/components/marks/BarMarkX.svelte +7 -3
  24. package/dist/components/marks/BarMarkY.svelte +7 -3
  25. package/dist/components/marks/DotsMark.svelte +6 -3
  26. package/dist/components/marks/LineMark.svelte +11 -8
  27. package/dist/components/marks/ParallelCoordinatesMark.svelte +7 -2
  28. package/dist/components/marks/ParallelCoordinatesMark.svelte.d.ts +2 -0
  29. package/dist/components/marks/RectMark.svelte +6 -3
  30. package/dist/components/marks/RectXMark.svelte +7 -3
  31. package/dist/components/marks/RectYMark.svelte +7 -3
  32. package/dist/components/marks/RuleMarkX.svelte +30 -26
  33. package/dist/components/marks/RuleMarkY.svelte +30 -26
  34. package/dist/components/marks/TextMark.svelte +44 -5
  35. package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.d.ts +16 -0
  36. package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.js +398 -54
  37. package/dist/components/marks/lifecycle/domain-queries.d.ts +11 -2
  38. package/dist/components/marks/lifecycle/domain-queries.js +33 -6
  39. package/dist/components/marks/lifecycle/domain-registration.d.ts +6 -1
  40. package/dist/components/marks/lifecycle/domain-registration.js +15 -14
  41. package/dist/components/marks/lifecycle/other-rows.svelte.d.ts +9 -1
  42. package/dist/components/marks/lifecycle/other-rows.svelte.js +2 -2
  43. package/dist/components/marks/lifecycle/query-builder.d.ts +27 -2
  44. package/dist/components/marks/lifecycle/query-builder.js +23 -20
  45. package/dist/components/marks/utils/MarkColor.svelte.d.ts +3 -1
  46. package/dist/components/marks/utils/MarkColor.svelte.js +33 -3
  47. package/dist/components/marks/utils/MarkSeries.svelte.d.ts +14 -1
  48. package/dist/components/marks/utils/MarkSeries.svelte.js +25 -1
  49. package/dist/components/marks/utils/MarkSize.svelte.js +4 -0
  50. package/dist/components/marks/utils/reactive-config.d.ts +1 -1
  51. package/dist/components/marks/utils/reactive-config.js +3 -0
  52. package/dist/components/rendering/Areas.svelte +57 -19
  53. package/dist/components/rendering/Areas.svelte.d.ts +12 -0
  54. package/dist/components/rendering/BaseGPURenderer.svelte.d.ts +1 -1
  55. package/dist/components/rendering/Dots.svelte +23 -3
  56. package/dist/components/rendering/Dots.svelte.d.ts +6 -0
  57. package/dist/components/rendering/GridLine.svelte +13 -1
  58. package/dist/components/rendering/GridLine.svelte.d.ts +5 -0
  59. package/dist/components/rendering/Lines.svelte +48 -13
  60. package/dist/components/rendering/Lines.svelte.d.ts +12 -0
  61. package/dist/components/rendering/ParallelLines.svelte +5 -5
  62. package/dist/components/rendering/Rects.svelte +51 -11
  63. package/dist/components/rendering/Rects.svelte.d.ts +16 -2
  64. package/dist/components/rendering/RuleX.svelte +1 -1
  65. package/dist/components/rendering/RuleY.svelte +1 -1
  66. package/dist/components/rendering/WebGPUCanvas.svelte +4 -2
  67. package/dist/components/rendering/activity-reads.d.ts +41 -24
  68. package/dist/components/rendering/activity-reads.js +54 -31
  69. package/dist/components/rendering/uniform-sizes.d.ts +1 -1
  70. package/dist/components/rendering/uniform-sizes.js +1 -1
  71. package/dist/config/ChartConfig.svelte.d.ts +7 -0
  72. package/dist/config/ChartConfig.svelte.js +17 -0
  73. package/dist/gpu/GPUCoordinator.svelte.d.ts +31 -1
  74. package/dist/gpu/GPUCoordinator.svelte.js +164 -7
  75. package/dist/gpu/MorphedChannels.d.ts +11 -2
  76. package/dist/gpu/MorphedChannels.js +33 -11
  77. package/dist/gpu/ScaleTemplateSystem.d.ts +20 -2
  78. package/dist/gpu/ScaleTemplateSystem.js +58 -2
  79. package/dist/index.d.ts +17 -4
  80. package/dist/index.js +19 -4
  81. package/dist/interactors/TooltipManager.svelte.js +14 -5
  82. package/dist/query-engine/CrossfilterSelection.svelte.d.ts +4 -2
  83. package/dist/query-engine/CrossfilterSelection.svelte.js +2 -0
  84. package/dist/query-engine/FieldActivity.svelte.d.ts +2 -3
  85. package/dist/query-engine/FieldActivity.svelte.js +1 -2
  86. package/dist/query-engine/MarkHandle.svelte.d.ts +28 -1
  87. package/dist/query-engine/MarkHandle.svelte.js +33 -1
  88. package/dist/query-engine/QueryEngine.d.ts +225 -5
  89. package/dist/query-engine/QueryEngine.js +671 -41
  90. package/dist/query-engine/categorical-identity.d.ts +34 -8
  91. package/dist/query-engine/categorical-identity.js +138 -73
  92. package/dist/query-engine/debug/query-log.d.ts +39 -0
  93. package/dist/query-engine/debug/query-log.js +90 -0
  94. package/dist/query-engine/duckdb.d.ts +55 -8
  95. package/dist/query-engine/duckdb.js +82 -30
  96. package/dist/query-engine/index.d.ts +7 -4
  97. package/dist/query-engine/index.js +3 -3
  98. package/dist/query-engine/preagg/BuildQueue.d.ts +33 -13
  99. package/dist/query-engine/preagg/BuildQueue.js +51 -15
  100. package/dist/query-engine/preagg/Preagg.d.ts +46 -3
  101. package/dist/query-engine/preagg/Preagg.js +159 -31
  102. package/dist/query-engine/preagg/plan.d.ts +28 -1
  103. package/dist/query-engine/preagg/plan.js +128 -8
  104. package/dist/query-engine/sliced-lane.d.ts +108 -0
  105. package/dist/query-engine/sliced-lane.js +299 -0
  106. package/dist/query-engine/source-routing.d.ts +106 -0
  107. package/dist/query-engine/source-routing.js +189 -0
  108. package/dist/scales/BandScaleState.svelte.d.ts +9 -1
  109. package/dist/scales/BandScaleState.svelte.js +16 -5
  110. package/dist/scales/ChartScales.svelte.d.ts +3 -0
  111. package/dist/scales/ChartScales.svelte.js +26 -8
  112. package/dist/scales/ColorScaleState.svelte.d.ts +18 -2
  113. package/dist/scales/ColorScaleState.svelte.js +138 -26
  114. package/dist/scales/LinearScaleState.svelte.d.ts +7 -10
  115. package/dist/scales/LinearScaleState.svelte.js +21 -39
  116. package/dist/scales/ScaleGeneration.svelte.d.ts +48 -0
  117. package/dist/scales/ScaleGeneration.svelte.js +42 -0
  118. package/dist/scales/ScaleRegistry.svelte.js +3 -1
  119. package/dist/scales/SlotTransition.d.ts +15 -1
  120. package/dist/scales/SlotTransition.js +33 -4
  121. package/dist/scales/TimeScaleState.svelte.d.ts +7 -3
  122. package/dist/scales/TimeScaleState.svelte.js +7 -7
  123. package/dist/scales/color-utils.d.ts +0 -3
  124. package/dist/scales/color-utils.js +0 -3
  125. package/dist/scales/entered.d.ts +8 -0
  126. package/dist/scales/entered.js +10 -0
  127. package/dist/spec/LazyQueryEngine.js +2 -19
  128. package/dist/spec/MarkRegistry.d.ts +3 -1
  129. package/dist/spec/MarkRegistry.js +13 -1
  130. package/dist/spec/PlotFromSpec.svelte +1 -0
  131. package/dist/spec/export.js +11 -2
  132. package/dist/spec/types.d.ts +41 -13
  133. package/dist/sql/bin.d.ts +25 -0
  134. package/dist/sql/bin.js +108 -5
  135. package/dist/transforms/TransformCompiler.d.ts +10 -0
  136. package/dist/transforms/TransformCompiler.js +26 -0
  137. package/dist/transforms/index.js +144 -24
  138. package/dist/types.d.ts +136 -2
  139. package/package.json +2 -2
@@ -21,6 +21,8 @@ import { tableFromIPC } from '@uwdata/flechette';
21
21
  import { assembleQuery, castNumeric, splitWindowSelect } from '../components/marks/lifecycle/query-builder';
22
22
  import { Preagg } from './preagg/Preagg';
23
23
  import { MarkHandle } from './MarkHandle.svelte';
24
+ import { LaneSlot } from './sliced-lane';
25
+ import { SourceRouting, sourceDDL } from './source-routing';
24
26
  import { CategoricalIdentity } from './categorical-identity';
25
27
  import { NO_ROUTE } from './CrossfilterSelection.svelte';
26
28
  /**
@@ -31,6 +33,33 @@ import { NO_ROUTE } from './CrossfilterSelection.svelte';
31
33
  * build instead.
32
34
  */
33
35
  const BUILD_MAX_WAIT_MS = 100;
36
+ /**
37
+ * Sliced lanes for interaction reads of unknown or slow sources. A source
38
+ * sticks to one; more sources than lanes share them (reads of one source are
39
+ * serial anyway).
40
+ */
41
+ const SOURCE_LANES = 4;
42
+ /**
43
+ * How long the main connection must stay idle before a lane runs its next
44
+ * slice. Longer than the JS gap between two statements of a dependent chain
45
+ * (one settles, the next is built and sent), so the chain runs unbroken
46
+ * instead of letting one slice in at every gap.
47
+ */
48
+ const MAIN_QUIET_MS = 2;
49
+ /**
50
+ * While the main connection works, all lanes together get one slice per this
51
+ * many ms: half the worker at 5 ms slices. A slow source keeps a minimum
52
+ * rate, and a fast query waits at most one slice for it. One budget for
53
+ * every lane, the background lane too: a budget per lane would let five busy
54
+ * lanes take the whole worker between them.
55
+ *
56
+ * Measured beside a slow chart during a brush (/test/query-load?slow=1,
57
+ * scripted drag, two runs each): the fast charts' p90 stayed at ~20 ms and
58
+ * their updates the same at 25, 15 and 10 ms (their cost follows the slow
59
+ * chart's large results decoding, not the share), while the slow chart's
60
+ * data age fell from ~500 to ~375 to ~345 ms median.
61
+ */
62
+ const SLICE_SHARE_MS = 10;
34
63
  // DESCRIBE reports canonical type names, so these match exactly. Anything else (VARCHAR,
35
64
  // ENUM, lists, STRUCT, INTERVAL, extension types) is a string, which is never cast to REAL.
36
65
  const NUMBER_TYPES = new Set([
@@ -85,11 +114,41 @@ export function buildSchemaMap(columns) {
85
114
  // ---------------------------------------------------------------------------
86
115
  // QueryEngine
87
116
  // ---------------------------------------------------------------------------
117
+ /**
118
+ * How a mark's data read is placed. Routed by the table it reads
119
+ * (SourceRouting): the preagg table when one answers, else the mark's BASE
120
+ * table (its identity view reads the same rows, and its range and stats reads
121
+ * name the base table: one record per source). `slow` (holdsEntrance={false})
122
+ * applies to the base query only: a preagg read is ~1 ms, and forced onto a
123
+ * lane during a brush it would cost ~20 ms of round trips in exactly the case
124
+ * preagg exists to make fast.
125
+ */
126
+ export function markReadOptions(markId, descriptor, preaggTable) {
127
+ return {
128
+ callerId: markId,
129
+ source: preaggTable ?? descriptor.baseTable,
130
+ data: true,
131
+ slow: !preaggTable && descriptor.slow
132
+ };
133
+ }
134
+ /**
135
+ * DuckDB's catalog error for a table or view this connection cannot see.
136
+ * Matched by its wording, which an upgrade could change: the integration
137
+ * test missing-relation.integration.test.ts checks it against real DuckDB.
138
+ */
139
+ export function isMissingRelation(e) {
140
+ const message = e instanceof Error ? e.message : String(e);
141
+ return /Catalog Error: (Table|View) with name .+ does not exist/i.test(message);
142
+ }
88
143
  export class QueryEngine {
89
144
  #conn = null;
90
145
  #connPromise = null;
91
146
  #inflight = new Map();
92
147
  #pending = new Map();
148
+ /** Interaction statements handed to the connection and not yet settled (see #send). */
149
+ #interactionInFlight = 0;
150
+ /** Debug listeners for every statement sent (observeStatements). */
151
+ #statementObservers = new Set();
93
152
  #describeCache = new Map();
94
153
  #marks = new Map();
95
154
  #destroyed = false;
@@ -126,19 +185,44 @@ export class QueryEngine {
126
185
  rows.push(table.get(i));
127
186
  }
128
187
  return rows;
188
+ },
189
+ // Seen created as a table through the engine, or listed in the catalog
190
+ // (which lists tables, not views).
191
+ isBaseTable: (table) => this.#known.get(table) === 'table' || !!this.#catalog?.has(table),
192
+ observing: () => this.#identityObservers.size > 0,
193
+ onRequest: (table, fields, time) => {
194
+ this.#notifyIdentity({ kind: 'request', table, fields, time });
195
+ },
196
+ onDrain: (table, fields, time) => {
197
+ this.#notifyIdentity({ kind: 'drain', table, fields, time });
198
+ },
199
+ execBatch: async (statements) => {
200
+ const table = await this.execBatch(statements);
201
+ const rows = [];
202
+ for (let i = 0; i < table.numRows; i++) {
203
+ rows.push(table.get(i));
204
+ }
205
+ return rows;
129
206
  }
130
207
  }, this.#session);
131
208
  /** Notified after clearQueryCache() drops the identity dims and views. */
132
209
  #identityResetListeners = new Set();
133
210
  constructor(connection, options = {}) {
134
211
  this.#preagg = new Preagg({
135
- exec: (sql) => this.exec(sql),
136
- query: (sql, opts) => this.query(sql, opts),
137
- busy: () => this.#inflight.size > 0 || this.#pending.size > 0,
212
+ exec: (sql, signal) => this.exec(sql, { class: 'background', signal }),
213
+ query: (sql, opts) => this.query(sql, { class: 'background', ...opts }),
214
+ // A statement someone waits for is on the connection, or a mark
215
+ // query is waiting to be sent.
216
+ busy: () => this.#interactionInFlight > 0 || this.#pending.size > 0,
217
+ sliced: () => this.#slicedReady(),
138
218
  identity: this.#identity,
139
219
  mark: (markId) => this.#marks.get(markId),
140
- marks: () => this.#marks
220
+ marks: () => this.#marks,
221
+ observingBuilds: () => this.#buildObservers.size > 0,
222
+ onBuild: (event) => this.#notifyBuild(event)
141
223
  }, options.buildMaxWaitMs ?? BUILD_MAX_WAIT_MS);
224
+ this.#sliceShareMs = options.sliceShareMs ?? SLICE_SHARE_MS;
225
+ this.#unbatch = options.unbatch ?? false;
142
226
  if (connection instanceof Promise) {
143
227
  this.#connPromise = connection;
144
228
  connection.then((c) => {
@@ -293,18 +377,316 @@ export class QueryEngine {
293
377
  }
294
378
  }
295
379
  }
380
+ /**
381
+ * The one place a statement reaches the connection. Everything that talks to
382
+ * DuckDB goes through here: queries, statements, DESCRIBE, Arrow inserts.
383
+ *
384
+ * What attaches here: the interaction count builds yield to, and statement
385
+ * observers (debugging, measurement). Later: routing a class to a connection,
386
+ * and per-connection queues.
387
+ */
388
+ async #send(kind, sql, place, how) {
389
+ const { run, runSliced, signal } = how;
390
+ if (!this.#conn)
391
+ await this.#ensureConnection();
392
+ const cls = place.class;
393
+ // Awaited only while a lane is opening (LaneSlot.get): once its state is
394
+ // known the choice is made in this tick.
395
+ const slot = runSliced ? this.#slotFor(place) : null;
396
+ const got = slot ? slot.get() : null;
397
+ const lane = got instanceof Promise ? await got : got;
398
+ // The main connection cannot interrupt a statement once sent: a signal
399
+ // only stops one that has not gone yet. A lane cancels (SlicedLane).
400
+ if (!lane && signal?.aborted)
401
+ throw new DOMException('Statement aborted', 'AbortError');
402
+ const conn = this.#conn;
403
+ const interaction = cls === 'interaction';
404
+ if (interaction)
405
+ this.#interactionInFlight++;
406
+ const start = performance.now();
407
+ let error;
408
+ // Paired with #mainIdle in the finally below.
409
+ if (!lane)
410
+ this.#mainBusy();
411
+ try {
412
+ return lane ? await runSliced(lane) : await run(conn);
413
+ }
414
+ catch (e) {
415
+ error = e;
416
+ throw e;
417
+ }
418
+ finally {
419
+ if (this.#statementObservers.size > 0) {
420
+ const record = {
421
+ sql,
422
+ kind,
423
+ class: cls,
424
+ connection: lane ? 'lane' : 'main',
425
+ count: how.count,
426
+ source: place.source,
427
+ requested: place.requestedAt,
428
+ caller: place.callerId,
429
+ start,
430
+ end: performance.now(),
431
+ error
432
+ };
433
+ // A listener is for debugging: one that throws must not fail the
434
+ // statement, nor skip the in-flight count below.
435
+ for (const observe of this.#statementObservers) {
436
+ try {
437
+ observe(record);
438
+ }
439
+ catch (e) {
440
+ console.error('[QueryEngine] statement observer threw:', e);
441
+ }
442
+ }
443
+ }
444
+ if (!lane)
445
+ this.#mainIdle();
446
+ // The last interaction statement settling frees the connection for a build.
447
+ if (interaction && --this.#interactionInFlight === 0)
448
+ this.#preagg.pump();
449
+ }
450
+ }
296
451
  /**
297
452
  * Execute a SQL query and return a flechette Table.
298
453
  * Uses the fast IPC path (queryIPC → tableFromIPC) when available,
299
454
  * falling back to the standard conn.query() for mocks/testing.
300
455
  */
301
- async #queryRaw(sql) {
456
+ #queryRaw(sql, place, kind = 'query', signal) {
457
+ let sliced = false;
458
+ const read = this.#send(kind, sql, place, {
459
+ run: async (conn) => {
460
+ if (!conn.queryIPC)
461
+ return conn.query(sql);
462
+ const t0 = performance.now();
463
+ const bytes = await conn.queryIPC(sql);
464
+ const ms = performance.now() - t0;
465
+ const table = tableFromIPC(bytes);
466
+ if (this.#notesCost(place))
467
+ this.#routing.noteMainRead(place.source, ms, table.numRows);
468
+ return table;
469
+ },
470
+ runSliced: async (lane) => {
471
+ sliced = true;
472
+ const result = await lane.query(sql, signal);
473
+ const table = tableFromIPC(result.ipc);
474
+ if (this.#notesCost(place)) {
475
+ this.#routing.noteLaneRead(place.source, result.polls, result.execMs, table.numRows);
476
+ }
477
+ return table;
478
+ },
479
+ signal
480
+ });
481
+ // Safety net for a TEMP source made where the engine could not see it
482
+ // (on the app's connection, which is the main one): a lane does not
483
+ // have it. Once found missing there, it stays on the main connection
484
+ // for good, and this read is sent there. Local is the safe direction:
485
+ // a race (a lane read during a drop and re-create made outside the
486
+ // engine) can only make a source less sliced, which is how every
487
+ // source read before lanes.
488
+ return read.catch((e) => {
489
+ const source = place.source;
490
+ if (!sliced || !source || !isMissingRelation(e))
491
+ throw e;
492
+ this.#localSources.add(source);
493
+ return this.#queryRaw(sql, place, kind, signal);
494
+ });
495
+ }
496
+ /**
497
+ * The lane a statement runs on, or null for the main connection:
498
+ * - connection-local state, or a statement with no sliced form: main;
499
+ * - background: the background lane (preagg builds and their reads);
500
+ * - interaction naming a source: by the source's cost (SourceRouting),
501
+ * unknown or slow on that source's lane, known-fast on main.
502
+ * Without the capability every slot answers null: one connection.
503
+ */
504
+ #slotFor(place) {
505
+ if (place.connectionLocal)
506
+ return null;
507
+ if (place.class === 'background')
508
+ return this.#backgroundLane;
509
+ if (!place.source || !this.#conn?.openSlicedLane)
510
+ return null;
511
+ if (this.#localSources.has(place.source))
512
+ return null;
513
+ const i = place.slow ? this.#routing.laneOf(place.source) : this.#routing.route(place.source);
514
+ return i == null ? null : this.#sourceLanes[i];
515
+ }
516
+ /** Whether a read's cost goes into its source's record: interaction data reads. */
517
+ #notesCost(place) {
518
+ // A read known to be slow says nothing about its source: a slow mark's
519
+ // heavy query would otherwise turn a fast table slow for every mark.
520
+ return place.class === 'interaction' && !!place.data && !!place.source && !place.slow;
521
+ }
522
+ /**
523
+ * Whether background statements run sliced, on their own connection, so
524
+ * they no longer hold up interaction. Asks for the lane, so the first ask
525
+ * opens it; false until it is open.
526
+ */
527
+ #slicedReady() {
528
+ this.#backgroundLane.get();
529
+ return this.#backgroundLane.isOpen;
530
+ }
531
+ #openLaneFn = () => {
302
532
  const conn = this.#conn;
303
- if (conn.queryIPC) {
304
- const ipc = await conn.queryIPC(sql);
305
- return tableFromIPC(ipc);
533
+ if (!conn?.openSlicedLane || this.#destroyed)
534
+ return null;
535
+ return conn.openSlicedLane({ hold: this.#sliceGrant });
536
+ };
537
+ // --- Holding lanes while the main connection works ---
538
+ //
539
+ // A lane's slice is a message at the back of the worker's queue, so it
540
+ // runs between two main-connection statements; in a dependent chain (a
541
+ // mark's setup: dims, views, then data) every step then waits a slice.
542
+ // So while the main connection works, lanes share a budget of slices
543
+ // (SLICE_SHARE_MS, across all of them); once it has been idle for
544
+ // MAIN_QUIET_MS they run freely. Counted here, per connection: a slow
545
+ // source's reads are interaction class too, and a lane waiting on
546
+ // #interactionInFlight would wait for itself.
547
+ /** Statements on the main connection, sent and not settled. */
548
+ #mainInFlight = 0;
549
+ /** When the main connection last went idle. */
550
+ #mainIdleAt = 0;
551
+ #sliceShareMs;
552
+ #unbatch;
553
+ /** When any lane last took a slice from the share. */
554
+ #lastSliceAt = -Infinity;
555
+ /** Lanes waiting for a slice, first come first served. */
556
+ #sliceWaiters = [];
557
+ #shareTimer = null;
558
+ #quietTimer = null;
559
+ #mainBusy() {
560
+ this.#mainInFlight++;
561
+ if (this.#quietTimer) {
562
+ clearTimeout(this.#quietTimer);
563
+ this.#quietTimer = null;
564
+ }
565
+ }
566
+ #mainIdle() {
567
+ if (--this.#mainInFlight > 0)
568
+ return;
569
+ this.#mainIdleAt = performance.now();
570
+ if (this.#sliceWaiters.length > 0)
571
+ this.#armQuiet();
572
+ }
573
+ #mainQuietNow() {
574
+ return this.#mainInFlight === 0 && performance.now() - this.#mainIdleAt >= MAIN_QUIET_MS;
575
+ }
576
+ /** Once the main connection has stayed idle long enough, every waiting lane goes. */
577
+ #armQuiet() {
578
+ if (this.#quietTimer)
579
+ clearTimeout(this.#quietTimer);
580
+ const left = MAIN_QUIET_MS - (performance.now() - this.#mainIdleAt);
581
+ this.#quietTimer = setTimeout(() => {
582
+ this.#quietTimer = null;
583
+ if (this.#mainInFlight > 0)
584
+ return;
585
+ const waiting = this.#sliceWaiters;
586
+ this.#sliceWaiters = [];
587
+ for (const go of waiting)
588
+ go();
589
+ }, Math.max(0, left));
590
+ }
591
+ /** The next waiting lane gets the share's next slice. */
592
+ #armShare() {
593
+ if (this.#shareTimer)
594
+ return;
595
+ const left = this.#lastSliceAt + this.#sliceShareMs - performance.now();
596
+ this.#shareTimer = setTimeout(() => {
597
+ this.#shareTimer = null;
598
+ const go = this.#sliceWaiters.shift();
599
+ if (!go)
600
+ return;
601
+ this.#lastSliceAt = performance.now();
602
+ go();
603
+ if (this.#sliceWaiters.length > 0)
604
+ this.#armShare();
605
+ }, Math.max(0, left));
606
+ }
607
+ /** LaneHold: may a lane run its next slice now? */
608
+ #sliceGrant = () => {
609
+ if (this.#mainQuietNow())
610
+ return null;
611
+ const now = performance.now();
612
+ if (this.#sliceWaiters.length === 0 && now - this.#lastSliceAt >= this.#sliceShareMs) {
613
+ this.#lastSliceAt = now;
614
+ return null;
615
+ }
616
+ return new Promise((resolve) => {
617
+ this.#sliceWaiters.push(resolve);
618
+ this.#armShare();
619
+ if (this.#mainInFlight === 0)
620
+ this.#armQuiet();
621
+ });
622
+ };
623
+ #laneFailed = (e) => {
624
+ if (this.#warnedLaneFailed)
625
+ return;
626
+ this.#warnedLaneFailed = true;
627
+ console.warn('[QueryEngine] could not open a sliced lane; its statements stay on the main connection:', e);
628
+ };
629
+ #warnedLaneFailed = false;
630
+ /** Background statements: preagg builds, their counts and drops. */
631
+ #backgroundLane = new LaneSlot(this.#openLaneFn, this.#laneFailed);
632
+ /** Interaction reads of unknown and slow sources, a source sticking to one. */
633
+ #sourceLanes = Array.from({ length: SOURCE_LANES }, () => new LaneSlot(this.#openLaneFn, this.#laneFailed));
634
+ #routing = new SourceRouting(SOURCE_LANES);
635
+ /**
636
+ * Debug: every categorical identity request (a mark's ensureColumns call),
637
+ * with its table, fields and time, before deduplication, and every drain
638
+ * start (a table's dims beginning to build, with the fields it found
639
+ * pending). Returns the unsubscribe; costs nothing while no one listens, and
640
+ * a listener that throws is logged without failing identity work.
641
+ */
642
+ observeIdentityRequests(listener) {
643
+ this.#identityObservers.add(listener);
644
+ return () => this.#identityObservers.delete(listener);
645
+ }
646
+ #identityObservers = new Set();
647
+ /**
648
+ * Debug: every step of every preagg build (BuildEvent): planned or
649
+ * skipped (with the size guard's numbers), queued (with its hover batch),
650
+ * started, then built, failed or dropped (with the check that failed and
651
+ * the values it compared). Returns the unsubscribe; costs nothing while no
652
+ * one listens, and a listener that throws is logged without failing the
653
+ * build.
654
+ */
655
+ observeBuilds(listener) {
656
+ this.#buildObservers.add(listener);
657
+ return () => this.#buildObservers.delete(listener);
658
+ }
659
+ #buildObservers = new Set();
660
+ /** Each listener in its own try/catch: a debugging hook must not fail a build. */
661
+ #notifyBuild(event) {
662
+ for (const observe of this.#buildObservers) {
663
+ try {
664
+ observe(event);
665
+ }
666
+ catch (e) {
667
+ console.error('[QueryEngine] build observer threw:', e);
668
+ }
669
+ }
670
+ }
671
+ /** Each listener in its own try/catch: a debugging hook must not fail identity work. */
672
+ #notifyIdentity(request) {
673
+ for (const observe of this.#identityObservers) {
674
+ try {
675
+ observe(request);
676
+ }
677
+ catch (e) {
678
+ console.error('[QueryEngine] identity observer threw:', e);
679
+ }
306
680
  }
307
- return conn.query(sql);
681
+ }
682
+ /**
683
+ * Listen to every statement sent to the connection, as it settles. For
684
+ * debugging and measurement; returns the unsubscribe. Costs nothing while
685
+ * no one listens.
686
+ */
687
+ observeStatements(listener) {
688
+ this.#statementObservers.add(listener);
689
+ return () => this.#statementObservers.delete(listener);
308
690
  }
309
691
  /** Resolve #conn from #connPromise if needed. No-op when already resolved. */
310
692
  #ensureConnection() {
@@ -332,6 +714,15 @@ export class QueryEngine {
332
714
  if (!this.#conn)
333
715
  await this.#ensureConnection();
334
716
  const sqlStr = typeof sql === 'string' ? sql : sql.toString();
717
+ const place = {
718
+ class: opts?.class ?? 'interaction',
719
+ connectionLocal: opts?.connectionLocal,
720
+ source: opts?.source,
721
+ data: opts?.data,
722
+ requestedAt: performance.now(),
723
+ callerId: opts?.callerId,
724
+ slow: opts?.slow
725
+ };
335
726
  // Cache check: return cached result immediately, bypassing
336
727
  // inflight/pending bookkeeping entirely.
337
728
  // Opt out with cache: false for queries against volatile temp tables.
@@ -356,23 +747,54 @@ export class QueryEngine {
356
747
  }
357
748
  // Store as pending — will be executed when inflight completes
358
749
  return new Promise((resolve, reject) => {
359
- this.#pending.set(callerId, { sql: sqlStr, resolve, reject, cache: useCache });
750
+ this.#pending.set(callerId, {
751
+ sql: sqlStr,
752
+ resolve,
753
+ reject,
754
+ cache: useCache,
755
+ place
756
+ });
360
757
  });
361
758
  }
362
- return this.#executeForCaller(callerId, sqlStr, opts.signal, useCache);
759
+ return this.#executeForCaller(callerId, sqlStr, opts.signal, useCache, place);
760
+ }
761
+ // No callerId, cache asked for: the first caller's promise goes in the
762
+ // cache, so a concurrent identical query (two marks asking the same
763
+ // range) shares it, and a later one reads the result. A cached hit
764
+ // already returned above.
765
+ if (opts?.cache === true) {
766
+ if (opts.signal?.aborted)
767
+ throw new DOMException('Query aborted', 'AbortError');
768
+ const key = this.#cacheKey(sqlStr);
769
+ const promise = this.#queryRaw(sqlStr, place, 'query', opts.signal);
770
+ this.#queryCache.set(key, promise);
771
+ let result;
772
+ try {
773
+ result = await promise;
774
+ this.#queryCache.set(key, result);
775
+ }
776
+ catch (e) {
777
+ this.#queryCache.delete(key);
778
+ throw e;
779
+ }
780
+ // Aborted while running: the result stays cached for others (it is
781
+ // correct), but this caller asked not to receive it.
782
+ if (opts.signal?.aborted)
783
+ throw new DOMException('Query aborted', 'AbortError');
784
+ return result;
363
785
  }
364
786
  // No callerId: direct execution, still respect external signal
365
787
  if (opts?.signal) {
366
788
  if (opts.signal.aborted) {
367
789
  throw new DOMException('Query aborted', 'AbortError');
368
790
  }
369
- const result = await this.#queryRaw(sqlStr);
791
+ const result = await this.#queryRaw(sqlStr, place, 'query', opts.signal);
370
792
  if (opts.signal.aborted) {
371
793
  throw new DOMException('Query aborted', 'AbortError');
372
794
  }
373
795
  return result;
374
796
  }
375
- return this.#queryRaw(sqlStr);
797
+ return this.#queryRaw(sqlStr, place);
376
798
  }
377
799
  /**
378
800
  * The cache key for a query: its text and the dims' version, so a result read
@@ -381,7 +803,7 @@ export class QueryEngine {
381
803
  #cacheKey(sqlStr) {
382
804
  return `${this.#identity.version}\u0000${sqlStr}`;
383
805
  }
384
- async #executeForCaller(callerId, sqlStr, signal, cache = true) {
806
+ async #executeForCaller(callerId, sqlStr, signal, cache = true, place = { class: 'interaction' }) {
385
807
  const controller = new AbortController();
386
808
  this.#inflight.set(callerId, controller);
387
809
  let key = null;
@@ -396,7 +818,7 @@ export class QueryEngine {
396
818
  // later, against whatever the dims hold by then.
397
819
  key = this.#cacheKey(sqlStr);
398
820
  // Cache the Promise so concurrent identical queries share one inflight
399
- const queryPromise = this.#queryRaw(sqlStr);
821
+ const queryPromise = this.#queryRaw(sqlStr, place, 'query', controller.signal);
400
822
  if (cache)
401
823
  this.#queryCache.set(key, queryPromise);
402
824
  const result = await queryPromise;
@@ -426,7 +848,7 @@ export class QueryEngine {
426
848
  const pending = this.#pending.get(callerId);
427
849
  if (pending) {
428
850
  this.#pending.delete(callerId);
429
- this.#executeForCaller(callerId, pending.sql, undefined, pending.cache)
851
+ this.#executeForCaller(callerId, pending.sql, undefined, pending.cache, pending.place)
430
852
  .then(pending.resolve)
431
853
  .catch(pending.reject);
432
854
  }
@@ -434,28 +856,117 @@ export class QueryEngine {
434
856
  this.#preagg.pump();
435
857
  }
436
858
  }
859
+ /**
860
+ * Run several statements as ONE message on the main connection, returning
861
+ * the last statement's rows. Each statement commits on its own and
862
+ * execution stops at the first failure, as when sent one by one (measured
863
+ * on DuckDB-WASM, /test/query-slicing §13); the error does not say which
864
+ * statement failed.
865
+ *
866
+ * For statements on tables the main connection owns (fjader's identity
867
+ * dims, present tables, views): one connection per table. Deliberately no
868
+ * class, source or routing options: it always runs on the main connection
869
+ * as interaction (so lanes hold their slices while it runs). What it saves
870
+ * is queue entries: a dependent chain sent one by one goes to the back of
871
+ * the queue at every step.
872
+ *
873
+ * Logged as one record, kind 'batch', with the joined text and `count`.
874
+ * Without `queryIPC` (mocks, custom connections), or with
875
+ * `QueryEngineOptions.unbatch`, the statements go one by one.
876
+ */
877
+ async execBatch(statements) {
878
+ if (this.#destroyed)
879
+ throw new Error('QueryEngine has been destroyed');
880
+ if (statements.length === 0)
881
+ throw new Error('execBatch: no statements');
882
+ if (!this.#conn)
883
+ await this.#ensureConnection();
884
+ const place = { class: 'interaction' };
885
+ let result;
886
+ if (this.#unbatch || !this.#conn?.queryIPC) {
887
+ for (const stmt of statements.slice(0, -1))
888
+ await this.exec(stmt);
889
+ result = await this.#queryRaw(statements[statements.length - 1], place);
890
+ }
891
+ else {
892
+ const sql = statements.join(';\n');
893
+ result = await this.#send('batch', sql, place, {
894
+ run: async (conn) => tableFromIPC(await conn.queryIPC(sql)),
895
+ count: statements.length
896
+ });
897
+ }
898
+ for (const stmt of statements)
899
+ this.#noteDDL(stmt);
900
+ return result;
901
+ }
437
902
  /**
438
903
  * Execute DDL/DML statements (CREATE TABLE, DROP TABLE, INSERT, etc.).
439
904
  * Accepts a single SQL string or an array of statements to execute
440
905
  * sequentially.
441
906
  */
442
- async exec(sql) {
907
+ async exec(sql, opts) {
443
908
  if (this.#destroyed) {
444
909
  throw new Error('QueryEngine has been destroyed');
445
910
  }
446
- if (!this.#conn)
447
- await this.#ensureConnection();
911
+ const place = {
912
+ class: opts?.class ?? 'interaction',
913
+ connectionLocal: opts?.connectionLocal
914
+ };
915
+ const signal = opts?.signal;
448
916
  const statements = Array.isArray(sql) ? sql : [sql];
449
- const execFn = this.#conn.exec?.bind(this.#conn);
917
+ // One statement at a time, each committing on its own.
450
918
  for (const stmt of statements) {
451
- if (execFn) {
452
- await execFn(stmt);
453
- }
454
- else {
455
- await this.#conn.query(stmt);
456
- }
919
+ await this.#send('exec', stmt, place, {
920
+ run: async (conn) => {
921
+ if (conn.exec)
922
+ await conn.exec(stmt);
923
+ else
924
+ await conn.query(stmt);
925
+ },
926
+ runSliced: async (lane) => {
927
+ await lane.exec(stmt, signal);
928
+ },
929
+ signal
930
+ });
931
+ this.#noteDDL(stmt);
457
932
  }
458
933
  }
934
+ /**
935
+ * Learn what a source is from the statement that made it (SourceRouting's
936
+ * prior), at no cost: a table created under a plain name starts fast, a
937
+ * view unknown, a dropped name forgets. Covers everything created through
938
+ * the engine (loadParquet, Arrow inserts, fjader's own tables); a source
939
+ * made elsewhere falls back to the catalog (#classifySource).
940
+ */
941
+ #noteDDL(sql) {
942
+ const ddl = sourceDDL(sql);
943
+ if (!ddl)
944
+ return;
945
+ if (ddl.kind === 'drop') {
946
+ this.#known.delete(ddl.name);
947
+ this.#localSources.delete(ddl.name);
948
+ this.#routing.forget(ddl.name);
949
+ return;
950
+ }
951
+ this.#known.set(ddl.name, ddl.kind);
952
+ if (ddl.temp)
953
+ this.#localSources.add(ddl.name);
954
+ else
955
+ this.#localSources.delete(ddl.name);
956
+ // Replacing a name re-opens its record: a view over a table, a new table.
957
+ this.#routing.forget(ddl.name);
958
+ if (ddl.kind === 'table')
959
+ this.#routing.prior(ddl.name, 'fast');
960
+ }
961
+ /** Sources seen created through the engine, by name. */
962
+ #known = new Map();
963
+ /**
964
+ * Sources that exist only on the main connection: TEMP tables and views
965
+ * (they belong to the connection that made them), whether seen created
966
+ * through the engine or found missing on a lane. Never routed to a lane,
967
+ * whatever their cost.
968
+ */
969
+ #localSources = new Set();
459
970
  /**
460
971
  * Get column info for a table via DESCRIBE.
461
972
  * Results are cached — same table returns the same promise.
@@ -469,13 +980,20 @@ export class QueryEngine {
469
980
  return cached;
470
981
  const promise = this.#fetchDescribe(table);
471
982
  this.#describeCache.set(table, promise);
983
+ // A failure is not remembered: a DESCRIBE sent before its table existed
984
+ // would otherwise fail every later one for good.
985
+ promise.catch(() => {
986
+ if (this.#describeCache.get(table) === promise)
987
+ this.#describeCache.delete(table);
988
+ });
472
989
  return promise;
473
990
  }
474
991
  async #fetchDescribe(table) {
475
- if (!this.#conn)
476
- await this.#ensureConnection();
477
992
  const escaped = table.replaceAll('"', '""');
478
- const result = await this.#queryRaw(`DESCRIBE "${escaped}"`);
993
+ // A source not seen created through the engine is looked up in the
994
+ // catalog, off the critical path: until the answer it reads sliced.
995
+ void this.#classifySource(table);
996
+ const result = await this.#queryRaw(`DESCRIBE "${escaped}"`, { class: 'interaction' }, 'describe');
479
997
  const columns = [];
480
998
  for (let i = 0; i < result.numRows; i++) {
481
999
  const row = result.get(i);
@@ -487,6 +1005,67 @@ export class QueryEngine {
487
1005
  }
488
1006
  return columns;
489
1007
  }
1008
+ /**
1009
+ * Set a source's prior from what it is (SourceRouting): a base or
1010
+ * temporary table in an in-memory catalog starts fast; a view, or a
1011
+ * table in an attached database with a path (a file, a URL), stays
1012
+ * unknown and is read sliced until measured. Only where lanes exist: a
1013
+ * single connection has nothing to route.
1014
+ */
1015
+ async #classifySource(table) {
1016
+ if (!this.#conn?.openSlicedLane)
1017
+ return;
1018
+ if (this.#known.has(table))
1019
+ return;
1020
+ let known = this.#catalog?.get(table);
1021
+ if (known === undefined)
1022
+ known = (await this.#refreshCatalog()).get(table);
1023
+ if (known === 'fast')
1024
+ this.#routing.prior(table, 'fast');
1025
+ }
1026
+ /**
1027
+ * Every user table, by name: 'fast' when every catalog holding that name
1028
+ * is in memory, 'unknown' otherwise. A name not in it (a view, a table
1029
+ * function) stays unknown, so the views need not be listed: duckdb_views()
1030
+ * enumerates DuckDB's internal views before any filter and was most of
1031
+ * the cost (information_schema per table: 30-38 ms at load on WASM;
1032
+ * tables and views together still 33-41 ms; tables and databases alone
1033
+ * about a quarter of that, natively). Refreshed when a name is missing;
1034
+ * concurrent misses (a dozen marks mounting) share the refresh in flight.
1035
+ */
1036
+ #refreshCatalog() {
1037
+ this.#catalogRefresh ??= this.#queryRaw(`SELECT t.table_name AS name, d.path AS path FROM duckdb_tables() t ` +
1038
+ `JOIN duckdb_databases() d ON d.database_name = t.database_name WHERE NOT t.internal`, { class: 'interaction' })
1039
+ .then((rows) => {
1040
+ const catalog = new Map();
1041
+ for (let i = 0; i < rows.numRows; i++) {
1042
+ const row = rows.get(i);
1043
+ const inMemory = !row.path || row.path === ':memory:';
1044
+ // A name in two catalogs is fast only if every one is.
1045
+ if (catalog.get(row.name) !== 'unknown') {
1046
+ catalog.set(row.name, inMemory ? 'fast' : 'unknown');
1047
+ }
1048
+ }
1049
+ this.#catalog = catalog;
1050
+ return catalog;
1051
+ })
1052
+ .catch(() => this.#catalog ?? new Map())
1053
+ .finally(() => {
1054
+ this.#catalogRefresh = null;
1055
+ });
1056
+ return this.#catalogRefresh;
1057
+ }
1058
+ #catalog = null;
1059
+ #catalogRefresh = null;
1060
+ /**
1061
+ * Tell the engine how a source reads, overriding its prior: `'slow'` for a
1062
+ * table that is slow to query (it reads sliced, so it never holds up
1063
+ * other charts), `'fast'` for a view that is cheap (it reads on the main
1064
+ * connection from its first query). A measured read still corrects it.
1065
+ */
1066
+ sourceCost(source, cost) {
1067
+ this.#routing.hint(source, cost);
1068
+ }
490
1069
  /** Clear cached DESCRIBE results. Pass a table name to clear one, or omit for all. */
491
1070
  clearDescribeCache(table) {
492
1071
  if (table) {
@@ -529,6 +1108,16 @@ export class QueryEngine {
529
1108
  // against dropped state (see CategoricalIdentity's generation counter).
530
1109
  const identityReset = this.#identity.reset().finally(() => this.#notifyIdentityReset());
531
1110
  this.#preagg.reset();
1111
+ // New data: a source's cost is unknown again (a reload can be slower),
1112
+ // and the catalog is read again.
1113
+ this.#routing.reset();
1114
+ this.#catalog = null;
1115
+ // Tables learned from DDL are still tables: their prior comes back
1116
+ // (#classifySource skips names it already knows).
1117
+ for (const [name, kind] of this.#known) {
1118
+ if (kind === 'table')
1119
+ this.#routing.prior(name, 'fast');
1120
+ }
532
1121
  this.#queryCache.clear();
533
1122
  return identityReset;
534
1123
  }
@@ -558,13 +1147,17 @@ export class QueryEngine {
558
1147
  * Throws if the underlying connection doesn't support it (e.g. REST).
559
1148
  */
560
1149
  async insertArrowFromIPCStream(data, options) {
561
- if (!this.#conn)
562
- await this.#ensureConnection();
563
- const conn = this.#conn;
564
- if (!conn.insertArrowFromIPCStream) {
565
- throw new Error('Arrow IPC loading requires a DuckDB-WASM connection');
566
- }
567
- await conn.insertArrowFromIPCStream(data, options);
1150
+ await this.#send('insert', `INSERT ARROW INTO "${options.name}" (${data.byteLength} bytes)`, { class: 'interaction' }, {
1151
+ run: async (conn) => {
1152
+ if (!conn.insertArrowFromIPCStream) {
1153
+ throw new Error('Arrow IPC loading requires a DuckDB-WASM connection');
1154
+ }
1155
+ await conn.insertArrowFromIPCStream(data, options);
1156
+ }
1157
+ });
1158
+ // The notebook widget's load path: a table made from Arrow is a table.
1159
+ if (options.create)
1160
+ this.#noteDDL(`CREATE TABLE "${options.name.replaceAll('"', '""')}" (x)`);
568
1161
  }
569
1162
  // -----------------------------------------------------------------
570
1163
  // v3: Mark registration + batch filter queries
@@ -591,15 +1184,29 @@ export class QueryEngine {
591
1184
  }
592
1185
  // Reuse existing handle to preserve arrowTable during re-query
593
1186
  const handle = prev?.handle ?? new MarkHandle();
594
- const reg = { descriptor, handle, filterGroup };
1187
+ const reg = {
1188
+ descriptor,
1189
+ handle,
1190
+ filterGroup,
1191
+ stale: false,
1192
+ needsFirst: !prev || prev.descriptor.parts !== descriptor.parts
1193
+ };
595
1194
  this.#marks.set(markId, reg);
596
1195
  this.#preagg.trackTable(descriptor.baseTable);
597
1196
  handle._onParamsChange = () => this.#queryMark(markId, reg);
1197
+ handle._onActivate = () => {
1198
+ if (!reg.stale)
1199
+ return false;
1200
+ reg.stale = false;
1201
+ this.#queryMark(markId, reg, 'catchUp');
1202
+ return true;
1203
+ };
598
1204
  handle.destroy = () => {
599
1205
  this.cancel(markId);
600
1206
  this.#preagg.dropMark(markId);
601
1207
  this.#marks.delete(markId);
602
1208
  handle._onParamsChange = null;
1209
+ handle._onActivate = null;
603
1210
  };
604
1211
  // Re-registration: trigger re-query immediately with updated descriptor.
605
1212
  // The params effect won't fire since the handle reference didn't change.
@@ -664,16 +1271,37 @@ export class QueryEngine {
664
1271
  * Query a single mark: on a filter change or a params
665
1272
  * change (via _onParamsChange). Reads a preagg table when one answers; if
666
1273
  * that read fails, the table is set aside and the base table is queried once.
1274
+ *
1275
+ * `cause` travels with this query's own promise chain to `_setResult`, not
1276
+ * on the handle: a catch-up in flight and an update waiting behind it are
1277
+ * two chains, so each result lands with its own cause. A filter change
1278
+ * during a catch-up lets the catch-up land (without animating) and the
1279
+ * update then animates from there; a superseded pending query rejects, and
1280
+ * its chain sets nothing.
667
1281
  */
668
- #queryMark(markId, reg) {
1282
+ #queryMark(markId, reg, cause = 'update') {
669
1283
  const { handle } = reg;
1284
+ // Off screen and already holding data for this registration: nothing to
1285
+ // show for it now. A registration's first query still runs (it can feed
1286
+ // a domain another chart shares), including after a re-registration
1287
+ // with new parts, where the handle still holds the old data.
1288
+ if (!handle.active && handle.dataVersion > 0 && !reg.needsFirst) {
1289
+ reg.stale = true;
1290
+ return;
1291
+ }
1292
+ reg.stale = false;
670
1293
  const preagg = this.#preagg.sqlFor(markId, reg);
671
1294
  const sqlStr = preagg?.sql || this.#baseSQL(markId, reg);
672
1295
  if (!sqlStr)
673
1296
  return;
1297
+ // Only once a first query actually goes out.
1298
+ reg.needsFirst = false;
674
1299
  // Use the public query() path — its execute-latest logic ensures
675
1300
  // at most 1 inflight + 1 pending per callerId during rapid brushing.
676
- this.query(sqlStr, { callerId: markId })
1301
+ // A preagg table is built in memory by fjader: fast until measured.
1302
+ if (preagg)
1303
+ this.#routing.prior(preagg.table, 'fast');
1304
+ this.query(sqlStr, markReadOptions(markId, reg.descriptor, preagg?.table ?? null))
677
1305
  .catch((e) => {
678
1306
  if (!preagg || e?.name === 'AbortError')
679
1307
  throw e;
@@ -681,9 +1309,9 @@ export class QueryEngine {
681
1309
  const baseSQL = this.#baseSQL(markId, reg);
682
1310
  if (!baseSQL)
683
1311
  throw e;
684
- return this.query(baseSQL, { callerId: markId });
1312
+ return this.query(baseSQL, markReadOptions(markId, reg.descriptor, null));
685
1313
  })
686
- .then((table) => handle._setResult(table))
1314
+ .then((table) => handle._setResult(table, cause))
687
1315
  .catch((e) => {
688
1316
  if (e?.name === 'AbortError')
689
1317
  return;
@@ -776,6 +1404,8 @@ export class QueryEngine {
776
1404
  pending.reject(new DOMException('Engine destroyed', 'AbortError'));
777
1405
  }
778
1406
  this.#pending.clear();
1407
+ // The preagg drops above ran on the lane; close it, then the connection.
1408
+ await Promise.all([this.#backgroundLane, ...this.#sourceLanes].map((slot) => slot.close()));
779
1409
  const conn = this.#conn ?? (this.#connPromise ? await this.#connPromise : null);
780
1410
  if (conn)
781
1411
  await conn.close();