@classytic/repo-core 0.2.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.
- package/CHANGELOG.md +363 -0
- package/README.md +28 -7
- 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/contract.d.mts +37 -0
- package/dist/errors/contract.mjs +75 -0
- package/dist/errors/index.d.mts +4 -2
- package/dist/errors/index.mjs +4 -1
- package/dist/errors/schema.d.mts +101 -0
- package/dist/errors/schema.mjs +78 -0
- package/dist/errors/types.d.mts +113 -8
- package/dist/errors/types.mjs +29 -0
- package/dist/filter/match.mjs +38 -2
- package/dist/pagination/canonical.d.mts +35 -0
- package/dist/pagination/canonical.mjs +26 -0
- package/dist/pagination/cursor.mjs +4 -1
- package/dist/pagination/index.d.mts +3 -2
- package/dist/pagination/index.mjs +2 -1
- package/dist/pagination/types.d.mts +57 -3
- 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/base.mjs +21 -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 +935 -48
- package/dist/schema/field-rules.d.mts +60 -9
- package/dist/schema/field-rules.mjs +121 -10
- package/dist/schema/generator.d.mts +72 -0
- package/dist/schema/generator.mjs +16 -0
- package/dist/schema/index.d.mts +3 -2
- package/dist/schema/index.mjs +3 -2
- package/dist/schema/types.d.mts +77 -3
- package/dist/tenant/index.d.mts +3 -0
- package/dist/tenant/index.mjs +2 -0
- package/dist/tenant/resolve.d.mts +27 -0
- package/dist/tenant/resolve.mjs +69 -0
- package/dist/tenant/types.d.mts +142 -0
- package/dist/testing/conformance.mjs +666 -17
- package/dist/testing/index.d.mts +2 -2
- package/dist/testing/types.d.mts +99 -2
- package/package.json +27 -1
- 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
|
-
/**
|
|
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
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
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
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
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
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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`.
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
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.
|
|
365
|
-
*
|
|
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
|
-
|
|
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 ...
|
|
465
|
-
* kits and to a `[$match, $group, $sort,
|
|
466
|
-
* Output shape (`{ rows }`) is
|
|
467
|
-
*
|
|
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
|
-
*
|
|
470
|
-
* `
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
*
|
|
475
|
-
*
|
|
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
|
|
480
|
-
*
|
|
481
|
-
*
|
|
482
|
-
*
|
|
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 };
|