@lossless.org/client 1.13.0 → 1.15.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.
@@ -16,6 +16,10 @@ import {
16
16
  type ISmartdataStoredInspectionOptions, type ISmartdataStoredInspection,
17
17
  type ISmartdataStoredEvidenceInspection,
18
18
  } from './classes.storedinspection.js';
19
+ import {
20
+ inspectQueryPlan, requireQueryPlanOptions,
21
+ type ISmartdataQueryPlan, type ISmartdataQueryPlanOptions,
22
+ } from './classes.queryplaninspection.js';
19
23
  import {
20
24
  getIdentityDocumentIdField,
21
25
  type IManager,
@@ -218,6 +222,13 @@ export interface SvDbOptions {
218
222
  * below this persisted field.
219
223
  */
220
224
  atomicPaths?: readonly string[];
225
+ /**
226
+ * Explicit relative nested paths below this persisted field that hold
227
+ * arrays. Each one is a declared nested path exactly like an `atomicPaths`
228
+ * entry, and in addition the array operators `$push`, `$addToSet` and
229
+ * `$pull` and the grouped-totals `unwind` may target it.
230
+ */
231
+ arrayPaths?: readonly string[];
221
232
  /**
222
233
  * Declares this persisted top-level field as numeric for strict atomic
223
234
  * expression references.
@@ -282,6 +293,13 @@ export function svDb(options?: SvDbOptions) {
282
293
  : options.atomicPaths,
283
294
  }
284
295
  : {}),
296
+ ...(options.arrayPaths !== undefined
297
+ ? {
298
+ arrayPaths: Array.isArray(options.arrayPaths)
299
+ ? [...options.arrayPaths]
300
+ : options.arrayPaths,
301
+ }
302
+ : {}),
285
303
  }
286
304
  : undefined;
287
305
  return (value: undefined, context: ClassFieldDecoratorContext) => {
@@ -773,16 +791,48 @@ export interface ISmartdataAtomicNumericExpression<T> {
773
791
  ];
774
792
  }
775
793
 
776
- export interface ISmartdataGroupedTotalsOptions<T> {
794
+ /**
795
+ * A path grouped totals may group by or sum: a declared top-level field or a
796
+ * nested path declared through `@svDb({ atomicPaths })` or
797
+ * `@svDb({ arrayPaths })`.
798
+ */
799
+ export type TSmartdataGroupedTotalsPath<T> = TSmartdataAtomicDocumentPath<T>;
800
+
801
+ /**
802
+ * A path grouped totals may unwind: a declared top-level array field or a
803
+ * nested path declared through `@svDb({ arrayPaths })`.
804
+ */
805
+ export type TSmartdataGroupedTotalsUnwindPath<T> =
806
+ | TSmartdataAtomicArrayPath<T>
807
+ | TSmartdataAtomicNestedDocumentPath<T>;
808
+
809
+ export interface ISmartdataGroupedTotalsOptions<
810
+ T,
811
+ TUnwind extends TSmartdataGroupedTotalsUnwindPath<T> = never,
812
+ > {
813
+ /**
814
+ * One or two distinct declared paths whose value combinations form the
815
+ * groups: declared top-level fields or nested paths declared through
816
+ * `@svDb({ atomicPaths })` or `@svDb({ arrayPaths })`.
817
+ */
818
+ groupBy: ReadonlyArray<TSmartdataGroupedTotalsPath<T>>;
777
819
  /**
778
- * One or two declared top-level fields whose distinct value combinations
779
- * form the groups.
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.
780
827
  */
781
- groupBy: ReadonlyArray<TSmartdataDocumentPath<T>>;
828
+ unwind?: TUnwind;
782
829
  /**
783
- * Declared numeric top-level fields to sum per group.
830
+ * Declared numeric top-level fields, or declared nested paths holding
831
+ * numbers, to sum per group.
784
832
  */
785
- sums?: ReadonlyArray<TSmartdataNumericDocumentPath<T>>;
833
+ sums?: ReadonlyArray<
834
+ TSmartdataNumericDocumentPath<T> | TSmartdataAtomicNestedDocumentPath<T>
835
+ >;
786
836
  filter?: MongoFilter<T>;
787
837
  /**
788
838
  * Required bound on the number of returned groups (1..10000). The group
@@ -803,18 +853,37 @@ export interface ISmartdataGroupedTotalsOptions<T> {
803
853
  session?: TSmartdataOrdinarySession;
804
854
  }
805
855
 
806
- export interface ISmartdataGroupedTotalsRow<T> {
856
+ export interface ISmartdataGroupedTotalsRow<
857
+ T,
858
+ TUnwind extends TSmartdataGroupedTotalsUnwindPath<T> = never,
859
+ > {
807
860
  /**
808
- * The group's key values. Documents missing a group field and documents
809
- * holding an explicit null merge into one group reported as null.
861
+ * The group's key values, keyed by the `groupBy` paths as given. Documents
862
+ * missing a group path and documents holding an explicit null merge into
863
+ * one group reported as null. An unwound top-level array reports the
864
+ * element; nested paths report the stored value untyped.
810
865
  */
811
- group: { [TKey in TSmartdataDocumentPath<T>]?: T[TKey] | null };
866
+ group:
867
+ & {
868
+ [TKey in TSmartdataDocumentPath<T>]?:
869
+ | (TKey extends TUnwind ? TSmartdataAtomicArrayElement<T[TKey]> : T[TKey])
870
+ | null;
871
+ }
872
+ & Partial<Record<TSmartdataAtomicNestedDocumentPath<T>, unknown>>;
812
873
  count: number;
813
- sums: Partial<Record<TSmartdataNumericDocumentPath<T>, number>>;
874
+ sums: Partial<
875
+ Record<
876
+ TSmartdataNumericDocumentPath<T> | TSmartdataAtomicNestedDocumentPath<T>,
877
+ number
878
+ >
879
+ >;
814
880
  }
815
881
 
816
- export interface ISmartdataGroupedTotalsPage<T> {
817
- rows: Array<ISmartdataGroupedTotalsRow<T>>;
882
+ export interface ISmartdataGroupedTotalsPage<
883
+ T,
884
+ TUnwind extends TSmartdataGroupedTotalsUnwindPath<T> = never,
885
+ > {
886
+ rows: Array<ISmartdataGroupedTotalsRow<T, TUnwind>>;
818
887
  hasMore: boolean;
819
888
  }
820
889
 
@@ -826,12 +895,19 @@ export type TSmartdataAtomicArrayPath<T> = {
826
895
  type TSmartdataAtomicArrayElement<TValue> =
827
896
  NonNullable<TValue> extends ReadonlyArray<infer TElement> ? TElement : never;
828
897
 
829
- type TSmartdataAtomicArrayOperation<T> = Partial<{
830
- [TKey in TSmartdataAtomicArrayPath<T>]: TSmartdataAtomicArrayElement<T[TKey]>;
831
- }>;
898
+ /**
899
+ * Array operators take a declared top-level array field, typed by its element,
900
+ * or a nested path below a declared field. A nested path is accepted at run
901
+ * time only when the model declares it through `@svDb({ arrayPaths })`.
902
+ */
903
+ type TSmartdataAtomicArrayOperation<T> =
904
+ & Partial<{
905
+ [TKey in TSmartdataAtomicArrayPath<T>]: TSmartdataAtomicArrayElement<T[TKey]>;
906
+ }>
907
+ & Partial<Record<TSmartdataAtomicNestedDocumentPath<T>, unknown>>;
832
908
 
833
- type TSmartdataAtomicPullScalar<TValue> = Extract<
834
- TValue,
909
+ /** The scalar equality operands `$pull` accepts. */
910
+ type TSmartdataAtomicPullScalarValue =
835
911
  | string
836
912
  | number
837
913
  | boolean
@@ -843,14 +919,22 @@ type TSmartdataAtomicPullScalar<TValue> = Extract<
843
919
  | plugins.mongodb.Long
844
920
  | plugins.mongodb.Int32
845
921
  | plugins.mongodb.Double
846
- | plugins.mongodb.Timestamp
922
+ | plugins.mongodb.Timestamp;
923
+
924
+ type TSmartdataAtomicPullScalar<TValue> = Extract<
925
+ TValue,
926
+ TSmartdataAtomicPullScalarValue
847
927
  >;
848
928
 
849
- type TSmartdataAtomicArrayPullOperation<T> = Partial<{
850
- [TKey in TSmartdataAtomicArrayPath<T>]: TSmartdataAtomicPullScalar<
851
- TSmartdataAtomicArrayElement<T[TKey]>
929
+ type TSmartdataAtomicArrayPullOperation<T> =
930
+ & Partial<{
931
+ [TKey in TSmartdataAtomicArrayPath<T>]: TSmartdataAtomicPullScalar<
932
+ TSmartdataAtomicArrayElement<T[TKey]>
933
+ >;
934
+ }>
935
+ & Partial<
936
+ Record<TSmartdataAtomicNestedDocumentPath<T>, TSmartdataAtomicPullScalarValue>
852
937
  >;
853
- }>;
854
938
 
855
939
  export interface ISmartdataAtomicUpdate<T> {
856
940
  $set?: TSmartdataAtomicSet<T>;
@@ -872,19 +956,21 @@ export interface ISmartdataAtomicUpdate<T> {
872
956
  $min?: TSmartdataAtomicMonotonic<T>;
873
957
  $setOnInsert?: TSmartdataAtomicSet<T>;
874
958
  /**
875
- * Appends one element to a declared top-level array field. Modifier
876
- * documents ($each, $position, $slice, $sort) are rejected.
959
+ * Appends one element to a declared top-level array field or a nested
960
+ * path declared through `@svDb({ arrayPaths })`. Modifier documents
961
+ * ($each, $position, $slice, $sort) are rejected.
877
962
  */
878
963
  $push?: TSmartdataAtomicArrayOperation<T>;
879
964
  /**
880
- * Adds one element to a declared top-level array field unless an equal
881
- * element is already present. Modifier documents are rejected.
965
+ * Adds one element to a declared top-level array field or a declared
966
+ * nested array path unless an equal element is already present. Modifier
967
+ * documents are rejected.
882
968
  */
883
969
  $addToSet?: TSmartdataAtomicArrayOperation<T>;
884
970
  /**
885
971
  * Removes every element equal to a scalar operand from a declared
886
- * top-level array field. Operator documents, plain objects, arrays, and
887
- * regular expressions are rejected.
972
+ * top-level array field or a declared nested array path. Operator
973
+ * documents, plain objects, arrays, and regular expressions are rejected.
888
974
  */
889
975
  $pull?: TSmartdataAtomicArrayPullOperation<T>;
890
976
  }
@@ -3047,6 +3133,8 @@ const normalizeAtomicUpdate = <T>(
3047
3133
  updateArg: ISmartdataAtomicUpdate<T>,
3048
3134
  declaredRootsArg: Set<string>,
3049
3135
  declaredNestedPathsArg: Set<string>,
3136
+ /** Nested paths declared through `@svDb({ arrayPaths })`; array operators may target them. */
3137
+ declaredArrayPathsArg: Set<string>,
3050
3138
  uniqueRootsArg: Set<string>,
3051
3139
  serializedRootsArg: Set<string>,
3052
3140
  /**
@@ -3160,10 +3248,13 @@ const normalizeAtomicUpdate = <T>(
3160
3248
  }
3161
3249
  occupiedPaths.push({ path, operator });
3162
3250
  if (arrayOperators.has(operator)) {
3163
- if (path !== root) {
3251
+ if (path !== root && !declaredArrayPathsArg.has(path)) {
3252
+ // The shape of a nested value is known only from its declaration, so
3253
+ // a nested path that is not declared as an array is refused rather
3254
+ // than left to fail on the stored value.
3164
3255
  throw new SmartdataPersistenceError(
3165
3256
  'invalid_argument',
3166
- `${operator} may only target declared top-level array fields; "${path}" is a nested path.`,
3257
+ `${operator} may only target a declared top-level array field or a nested path declared through @svDb({ arrayPaths }); "${path}" is not a declared array path.`,
3167
3258
  );
3168
3259
  }
3169
3260
  if (value === undefined) {
@@ -3727,6 +3818,32 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
3727
3818
  return inspectStoredDocumentEvidence(this, dbArg, optionsArg);
3728
3819
  }
3729
3820
 
3821
+ /**
3822
+ * Explains one model query with `executionStats` on `dbArg` and returns the
3823
+ * winning plan's stages and execution totals, so a consumer can prove which
3824
+ * index serves the query. Read-only: nothing is created, bound or hydrated.
3825
+ */
3826
+ public static async inspectQueryPlan<T>(
3827
+ this: plugins.tsclass.typeFest.Class<T>,
3828
+ dbArg: SmartdataDb,
3829
+ optionsArg: ISmartdataQueryPlanOptions<T>,
3830
+ ): Promise<ISmartdataQueryPlan> {
3831
+ const model = this as unknown as typeof SmartDataDbDoc;
3832
+ const options = requireQueryPlanOptions(optionsArg);
3833
+ model.assertHydratedProjection(options.projection);
3834
+ const declaredRoots = model.getDeclaredPersistedRoots();
3835
+ const declaredQueryPaths = model.getDeclaredQueryPaths();
3836
+ return inspectQueryPlan(dbArg, {
3837
+ collectionName: getExpectedCollectionTopologyForModel(model).collectionName,
3838
+ filter: model.normalizeReadFilter(options.filter),
3839
+ sort: normalizeSort(options.sort, declaredRoots, declaredQueryPaths),
3840
+ projection: normalizeProjection(options.projection, declaredRoots, declaredQueryPaths),
3841
+ limit: options.limit,
3842
+ timeoutMS: options.timeoutMS,
3843
+ signal: options.signal,
3844
+ });
3845
+ }
3846
+
3730
3847
  public static createInstanceFromMongoDbNativeDoc<T>(
3731
3848
  this: plugins.tsclass.typeFest.Class<T>,
3732
3849
  mongoDbNativeDocArg: any,
@@ -3873,21 +3990,41 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
3873
3990
  return keySets;
3874
3991
  }
3875
3992
 
3993
+ /**
3994
+ * Every nested path the model declares below a persisted field, through
3995
+ * `@svDb({ atomicPaths })` or `@svDb({ arrayPaths })`.
3996
+ */
3876
3997
  private static getDeclaredAtomicPaths(): Set<string> {
3998
+ return new Set([
3999
+ ...(this as any).readDeclaredNestedPaths('atomicPaths') as Set<string>,
4000
+ ...(this as any).readDeclaredNestedPaths('arrayPaths') as Set<string>,
4001
+ ]);
4002
+ }
4003
+
4004
+ /** The nested paths declared through `@svDb({ arrayPaths })` as holding arrays. */
4005
+ private static getDeclaredArrayPaths(): Set<string> {
4006
+ return (this as any).readDeclaredNestedPaths('arrayPaths') as Set<string>;
4007
+ }
4008
+
4009
+ private static readDeclaredNestedPaths(
4010
+ declarationArg: 'atomicPaths' | 'arrayPaths',
4011
+ ): Set<string> {
3877
4012
  const declaredRoots = (this as any).getDeclaredPersistedRoots() as Set<string>;
3878
4013
  const uniqueRoots = (this as any).getDeclaredUniqueRoots() as Set<string>;
3879
4014
  const options = (this as any)._svDbOptions as
3880
4015
  | Record<string, SvDbOptions>
3881
4016
  | undefined;
3882
- const atomicPaths = new Set<string>();
4017
+ const label = declarationArg === 'atomicPaths' ? 'Atomic' : 'Array';
4018
+ const nestedPaths = new Set<string>();
3883
4019
  for (const [root, fieldOptions] of Object.entries(options || {})) {
3884
- if (fieldOptions.atomicPaths === undefined) {
4020
+ const relativePaths = fieldOptions[declarationArg];
4021
+ if (relativePaths === undefined) {
3885
4022
  continue;
3886
4023
  }
3887
- if (!Array.isArray(fieldOptions.atomicPaths)) {
4024
+ if (!Array.isArray(relativePaths)) {
3888
4025
  throw new SmartdataPersistenceError(
3889
4026
  'invalid_configuration',
3890
- `Atomic paths for persisted field "${root}" must be an array.`,
4027
+ `${label} paths for persisted field "${root}" must be an array.`,
3891
4028
  );
3892
4029
  }
3893
4030
  if (
@@ -3896,20 +4033,20 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
3896
4033
  ) {
3897
4034
  throw new SmartdataPersistenceError(
3898
4035
  'invalid_configuration',
3899
- `Serialized persisted field "${root}" may not declare atomic nested paths.`,
4036
+ `Serialized persisted field "${root}" may not declare ${label.toLowerCase()} nested paths.`,
3900
4037
  );
3901
4038
  }
3902
4039
  if (uniqueRoots.has(root)) {
3903
4040
  throw new SmartdataPersistenceError(
3904
4041
  'invalid_configuration',
3905
- `Immutable @unI() field "${root}" may not declare atomic nested paths.`,
4042
+ `Immutable @unI() field "${root}" may not declare ${label.toLowerCase()} nested paths.`,
3906
4043
  );
3907
4044
  }
3908
- for (const relativePath of fieldOptions.atomicPaths) {
4045
+ for (const relativePath of relativePaths) {
3909
4046
  if (typeof relativePath !== 'string' || relativePath.length === 0) {
3910
4047
  throw new SmartdataPersistenceError(
3911
4048
  'invalid_configuration',
3912
- `Atomic paths for persisted field "${root}" must be non-empty strings.`,
4049
+ `${label} paths for persisted field "${root}" must be non-empty strings.`,
3913
4050
  );
3914
4051
  }
3915
4052
  const fullPath = `${root}.${relativePath}`;
@@ -3917,20 +4054,20 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
3917
4054
  requireDeclaredPath(
3918
4055
  fullPath,
3919
4056
  declaredRoots,
3920
- 'Atomic path declaration',
4057
+ `${label} path declaration`,
3921
4058
  new Set([fullPath]),
3922
4059
  );
3923
4060
  } catch (errorArg) {
3924
4061
  throw new SmartdataPersistenceError(
3925
4062
  'invalid_configuration',
3926
- `Atomic path declaration "${fullPath}" is invalid.`,
4063
+ `${label} path declaration "${fullPath}" is invalid.`,
3927
4064
  { cause: errorArg },
3928
4065
  );
3929
4066
  }
3930
- atomicPaths.add(fullPath);
4067
+ nestedPaths.add(fullPath);
3931
4068
  }
3932
4069
  }
3933
- return atomicPaths;
4070
+ return nestedPaths;
3934
4071
  }
3935
4072
 
3936
4073
  private static getSerializedPersistedRoots(): Set<string> {
@@ -4267,6 +4404,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4267
4404
  }
4268
4405
  const declaredRoots = (this as any).getDeclaredPersistedRoots() as Set<string>;
4269
4406
  const declaredAtomicPaths = (this as any).getDeclaredAtomicPaths() as Set<string>;
4407
+ const declaredArrayPaths = (this as any).getDeclaredArrayPaths() as Set<string>;
4270
4408
  const identityValueTypes = (this as any).getDeclaredIdentityValueTypes() as Map<
4271
4409
  string,
4272
4410
  TSmartdataIdentityValueType
@@ -4302,6 +4440,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4302
4440
  updateArg,
4303
4441
  declaredRoots,
4304
4442
  declaredAtomicPaths,
4443
+ declaredArrayPaths,
4305
4444
  uniqueRoots,
4306
4445
  serializedRoots,
4307
4446
  upserting ? { identityValueTypes } : undefined,
@@ -4377,6 +4516,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4377
4516
  const policy = getOrdinaryPersistencePolicy(this);
4378
4517
  const declaredRoots = (this as any).getDeclaredPersistedRoots() as Set<string>;
4379
4518
  const declaredAtomicPaths = (this as any).getDeclaredAtomicPaths() as Set<string>;
4519
+ const declaredArrayPaths = (this as any).getDeclaredArrayPaths() as Set<string>;
4380
4520
  const identityValueTypes = (this as any).getDeclaredIdentityValueTypes() as Map<
4381
4521
  string,
4382
4522
  TSmartdataIdentityValueType
@@ -4403,6 +4543,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4403
4543
  updateArg,
4404
4544
  declaredRoots,
4405
4545
  declaredAtomicPaths,
4546
+ declaredArrayPaths,
4406
4547
  uniqueRoots,
4407
4548
  serializedRoots,
4408
4549
  normalizedOptions.upsert ? { identityValueTypes } : undefined,
@@ -4733,6 +4874,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4733
4874
  const timeoutMS = requireBoundedInteger(opts?.timeoutMS, 'Atomic update-many timeoutMS', 120_000);
4734
4875
  const declaredRoots = (this as any).getDeclaredPersistedRoots() as Set<string>;
4735
4876
  const declaredAtomicPaths = (this as any).getDeclaredAtomicPaths() as Set<string>;
4877
+ const declaredArrayPaths = (this as any).getDeclaredArrayPaths() as Set<string>;
4736
4878
  const uniqueRoots = (this as any).getDeclaredUniqueRoots() as Set<string>;
4737
4879
  const identityValueTypes = (this as any).getDeclaredIdentityValueTypes() as Map<
4738
4880
  string,
@@ -4753,6 +4895,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4753
4895
  updateArg,
4754
4896
  declaredRoots,
4755
4897
  declaredAtomicPaths,
4898
+ declaredArrayPaths,
4756
4899
  uniqueRoots,
4757
4900
  serializedRoots,
4758
4901
  );
@@ -4843,6 +4986,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4843
4986
  );
4844
4987
  const declaredRoots = (this as any).getDeclaredPersistedRoots() as Set<string>;
4845
4988
  const declaredAtomicPaths = (this as any).getDeclaredAtomicPaths() as Set<string>;
4989
+ const declaredArrayPaths = (this as any).getDeclaredArrayPaths() as Set<string>;
4846
4990
  const identityValueTypes = (this as any).getDeclaredIdentityValueTypes() as Map<
4847
4991
  string,
4848
4992
  TSmartdataIdentityValueType
@@ -4891,6 +5035,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4891
5035
  operation.update as ISmartdataAtomicUpdate<T>,
4892
5036
  declaredRoots,
4893
5037
  declaredAtomicPaths,
5038
+ declaredArrayPaths,
4894
5039
  uniqueRoots,
4895
5040
  serializedRoots,
4896
5041
  { identityValueTypes },
@@ -4946,18 +5091,24 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4946
5091
 
4947
5092
  /**
4948
5093
  * Computes bounded grouped counts and optional numeric sums server-side.
4949
- * Groups by one or two declared top-level fields, always returns a `count`
4950
- * per group, and sums only declared numeric fields. The `$limit` follows
4951
- * the group stage without a sort, so when more groups exist than the limit
4952
- * the returned subset is arbitrary. Use `getGroupedTotalsPage()` when the
4953
- * caller must detect overflow. The aggregation cursor never leaves SmartData.
5094
+ * Groups by one or two declared paths — top-level fields or nested paths
5095
+ * 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.
4954
5102
  */
4955
- public static async getGroupedTotals<T>(
5103
+ public static async getGroupedTotals<
5104
+ T,
5105
+ TUnwind extends TSmartdataGroupedTotalsUnwindPath<T> = never,
5106
+ >(
4956
5107
  this: plugins.tsclass.typeFest.Class<T>,
4957
- optionsArg: ISmartdataGroupedTotalsOptions<T>,
4958
- ): Promise<Array<ISmartdataGroupedTotalsRow<T>>> {
5108
+ optionsArg: ISmartdataGroupedTotalsOptions<T, TUnwind>,
5109
+ ): Promise<Array<ISmartdataGroupedTotalsRow<T, TUnwind>>> {
4959
5110
  const page = await (this as any).getGroupedTotalsPage(optionsArg) as
4960
- ISmartdataGroupedTotalsPage<T>;
5111
+ ISmartdataGroupedTotalsPage<T, TUnwind>;
4961
5112
  return page.rows;
4962
5113
  }
4963
5114
 
@@ -4966,10 +5117,13 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4966
5117
  * reports whether at least one additional group exists beyond the requested
4967
5118
  * row limit.
4968
5119
  */
4969
- public static async getGroupedTotalsPage<T>(
5120
+ public static async getGroupedTotalsPage<
5121
+ T,
5122
+ TUnwind extends TSmartdataGroupedTotalsUnwindPath<T> = never,
5123
+ >(
4970
5124
  this: plugins.tsclass.typeFest.Class<T>,
4971
- optionsArg: ISmartdataGroupedTotalsOptions<T>,
4972
- ): Promise<ISmartdataGroupedTotalsPage<T>> {
5125
+ optionsArg: ISmartdataGroupedTotalsOptions<T, TUnwind>,
5126
+ ): Promise<ISmartdataGroupedTotalsPage<T, TUnwind>> {
4973
5127
  if ((this as any)[exactPersistencePolicySymbol]) {
4974
5128
  throw new SmartdataPersistenceError(
4975
5129
  'unsupported_operation',
@@ -4989,6 +5143,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4989
5143
  }
4990
5144
  const allowedKeys = new Set([
4991
5145
  'groupBy',
5146
+ 'unwind',
4992
5147
  'sums',
4993
5148
  'filter',
4994
5149
  'limit',
@@ -5017,6 +5172,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5017
5172
  }
5018
5173
  }
5019
5174
  const groupByOption = optionsArg.groupBy;
5175
+ const unwindOption = optionsArg.unwind;
5020
5176
  const sumsOption = optionsArg.sums;
5021
5177
  const filterOption = optionsArg.filter;
5022
5178
  const limitOption = optionsArg.limit;
@@ -5029,6 +5185,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5029
5185
  signalOption?.throwIfAborted();
5030
5186
  const sessionOption = optionsArg.session;
5031
5187
  const declaredRoots = (this as any).getDeclaredPersistedRoots() as Set<string>;
5188
+ const declaredNestedPaths = (this as any).getDeclaredAtomicPaths() as Set<string>;
5032
5189
  const serializedRoots = (this as any).getSerializedPersistedRoots() as Set<string>;
5033
5190
  const requireDeclaredGroupField = (
5034
5191
  fieldArg: unknown,
@@ -5037,23 +5194,22 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5037
5194
  if (typeof fieldArg !== 'string') {
5038
5195
  throw new SmartdataPersistenceError(
5039
5196
  'invalid_argument',
5040
- `${labelArg} must name a declared top-level field.`,
5041
- );
5042
- }
5043
- const root = requireDeclaredPath(fieldArg, declaredRoots, labelArg);
5044
- if (root !== fieldArg) {
5045
- throw new SmartdataPersistenceError(
5046
- 'invalid_argument',
5047
- `${labelArg} must name a declared top-level field, not a nested path.`,
5197
+ `${labelArg} must name a declared top-level field or a declared nested path.`,
5048
5198
  );
5049
5199
  }
5200
+ const root = requireDeclaredPath(
5201
+ fieldArg,
5202
+ declaredRoots,
5203
+ labelArg,
5204
+ declaredNestedPaths,
5205
+ );
5050
5206
  if (serializedRoots.has(root)) {
5051
5207
  throw new SmartdataPersistenceError(
5052
5208
  'unsupported_operation',
5053
5209
  `${labelArg} cannot target serialized field "${root}".`,
5054
5210
  );
5055
5211
  }
5056
- return root;
5212
+ return fieldArg;
5057
5213
  };
5058
5214
  if (
5059
5215
  !Array.isArray(groupByOption)
@@ -5071,11 +5227,14 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5071
5227
  if (
5072
5228
  groupByFields.length < 1
5073
5229
  || groupByFields.length > 2
5074
- || new Set(groupByFields).size !== groupByFields.length
5230
+ || (
5231
+ groupByFields.length === 2
5232
+ && pathsConflict(groupByFields[0], groupByFields[1])
5233
+ )
5075
5234
  ) {
5076
5235
  throw new SmartdataPersistenceError(
5077
5236
  'invalid_argument',
5078
- 'Grouped totals groupBy fields must be one or two distinct fields.',
5237
+ 'Grouped totals groupBy fields must be one or two distinct fields, neither nested in the other.',
5079
5238
  );
5080
5239
  }
5081
5240
  if (sumsOption !== undefined && !Array.isArray(sumsOption)) {
@@ -5093,6 +5252,27 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5093
5252
  'Grouped totals sum fields must be distinct.',
5094
5253
  );
5095
5254
  }
5255
+ let unwindField: string | undefined;
5256
+ if (unwindOption !== undefined) {
5257
+ if (typeof unwindOption !== 'string' || !groupByFields.includes(unwindOption)) {
5258
+ throw new SmartdataPersistenceError(
5259
+ 'invalid_argument',
5260
+ 'Grouped totals unwind must name one of the groupBy paths.',
5261
+ );
5262
+ }
5263
+ if (
5264
+ unwindOption.includes('.')
5265
+ && !((this as any).getDeclaredArrayPaths() as Set<string>).has(unwindOption)
5266
+ ) {
5267
+ // A nested value's shape is known only from its declaration; unwinding
5268
+ // a path not declared as an array would count scalars as elements.
5269
+ throw new SmartdataPersistenceError(
5270
+ 'invalid_argument',
5271
+ `Grouped totals unwind path "${unwindOption}" is not declared through @svDb({ arrayPaths }).`,
5272
+ );
5273
+ }
5274
+ unwindField = unwindOption;
5275
+ }
5096
5276
  const limit = requireBoundedInteger(
5097
5277
  limitOption,
5098
5278
  'Grouped totals limit',
@@ -5124,11 +5304,17 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5124
5304
  + `call, such as groupBy: ["${groupByFields[0]}"].`,
5125
5305
  );
5126
5306
  }
5307
+ // A composite key names its members by position: a group path may be
5308
+ // dotted, and an expression object's field names may not contain a dot.
5309
+ const compositeKeyName = (indexArg: number): string => 'k' + indexArg;
5127
5310
  const groupStage: Record<string, unknown> = {
5128
5311
  _id: groupByFields.length === 1
5129
5312
  ? '$' + groupByFields[0]
5130
5313
  : Object.fromEntries(
5131
- groupByFields.map((fieldArg) => [fieldArg, '$' + fieldArg]),
5314
+ groupByFields.map((fieldArg, indexArg) => [
5315
+ compositeKeyName(indexArg),
5316
+ '$' + fieldArg,
5317
+ ]),
5132
5318
  ),
5133
5319
  __groupedTotalsCount: { $sum: 1 },
5134
5320
  };
@@ -5146,6 +5332,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5146
5332
  ...(maxTimeMS !== undefined ? { maxTimeMS } : {}),
5147
5333
  ...(timeoutMS !== undefined ? { timeoutMS } : {}),
5148
5334
  ...(signalOption !== undefined ? { signal: signalOption } : {}),
5335
+ ...(unwindField !== undefined ? { unwindPath: unwindField } : {}),
5149
5336
  },
5150
5337
  );
5151
5338
  const hasMore = rows.length > limit;
@@ -5168,10 +5355,10 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5168
5355
  group[groupByFields[0]] = rowArg._id ?? null;
5169
5356
  } else {
5170
5357
  const compoundKey = (rowArg._id ?? {}) as Record<string, unknown>;
5171
- for (const fieldArg of groupByFields) {
5358
+ for (const [index, fieldArg] of groupByFields.entries()) {
5172
5359
  // Missing and explicit-null values merge into one group that is
5173
5360
  // reported as null in both the single-field and compound shapes.
5174
- group[fieldArg] = compoundKey[fieldArg] ?? null;
5361
+ group[fieldArg] = compoundKey[compositeKeyName(index)] ?? null;
5175
5362
  }
5176
5363
  }
5177
5364
  const sums: Record<string, number> = {};
@@ -5188,7 +5375,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5188
5375
  'Grouped totals count',
5189
5376
  ),
5190
5377
  sums,
5191
- } as ISmartdataGroupedTotalsRow<T>;
5378
+ } as ISmartdataGroupedTotalsRow<T, TUnwind>;
5192
5379
  });
5193
5380
  return {
5194
5381
  rows: mappedRows,