squirreling 0.12.24 → 0.12.25

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/README.md CHANGED
@@ -12,6 +12,8 @@
12
12
 
13
13
  Squirreling is a streaming async SQL engine in pure JavaScript. Built for the browser from the ground up: streaming input and output, pluggable data sources, and lazy async cell evaluation. This makes Squirreling ideal for querying data from network sources, APIs, or LLMs where latency and cost matter.
14
14
 
15
+ > Part of **[HypStack](https://hypstack.ai/)**, an open-source stack for AI observability.
16
+
15
17
  - **Standard SQL**: Full SQL support for querying data (read-only)
16
18
  - **Async UDFs**: User-defined functions can call APIs or models
17
19
  - **Tiny**: 13 kb bundle, zero dependencies, instant startup
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squirreling",
3
- "version": "0.12.24",
3
+ "version": "0.12.25",
4
4
  "description": "Squirreling Async SQL Engine",
5
5
  "author": "Hyperparam",
6
6
  "homepage": "https://hyperparam.app",
@@ -39,11 +39,11 @@
39
39
  "test": "vitest run"
40
40
  },
41
41
  "devDependencies": {
42
- "@types/node": "25.9.3",
43
- "@vitest/coverage-v8": "4.1.8",
42
+ "@types/node": "26.0.1",
43
+ "@vitest/coverage-v8": "4.1.9",
44
44
  "eslint": "9.39.4",
45
- "eslint-plugin-jsdoc": "63.0.2",
45
+ "eslint-plugin-jsdoc": "63.0.10",
46
46
  "typescript": "6.0.3",
47
- "vitest": "4.1.8"
47
+ "vitest": "4.1.9"
48
48
  }
49
49
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @import { AsyncCells, AsyncDataSource, AsyncRow, ScanColumnOptions, SqlPrimitive } from '../types.js'
2
+ * @import { AsyncCells, AsyncDataSource, AsyncRow, SqlPrimitive } from '../types.js'
3
3
  */
4
4
 
5
5
  /**
@@ -71,13 +71,15 @@ export function memorySource({ data, columns }) {
71
71
  }
72
72
 
73
73
  /**
74
- * Wraps a data source that caches all accessed rows in memory
74
+ * Wraps a data source, memoizing accessed cells. The row cache is a WeakMap
75
+ * keyed on row identity so entries are collectible once the row is unreachable,
76
+ * keeping a streaming scan O(1) instead of O(rows).
75
77
  * @param {AsyncDataSource} source
76
78
  * @returns {AsyncDataSource}
77
79
  */
78
80
  export function cachedDataSource(source) {
79
- /** @type {Map<string, Promise<SqlPrimitive>>} */
80
- const cache = new Map()
81
+ /** @type {WeakMap<object, Map<string, Promise<SqlPrimitive>>>} */
82
+ const cache = new WeakMap()
81
83
  return {
82
84
  ...source,
83
85
  scan(options) {
@@ -90,68 +92,38 @@ export function cachedDataSource(source) {
90
92
  return { rows, appliedWhere, appliedLimitOffset }
91
93
  }
92
94
 
93
- // Adjust index when source applied offset so cache keys match original rows
94
- const indexOffset = appliedLimitOffset && options.offset ? options.offset : 0
95
-
96
95
  return {
97
96
  async *rows() {
98
- let index = 0
99
97
  for await (const row of rows()) {
100
98
  if (options.signal?.aborted) break
101
- const rowIndex = index + indexOffset
99
+ const anchor = row.resolved ?? row
100
+ let rowCache = cache.get(anchor)
101
+ if (!rowCache) {
102
+ rowCache = new Map()
103
+ cache.set(anchor, rowCache)
104
+ }
102
105
  /** @type {AsyncCells} */
103
106
  const cells = {}
104
107
  for (const key of row.columns) {
105
108
  const cell = row.cells[key]
106
- // Wrap the cell to cache accesses
107
109
  cells[key] = () => {
108
- const cacheKey = `${rowIndex}:${key}`
109
- let value = cache.get(cacheKey)
110
+ let value = rowCache.get(key)
110
111
  if (!value) {
111
112
  value = cell()
112
- cache.set(cacheKey, value)
113
+ rowCache.set(key, value)
113
114
  }
114
115
  return value
115
116
  }
116
117
  }
117
- yield { columns: row.columns, cells }
118
- index++
118
+ // Preserve resolved so downstream fast paths still apply.
119
+ yield { columns: row.columns, cells, resolved: row.resolved }
119
120
  }
120
121
  },
121
122
  appliedWhere,
122
123
  appliedLimitOffset,
123
124
  }
124
125
  },
125
- ...source.scanColumn && {
126
- /**
127
- * @param {ScanColumnOptions} options
128
- * @returns {AsyncIterable<ArrayLike<SqlPrimitive>>}
129
- */
130
- scanColumn(options) {
131
- const inner = source.scanColumn(options)
132
- const indexOffset = options.offset ?? 0
133
- return (async function* () {
134
- let chunkStart = 0
135
- for await (const chunk of inner) {
136
- if (options.signal?.aborted) break
137
- /** @type {SqlPrimitive[]} */
138
- const cached = new Array(chunk.length)
139
- for (let i = 0; i < chunk.length; i++) {
140
- const cacheKey = `${chunkStart + i + indexOffset}:${options.column}`
141
- const existing = cache.get(cacheKey)
142
- if (existing) {
143
- cached[i] = await existing
144
- } else {
145
- const value = chunk[i]
146
- cache.set(cacheKey, Promise.resolve(value))
147
- cached[i] = value
148
- }
149
- }
150
- yield cached
151
- chunkStart += chunk.length
152
- }
153
- })()
154
- },
155
- },
126
+ // scanColumn passes through from ...source unwrapped: column values are
127
+ // already materialized, so caching them only retained memory unboundedly.
156
128
  }
157
129
  }
