@fjader/svelte 0.2.3 → 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 (167) hide show
  1. package/README.md +3 -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 +27 -8
  9. package/dist/components/axes/AxisBottom.svelte.d.ts +10 -0
  10. package/dist/components/axes/AxisCenter.svelte +4 -2
  11. package/dist/components/axes/AxisLeft.svelte +81 -10
  12. package/dist/components/axes/AxisLeft.svelte.d.ts +17 -0
  13. package/dist/components/axes/BandAxisTicks.svelte +4 -3
  14. package/dist/components/axes/axis-title.d.ts +30 -0
  15. package/dist/components/axes/axis-title.js +77 -0
  16. package/dist/components/charts/ChartCell.svelte +33 -6
  17. package/dist/components/charts/ChartCell.svelte.d.ts +9 -0
  18. package/dist/components/charts/ChartContainer.svelte +55 -5
  19. package/dist/components/charts/ChartContainer.svelte.d.ts +14 -1
  20. package/dist/components/charts/ChartHover.svelte.d.ts +14 -0
  21. package/dist/components/charts/ChartHover.svelte.js +22 -0
  22. package/dist/components/charts/ChartVisibility.svelte.d.ts +43 -0
  23. package/dist/components/charts/ChartVisibility.svelte.js +67 -0
  24. package/dist/components/guides/Grid.svelte +12 -1
  25. package/dist/components/guides/legend/ContinuousColorLegend.svelte +7 -1
  26. package/dist/components/guides/legend/OrdinalColorLegend.svelte +8 -5
  27. package/dist/components/marks/AreaMarkX.svelte +18 -4
  28. package/dist/components/marks/AreaMarkY.svelte +18 -4
  29. package/dist/components/marks/BarMarkX.svelte +20 -4
  30. package/dist/components/marks/BarMarkY.svelte +20 -4
  31. package/dist/components/marks/DotsMark.svelte +8 -3
  32. package/dist/components/marks/LineMark.svelte +13 -6
  33. package/dist/components/marks/ParallelCoordinatesMark.svelte +11 -2
  34. package/dist/components/marks/ParallelCoordinatesMark.svelte.d.ts +4 -0
  35. package/dist/components/marks/RectMark.svelte +8 -3
  36. package/dist/components/marks/RectXMark.svelte +9 -3
  37. package/dist/components/marks/RectYMark.svelte +9 -3
  38. package/dist/components/marks/RuleMarkX.svelte +31 -25
  39. package/dist/components/marks/RuleMarkY.svelte +31 -25
  40. package/dist/components/marks/TextMark.svelte +52 -14
  41. package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.d.ts +22 -1
  42. package/dist/components/marks/lifecycle/MarkLifecycleV2.svelte.js +579 -66
  43. package/dist/components/marks/lifecycle/domain-queries.d.ts +13 -2
  44. package/dist/components/marks/lifecycle/domain-queries.js +43 -11
  45. package/dist/components/marks/lifecycle/domain-registration.d.ts +6 -1
  46. package/dist/components/marks/lifecycle/domain-registration.js +17 -20
  47. package/dist/components/marks/lifecycle/other-rows.svelte.d.ts +112 -0
  48. package/dist/components/marks/lifecycle/other-rows.svelte.js +193 -0
  49. package/dist/components/marks/lifecycle/other-split.d.ts +80 -0
  50. package/dist/components/marks/lifecycle/other-split.js +112 -0
  51. package/dist/components/marks/lifecycle/query-builder.d.ts +53 -3
  52. package/dist/components/marks/lifecycle/query-builder.js +46 -23
  53. package/dist/components/marks/utils/MarkColor.svelte.d.ts +3 -1
  54. package/dist/components/marks/utils/MarkColor.svelte.js +33 -3
  55. package/dist/components/marks/utils/MarkSeries.svelte.d.ts +14 -1
  56. package/dist/components/marks/utils/MarkSeries.svelte.js +25 -1
  57. package/dist/components/marks/utils/MarkSize.svelte.js +4 -0
  58. package/dist/components/marks/utils/reactive-config.d.ts +1 -1
  59. package/dist/components/marks/utils/reactive-config.js +9 -0
  60. package/dist/components/marks/utils/tooltip-helpers.d.ts +4 -1
  61. package/dist/components/marks/utils/tooltip-helpers.js +2 -1
  62. package/dist/components/rendering/Areas.svelte +57 -19
  63. package/dist/components/rendering/Areas.svelte.d.ts +12 -0
  64. package/dist/components/rendering/BaseGPURenderer.svelte.d.ts +3 -1
  65. package/dist/components/rendering/BaseGPURenderer.svelte.js +4 -2
  66. package/dist/components/rendering/Dots.svelte +23 -3
  67. package/dist/components/rendering/Dots.svelte.d.ts +6 -0
  68. package/dist/components/rendering/GridLine.svelte +13 -1
  69. package/dist/components/rendering/GridLine.svelte.d.ts +5 -0
  70. package/dist/components/rendering/Lines.svelte +48 -13
  71. package/dist/components/rendering/Lines.svelte.d.ts +12 -0
  72. package/dist/components/rendering/ParallelLines.svelte +5 -5
  73. package/dist/components/rendering/Rects.svelte +164 -30
  74. package/dist/components/rendering/Rects.svelte.d.ts +23 -2
  75. package/dist/components/rendering/RuleX.svelte +1 -1
  76. package/dist/components/rendering/RuleY.svelte +1 -1
  77. package/dist/components/rendering/WebGPUCanvas.svelte +4 -2
  78. package/dist/components/rendering/activity-reads.d.ts +47 -12
  79. package/dist/components/rendering/activity-reads.js +61 -19
  80. package/dist/components/rendering/uniform-sizes.d.ts +1 -1
  81. package/dist/components/rendering/uniform-sizes.js +1 -1
  82. package/dist/config/ChartConfig.svelte.d.ts +10 -1
  83. package/dist/config/ChartConfig.svelte.js +17 -0
  84. package/dist/gpu/GPUCoordinator.svelte.d.ts +31 -1
  85. package/dist/gpu/GPUCoordinator.svelte.js +164 -7
  86. package/dist/gpu/MorphedChannels.d.ts +11 -28
  87. package/dist/gpu/MorphedChannels.js +72 -8
  88. package/dist/gpu/ScaleTemplateSystem.d.ts +20 -2
  89. package/dist/gpu/ScaleTemplateSystem.js +83 -6
  90. package/dist/index.d.ts +19 -4
  91. package/dist/index.js +22 -4
  92. package/dist/interactors/Interval1D.svelte +6 -1
  93. package/dist/interactors/Interval2D.svelte +6 -1
  94. package/dist/interactors/ParallelAxisSource.svelte +10 -1
  95. package/dist/interactors/Toggle.svelte +32 -3
  96. package/dist/interactors/Tooltip.svelte +138 -41
  97. package/dist/interactors/TooltipManager.svelte.d.ts +20 -5
  98. package/dist/interactors/TooltipManager.svelte.js +46 -12
  99. package/dist/interactors/other-tooltip.d.ts +16 -0
  100. package/dist/interactors/other-tooltip.js +22 -0
  101. package/dist/query-engine/CrossfilterSelection.svelte.d.ts +4 -2
  102. package/dist/query-engine/CrossfilterSelection.svelte.js +2 -0
  103. package/dist/query-engine/FieldActivity.svelte.d.ts +49 -1
  104. package/dist/query-engine/FieldActivity.svelte.js +175 -5
  105. package/dist/query-engine/FilterGroup.svelte.d.ts +23 -1
  106. package/dist/query-engine/FilterGroup.svelte.js +43 -3
  107. package/dist/query-engine/FilterGroupRegistry.svelte.d.ts +31 -3
  108. package/dist/query-engine/FilterGroupRegistry.svelte.js +62 -15
  109. package/dist/query-engine/MarkHandle.svelte.d.ts +28 -1
  110. package/dist/query-engine/MarkHandle.svelte.js +33 -1
  111. package/dist/query-engine/QueryEngine.d.ts +225 -5
  112. package/dist/query-engine/QueryEngine.js +697 -47
  113. package/dist/query-engine/categorical-identity.d.ts +41 -0
  114. package/dist/query-engine/categorical-identity.js +145 -65
  115. package/dist/query-engine/debug/query-log.d.ts +39 -0
  116. package/dist/query-engine/debug/query-log.js +90 -0
  117. package/dist/query-engine/duckdb.d.ts +55 -8
  118. package/dist/query-engine/duckdb.js +82 -30
  119. package/dist/query-engine/index.d.ts +7 -4
  120. package/dist/query-engine/index.js +3 -3
  121. package/dist/query-engine/preagg/BuildQueue.d.ts +33 -13
  122. package/dist/query-engine/preagg/BuildQueue.js +51 -15
  123. package/dist/query-engine/preagg/Preagg.d.ts +46 -3
  124. package/dist/query-engine/preagg/Preagg.js +159 -31
  125. package/dist/query-engine/preagg/plan.d.ts +33 -1
  126. package/dist/query-engine/preagg/plan.js +158 -14
  127. package/dist/query-engine/sliced-lane.d.ts +108 -0
  128. package/dist/query-engine/sliced-lane.js +299 -0
  129. package/dist/query-engine/source-routing.d.ts +106 -0
  130. package/dist/query-engine/source-routing.js +189 -0
  131. package/dist/query-engine/toggle-predicate.d.ts +21 -0
  132. package/dist/query-engine/toggle-predicate.js +29 -1
  133. package/dist/scales/ActivityTable.d.ts +5 -1
  134. package/dist/scales/ActivityTable.js +12 -3
  135. package/dist/scales/BandScaleState.svelte.d.ts +22 -9
  136. package/dist/scales/BandScaleState.svelte.js +39 -21
  137. package/dist/scales/ChartScales.svelte.d.ts +3 -0
  138. package/dist/scales/ChartScales.svelte.js +26 -8
  139. package/dist/scales/ColorScaleState.svelte.d.ts +18 -2
  140. package/dist/scales/ColorScaleState.svelte.js +138 -26
  141. package/dist/scales/LinearScaleState.svelte.d.ts +7 -10
  142. package/dist/scales/LinearScaleState.svelte.js +21 -39
  143. package/dist/scales/ScaleGeneration.svelte.d.ts +48 -0
  144. package/dist/scales/ScaleGeneration.svelte.js +42 -0
  145. package/dist/scales/ScaleRegistry.svelte.js +3 -1
  146. package/dist/scales/SlotTransition.d.ts +15 -1
  147. package/dist/scales/SlotTransition.js +33 -4
  148. package/dist/scales/TimeScaleState.svelte.d.ts +7 -3
  149. package/dist/scales/TimeScaleState.svelte.js +7 -7
  150. package/dist/scales/color-utils.d.ts +0 -3
  151. package/dist/scales/color-utils.js +0 -3
  152. package/dist/scales/entered.d.ts +8 -0
  153. package/dist/scales/entered.js +10 -0
  154. package/dist/spec/LazyQueryEngine.js +2 -19
  155. package/dist/spec/MarkRegistry.d.ts +3 -1
  156. package/dist/spec/MarkRegistry.js +13 -1
  157. package/dist/spec/PlotFromSpec.svelte +1 -0
  158. package/dist/spec/export.js +11 -2
  159. package/dist/spec/types.d.ts +43 -13
  160. package/dist/sql/bin.d.ts +25 -0
  161. package/dist/sql/bin.js +108 -5
  162. package/dist/theme.css +5 -4
  163. package/dist/transforms/TransformCompiler.d.ts +47 -2
  164. package/dist/transforms/TransformCompiler.js +70 -1
  165. package/dist/transforms/index.js +176 -42
  166. package/dist/types.d.ts +177 -2
  167. package/package.json +4 -3
