@lossless.org/client 1.16.0 → 1.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/.smartconfig.json +20 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/nosqldb/classes.collection.d.ts +6 -1
  4. package/dist_ts/nosqldb/classes.collection.js +13 -2
  5. package/dist_ts/nosqldb/classes.distributedcoordinator.js +7 -7
  6. package/dist_ts/nosqldb/classes.doc.d.ts +79 -14
  7. package/dist_ts/nosqldb/classes.doc.js +395 -37
  8. package/dist_ts/nosqldb/classes.easystore.js +2 -2
  9. package/dist_ts/nosqldb/classes.watcher.js +2 -2
  10. package/dist_ts/nosqldb/plugins.d.ts +1 -2
  11. package/dist_ts/nosqldb/plugins.js +2 -3
  12. package/dist_ts/sqldb/clickhouse/smartclickhouse.classes.clickhousetable.js +2 -2
  13. package/dist_ts/sqldb/clickhouse/smartclickhouse.classes.smartclickhouse.js +2 -2
  14. package/dist_ts/sqldb/clickhouse/smartclickhouse.plugins.d.ts +1 -2
  15. package/dist_ts/sqldb/clickhouse/smartclickhouse.plugins.js +2 -3
  16. package/licenses/SOURCES.md +13 -0
  17. package/licenses/aws-sdk-js-v3.txt +201 -0
  18. package/licenses/nodable-entities.txt +21 -0
  19. package/package.json +4 -3
  20. package/readme.md +4 -2
  21. package/ts/00_commitinfo_data.ts +1 -1
  22. package/ts/nosqldb/classes.collection.ts +15 -1
  23. package/ts/nosqldb/classes.distributedcoordinator.ts +7 -7
  24. package/ts/nosqldb/classes.doc.ts +594 -61
  25. package/ts/nosqldb/classes.easystore.ts +1 -1
  26. package/ts/nosqldb/classes.watcher.ts +1 -1
  27. package/ts/nosqldb/plugins.ts +0 -2
  28. package/ts/sqldb/clickhouse/smartclickhouse.classes.clickhousetable.ts +1 -1
  29. package/ts/sqldb/clickhouse/smartclickhouse.classes.smartclickhouse.ts +1 -1
  30. package/ts/sqldb/clickhouse/smartclickhouse.plugins.ts +1 -2
@@ -827,9 +827,23 @@ export interface ISmartdataGroupedTotalsOptions<
827
827
  * counts elements and a document contributes its `sums` once per element;
828
828
  * a document whose array is missing, null or empty contributes nothing.
829
829
  * The `filter` still selects whole documents before the array is unwound,
830
- * and no `groupBy` or `sums` path may contain the unwound array.
830
+ * and no `groupBy` or `sums` path may contain the unwound array; use
831
+ * `elementFilter` to select elements.
831
832
  */
832
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>>;
833
847
  /**
834
848
  * Declared numeric top-level fields, or declared nested paths holding
835
849
  * numbers, to sum per group.
@@ -839,12 +853,21 @@ export interface ISmartdataGroupedTotalsOptions<
839
853
  >;
840
854
  filter?: MongoFilter<T>;
841
855
  /**
842
- * Required bound on the number of returned groups (1..10000). The group
843
- * stage is not sorted, so when more groups exist than the limit the
844
- * returned subset is arbitrary. Use `getGroupedTotalsPage()` when the
845
- * 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.
846
861
  */
847
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;
848
871
  maxTimeMS?: number;