@@ -95,6 +95,7 @@ export function executeHashAggregate(plan, context) {
95
95
  }
96
96
  allRows.push(row)
97
97
  }
98
+ context.signal?.throwIfAborted()
98
99
 
99
100
  // Group rows by GROUP BY keys.
100
101
  // Each chunk dispatches all per-row key evaluations in parallel so
@@ -211,6 +212,7 @@ export function executeScalarAggregate(plan, context) {
211
212
  }
212
213
  group.push(row)
213
214
  }
215
+ context.signal?.throwIfAborted()
214
216
 
215
217
  const asyncRow = projectAggregateColumns(plan.columns, group, context)
216
218
 
@@ -282,9 +284,36 @@ function tryColumnScanAggregate(plan, { tables, signal }) {
282
284
  /** @type {AsyncCells} */
283
285
  const cells = {}
284
286
 
287
+ // Group specs by column so each column is scanned at most once no matter how
288
+ // many aggregates read it (e.g. MIN(x), MAX(x), AVG(x) share one pass).
289
+ /** @type {Map<string, ColumnAggSpec[]>} */
290
+ const specsByColumn = new Map()
291
+ for (const spec of specs) {
292
+ const group = specsByColumn.get(spec.column)
293
+ if (group) group.push(spec)
294
+ else specsByColumn.set(spec.column, [spec])
295
+ }
296
+
297
+ // Each column's single pass is computed once and shared by all its cells;
298
+ // a column is only scanned if one of its aggregates is actually read.
299
+ /** @type {Map<string, Promise<Map<string, SqlPrimitive>>>} */
300
+ const passes = new Map()
301
+ /**
302
+ * @param {string} column
303
+ * @returns {Promise<Map<string, SqlPrimitive>>}
304
+ */
305
+ function scanOnce(column) {
306
+ let pass = passes.get(column)
307
+ if (!pass) {
308
+ pass = scanColumnGroup({ table, specs: specsByColumn.get(column) ?? [], limit, offset, signal })
309
+ passes.set(column, pass)
310
+ }
311
+ return pass
312
+ }
313
+
285
314
  for (const spec of specs) {
286
315
  columns.push(spec.alias)
287
- cells[spec.alias] = () => scanColumnAggregate({ table, spec, limit, offset, signal })
316
+ cells[spec.alias] = async () => (await scanOnce(spec.column)).get(spec.alias) ?? null
288
317
  }
289
318
 
290
319
  yield { columns, cells }
@@ -316,68 +345,93 @@ function extractColumnAggSpec({ expr, alias }) {
316
345
  }
317
346
 
318
347
  /**
319
- * Scans a single column and computes an aggregate value.
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
+ /**
359
+ * Scans a column once and computes every aggregate over it in a single pass.
360
+ * All specs share the one scanColumn walk, so MIN(x)/MAX(x)/AVG(x) decode x once.
320
361
  *
321
362
  * @param {Object} options
322
363
  * @param {AsyncDataSource} options.table
323
- * @param {ColumnAggSpec} options.spec
364
+ * @param {ColumnAggSpec[]} options.specs - aggregates over the same column
324
365
  * @param {number} [options.limit]
325
366
  * @param {number} [options.offset]
326
367
  * @param {AbortSignal} [options.signal]
327
- * @returns {Promise<SqlPrimitive>}
368
+ * @returns {Promise<Map<string, SqlPrimitive>>} alias → aggregate value
328
369
  */
329
- async function scanColumnAggregate({ table, spec, limit, offset, signal }) {
330
- const values = table.scanColumn({ column: spec.column, limit, offset, signal })
331
-
332
- if (spec.funcName === 'COUNT' && spec.distinct) {
333
- const seen = new Set()
334
- for await (const chunk of values) {
335
- if (signal?.aborted) return
336
- for (let i = 0; i < chunk.length; i++) {
337
- const v = chunk[i]
338
- if (v == null) continue
339
- seen.add(keyify(v))
340
- }
341
- }
342
- return seen.size
343
- }
344
-
345
- if (spec.funcName === 'COUNT') {
346
- let count = 0
347
- for await (const chunk of values) {
348
- if (signal?.aborted) return
349
- for (let i = 0; i < chunk.length; i++) {
350
- if (chunk[i] != null) count++
351
- }
352
- }
353
- return count
354
- }
355
-
356
- // SUM, AVG, MIN, MAX
357
- let sum = 0
358
- let count = 0
359
- /** @type {SqlPrimitive} */
360
- let min = null
361
- /** @type {SqlPrimitive} */
362
- let max = null
370
+ async function scanColumnGroup({ table, specs, limit, offset, signal }) {
371
+ const { column } = specs[0]
372
+ const values = table.scanColumn({ column, limit, offset, signal })
373
+
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
+ }))
363
383
 
