squirreling 0.12.26 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squirreling",
3
- "version": "0.12.26",
3
+ "version": "0.13.0",
4
4
  "description": "Squirreling Async SQL Engine",
5
5
  "author": "Hyperparam",
6
6
  "homepage": "https://hyperparam.app",
@@ -42,7 +42,7 @@
42
42
  "@types/node": "26.1.0",
43
43
  "@vitest/coverage-v8": "4.1.9",
44
44
  "eslint": "9.39.4",
45
- "eslint-plugin-jsdoc": "63.0.10",
45
+ "eslint-plugin-jsdoc": "63.0.11",
46
46
  "typescript": "6.0.3",
47
47
  "vitest": "4.1.9"
48
48
  }
@@ -0,0 +1,89 @@
1
+ import { keyify } from './utils.js'
2
+
3
+ /**
4
+ * @import { SqlPrimitive } from '../types.js'
5
+ */
6
+
7
+ /**
8
+ * Incremental state for one streamable aggregate. Shared by the streaming
9
+ * aggregate executor and the scanColumn fast path so COUNT/COUNTIF/SUM/AVG/
10
+ * MIN/MAX fold semantics live in one place.
11
+ *
12
+ * @typedef {{
13
+ * count: number,
14
+ * sum: number,
15
+ * min: SqlPrimitive,
16
+ * max: SqlPrimitive,
17
+ * seen: Set<unknown> | null,
18
+ * }} Accumulator
19
+ */
20
+
21
+ /**
22
+ * @param {string} funcName
23
+ * @param {boolean} [distinct]
24
+ * @returns {Accumulator}
25
+ */
26
+ export function newAccumulator(funcName, distinct) {
27
+ return {
28
+ count: 0,
29
+ sum: 0,
30
+ min: null,
31
+ max: null,
32
+ seen: funcName === 'COUNT' && distinct ? new Set() : null,
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Folds one value into an accumulator, matching the buffered semantics in
38
+ * evaluate.js: COUNT counts non-null, COUNTIF counts truthy, MIN/MAX compare
39
+ * raw values, SUM/AVG only accumulate finite numbers.
40
+ *
41
+ * @param {string} funcName
42
+ * @param {Accumulator} acc
43
+ * @param {SqlPrimitive} value
44
+ */
45
+ export function updateAccumulator(funcName, acc, value) {
46
+ switch (funcName) {
47
+ case 'COUNT':
48
+ if (value == null) break
49
+ if (acc.seen) acc.seen.add(keyify(value))
50
+ else acc.count++
51
+ break
52
+ case 'COUNTIF':
53
+ if (value) acc.count++
54
+ break
55
+ case 'MIN':
56
+ if (value != null && (acc.min === null || value < acc.min)) acc.min = value
57
+ break
58
+ case 'MAX':
59
+ if (value != null && (acc.max === null || value > acc.max)) acc.max = value
60
+ break
61
+ default: { // SUM, AVG
62
+ if (value == null) break
63
+ const num = Number(value)
64
+ if (Number.isFinite(num)) {
65
+ acc.sum += num
66
+ acc.count++
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Reduces an accumulator to its final aggregate value.
74
+ *
75
+ * @param {string} funcName
76
+ * @param {Accumulator} acc
77
+ * @returns {SqlPrimitive}
78
+ */
79
+ export function finalizeAccumulator(funcName, acc) {
80
+ switch (funcName) {
81
+ case 'COUNT': return acc.seen ? acc.seen.size : acc.count
82
+ case 'COUNTIF': return acc.count
83
+ case 'SUM': return acc.count === 0 ? null : acc.sum
84
+ case 'AVG': return acc.count === 0 ? null : acc.sum / acc.count
85
+ case 'MIN': return acc.min
86
+ case 'MAX': return acc.max
87
+ default: return null
88
+ }
89
+ }
@@ -1,7 +1,9 @@
1
1
  import { derivedAlias } from '../expression/alias.js'
2
2
  import { evaluateExpr } from '../expression/evaluate.js'
3
+ import { finalizeAccumulator, newAccumulator, updateAccumulator } from './accumulator.js'
3
4
  import { executePlan, selectColumnNames } from './execute.js'
4
5
  import { sortEntriesByTerms } from './sort.js'
6
+ import { planStreamingAggregates, streamingHashAggregateRows, streamingScalarAggregateRows } from './streamingAggregate.js'
5
7
  import { keyify } from './utils.js'
6
8
  import { yieldToEventLoop } from './yield.js'
7
9
 
@@ -80,6 +82,14 @@ function aggregateContextRow(group, aggregateRow) {
80
82
  */
81
83
  export function executeHashAggregate(plan, context) {
82
84
  const child = executePlan({ plan: plan.child, context })
85
+ const streaming = planStreamingAggregates(plan, child.columns)
86
+ if (streaming) {
87
+ return {
88
+ columns: selectColumnNames(plan.columns, child.columns),
89
+ maxRows: child.maxRows,
90
+ rows: streamingHashAggregateRows({ plan, streaming, child, context }),
91
+ }
92
+ }
83
93
  return {
84
94
  columns: selectColumnNames(plan.columns, child.columns),
85
95
  maxRows: child.maxRows,
@@ -91,7 +101,7 @@ export function executeHashAggregate(plan, context) {
91
101
  for await (const row of child.rows()) {
92
102
  if (++collectCount % YIELD_INTERVAL === 0) {
93
103
  await yieldToEventLoop()
94
- if (context.signal?.aborted) return
104
+ context.signal?.throwIfAborted()
95
105
  }
96
106
  allRows.push(row)
97
107
  }
@@ -111,7 +121,7 @@ export function executeHashAggregate(plan, context) {
111
121
  for (let chunkStart = 0; chunkStart < allRows.length; chunkStart += YIELD_INTERVAL) {
112
122
  if (chunkStart > 0) {
113
123
  await yieldToEventLoop()
114
- if (context.signal?.aborted) return
124
+ context.signal?.throwIfAborted()
115
125
  }
116
126
  const chunkEnd = Math.min(chunkStart + YIELD_INTERVAL, allRows.length)
117
127
  const chunkLen = chunkEnd - chunkStart
@@ -196,6 +206,15 @@ export function executeScalarAggregate(plan, context) {
196
206
  }
197
207
 
198
208
  const child = executePlan({ plan: plan.child, context })
209
+ const streaming = planStreamingAggregates(plan, child.columns)
210
+ if (streaming) {
211
+ return {
212
+ columns: selectColumnNames(plan.columns, child.columns),
213
+ numRows: plan.having ? undefined : 1,
214
+ maxRows: 1,
215
+ rows: streamingScalarAggregateRows({ plan, streaming, child, context }),
216
+ }
217
+ }
199
218
  return {
200
219
  columns: selectColumnNames(plan.columns, child.columns),
201
220
  numRows: plan.having ? undefined : 1,
@@ -208,7 +227,7 @@ export function executeScalarAggregate(plan, context) {
208
227
  for await (const row of child.rows()) {
209
228
  if (++collectCount % YIELD_INTERVAL === 0) {
210
229
  await yieldToEventLoop()
211
- if (context.signal?.aborted) return
230
+ context.signal?.throwIfAborted()
212
231
  }
213
232
  group.push(row)
214
233
  }
@@ -344,17 +363,6 @@ function extractColumnAggSpec({ expr, alias }) {
344
363
  }
345
364
  }
346
365
 
347
- /**
348
- * @typedef {{
349
- * spec: ColumnAggSpec,
350
- * count: number,
351
- * sum: number,
352
- * min: SqlPrimitive,
353
- * max: SqlPrimitive,
354
- * seen: Set<unknown> | null,
355
- * }} AggAccumulator
356
- */
357
-
358
366
  /**
359
367
  * Scans a column once and computes every aggregate over it in a single pass.
360
368
  * All specs share the one scanColumn walk, so MIN(x)/MAX(x)/AVG(x) decode x once.
@@ -371,41 +379,15 @@ async function scanColumnGroup({ table, specs, limit, offset, signal }) {
371
379
  const { column } = specs[0]
372
380
  const values = table.scanColumn({ column, limit, offset, signal })
373
381
 
374
- /** @type {AggAccumulator[]} */
375
- const accs = specs.map(spec => /** @type {AggAccumulator} */ ({
376
- spec,
377
- count: 0,
378
- sum: 0,
379
- min: null,
380
- max: null,
381
- seen: spec.funcName === 'COUNT' && spec.distinct ? new Set() : null,
382
- }))
382
+ const accs = specs.map(spec => ({ spec, acc: newAccumulator(spec.funcName, spec.distinct) }))
383
383
 
384
384
  for await (const chunk of values) {
385
385
  signal?.throwIfAborted()
386
386
  for (let i = 0; i < chunk.length; i++) {
387
387
  const v = chunk[i]
388
388
  if (v == null) continue
389
- for (const acc of accs) {
390
- switch (acc.spec.funcName) {
391
- case 'COUNT':
392
- if (acc.seen) acc.seen.add(keyify(v))
393
- else acc.count++
394
- break
395
- case 'MIN':
396
- if (acc.min === null || v < acc.min) acc.min = v
397
- break
398
- case 'MAX':
399
- if (acc.max === null || v > acc.max) acc.max = v
400
- break
401
- default: { // SUM, AVG
402
- const num = Number(v)
403
- if (Number.isFinite(num)) {
404
- acc.sum += num
405
- acc.count++
406
- }
407
- }
408
- }
389
+ for (const { spec, acc } of accs) {
390
+ updateAccumulator(spec.funcName, acc, v)
409
391
  }
410
392
  }
411
393
  }
@@ -413,25 +395,8 @@ async function scanColumnGroup({ table, specs, limit, offset, signal }) {
413
395
 
414
396
  /** @type {Map<string, SqlPrimitive>} */
415
397
  const result = new Map()
416
- for (const acc of accs) {
417
- result.set(acc.spec.alias, finalizeAgg(acc))
398
+ for (const { spec, acc } of accs) {
399
+ result.set(spec.alias, finalizeAccumulator(spec.funcName, acc))
418
400
  }
419
401
  return result
420
402
  }
421
-
422
- /**
423
- * Reduces one accumulator to its final aggregate value.
424
- *
425
- * @param {AggAccumulator} acc
426
- * @returns {SqlPrimitive}
427
- */
428
- function finalizeAgg(acc) {
429
- switch (acc.spec.funcName) {
430
- case 'COUNT': return acc.seen ? acc.seen.size : acc.count
431
- case 'SUM': return acc.count === 0 ? null : acc.sum
432
- case 'AVG': return acc.count === 0 ? null : acc.sum / acc.count
433
- case 'MIN': return acc.min
434
- case 'MAX': return acc.max
435
- default: return null
436
- }
437
- }
@@ -174,7 +174,7 @@ function executeUnnest(plan, context) {
174
174
  const value = await evaluateExpr({ node: plan.args[0], row, rowIndex: 1, context })
175
175
  if (!Array.isArray(value)) return
176
176
  for (const element of value) {
177
- if (context.signal?.aborted) return
177
+ context.signal?.throwIfAborted()
178
178
  yield {
179
179
  columns,
180
180
  cells: { [columnName]: () => Promise.resolve(element) },
@@ -209,7 +209,7 @@ function executeJsonEach(plan, context) {
209
209
  }
210
210
  if (Array.isArray(parsed)) {
211
211
  for (let i = 0; i < parsed.length; i++) {
212
- if (context.signal?.aborted) return
212
+ context.signal?.throwIfAborted()
213
213
  const k = i
214
214
  const v = parsed[i]
215
215
  yield {
@@ -224,7 +224,7 @@ function executeJsonEach(plan, context) {
224
224
  }
225
225
  if (typeof parsed === 'object' && parsed !== null) {
226
226
  for (const [k, v] of Object.entries(parsed)) {
227
- if (context.signal?.aborted) return
227
+ context.signal?.throwIfAborted()
228
228
  yield {
229
229
  columns,
230
230
  cells: {
@@ -294,7 +294,7 @@ function executeScan(plan, context) {
294
294
  async *rows() {
295
295
  const columns = [column]
296
296
  for await (const chunk of chunks) {
297
- if (signal?.aborted) return
297
+ signal?.throwIfAborted()
298
298
  for (let i = 0; i < chunk.length; i++) {
299
299
  const value = chunk[i]
300
300
  yield {
@@ -303,6 +303,9 @@ function executeScan(plan, context) {
303
303
  }
304
304
  }
305
305
  }
306
+ // A data source may end its stream cooperatively on abort; surface
307
+ // the abort so a truncated scan is not mistaken for a complete one
308
+ signal?.throwIfAborted()
306
309
  },
307
310
  }
308
311
  }
@@ -335,6 +338,10 @@ function executeScan(plan, context) {
335
338
  }
336
339
 
337
340
  yield* result
341
+
342
+ // A data source may end its stream cooperatively on abort; surface
343
+ // the abort so a truncated scan is not mistaken for a complete one
344
+ signal?.throwIfAborted()
338
345
  },
339
346
  }
340
347
  }
@@ -420,10 +427,10 @@ async function* filterRows(rows, condition, context, limit) {
420
427
  let buffer = []
421
428
 
422
429
  for await (const row of rows) {
423
- if (context.signal?.aborted) return
430
+ context.signal?.throwIfAborted()
424
431
  if (++innerCount % YIELD_INTERVAL === 0) {
425
432
  await yieldToEventLoop()
426
- if (context.signal?.aborted) return
433
+ context.signal?.throwIfAborted()
427
434
  }
428
435
  rowIndex++
429
436
  buffer.push({ row, rowIndex })
@@ -466,10 +473,10 @@ async function* limitRows(rows, limit = Infinity, offset = 0, signal) {
466
473
  let yielded = 0
467
474
  let innerCount = 0
468
475
  for await (const row of rows) {
469
- if (signal?.aborted) return
476
+ signal?.throwIfAborted()
470
477
  if (++innerCount % YIELD_INTERVAL === 0) {
471
478
  await yieldToEventLoop()
472
- if (signal?.aborted) return
479
+ signal?.throwIfAborted()
473
480
  }
474
481
  if (skipped < offset) {
475
482
  skipped++
@@ -521,10 +528,10 @@ function executeProject(plan, context) {
521
528
  let innerCount = 0
522
529
 
523
530
  for await (const row of child.rows()) {
524
- if (context.signal?.aborted) return
531
+ context.signal?.throwIfAborted()
525
532
  if (++innerCount % YIELD_INTERVAL === 0) {
526
533
  await yieldToEventLoop()
527
- if (context.signal?.aborted) return
534
+ context.signal?.throwIfAborted()
528
535
  }
529
536
  rowIndex++
530
537
  const currentRowIndex = rowIndex
@@ -614,10 +621,10 @@ function executeDistinct(plan, context) {
614
621
  let innerCount = 0
615
622
 
616
623
  for await (const row of child.rows()) {
617
- if (signal?.aborted) return
624
+ signal?.throwIfAborted()
618
625
  if (++innerCount % YIELD_INTERVAL === 0) {
619
626
  await yieldToEventLoop()
620
- if (signal?.aborted) return
627
+ signal?.throwIfAborted()
621
628
  }
622
629
  buffer.push(row)
623
630
 
@@ -701,10 +708,10 @@ function executeSetOperation(plan, context) {
701
708
  const seen = new Set()
702
709
  let count = 0
703
710
  for await (const row of left.rows()) {
704
- if (signal?.aborted) return
711
+ signal?.throwIfAborted()
705
712
  if (++count % YIELD_INTERVAL === 0) {
706
713
  await yieldToEventLoop()
707
- if (signal?.aborted) return
714
+ signal?.throwIfAborted()
708
715
  }
709
716
  const key = await stableRowKey(row)
710
717
  if (!seen.has(key)) {
@@ -713,10 +720,10 @@ function executeSetOperation(plan, context) {
713
720
  }
714
721
  }
715
722
  for await (const row of right.rows()) {
716
- if (signal?.aborted) return
723
+ signal?.throwIfAborted()
717
724
  if (++count % YIELD_INTERVAL === 0) {
718
725
  await yieldToEventLoop()
719
- if (signal?.aborted) return
726
+ signal?.throwIfAborted()
720
727
  }
721
728
  const key = await stableRowKey(row)
722
729
  if (!seen.has(key)) {
@@ -739,10 +746,10 @@ function executeSetOperation(plan, context) {
739
746
  const rightKeys = new Map()
740
747
  let tick = 0
741
748
  for await (const row of right.rows()) {
742
- if (signal?.aborted) return
749
+ signal?.throwIfAborted()
743
750
  if (++tick % YIELD_INTERVAL === 0) {
744
751
  await yieldToEventLoop()
745
- if (signal?.aborted) return
752
+ signal?.throwIfAborted()
746
753
  }
747
754
  const key = await stableRowKey(row)
748
755
  rightKeys.set(key, (rightKeys.get(key) ?? 0) + 1)
@@ -751,10 +758,10 @@ function executeSetOperation(plan, context) {
751
758
  if (plan.all) {
752
759
  // INTERSECT ALL: yield each left row that matches, consuming right counts
753
760
  for await (const row of left.rows()) {
754
- if (signal?.aborted) return
761
+ signal?.throwIfAborted()
755
762
  if (++tick % YIELD_INTERVAL === 0) {
756
763
  await yieldToEventLoop()
757
- if (signal?.aborted) return
764
+ signal?.throwIfAborted()
758
765
  }
759
766
  const key = await stableRowKey(row)
760
767
  const count = rightKeys.get(key)
@@ -767,10 +774,10 @@ function executeSetOperation(plan, context) {
767
774
  // INTERSECT: yield deduplicated rows present in both
768
775
  const seen = new Set()
769
776
  for await (const row of left.rows()) {
770
- if (signal?.aborted) return
777
+ signal?.throwIfAborted()
771
778
  if (++tick % YIELD_INTERVAL === 0) {
772
779
  await yieldToEventLoop()
773
- if (signal?.aborted) return
780
+ signal?.throwIfAborted()
774
781
  }
775
782
  const key = await stableRowKey(row)
776
783
  if (rightKeys.has(key) && !seen.has(key)) {
@@ -794,10 +801,10 @@ function executeSetOperation(plan, context) {
794
801
  const rightKeys = new Map()
795
802
  let tick = 0
796
803
  for await (const row of right.rows()) {
797
- if (signal?.aborted) return
804
+ signal?.throwIfAborted()
798
805
  if (++tick % YIELD_INTERVAL === 0) {
799
806
  await yieldToEventLoop()
800
- if (signal?.aborted) return
807
+ signal?.throwIfAborted()
801
808
  }
802
809
  const key = await stableRowKey(row)
803
810
  rightKeys.set(key, (rightKeys.get(key) ?? 0) + 1)
@@ -806,10 +813,10 @@ function executeSetOperation(plan, context) {
806
813
  if (plan.all) {
807
814
  // EXCEPT ALL: yield left rows, consuming right counts
808
815
  for await (const row of left.rows()) {
809
- if (signal?.aborted) return
816
+ signal?.throwIfAborted()
810
817
  if (++tick % YIELD_INTERVAL === 0) {
811
818
  await yieldToEventLoop()
812
- if (signal?.aborted) return
819
+ signal?.throwIfAborted()
813
820
  }
814
821
  const key = await stableRowKey(row)
815
822
  const count = rightKeys.get(key)
@@ -823,10 +830,10 @@ function executeSetOperation(plan, context) {
823
830
  // EXCEPT: yield deduplicated left rows not in right
824
831
  const seen = new Set()
825
832
  for await (const row of left.rows()) {
826
- if (signal?.aborted) return
833
+ signal?.throwIfAborted()
827
834
  if (++tick % YIELD_INTERVAL === 0) {
828
835
  await yieldToEventLoop()
829
- if (signal?.aborted) return
836
+ signal?.throwIfAborted()
830
837
  }
831
838
  const key = await stableRowKey(row)
832
839
  if (!rightKeys.has(key) && !seen.has(key)) {
@@ -56,7 +56,7 @@ export function executeNestedLoopJoin(plan, context) {
56
56
  /** @type {AsyncRow[]} */
57
57
  const innerRows = []
58
58
  for await (const row of inner.rows()) {
59
- if (context.signal?.aborted) return
59
+ context.signal?.throwIfAborted()
60
60
  innerRows.push(row)
61
61
  }
62
62
 
@@ -69,7 +69,7 @@ export function executeNestedLoopJoin(plan, context) {
69
69
 
70
70
  let innerCount = 0
71
71
  for await (const outerRow of outer.rows()) {
72
- if (context.signal?.aborted) return
72
+ context.signal?.throwIfAborted()
73
73
 
74
74
  if (!outerCols) {
75
75
  outerCols = outerRow.columns
@@ -80,7 +80,7 @@ export function executeNestedLoopJoin(plan, context) {
80
80
  for (const innerRow of innerRows) {
81
81
  if (++innerCount % YIELD_INTERVAL === 0) {
82
82
  await yieldToEventLoop()
83
- if (context.signal?.aborted) return
83
+ context.signal?.throwIfAborted()
84
84
  }
85
85
  const tempMerged = merge(outerRow, innerRow)
86
86
  const matches = await evaluateExpr({
@@ -101,7 +101,7 @@ export function executeNestedLoopJoin(plan, context) {
101
101
  }
102
102
  }
103
103
 
104
- if (context.signal?.aborted) return
104
+ context.signal?.throwIfAborted()
105
105
 
106
106
  // Unmatched inner rows for outer joins on the buffered side
107
107
  if (matchedInnerRows) {
@@ -134,7 +134,7 @@ function executeLateralJoin(plan, context) {
134
134
  const rightTable = plan.rightAlias
135
135
 
136
136
  for await (const leftRow of left.rows()) {
137
- if (context.signal?.aborted) return
137
+ context.signal?.throwIfAborted()
138
138
 
139
139
  // When nested inside a correlated subquery, preserve the enclosing
140
140
  // outer row so UNNEST args can reference its columns (e.g. o.arr).
@@ -146,7 +146,7 @@ function executeLateralJoin(plan, context) {
146
146
 
147
147
  let hasMatch = false
148
148
  for await (const rightRow of right.rows()) {
149
- if (context.signal?.aborted) return
149
+ context.signal?.throwIfAborted()
150
150
  const merged = mergeRows(leftRow, rightRow, leftTable, rightTable)
151
151
  const matches = plan.condition === undefined
152
152
  ? true
@@ -199,10 +199,10 @@ export function executePositionalJoin(plan, context) {
199
199
  while (true) {
200
200
  const [leftResult, rightResult] = await Promise.all([leftRows.next(), rightRows.next()])
201
201
  if (leftResult.done && rightResult.done) return
202
- if (signal?.aborted) return
202
+ signal?.throwIfAborted()
203
203
  if (++tick % YIELD_INTERVAL === 0) {
204
204
  await yieldToEventLoop()
205
- if (signal?.aborted) return
205
+ signal?.throwIfAborted()
206
206
  }
207
207
  if (!leftResult.done && !leftCols.length) leftCols = leftResult.value.columns
208
208
  if (!rightResult.done && !rightCols.length) rightCols = rightResult.value.columns
@@ -266,10 +266,10 @@ export function executeHashJoin(plan, context) {
266
266
  let buildCols = []
267
267
  let innerCount = 0
268
268
  for await (const buildRow of build.rows()) {
269
- if (context.signal?.aborted) return
269
+ context.signal?.throwIfAborted()
270
270
  if (++innerCount % YIELD_INTERVAL === 0) {
271
271
  await yieldToEventLoop()
272
- if (context.signal?.aborted) return
272
+ context.signal?.throwIfAborted()
273
273
  }
274
274
  if (!buildCols.length) {
275
275
  buildCols = buildRow.columns
@@ -297,7 +297,7 @@ export function executeHashJoin(plan, context) {
297
297
 
298
298
  // Probe phase: stream the other side
299
299
  for await (const probeRow of probe.rows()) {
300
- if (context.signal?.aborted) return
300
+ context.signal?.throwIfAborted()
301
301
 
302
302
  if (!probeCols) {
303
303
  probeCols = probeRow.columns
@@ -314,7 +314,7 @@ export function executeHashJoin(plan, context) {
314
314
  for (const buildRow of candidates) {
315
315
  if (++innerCount % YIELD_INTERVAL === 0) {
316
316
  await yieldToEventLoop()
317
- if (context.signal?.aborted) return
317
+ context.signal?.throwIfAborted()
318
318
  }
319
319
  const merged = merge(probeRow, buildRow)
320
320
  if (residual) {
@@ -333,7 +333,7 @@ export function executeHashJoin(plan, context) {
333
333
  }
334
334
  }
335
335
 
336
- if (context.signal?.aborted) return
336
+ context.signal?.throwIfAborted()
337
337
 
338
338
  // Unmatched build rows for outer joins on the build side
339
339
  if (buildRows && matchedBuildRows) {
@@ -4,16 +4,21 @@ import { executePlan } from './execute.js'
4
4
  import { compareForTerm } from './utils.js'
5
5
 
6
6
  /**
7
- * @import { AsyncRow, ExecuteContext, OrderByItem, QueryResults, SqlPrimitive } from '../types.js'
7
+ * @import { AsyncRow, ExecuteContext, ExprNode, OrderByItem, QueryResults, SqlPrimitive } from '../types.js'
8
8
  * @import { SortNode } from '../plan/types.js'
9
9
  */
10
10
 
11
11
  const MAX_CHUNK = 256
12
12
 
13
13
  /**
14
+ * When exprs is set, exprs[i] is evaluated for ORDER BY term i instead of the
15
+ * term's own expression, so callers can pre-substitute per-entry values (for
16
+ * example, finalized aggregates) while keeping tie-aware term evaluation.
17
+ *
14
18
  * @typedef {{
15
19
  * row: AsyncRow,
16
20
  * rows?: AsyncRow[],
21
+ * exprs?: ExprNode[],
17
22
  * }} SortEntry
18
23
  */
19
24
 
@@ -63,7 +68,7 @@ export async function sortEntriesByTerms({ entries, orderBy, context, cacheValue
63
68
  const chunk = missing.slice(start, start + chunkSize)
64
69
  const values = await Promise.all(chunk.map(idx =>
65
70
  evaluateExpr({
66
- node: term.expr,
71
+ node: entries[idx].exprs?.[orderByIdx] ?? term.expr,
67
72
  row: entries[idx].row,
68
73
  rows: entries[idx].rows,
69
74
  context,
@@ -141,7 +146,7 @@ export function executeSort(plan, context) {
141
146
  /** @type {SortEntry[]} */
142
147
  let entries = []
143
148
  for await (const row of child.rows()) {
144
- if (context.signal?.aborted) return
149
+ context.signal?.throwIfAborted()
145
150
  entries.push({ row })
146
151
  if (entries.length >= bufferLimit) {
147
152
  // Sort keys are cached on the rows, so survivors of one truncation