@classytic/repo-core 0.3.0 → 0.4.1
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/CHANGELOG.md +243 -0
- package/dist/_virtual/_rolldown/runtime.mjs +7 -0
- package/dist/adapter/index.d.mts +3 -0
- package/dist/adapter/index.mjs +2 -0
- package/dist/adapter/types.d.mts +222 -0
- package/dist/adapter/widen.d.mts +22 -0
- package/dist/adapter/widen.mjs +26 -0
- package/dist/aggregate/index.d.mts +3 -0
- package/dist/aggregate/index.mjs +3 -0
- package/dist/aggregate/keyset.d.mts +57 -0
- package/dist/aggregate/keyset.mjs +45 -0
- package/dist/aggregate/normalize.d.mts +24 -0
- package/dist/aggregate/normalize.mjs +28 -0
- package/dist/better-auth/index.d.mts +110 -0
- package/dist/better-auth/index.mjs +71 -0
- package/dist/cache/engine.d.mts +127 -0
- package/dist/cache/engine.mjs +235 -0
- package/dist/cache/envelope.mjs +32 -0
- package/dist/cache/index.d.mts +7 -2
- package/dist/cache/index.mjs +6 -2
- package/dist/cache/keys.mjs +131 -0
- package/dist/cache/memory-adapter.mjs +41 -7
- package/dist/cache/options.d.mts +112 -0
- package/dist/cache/options.mjs +25 -0
- package/dist/cache/plugin/context.d.mts +18 -0
- package/dist/cache/plugin/context.mjs +121 -0
- package/dist/cache/plugin/index.d.mts +86 -0
- package/dist/cache/plugin/index.mjs +78 -0
- package/dist/cache/plugin/invalidation-hooks.mjs +35 -0
- package/dist/cache/plugin/read-hooks.mjs +96 -0
- package/dist/cache/plugin/swr.mjs +20 -0
- package/dist/cache/runtime.d.mts +43 -0
- package/dist/cache/runtime.mjs +14 -0
- package/dist/cache/tag-index.mjs +84 -0
- package/dist/cache/timeout-adapter.d.mts +30 -0
- package/dist/cache/timeout-adapter.mjs +58 -0
- package/dist/cache/types.d.mts +45 -0
- package/dist/cache/version-store.mjs +57 -0
- package/dist/errors/index.d.mts +2 -1
- package/dist/errors/index.mjs +2 -1
- package/dist/errors/schema.d.mts +101 -0
- package/dist/errors/schema.mjs +78 -0
- package/dist/filter/match.mjs +38 -2
- package/dist/lock/index.d.mts +132 -0
- package/dist/lock/index.mjs +162 -0
- package/dist/pagination/canonical.d.mts +8 -8
- package/dist/pagination/canonical.mjs +3 -9
- package/dist/pagination/cursor.mjs +4 -1
- package/dist/pagination/index.d.mts +2 -2
- package/dist/pagination/types.d.mts +17 -27
- package/dist/plugins/index.d.mts +2 -0
- package/dist/plugins/index.mjs +2 -0
- package/dist/plugins/tenant-helpers.d.mts +63 -0
- package/dist/plugins/tenant-helpers.mjs +84 -0
- package/dist/query-parser/index.d.mts +2 -1
- package/dist/query-parser/index.mjs +2 -1
- package/dist/query-parser/parse-url.mjs +13 -11
- package/dist/query-parser/reserved.d.mts +43 -0
- package/dist/query-parser/reserved.mjs +56 -0
- package/dist/repository/agg-output.d.mts +63 -0
- package/dist/repository/agg-output.mjs +89 -0
- package/dist/repository/index.d.mts +4 -2
- package/dist/repository/index.mjs +3 -1
- package/dist/repository/options.d.mts +62 -0
- package/dist/repository/options.mjs +57 -0
- package/dist/repository/types.d.mts +936 -49
- package/dist/schema/field-rules.d.mts +41 -1
- package/dist/schema/field-rules.mjs +92 -1
- package/dist/schema/index.d.mts +2 -2
- package/dist/schema/index.mjs +2 -2
- package/dist/schema/types.d.mts +21 -0
- package/dist/testing/conformance.mjs +666 -17
- package/dist/testing/index.d.mts +3 -2
- package/dist/testing/index.mjs +2 -1
- package/dist/testing/lock-conformance.d.mts +25 -0
- package/dist/testing/lock-conformance.mjs +167 -0
- package/dist/testing/types.d.mts +99 -2
- package/package.json +23 -1
- package/dist/cache/stable-stringify.d.mts +0 -15
|
@@ -25,6 +25,15 @@ function isoAt(offsetSeconds) {
|
|
|
25
25
|
return new Date(Date.UTC(2026, 3, 1) + offsetSeconds * 1e3).toISOString();
|
|
26
26
|
}
|
|
27
27
|
function runStandardRepoConformance(harness) {
|
|
28
|
+
const ops = harness.features.aggregateOps;
|
|
29
|
+
const aggGate = harness.features.aggregate;
|
|
30
|
+
const skipNoAgg = !aggGate;
|
|
31
|
+
const skipNoTopN = !aggGate || !ops?.topN;
|
|
32
|
+
const skipNoPercentile = !aggGate || !ops?.percentile;
|
|
33
|
+
const skipNoCustomBuckets = !aggGate || !ops?.customDateBuckets;
|
|
34
|
+
const skipNoSubMinuteBuckets = !aggGate || !ops?.dateBucketSubMinute;
|
|
35
|
+
const skipNoStddev = !aggGate || !ops?.stddev;
|
|
36
|
+
const skipNoCache = !aggGate || !ops?.cache;
|
|
28
37
|
describe(`[conformance] ${harness.name}`, () => {
|
|
29
38
|
let ctx;
|
|
30
39
|
beforeEach(async () => {
|
|
@@ -67,10 +76,12 @@ function runStandardRepoConformance(harness) {
|
|
|
67
76
|
expect((await ctx.repo.update(id, { count: 99 }))?.count).toBe(99);
|
|
68
77
|
expect(await ctx.repo.update("no-such-id", { count: 1 })).toBeNull();
|
|
69
78
|
});
|
|
70
|
-
it("delete by id succeeds; second delete returns
|
|
79
|
+
it("delete by id succeeds; second delete returns null (miss)", async () => {
|
|
71
80
|
const id = idOf(await ctx.repo.create(harness.makeDoc({ name: "Carol" })), harness.idField);
|
|
72
|
-
|
|
73
|
-
expect(
|
|
81
|
+
const first = await ctx.repo.delete(id);
|
|
82
|
+
expect(first).not.toBeNull();
|
|
83
|
+
expect(first?.message).toBeDefined();
|
|
84
|
+
expect(await ctx.repo.delete(id)).toBeNull();
|
|
74
85
|
});
|
|
75
86
|
});
|
|
76
87
|
describe("findOneAndUpdate", () => {
|
|
@@ -241,7 +252,7 @@ function runStandardRepoConformance(harness) {
|
|
|
241
252
|
})
|
|
242
253
|
]);
|
|
243
254
|
});
|
|
244
|
-
it.skipIf(
|
|
255
|
+
it.skipIf(skipNoAgg)("empty result set returns { rows: [] } (no throw)", async () => {
|
|
245
256
|
if (!ctx.repo.aggregate) return;
|
|
246
257
|
expect((await ctx.repo.aggregate({
|
|
247
258
|
filter: { category: "does-not-exist" },
|
|
@@ -252,7 +263,7 @@ function runStandardRepoConformance(harness) {
|
|
|
252
263
|
} }
|
|
253
264
|
})).rows).toEqual([]);
|
|
254
265
|
});
|
|
255
|
-
it.skipIf(
|
|
266
|
+
it.skipIf(skipNoAgg)("groupBy + sum produces one row per group with correct totals", async () => {
|
|
256
267
|
if (!ctx.repo.aggregate) return;
|
|
257
268
|
const result = await ctx.repo.aggregate({
|
|
258
269
|
groupBy: "category",
|
|
@@ -268,7 +279,7 @@ function runStandardRepoConformance(harness) {
|
|
|
268
279
|
expect(byCategory["admin"]).toBe(70);
|
|
269
280
|
expect(byCategory["reader"]).toBe(30);
|
|
270
281
|
});
|
|
271
|
-
it.skipIf(
|
|
282
|
+
it.skipIf(skipNoAgg)("scalar aggregate (no groupBy) returns single row", async () => {
|
|
272
283
|
if (!ctx.repo.aggregate) return;
|
|
273
284
|
const result = await ctx.repo.aggregate({ measures: {
|
|
274
285
|
total: {
|
|
@@ -281,7 +292,7 @@ function runStandardRepoConformance(harness) {
|
|
|
281
292
|
expect(Number(result.rows[0]?.total)).toBe(100);
|
|
282
293
|
expect(Number(result.rows[0]?.n)).toBe(4);
|
|
283
294
|
});
|
|
284
|
-
it.skipIf(
|
|
295
|
+
it.skipIf(skipNoAgg)("having filters aggregated rows by measure alias", async () => {
|
|
285
296
|
if (!ctx.repo.aggregate) return;
|
|
286
297
|
const result = await ctx.repo.aggregate({
|
|
287
298
|
groupBy: "category",
|
|
@@ -294,6 +305,640 @@ function runStandardRepoConformance(harness) {
|
|
|
294
305
|
expect(result.rows).toHaveLength(1);
|
|
295
306
|
expect(result.rows[0].category).toBe("admin");
|
|
296
307
|
});
|
|
308
|
+
it.skipIf(skipNoAgg)("filtered sum/count: KPI tiles side-by-side", async () => {
|
|
309
|
+
if (!ctx.repo.aggregate) return;
|
|
310
|
+
expect((await ctx.repo.aggregate({
|
|
311
|
+
groupBy: "category",
|
|
312
|
+
measures: {
|
|
313
|
+
activeCount: {
|
|
314
|
+
op: "count",
|
|
315
|
+
where: eq("active", true)
|
|
316
|
+
},
|
|
317
|
+
inactiveCount: {
|
|
318
|
+
op: "count",
|
|
319
|
+
where: eq("active", false)
|
|
320
|
+
},
|
|
321
|
+
activeTotal: {
|
|
322
|
+
op: "sum",
|
|
323
|
+
field: "count",
|
|
324
|
+
where: eq("active", true)
|
|
325
|
+
},
|
|
326
|
+
grandTotal: {
|
|
327
|
+
op: "sum",
|
|
328
|
+
field: "count"
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
sort: { category: 1 }
|
|
332
|
+
})).rows).toEqual([{
|
|
333
|
+
category: "admin",
|
|
334
|
+
activeCount: 1,
|
|
335
|
+
inactiveCount: 1,
|
|
336
|
+
activeTotal: 40,
|
|
337
|
+
grandTotal: 70
|
|
338
|
+
}, {
|
|
339
|
+
category: "reader",
|
|
340
|
+
activeCount: 2,
|
|
341
|
+
inactiveCount: 0,
|
|
342
|
+
activeTotal: 30,
|
|
343
|
+
grandTotal: 30
|
|
344
|
+
}]);
|
|
345
|
+
});
|
|
346
|
+
it.skipIf(skipNoAgg)("filtered avg ignores non-matching rows (no bias toward 0)", async () => {
|
|
347
|
+
if (!ctx.repo.aggregate) return;
|
|
348
|
+
const result = await ctx.repo.aggregate({
|
|
349
|
+
filter: eq("category", "admin"),
|
|
350
|
+
measures: { avgActive: {
|
|
351
|
+
op: "avg",
|
|
352
|
+
field: "count",
|
|
353
|
+
where: eq("active", true)
|
|
354
|
+
} }
|
|
355
|
+
});
|
|
356
|
+
expect(Number(result.rows[0]?.avgActive)).toBe(40);
|
|
357
|
+
});
|
|
358
|
+
it.skipIf(skipNoTopN)("top-N: keep top 1 per category by count", async () => {
|
|
359
|
+
if (!ctx.repo.aggregate) return;
|
|
360
|
+
expect((await ctx.repo.aggregate({
|
|
361
|
+
groupBy: ["category", "name"],
|
|
362
|
+
measures: { n: {
|
|
363
|
+
op: "sum",
|
|
364
|
+
field: "count"
|
|
365
|
+
} },
|
|
366
|
+
topN: {
|
|
367
|
+
partitionBy: "category",
|
|
368
|
+
sortBy: { n: -1 },
|
|
369
|
+
limit: 1
|
|
370
|
+
},
|
|
371
|
+
sort: { category: 1 }
|
|
372
|
+
})).rows).toEqual([{
|
|
373
|
+
category: "admin",
|
|
374
|
+
name: "d",
|
|
375
|
+
n: 40
|
|
376
|
+
}, {
|
|
377
|
+
category: "reader",
|
|
378
|
+
name: "b",
|
|
379
|
+
n: 20
|
|
380
|
+
}]);
|
|
381
|
+
});
|
|
382
|
+
it.skipIf(skipNoTopN)("top-N: row_number ties strategy yields exactly N rows per partition", async () => {
|
|
383
|
+
if (!ctx.repo.aggregate) return;
|
|
384
|
+
expect((await ctx.repo.aggregate({
|
|
385
|
+
groupBy: ["category", "name"],
|
|
386
|
+
measures: { n: { op: "count" } },
|
|
387
|
+
topN: {
|
|
388
|
+
partitionBy: "category",
|
|
389
|
+
sortBy: { name: 1 },
|
|
390
|
+
limit: 1,
|
|
391
|
+
ties: "row_number"
|
|
392
|
+
},
|
|
393
|
+
sort: { category: 1 }
|
|
394
|
+
})).rows.map((r) => `${r.category}:${r.name}`)).toEqual(["admin:c", "reader:a"]);
|
|
395
|
+
});
|
|
396
|
+
it.skipIf(skipNoTopN)("top-N: throws on partitionBy referencing an unknown column", async () => {
|
|
397
|
+
if (!ctx.repo.aggregate) return;
|
|
398
|
+
await expect(ctx.repo.aggregate({
|
|
399
|
+
groupBy: "category",
|
|
400
|
+
measures: { n: { op: "count" } },
|
|
401
|
+
topN: {
|
|
402
|
+
partitionBy: "does-not-exist",
|
|
403
|
+
sortBy: { n: -1 },
|
|
404
|
+
limit: 1
|
|
405
|
+
}
|
|
406
|
+
})).rejects.toThrow(/topN\.partitionBy "does-not-exist"/);
|
|
407
|
+
});
|
|
408
|
+
it.skipIf(skipNoPercentile)("percentile: P50 / P95 / P99 over a uniform distribution", async () => {
|
|
409
|
+
if (!ctx.repo.aggregate) return;
|
|
410
|
+
for (let i = 1; i <= 100; i++) await ctx.repo.create(harness.makeDoc({
|
|
411
|
+
name: `p${i}`,
|
|
412
|
+
category: "pct",
|
|
413
|
+
count: i
|
|
414
|
+
}));
|
|
415
|
+
const row = (await ctx.repo.aggregate({
|
|
416
|
+
filter: eq("category", "pct"),
|
|
417
|
+
measures: {
|
|
418
|
+
p50: {
|
|
419
|
+
op: "percentile",
|
|
420
|
+
field: "count",
|
|
421
|
+
p: .5
|
|
422
|
+
},
|
|
423
|
+
p95: {
|
|
424
|
+
op: "percentile",
|
|
425
|
+
field: "count",
|
|
426
|
+
p: .95
|
|
427
|
+
},
|
|
428
|
+
p99: {
|
|
429
|
+
op: "percentile",
|
|
430
|
+
field: "count",
|
|
431
|
+
p: .99
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
})).rows[0];
|
|
435
|
+
expect(Number(row.p50)).toBeGreaterThanOrEqual(48);
|
|
436
|
+
expect(Number(row.p50)).toBeLessThanOrEqual(52);
|
|
437
|
+
expect(Number(row.p95)).toBeGreaterThanOrEqual(93);
|
|
438
|
+
expect(Number(row.p95)).toBeLessThanOrEqual(97);
|
|
439
|
+
expect(Number(row.p99)).toBeGreaterThanOrEqual(97);
|
|
440
|
+
expect(Number(row.p99)).toBeLessThanOrEqual(100);
|
|
441
|
+
});
|
|
442
|
+
it.skipIf(skipNoPercentile)("percentile: rejects p outside [0, 1]", async () => {
|
|
443
|
+
if (!ctx.repo.aggregate) return;
|
|
444
|
+
await expect(ctx.repo.aggregate({ measures: { bad: {
|
|
445
|
+
op: "percentile",
|
|
446
|
+
field: "count",
|
|
447
|
+
p: 1.5
|
|
448
|
+
} } })).rejects.toThrow(/percentile/);
|
|
449
|
+
});
|
|
450
|
+
it.skipIf(skipNoStddev)("stddev (sample) matches numpy.std(ddof=1) over [2, 4, 4, 4, 5, 5, 7, 9]", async () => {
|
|
451
|
+
if (!ctx.repo.aggregate) return;
|
|
452
|
+
for (const value of [
|
|
453
|
+
2,
|
|
454
|
+
4,
|
|
455
|
+
4,
|
|
456
|
+
4,
|
|
457
|
+
5,
|
|
458
|
+
5,
|
|
459
|
+
7,
|
|
460
|
+
9
|
|
461
|
+
]) await ctx.repo.create(harness.makeDoc({
|
|
462
|
+
name: `s${value}`,
|
|
463
|
+
category: "std",
|
|
464
|
+
count: value
|
|
465
|
+
}));
|
|
466
|
+
const result = await ctx.repo.aggregate({
|
|
467
|
+
filter: eq("category", "std"),
|
|
468
|
+
measures: { s: {
|
|
469
|
+
op: "stddev",
|
|
470
|
+
field: "count"
|
|
471
|
+
} }
|
|
472
|
+
});
|
|
473
|
+
expect(Number(result.rows[0]?.s)).toBeCloseTo(2.138, 2);
|
|
474
|
+
});
|
|
475
|
+
it.skipIf(skipNoStddev)("stddevPop (population) matches numpy.std(ddof=0) over the same set", async () => {
|
|
476
|
+
if (!ctx.repo.aggregate) return;
|
|
477
|
+
for (const value of [
|
|
478
|
+
2,
|
|
479
|
+
4,
|
|
480
|
+
4,
|
|
481
|
+
4,
|
|
482
|
+
5,
|
|
483
|
+
5,
|
|
484
|
+
7,
|
|
485
|
+
9
|
|
486
|
+
]) await ctx.repo.create(harness.makeDoc({
|
|
487
|
+
name: `p${value}`,
|
|
488
|
+
category: "pop",
|
|
489
|
+
count: value
|
|
490
|
+
}));
|
|
491
|
+
const result = await ctx.repo.aggregate({
|
|
492
|
+
filter: eq("category", "pop"),
|
|
493
|
+
measures: { s: {
|
|
494
|
+
op: "stddevPop",
|
|
495
|
+
field: "count"
|
|
496
|
+
} }
|
|
497
|
+
});
|
|
498
|
+
expect(Number(result.rows[0]?.s)).toBeCloseTo(2, 6);
|
|
499
|
+
});
|
|
500
|
+
it.skipIf(skipNoCache)("cache hit: same call within staleTime returns cached value (no DB re-read)", async () => {
|
|
501
|
+
const cachedRepo = ctx.cachedRepo;
|
|
502
|
+
if (!cachedRepo || !cachedRepo.aggregate) return;
|
|
503
|
+
await cachedRepo.create(harness.makeDoc({
|
|
504
|
+
name: "c1",
|
|
505
|
+
category: "cache",
|
|
506
|
+
count: 100
|
|
507
|
+
}));
|
|
508
|
+
const first = await cachedRepo.aggregate({
|
|
509
|
+
filter: eq("category", "cache"),
|
|
510
|
+
measures: { sum: {
|
|
511
|
+
op: "sum",
|
|
512
|
+
field: "count"
|
|
513
|
+
} },
|
|
514
|
+
cache: { staleTime: 60 }
|
|
515
|
+
});
|
|
516
|
+
expect(Number(first.rows[0]?.sum)).toBe(100);
|
|
517
|
+
const second = await cachedRepo.aggregate({
|
|
518
|
+
filter: eq("category", "cache"),
|
|
519
|
+
measures: { sum: {
|
|
520
|
+
op: "sum",
|
|
521
|
+
field: "count"
|
|
522
|
+
} },
|
|
523
|
+
cache: { staleTime: 60 }
|
|
524
|
+
});
|
|
525
|
+
expect(Number(second.rows[0]?.sum)).toBe(100);
|
|
526
|
+
});
|
|
527
|
+
it.skipIf(skipNoCache)("write invalidates the cache: subsequent read sees the fresh result", async () => {
|
|
528
|
+
const cachedRepo = ctx.cachedRepo;
|
|
529
|
+
if (!cachedRepo || !cachedRepo.aggregate) return;
|
|
530
|
+
await cachedRepo.create(harness.makeDoc({
|
|
531
|
+
name: "wi1",
|
|
532
|
+
category: "wi",
|
|
533
|
+
count: 100
|
|
534
|
+
}));
|
|
535
|
+
const first = await cachedRepo.aggregate({
|
|
536
|
+
filter: eq("category", "wi"),
|
|
537
|
+
measures: { sum: {
|
|
538
|
+
op: "sum",
|
|
539
|
+
field: "count"
|
|
540
|
+
} },
|
|
541
|
+
cache: { staleTime: 60 }
|
|
542
|
+
});
|
|
543
|
+
expect(Number(first.rows[0]?.sum)).toBe(100);
|
|
544
|
+
await cachedRepo.create(harness.makeDoc({
|
|
545
|
+
name: "wi2",
|
|
546
|
+
category: "wi",
|
|
547
|
+
count: 999
|
|
548
|
+
}));
|
|
549
|
+
const second = await cachedRepo.aggregate({
|
|
550
|
+
filter: eq("category", "wi"),
|
|
551
|
+
measures: { sum: {
|
|
552
|
+
op: "sum",
|
|
553
|
+
field: "count"
|
|
554
|
+
} },
|
|
555
|
+
cache: { staleTime: 60 }
|
|
556
|
+
});
|
|
557
|
+
expect(Number(second.rows[0]?.sum)).toBe(1099);
|
|
558
|
+
});
|
|
559
|
+
it.skipIf(skipNoCache)("bypass: forces fresh fetch + overwrites cached entry", async () => {
|
|
560
|
+
const cachedRepo = ctx.cachedRepo;
|
|
561
|
+
if (!cachedRepo || !cachedRepo.aggregate) return;
|
|
562
|
+
await cachedRepo.create(harness.makeDoc({
|
|
563
|
+
name: "b1",
|
|
564
|
+
category: "bp",
|
|
565
|
+
count: 50
|
|
566
|
+
}));
|
|
567
|
+
await cachedRepo.aggregate({
|
|
568
|
+
filter: eq("category", "bp"),
|
|
569
|
+
measures: { sum: {
|
|
570
|
+
op: "sum",
|
|
571
|
+
field: "count"
|
|
572
|
+
} },
|
|
573
|
+
cache: { staleTime: 60 }
|
|
574
|
+
});
|
|
575
|
+
await cachedRepo.create(harness.makeDoc({
|
|
576
|
+
name: "b2",
|
|
577
|
+
category: "bp",
|
|
578
|
+
count: 100
|
|
579
|
+
}));
|
|
580
|
+
const fresh = await cachedRepo.aggregate({
|
|
581
|
+
filter: eq("category", "bp"),
|
|
582
|
+
measures: { sum: {
|
|
583
|
+
op: "sum",
|
|
584
|
+
field: "count"
|
|
585
|
+
} },
|
|
586
|
+
cache: {
|
|
587
|
+
staleTime: 60,
|
|
588
|
+
bypass: true
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
expect(Number(fresh.rows[0]?.sum)).toBe(150);
|
|
592
|
+
});
|
|
593
|
+
it.skipIf(skipNoCache)("tag invalidation via repo.cache.invalidateByTags clears matching entries", async () => {
|
|
594
|
+
const cachedRepo = ctx.cachedRepo;
|
|
595
|
+
if (!cachedRepo || !cachedRepo.aggregate) return;
|
|
596
|
+
await cachedRepo.create(harness.makeDoc({
|
|
597
|
+
name: "i1",
|
|
598
|
+
category: "inv",
|
|
599
|
+
count: 10
|
|
600
|
+
}));
|
|
601
|
+
await cachedRepo.aggregate({
|
|
602
|
+
filter: eq("category", "inv"),
|
|
603
|
+
measures: { sum: {
|
|
604
|
+
op: "sum",
|
|
605
|
+
field: "count"
|
|
606
|
+
} },
|
|
607
|
+
cache: {
|
|
608
|
+
staleTime: 60,
|
|
609
|
+
tags: ["inv-tag"]
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
const handle = cachedRepo.cache;
|
|
613
|
+
if (!handle) return;
|
|
614
|
+
expect(await handle.invalidateByTags(["inv-tag"])).toBeGreaterThanOrEqual(1);
|
|
615
|
+
});
|
|
616
|
+
});
|
|
617
|
+
describe("aggregate — date buckets + keyset", () => {
|
|
618
|
+
const BUCKET_CATS = ["bk-paid", "bk-pending"];
|
|
619
|
+
const KEYSET_CATS = [
|
|
620
|
+
"ks-a",
|
|
621
|
+
"ks-b",
|
|
622
|
+
"ks-c",
|
|
623
|
+
"ks-d",
|
|
624
|
+
"ks-e"
|
|
625
|
+
];
|
|
626
|
+
beforeEach(async () => {
|
|
627
|
+
await ctx.repo.createMany([
|
|
628
|
+
harness.makeDoc({
|
|
629
|
+
name: "bk1",
|
|
630
|
+
category: "bk-paid",
|
|
631
|
+
count: 100,
|
|
632
|
+
createdAt: "2026-01-15T10:00:00Z"
|
|
633
|
+
}),
|
|
634
|
+
harness.makeDoc({
|
|
635
|
+
name: "bk2",
|
|
636
|
+
category: "bk-paid",
|
|
637
|
+
count: 200,
|
|
638
|
+
createdAt: "2026-01-22T10:00:00Z"
|
|
639
|
+
}),
|
|
640
|
+
harness.makeDoc({
|
|
641
|
+
name: "bk3",
|
|
642
|
+
category: "bk-paid",
|
|
643
|
+
count: 300,
|
|
644
|
+
createdAt: "2026-02-05T10:00:00Z"
|
|
645
|
+
}),
|
|
646
|
+
harness.makeDoc({
|
|
647
|
+
name: "bk4",
|
|
648
|
+
category: "bk-pending",
|
|
649
|
+
count: 50,
|
|
650
|
+
createdAt: "2026-02-05T10:00:00Z"
|
|
651
|
+
}),
|
|
652
|
+
harness.makeDoc({
|
|
653
|
+
name: "bk5",
|
|
654
|
+
category: "bk-paid",
|
|
655
|
+
count: 400,
|
|
656
|
+
createdAt: "2026-04-10T10:00:00Z"
|
|
657
|
+
}),
|
|
658
|
+
harness.makeDoc({
|
|
659
|
+
name: "bk6",
|
|
660
|
+
category: "bk-paid",
|
|
661
|
+
count: 500,
|
|
662
|
+
createdAt: "2026-07-20T10:00:00Z"
|
|
663
|
+
})
|
|
664
|
+
]);
|
|
665
|
+
for (let i = 0; i < KEYSET_CATS.length; i++) {
|
|
666
|
+
const cat = KEYSET_CATS[i];
|
|
667
|
+
await ctx.repo.create(harness.makeDoc({
|
|
668
|
+
name: cat,
|
|
669
|
+
category: cat,
|
|
670
|
+
count: 10 + i * 10
|
|
671
|
+
}));
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
it.skipIf(skipNoAgg)("date bucket month groups rows under YYYY-MM labels", async () => {
|
|
675
|
+
if (!ctx.repo.aggregate) return;
|
|
676
|
+
expect((await ctx.repo.aggregate({
|
|
677
|
+
filter: { category: "bk-paid" },
|
|
678
|
+
dateBuckets: { month: {
|
|
679
|
+
field: "createdAt",
|
|
680
|
+
interval: "month"
|
|
681
|
+
} },
|
|
682
|
+
measures: { revenue: {
|
|
683
|
+
op: "sum",
|
|
684
|
+
field: "count"
|
|
685
|
+
} },
|
|
686
|
+
sort: { month: 1 }
|
|
687
|
+
})).rows).toEqual([
|
|
688
|
+
{
|
|
689
|
+
month: "2026-01",
|
|
690
|
+
revenue: 300
|
|
691
|
+
},
|
|
692
|
+
{
|
|
693
|
+
month: "2026-02",
|
|
694
|
+
revenue: 300
|
|
695
|
+
},
|
|
696
|
+
{
|
|
697
|
+
month: "2026-04",
|
|
698
|
+
revenue: 400
|
|
699
|
+
},
|
|
700
|
+
{
|
|
701
|
+
month: "2026-07",
|
|
702
|
+
revenue: 500
|
|
703
|
+
}
|
|
704
|
+
]);
|
|
705
|
+
});
|
|
706
|
+
it.skipIf(skipNoAgg)("date bucket day emits YYYY-MM-DD", async () => {
|
|
707
|
+
if (!ctx.repo.aggregate) return;
|
|
708
|
+
expect((await ctx.repo.aggregate({
|
|
709
|
+
filter: { category: "bk-paid" },
|
|
710
|
+
dateBuckets: { day: {
|
|
711
|
+
field: "createdAt",
|
|
712
|
+
interval: "day"
|
|
713
|
+
} },
|
|
714
|
+
measures: { n: { op: "count" } },
|
|
715
|
+
sort: { day: 1 }
|
|
716
|
+
})).rows.map((r) => r.day)).toEqual([
|
|
717
|
+
"2026-01-15",
|
|
718
|
+
"2026-01-22",
|
|
719
|
+
"2026-02-05",
|
|
720
|
+
"2026-04-10",
|
|
721
|
+
"2026-07-20"
|
|
722
|
+
]);
|
|
723
|
+
});
|
|
724
|
+
it.skipIf(skipNoAgg)("date bucket quarter emits YYYY-Qn", async () => {
|
|
725
|
+
if (!ctx.repo.aggregate) return;
|
|
726
|
+
expect((await ctx.repo.aggregate({
|
|
727
|
+
filter: { category: "bk-paid" },
|
|
728
|
+
dateBuckets: { q: {
|
|
729
|
+
field: "createdAt",
|
|
730
|
+
interval: "quarter"
|
|
731
|
+
} },
|
|
732
|
+
measures: { n: { op: "count" } },
|
|
733
|
+
sort: { q: 1 }
|
|
734
|
+
})).rows).toEqual([
|
|
735
|
+
{
|
|
736
|
+
q: "2026-Q1",
|
|
737
|
+
n: 3
|
|
738
|
+
},
|
|
739
|
+
{
|
|
740
|
+
q: "2026-Q2",
|
|
741
|
+
n: 1
|
|
742
|
+
},
|
|
743
|
+
{
|
|
744
|
+
q: "2026-Q3",
|
|
745
|
+
n: 1
|
|
746
|
+
}
|
|
747
|
+
]);
|
|
748
|
+
});
|
|
749
|
+
it.skipIf(skipNoAgg)("date bucket year emits YYYY", async () => {
|
|
750
|
+
if (!ctx.repo.aggregate) return;
|
|
751
|
+
expect((await ctx.repo.aggregate({
|
|
752
|
+
filter: { category: "bk-paid" },
|
|
753
|
+
dateBuckets: { year: {
|
|
754
|
+
field: "createdAt",
|
|
755
|
+
interval: "year"
|
|
756
|
+
} },
|
|
757
|
+
measures: { n: { op: "count" } }
|
|
758
|
+
})).rows).toEqual([{
|
|
759
|
+
year: "2026",
|
|
760
|
+
n: 5
|
|
761
|
+
}]);
|
|
762
|
+
});
|
|
763
|
+
it.skipIf(skipNoCustomBuckets)("custom 15-minute bins", async () => {
|
|
764
|
+
if (!ctx.repo.aggregate) return;
|
|
765
|
+
await ctx.repo.createMany([
|
|
766
|
+
harness.makeDoc({
|
|
767
|
+
name: "q1",
|
|
768
|
+
category: "bk2",
|
|
769
|
+
count: 1,
|
|
770
|
+
createdAt: "2026-04-15T10:00:00Z"
|
|
771
|
+
}),
|
|
772
|
+
harness.makeDoc({
|
|
773
|
+
name: "q2",
|
|
774
|
+
category: "bk2",
|
|
775
|
+
count: 1,
|
|
776
|
+
createdAt: "2026-04-15T10:14:00Z"
|
|
777
|
+
}),
|
|
778
|
+
harness.makeDoc({
|
|
779
|
+
name: "q3",
|
|
780
|
+
category: "bk2",
|
|
781
|
+
count: 1,
|
|
782
|
+
createdAt: "2026-04-15T10:15:00Z"
|
|
783
|
+
}),
|
|
784
|
+
harness.makeDoc({
|
|
785
|
+
name: "q4",
|
|
786
|
+
category: "bk2",
|
|
787
|
+
count: 1,
|
|
788
|
+
createdAt: "2026-04-15T10:29:00Z"
|
|
789
|
+
}),
|
|
790
|
+
harness.makeDoc({
|
|
791
|
+
name: "q5",
|
|
792
|
+
category: "bk2",
|
|
793
|
+
count: 1,
|
|
794
|
+
createdAt: "2026-04-15T10:30:00Z"
|
|
795
|
+
})
|
|
796
|
+
]);
|
|
797
|
+
expect((await ctx.repo.aggregate({
|
|
798
|
+
filter: eq("category", "bk2"),
|
|
799
|
+
dateBuckets: { bin: {
|
|
800
|
+
field: "createdAt",
|
|
801
|
+
interval: {
|
|
802
|
+
every: 15,
|
|
803
|
+
unit: "minute"
|
|
804
|
+
}
|
|
805
|
+
} },
|
|
806
|
+
measures: { n: { op: "count" } },
|
|
807
|
+
sort: { bin: 1 }
|
|
808
|
+
})).rows).toEqual([
|
|
809
|
+
{
|
|
810
|
+
bin: "2026-04-15T10:00",
|
|
811
|
+
n: 2
|
|
812
|
+
},
|
|
813
|
+
{
|
|
814
|
+
bin: "2026-04-15T10:15",
|
|
815
|
+
n: 2
|
|
816
|
+
},
|
|
817
|
+
{
|
|
818
|
+
bin: "2026-04-15T10:30",
|
|
819
|
+
n: 1
|
|
820
|
+
}
|
|
821
|
+
]);
|
|
822
|
+
});
|
|
823
|
+
it.skipIf(skipNoSubMinuteBuckets)("named hour bucket emits YYYY-MM-DDTHH:00", async () => {
|
|
824
|
+
if (!ctx.repo.aggregate) return;
|
|
825
|
+
await ctx.repo.createMany([
|
|
826
|
+
harness.makeDoc({
|
|
827
|
+
name: "h1",
|
|
828
|
+
category: "bk3",
|
|
829
|
+
count: 1,
|
|
830
|
+
createdAt: "2026-04-15T10:15:00Z"
|
|
831
|
+
}),
|
|
832
|
+
harness.makeDoc({
|
|
833
|
+
name: "h2",
|
|
834
|
+
category: "bk3",
|
|
835
|
+
count: 1,
|
|
836
|
+
createdAt: "2026-04-15T10:45:00Z"
|
|
837
|
+
}),
|
|
838
|
+
harness.makeDoc({
|
|
839
|
+
name: "h3",
|
|
840
|
+
category: "bk3",
|
|
841
|
+
count: 1,
|
|
842
|
+
createdAt: "2026-04-15T11:05:00Z"
|
|
843
|
+
})
|
|
844
|
+
]);
|
|
845
|
+
expect((await ctx.repo.aggregate({
|
|
846
|
+
filter: eq("category", "bk3"),
|
|
847
|
+
dateBuckets: { hour: {
|
|
848
|
+
field: "createdAt",
|
|
849
|
+
interval: "hour"
|
|
850
|
+
} },
|
|
851
|
+
measures: { n: { op: "count" } },
|
|
852
|
+
sort: { hour: 1 }
|
|
853
|
+
})).rows).toEqual([{
|
|
854
|
+
hour: "2026-04-15T10:00",
|
|
855
|
+
n: 2
|
|
856
|
+
}, {
|
|
857
|
+
hour: "2026-04-15T11:00",
|
|
858
|
+
n: 1
|
|
859
|
+
}]);
|
|
860
|
+
});
|
|
861
|
+
it.skipIf(skipNoAgg)("date bucket combines with groupBy column", async () => {
|
|
862
|
+
if (!ctx.repo.aggregate) return;
|
|
863
|
+
expect((await ctx.repo.aggregate({
|
|
864
|
+
filter: in_("category", [...BUCKET_CATS]),
|
|
865
|
+
dateBuckets: { month: {
|
|
866
|
+
field: "createdAt",
|
|
867
|
+
interval: "month"
|
|
868
|
+
} },
|
|
869
|
+
groupBy: "category",
|
|
870
|
+
measures: { n: { op: "count" } },
|
|
871
|
+
sort: {
|
|
872
|
+
month: 1,
|
|
873
|
+
category: 1
|
|
874
|
+
}
|
|
875
|
+
})).rows).toEqual([
|
|
876
|
+
{
|
|
877
|
+
month: "2026-01",
|
|
878
|
+
category: "bk-paid",
|
|
879
|
+
n: 2
|
|
880
|
+
},
|
|
881
|
+
{
|
|
882
|
+
month: "2026-02",
|
|
883
|
+
category: "bk-paid",
|
|
884
|
+
n: 1
|
|
885
|
+
},
|
|
886
|
+
{
|
|
887
|
+
month: "2026-02",
|
|
888
|
+
category: "bk-pending",
|
|
889
|
+
n: 1
|
|
890
|
+
},
|
|
891
|
+
{
|
|
892
|
+
month: "2026-04",
|
|
893
|
+
category: "bk-paid",
|
|
894
|
+
n: 1
|
|
895
|
+
},
|
|
896
|
+
{
|
|
897
|
+
month: "2026-07",
|
|
898
|
+
category: "bk-paid",
|
|
899
|
+
n: 1
|
|
900
|
+
}
|
|
901
|
+
]);
|
|
902
|
+
});
|
|
903
|
+
it.skipIf(skipNoAgg)("aggregatePaginate keyset walks all groups via cursor", async () => {
|
|
904
|
+
if (!ctx.repo.aggregatePaginate) return;
|
|
905
|
+
const seen = [];
|
|
906
|
+
let cursor = null;
|
|
907
|
+
const aggregatePaginate = ctx.repo.aggregatePaginate.bind(ctx.repo);
|
|
908
|
+
for (let i = 0; i < 10; i++) {
|
|
909
|
+
const result = await aggregatePaginate({
|
|
910
|
+
filter: in_("category", [...KEYSET_CATS]),
|
|
911
|
+
groupBy: "category",
|
|
912
|
+
measures: { n: { op: "count" } },
|
|
913
|
+
sort: { category: 1 },
|
|
914
|
+
pagination: "keyset",
|
|
915
|
+
limit: 2,
|
|
916
|
+
...cursor ? { after: cursor } : {}
|
|
917
|
+
});
|
|
918
|
+
expect(result.method).toBe("keyset");
|
|
919
|
+
if (result.method !== "keyset") throw new Error("expected keyset envelope");
|
|
920
|
+
for (const row of result.data) seen.push(row.category);
|
|
921
|
+
cursor = result.next;
|
|
922
|
+
if (!result.hasMore) break;
|
|
923
|
+
}
|
|
924
|
+
expect(seen).toEqual([...KEYSET_CATS]);
|
|
925
|
+
});
|
|
926
|
+
it.skipIf(skipNoAgg)("aggregatePaginate keyset hasMore is false on the final page", async () => {
|
|
927
|
+
if (!ctx.repo.aggregatePaginate) return;
|
|
928
|
+
const result = await ctx.repo.aggregatePaginate.bind(ctx.repo)({
|
|
929
|
+
filter: in_("category", [...KEYSET_CATS]),
|
|
930
|
+
groupBy: "category",
|
|
931
|
+
measures: { n: { op: "count" } },
|
|
932
|
+
sort: { category: 1 },
|
|
933
|
+
pagination: "keyset",
|
|
934
|
+
limit: 100
|
|
935
|
+
});
|
|
936
|
+
expect(result.method).toBe("keyset");
|
|
937
|
+
if (result.method !== "keyset") throw new Error("expected keyset envelope");
|
|
938
|
+
expect(result.data).toHaveLength(KEYSET_CATS.length);
|
|
939
|
+
expect(result.hasMore).toBe(false);
|
|
940
|
+
expect(result.next).toBeNull();
|
|
941
|
+
});
|
|
297
942
|
});
|
|
298
943
|
describe("pagination edges", () => {
|
|
299
944
|
beforeEach(async () => {
|
|
@@ -309,8 +954,8 @@ function runStandardRepoConformance(harness) {
|
|
|
309
954
|
limit: 10,
|
|
310
955
|
sort: "createdAt"
|
|
311
956
|
});
|
|
312
|
-
expect(Array.isArray(out.
|
|
313
|
-
expect(out.
|
|
957
|
+
expect(Array.isArray(out.data)).toBe(true);
|
|
958
|
+
expect(out.data).toHaveLength(0);
|
|
314
959
|
expect(out.total).toBe(5);
|
|
315
960
|
});
|
|
316
961
|
it("limit larger than dataset returns all rows, no error", async () => {
|
|
@@ -319,7 +964,7 @@ function runStandardRepoConformance(harness) {
|
|
|
319
964
|
limit: 1e3,
|
|
320
965
|
sort: "createdAt"
|
|
321
966
|
});
|
|
322
|
-
expect(out.
|
|
967
|
+
expect(out.data).toHaveLength(5);
|
|
323
968
|
expect(out.total).toBe(5);
|
|
324
969
|
});
|
|
325
970
|
it("explicit filter in pagination narrows results consistently", async () => {
|
|
@@ -328,7 +973,7 @@ function runStandardRepoConformance(harness) {
|
|
|
328
973
|
page: 1,
|
|
329
974
|
limit: 10
|
|
330
975
|
});
|
|
331
|
-
expect(out.
|
|
976
|
+
expect(out.data).toHaveLength(1);
|
|
332
977
|
expect(out.total).toBe(1);
|
|
333
978
|
});
|
|
334
979
|
});
|
|
@@ -417,24 +1062,28 @@ function runStandardRepoConformance(harness) {
|
|
|
417
1062
|
});
|
|
418
1063
|
});
|
|
419
1064
|
describe.skipIf(!harness.features.getOrCreate)("getOrCreate", () => {
|
|
420
|
-
it("inserts when no row matches filter", async () => {
|
|
1065
|
+
it("inserts when no row matches filter (created: true)", async () => {
|
|
421
1066
|
if (!ctx.repo.getOrCreate) return;
|
|
422
|
-
|
|
1067
|
+
const result = await ctx.repo.getOrCreate({ email: "fresh@x.com" }, harness.makeDoc({
|
|
423
1068
|
name: "Fresh",
|
|
424
1069
|
email: "fresh@x.com"
|
|
425
|
-
}))
|
|
1070
|
+
}));
|
|
1071
|
+
expect(result.doc.name).toBe("Fresh");
|
|
1072
|
+
expect(result.created).toBe(true);
|
|
426
1073
|
expect(await ctx.repo.findAll()).toHaveLength(1);
|
|
427
1074
|
});
|
|
428
|
-
it("returns existing row when filter matches (no insert)", async () => {
|
|
1075
|
+
it("returns existing row when filter matches (created: false, no insert)", async () => {
|
|
429
1076
|
if (!ctx.repo.getOrCreate) return;
|
|
430
1077
|
await ctx.repo.create(harness.makeDoc({
|
|
431
1078
|
name: "Existing",
|
|
432
1079
|
email: "existing@x.com"
|
|
433
1080
|
}));
|
|
434
|
-
|
|
1081
|
+
const result = await ctx.repo.getOrCreate({ email: "existing@x.com" }, harness.makeDoc({
|
|
435
1082
|
name: "WouldOverwrite",
|
|
436
1083
|
email: "existing@x.com"
|
|
437
|
-
}))
|
|
1084
|
+
}));
|
|
1085
|
+
expect(result.doc.name).toBe("Existing");
|
|
1086
|
+
expect(result.created).toBe(false);
|
|
438
1087
|
expect(await ctx.repo.findAll()).toHaveLength(1);
|
|
439
1088
|
});
|
|
440
1089
|
});
|