@@ -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;
@@ -97,7 +156,15 @@ export class QueryEngine {
97
156
  #preagg;
98
157
  /** When a filter last changed, in any group (performance.now()). */
99
158
  #lastFilterChangeAt = 0;
100
- /** LRU query cache: SQL string → Arrow Table (or Promise<Table> while inflight). */
159
+ /**
160
+ * LRU query cache: Arrow Table (or Promise<Table> while inflight), keyed by
161
+ * `#cacheKey`, the SQL text and the dims' version. A mark's query reads dim
162
+ * tables (dense padding pads every category the dim holds), and dims grow
163
+ * after the first queries run: a table folding in, a topN mark reserving its
164
+ * Other. The text does not change when they do, so keyed by text alone the
165
+ * cache served a result from before, with fewer rows: the row set changed
166
+ * between updates and the bars snapped instead of morphing.
167
+ */
101
168
  #queryCache = lruCache({ max: 1000, ttl: 3 * 60 * 60 * 1000 });
102
169
  /**
103
170
  * Per-instance suffix for every table and view this engine creates.
@@ -118,19 +185,44 @@ export class QueryEngine {
118
185
  rows.push(table.get(i));
119
186
  }
120
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;
121
206
  }
122
207
  }, this.#session);
123
208
  /** Notified after clearQueryCache() drops the identity dims and views. */
124
209
  #identityResetListeners = new Set();
125
210
  constructor(connection, options = {}) {
126
211
  this.#preagg = new Preagg({
127
- exec: (sql) => this.exec(sql),
128
- query: (sql, opts) => this.query(sql, opts),
129
- 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(),
130
218
  identity: this.#identity,
131
219
  mark: (markId) => this.#marks.get(markId),
132
- marks: () => this.#marks
220
+ marks: () => this.#marks,
221
+ observingBuilds: () => this.#buildObservers.size > 0,
222
+ onBuild: (event) => this.#notifyBuild(event)
133
223
  }, options.buildMaxWaitMs ?? BUILD_MAX_WAIT_MS);