849
872
  /**
850
873
  * Client-side deadline for the aggregation cursor up to its last reply. More
@@ -889,8 +912,32 @@ export interface ISmartdataGroupedTotalsPage<
889
912
  > {
890
913
  rows: Array<ISmartdataGroupedTotalsRow<T, TUnwind>>;
891
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;
892
921
  }
893
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
+
894
941
  export type TSmartdataAtomicArrayPath<T> = {
895
942
  [TKey in TSmartdataDocumentPath<T>]:
896
943
  NonNullable<T[TKey]> extends ReadonlyArray<unknown> ? TKey : never;
@@ -2813,6 +2860,50 @@ const normalizeAtomicNumericExpression = (
2813
2860
  : guardedComparison;
2814
2861
  };
2815
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
+
2816
2907
  const normalizeStrictFilter = (
2817
2908
  filterArg: Record<string, unknown>,
2818
2909
  declaredRootsArg: Set<string>,
@@ -2849,48 +2940,6 @@ const normalizeStrictFilter = (
2849
2940
  `${operationLabelArg} filter`,
2850
2941
  false,
2851
2942
  );
2852
- const requireInertLogicalFilterArray: (
2853
- valueArg: unknown,
2854
- labelArg: string,
2855
- ) => asserts valueArg is Array<Record<string, unknown>> = (
2856
- valueArg,
2857
- labelArg,
2858
- ) => {
2859
- if (
2860
- !Array.isArray(valueArg)
2861
- || plugins.nodeUtil.types.isProxy(valueArg)
2862
- || Object.getPrototypeOf(valueArg) !== Array.prototype
2863
- || valueArg.length === 0
2864
- ) {
2865
- throw new SmartdataPersistenceError(
2866
- 'invalid_argument',
2867
- `${labelArg} must be a non-empty ordinary array of filters.`,
2868
- );
2869
- }
2870
- assertOnlyDataProperties(valueArg, labelArg, true);
2871
- for (let index = 0; index < valueArg.length; index++) {
2872
- if (!Object.prototype.hasOwnProperty.call(valueArg, index)) {
2873
- throw new SmartdataPersistenceError(
2874
- 'invalid_argument',
2875
- `${labelArg} may not contain sparse array positions.`,
2876
- );
2877
- }
2878
- const entry = valueArg[index];
2879
- if (
2880
- typeof entry !== 'object'
2881
- || entry === null
2882
- || Array.isArray(entry)
2883
- || plugins.nodeUtil.types.isProxy(entry)
2884
- || !isPlainObject(entry)
2885
- ) {
2886
- throw new SmartdataPersistenceError(
2887
- 'invalid_argument',
2888
- `${labelArg} entries must be plain filter objects.`,
2889
- );
2890
- }
2891
- assertOnlyDataProperties(entry, `${labelArg} entry`, false);
2892
- }
2893
- };
2894
2943
  /**
2895
2944
  * Plural intent is explicit in the plural method names, so an explicitly
2896
2945
  * enumerated, non-empty `$in` over equality scalars anchors those filters as
@@ -3128,6 +3177,341 @@ const normalizeStrictFilter = (
3128
3177
  ) as plugins.mongodb.Filter<plugins.mongodb.Document>;
3129
3178
  };
3130
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
+
3131
3515
  const pathsConflict = (leftArg: string, rightArg: string): boolean =>
3132
3516
  leftArg === rightArg ||
3133
3517
  leftArg.startsWith(`${rightArg}.`) ||
@@ -5098,12 +5482,12 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5098
5482
  * Groups by one or two declared paths — top-level fields or nested paths
5099
5483
  * declared through `@svDb({ atomicPaths })` or `@svDb({ arrayPaths })` —
5100
5484
  * optionally per element of one declared array (`unwind`), whose element
5101
- * fields `groupBy` and `sums` may then name, always returns a `count` per
5102
- * group, and sums only declared numeric paths. The
5103
- * `$limit` follows the group stage without a sort, so when more groups
5104
- * exist than the limit the returned subset is arbitrary. Use
5105
- * `getGroupedTotalsPage()` when the caller must detect overflow. The
5106
- * aggregation cursor never leaves SmartData.
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.
5107
5491
  */
5108
5492
  public static async getGroupedTotals<
5109
5493
  T,
@@ -5112,15 +5496,27 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5112
5496
  this: plugins.tsclass.typeFest.Class<T>,
5113
5497
  optionsArg: ISmartdataGroupedTotalsOptions<T, TUnwind>,
5114
5498
  ): Promise<Array<ISmartdataGroupedTotalsRow<T, TUnwind>>> {
5115
- const page = await (this as any).getGroupedTotalsPage(optionsArg) as
5499
+ const page = await (this as any).readGroupedTotalsPage(optionsArg, false) as
5116
5500
  ISmartdataGroupedTotalsPage<T, TUnwind>;
5117
5501
  return page.rows;
5118
5502
  }
5119
5503
 
