@lossless.org/client 1.15.0 → 1.17.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.
@@ -817,15 +817,33 @@ export interface ISmartdataGroupedTotalsOptions<
817
817
  */
818
818
  groupBy: ReadonlyArray<TSmartdataGroupedTotalsPath<T>>;
819
819
  /**
820
- * Groups by each element of one array instead of by the array: names
821
- * exactly one `groupBy` path, which must be a declared top-level array
822
- * field or a nested path declared through `@svDb({ arrayPaths })`. Every
823
- * element forms its own row, so `count` counts elements and a document
824
- * contributes its `sums` once per element; a document whose array is
825
- * missing, null or empty contributes nothing. The `filter` still selects
826
- * whole documents before the array is unwound.
820
+ * Aggregates per element of one array instead of per document: names a
821
+ * declared top-level array field or a nested path declared through
822
+ * `@svDb({ arrayPaths })`, which is itself a `groupBy` or `sums` path or
823
+ * holds the element fields that `groupBy` and `sums` paths name below it
824
+ * (`lines.accountNumber` below `unwind: 'lines'`). Element fields are
825
+ * declared nested paths like any other, through `@svDb({ atomicPaths })`
826
+ * on the array's field. Every element contributes on its own, so `count`
827
+ * counts elements and a document contributes its `sums` once per element;
828
+ * a document whose array is missing, null or empty contributes nothing.
829
+ * The `filter` still selects whole documents before the array is unwound,
830
+ * and no `groupBy` or `sums` path may contain the unwound array; use
831
+ * `elementFilter` to select elements.
827
832
  */
828
833
  unwind?: TUnwind;
834
+ /**
835
+ * Selects the unwound elements that contribute, applied after the array is
836
+ * unwound and before grouping: only an element that matches is counted and
837
+ * summed. Requires `unwind`. Every path is the unwound array itself (its
838
+ * scalar element) or a declared nested path below it
839
+ * (`lines.accountNumber` below `unwind: 'lines'`), combined through
840
+ * `$and`, `$or` and `$nor`, with the operators of atomic filters: `$eq`,
841
+ * `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`, `$exists`, `$type`,
842
+ * `$regex` with `$options`, and `$not`. Any other path or operator is
843
+ * refused with `invalid_argument`. The document `filter` can carry the same
844
+ * condition to preselect documents through an index.
845
+ */
846
+ elementFilter?: TSmartdataGroupedTotalsElementFilter<NoInfer<TUnwind>>;
829
847
  /**
830
848
  * Declared numeric top-level fields, or declared nested paths holding
831
849
  * numbers, to sum per group.
@@ -835,12 +853,21 @@ export interface ISmartdataGroupedTotalsOptions<
835
853
  >;
836
854
  filter?: MongoFilter<T>;
837
855
  /**
838
- * Required bound on the number of returned groups (1..10000). The group
839
- * stage is not sorted, so when more groups exist than the limit the
840
- * returned subset is arbitrary. Use `getGroupedTotalsPage()` when the
841
- * caller must detect overflow at the 10000-group boundary.
856
+ * Required bound on the number of returned groups (1..10000). Groups are
857
+ * ordered by their key — the `groupBy` values in the given order — and the
858
+ * first `limit` groups in that order are returned. Use
859
+ * `getGroupedTotalsPage()` to detect more groups and read them through
860
+ * `nextCursor`, or `iterateGroupedTotals()` to read every group.
842
861
  */
843
862
  limit: number;
863
+ /**
864
+ * Continues after the last group of an earlier page: the `nextCursor` that
865
+ * `getGroupedTotalsPage()` returned for the same `groupBy`, `unwind`,
866
+ * `sums`, `filter` and `elementFilter`. The page holds the groups whose key
867
+ * sorts after that group's key. A cursor that does not decode, or that
868
+ * another query shape produced, is refused with `invalid_argument`.
869
+ */
870
+ cursor?: string;
844
871
  maxTimeMS?: number;
845
872
  /**
846
873
  * Client-side deadline for the aggregation cursor up to its last reply. More
@@ -885,8 +912,32 @@ export interface ISmartdataGroupedTotalsPage<
885
912
  > {
886
913
  rows: Array<ISmartdataGroupedTotalsRow<T, TUnwind>>;
887
914
  hasMore: boolean;
915
+ /**
916
+ * Present exactly when `hasMore` is true: pass it back as `cursor`, with the
917
+ * same query, to read the next page. Opaque; it carries the query's shape
918
+ * and the last group's key.
919
+ */
920
+ nextCursor?: string;
888
921
  }
889
922
 