224
+ this.#sliceShareMs = options.sliceShareMs ?? SLICE_SHARE_MS;
225
+ this.#unbatch = options.unbatch ?? false;
134
226
  if (connection instanceof Promise) {
135
227
  this.#connPromise = connection;
136
228
  connection.then((c) => {
@@ -285,18 +377,316 @@ export class QueryEngine {
285
377
  }
286
378
  }
287
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
+ }
288
451
  /**
289
452
  * Execute a SQL query and return a flechette Table.
290
453
  * Uses the fast IPC path (queryIPC → tableFromIPC) when available,
291
454
  * falling back to the standard conn.query() for mocks/testing.
292
455
  */
293
- 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 = () => {
294
532
  const conn = this.#conn;
295
- if (conn.queryIPC) {
296
- const ipc = await conn.queryIPC(sql);
297
- 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;
298
564
  }
299
- return conn.query(sql);
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
+ }
680
+ }
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);
300
690
  }
301
691
  /** Resolve #conn from #connPromise if needed. No-op when already resolved. */
302
692
  #ensureConnection() {
@@ -324,12 +714,21 @@ export class QueryEngine {
324
714
  if (!this.#conn)
325
715
  await this.#ensureConnection();
326
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
+ };
327
726
  // Cache check: return cached result immediately, bypassing
328
727
  // inflight/pending bookkeeping entirely.
329
728
  // Opt out with cache: false for queries against volatile temp tables.
330
729
  const useCache = opts?.cache !== false;
331
730
  if (useCache) {
332
- const cached = this.#queryCache.get(sqlStr);
731
+ const cached = this.#queryCache.get(this.#cacheKey(sqlStr));
333
732
  if (cached !== undefined) {
334
733
  return cached instanceof Promise ? cached : Promise.resolve(cached);
335
734
  }
@@ -348,27 +747,66 @@ export class QueryEngine {
348
747
  }
349
748
  // Store as pending — will be executed when inflight completes
350
749
  return new Promise((resolve, reject) => {
351
- 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
+ });
352
757
  });
