@classytic/repo-core 0.3.0 → 0.4.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.
Files changed (73) hide show
  1. package/CHANGELOG.md +243 -0
  2. package/dist/adapter/index.d.mts +3 -0
  3. package/dist/adapter/index.mjs +2 -0
  4. package/dist/adapter/types.d.mts +222 -0
  5. package/dist/adapter/widen.d.mts +22 -0
  6. package/dist/adapter/widen.mjs +26 -0
  7. package/dist/aggregate/index.d.mts +3 -0
  8. package/dist/aggregate/index.mjs +3 -0
  9. package/dist/aggregate/keyset.d.mts +57 -0
  10. package/dist/aggregate/keyset.mjs +45 -0
  11. package/dist/aggregate/normalize.d.mts +24 -0
  12. package/dist/aggregate/normalize.mjs +28 -0
  13. package/dist/better-auth/index.d.mts +110 -0
  14. package/dist/better-auth/index.mjs +71 -0
  15. package/dist/cache/engine.d.mts +127 -0
  16. package/dist/cache/engine.mjs +235 -0
  17. package/dist/cache/envelope.mjs +32 -0
  18. package/dist/cache/index.d.mts +7 -2
  19. package/dist/cache/index.mjs +6 -2
  20. package/dist/cache/keys.mjs +131 -0
  21. package/dist/cache/memory-adapter.mjs +41 -7
  22. package/dist/cache/options.d.mts +112 -0
  23. package/dist/cache/options.mjs +25 -0
  24. package/dist/cache/plugin/context.d.mts +18 -0
  25. package/dist/cache/plugin/context.mjs +121 -0
  26. package/dist/cache/plugin/index.d.mts +86 -0
  27. package/dist/cache/plugin/index.mjs +78 -0
  28. package/dist/cache/plugin/invalidation-hooks.mjs +35 -0
  29. package/dist/cache/plugin/read-hooks.mjs +96 -0
  30. package/dist/cache/plugin/swr.mjs +20 -0
  31. package/dist/cache/runtime.d.mts +43 -0
  32. package/dist/cache/runtime.mjs +14 -0
  33. package/dist/cache/tag-index.mjs +84 -0
  34. package/dist/cache/timeout-adapter.d.mts +30 -0
  35. package/dist/cache/timeout-adapter.mjs +58 -0
  36. package/dist/cache/types.d.mts +45 -0
  37. package/dist/cache/version-store.mjs +57 -0
  38. package/dist/errors/index.d.mts +2 -1
  39. package/dist/errors/index.mjs +2 -1
  40. package/dist/errors/schema.d.mts +101 -0
  41. package/dist/errors/schema.mjs +78 -0
  42. package/dist/filter/match.mjs +38 -2
  43. package/dist/pagination/canonical.d.mts +8 -8
  44. package/dist/pagination/canonical.mjs +3 -9
  45. package/dist/pagination/cursor.mjs +4 -1
  46. package/dist/pagination/index.d.mts +2 -2
  47. package/dist/pagination/types.d.mts +17 -27
  48. package/dist/plugins/index.d.mts +2 -0
  49. package/dist/plugins/index.mjs +2 -0
  50. package/dist/plugins/tenant-helpers.d.mts +63 -0
  51. package/dist/plugins/tenant-helpers.mjs +84 -0
  52. package/dist/query-parser/index.d.mts +2 -1
  53. package/dist/query-parser/index.mjs +2 -1
  54. package/dist/query-parser/parse-url.mjs +13 -11
  55. package/dist/query-parser/reserved.d.mts +43 -0
  56. package/dist/query-parser/reserved.mjs +56 -0
  57. package/dist/repository/agg-output.d.mts +63 -0
  58. package/dist/repository/agg-output.mjs +89 -0
  59. package/dist/repository/index.d.mts +4 -2
  60. package/dist/repository/index.mjs +3 -1
  61. package/dist/repository/options.d.mts +62 -0
  62. package/dist/repository/options.mjs +57 -0
  63. package/dist/repository/types.d.mts +935 -48
  64. package/dist/schema/field-rules.d.mts +41 -1
  65. package/dist/schema/field-rules.mjs +92 -1
  66. package/dist/schema/index.d.mts +2 -2
  67. package/dist/schema/index.mjs +2 -2
  68. package/dist/schema/types.d.mts +21 -0
  69. package/dist/testing/conformance.mjs +666 -17
  70. package/dist/testing/index.d.mts +2 -2
  71. package/dist/testing/types.d.mts +99 -2
  72. package/package.json +19 -1
  73. 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 success:false", async () => {
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
- expect((await ctx.repo.delete(id)).success).toBe(true);
73
- expect((await ctx.repo.delete(id)).success).toBe(false);
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(!harness.features.aggregate)("empty result set returns { rows: [] } (no throw)", async () => {
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(!harness.features.aggregate)("groupBy + sum produces one row per group with correct totals", async () => {
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(!harness.features.aggregate)("scalar aggregate (no groupBy) returns single row", async () => {
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(!harness.features.aggregate)("having filters aggregated rows by measure alias", async () => {
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.docs)).toBe(true);
313
- expect(out.docs).toHaveLength(0);
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.docs).toHaveLength(5);
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.docs).toHaveLength(1);
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
- expect((await ctx.repo.getOrCreate({ email: "fresh@x.com" }, harness.makeDoc({
1067
+ const result = await ctx.repo.getOrCreate({ email: "fresh@x.com" }, harness.makeDoc({
423
1068
  name: "Fresh",
424
1069
  email: "fresh@x.com"
425
- })))?.name).toBe("Fresh");
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
- expect((await ctx.repo.getOrCreate({ email: "existing@x.com" }, harness.makeDoc({
1081
+ const result = await ctx.repo.getOrCreate({ email: "existing@x.com" }, harness.makeDoc({
435
1082
  name: "WouldOverwrite",
436
1083
  email: "existing@x.com"
437
- })))?.name).toBe("Existing");
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
  });