5120
5504
  /**
5121
- * Computes the same bounded grouped totals as `getGroupedTotals()` and
5122
- * reports whether at least one additional group exists beyond the requested
5123
- * 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.
5124
5520
  */
5125
5521
  public static async getGroupedTotalsPage<
5126
5522
  T,
@@ -5128,6 +5524,53 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5128
5524
  >(
5129
5525
  this: plugins.tsclass.typeFest.Class<T>,
5130
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,
5131
5574
  ): Promise<ISmartdataGroupedTotalsPage<T, TUnwind>> {
5132
5575
  if ((this as any)[exactPersistencePolicySymbol]) {
5133
5576
  throw new SmartdataPersistenceError(
@@ -5149,9 +5592,11 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5149
5592
  const allowedKeys = new Set([
5150
5593
  'groupBy',
5151
5594
  'unwind',
5595
+ 'elementFilter',
5152
5596
  'sums',
5153
5597
  'filter',
5154
5598
  'limit',
5599
+ 'cursor',
5155
5600
  'maxTimeMS',
5156
5601
  'timeoutMS',
5157
5602
  'signal',
@@ -5178,9 +5623,11 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5178
5623
  }
5179
5624
  const groupByOption = optionsArg.groupBy;
5180
5625
  const unwindOption = optionsArg.unwind;
5626
+ const elementFilterOption = optionsArg.elementFilter;
5181
5627
  const sumsOption = optionsArg.sums;
5182
5628
  const filterOption = optionsArg.filter;
5183
5629
  const limitOption = optionsArg.limit;
5630
+ const cursorOption = optionsArg.cursor;
5184
5631
  const maxTimeMSOption = optionsArg.maxTimeMS;
5185
5632
  const timeoutMSOption = optionsArg.timeoutMS;
5186
5633
  const signalOption = optionsArg.signal;
@@ -5304,6 +5751,21 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5304
5751
  }
5305
5752
  unwindField = unwindOption;
5306
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
+ }
5307
5769
  const limit = requireBoundedInteger(
5308
5770
  limitOption,
5309
5771
  'Grouped totals limit',
@@ -5338,13 +5800,20 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5338
5800
  // A composite key names its members by position: a group path may be
5339
5801
  // dotted, and an expression object's field names may not contain a dot.
5340
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;
5341
5810
  const groupStage: Record<string, unknown> = {
5342
5811
  _id: groupByFields.length === 1
5343
5812
  ? '$' + groupByFields[0]
5344
5813
  : Object.fromEntries(
5345
5814
  groupByFields.map((fieldArg, indexArg) => [
5346
5815
  compositeKeyName(indexArg),
5347
- '$' + fieldArg,
5816
+ compositeMember(fieldArg),
5348
5817
  ]),
5349
5818
  ),
5350
5819
  __groupedTotalsCount: { $sum: 1 },
@@ -5352,10 +5821,38 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5352
5821
  for (const [index, sumField] of sumFields.entries()) {
5353
5822
  groupStage['__groupedTotalsSum' + index] = { $sum: '$' + sumField };
5354
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;
5355
5854
  const rows = await collection.aggregateGroupedTotals(
5356
- filterOption !== undefined
5357
- ? (this as any).normalizeReadFilter(filterOption)
5358
- : undefined,
5855
+ normalizedFilter,
5359
5856
  groupStage,
5360
5857
  limit + 1,
5361
5858
  {
@@ -5364,10 +5861,43 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5364
5861
  ...(timeoutMS !== undefined ? { timeoutMS } : {}),
5365
5862
  ...(signalOption !== undefined ? { signal: signalOption } : {}),
5366
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])),
5367
5869
  },
5368
5870
  );
5369
5871
  const hasMore = rows.length > limit;
5370
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
+ }
5371
5901
  const requireFiniteNumber = (
5372
5902
  valueArg: unknown,
5373
5903
  labelArg: string,
@@ -5411,6 +5941,9 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5411
5941
  return {
5412
5942
  rows: mappedRows,
5413
5943
  hasMore,
5944
+ ...(mintsCursor && lastKey !== undefined
5945
+ ? { nextCursor: encodeGroupedTotalsCursor(queryShape(), lastKey) }
5946
+ : {}),
5414
5947
  };
5415
5948
  }
5416
5949