Skip to content

Commit a227ed7

Browse files
os-zhuangclaude
andauthored
fix(objectql)!: one key for the empty group bucket — real null, on both aggregation paths (#3839) (#3848)
`engine.aggregate` has two implementations of one feature and picks between them per query: it pushes the aggregate down as SQL when the driver advertises every requested granularity and the reference timezone is UTC, otherwise it fetches rows and buckets them in JS. The two disagreed about how to spell "empty" — SQL NULL against the in-memory literal `'(null)'` — so the same dataset produced a different bucket key type on SQLite+UTC+`month` than on `week`, a non-UTC timezone, or driver-rest / driver-memory / a remote Turso. The measures were always right; only the key's type and literal differed, which is why it went unnoticed. It was never date-specific either — a plain `groupBy: ['stage']` over a NULL column diverged the same way. Consumers are written against `null`: they check `== null` and supply their own empty label. The sentinel defeated every one of them, leaking a raw English debug string into the UI and compiling a drill on the empty bucket to `field = '(null)'`, which matches nothing. The comment justifying the string cited the client `useReportData` hook, removed with ADR-0021 — and the literal never appeared in it. - `applyInMemoryAggregation` / `bucketDateValue` key the empty bucket as `null`; `bucketDateValue` returns `string | null`. Null and unparseable instants still share one bucket, because SQL cannot tell them apart either. - The internal composite bucket id is JSON-encoded, so the empty bucket stays distinct from a row whose value is the literal string `"null"`. Same in service-analytics' cross-object rebucketing. - `bucketKeyToCalendarRange` accepts `string | null` — the empty bucket has no calendar span, so a drill on it opens the unscoped superset as before. - The driver output contract in spec now states the rule. Gates: `checkDateBucketParity`'s fixture deliberately had no null instant because the divergence would have failed it for a reason it was not about; it has one now. Two fixes made that meaningful — the check folded labels through `String(value)`, which turns SQL NULL into `'null'` and could compare equal to a sentinel string, and it compared label sets with `JSON.stringify`, which is sensitive to key insertion order that the two paths legitimately differ on (a correct driver could be flagged, with an empty diff message). A new dogfood check covers the non-date half against real drivers. Both gates were confirmed to fail with the sentinel restored. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 720c5ad commit a227ed7

14 files changed

Lines changed: 433 additions & 57 deletions
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
---
2+
"@objectstack/objectql": minor
3+
"@objectstack/verify": minor
4+
"@objectstack/core": patch
5+
"@objectstack/service-analytics": patch
6+
"@objectstack/spec": patch
7+
---
8+
9+
fix(objectql)!: one key for the empty group bucket — real `null`, on both aggregation paths (#3839)
10+
11+
A grouped row whose dimension value is empty now carries `null` for that
12+
dimension no matter which way the aggregate ran. Downstream code can test the
13+
empty bucket with a plain `value == null` again: charts render their own empty
14+
label, drill-through on that bucket builds `field = null` and returns the rows
15+
it should, and a dashboard no longer changes shape when the driver, the
16+
granularity or the reference timezone changes.
17+
18+
### What was wrong
19+
20+
`engine.aggregate` has two implementations of one feature. It pushes the
21+
aggregate down as SQL when the driver advertises every requested granularity and
22+
the reference timezone is UTC; otherwise it fetches rows and buckets them in JS.
23+
The two disagreed about how to spell "empty":
24+
25+
```
26+
--- same dataset, same query, one row with a NULL value ---
27+
pushed-down SQL : [{ "key": null, "type": "null", "total": 2 }, …]
28+
in-memory : [{ "key": "(null)", "type": "string", "total": 2 }, …]
29+
```
30+
31+
The measures were always right — only the key's type and literal differed —
32+
which is why this went unnoticed for so long: every total reconciled. But the
33+
engine picks a path per query, so the same data produced a different bucket key
34+
on SQLite-plus-UTC-plus-`month` than on `week` (which SQLite does not advertise),
35+
a non-UTC timezone, or `driver-rest` / `driver-memory` / a remote Turso, all of
36+
which bucket in memory unconditionally.
37+
38+
It was never date-specific either. A plain `groupBy: ['stage']` over a NULL
39+
column diverged the same way.
40+
41+
Consumers are written against `null` — they check `== null` and supply their own
42+
empty label ('—', '(empty)', a localized "Uncategorized"). The sentinel defeated
43+
every one of them: it rendered a raw English debug string in the UI, and a drill
44+
on the empty bucket compiled to `field = '(null)'` and matched nothing.
45+
46+
The in-memory path's comment justified the string as staying "consistent with
47+
the client `useReportData` hook". That hook was removed with ADR-0021, and the
48+
literal never appeared in it.
49+
50+
### What changed
51+
52+
- `applyInMemoryAggregation` and `bucketDateValue` (`@objectstack/objectql`) key
53+
the empty bucket as `null`. `bucketDateValue` now returns `string | null`. A
54+
null instant and an unparseable one still share one bucket, because SQL cannot
55+
tell them apart either (`strftime('%Y-%m', 'not-a-date')` is NULL).
56+
- The internal composite bucket id is JSON-encoded, so the empty bucket stays
57+
distinct from a row whose value is the literal string `"null"`.
58+
- `bucketKeyToCalendarRange` (`@objectstack/core`) accepts `string | null`. The
59+
empty bucket has no calendar span, so a drill on it opens the unscoped
60+
superset instead of an invented bound — unchanged behavior, honest signature.
61+
- The driver output contract in `@objectstack/spec` now states the rule: a row
62+
with no value keys as `null`, never a sentinel. Propagating NULL through the
63+
bucket expression is the whole of it; a driver only breaks it by adding a
64+
`COALESCE`.
65+
66+
### Gates
67+
68+
`checkDateBucketParity` (`@objectstack/verify`) deliberately carried no null
69+
instant, because the divergence would have failed it for a reason it was not
70+
about. Its fixture now has one, so the convergence is held in place — including
71+
for out-of-tree drivers that run the check against themselves.
72+
73+
Two fixes were needed to make that fixture meaningful:
74+
75+
- The check folded bucket labels through `String(value)`, which turns SQL NULL
76+
into `'null'` — a label a TEXT column can genuinely hold. A driver spelling
77+
"empty" as a string could compare equal to one returning real NULL. The empty
78+
bucket is now keyed out of band.
79+
- Label sets were compared with `JSON.stringify`, which is sensitive to key
80+
insertion order. Row order is not part of this contract and the two paths
81+
naturally differ (SQL sorts its groups; the in-memory path emits first-seen
82+
order), so a driver with entirely correct buckets could be reported as
83+
disagreeing — with an empty diff message, since nothing actually differed.
84+
The comparison is now order-insensitive.
85+
86+
A new dogfood check covers the non-date half against real drivers: same dataset,
87+
plain and date-bucketed `groupBy`, both paths, one key.

packages/core/src/utils/datetime.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,13 +144,17 @@ function isoWeekLabelUtc(d: Date): string {
144144
* `datetime` field in a reference timezone layers that on top (and, per
145145
* ADR-0053, a `date` field compares against these `YYYY-MM-DD` bounds directly).
146146
*
147-
* Returns `null` for the null/empty bucket, an unparseable key, or a key that
148-
* is shape-valid but out of range (e.g. `2026-13`, a `-W53` in a 52-week year,
147+
* Returns `null` for the empty bucket, an unparseable key, or a key that is
148+
* shape-valid but out of range (e.g. `2026-13`, a `-W53` in a 52-week year,
149149
* `2026-02-30`). Callers drop the range and fall back to an unscoped (superset)
150150
* drill rather than emit a wrong bound.
151+
*
152+
* `key` admits `null` because that IS the empty bucket's key on both aggregation
153+
* paths (#3839); callers pass a grouped row's dimension value straight through
154+
* rather than casting a lie.
151155
*/
152156
export function bucketKeyToCalendarRange(
153-
key: string,
157+
key: string | null | undefined,
154158
granularity: BucketGranularity,
155159
): { start: string; end: string } | null {
156160
if (typeof key !== 'string' || key.length === 0) return null;

packages/objectql/src/date-bucket-range.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,12 @@ describe('bucketKeyToCalendarRange exact boundaries', () => {
7373

7474
describe('bucketKeyToCalendarRange rejects unbucketable / out-of-range keys → null (superset fallback)', () => {
7575
it('null and empty buckets', () => {
76+
// #3839 — the empty bucket's key IS `null`, on both the pushed-down and the
77+
// in-memory path. It has no calendar span, so the caller drops the range and
78+
// drills the unscoped superset instead of inventing a bound.
79+
expect(bucketKeyToCalendarRange(null, 'month')).toBeNull();
80+
expect(bucketKeyToCalendarRange(undefined, 'month')).toBeNull();
81+
// The pre-#3839 sentinel, in case one survives in stored/replayed data.
7682
expect(bucketKeyToCalendarRange('(null)', 'month')).toBeNull();
7783
expect(bucketKeyToCalendarRange('', 'day')).toBeNull();
7884
});

packages/objectql/src/in-memory-aggregation.test.ts

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,13 +76,44 @@ describe('applyInMemoryAggregation', () => {
7676
expect(east!.owner_str).toBe('alice,alice,bob');
7777
});
7878

79-
it('treats null group values as the literal (null) bucket', () => {
80-
const dataset = [{ stage: null, amount: 10 }, { stage: 'won', amount: 5 }];
79+
// #3839 — this used to be the literal string `'(null)'`, which the pushed-down
80+
// SQL path never produced (a NULL group column stays SQL NULL). The engine
81+
// picks between the two paths per query, so the bucket key's TYPE changed
82+
// under a dashboard when the driver, the granularity or the timezone changed.
83+
it('keys the empty bucket as real null, like the pushed-down SQL', () => {
84+
const dataset = [
85+
{ stage: null, amount: 10 },
86+
{ stage: undefined, amount: 1 },
87+
{ stage: 'won', amount: 5 },
88+
{ amount: 2 }, // field absent entirely
89+
];
8190
const out = applyInMemoryAggregation(dataset, {
8291
groupBy: ['stage'],
8392
aggregations: [{ function: 'sum', field: 'amount', alias: 'total' }],
8493
});
85-
expect(out.find((r) => r.stage === '(null)')!.total).toBe(10);
94+
// null / undefined / absent all describe the same emptiness → one bucket.
95+
const empty = out.filter((r) => r.stage === null);
96+
expect(empty).toHaveLength(1);
97+
expect(empty[0].total).toBe(13);
98+
// …and it is a real null, not a string that happens to read like one.
99+
expect(out.some((r) => typeof r.stage === 'string' && /null/i.test(r.stage))).toBe(false);
100+
});
101+
102+
// The empty bucket's key is now `null`, and `${null}` is the string 'null' —
103+
// so a row whose value IS the string "null" would merge into the empty bucket
104+
// if the internal bucket id were built by plain interpolation.
105+
it('keeps the empty bucket distinct from the literal string "null"', () => {
106+
const dataset = [
107+
{ stage: null, amount: 10 },
108+
{ stage: 'null', amount: 5 },
109+
];
110+
const out = applyInMemoryAggregation(dataset, {
111+
groupBy: ['stage'],
112+
aggregations: [{ function: 'sum', field: 'amount', alias: 'total' }],
113+
});
114+
expect(out).toHaveLength(2);
115+
expect(out.find((r) => r.stage === null)!.total).toBe(10);
116+
expect(out.find((r) => r.stage === 'null')!.total).toBe(5);
86117
});
87118
});
88119

@@ -101,9 +132,13 @@ describe('bucketDateValue', () => {
101132
expect(bucketDateValue('2024-12-30', 'week')).toBe('2025-W01');
102133
});
103134

104-
it('returns (null) for null / invalid dates', () => {
105-
expect(bucketDateValue(null, 'month')).toBe('(null)');
106-
expect(bucketDateValue('not-a-date', 'month')).toBe('(null)');
135+
// #3839 — `null`, not a sentinel string. SQL propagates NULL through the
136+
// bucket expression for both of these (`strftime('%Y-%m', 'not-a-date')` is
137+
// NULL), so the two paths agree on the empty bucket as well as the full ones.
138+
it('returns null for null / invalid dates', () => {
139+
expect(bucketDateValue(null, 'month')).toBeNull();
140+
expect(bucketDateValue(undefined, 'month')).toBeNull();
141+
expect(bucketDateValue('not-a-date', 'month')).toBeNull();
107142
});
108143

109144
// #3773 — parity with the pushed-down SQL. SQLite stores a `Field.datetime`

packages/objectql/src/in-memory-aggregation.ts

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,25 @@
2121
// possible; the in-memory fallback ignores the per-aggregation filter and
2222
// logs a warning if one is present.
2323
//
24-
// Date bucketing uses ISO-8601 conventions (weeks start Monday). Null /
25-
// invalid values bucket as the literal string `'(null)'` to remain
26-
// consistent with the client `useReportData` hook.
24+
// Date bucketing uses ISO-8601 conventions (weeks start Monday).
25+
//
26+
// THE EMPTY BUCKET'S KEY IS REAL `null` — not a sentinel string (#3839). A
27+
// grouped row whose value is null/absent (or, for a date bucket, unparseable)
28+
// carries `null` for that dimension, which is what the pushed-down SQL path
29+
// emits for the same row: the group column is SQL NULL, or `strftime(...)` /
30+
// `date_trunc(...)` returns NULL for a NULL input. Both paths therefore
31+
// describe "empty" the same way, and `engine.aggregate` picking one per query
32+
// — by driver, granularity, or reference timezone — can no longer change the
33+
// TYPE of a bucket key under a dashboard that drills across the seam.
34+
//
35+
// This previously emitted the literal `'(null)'` "to remain consistent with
36+
// the client `useReportData` hook". That hook was deleted with ADR-0021 (its
37+
// epitaph is objectui `packages/plugin-report/src/index.tsx`), and the string
38+
// never appeared in it anyway. What the string DID do was defeat every
39+
// downstream `== null` check: consumers render their own empty label ('—',
40+
// '(empty)', a localized "Uncategorized") and build drill filters as
41+
// `field = null`, so a sentinel leaked a raw English debug string into the UI
42+
// and turned the empty bucket's drill-through into a zero-row query.
2743

2844
import { calendarPartsInTzOrUtc } from '@objectstack/core';
2945
import type { QueryAST, GroupByNode, AggregationNode, DateGranularityValue } from '@objectstack/spec/data';
@@ -59,7 +75,11 @@ export function applyInMemoryAggregation(
5975
const fieldName = typeof g === 'string' ? g : (g.alias ?? g.field);
6076
const value = projectGroupValue(row, g, timezone);
6177
key[fieldName] = value;
62-
parts.push(`${fieldName}=${value}`);
78+
// JSON-encoded so the empty bucket's `null` key cannot collide with a row
79+
// whose value is the literal STRING `"null"` — plain interpolation renders
80+
// both as `null` and would merge two distinct groups. This id is internal
81+
// to the bucketing loop; only `key` is emitted.
82+
parts.push(`${fieldName}=${JSON.stringify(value)}`);
6383
}
6484
const id = parts.join('\u0001');
6585
let bucket = buckets.get(id);
@@ -78,13 +98,15 @@ export function applyInMemoryAggregation(
7898
return out;
7999
}
80100

81-
function projectGroupValue(row: any, g: GroupByNode, timezone?: string): string {
101+
function projectGroupValue(row: any, g: GroupByNode, timezone?: string): string | null {
82102
const field = typeof g === 'string' ? g : g.field;
83103
const v = row?.[field];
84104
if (typeof g !== 'string' && g.dateGranularity) {
85105
return bucketDateValue(v, g.dateGranularity, timezone);
86106
}
87-
return v == null ? '(null)' : String(v);
107+
// `null`, not a sentinel string — same key the pushed-down SQL gives a NULL
108+
// group column (#3839). See the empty-bucket note at the top of this file.
109+
return v == null ? null : String(v);
88110
}
89111

90112
function aggregateBucket(rows: any[], aggregations: AggregationNode[]): Record<string, any> {
@@ -199,23 +221,29 @@ function toNumber(v: any): number {
199221
* A finite NUMBER is read as epoch milliseconds — the form SQLite stores a
200222
* `Field.datetime` in, and what any driver that hands back raw storage values
201223
* yields. `new Date(String(1767225600000))` is an Invalid Date, so without this
202-
* branch such a row bucketed as `'(null)'` while the pushed-down SQL bucketed it
203-
* correctly (#3773) — the two paths must label the same instant identically or a
204-
* drill-down built on one breaks against the other.
224+
* branch such a row landed in the empty bucket while the pushed-down SQL
225+
* bucketed it correctly (#3773) — the two paths must label the same instant
226+
* identically or a drill-down built on one breaks against the other.
227+
*
228+
* Returns `null` for a null/absent or unparseable instant — the same key the
229+
* pushed-down SQL yields, where the bucket expression propagates NULL (#3839).
230+
* Null and unparseable deliberately share one bucket: SQL cannot tell them
231+
* apart either (`strftime('%Y-%m', 'not-a-date')` is NULL), and splitting them
232+
* here would re-open the seam this function exists to close.
205233
*/
206234
export function bucketDateValue(
207235
value: unknown,
208236
granularity: DateGranularityValue,
209237
timezone?: string,
210-
): string {
211-
if (value == null) return '(null)';
238+
): string | null {
239+
if (value == null) return null;
212240
const d =
213241
value instanceof Date
214242
? value
215243
: typeof value === 'number'
216244
? new Date(value)
217245
: new Date(String(value));
218-
if (Number.isNaN(d.getTime())) return '(null)';
246+
if (Number.isNaN(d.getTime())) return null;
219247
const { year: y, month: m, day } = calendarPartsInTzOrUtc(d, timezone);
220248
switch (granularity) {
221249
case 'year':

packages/plugins/driver-sql/src/sql-driver-date-bucket-storage.test.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,12 @@ const GRANULARITIES: Granularity[] = ['day', 'month', 'quarter', 'year'];
3737
* in `packages/qa/dogfood/test/date-bucket-parity-conformance.test.ts`, where
3838
* the reference side is the REAL `applyInMemoryAggregation`.
3939
*/
40-
function bucketDateValue(value: unknown, g: Granularity): string {
41-
if (value == null) return '(null)';
40+
function bucketDateValue(value: unknown, g: Granularity): string | null {
41+
if (value == null) return null;
4242
// A finite number is epoch milliseconds — SQLite's `Field.datetime` storage.
4343
const d =
4444
value instanceof Date ? value : typeof value === 'number' ? new Date(value) : new Date(String(value));
45-
if (Number.isNaN(d.getTime())) return '(null)';
45+
if (Number.isNaN(d.getTime())) return null;
4646
const y = d.getUTCFullYear();
4747
const m = d.getUTCMonth() + 1;
4848
switch (g) {
@@ -73,11 +73,21 @@ const FIXTURE: Array<{ id: string; iso: string; amount: number }> = [
7373
{ id: 'r7', iso: '2026-07-01T00:00:00.000Z', amount: 64 }, // first instant of Q3
7474
];
7575

76+
/**
77+
* Test-local key for the empty bucket. `String(null)` is `'null'` — a label a
78+
* TEXT column could genuinely hold, and the coercion that used to hide the
79+
* #3839 divergence: it made the SQL side's NULL and a sentinel string compare
80+
* as different strings only by luck. Keying it out of band keeps "empty"
81+
* unmistakable on both sides.
82+
*/
83+
const EMPTY = '‹empty bucket›';
84+
const labelOf = (v: unknown): string => (v == null ? EMPTY : String(v));
85+
7686
/** The labels the in-memory path would produce, folded into bucket → sum. */
7787
function expectedBuckets(g: Granularity): Record<string, number> {
7888
const out: Record<string, number> = {};
7989
for (const row of FIXTURE) {
80-
const key = bucketDateValue(row.iso, g);
90+
const key = labelOf(bucketDateValue(row.iso, g));
8191
out[key] = (out[key] ?? 0) + row.amount;
8292
}
8393
return out;
@@ -88,7 +98,7 @@ async function bucketSums(driver: SqlDriver, field: string, g: Granularity) {
8898
groupBy: [{ field, dateGranularity: g }],
8999
aggregations: [{ function: 'sum', field: 'amount', alias: 'total' }],
90100
} as any);
91-
return Object.fromEntries(rows.map((r: any) => [String(r[field]), Number(r.total)]));
101+
return Object.fromEntries(rows.map((r: any) => [labelOf(r[field]), Number(r.total)]));
92102
}
93103

94104
describe('SqlDriver date bucketing is storage-form independent (#3773)', () => {
@@ -218,9 +228,11 @@ describe('SqlDriver date bucketing over a MIXED-form datetime column (#3773)', (
218228

219229
it('leaves a NULL instant in its own bucket', async () => {
220230
const byMonth = await bucketSums(driver, 'closed_at', 'month');
221-
// SQL NULL aliases to the string 'null' through `String(r[field])` — a
222-
// pre-existing divergence from the in-memory label `'(null)'`, unchanged
223-
// here and equally true of a TEXT-stored column.
224-
expect(byMonth.null).toBe(8);
231+
// SQL NULL propagates through the bucket expression, so the row lands in the
232+
// empty bucket instead of under some real month. Since #3839 the in-memory
233+
// path keys that bucket as `null` as well, so the two paths agree on it —
234+
// the executable proof is `checkDateBucketParity`, whose fixture now carries
235+
// a null instant for exactly this.
236+
expect(byMonth[EMPTY]).toBe(8);
225237
});
226238
});

packages/plugins/driver-sql/src/sql-driver-date-bucket.test.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@ import { SqlDriver } from '../src/index.js';
1717

1818
type Granularity = 'day' | 'week' | 'month' | 'quarter' | 'year';
1919

20+
/**
21+
* Test-local key for the empty bucket, on both sides of the comparison below.
22+
* `String(null)` is `'null'` — a label a TEXT column could genuinely hold — so
23+
* folding the empty bucket through it makes "empty" indistinguishable from a
24+
* real value. Keying it out of band is what lets a sentinel-vs-NULL divergence
25+
* (#3839) show up as a difference instead of comparing equal by coincidence.
26+
*/
27+
const EMPTY = '‹empty bucket›';
28+
const labelOf = (v: unknown): string => (v == null ? EMPTY : String(v));
29+
2030
/**
2131
* ⚠️ Keep in sync with `packages/objectql/src/in-memory-aggregation.ts#bucketDateValue`.
2232
*
@@ -27,12 +37,12 @@ type Granularity = 'day' | 'week' | 'month' | 'quarter' | 'year';
2737
* in `packages/qa/dogfood/test/date-bucket-parity-conformance.test.ts`, where
2838
* the reference side is the REAL `applyInMemoryAggregation`.
2939
*/
30-
function bucketDateValue(value: unknown, g: Granularity): string {
31-
if (value == null) return '(null)';
40+
function bucketDateValue(value: unknown, g: Granularity): string | null {
41+
if (value == null) return null;
3242
// A finite number is epoch milliseconds — SQLite's `Field.datetime` storage.
3343
const d =
3444
value instanceof Date ? value : typeof value === 'number' ? new Date(value) : new Date(String(value));
35-
if (Number.isNaN(d.getTime())) return '(null)';
45+
if (Number.isNaN(d.getTime())) return null;
3646
const y = d.getUTCFullYear();
3747
const m = d.getUTCMonth() + 1;
3848
switch (g) {
@@ -113,13 +123,13 @@ describe('SqlDriver date bucket (dateGranularity)', () => {
113123

114124
const expectedBuckets = new Map<string, number>();
115125
for (const r of FIXTURE) {
116-
const key = bucketDateValue(r.ts, g);
126+
const key = labelOf(bucketDateValue(r.ts, g));
117127
expectedBuckets.set(key, (expectedBuckets.get(key) ?? 0) + 1);
118128
}
119129

120130
const actualBuckets = new Map<string, number>();
121131
for (const row of rows) {
122-
actualBuckets.set(String(row.ts), Number(row.n));
132+
actualBuckets.set(labelOf(row.ts), Number(row.n));
123133
}
124134

125135
expect([...actualBuckets.entries()].sort()).toEqual(

0 commit comments

Comments
 (0)