923
+ /**
924
+ * The `elementFilter` of grouped totals: conditions on the unwound array
925
+ * `TUnwind` itself or on paths below it, in the shape of atomic filter
926
+ * conditions, combined through `$and`, `$or` and `$nor`.
927
+ */
928
+ export type TSmartdataGroupedTotalsElementFilter<TUnwind extends string> =
929
+ & Partial<
930
+ Record<
931
+ TUnwind | `${TUnwind}.${string}`,
932
+ TSmartdataAtomicFilterCondition<unknown>
933
+ >
934
+ >
935
+ & {
936
+ $and?: Array<TSmartdataGroupedTotalsElementFilter<TUnwind>>;
937
+ $or?: Array<TSmartdataGroupedTotalsElementFilter<TUnwind>>;
938
+ $nor?: Array<TSmartdataGroupedTotalsElementFilter<TUnwind>>;
939
+ };
940
+
890
941
  export type TSmartdataAtomicArrayPath<T> = {
891
942
  [TKey in TSmartdataDocumentPath<T>]:
892
943
  NonNullable<T[TKey]> extends ReadonlyArray<unknown> ? TKey : never;
@@ -2809,6 +2860,50 @@ const normalizeAtomicNumericExpression = (
2809
2860
  : guardedComparison;
2810
2861
  };
2811
2862
 
2863
+ /** Requires a non-empty, ordinary, inert array of plain filter objects for `$and`, `$or` or `$nor`. */
2864
+ const requireInertLogicalFilterArray: (
2865
+ valueArg: unknown,
2866
+ labelArg: string,
2867
+ ) => asserts valueArg is Array<Record<string, unknown>> = (
2868
+ valueArg,
2869
+ labelArg,
2870
+ ) => {
2871
+ if (
2872
+ !Array.isArray(valueArg)
2873
+ || plugins.nodeUtil.types.isProxy(valueArg)
2874
+ || Object.getPrototypeOf(valueArg) !== Array.prototype
2875
+ || valueArg.length === 0
2876
+ ) {
2877
+ throw new SmartdataPersistenceError(
2878
+ 'invalid_argument',
2879
+ `${labelArg} must be a non-empty ordinary array of filters.`,
2880
+ );
2881
+ }
2882
+ assertOnlyDataProperties(valueArg, labelArg, true);
2883
+ for (let index = 0; index < valueArg.length; index++) {
2884
+ if (!Object.prototype.hasOwnProperty.call(valueArg, index)) {
2885
+ throw new SmartdataPersistenceError(
2886
+ 'invalid_argument',
2887
+ `${labelArg} may not contain sparse array positions.`,
2888
+ );
2889
+ }
2890
+ const entry = valueArg[index];
2891
+ if (
2892
+ typeof entry !== 'object'
2893
+ || entry === null
2894
+ || Array.isArray(entry)
2895
+ || plugins.nodeUtil.types.isProxy(entry)
2896
+ || !isPlainObject(entry)
2897
+ ) {
2898
+ throw new SmartdataPersistenceError(
2899
+ 'invalid_argument',
2900
+ `${labelArg} entries must be plain filter objects.`,
2901
+ );
2902
+ }
2903
+ assertOnlyDataProperties(entry, `${labelArg} entry`, false);
2904
+ }
2905
+ };
2906
+
2812
2907
  const normalizeStrictFilter = (
2813
2908
  filterArg: Record<string, unknown>,
2814
2909
  declaredRootsArg: Set<string>,
@@ -2845,48 +2940,6 @@ const normalizeStrictFilter = (
2845
2940
  `${operationLabelArg} filter`,
2846
2941
  false,
2847
2942
  );
2848
- const requireInertLogicalFilterArray: (
2849
- valueArg: unknown,
2850
- labelArg: string,
2851
- ) => asserts valueArg is Array<Record<string, unknown>> = (
2852
- valueArg,
2853
- labelArg,
2854
- ) => {
2855
- if (
2856
- !Array.isArray(valueArg)
2857
- || plugins.nodeUtil.types.isProxy(valueArg)
2858
- || Object.getPrototypeOf(valueArg) !== Array.prototype
2859
- || valueArg.length === 0
2860
- ) {
2861
- throw new SmartdataPersistenceError(
2862
- 'invalid_argument',
2863
- `${labelArg} must be a non-empty ordinary array of filters.`,
2864
- );
2865
- }
2866
- assertOnlyDataProperties(valueArg, labelArg, true);
2867
- for (let index = 0; index < valueArg.length; index++) {
2868
- if (!Object.prototype.hasOwnProperty.call(valueArg, index)) {
2869
- throw new SmartdataPersistenceError(
2870
- 'invalid_argument',
2871
- `${labelArg} may not contain sparse array positions.`,
2872
- );
2873
- }
2874
- const entry = valueArg[index];
2875
- if (
2876
- typeof entry !== 'object'
2877
- || entry === null
2878
- || Array.isArray(entry)
2879
- || plugins.nodeUtil.types.isProxy(entry)
2880
- || !isPlainObject(entry)
2881
- ) {
2882
- throw new SmartdataPersistenceError(
2883
- 'invalid_argument',
2884
- `${labelArg} entries must be plain filter objects.`,
2885
- );
2886
- }
2887
- assertOnlyDataProperties(entry, `${labelArg} entry`, false);
2888
- }
2889
- };
2890
2943
  /**
2891
2944
  * Plural intent is explicit in the plural method names, so an explicitly
2892
2945
  * enumerated, non-empty `$in` over equality scalars anchors those filters as
@@ -3124,6 +3177,341 @@ const normalizeStrictFilter = (
3124
3177
  ) as plugins.mongodb.Filter<plugins.mongodb.Document>;
3125
3178
  };
3126
3179
 
3180
+ /**
3181
+ * Validates the `elementFilter` of grouped totals, which runs after `$unwind`:
3182
+ * every path is the unwound array itself or a declared nested path below it,
3183
+ * and every condition uses the operator subset of atomic filters.
3184
+ */
3185
+ const normalizeGroupedTotalsElementFilter = (
3186
+ filterArg: unknown,
3187
+ unwindPathArg: string,
3188
+ declaredRootsArg: Set<string>,
3189
+ declaredNestedPathsArg: Set<string>,
3190
+ ): plugins.mongodb.Filter<plugins.mongodb.Document> => {
3191
+ const label = 'Grouped totals elementFilter';
3192
+ if (
3193
+ typeof filterArg !== 'object'
3194
+ || filterArg === null
3195
+ || Array.isArray(filterArg)
3196
+ || plugins.nodeUtil.types.isProxy(filterArg)
3197
+ || !isPlainObject(filterArg)
3198
+ || Object.keys(filterArg).length === 0
3199
+ ) {
3200
+ throw new SmartdataPersistenceError(
3201
+ 'invalid_argument',
3202
+ `${label} must be a non-empty plain filter object.`,
3203
+ );
3204
+ }
3205
+ const normalizeFilterObject = (
3206
+ filterObjectArg: Record<string, unknown>,
3207
+ ): Record<string, unknown> => {
3208
+ assertOnlyDataProperties(filterObjectArg, label, false);
3209
+ const normalized: Record<string, unknown> = {};
3210
+ for (const [key, value] of Object.entries(filterObjectArg)) {
3211
+ if (key === '$and' || key === '$or' || key === '$nor') {
3212
+ requireInertLogicalFilterArray(value, `${label} ${key}`);
3213
+ normalized[key] = value.map((entryArg) => {
3214
+ if (Object.keys(entryArg).length === 0) {
3215
+ throw new SmartdataPersistenceError(
3216
+ 'invalid_argument',
3217
+ `${label} ${key} entries must be non-empty filter objects.`,
3218
+ );
3219
+ }
3220
+ return normalizeFilterObject(entryArg);
3221
+ });
3222
+ continue;
3223
+ }
3224
+ if (key.startsWith('$')) {
3225
+ throw new SmartdataPersistenceError(
3226
+ 'invalid_argument',
3227
+ `${label} uses unsupported operator "${key}".`,
3228
+ );
3229
+ }
3230
+ if (key !== unwindPathArg && !key.startsWith(unwindPathArg + '.')) {
3231
+ throw new SmartdataPersistenceError(
3232
+ 'invalid_argument',
3233
+ `${label} path "${key}" is not an element path of the unwound array "${unwindPathArg}".`,
3234
+ );
3235
+ }
3236
+ requireDeclaredPath(key, declaredRootsArg, label, declaredNestedPathsArg);
3237
+ normalized[key] = normalizeAtomicFilterCondition(
3238
+ value,
3239
+ `${label} path "${key}"`,
3240
+ );
3241
+ }
3242
+ return normalized;
3243
+ };
3244
+ return normalizeFilterObject(
3245
+ filterArg as Record<string, unknown>,
3246
+ ) as plugins.mongodb.Filter<plugins.mongodb.Document>;
3247
+ };
3248
+
3249
+ /**
3250
+ * The group value types grouped-totals paging orders, ascending. Missing values
3251
+ * merge into null; numbers compare by value across int, long and double;
3252
+ * strings compare by their UTF-8 bytes; ObjectIds by their bytes; false sorts
3253
+ * before true. NoSQLDB and MongoDB order these types identically, and their
3254
+ * range operators compare within each type the same way.
3255
+ */
3256
+ const groupedTotalsOrderedTypes = ['null', 'number', 'string', 'objectId', 'bool', 'date'] as const;
3257
+
3258
+ type TGroupedTotalsOrderedType = typeof groupedTotalsOrderedTypes[number];
3259
+
3260
+ /** A group value paging can order, or undefined for any other value. */
3261
+ const groupedTotalsOrderedTypeOf = (
3262
+ valueArg: unknown,
3263
+ ): TGroupedTotalsOrderedType | undefined => {
3264
+ if (valueArg === null) {
3265
+ return 'null';
3266
+ }
3267
+ if (typeof valueArg === 'number') {
3268
+ // NaN sorts below every number on both engines, but a range operator
3269
+ // matches it on NoSQLDB and not on MongoDB.
3270
+ return Number.isNaN(valueArg) ? undefined : 'number';
3271
+ }
3272
+ if (typeof valueArg === 'string') {
3273
+ return 'string';
3274
+ }
3275
+ if (typeof valueArg === 'boolean') {
3276
+ return 'bool';
3277
+ }
3278
+ if (valueArg instanceof plugins.mongodb.Long) {
3279
+ return 'number';
3280
+ }
3281
+ if (valueArg instanceof plugins.mongodb.ObjectId) {
3282
+ return 'objectId';
3283
+ }
3284
+ if (valueArg instanceof Date) {
3285
+ return Number.isNaN(valueArg.getTime()) ? undefined : 'date';
3286
+ }
3287
+ return undefined;
3288
+ };
3289
+
3290
+ const groupedTotalsExactNumber = (
3291
+ valueArg: number | plugins.mongodb.Long,
3292
+ ): number | bigint => typeof valueArg === 'number' ? valueArg : BigInt(valueArg.toString());
3293
+
3294
+ /** Compares a finite or infinite double with an exact integer. */
3295
+ const compareGroupedTotalsDoubleWithInteger = (doubleArg: number, integerArg: bigint): number => {
3296
+ if (!Number.isFinite(doubleArg)) {
3297
+ return doubleArg > 0 ? 1 : -1;
3298
+ }
3299
+ const floor = Math.floor(doubleArg);
3300
+ const floorInteger = BigInt(floor);
3301
+ if (floorInteger !== integerArg) {
3302
+ return floorInteger < integerArg ? -1 : 1;
3303
+ }
3304
+ return doubleArg === floor ? 0 : 1;
3305
+ };
3306
+
3307
+ const compareGroupedTotalsNumbers = (
3308
+ leftArg: number | plugins.mongodb.Long,
3309
+ rightArg: number | plugins.mongodb.Long,
3310
+ ): number => {
3311
+ const left = groupedTotalsExactNumber(leftArg);
3312
+ const right = groupedTotalsExactNumber(rightArg);
3313
+ if (typeof left === 'number' && typeof right === 'number') {
3314
+ return left < right ? -1 : left > right ? 1 : 0;
3315
+ }
3316
+ if (typeof left === 'bigint' && typeof right === 'bigint') {
3317
+ return left < right ? -1 : left > right ? 1 : 0;
3318
+ }
3319
+ return typeof left === 'number'
3320
+ ? compareGroupedTotalsDoubleWithInteger(left, right as bigint)
3321
+ : -compareGroupedTotalsDoubleWithInteger(right as number, left);
3322
+ };
3323
+
3324
+ /** Compares two ordered group values in the documented order. */
3325
+ const compareGroupedTotalsValues = (leftArg: unknown, rightArg: unknown): number => {
3326
+ const leftType = groupedTotalsOrderedTypeOf(leftArg)!;
3327
+ const rightType = groupedTotalsOrderedTypeOf(rightArg)!;
3328
+ if (leftType !== rightType) {
3329
+ return groupedTotalsOrderedTypes.indexOf(leftType) - groupedTotalsOrderedTypes.indexOf(rightType);
3330
+ }
3331
+ switch (leftType) {
3332
+ case 'null':
3333
+ return 0;
3334
+ case 'number':
3335
+ return compareGroupedTotalsNumbers(
3336
+ leftArg as number | plugins.mongodb.Long,
3337
+ rightArg as number | plugins.mongodb.Long,
3338
+ );
3339
+ case 'string':
3340
+ return Buffer.compare(Buffer.from(leftArg as string, 'utf8'), Buffer.from(rightArg as string, 'utf8'));
3341
+ case 'objectId':
3342
+ return Buffer.compare(
3343
+ Buffer.from((leftArg as plugins.mongodb.ObjectId).toHexString(), 'hex'),
3344
+ Buffer.from((rightArg as plugins.mongodb.ObjectId).toHexString(), 'hex'),
3345
+ );
3346
+ case 'bool':
3347
+ return Number(leftArg) - Number(rightArg);
3348
+ case 'date':
3349
+ return Math.sign((leftArg as Date).getTime() - (rightArg as Date).getTime());
3350
+ }
3351
+ };
3352
+
3353
+ /** Compares two group keys member by member. */
3354
+ const compareGroupedTotalsKeys = (
3355
+ leftArg: readonly unknown[],
3356
+ rightArg: readonly unknown[],
3357
+ ): number => {
3358
+ for (const [index, leftValue] of leftArg.entries()) {
3359
+ const order = compareGroupedTotalsValues(leftValue, rightArg[index]);
3360
+ if (order !== 0) {
3361
+ return order;
3362
+ }
3363
+ }
3364
+ return 0;
3365
+ };
3366
+
3367
+ const groupedTotalsTypeClause = (
3368
+ pathArg: string,
3369
+ typeArg: TGroupedTotalsOrderedType,
3370
+ ): plugins.mongodb.Document =>
3371
+ typeArg === 'null' ? { [pathArg]: null } : { [pathArg]: { $type: typeArg } };
3372
+
3373
+ /**
3374
+ * Clauses matching a group key member that sorts before `valueArg`, or at it
3375
+ * when `inclusiveArg` is set. A range operator only compares within one type,
3376
+ * so every lower type is matched by its `$type`.
3377
+ */
3378
+ const groupedTotalsBelowClauses = (
3379
+ pathArg: string,
3380
+ valueArg: unknown,
3381
+ inclusiveArg: boolean,
3382
+ ): plugins.mongodb.Document[] => {
3383
+ const type = groupedTotalsOrderedTypeOf(valueArg)!;
3384
+ const clauses = groupedTotalsOrderedTypes
3385
+ .slice(0, groupedTotalsOrderedTypes.indexOf(type))
3386
+ .map((typeArg) => groupedTotalsTypeClause(pathArg, typeArg));
3387
+ if (type === 'null') {
3388
+ if (inclusiveArg) {
3389
+ clauses.push({ [pathArg]: null });
3390
+ }
3391
+ } else {
3392
+ clauses.push({ [pathArg]: { [inclusiveArg ? '$lte' : '$lt']: valueArg } });
3393
+ }
3394
+ return clauses;
3395
+ };
3396
+
3397
+ /**
3398
+ * Clauses matching a group key at or before `afterArg` in lexicographic order
3399
+ * over the members: a lower first member, or an equal first member and the
3400
+ * rest at or before the rest of `afterArg`.
3401
+ */
3402
+ const groupedTotalsAtOrBeforeClauses = (
3403
+ memberPathsArg: readonly string[],
3404
+ afterArg: readonly unknown[],
3405
+ ): plugins.mongodb.Document[] => {
3406
+ const [path, ...restPaths] = memberPathsArg;
3407
+ const [value, ...restValues] = afterArg;
3408
+ if (restPaths.length === 0) {
3409
+ return groupedTotalsBelowClauses(path, value, true);
3410
+ }
3411
+ return [
3412
+ ...groupedTotalsBelowClauses(path, value, false),
3413
+ {
3414
+ $and: [
3415
+ value === null ? { [path]: null } : { [path]: { $eq: value } },
3416
+ { $or: groupedTotalsAtOrBeforeClauses(restPaths, restValues) },
3417
+ ],
3418
+ },
3419
+ ];
3420
+ };
3421
+
3422
+ /**
3423
+ * Clauses matching a group key member paging cannot order: an array, a
3424
+ * document, a Decimal128, NaN or any type outside the ordered types. Such a
3425
+ * group always stays a candidate for the next page, so the page that reaches
3426
+ * it refuses it instead of skipping it silently.
3427
+ */
3428
+ const groupedTotalsUnorderedClauses = (pathArg: string): plugins.mongodb.Document[] => [
3429
+ { [pathArg]: { $type: 'array' } },
3430
+ { [pathArg]: { $type: 'object' } },
3431
+ { [pathArg]: { $type: 'decimal' } },
3432
+ { [pathArg]: Number.NaN },
3433
+ { $nor: groupedTotalsOrderedTypes.map((typeArg) => groupedTotalsTypeClause(pathArg, typeArg)) },
3434
+ ];
3435
+
3436
+ /** The post-group `$match` that keeps the groups whose key sorts after `afterArg`. */
3437
+ const groupedTotalsAfterFilter = (
3438
+ memberPathsArg: readonly string[],
3439
+ afterArg: readonly unknown[],
3440
+ ): plugins.mongodb.Document => ({
3441
+ $or: [
3442
+ ...memberPathsArg.flatMap((pathArg) => groupedTotalsUnorderedClauses(pathArg)),
3443
+ { $nor: groupedTotalsAtOrBeforeClauses(memberPathsArg, afterArg) },
3444
+ ],
3445
+ });
3446
+
3447
+ /** Bounds a decoded grouped-totals cursor; a group key is at most two values. */
3448
+ const groupedTotalsCursorMaximumLength = 65_536;
3449
+
3450
+ const encodeGroupedTotalsCursor = (
3451
+ shapeArg: string,
3452
+ afterArg: readonly unknown[],
3453
+ ): string => Buffer.from(
3454
+ plugins.mongodb.BSON.EJSON.stringify({ shape: shapeArg, after: afterArg }, { relaxed: false }),
3455
+ 'utf8',
3456
+ ).toString('base64url');
3457
+
3458
+ /**
3459
+ * Decodes a grouped-totals cursor for the query shape `shapeArg`. The cursor
3460
+ * grants nothing a caller could not ask for directly — every page still applies
3461
+ * the caller's filters — so it is checked, not signed: it must decode, carry
3462
+ * this query's shape and one ordered value per group key member.
3463
+ */
3464
+ const decodeGroupedTotalsCursor = (
3465
+ cursorArg: unknown,
3466
+ shapeArg: string,
3467
+ memberCountArg: number,
3468
+ ): unknown[] => {
3469
+ const invalid = (reasonArg: string): SmartdataPersistenceError =>
3470
+ new SmartdataPersistenceError('invalid_argument', `Grouped totals cursor ${reasonArg}.`);
3471
+ if (
3472
+ typeof cursorArg !== 'string'
3473
+ || cursorArg.length === 0
3474
+ || cursorArg.length > groupedTotalsCursorMaximumLength
3475
+ || !/^[A-Za-z0-9_-]+$/.test(cursorArg)
3476
+ ) {
3477
+ throw invalid('must be a nextCursor returned by getGroupedTotalsPage()');
3478
+ }
3479
+ let decoded: unknown;
3480
+ try {
3481
+ decoded = plugins.mongodb.BSON.EJSON.parse(
3482
+ Buffer.from(cursorArg, 'base64url').toString('utf8'),
3483
+ { relaxed: false },
3484
+ );
3485
+ } catch {
3486
+ throw invalid('does not decode');
3487
+ }
3488
+ if (
3489
+ !isPlainObject(decoded)
3490
+ || Object.keys(decoded).length !== 2
3491
+ || typeof decoded.shape !== 'string'
3492
+ || !Array.isArray(decoded.after)
3493
+ ) {
3494
+ throw invalid('does not decode');
3495
+ }
3496
+ if (decoded.shape !== shapeArg) {
3497
+ throw invalid(
3498
+ 'belongs to another query: groupBy, unwind, sums, filter and elementFilter must match the page that returned it',
3499
+ );
3500
+ }
3501
+ if (decoded.after.length !== memberCountArg) {
3502
+ throw invalid('does not hold one value per groupBy path');
3503
+ }
3504
+ return decoded.after.map((valueArg: unknown) => {
3505
+ const value = valueArg instanceof plugins.mongodb.Int32 || valueArg instanceof plugins.mongodb.Double
3506
+ ? valueArg.valueOf()
3507
+ : valueArg;
3508
+ if (groupedTotalsOrderedTypeOf(value) === undefined) {
3509
+ throw invalid('holds a group value paging does not order');
3510
+ }
3511
+ return value;
3512
+ });
3513
+ };
3514
+
3127
3515
  const pathsConflict = (leftArg: string, rightArg: string): boolean =>
3128
3516
  leftArg === rightArg ||
3129
3517
  leftArg.startsWith(`${rightArg}.`) ||
@@ -5093,12 +5481,13 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5093
5481
  * Computes bounded grouped counts and optional numeric sums server-side.
5094
5482
  * Groups by one or two declared paths — top-level fields or nested paths
5095
5483
  * declared through `@svDb({ atomicPaths })` or `@svDb({ arrayPaths })` —
5096
- * optionally by each element of one declared array (`unwind`), always
5097
- * returns a `count` per group, and sums only declared numeric paths. The
5098
- * `$limit` follows the group stage without a sort, so when more groups
5099
- * exist than the limit the returned subset is arbitrary. Use
5100
- * `getGroupedTotalsPage()` when the caller must detect overflow. The
5101
- * aggregation cursor never leaves SmartData.
5484
+ * optionally per element of one declared array (`unwind`), whose element
5485
+ * fields `groupBy` and `sums` may then name and `elementFilter` may select,
5486
+ * always returns a `count` per group, and sums only declared numeric paths.
5487
+ * Rows come ordered by group key, and the first `limit` groups in that
5488
+ * order are returned. Use `getGroupedTotalsPage()` when the caller must
5489
+ * detect or read further groups. The aggregation cursor never leaves
5490
+ * SmartData.
5102
5491
  */
5103
5492
  public static async getGroupedTotals<
5104
5493
  T,
@@ -5107,15 +5496,27 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5107
5496
  this: plugins.tsclass.typeFest.Class<T>,
5108
5497
  optionsArg: ISmartdataGroupedTotalsOptions<T, TUnwind>,
5109
5498
  ): Promise<Array<ISmartdataGroupedTotalsRow<T, TUnwind>>> {
5110
- const page = await (this as any).getGroupedTotalsPage(optionsArg) as
5499
+ const page = await (this as any).readGroupedTotalsPage(optionsArg, false) as
5111
5500
  ISmartdataGroupedTotalsPage<T, TUnwind>;
5112
5501
  return page.rows;
5113
5502
  }
5114
5503
 
5115
5504
  /**
5116
- * Computes the same bounded grouped totals as `getGroupedTotals()` and
5117
- * reports whether at least one additional group exists beyond the requested
5118
- * row limit.
5505
+ * Computes the same grouped totals as `getGroupedTotals()`, reports whether
5506
+ * at least one further group exists beyond the requested row limit and, when
5507
+ * one does, returns `nextCursor` to read the next page through `cursor`.
5508
+ * Pages follow the group key order, so reading them to the end reports
5509
+ * every group exactly once while the data does not change. Under concurrent
5510
+ * writes every page reads the data as it is at that page's read: the cursor
5511
+ * only moves forward, so no group is reported twice, but a group created
5512
+ * with a key before the cursor is not reported, and totals on different
5513
+ * pages can reflect different moments. A caller that needs one consistent
5514
+ * view reads every page inside one transaction session, as far as the
5515
+ * engine's transaction isolation provides one. Paging orders group values that are null or missing,
5516
+ * numbers, strings, ObjectIds, booleans and dates; a page that returns a
5517
+ * cursor or continues one refuses any other group value — an array, a
5518
+ * document, a Decimal128, NaN — with `unsupported_operation` instead of
5519
+ * skipping it.
5119
5520
  */
5120
5521
  public static async getGroupedTotalsPage<
5121
5522
  T,
@@ -5123,6 +5524,53 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5123
5524
  >(
5124
5525
  this: plugins.tsclass.typeFest.Class<T>,
5125
5526
  optionsArg: ISmartdataGroupedTotalsOptions<T, TUnwind>,
5527
+ ): Promise<ISmartdataGroupedTotalsPage<T, TUnwind>> {
5528
+ return await (this as any).readGroupedTotalsPage(optionsArg, true) as
5529
+ ISmartdataGroupedTotalsPage<T, TUnwind>;
5530
+ }
5531
+
5532
+ /**
5533
+ * Reads every group of a grouped-totals query in group key order, one page
5534
+ * of `limit` groups per database round trip through
5535
+ * `getGroupedTotalsPage()`, with the same exactness, ordering and
5536
+ * concurrency rules. `cursor` resumes after an earlier page; `signal`,
5537
+ * `timeoutMS` and `maxTimeMS` apply to each page.
5538
+ */
5539
+ public static async *iterateGroupedTotals<
5540
+ T,
5541
+ TUnwind extends TSmartdataGroupedTotalsUnwindPath<T> = never,
5542
+ >(
5543
+ this: plugins.tsclass.typeFest.Class<T>,
5544
+ optionsArg: ISmartdataGroupedTotalsOptions<T, TUnwind>,
5545
+ ): AsyncGenerator<ISmartdataGroupedTotalsRow<T, TUnwind>, void, undefined> {
5546
+ // The first page validates the caller's options, so every later page
5547
+ // copies an inert plain object.
5548
+ let page = await (this as any).readGroupedTotalsPage(optionsArg, true) as
5549
+ ISmartdataGroupedTotalsPage<T, TUnwind>;
5550
+ while (true) {
5551
+ yield* page.rows;
5552
+ if (page.nextCursor === undefined) {
5553
+ return;
5554
+ }
5555
+ page = await (this as any).readGroupedTotalsPage(
5556
+ { ...optionsArg, cursor: page.nextCursor },
5557
+ true,
5558
+ ) as ISmartdataGroupedTotalsPage<T, TUnwind>;
5559
+ }
5560
+ }
5561
+
5562
+ /**
5563
+ * Validates grouped-totals options and reads one page. `mintCursorArg` is
5564
+ * false for `getGroupedTotals()`, which returns rows only and so never
5565
+ * needs the next page's cursor.
5566
+ */
5567
+ private static async readGroupedTotalsPage<
5568
+ T,
5569
+ TUnwind extends TSmartdataGroupedTotalsUnwindPath<T> = never,
5570
+ >(
5571
+ this: plugins.tsclass.typeFest.Class<T>,
5572
+ optionsArg: ISmartdataGroupedTotalsOptions<T, TUnwind>,
5573
+ mintCursorArg: boolean,
5126
5574
  ): Promise<ISmartdataGroupedTotalsPage<T, TUnwind>> {
5127
5575
  if ((this as any)[exactPersistencePolicySymbol]) {
5128
5576
  throw new SmartdataPersistenceError(
@@ -5144,9 +5592,11 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5144
5592
  const allowedKeys = new Set([
5145
5593
  'groupBy',
5146
5594
  'unwind',
5595
+ 'elementFilter',
5147
5596
  'sums',
5148
5597
  'filter',
5149
5598
  'limit',
5599
+ 'cursor',
5150
5600
  'maxTimeMS',
5151
5601
  'timeoutMS',
5152
5602
  'signal',
@@ -5173,9 +5623,11 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5173
5623
  }
5174
5624
  const groupByOption = optionsArg.groupBy;
5175
5625
  const unwindOption = optionsArg.unwind;
5626
+ const elementFilterOption = optionsArg.elementFilter;
5176
5627
  const sumsOption = optionsArg.sums;
5177
5628
  const filterOption = optionsArg.filter;
5178
5629
  const limitOption = optionsArg.limit;
5630
+ const cursorOption = optionsArg.cursor;
5179
5631
  const maxTimeMSOption = optionsArg.maxTimeMS;
5180
5632
  const timeoutMSOption = optionsArg.timeoutMS;
5181
5633
  const signalOption = optionsArg.signal;
@@ -5254,10 +5706,36 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5254
5706
  }
5255
5707
  let unwindField: string | undefined;
5256
5708
  if (unwindOption !== undefined) {
5257
- if (typeof unwindOption !== 'string' || !groupByFields.includes(unwindOption)) {
5709
+ if (typeof unwindOption !== 'string') {
5710
+ throw new SmartdataPersistenceError(
5711
+ 'invalid_argument',
5712
+ 'Grouped totals unwind must name a declared array path.',
5713
+ );
5714
+ }
5715
+ requireDeclaredGroupField(unwindOption, 'Grouped totals unwind path');
5716
+ const aggregatedFields = [...groupByFields, ...sumFields];
5717
+ // The unwound array either is a groupBy or sum path, or holds the
5718
+ // element fields a groupBy or sum path names below it.
5719
+ if (
5720
+ !aggregatedFields.some((fieldArg) =>
5721
+ fieldArg === unwindOption || fieldArg.startsWith(unwindOption + '.'),
5722
+ )
5723
+ ) {
5724
+ throw new SmartdataPersistenceError(
5725
+ 'invalid_argument',
5726
+ 'Grouped totals unwind must name a groupBy or sum path, or the array holding one of them.',
5727
+ );
5728
+ }
5729
+ // A path that contains the unwound array would read a document whose
5730
+ // array has been replaced by one element: neither the document's value
5731
+ // nor the element's.
5732
+ const containingField = aggregatedFields.find((fieldArg) =>
5733
+ unwindOption.startsWith(fieldArg + '.'),
5734
+ );
5735
+ if (containingField !== undefined) {
5258
5736
  throw new SmartdataPersistenceError(
5259
5737
  'invalid_argument',
5260
- 'Grouped totals unwind must name one of the groupBy paths.',
5738
+ `Grouped totals path "${containingField}" contains the unwound array "${unwindOption}".`,
5261
5739
  );
5262
5740
  }
5263
5741
  if (
@@ -5273,6 +5751,21 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5273
5751
  }
5274
5752
  unwindField = unwindOption;
5275
5753
  }
5754
+ let elementMatch: plugins.mongodb.Filter<plugins.mongodb.Document> | undefined;
5755
+ if (elementFilterOption !== undefined) {
5756
+ if (unwindField === undefined) {
5757
+ throw new SmartdataPersistenceError(
5758
+ 'invalid_argument',
5759
+ 'Grouped totals elementFilter requires unwind: it selects elements of the unwound array.',
5760
+ );
5761
+ }
5762
+ elementMatch = normalizeGroupedTotalsElementFilter(
5763
+ elementFilterOption,
5764
+ unwindField,
5765
+ declaredRoots,
5766
+ declaredNestedPaths,
5767
+ );
5768
+ }
5276
5769
  const limit = requireBoundedInteger(
5277
5770
  limitOption,
5278
5771
  'Grouped totals limit',
@@ -5307,13 +5800,20 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5307
5800
  // A composite key names its members by position: a group path may be
5308
5801
  // dotted, and an expression object's field names may not contain a dot.
5309
5802
  const compositeKeyName = (indexArg: number): string => 'k' + indexArg;
5803
+ // MongoDB omits a missing member from a composite key, which would split
5804
+ // missing and null into two groups reported alike; NoSQLDB evaluates a
5805
+ // missing member as null. `$ifNull` merges them on MongoDB as well.
5806
+ const compositeMember = (fieldArg: string): unknown =>
5807
+ collection.smartdataDb.capabilities?.backend === 'mongodb'
5808
+ ? { $ifNull: ['$' + fieldArg, null] }
5809
+ : '$' + fieldArg;
5310
5810
  const groupStage: Record<string, unknown> = {
5311
5811
  _id: groupByFields.length === 1
5312
5812
  ? '$' + groupByFields[0]
5313
5813
  : Object.fromEntries(
5314
5814
  groupByFields.map((fieldArg, indexArg) => [
5315
5815
  compositeKeyName(indexArg),
5316
- '$' + fieldArg,
5816
+ compositeMember(fieldArg),
5317
5817
  ]),
5318
5818
  ),
5319
5819
  __groupedTotalsCount: { $sum: 1 },
@@ -5321,10 +5821,38 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5321
5821
  for (const [index, sumField] of sumFields.entries()) {
5322
5822
  groupStage['__groupedTotalsSum' + index] = { $sum: '$' + sumField };
5323
5823
  }
5824
+ const normalizedFilter = filterOption !== undefined
5825
+ ? (this as any).normalizeReadFilter(filterOption) as plugins.mongodb.Filter<plugins.mongodb.Document>
5826
+ : undefined;
5827
+ // The group key's members as the post-group stages address them.
5828
+ const keyMemberPaths = groupByFields.length === 1
5829
+ ? ['_id']
5830
+ : groupByFields.map((_fieldArg, indexArg) => '_id.' + compositeKeyName(indexArg));
5831
+ const readKeyMembers = (rowArg: plugins.mongodb.Document): unknown[] => {
5832
+ if (groupByFields.length === 1) {
5833
+ return [rowArg._id ?? null];
5834
+ }
5835
+ const compoundKey = (rowArg._id ?? {}) as Record<string, unknown>;
5836
+ return groupByFields.map((_fieldArg, indexArg) => compoundKey[compositeKeyName(indexArg)] ?? null);
5837
+ };
5838
+ // Binds a cursor to the query it continues. The limit may change between
5839
+ // pages; everything that decides which groups exist may not.
5840
+ const queryShape = (): string => plugins.nodeCrypto
5841
+ .createHash('sha256')
5842
+ .update(plugins.mongodb.BSON.EJSON.stringify({
5843
+ collection: collection.collectionName,
5844
+ groupBy: groupByFields,
5845
+ unwind: unwindField ?? null,
5846
+ sums: sumFields,
5847
+ filter: normalizedFilter ?? null,
5848
+ elementFilter: elementMatch ?? null,
5849
+ }, { relaxed: false }))
5850
+ .digest('base64url');
5851
+ const after = cursorOption !== undefined
5852
+ ? decodeGroupedTotalsCursor(cursorOption, queryShape(), keyMemberPaths.length)
5853
+ : undefined;
5324
5854
  const rows = await collection.aggregateGroupedTotals(
5325
- filterOption !== undefined
5326
- ? (this as any).normalizeReadFilter(filterOption)
5327
- : undefined,
5855
+ normalizedFilter,
5328
5856
  groupStage,
5329
5857
  limit + 1,
5330
5858
  {
@@ -5333,10 +5861,43 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5333
5861
  ...(timeoutMS !== undefined ? { timeoutMS } : {}),
5334
5862
  ...(signalOption !== undefined ? { signal: signalOption } : {}),
5335
5863
  ...(unwindField !== undefined ? { unwindPath: unwindField } : {}),
5864
+ ...(elementMatch !== undefined ? { elementMatch } : {}),
5865
+ ...(after !== undefined
5866
+ ? { groupMatch: groupedTotalsAfterFilter(keyMemberPaths, after) }
5867
+ : {}),
5868
+ sort: Object.fromEntries(keyMemberPaths.map((pathArg) => [pathArg, 1])),
5336
5869
  },
5337
5870
  );
5338
5871
  const hasMore = rows.length > limit;
5339
5872
  const returnedRows = rows.slice(0, limit);
5873
+ const mintsCursor = mintCursorArg && hasMore;
5874
+ let lastKey: unknown[] | undefined;
5875
+ if (mintsCursor || after !== undefined) {
5876
+ // A page that continues or is continued claims exact paging, so every
5877
+ // key on it must be an ordered value, and the engine must have returned
5878
+ // the keys strictly ascending after the cursor.
5879
+ let previousKey = after;
5880
+ for (const row of returnedRows) {
5881
+ const key = readKeyMembers(row);
5882
+ for (const [index, value] of key.entries()) {
5883
+ if (groupedTotalsOrderedTypeOf(value) === undefined) {
5884
+ throw new SmartdataPersistenceError(
5885
+ 'unsupported_operation',
5886
+ `Grouped totals cannot page over group values of "${groupByFields[index]}" that are not `
5887
+ + 'null, numbers, strings, ObjectIds, booleans or dates.',
5888
+ );
5889
+ }
5890
+ }
5891
+ if (previousKey !== undefined && compareGroupedTotalsKeys(previousKey, key) >= 0) {
5892
+ throw new SmartdataPersistenceError(
5893
+ 'unsupported_operation',
5894
+ 'Grouped totals paging received groups out of group key order from this backend.',
5895
+ );
5896
+ }
5897
+ previousKey = key;
5898
+ }
5899
+ lastKey = previousKey;
5900
+ }
5340
5901
  const requireFiniteNumber = (
5341
5902
  valueArg: unknown,
5342
5903
  labelArg: string,
@@ -5380,6 +5941,9 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5380
5941
  return {
5381
5942
  rows: mappedRows,
5382
5943
  hasMore,
5944
+ ...(mintsCursor && lastKey !== undefined
5945
+ ? { nextCursor: encodeGroupedTotalsCursor(queryShape(), lastKey) }
5946
+ : {}),
5383
5947
  };
5384
5948
  }
5385
5949