364
384
  for await (const chunk of values) {
365
- if (signal?.aborted) return
385
+ signal?.throwIfAborted()
366
386
  for (let i = 0; i < chunk.length; i++) {
367
387
  const v = chunk[i]
368
388
  if (v == null) continue
369
- if (min === null || v < min) min = v
370
- if (max === null || v > max) max = v
371
- const num = Number(v)
372
- if (!Number.isFinite(num)) continue
373
- sum += num
374
- count++
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
+ }
409
+ }
375
410
  }
376
411
  }
412
+ signal?.throwIfAborted()
377
413
 
378
- if (spec.funcName === 'SUM') return count === 0 ? null : sum
379
- if (spec.funcName === 'AVG') return count === 0 ? null : sum / count
380
- if (spec.funcName === 'MIN') return min
381
- if (spec.funcName === 'MAX') return max
382
- return null
414
+ /** @type {Map<string, SqlPrimitive>} */
415
+ const result = new Map()
416
+ for (const acc of accs) {
417
+ result.set(acc.spec.alias, finalizeAgg(acc))
418
+ }
419
+ return result
420
+ }
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
+ }
383
437
  }
@@ -356,16 +356,20 @@ function executeCount(plan, context) {
356
356
  numRows: 1,
357
357
  maxRows: 1,
358
358
  async *rows() {
359
- // Use source numRows if available
360
- const countPromise = table.numRows !== undefined ? Promise.resolve(table.numRows) : (async () => {
359
+ const countPromise = (async () => {
360
+ signal?.throwIfAborted()
361
+ // Use source numRows if available
362
+ if (table.numRows !== undefined) return table.numRows
363
+
361
364
  // Fall back to counting rows via scan
362
365
  let count = 0
363
366
  const { rows } = table.scan({ signal })
364
367
  // eslint-disable-next-line no-unused-vars
365
368
  for await (const _ of rows()) {
366
- if (signal?.aborted) return
369
+ signal?.throwIfAborted()
367
370
  count++
368
371
  }
372
+ signal?.throwIfAborted()
369
373
  return count
370
374
  })()
371
375
 
@@ -47,7 +47,7 @@ export function executeNestedLoopJoin(plan, context) {
47
47
 
48
48
  let innerCount = 0
49
49
  for await (const leftRow of left.rows()) {
50
- if (context.signal?.aborted) break
50
+ if (context.signal?.aborted) return
51
51
 
52
52
  if (!leftCols) {
53
53
  leftCols = leftRow.columns
@@ -80,6 +80,8 @@ export function executeNestedLoopJoin(plan, context) {
80
80
  }
81
81
  }
82
82
 
83
+ if (context.signal?.aborted) return
84
+
83
85
  // Unmatched right rows for RIGHT/FULL joins
84
86
  if (matchedRightRows) {
85
87
  for (const rightRow of rightRows) {
@@ -248,7 +250,7 @@ export function executeHashJoin(plan, context) {
248
250
  // Probe phase: stream left rows
249
251
  let innerCount = 0
250
252
  for await (const leftRow of left.rows()) {
251
- if (context.signal?.aborted) break
253
+ if (context.signal?.aborted) return
252
254
 
253
255
  if (!leftCols) {
254
256
  leftCols = leftRow.columns
@@ -285,6 +287,8 @@ export function executeHashJoin(plan, context) {
285
287
  }
286
288
  }
287
289
 
290
+ if (context.signal?.aborted) return
291
+
288
292
  // Unmatched right rows for RIGHT/FULL joins
289
293
  if (matchedRightRows) {
290
294
  for (const rightRow of rightRows) {