From 5e74855bd6fabbe5e18a893292399fe7ba5b8bb6 Mon Sep 17 00:00:00 2001 From: ybai08 <271969568+ybai08@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:44:54 -0500 Subject: [PATCH 1/2] fix: call aggregate init when there are no rows to aggregate SQLite never calls xStep for an aggregate over an empty input, only xFinal. The JavaScript state was only initialized in the step wrapper, so finalize received undefined instead of the value returned by init: `SELECT json_agg(x) FROM t WHERE 0` returned NULL instead of '[]', an aggregate with `init: () => 0` and no finalize returned NULL instead of 0, and a finalize that reads properties of its state threw a TypeError. Initialize the state in the finalize wrapper as well when it is missing. --- src/api.js | 3 +++ test/test_aggregate_functions.js | 30 ++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/api.js b/src/api.js index e391305c..c0d734ba 100644 --- a/src/api.js +++ b/src/api.js @@ -1381,6 +1381,9 @@ Module["onRuntimeInitialized"] = function onRuntimeInitialized() { var result; var p = sqlite3_aggregate_context(cx, 1); try { + // wrapped_step is never called when there are no rows to + // aggregate, so the state may not have been initialized yet + if (!Object.hasOwnProperty.call(state, p)) state[p] = init(); result = finalize(state[p]); } catch (error) { delete state[p]; diff --git a/test/test_aggregate_functions.js b/test/test_aggregate_functions.js index bcbbdb9d..63229d6f 100644 --- a/test/test_aggregate_functions.js +++ b/test/test_aggregate_functions.js @@ -53,6 +53,36 @@ exports.test = function (SQL, assert) { "Multiple aggregations at once" ); + result = db.exec("SELECT json_agg(col) FROM test2 WHERE 0;"); + assert.strictEqual( + result[0].values[0][0], + "[]", + "Aggregate over no rows finalizes the value returned by init" + ); + + db.create_aggregate("count_rows", { + init: () => 0, + step: (state, value) => state + 1, + }); + result = db.exec("SELECT count_rows(col) FROM test2 WHERE 0;"); + assert.strictEqual( + result[0].values[0][0], + 0, + "Aggregate over no rows without finalize returns the init value" + ); + result = db.exec("SELECT count_rows(col) FROM test2;"); + assert.strictEqual(result[0].values[0][0], 3, "Aggregate counts rows"); + + db.create_aggregate("no_init", { + step: (state, value) => value, + }); + result = db.exec("SELECT no_init(col) FROM test2 WHERE 0;"); + assert.strictEqual( + result[0].values[0][0], + null, + "Aggregate over no rows without init returns null" + ); + db.create_aggregate("is_even", { init: () => true, step: state => !state From cd34e84e69f98557a27fa8fe9d03ecfb8ce802a2 Mon Sep 17 00:00:00 2001 From: Ophir Lojkine Date: Mon, 19 Oct 2026 08:25:02 +0000 Subject: [PATCH 2/2] refactor: use a Map for aggregate state --- src/api.js | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/src/api.js b/src/api.js index c0d734ba..0d20c047 100644 --- a/src/api.js +++ b/src/api.js @@ -1343,10 +1343,13 @@ Module["onRuntimeInitialized"] = function onRuntimeInitialized() { throw "An aggregate function must have a step function in " + name; } - // state is a state object; we'll use the pointer p to serve as the - // key for where we hold our state so that multiple invocations of - // this function never step on each other - var state = {}; + // Keep each aggregate invocation's state under its context pointer. + var state = new Map(); + + function getState(p) { + if (!state.has(p)) state.set(p, init()); + return state.get(p); + } function wrapped_step(cx, argc, argv) { // > The first time the sqlite3_aggregate_context(C,N) routine is @@ -1354,7 +1357,7 @@ Module["onRuntimeInitialized"] = function onRuntimeInitialized() { // > bytes of memory, zeroes out that memory, and returns a pointer // > to the new memory. // - // We're going to use that pointer as a key to our state array, + // We're going to use that pointer as a key to our state map, // since using sqlite3_aggregate_context as it's meant to be used // through webassembly seems to be very difficult. Just allocate // one byte. @@ -1363,16 +1366,16 @@ Module["onRuntimeInitialized"] = function onRuntimeInitialized() { // If this is the first invocation of wrapped_step, call `init` // // Make sure that every path through the step and finalize - // functions deletes the value state[p] when it's done so we don't + // functions delete the state entry when done so we don't // leak memory and possibly stomp the init value of future calls - if (!Object.hasOwnProperty.call(state, p)) state[p] = init(); + var currentState = getState(p); var args = parseFunctionArguments(argc, argv); - var mergedArgs = [state[p]].concat(args); + var mergedArgs = [currentState].concat(args); try { - state[p] = step.apply(null, mergedArgs); + state.set(p, step.apply(null, mergedArgs)); } catch (error) { - delete state[p]; + state.delete(p); setFunctionError(cx, error); } } @@ -1383,15 +1386,14 @@ Module["onRuntimeInitialized"] = function onRuntimeInitialized() { try { // wrapped_step is never called when there are no rows to // aggregate, so the state may not have been initialized yet - if (!Object.hasOwnProperty.call(state, p)) state[p] = init(); - result = finalize(state[p]); + result = finalize(getState(p)); } catch (error) { - delete state[p]; + state.delete(p); setFunctionError(cx, error); return; } setFunctionResult(cx, result); - delete state[p]; + state.delete(p); } if (Object.hasOwnProperty.call(this.functions, name)) {