353
758
  }
354
- 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;
355
785
  }
356
786
  // No callerId: direct execution, still respect external signal
357
787
  if (opts?.signal) {
358
788
  if (opts.signal.aborted) {
359
789
  throw new DOMException('Query aborted', 'AbortError');
360
790
  }
361
- const result = await this.#queryRaw(sqlStr);
791
+ const result = await this.#queryRaw(sqlStr, place, 'query', opts.signal);
362
792
  if (opts.signal.aborted) {
363
793
  throw new DOMException('Query aborted', 'AbortError');
364
794
  }
365
795
  return result;
366
796
  }
367
- return this.#queryRaw(sqlStr);
797
+ return this.#queryRaw(sqlStr, place);
368
798
  }
369
- async #executeForCaller(callerId, sqlStr, signal, cache = true) {
799
+ /**
800
+ * The cache key for a query: its text and the dims' version, so a result read
801
+ * against a dim that has since grown is not served again.
802
+ */
803
+ #cacheKey(sqlStr) {
804
+ return `${this.#identity.version}\u0000${sqlStr}`;
805
+ }
806
+ async #executeForCaller(callerId, sqlStr, signal, cache = true, place = { class: 'interaction' }) {
370
807
  const controller = new AbortController();
371
808
  this.#inflight.set(callerId, controller);
