@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
@@ -1,7 +1,8 @@
1
1
  import { Filter } from "../filter/types.mjs";
2
2
  import { OffsetPaginationResult } from "../pagination/types.mjs";
3
- import { LookupPopulateOptions, LookupPopulateResult } from "../lookup/types.mjs";
3
+ import { LookupPopulateOptions, LookupPopulateResult, LookupSpec } from "../lookup/types.mjs";
4
4
  import { UpdateInput } from "../update/types.mjs";
5
+ import { CacheOptions } from "../cache/options.mjs";
5
6
 
6
7
  //#region src/repository/types.d.ts
7
8
  /**
@@ -82,9 +83,173 @@ interface FindOneAndUpdateOptions extends QueryOptions {
82
83
  /** Insert when no doc matches. Default: false. */
83
84
  upsert?: boolean;
84
85
  }
85
- /** Result of a single delete — matches mongokit's shape. */
86
+ /**
87
+ * Transition spec for `StandardRepo.claim()` — a CAS state change.
88
+ *
89
+ * Most state machines key off a `status` field (the default), so the
90
+ * common shape is just `{ from, to }`. Use the `field` key when the
91
+ * state lives on a different column (`phase`, `state`, `step`, etc.).
92
+ *
93
+ * For state machines whose "ready to claim" predicate isn't expressible
94
+ * by the state field alone — paused guards, retry-time guards,
95
+ * heartbeat-staleness, sub-document `$elemMatch` predicates — see the
96
+ * `where` field below.
97
+ *
98
+ * **Cross-package stability contract.** This shape is the canonical
99
+ * source of truth that mongokit's `Repository.claim`, sqlitekit's
100
+ * `claim`, and primitives' `ClaimableRepo<TDoc>` (in
101
+ * `@classytic/primitives/state-machine`) ALL conform to. Primitives
102
+ * stays dep-free by mirroring this shape structurally with
103
+ * `Record<string, unknown>` for the options slot — kits implement it
104
+ * with their own option-bag refinements (mongokit's `SessionOptions
105
+ * & { idField?, upsert? }`, sqlitekit's analogous shape) which remain
106
+ * structurally compatible.
107
+ *
108
+ * Editors: changing field names, removing slots, or narrowing
109
+ * existing parameter types here is a contract-level break. Adding
110
+ * new optional fields is additive — fine. The conformance test in
111
+ * mongokit's `tests/unit/standard-repo-assignment.test-d.ts`
112
+ * (wired into `prepublishOnly`) catches drift at the kit boundary.
113
+ */
114
+ interface ClaimTransition {
115
+ /**
116
+ * Document field carrying the state. Defaults to `'status'` —
117
+ * matches the convention across `streamline`, `@classytic/order`,
118
+ * `revenue`, and `invoice` packages.
119
+ */
120
+ field?: string;
121
+ /**
122
+ * Required current value of the state field for the CAS to match.
123
+ *
124
+ * **Single value or array.** Pass a literal (`from: 'pending'`) for a
125
+ * single-source transition, or an array (`from: ['pending',
126
+ * 'approved']`) when the transition is legal from multiple states —
127
+ * compiles to `[field]: { $in: [...] }` on mongokit and
128
+ * `[field] IN (?, ?, ...)` on SQL kits. Real-world frequency:
129
+ * commission has 4 sites (`voidRecord`, `markClawedBack`,
130
+ * `endAgreement`, `_transition`) keyed off multi-source transitions;
131
+ * media-kit's error path catches failures from either `pending` or
132
+ * `processing`. Without array support these sites fall back to raw
133
+ * `findOneAndUpdate` and lose the ergonomic + plugin-routed claim
134
+ * benefits.
135
+ *
136
+ * **`from === to` is allowed** — idempotent re-claim with a payload
137
+ * write is a valid pattern (yard's `reviseDeparture` writes
138
+ * `departed → departed` to update fields atomically while asserting
139
+ * the row hasn't moved on). The CAS still returns `null` if the row
140
+ * left the source state, so race-loss semantics hold.
141
+ *
142
+ * **Upsert + array from:** when `upsert: true` is set on the call
143
+ * options and `from` is an array, mongo / SQL kits skip the literal-
144
+ * value insert behavior for that field — only a literal `from`
145
+ * value lands on the inserted row. Use a literal `from` if you need
146
+ * the inserted row to carry that source-state value (rare).
147
+ */
148
+ from: unknown | readonly unknown[];
149
+ /**
150
+ * Target value written to the state field on success. Always a
151
+ * literal — claim writes one target value per call.
152
+ */
153
+ to: unknown;
154
+ /**
155
+ * Additional filter predicates AND-merged into the CAS query
156
+ * alongside `{ [idField]: id, [field]: from }`. Use for guards
157
+ * the state field alone can't express. NOTE: this is the same
158
+ * `where` slot supported on `ClaimVersionTransition`.
159
+ *
160
+ * - `{ paused: { $ne: true } }` — skip paused docs
161
+ * - `{ retryAfter: { $lte: now } }` — only fire when the timer
162
+ * elapsed
163
+ * - `{ $or: [{ lastHeartbeat: { $lt: stale } }, ...] }` —
164
+ * heartbeat-staleness, multi-condition match
165
+ * - `{ steps: { $elemMatch: { status: 'pending' } } }` —
166
+ * sub-document predicates
167
+ *
168
+ * Real-world data: of streamline's 21 atomic-claim sites, only 1
169
+ * fits the bare `{ [idField]: id, [field]: from }` shape; the
170
+ * other 20 carry compound predicates of exactly this shape. Same
171
+ * pattern across `revenue`, `order`, `invoice`. Without this field,
172
+ * `claim` covers the textbook example but misses every real-world
173
+ * state machine in the ecosystem.
174
+ *
175
+ * Null-on-race semantics unchanged: if no doc matches the full
176
+ * compound filter (state field OR any `where` predicate), `claim`
177
+ * returns `null`. The caller can't distinguish "lost race" from
178
+ * "guard predicate failed" — both mean "don't proceed."
179
+ *
180
+ * Cross-kit notes:
181
+ * - Mongokit: ANDed into the `findOneAndUpdate` filter.
182
+ * - SQL kits: ANDed into the `WHERE` clause (raw column literals
183
+ * accepted; portable Filter IR is compiled).
184
+ * - Prismakit: merged as additional keys on the `where` object.
185
+ */
186
+ where?: Record<string, unknown>;
187
+ }
188
+ /**
189
+ * Transition spec for `StandardRepo.claimVersion()` — optimistic-
190
+ * concurrency CAS via a version stamp. Sibling to `ClaimTransition`;
191
+ * different mental model:
192
+ *
193
+ * - `claim` is a state machine: "move from status A to status B,
194
+ * atomically".
195
+ * - `claimVersion` is optimistic locking: "I expect version N; if
196
+ * it still is, apply this update and bump the version".
197
+ *
198
+ * The CAS round-trip:
199
+ *
200
+ * ```ts
201
+ * findOneAndUpdate(
202
+ * { _id, [versionField]: from, ...where }, // CAS match
203
+ * { ...update, $inc: { [versionField]: by ?? 1 } }, // bump on success
204
+ * );
205
+ * ```
206
+ *
207
+ * Returns `null` when the row is missing, the version doesn't match
208
+ * (race-loss), or any `where` predicate fails. Same null-on-race
209
+ * semantics as `claim`.
210
+ */
211
+ interface ClaimVersionTransition {
212
+ /**
213
+ * Field carrying the version stamp. Defaults to `'version'` —
214
+ * matches the convention across `@classytic/order`, `leave`,
215
+ * `payrun`.
216
+ */
217
+ field?: string;
218
+ /**
219
+ * Required current value of the version field for the CAS to
220
+ * match. **`undefined` is admitted** for first-write CAS — matches
221
+ * docs whose version field is missing OR null. Lean reads return
222
+ * `version: number | undefined` because field defaults are absent
223
+ * on fresh-from-DB POJOs; tolerating `undefined` removes the
224
+ * `?? 0` ceremony at every call site. The implementation
225
+ * initializes via `$set` on the first-write path (since `$inc`
226
+ * can't apply to a null-valued field on mongo).
227
+ */
228
+ from: number | undefined;
229
+ /**
230
+ * Increment step. Defaults to `1`. Use `by: -1` for unusual
231
+ * decrement-on-CAS patterns; not common.
232
+ */
233
+ by?: number;
234
+ /**
235
+ * Additional filter predicates AND-merged into the CAS query
236
+ * alongside `{ [idField]: id, [versionField]: from }`. Same
237
+ * compound-CAS semantic as `ClaimTransition.where` — paused
238
+ * guards, status guards (yard's `{ _id, status, version }`
239
+ * pattern), sub-document `$elemMatch`. Without this, callers had
240
+ * to fall back to raw `findOneAndUpdate` for state+version CAS.
241
+ */
242
+ where?: Record<string, unknown>;
243
+ }
244
+ /**
245
+ * Result of a single delete — what the kit returns AND what the wire
246
+ * emits (data and wire shapes are identical, like the pagination types).
247
+ *
248
+ * Miss handling is `null` (matches `update()`, `Map.get`, `Array.find`
249
+ * conventions) — kits return `Promise<DeleteResult | null>`. Callers
250
+ * check `if (!result)` for "nothing was deleted".
251
+ */
86
252
  interface DeleteResult {
87
- success: boolean;
88
253
  message: string;
89
254
  /** Primary key of the removed doc (string form). */
90
255
  id?: string;
@@ -93,6 +258,16 @@ interface DeleteResult {
93
258
  /** For batch-variant implementations that surface the count inline. */
94
259
  count?: number;
95
260
  }
261
+ /**
262
+ * Result of a bulk create — what kits return from `createMany()` AND
263
+ * what the wire emits. `data` is the inserted docs (kits may omit when
264
+ * caller passed `returnDocs: false`); `count` is the number successfully
265
+ * inserted.
266
+ */
267
+ interface BulkCreateResult<T = unknown> {
268
+ data?: T[];
269
+ count?: number;
270
+ }
96
271
  /** Result of a batch delete. */
97
272
  interface DeleteManyResult {
98
273
  acknowledged?: boolean;
@@ -186,83 +361,627 @@ interface BulkWriteResult {
186
361
  * Kit compilers normalize unknown ops to a runtime error — keep the set
187
362
  * tight so aggregations compile identically everywhere.
188
363
  */
364
+ /**
365
+ * Optional per-measure predicate. When set, the measure aggregates
366
+ * ONLY over rows matching the predicate within each group — semantic
367
+ * equivalent of SQL's `SUM(amount) FILTER (WHERE status = 'paid')`
368
+ * and Mongo's `{ $sum: { $cond: [<expr>, '$amount', 0] } }`.
369
+ *
370
+ * Lets one query compute `paid_revenue` + `total_revenue` +
371
+ * `refund_count` side-by-side, instead of running N pre-filtered
372
+ * pipelines and stitching the rows together at the call site. The
373
+ * #1 dashboard-query lever; cuts query count + DB load proportional
374
+ * to the number of distinct slices in a typical KPI tile group.
375
+ *
376
+ * Accepts the same `FilterInput` shape every other slot does (Filter
377
+ * IR or plain record). References BASE columns + joined-alias paths
378
+ * (when `lookups` are configured), same as the top-level `filter`.
379
+ *
380
+ * Does NOT replace the top-level `AggRequest.filter` — that narrows
381
+ * the rows feeding into ALL measures; per-measure `where` narrows
382
+ * within the group AFTER the top-level filter has already applied.
383
+ *
384
+ * @example
385
+ * ```ts
386
+ * await orders.aggregate({
387
+ * groupBy: 'category',
388
+ * measures: {
389
+ * totalRevenue: { op: 'sum', field: 'amount' },
390
+ * paidRevenue: { op: 'sum', field: 'amount', where: eq('status', 'paid') },
391
+ * refundCount: { op: 'count', where: eq('status', 'refunded') },
392
+ * },
393
+ * });
394
+ * ```
395
+ */
189
396
  type AggMeasure = {
190
397
  op: 'count';
191
398
  field?: string;
399
+ where?: FilterInput;
192
400
  } | {
193
401
  op: 'countDistinct';
194
402
  field: string;
403
+ where?: FilterInput;
195
404
  } | {
196
405
  op: 'sum';
197
406
  field: string;
407
+ where?: FilterInput;
198
408
  } | {
199
409
  op: 'avg';
200
410
  field: string;
411
+ where?: FilterInput;
201
412
  } | {
202
413
  op: 'min';
203
414
  field: string;
415
+ where?: FilterInput;
204
416
  } | {
205
417
  op: 'max';
206
418
  field: string;
419
+ where?: FilterInput;
420
+ } | {
421
+ /**
422
+ * Continuous percentile — interpolates between adjacent values
423
+ * when the requested rank falls between two rows. Equivalent to
424
+ * SQL's `PERCENTILE_CONT(p) WITHIN GROUP (ORDER BY field)` and
425
+ * Mongo's `$percentile` with `method: 'approximate'` (Mongo 7+).
426
+ *
427
+ * `p` is the percentile in `[0, 1]` (e.g. `0.5` for median,
428
+ * `0.95` for P95 latency).
429
+ *
430
+ * **Kit support is asymmetric.** Mongokit (≥3.13) maps to
431
+ * `$percentile`; sqlitekit throws `UnsupportedOperationError`
432
+ * (SQLite has no native percentile function and emulating via
433
+ * window functions is approximate + slow). Hosts targeting
434
+ * percentile dashboards pin to a backend that supports it
435
+ * (mongokit, future pgkit's `PERCENTILE_CONT`).
436
+ */
437
+ op: 'percentile';
438
+ field: string;
439
+ p: number;
440
+ where?: FilterInput;
441
+ } | {
442
+ /**
443
+ * Sample standard deviation — `sqrt(variance / (n - 1))`.
444
+ * Equivalent to SQL's `STDDEV_SAMP()` and Mongo's
445
+ * `$stdDevSamp`. Use `'stddevPop'` for population stddev
446
+ * (`/ n` instead of `/ (n - 1)`); pick `'stddev'` (sample) by
447
+ * default — that's what every BI tool reports without
448
+ * qualification, and matches `numpy.std(ddof=1)`.
449
+ *
450
+ * **Kit support is asymmetric.** Mongokit uses native
451
+ * `$stdDevSamp` (numerically stable Welford algorithm).
452
+ * Sqlitekit throws `UnsupportedOperationError` — SQLite ships
453
+ * no native `STDDEV` aggregate and emulating via the
454
+ * computational formula `sqrt(sum(x²) - sum(x)²/n / (n-1))`
455
+ * is numerically unstable for typical dashboard data
456
+ * (catastrophic cancellation when values are near-equal). Hosts
457
+ * needing stddev dashboards pin to mongokit or future pgkit.
458
+ */
459
+ op: 'stddev';
460
+ field: string;
461
+ where?: FilterInput;
462
+ } | {
463
+ /** Population standard deviation (`/ n`). See `'stddev'` for
464
+ * the sample variant + per-kit support matrix (identical). */
465
+ op: 'stddevPop';
466
+ field: string;
467
+ where?: FilterInput;
207
468
  };
208
469
  /**
209
- * Portable aggregation request. Compiles to SQL (`SELECT ... WHERE ...
210
- * GROUP BY ... HAVING ... ORDER BY ... LIMIT ... OFFSET`) on sqlitekit /
211
- * pgkit and to a `[$match, $group, $match, $sort, $limit, $skip]`
212
- * pipeline on mongokit. Output shape is identical either way: one row
213
- * per group, keyed by `groupBy` fields + measure aliases.
470
+ * Tie-breaking strategy for `AggTopN.ties`. Mirrors the three SQL
471
+ * window-rank functions; mongokit maps the same names to
472
+ * `$rank` / `$denseRank` / `$documentNumber`.
473
+ *
474
+ * - `'rank'` — `RANK()`. Ties share a rank; gaps after.
475
+ * e.g. `[100, 100, 80] → ranks 1, 1, 3`.
476
+ * - `'dense_rank'` — `DENSE_RANK()`. Ties share a rank; no gaps.
477
+ * e.g. `[100, 100, 80] → ranks 1, 1, 2`.
478
+ * - `'row_number'` — `ROW_NUMBER()`. Each row gets a unique rank
479
+ * regardless of ties; tie-broken arbitrarily.
480
+ * e.g. `[100, 100, 80] → ranks 1, 2, 3`.
481
+ *
482
+ * Default: `'rank'`. Choose `'row_number'` when you need EXACTLY N
483
+ * rows per partition with no chance of overshoot from ties.
484
+ */
485
+ type AggTopNTies = 'rank' | 'dense_rank' | 'row_number';
486
+ /**
487
+ * Top-N-per-group spec — keep only the top `limit` rows per
488
+ * partition, ranked by `sortBy`. The classic "top 3 products per
489
+ * category" / "top 5 customers per region" dashboard primitive.
490
+ *
491
+ * **Semantics.** Each unique combination of `partitionBy` columns
492
+ * forms a partition. Within each partition, rows are ranked by
493
+ * `sortBy` (descending by default — typical "top N" intent). The
494
+ * top `limit` rows from each partition land in the result; the rest
495
+ * are dropped.
496
+ *
497
+ * **Compile target.**
498
+ *
499
+ * - **Mongokit** — `$setWindowFields` + `$match: { rank: { $lte: limit } }`.
500
+ * Mongo 5+. The window stage runs AFTER `$group` / `$project`,
501
+ * so `partitionBy` and `sortBy` may reference both group keys
502
+ * and measure aliases.
503
+ * - **Sqlitekit** — `RANK() / DENSE_RANK() / ROW_NUMBER() OVER
504
+ * (PARTITION BY ... ORDER BY ...)` wrapped in a subquery, with
505
+ * a `WHERE rank <= limit` outer filter. SQLite ≥3.25 (Sept 2018).
506
+ *
507
+ * **Cross-kit shape contract.** The output row shape doesn't change
508
+ * — top-N just drops rows that didn't make the cut. Internal rank
509
+ * column is stripped before the row reaches the caller.
510
+ *
511
+ * @example "Top 3 products per category by revenue"
512
+ * ```ts
513
+ * await orders.aggregate({
514
+ * groupBy: ['category', 'product'],
515
+ * measures: { revenue: { op: 'sum', field: 'amount' } },
516
+ * topN: {
517
+ * partitionBy: 'category',
518
+ * sortBy: { revenue: -1 },
519
+ * limit: 3,
520
+ * },
521
+ * sort: { category: 1, revenue: -1 },
522
+ * });
523
+ * ```
524
+ *
525
+ * @example "Top 1 highest spender per region per month"
526
+ * ```ts
527
+ * await orders.aggregate({
528
+ * dateBuckets: { month: { field: 'createdAt', interval: 'month' } },
529
+ * groupBy: ['region', 'customerId'],
530
+ * measures: { spent: { op: 'sum', field: 'amount' } },
531
+ * topN: {
532
+ * partitionBy: ['region', 'month'],
533
+ * sortBy: { spent: -1 },
534
+ * limit: 1,
535
+ * ties: 'row_number', // exactly one winner per partition
536
+ * },
537
+ * });
538
+ * ```
539
+ */
540
+ interface AggTopN {
541
+ /**
542
+ * Group columns or `dateBuckets` aliases that define each partition.
543
+ * Must be a subset of `groupBy` + `dateBuckets` keys — kits validate
544
+ * at compile time and throw on a mismatch.
545
+ *
546
+ * Pass a single string for a one-column partition; an array for
547
+ * compound partitions.
548
+ */
549
+ partitionBy: string | readonly string[];
550
+ /**
551
+ * Ranking sort within each partition. Keys are measure aliases,
552
+ * `groupBy` columns, or `dateBuckets` aliases — the same surface
553
+ * as the top-level `sort`. Defaults to descending intent (top N
554
+ * by revenue → `{ revenue: -1 }`).
555
+ */
556
+ sortBy: Record<string, 1 | -1>;
557
+ /** Max rows to keep per partition. Must be a positive integer. */
558
+ limit: number;
559
+ /**
560
+ * Tie-breaking strategy. Default `'rank'`. See `AggTopNTies` for
561
+ * the three options.
562
+ */
563
+ ties?: AggTopNTies;
564
+ }
565
+ /**
566
+ * Per-request cache options for `aggregate()` / `aggregatePaginate()`.
567
+ *
568
+ * **Unified with the canonical `CacheOptions`** at `@classytic/repo-core/cache`.
569
+ * Same shape across CRUD, aggregate, and every kit — TanStack Query-
570
+ * inspired `staleTime` / `gcTime` model, `swr` flag, `tags` for group
571
+ * invalidation, `bypass` for refresh buttons, `enabled` for opt-out,
572
+ * `key` for explicit override.
573
+ *
574
+ * Kept here as a re-export so existing `import { AggCacheOptions }`
575
+ * paths keep working — they resolve to the same type.
576
+ */
577
+ type AggCacheOptions = CacheOptions;
578
+ /**
579
+ * Driver-tunable knobs forwarded to the kit's native aggregation API.
580
+ * Each kit honors the hints it can; unsupported keys are ignored
581
+ * (never thrown) — keeps the IR portable while letting hosts opt into
582
+ * backend-specific performance levers without dropping to kit-native
583
+ * pipelines.
584
+ *
585
+ * **Per-kit support matrix:**
586
+ *
587
+ * | Hint | mongokit | sqlitekit | future kits |
588
+ * | --------------- | ------------------------------ | ------------------------ | ----------- |
589
+ * | `allowDiskUse` | `aggregate({ allowDiskUse })` | ignored (planner spills) | per-driver |
590
+ * | `maxTimeMs` | `aggregate({ maxTimeMS })` | ignored (sync driver) | per-driver |
591
+ * | `indexHint` | `aggregate({ hint })` | ignored (planner-driven) | per-driver |
592
+ *
593
+ * **Hosts that need a hint to work** must pin the kit version that
594
+ * supports it via peer-deps. Falling back to "ignored" keeps mixed-
595
+ * kit fleets working — if a sqlitekit user asks for `allowDiskUse`,
596
+ * they get the same query they would have without it (SQLite's
597
+ * planner spills to disk automatically).
598
+ *
599
+ * **Why sqlitekit ignores `maxTimeMs`:** better-sqlite3 (the reference
600
+ * driver) is synchronous — there is no event-loop tick during a query
601
+ * for a watchdog to interrupt. `db.pragma('busy_timeout', n)` is a
602
+ * connection-level wait-for-lock setting, NOT a query timeout, and is
603
+ * already configured at driver init. For workloads that need
604
+ * cancellable queries pin to mongokit / pgkit, or run sqlitekit on
605
+ * libsql (async driver) where statement-level abort is feasible.
606
+ */
607
+ interface AggExecutionHints {
608
+ /**
609
+ * Allow the aggregation to spill intermediate results to disk when
610
+ * the in-memory limit is exceeded. Mongo: `allowDiskUse: true`.
611
+ * SQL: typically a no-op (the query planner already manages spill).
612
+ */
613
+ allowDiskUse?: boolean;
614
+ /**
615
+ * Server-side query timeout in milliseconds. Mongo: `maxTimeMS`.
616
+ * Aborted queries throw a kit-native timeout error — wire it to
617
+ * your error handler if you want graceful degradation.
618
+ */
619
+ maxTimeMs?: number;
620
+ /**
621
+ * Index hint passed to the planner. Kit-specific shape:
622
+ * mongokit accepts `{ field: 1 }` or an index name string;
623
+ * sqlitekit ignores (the SQLite planner picks indexes itself).
624
+ * Use sparingly — most aggregations plan correctly without hints.
625
+ */
626
+ indexHint?: unknown;
627
+ }
628
+ /**
629
+ * Time-unit primitive for bucket sizing. Use `AggDateBucketInterval`
630
+ * (the union below) at API boundaries — this type is the underlying
631
+ * unit set both the named buckets (`'month'`) and the custom-bin
632
+ * shape (`{ every: 15, unit: 'minute' }`) draw from.
633
+ */
634
+ type AggDateBucketUnit = 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year';
635
+ /**
636
+ * Granularity for time-bucket grouping. Two surface forms:
637
+ *
638
+ * 1. **Named buckets** — string-shape, emit canonical ISO-shaped labels:
639
+ *
640
+ * - `'minute'` → `'YYYY-MM-DDTHH:MM'` (`'2026-04-15T10:30'`)
641
+ * - `'hour'` → `'YYYY-MM-DDTHH:00'` (`'2026-04-15T10:00'`)
642
+ * - `'day'` → `'YYYY-MM-DD'` (`'2026-04-15'`)
643
+ * - `'week'` → `'YYYY-Www'` (`'2026-W15'`, ISO 8601 week)
644
+ * - `'month'` → `'YYYY-MM'` (`'2026-04'`)
645
+ * - `'quarter'` → `'YYYY-Qn'` (`'2026-Q2'`)
646
+ * - `'year'` → `'YYYY'` (`'2026'`)
647
+ *
648
+ * 2. **Custom bins** — object-shape, for arbitrary intervals like
649
+ * "every 15 minutes" or "every 6 hours":
650
+ *
651
+ * ```ts
652
+ * { every: 15, unit: 'minute' } // 00:00, 00:15, 00:30, ...
653
+ * { every: 6, unit: 'hour' } // 00:00, 06:00, 12:00, ...
654
+ * { every: 7, unit: 'day' } // weekly bins anchored on epoch
655
+ * ```
656
+ *
657
+ * Custom bins emit ISO-shaped labels matching the largest unit
658
+ * granularity that disambiguates the bin start (e.g. 15-minute bins
659
+ * emit `'YYYY-MM-DDTHH:MM'`; 6-hour bins emit `'YYYY-MM-DDTHH:00'`).
660
+ * `every` must be a positive integer; `unit` is the same set as
661
+ * named buckets minus quarter (which only makes sense as `every: 1`).
662
+ *
663
+ * **All bucketing is UTC.** Kits that need wall-clock semantics for
664
+ * "this month in America/Los_Angeles" pre-shift the column at the
665
+ * application layer — keeping the IR timezone-free dodges per-kit
666
+ * tz library mismatches (mongo's `$dateToString` and SQLite's
667
+ * `strftime` use different IANA tables).
668
+ *
669
+ * The bucket label is sortable lexicographically — that's the whole
670
+ * point of the format. `ORDER BY month ASC` yields chronological
671
+ * order without parsing the string back into a date.
672
+ */
673
+ type AggDateBucketInterval = AggDateBucketUnit | {
674
+ /** Number of `unit`-sized chunks per bin. Must be a positive integer. */every: number; /** Base unit. `quarter` and `year` aren't supported in custom-bin form. */
675
+ unit: Exclude<AggDateBucketUnit, 'quarter' | 'year'>;
676
+ };
677
+ /**
678
+ * Time-bucket group expression. Promotes a date column into a
679
+ * synthetic group key bucketed by the chosen interval.
680
+ *
681
+ * **Why a separate slot, not inline in `groupBy`.** A computed
682
+ * expression isn't a column reference. Each kit needs the
683
+ * `field + interval` pair to emit its dialect (`$dateToString` on
684
+ * Mongo, `strftime` on SQLite, `DATE_TRUNC` on PG). Keeping it on its
685
+ * own slot lets `groupBy` stay a plain column-name list — no shape
686
+ * change for existing callers.
687
+ *
688
+ * The bucket alias (the key in `dateBuckets`) becomes a regular group
689
+ * key — it counts as a `groupBy` participant for cardinality, sort,
690
+ * and pagination. Reference it in `having` / `sort` by alias.
691
+ *
692
+ * @example
693
+ * ```ts
694
+ * await orders.aggregate({
695
+ * filter: { status: 'paid' },
696
+ * dateBuckets: { month: { field: 'createdAt', interval: 'month' } },
697
+ * measures: { revenue: { op: 'sum', field: 'amount' } },
698
+ * sort: { month: 1 },
699
+ * });
700
+ * // rows: [
701
+ * // { month: '2026-01', revenue: 12345 },
702
+ * // { month: '2026-02', revenue: 18900 },
703
+ * // ]
704
+ * ```
705
+ */
706
+ interface AggDateBucket {
707
+ /**
708
+ * The date / timestamp column to bucket. May be a dotted path into a
709
+ * joined alias when `lookups` is configured.
710
+ *
711
+ * **Storage shape kits must accept:** ISO-8601 strings (the canonical
712
+ * cross-kit shape from arc + kit serializers), millisecond epoch
713
+ * numbers, and native Date / BSON Date values. SQLite's TEXT-stored
714
+ * timestamps in `'YYYY-MM-DD HH:MM:SS'` form also work.
715
+ */
716
+ field: string;
717
+ /** Bucket granularity. */
718
+ interval: AggDateBucketInterval;
719
+ }
720
+ /**
721
+ * Portable aggregation request. Compiles to SQL (`SELECT ... FROM ...
722
+ * [LEFT JOIN ...] WHERE ... GROUP BY ... HAVING ... ORDER BY ... LIMIT ...
723
+ * OFFSET`) on sqlitekit / pgkit and to a `[$match, $lookup*, $group,
724
+ * $match, $sort, $limit, $skip]` pipeline on mongokit. Output shape is
725
+ * identical either way: one row per group, keyed by `groupBy` fields
726
+ * + measure aliases.
214
727
  *
215
728
  * Without `groupBy`: returns a single row of scalar aggregates over the
216
729
  * full filtered set. With `groupBy`: one row per distinct group.
217
730
  *
218
- * `filter` and `having` both reuse the Filter IR — `filter` narrows the
219
- * rows that feed into the aggregate (WHERE), `having` narrows the
220
- * aggregated result (HAVING). Use `having` to reference measure aliases
221
- * (`{ field: 'revenue', op: 'gt', value: 1000 }`); kit compilers
731
+ * **Compile order (every kit MUST follow):**
732
+ *
733
+ * 1. `filter` — pre-aggregate predicate, applied to BASE rows
734
+ * BEFORE joins. Drives index selection. (WHERE / `$match`)
735
+ * 2. `lookups` — joins, processed in array order. Each `LookupSpec`
736
+ * contributes one stage. Joined rows land at the
737
+ * alias declared in `LookupSpec.as` (or `from` when
738
+ * `as` omitted).
739
+ * 3. `groupBy` — one row per distinct combination of group keys.
740
+ * May reference dotted paths into joined aliases:
741
+ * `'category.parent'` groups by the joined
742
+ * `category` row's `parent` field.
743
+ * 4. `measures` — aggregate functions applied to the grouped rows.
744
+ * `measure.field` accepts the same dotted-path syntax
745
+ * as `groupBy` for joined fields.
746
+ * 5. `having` — post-aggregate predicate, references measure
747
+ * aliases. (HAVING)
748
+ * 6. `sort` — order grouped rows. Keys are `groupBy` fields,
749
+ * measure aliases, or dotted paths into joined rows.
750
+ * 7. `limit` / `offset` — row cap + skip.
751
+ *
752
+ * **Filter IR usage:** `filter` and `having` both reuse the Filter IR.
753
+ * `filter` narrows the rows that feed into the aggregate; `having`
754
+ * narrows the aggregated result. Reference measure aliases in `having`
755
+ * (`{ field: 'revenue', op: 'gt', value: 1000 }`) — kit compilers
222
756
  * substitute the aggregate expression when the field matches a measure.
223
757
  *
224
- * Power features that don't translate across backends `$lookup`,
225
- * `$unwind`, window functions, CTEs stay kit-native. Reach for
226
- * mongokit's `aggregatePipeline` or sqlitekit's raw `repo.db` when you
227
- * need them.
758
+ * **Tenant / policy filter alignment.** Hosts (or arc) MUST inject
759
+ * tenant-scope predicates as the LEFT-MOST clause of `filter` so the
760
+ * leading-key index is hit first. Order matters at scale: an
761
+ * unscoped aggregate on a billion-row collection scans the world.
762
+ *
763
+ * **Power features that stay kit-native.** Window functions, CTEs,
764
+ * pipeline-form `$lookup` with `let`, sub-aggregations, lateral
765
+ * correlated subqueries — reach for `aggregatePipeline` (mongokit) or
766
+ * raw Drizzle (`repo.db`) (sqlitekit). The portable IR draws a
767
+ * deliberate ceiling at "joins + grouping + measures."
228
768
  */
229
769
  interface AggRequest {
230
- /** Pre-aggregate predicate. Reuses Filter IR; compiles to WHERE / `$match`. */
770
+ /** Pre-aggregate predicate on the BASE rows (before joins). Reuses
771
+ * Filter IR; compiles to WHERE / `$match`. Place tenant-scope
772
+ * predicates first for correct index selection. */
231
773
  filter?: unknown;
232
- /** Grouping columns. Single string, array of strings, or omitted for scalar aggregation. */
774
+ /**
775
+ * Optional cross-table joins. Compiled BEFORE the grouping pipeline
776
+ * so `groupBy` / `measure.field` / `having` / `sort` may reference
777
+ * `joinedAlias.field` paths. Reuses the same `LookupSpec` IR that
778
+ * `lookupPopulate()` accepts — kits that already ship
779
+ * `lookupPopulate` reuse their compile path (mongokit emits
780
+ * `$lookup` stages, sqlitekit emits `LEFT JOIN`).
781
+ *
782
+ * **Per-lookup `where`** narrows the foreign side BEFORE the join
783
+ * runs. Always set when joining a high-cardinality table — without
784
+ * it, you join the world and post-filter, which doesn't scale.
785
+ *
786
+ * **Per-lookup `select`** projects only required columns from the
787
+ * joined row. Reduces row size and prevents accidental exposure of
788
+ * sensitive fields on the joined side.
789
+ *
790
+ * **Kit support is graceful-fail.** A kit that doesn't support
791
+ * `lookups` in its `aggregate()` throws an
792
+ * `UnsupportedOperationError` with a clear message at request time.
793
+ * Hosts pin their kit version via peer-deps to get the support
794
+ * window they need.
795
+ */
796
+ lookups?: readonly LookupSpec[];
797
+ /** Grouping columns. Single string, array of strings, or omitted for
798
+ * scalar aggregation. May reference dotted paths into joined aliases
799
+ * when `lookups` is present (e.g. `'category.parent'`). */
233
800
  groupBy?: string | readonly string[];
801
+ /**
802
+ * Synthetic time-bucket group keys. Each entry promotes a date
803
+ * column into a group key bucketed at the chosen granularity
804
+ * (`day` / `week` / `month` / `quarter` / `year`). The map key
805
+ * becomes a column in the output row holding the canonical
806
+ * ISO-shaped bucket label.
807
+ *
808
+ * Bucketed keys participate in grouping just like `groupBy` columns
809
+ * — `sort: { month: 1 }`, `having: { field: 'month', op: 'gte', ... }`,
810
+ * pagination cardinality all treat them as first-class.
811
+ *
812
+ * Aliases must NOT collide with `groupBy` field names or `measures`
813
+ * aliases — the row shape would be ambiguous. Kits surface a
814
+ * collision as a runtime error.
815
+ */
816
+ dateBuckets?: Record<string, AggDateBucket>;
234
817
  /**
235
818
  * Named aggregations. At least one key required — an empty `measures`
236
- * bag is a wiring bug (nothing to compute).
819
+ * bag is a wiring bug (nothing to compute). `measure.field` accepts
820
+ * the same dotted-path syntax as `groupBy` for joined fields.
237
821
  */
238
822
  measures: Record<string, AggMeasure>;
239
823
  /** Post-aggregate predicate. Reuses Filter IR; references measure aliases. */
240
824
  having?: unknown;
241
- /** Order the grouped rows. Keys may be `groupBy` fields or measure aliases. */
825
+ /** Order the grouped rows. Keys may be `groupBy` fields, measure
826
+ * aliases, or dotted paths into joined rows. */
242
827
  sort?: Record<string, 1 | -1>;
243
828
  /** Row cap; applied after `having` + `sort`. */
244
829
  limit?: number;
245
830
  /** Skip N grouped rows. Paginated callers use `aggregatePaginate` instead. */
246
831
  offset?: number;
832
+ /**
833
+ * Top-N-per-group filter. Keeps only the top `limit` rows per
834
+ * partition, ranked by `sortBy`. See `AggTopN` for full semantics.
835
+ *
836
+ * Composes with everything else — applies AFTER group / measures /
837
+ * having so `sortBy` may reference measure aliases. Composes with
838
+ * the top-level `sort` (which orders the FINAL row set, not within
839
+ * each partition).
840
+ */
841
+ topN?: AggTopN;
842
+ /**
843
+ * Driver-tunable performance knobs (allowDiskUse, maxTimeMs,
844
+ * indexHint). See `AggExecutionHints` for the per-kit support
845
+ * matrix. Hints a kit doesn't support are silently ignored — the
846
+ * IR stays portable while letting hosts opt into backend-specific
847
+ * levers without dropping to kit-native pipelines.
848
+ */
849
+ executionHints?: AggExecutionHints;
850
+ /**
851
+ * Per-request cache options (TTL, tags, bypass, SWR). See
852
+ * `AggCacheOptions` for full semantics. Caching is **opt-in per
853
+ * call** — omit this slot (or set `ttl: 0`) and the request
854
+ * bypasses the cache layer entirely. Requires the repo to have a
855
+ * `CacheAdapter` configured (`new Repository(model, plugins, {},
856
+ * { aggregateCache: adapter })`); throws at request time when
857
+ * caching is requested but no adapter is wired.
858
+ */
859
+ cache?: AggCacheOptions;
247
860
  }
248
861
  /**
249
- * Paginated variant of `AggRequest`. Returns the standard offset
250
- * pagination envelope same shape as `getAll({ page, limit })` so UI
251
- * code renders aggregates and raw document lists with the same
252
- * pagination primitives.
862
+ * Paginated variant of `AggRequest`. Two pagination modes are
863
+ * supported, picked by which fields are set:
864
+ *
865
+ * - **Offset** (default) — pass `page` (1-indexed) + `limit`. Returns
866
+ * the canonical offset envelope (`{ method: 'offset', data, total,
867
+ * pages, hasNext, hasPrev, page, limit }`). Same shape as
868
+ * `getAll({ page, limit })` so UI code renders aggregates and raw
869
+ * document lists with the same pagination primitives.
870
+ *
871
+ * - **Keyset** — pass `pagination: 'keyset'` (or supply `after`).
872
+ * Cursor-based: each response returns an opaque `next` token that
873
+ * the caller passes back as `after` to fetch the following page.
874
+ * `sort` is required (the cursor encodes the sort-key tuple of the
875
+ * last row); aliases referenced in `sort` must be either
876
+ * `groupBy` columns, `dateBuckets` aliases, or `measures` aliases.
877
+ *
878
+ * Keyset pagination scales to arbitrary group counts because it never
879
+ * scans skipped rows — `WHERE (k1, k2) > (a, b)` uses the
880
+ * `groupBy + sort` index path. Offset's `OFFSET N` skip is O(N), so it
881
+ * stalls past ~10–100k groups even with the right index. Reach for
882
+ * keyset on dashboards with deep result sets, infinite-scroll UI, and
883
+ * any time `total` doesn't have to render.
884
+ *
885
+ * ## Index requirements (don't skip this for big-data workloads)
886
+ *
887
+ * The keyset advantage **vanishes without the right index**. The
888
+ * compiled query is:
889
+ *
890
+ * ```sql
891
+ * SELECT ...groupCols, ...measures
892
+ * FROM <table>
893
+ * WHERE <filter>
894
+ * GROUP BY <groupCols>
895
+ * HAVING (sortKey1, sortKey2, ...) > (?, ?, ...) -- cursor predicate
896
+ * ORDER BY <sortKey1, sortKey2>
897
+ * LIMIT <pageSize>
898
+ * ```
899
+ *
900
+ * On large tables (10M+ rows) you MUST have:
901
+ *
902
+ * 1. **A composite index covering `filter` columns + `groupBy` columns**
903
+ * in roughly that order. Without this, every page rescans the base
904
+ * table — the cursor predicate runs on the post-`GROUP BY` set, so
905
+ * if grouping itself is a full scan, keyset gives no advantage over
906
+ * offset.
907
+ *
908
+ * 2. **Sort keys aligned with the index leftmost prefix.** When `sort`
909
+ * is on a measure alias (e.g. `revenue desc`), no index can help —
910
+ * the engine must compute every group before sorting. Such sorts
911
+ * are O(N log N) per page even with keyset; consider materializing
912
+ * the aggregate into a roll-up table and paginating that instead.
913
+ *
914
+ * **Examples** (mongokit / Mongo dialect; sqlitekit takes the same
915
+ * shape via Drizzle schema indexes):
916
+ *
917
+ * ```ts
918
+ * // groupBy: ['organizationId', 'category'] + sort: { category: 1 }
919
+ * Schema.index({ organizationId: 1, category: 1 });
920
+ *
921
+ * // filter: { active: true } + groupBy: ['userId'] + sort: { userId: 1 }
922
+ * Schema.index({ active: 1, userId: 1 });
923
+ *
924
+ * // dateBuckets: { day: { field: 'createdAt', unit: 'day' } }
925
+ * // + sort: { day: -1 } — cursor compares on the bucket expression,
926
+ * // so the index must cover `createdAt` (the planner pushes the bucket
927
+ * // through to a range scan):
928
+ * Schema.index({ createdAt: -1 });
929
+ * ```
930
+ *
931
+ * If `EXPLAIN` shows a `COLLSCAN` / `SCAN TABLE` on a keyset query,
932
+ * your index is missing or in the wrong order. Mongo's
933
+ * `db.coll.aggregate(...).explain('executionStats')` and SQLite's
934
+ * `EXPLAIN QUERY PLAN` both surface this.
253
935
  */
254
936
  interface AggPaginationRequest extends Omit<AggRequest, 'limit' | 'offset'> {
255
- /** 1-indexed page number. Defaults to 1. */
256
- page?: number;
257
937
  /** Rows per page. Defaults to the kit's standard limit. */
258
938
  limit?: number;
939
+ /**
940
+ * Pagination mode. `offset` (default) returns
941
+ * `{ method: 'offset', ... }`; `keyset` returns
942
+ * `{ method: 'keyset', ... }`. Setting `after` implies `'keyset'`.
943
+ */
944
+ pagination?: 'offset' | 'keyset';
945
+ /** 1-indexed page number. Used by `offset` mode only. Defaults to 1. */
946
+ page?: number;
259
947
  /**
260
948
  * `exact` runs `COUNT(DISTINCT groupBy)` (or `COUNT(*)` for scalar
261
949
  * aggregates) alongside the data query. `none` skips the count
262
950
  * entirely — the envelope's `total` / `pages` are 0 and `hasNext` is
263
- * derived from a `LIMIT N+1` peek. Defaults to `exact`.
951
+ * derived from a `LIMIT N+1` peek. Defaults to `exact`. Used by
952
+ * `offset` mode only — `keyset` never runs a count.
264
953
  */
265
954
  countStrategy?: 'exact' | 'none';
955
+ /**
956
+ * Opaque cursor from a prior keyset response's `next` field. When
957
+ * set, returns the page following the row identified by the cursor.
958
+ * Implies `pagination: 'keyset'`.
959
+ *
960
+ * Format is kit-defined and opaque — consumers MUST round-trip it
961
+ * verbatim. Cross-kit cursor compatibility is not guaranteed.
962
+ */
963
+ after?: string;
964
+ }
965
+ /**
966
+ * Keyset-paginated aggregation envelope. Returned by
967
+ * `aggregatePaginate(req)` when `pagination: 'keyset'` (or `after` is
968
+ * set). Mirrors the keyset shape `MinimalRepo.getAll()` produces for
969
+ * raw doc lists, so UI components can branch on `method` once and
970
+ * render either case identically.
971
+ */
972
+ interface KeysetAggPaginationResult<TRow extends AggRow = AggRow> {
973
+ method: 'keyset';
974
+ /** Aggregated rows for this page. */
975
+ data: TRow[];
976
+ /** Page size echoed back. */
977
+ limit: number;
978
+ /** True when another page exists after this one. */
979
+ hasMore: boolean;
980
+ /**
981
+ * Opaque cursor for the next page. `null` when `hasMore` is false.
982
+ * Consumers pass this back as `req.after` verbatim.
983
+ */
984
+ next: string | null;
266
985
  }
267
986
  /**
268
987
  * Shape of each row returned by `aggregate` / `aggregatePaginate`.
@@ -270,6 +989,30 @@ interface AggPaginationRequest extends Omit<AggRequest, 'limit' | 'offset'> {
270
989
  * aliases. Values are SQL-native scalars — numbers for count / sum /
271
990
  * avg, the group-by column's native type for group keys.
272
991
  *
992
+ * **Joined-alias paths in `groupBy` produce NESTED output rows.**
993
+ * Same convention `lookupPopulate` uses, so cross-operation row
994
+ * shapes stay consistent across all read primitives.
995
+ *
996
+ * ```ts
997
+ * await orders.aggregate({
998
+ * lookups: [{ from: 'category', localField: 'categoryId',
999
+ * foreignField: '_id', as: 'category', single: true }],
1000
+ * groupBy: ['status', 'category.code'],
1001
+ * measures: { count: 'count', revenue: 'sum:totalPrice' },
1002
+ * });
1003
+ *
1004
+ * // → { rows: [
1005
+ * // { status: 'pending', category: { code: 'BOOKS' }, count: 12, revenue: 800 },
1006
+ * // ...
1007
+ * // ]}
1008
+ * ```
1009
+ *
1010
+ * Both mongokit and sqlitekit normalize dotted-path groupBy keys to
1011
+ * nested objects on the returned row. Use repo-core's
1012
+ * `nestDottedKeys()` helper from `repository/agg-output.js` if you
1013
+ * receive flat-dotted rows from a future kit that hasn't normalized
1014
+ * yet, or build them yourself in tests.
1015
+ *
273
1016
  * Generic defaults to `Record<string, unknown>` because cross-kit
274
1017
  * callers usually don't need the narrower type — cast at the call
275
1018
  * site with your own `interface RevenueByCategory { ... }` if you do.
@@ -361,10 +1104,13 @@ interface MinimalRepo<TDoc> {
361
1104
  /** Update by primary key. Returns the updated doc or null. */
362
1105
  update(id: string, data: Partial<TDoc>, options?: WriteOptions): Promise<TDoc | null>;
363
1106
  /**
364
- * Delete by primary key. Pass `{ mode: 'hard' }` to bypass soft-delete
365
- * interception (kits without soft-delete accept and ignore the flag).
1107
+ * Delete by primary key. Returns the {@link DeleteResult} on success or
1108
+ * `null` on miss (matches the `update()` null-on-miss convention).
1109
+ *
1110
+ * Pass `{ mode: 'hard' }` to bypass soft-delete interception (kits
1111
+ * without soft-delete accept and ignore the flag).
366
1112
  */
367
- delete(id: string, options?: DeleteOptions): Promise<DeleteResult>;
1113
+ delete(id: string, options?: DeleteOptions): Promise<DeleteResult | null>;
368
1114
  }
369
1115
  /**
370
1116
  * Recommended repository contract. Every method beyond `MinimalRepo` is
@@ -404,6 +1150,95 @@ interface StandardRepo<TDoc> extends MinimalRepo<TDoc> {
404
1150
  * Kits dispatch via `isUpdateSpec(update)` from `@classytic/repo-core/update`.
405
1151
  */
406
1152
  findOneAndUpdate?(filter: FilterInput, update: UpdateInput, options?: FindOneAndUpdateOptions): Promise<TDoc | null>;
1153
+ /**
1154
+ * Atomic compare-and-swap state transition. Standardized shape for
1155
+ * the canonical state-machine write that every domain package was
1156
+ * hand-rolling on top of `findOneAndUpdate`:
1157
+ *
1158
+ * ```ts
1159
+ * findOneAndUpdate(
1160
+ * { _id: id, [field]: from }, // CAS — match only when state matches
1161
+ * { $set: { [field]: to, ...patch } }, // transition + patch
1162
+ * );
1163
+ * ```
1164
+ *
1165
+ * Returns the post-update doc on success; `null` when:
1166
+ * - the row doesn't exist, OR
1167
+ * - the row's `field` value isn't `from` (someone else transitioned
1168
+ * first — the standard race-loss signal)
1169
+ *
1170
+ * **Cross-kit portable.** Mongokit compiles to the `findOneAndUpdate`
1171
+ * above. SQL kits compile to
1172
+ * `UPDATE x SET ... WHERE id = ? AND <field> = <from> RETURNING *`.
1173
+ * Prismakit compiles to `prisma.x.updateMany({ where: { id, [field]:
1174
+ * from }, data: ... })` then a `findUnique` when `count > 0`. Same
1175
+ * input, same null-on-race semantics across every backend.
1176
+ *
1177
+ * **Pairs with `@classytic/primitives/state-machine`.** Use
1178
+ * `defineStateMachine()` for the domain "is `from → to` a legal
1179
+ * transition?" gate (compile-time table + `assertTransition()` early
1180
+ * throw); use `claim()` for the concurrency "did we win the
1181
+ * transition vs concurrent writers?" gate (runtime null on race).
1182
+ * The two layers compose:
1183
+ *
1184
+ * ```ts
1185
+ * ORDER_MACHINE.assertTransition(id, current, 'shipped');
1186
+ * const claimed = await repo.claim(id, { from: current, to: 'shipped' });
1187
+ * if (!claimed) throw new ConcurrentTransitionError(id, current, 'shipped');
1188
+ * ```
1189
+ *
1190
+ * **Required.** Both mongokit and sqlitekit ship `claim` as a class
1191
+ * primitive. Downstream domain packages (~10 in classytic alone) all
1192
+ * carry FSM verbs depending on it — none gracefully degrade.
1193
+ * Required-on-the-contract removes the boilerplate `if (repo.claim)
1194
+ * { ... }` at every call site and surfaces missing implementations
1195
+ * at the conformance gate instead of at runtime.
1196
+ *
1197
+ * @param id - Document primary key value
1198
+ * @param transition - `{ from, to }` (defaults to the `status` field)
1199
+ * or `{ field, from, to }` for state machines keyed off a non-`status`
1200
+ * column (`phase`, `state`, etc.)
1201
+ * @param patch - Extra fields written alongside the transition (e.g.
1202
+ * `{ lastHeartbeat: now, workerId: 'w-12' }`). Field-shape only —
1203
+ * for raw operators use `findOneAndUpdate` directly.
1204
+ * @returns The updated document, or `null` when the CAS lost / no match
1205
+ */
1206
+ claim(id: string, transition: ClaimTransition, patch?: Partial<TDoc>, options?: WriteOptions): Promise<TDoc | null>;
1207
+ /**
1208
+ * Atomic optimistic-concurrency CAS via a version stamp. Sibling to
1209
+ * `claim` for the optimistic-locking pattern: "I read at version
1210
+ * N, write back only if no one's incremented since."
1211
+ *
1212
+ * Builds:
1213
+ *
1214
+ * ```ts
1215
+ * findOneAndUpdate(
1216
+ * { _id, [versionField]: from, ...where },
1217
+ * { ...update, $inc: { [versionField]: by ?? 1 } },
1218
+ * );
1219
+ * ```
1220
+ *
1221
+ * Returns the post-update doc on success, `null` when:
1222
+ * - the row is missing,
1223
+ * - the version doesn't match `from` (race-loss),
1224
+ * - any `where` predicate fails.
1225
+ *
1226
+ * **Cross-kit portable.** Mongokit emits the operator update
1227
+ * directly. SQL kits compile to
1228
+ * `UPDATE x SET ... [versionField] = coalesce([versionField], 0) + by
1229
+ * WHERE id = ? AND [versionField] = ? RETURNING *`.
1230
+ *
1231
+ * **Required.** Same rationale as `claim`. Mongokit and sqlitekit
1232
+ * both ship a class primitive; downstream domain packages that need
1233
+ * versioned writes (`@classytic/order`, `leave`, `payrun`) carry
1234
+ * verbs depending on it.
1235
+ *
1236
+ * @param update - Mongo-operator-shape (`{ $set, $inc, $unset }`)
1237
+ * OR field-shape (auto-wrapped in `$set`). Mixed shapes throw —
1238
+ * mongo would silently drop the flat keys. SQL kits accept the
1239
+ * same shape and compile down to flat column writes.
1240
+ */
1241
+ claimVersion(id: string, transition: ClaimVersionTransition, update: Record<string, unknown>, options?: WriteOptions): Promise<TDoc | null>;
407
1242
  /**
408
1243
  * Classify an error from a write as a unique-constraint violation.
409
1244
  * Arc's idempotency + outbox adapters need this to distinguish
@@ -424,7 +1259,33 @@ interface StandardRepo<TDoc> extends MinimalRepo<TDoc> {
424
1259
  } | null>;
425
1260
  distinct?<T = unknown>(field: string, filter?: FilterInput, options?: QueryOptions): Promise<T[]>;
426
1261
  findAll?(filter?: FilterInput, options?: QueryOptions): Promise<TDoc[]>;
427
- getOrCreate?(filter: FilterInput, data: Partial<TDoc>, options?: WriteOptions): Promise<TDoc | null>;
1262
+ /**
1263
+ * Atomic "look up by filter, insert `data` if missing, return the doc."
1264
+ *
1265
+ * Returns `{ doc, created }` so the caller can disambiguate the two
1266
+ * outcomes without a follow-up read. `created: true` means *this*
1267
+ * caller's `data` won the race and was just inserted; `created: false`
1268
+ * means a doc matching `filter` already existed and was returned
1269
+ * unchanged. This discriminator is load-bearing for race-detection in
1270
+ * idempotency stores, lock acquisition, and "ensure-exists" flows —
1271
+ * a bare `TDoc` return (without `created`) cannot answer the question
1272
+ * the method is named for.
1273
+ *
1274
+ * Implementations:
1275
+ * - mongokit: `findOneAndUpdate({ filter }, { $setOnInsert: data },
1276
+ * { upsert: true, returnDocument: 'after', includeResultMetadata: true })`
1277
+ * and read `created = !!result.lastErrorObject?.upserted`.
1278
+ * - sqlitekit: `INSERT … ON CONFLICT DO NOTHING RETURNING *` — when
1279
+ * a row is returned, `created: true`; otherwise follow with
1280
+ * `SELECT … WHERE filter` and return `created: false`.
1281
+ * - Other kits: must preserve "single atomic round-trip" where
1282
+ * possible; a non-atomic fallback is acceptable but should be
1283
+ * documented.
1284
+ */
1285
+ getOrCreate?(filter: FilterInput, data: Partial<TDoc>, options?: WriteOptions): Promise<{
1286
+ doc: TDoc;
1287
+ created: boolean;
1288
+ }>;
428
1289
  createMany?(items: Partial<TDoc>[], options?: WriteOptions): Promise<TDoc[]>;
429
1290
  /**
430
1291
  * Apply the same update to every matching document. Required — every
@@ -461,27 +1322,53 @@ interface StandardRepo<TDoc> extends MinimalRepo<TDoc> {
461
1322
  */
462
1323
  bulkWrite?(operations: readonly BulkWriteOperation<TDoc>[]): Promise<BulkWriteResult>;
463
1324
  /**
464
- * Portable aggregation. Compiles to `SELECT ... GROUP BY ...` on SQL
465
- * kits and to a `[$match, $group, $sort, $limit]` pipeline on mongokit.
466
- * Output shape (`{ rows }`) is identical across backends — dashboards
467
- * and admin tooling read the same result regardless of the driver.
1325
+ * Portable aggregation. Compiles to `SELECT ... [LEFT JOIN ...] GROUP
1326
+ * BY ...` on SQL kits and to a `[$match, $lookup*, $group, $sort,
1327
+ * $limit]` pipeline on mongokit. Output shape (`{ rows }`) is
1328
+ * identical across backends dashboards and admin tooling read the
1329
+ * same result regardless of the driver.
1330
+ *
1331
+ * **Cross-table joins.** When `req.lookups` is set, the kit compiles
1332
+ * each `LookupSpec` as a join stage BEFORE the group/measure
1333
+ * pipeline. `groupBy`, `measure.field`, `having`, and `sort` may
1334
+ * then reference dotted paths into the joined aliases (e.g.
1335
+ * `'category.parent'`). Lookups in `aggregate` reuse the same
1336
+ * `LookupSpec` IR a kit's `lookupPopulate()` accepts — same
1337
+ * compile path, same semantics.
468
1338
  *
469
- * Distinct from kit-native aggregation APIs (mongokit's
470
- * `aggregatePipeline(stages)`, sqlitekit's raw `repo.db`) by design:
471
- * those take backend-specific inputs and return backend-specific
472
- * shapes, suited for joins / unwinds / window functions / CTEs. The
473
- * portable `aggregate` covers the filter + group + measures + sort +
474
- * limit subset that every backend supports and nothing else, so
475
- * the behavior stays identical across drivers.
1339
+ * **Kit support for `lookups` is incremental.** A kit that
1340
+ * implements `aggregate` but NOT yet `aggregate-with-lookups`
1341
+ * SHOULD throw `UnsupportedOperationError` at request time when
1342
+ * `req.lookups` is present, with a message telling the caller to
1343
+ * upgrade. Hosts pin via peer-deps; the API contract (this
1344
+ * interface) does NOT change as kits add support additive only.
1345
+ *
1346
+ * **Boundary** — kit-native aggregation APIs (mongokit's
1347
+ * `aggregatePipeline(stages)`, sqlitekit's raw `repo.db`) remain
1348
+ * the escape hatch for window functions, CTEs, pipeline-form
1349
+ * `$lookup` with `let`, and lateral subqueries. The portable
1350
+ * `aggregate` covers the filter + lookups + group + measures +
1351
+ * having + sort + limit subset that every backend supports —
1352
+ * deliberately nothing else, so behavior stays identical across
1353
+ * drivers.
476
1354
  */
477
1355
  aggregate?<TRow extends AggRow = AggRow>(req: AggRequest): Promise<AggResult<TRow>>;
478
1356
  /**
479
- * Paginated aggregation. Returns the standard offset envelope so UI
480
- * code paginates aggregated dashboards with the same primitives as
481
- * raw document lists. `countStrategy: 'none'` skips the distinct-
482
- * group count for infinite-scroll use.
1357
+ * Paginated aggregation. Returns one of two envelope shapes,
1358
+ * discriminated by `method`:
1359
+ *
1360
+ * - **`offset`** (default) standard offset envelope. Same shape
1361
+ * `getAll({ page, limit })` produces for raw document lists.
1362
+ * `countStrategy: 'none'` skips the distinct-group count for
1363
+ * infinite-scroll use.
1364
+ * - **`keyset`** (when `req.pagination === 'keyset'` or `req.after`
1365
+ * is set) — cursor-based envelope. Scales to arbitrary group
1366
+ * counts because it never scans skipped rows.
1367
+ *
1368
+ * UI components branch on `result.method` once and render either
1369
+ * envelope identically.
483
1370
  */
484
- aggregatePaginate?<TRow extends AggRow = AggRow>(req: AggPaginationRequest): Promise<OffsetPaginationResult<TRow>>;
1371
+ aggregatePaginate?<TRow extends AggRow = AggRow>(req: AggPaginationRequest): Promise<OffsetPaginationResult<TRow> | KeysetAggPaginationResult<TRow>>;
485
1372
  /**
486
1373
  * Paginated join. Compiles the portable `LookupSpec[]` to `$lookup`
487
1374
  * stages on mongokit or `LEFT JOIN` + `json_object()` / `json_group_array()`
@@ -520,4 +1407,4 @@ interface StandardRepo<TDoc> extends MinimalRepo<TDoc> {
520
1407
  withTransaction?<T>(fn: (txRepo: StandardRepo<TDoc>) => Promise<T>, options?: Record<string, unknown>): Promise<T>;
521
1408
  }
522
1409
  //#endregion
523
- export { AggMeasure, AggPaginationRequest, AggRequest, AggResult, AggRow, BulkWriteOperation, BulkWriteResult, DeleteManyResult, DeleteOptions, DeleteResult, FilterInput, FindOneAndUpdateOptions, InferDoc, MinimalRepo, PaginationParams, QueryOptions, RepositorySession, StandardRepo, UpdateManyResult, WriteOptions };
1410
+ export { AggCacheOptions, AggDateBucket, AggDateBucketInterval, AggDateBucketUnit, AggExecutionHints, AggMeasure, AggPaginationRequest, AggRequest, AggResult, AggRow, AggTopN, AggTopNTies, BulkCreateResult, BulkWriteOperation, BulkWriteResult, ClaimTransition, ClaimVersionTransition, DeleteManyResult, DeleteOptions, DeleteResult, FilterInput, FindOneAndUpdateOptions, InferDoc, KeysetAggPaginationResult, MinimalRepo, PaginationParams, QueryOptions, RepositorySession, StandardRepo, UpdateManyResult, WriteOptions };