809
+ let key = null;
372
810
  if (signal) {
373
811
  signal.addEventListener('abort', () => controller.abort(), { once: true });
374
812
  }
@@ -376,24 +814,28 @@ export class QueryEngine {
376
814
  if (controller.signal.aborted) {
377
815
  throw new DOMException('Query superseded', 'AbortError');
378
816
  }
817
+ // Keyed when it runs, not when it was asked for: a pending query runs
818
+ // later, against whatever the dims hold by then.
819
+ key = this.#cacheKey(sqlStr);
379
820
  // Cache the Promise so concurrent identical queries share one inflight
380
- const queryPromise = this.#queryRaw(sqlStr);
821
+ const queryPromise = this.#queryRaw(sqlStr, place, 'query', controller.signal);
381
822
  if (cache)
382
- this.#queryCache.set(sqlStr, queryPromise);
823
+ this.#queryCache.set(key, queryPromise);
383
824
  const result = await queryPromise;
384
825
  // Replace Promise with resolved data for future lookups
385
826
  if (cache)
386
- this.#queryCache.set(sqlStr, result);
827
+ this.#queryCache.set(key, result);
387
828
  if (controller.signal.aborted) {
388
829
  // Don't cache results from aborted queries
389
- this.#queryCache.delete(sqlStr);
830
+ this.#queryCache.delete(key);
390
831
  throw new DOMException('Query superseded', 'AbortError');
391
832
  }
392
833
  return result;
393
834
  }
394
835
  catch (e) {
395
836
  // Remove failed query from cache so we don't serve a rejected Promise
396
- this.#queryCache.delete(sqlStr);
837
+ if (key !== null)
838
+ this.#queryCache.delete(key);
397
839
  throw e;
398
840
  }
399
841
  finally {
@@ -406,7 +848,7 @@ export class QueryEngine {
406
848
  const pending = this.#pending.get(callerId);
407
849
  if (pending) {
408
850
  this.#pending.delete(callerId);
409
- this.#executeForCaller(callerId, pending.sql, undefined, pending.cache)
851
+ this.#executeForCaller(callerId, pending.sql, undefined, pending.cache, pending.place)
410
852
  .then(pending.resolve)
411
853
  .catch(pending.reject);
412
854
  }
@@ -414,28 +856,117 @@ export class QueryEngine {
414
856
  this.#preagg.pump();
415
857
  }
416
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
+ }
417
902
  /**
418
903
  * Execute DDL/DML statements (CREATE TABLE, DROP TABLE, INSERT, etc.).
419
904
  * Accepts a single SQL string or an array of statements to execute
420
905
  * sequentially.
421
906
  */
422
- async exec(sql) {
907
+ async exec(sql, opts) {
423
908
  if (this.#destroyed) {
424
909
  throw new Error('QueryEngine has been destroyed');
425
910
  }
426
- if (!this.#conn)
427
- await this.#ensureConnection();
911
+ const place = {
912
+ class: opts?.class ?? 'interaction',
913
+ connectionLocal: opts?.connectionLocal
914
+ };
915
+ const signal = opts?.signal;
428
916
  const statements = Array.isArray(sql) ? sql : [sql];
429
- const execFn = this.#conn.exec?.bind(this.#conn);
917
+ // One statement at a time, each committing on its own.
430
918
  for (const stmt of statements) {
431
- if (execFn) {
432
- await execFn(stmt);
433
- }
434
- else {
435
- await this.#conn.query(stmt);
436
- }
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);
437
932
  }
438
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();
439
970
  /**
440
971
  * Get column info for a table via DESCRIBE.
441
972
  * Results are cached — same table returns the same promise.
@@ -449,13 +980,20 @@ export class QueryEngine {
449
980
  return cached;
450
981
  const promise = this.#fetchDescribe(table);
451
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
+ });
452
989
  return promise;
453
990
  }
454
991
  async #fetchDescribe(table) {
455
- if (!this.#conn)
456
- await this.#ensureConnection();
457
992
  const escaped = table.replaceAll('"', '""');
458
- 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');
459
997
  const columns = [];
460
998
  for (let i = 0; i < result.numRows; i++) {
461
999
  const row = result.get(i);
@@ -467,6 +1005,67 @@ export class QueryEngine {
467
1005
  }
468
1006
  return columns;
469
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
+ }
470
1069
  /** Clear cached DESCRIBE results. Pass a table name to clear one, or omit for all. */
471
1070
  clearDescribeCache(table) {
472
1071
  if (table) {
@@ -509,6 +1108,16 @@ export class QueryEngine {
509
1108
  // against dropped state (see CategoricalIdentity's generation counter).
510
1109
  const identityReset = this.#identity.reset().finally(() => this.#notifyIdentityReset());
511
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
+ }
512
1121
  this.#queryCache.clear();
513
1122
  return identityReset;
514
1123
  }
@@ -538,13 +1147,17 @@ export class QueryEngine {
538
1147
  * Throws if the underlying connection doesn't support it (e.g. REST).
539
1148
  */
540
1149
  async insertArrowFromIPCStream(data, options) {
541
- if (!this.#conn)
542
- await this.#ensureConnection();
543
- const conn = this.#conn;
544
- if (!conn.insertArrowFromIPCStream) {
545
- throw new Error('Arrow IPC loading requires a DuckDB-WASM connection');
546
- }
547
- 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)`);
548
1161
  }
549
1162
  // -----------------------------------------------------------------
550
1163
  // v3: Mark registration + batch filter queries
@@ -571,15 +1184,29 @@ export class QueryEngine {
571
1184
  }
572
1185
  // Reuse existing handle to preserve arrowTable during re-query
573
1186
  const handle = prev?.handle ?? new MarkHandle();
574
- 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
+ };
575
1194
  this.#marks.set(markId, reg);
576
1195
  this.#preagg.trackTable(descriptor.baseTable);
577
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
+ };
578
1204
  handle.destroy = () => {
579
1205
  this.cancel(markId);
580
1206
  this.#preagg.dropMark(markId);
581
1207
  this.#marks.delete(markId);
582
1208
  handle._onParamsChange = null;
1209
+ handle._onActivate = null;
583
1210
  };
584
1211
  // Re-registration: trigger re-query immediately with updated descriptor.
585
1212
  // The params effect won't fire since the handle reference didn't change.
@@ -644,16 +1271,37 @@ export class QueryEngine {
644
1271
  * Query a single mark: on a filter change or a params
645
1272
  * change (via _onParamsChange). Reads a preagg table when one answers; if
646
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.
647
1281
  */
648
- #queryMark(markId, reg) {
1282
+ #queryMark(markId, reg, cause = 'update') {
649
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;
650
1293
  const preagg = this.#preagg.sqlFor(markId, reg);
651
1294
  const sqlStr = preagg?.sql || this.#baseSQL(markId, reg);
652
1295
  if (!sqlStr)
653
1296
  return;
1297
+ // Only once a first query actually goes out.
1298
+ reg.needsFirst = false;
654
1299
  // Use the public query() path — its execute-latest logic ensures
655
1300
  // at most 1 inflight + 1 pending per callerId during rapid brushing.
656
- 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))
657
1305
  .catch((e) => {
658
1306
  if (!preagg || e?.name === 'AbortError')
659
1307
  throw e;
@@ -661,9 +1309,9 @@ export class QueryEngine {
661
1309
  const baseSQL = this.#baseSQL(markId, reg);
662
1310
  if (!baseSQL)
663
1311
  throw e;
664
- return this.query(baseSQL, { callerId: markId });
1312
+ return this.query(baseSQL, markReadOptions(markId, reg.descriptor, null));
665
1313
  })
666
- .then((table) => handle._setResult(table))
1314
+ .then((table) => handle._setResult(table, cause))
667
1315
  .catch((e) => {
668
1316
  if (e?.name === 'AbortError')
669
1317
  return;
@@ -756,6 +1404,8 @@ export class QueryEngine {
756
1404
  pending.reject(new DOMException('Engine destroyed', 'AbortError'));
757
1405
  }
758
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()));
759
1409
  const conn = this.#conn ?? (this.#connPromise ? await this.#connPromise : null);
760
1410
  if (conn)
761
1411
  await conn.close();