@lossless.org/client 1.14.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.
@@ -222,6 +222,13 @@ export interface SvDbOptions {
222
222
  * below this persisted field.
223
223
  */
224
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[];
225
232
  /**
226
233
  * Declares this persisted top-level field as numeric for strict atomic
227
234
  * expression references.
@@ -286,6 +293,13 @@ export function svDb(options?: SvDbOptions) {
286
293
  : options.atomicPaths,
287
294
  }
288
295
  : {}),
296
+ ...(options.arrayPaths !== undefined
297
+ ? {
298
+ arrayPaths: Array.isArray(options.arrayPaths)
299
+ ? [...options.arrayPaths]
300
+ : options.arrayPaths,
301
+ }
302
+ : {}),
289
303
  }
290
304
  : undefined;
291
305
  return (value: undefined, context: ClassFieldDecoratorContext) => {
@@ -777,16 +791,48 @@ export interface ISmartdataAtomicNumericExpression<T> {
777
791
  ];
778
792
  }
779
793
 
780
- 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
+ > {
781
813
  /**
782
- * One or two declared top-level fields whose distinct value combinations
783
- * form the groups.
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 })`.
784
817
  */
785
- groupBy: ReadonlyArray<TSmartdataDocumentPath<T>>;
818
+ groupBy: ReadonlyArray<TSmartdataGroupedTotalsPath<T>>;
786
819
  /**
787
- * Declared numeric top-level fields to sum per group.
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.
788
827
  */
789
- sums?: ReadonlyArray<TSmartdataNumericDocumentPath<T>>;
828
+ unwind?: TUnwind;
829
+ /**
830
+ * Declared numeric top-level fields, or declared nested paths holding
831
+ * numbers, to sum per group.
832
+ */
833
+ sums?: ReadonlyArray<
834
+ TSmartdataNumericDocumentPath<T> | TSmartdataAtomicNestedDocumentPath<T>
835
+ >;
790
836
  filter?: MongoFilter<T>;
791
837
  /**
792
838
  * Required bound on the number of returned groups (1..10000). The group
@@ -807,18 +853,37 @@ export interface ISmartdataGroupedTotalsOptions<T> {
807
853
  session?: TSmartdataOrdinarySession;
808
854
  }
809
855
 
810
- export interface ISmartdataGroupedTotalsRow<T> {
856
+ export interface ISmartdataGroupedTotalsRow<
857
+ T,
858
+ TUnwind extends TSmartdataGroupedTotalsUnwindPath<T> = never,
859
+ > {
811
860
  /**
812
- * The group's key values. Documents missing a group field and documents
813
- * 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.
814
865
  */
815
- 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>>;
816
873
  count: number;
817
- sums: Partial<Record<TSmartdataNumericDocumentPath<T>, number>>;
874
+ sums: Partial<
875
+ Record<
876
+ TSmartdataNumericDocumentPath<T> | TSmartdataAtomicNestedDocumentPath<T>,
877
+ number
878
+ >
879
+ >;
818
880
  }
819
881
 
820
- export interface ISmartdataGroupedTotalsPage<T> {
821
- rows: Array<ISmartdataGroupedTotalsRow<T>>;
882
+ export interface ISmartdataGroupedTotalsPage<
883
+ T,
884
+ TUnwind extends TSmartdataGroupedTotalsUnwindPath<T> = never,
885
+ > {
886
+ rows: Array<ISmartdataGroupedTotalsRow<T, TUnwind>>;
822
887
  hasMore: boolean;
823
888
  }
824
889
 
@@ -830,12 +895,19 @@ export type TSmartdataAtomicArrayPath<T> = {
830
895
  type TSmartdataAtomicArrayElement<TValue> =
831
896
  NonNullable<TValue> extends ReadonlyArray<infer TElement> ? TElement : never;
832
897
 
833
- type TSmartdataAtomicArrayOperation<T> = Partial<{
834
- [TKey in TSmartdataAtomicArrayPath<T>]: TSmartdataAtomicArrayElement<T[TKey]>;
835
- }>;
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>>;
836
908
 
837
- type TSmartdataAtomicPullScalar<TValue> = Extract<
838
- TValue,
909
+ /** The scalar equality operands `$pull` accepts. */
910
+ type TSmartdataAtomicPullScalarValue =
839
911
  | string
840
912
  | number
841
913
  | boolean
@@ -847,14 +919,22 @@ type TSmartdataAtomicPullScalar<TValue> = Extract<
847
919
  | plugins.mongodb.Long
848
920
  | plugins.mongodb.Int32
849
921
  | plugins.mongodb.Double
850
- | plugins.mongodb.Timestamp
922
+ | plugins.mongodb.Timestamp;
923
+
924
+ type TSmartdataAtomicPullScalar<TValue> = Extract<
925
+ TValue,
926
+ TSmartdataAtomicPullScalarValue
851
927
  >;
852
928
 
853
- type TSmartdataAtomicArrayPullOperation<T> = Partial<{
854
- [TKey in TSmartdataAtomicArrayPath<T>]: TSmartdataAtomicPullScalar<
855
- 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>
856
937
  >;
857
- }>;
858
938
 
859
939
  export interface ISmartdataAtomicUpdate<T> {
860
940
  $set?: TSmartdataAtomicSet<T>;
@@ -876,19 +956,21 @@ export interface ISmartdataAtomicUpdate<T> {
876
956
  $min?: TSmartdataAtomicMonotonic<T>;
877
957
  $setOnInsert?: TSmartdataAtomicSet<T>;
878
958
  /**
879
- * Appends one element to a declared top-level array field. Modifier
880
- * 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.
881
962
  */
882
963
  $push?: TSmartdataAtomicArrayOperation<T>;
883
964
  /**
884
- * Adds one element to a declared top-level array field unless an equal
885
- * 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.
886
968
  */
887
969
  $addToSet?: TSmartdataAtomicArrayOperation<T>;
888
970
  /**
889
971
  * Removes every element equal to a scalar operand from a declared
890
- * top-level array field. Operator documents, plain objects, arrays, and
891
- * 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.
892
974
  */
893
975
  $pull?: TSmartdataAtomicArrayPullOperation<T>;
894
976
  }
@@ -3051,6 +3133,8 @@ const normalizeAtomicUpdate = <T>(
3051
3133
  updateArg: ISmartdataAtomicUpdate<T>,
3052
3134
  declaredRootsArg: Set<string>,
3053
3135
  declaredNestedPathsArg: Set<string>,
3136
+ /** Nested paths declared through `@svDb({ arrayPaths })`; array operators may target them. */
3137
+ declaredArrayPathsArg: Set<string>,
3054
3138
  uniqueRootsArg: Set<string>,
3055
3139
  serializedRootsArg: Set<string>,
3056
3140
  /**
@@ -3164,10 +3248,13 @@ const normalizeAtomicUpdate = <T>(
3164
3248
  }
3165
3249
  occupiedPaths.push({ path, operator });
3166
3250
  if (arrayOperators.has(operator)) {
3167
- 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.
3168
3255
  throw new SmartdataPersistenceError(
3169
3256
  'invalid_argument',
3170
- `${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.`,
3171
3258
  );
3172
3259
  }
3173
3260
  if (value === undefined) {
@@ -3903,21 +3990,41 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
3903
3990
  return keySets;
3904
3991
  }
3905
3992
 
3993
+ /**
3994
+ * Every nested path the model declares below a persisted field, through
3995
+ * `@svDb({ atomicPaths })` or `@svDb({ arrayPaths })`.
3996
+ */
3906
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> {
3907
4012
  const declaredRoots = (this as any).getDeclaredPersistedRoots() as Set<string>;
3908
4013
  const uniqueRoots = (this as any).getDeclaredUniqueRoots() as Set<string>;
3909
4014
  const options = (this as any)._svDbOptions as
3910
4015
  | Record<string, SvDbOptions>
3911
4016
  | undefined;
3912
- const atomicPaths = new Set<string>();
4017
+ const label = declarationArg === 'atomicPaths' ? 'Atomic' : 'Array';
4018
+ const nestedPaths = new Set<string>();
3913
4019
  for (const [root, fieldOptions] of Object.entries(options || {})) {
3914
- if (fieldOptions.atomicPaths === undefined) {
4020
+ const relativePaths = fieldOptions[declarationArg];
4021
+ if (relativePaths === undefined) {
3915
4022
  continue;
3916
4023
  }
3917
- if (!Array.isArray(fieldOptions.atomicPaths)) {
4024
+ if (!Array.isArray(relativePaths)) {
3918
4025
  throw new SmartdataPersistenceError(
3919
4026
  'invalid_configuration',
3920
- `Atomic paths for persisted field "${root}" must be an array.`,
4027
+ `${label} paths for persisted field "${root}" must be an array.`,
3921
4028
  );
3922
4029
  }
3923
4030
  if (
@@ -3926,20 +4033,20 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
3926
4033
  ) {
3927
4034
  throw new SmartdataPersistenceError(
3928
4035
  'invalid_configuration',
3929
- `Serialized persisted field "${root}" may not declare atomic nested paths.`,
4036
+ `Serialized persisted field "${root}" may not declare ${label.toLowerCase()} nested paths.`,
3930
4037
  );
3931
4038
  }
3932
4039
  if (uniqueRoots.has(root)) {
3933
4040
  throw new SmartdataPersistenceError(
3934
4041
  'invalid_configuration',
3935
- `Immutable @unI() field "${root}" may not declare atomic nested paths.`,
4042
+ `Immutable @unI() field "${root}" may not declare ${label.toLowerCase()} nested paths.`,
3936
4043
  );
3937
4044
  }
3938
- for (const relativePath of fieldOptions.atomicPaths) {
4045
+ for (const relativePath of relativePaths) {
3939
4046
  if (typeof relativePath !== 'string' || relativePath.length === 0) {
3940
4047
  throw new SmartdataPersistenceError(
3941
4048
  'invalid_configuration',
3942
- `Atomic paths for persisted field "${root}" must be non-empty strings.`,
4049
+ `${label} paths for persisted field "${root}" must be non-empty strings.`,
3943
4050
  );
3944
4051
  }
3945
4052
  const fullPath = `${root}.${relativePath}`;
@@ -3947,20 +4054,20 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
3947
4054
  requireDeclaredPath(
3948
4055
  fullPath,
3949
4056
  declaredRoots,
3950
- 'Atomic path declaration',
4057
+ `${label} path declaration`,
3951
4058
  new Set([fullPath]),
3952
4059
  );
3953
4060
  } catch (errorArg) {
3954
4061
  throw new SmartdataPersistenceError(
3955
4062
  'invalid_configuration',
3956
- `Atomic path declaration "${fullPath}" is invalid.`,
4063
+ `${label} path declaration "${fullPath}" is invalid.`,
3957
4064
  { cause: errorArg },
3958
4065
  );
3959
4066
  }
3960
- atomicPaths.add(fullPath);
4067
+ nestedPaths.add(fullPath);
3961
4068
  }
3962
4069
  }
3963
- return atomicPaths;
4070
+ return nestedPaths;
3964
4071
  }
3965
4072
 
3966
4073
  private static getSerializedPersistedRoots(): Set<string> {
@@ -4297,6 +4404,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4297
4404
  }
4298
4405
  const declaredRoots = (this as any).getDeclaredPersistedRoots() as Set<string>;
4299
4406
  const declaredAtomicPaths = (this as any).getDeclaredAtomicPaths() as Set<string>;
4407
+ const declaredArrayPaths = (this as any).getDeclaredArrayPaths() as Set<string>;
4300
4408
  const identityValueTypes = (this as any).getDeclaredIdentityValueTypes() as Map<
4301
4409
  string,
4302
4410
  TSmartdataIdentityValueType
@@ -4332,6 +4440,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4332
4440
  updateArg,
4333
4441
  declaredRoots,
4334
4442
  declaredAtomicPaths,
4443
+ declaredArrayPaths,
4335
4444
  uniqueRoots,
4336
4445
  serializedRoots,
4337
4446
  upserting ? { identityValueTypes } : undefined,
@@ -4407,6 +4516,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4407
4516
  const policy = getOrdinaryPersistencePolicy(this);
4408
4517
  const declaredRoots = (this as any).getDeclaredPersistedRoots() as Set<string>;
4409
4518
  const declaredAtomicPaths = (this as any).getDeclaredAtomicPaths() as Set<string>;
4519
+ const declaredArrayPaths = (this as any).getDeclaredArrayPaths() as Set<string>;
4410
4520
  const identityValueTypes = (this as any).getDeclaredIdentityValueTypes() as Map<
4411
4521
  string,
4412
4522
  TSmartdataIdentityValueType
@@ -4433,6 +4543,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4433
4543
  updateArg,
4434
4544
  declaredRoots,
4435
4545
  declaredAtomicPaths,
4546
+ declaredArrayPaths,
4436
4547
  uniqueRoots,
4437
4548
  serializedRoots,
4438
4549
  normalizedOptions.upsert ? { identityValueTypes } : undefined,
@@ -4763,6 +4874,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4763
4874
  const timeoutMS = requireBoundedInteger(opts?.timeoutMS, 'Atomic update-many timeoutMS', 120_000);
4764
4875
  const declaredRoots = (this as any).getDeclaredPersistedRoots() as Set<string>;
4765
4876
  const declaredAtomicPaths = (this as any).getDeclaredAtomicPaths() as Set<string>;
4877
+ const declaredArrayPaths = (this as any).getDeclaredArrayPaths() as Set<string>;
4766
4878
  const uniqueRoots = (this as any).getDeclaredUniqueRoots() as Set<string>;
4767
4879
  const identityValueTypes = (this as any).getDeclaredIdentityValueTypes() as Map<
4768
4880
  string,
@@ -4783,6 +4895,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4783
4895
  updateArg,
4784
4896
  declaredRoots,
4785
4897
  declaredAtomicPaths,
4898
+ declaredArrayPaths,
4786
4899
  uniqueRoots,
4787
4900
  serializedRoots,
4788
4901
  );
@@ -4873,6 +4986,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4873
4986
  );
4874
4987
  const declaredRoots = (this as any).getDeclaredPersistedRoots() as Set<string>;
4875
4988
  const declaredAtomicPaths = (this as any).getDeclaredAtomicPaths() as Set<string>;
4989
+ const declaredArrayPaths = (this as any).getDeclaredArrayPaths() as Set<string>;
4876
4990
  const identityValueTypes = (this as any).getDeclaredIdentityValueTypes() as Map<
4877
4991
  string,
4878
4992
  TSmartdataIdentityValueType
@@ -4921,6 +5035,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4921
5035
  operation.update as ISmartdataAtomicUpdate<T>,
4922
5036
  declaredRoots,
4923
5037
  declaredAtomicPaths,
5038
+ declaredArrayPaths,
4924
5039
  uniqueRoots,
4925
5040
  serializedRoots,
4926
5041
  { identityValueTypes },
@@ -4976,18 +5091,24 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4976
5091
 
4977
5092
  /**
4978
5093
  * Computes bounded grouped counts and optional numeric sums server-side.
4979
- * Groups by one or two declared top-level fields, always returns a `count`
4980
- * per group, and sums only declared numeric fields. The `$limit` follows
4981
- * the group stage without a sort, so when more groups exist than the limit
4982
- * the returned subset is arbitrary. Use `getGroupedTotalsPage()` when the
4983
- * 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.
4984
5102
  */
4985
- public static async getGroupedTotals<T>(
5103
+ public static async getGroupedTotals<
5104
+ T,
5105
+ TUnwind extends TSmartdataGroupedTotalsUnwindPath<T> = never,
5106
+ >(
4986
5107
  this: plugins.tsclass.typeFest.Class<T>,
4987
- optionsArg: ISmartdataGroupedTotalsOptions<T>,
4988
- ): Promise<Array<ISmartdataGroupedTotalsRow<T>>> {
5108
+ optionsArg: ISmartdataGroupedTotalsOptions<T, TUnwind>,
5109
+ ): Promise<Array<ISmartdataGroupedTotalsRow<T, TUnwind>>> {
4989
5110
  const page = await (this as any).getGroupedTotalsPage(optionsArg) as
4990
- ISmartdataGroupedTotalsPage<T>;
5111
+ ISmartdataGroupedTotalsPage<T, TUnwind>;
4991
5112
  return page.rows;
4992
5113
  }
4993
5114
 
@@ -4996,10 +5117,13 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4996
5117
  * reports whether at least one additional group exists beyond the requested
4997
5118
  * row limit.
4998
5119
  */
4999
- public static async getGroupedTotalsPage<T>(
5120
+ public static async getGroupedTotalsPage<
5121
+ T,
5122
+ TUnwind extends TSmartdataGroupedTotalsUnwindPath<T> = never,
5123
+ >(
5000
5124
  this: plugins.tsclass.typeFest.Class<T>,
5001
- optionsArg: ISmartdataGroupedTotalsOptions<T>,
5002
- ): Promise<ISmartdataGroupedTotalsPage<T>> {
5125
+ optionsArg: ISmartdataGroupedTotalsOptions<T, TUnwind>,
5126
+ ): Promise<ISmartdataGroupedTotalsPage<T, TUnwind>> {
5003
5127
  if ((this as any)[exactPersistencePolicySymbol]) {
5004
5128
  throw new SmartdataPersistenceError(
5005
5129
  'unsupported_operation',
@@ -5019,6 +5143,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5019
5143
  }
5020
5144
  const allowedKeys = new Set([
5021
5145
  'groupBy',
5146
+ 'unwind',
5022
5147
  'sums',
5023
5148
  'filter',
5024
5149
  'limit',
@@ -5047,6 +5172,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5047
5172
  }
5048
5173
  }
5049
5174
  const groupByOption = optionsArg.groupBy;
5175
+ const unwindOption = optionsArg.unwind;
5050
5176
  const sumsOption = optionsArg.sums;
5051
5177
  const filterOption = optionsArg.filter;
5052
5178
  const limitOption = optionsArg.limit;
@@ -5059,6 +5185,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5059
5185
  signalOption?.throwIfAborted();
5060
5186
  const sessionOption = optionsArg.session;
5061
5187
  const declaredRoots = (this as any).getDeclaredPersistedRoots() as Set<string>;
5188
+ const declaredNestedPaths = (this as any).getDeclaredAtomicPaths() as Set<string>;
5062
5189
  const serializedRoots = (this as any).getSerializedPersistedRoots() as Set<string>;
5063
5190
  const requireDeclaredGroupField = (
5064
5191
  fieldArg: unknown,
@@ -5067,23 +5194,22 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5067
5194
  if (typeof fieldArg !== 'string') {
5068
5195
  throw new SmartdataPersistenceError(
5069
5196
  'invalid_argument',
5070
- `${labelArg} must name a declared top-level field.`,
5071
- );
5072
- }
5073
- const root = requireDeclaredPath(fieldArg, declaredRoots, labelArg);
5074
- if (root !== fieldArg) {
5075
- throw new SmartdataPersistenceError(
5076
- 'invalid_argument',
5077
- `${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.`,
5078
5198
  );
5079
5199
  }
5200
+ const root = requireDeclaredPath(
5201
+ fieldArg,
5202
+ declaredRoots,
5203
+ labelArg,
5204
+ declaredNestedPaths,
5205
+ );
5080
5206
  if (serializedRoots.has(root)) {
5081
5207
  throw new SmartdataPersistenceError(
5082
5208
  'unsupported_operation',
5083
5209
  `${labelArg} cannot target serialized field "${root}".`,
5084
5210
  );
5085
5211
  }
5086
- return root;
5212
+ return fieldArg;
5087
5213
  };
5088
5214
  if (
5089
5215
  !Array.isArray(groupByOption)
@@ -5101,11 +5227,14 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5101
5227
  if (
5102
5228
  groupByFields.length < 1
5103
5229
  || groupByFields.length > 2
5104
- || new Set(groupByFields).size !== groupByFields.length
5230
+ || (
5231
+ groupByFields.length === 2
5232
+ && pathsConflict(groupByFields[0], groupByFields[1])
5233
+ )
5105
5234
  ) {
5106
5235
  throw new SmartdataPersistenceError(
5107
5236
  'invalid_argument',
5108
- '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.',
5109
5238
  );
5110
5239
  }
5111
5240
  if (sumsOption !== undefined && !Array.isArray(sumsOption)) {
@@ -5123,6 +5252,27 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5123
5252
  'Grouped totals sum fields must be distinct.',
5124
5253
  );
5125
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
+ }
5126
5276
  const limit = requireBoundedInteger(
5127
5277
  limitOption,
5128
5278
  'Grouped totals limit',
@@ -5154,11 +5304,17 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5154
5304
  + `call, such as groupBy: ["${groupByFields[0]}"].`,
5155
5305
  );
5156
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;
5157
5310
  const groupStage: Record<string, unknown> = {
5158
5311
  _id: groupByFields.length === 1
5159
5312
  ? '$' + groupByFields[0]
5160
5313
  : Object.fromEntries(
5161
- groupByFields.map((fieldArg) => [fieldArg, '$' + fieldArg]),
5314
+ groupByFields.map((fieldArg, indexArg) => [
5315
+ compositeKeyName(indexArg),
5316
+ '$' + fieldArg,
5317
+ ]),
5162
5318
  ),
5163
5319
  __groupedTotalsCount: { $sum: 1 },
5164
5320
  };
@@ -5176,6 +5332,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5176
5332
  ...(maxTimeMS !== undefined ? { maxTimeMS } : {}),
5177
5333
  ...(timeoutMS !== undefined ? { timeoutMS } : {}),
5178
5334
  ...(signalOption !== undefined ? { signal: signalOption } : {}),
5335
+ ...(unwindField !== undefined ? { unwindPath: unwindField } : {}),
5179
5336
  },
5180
5337
  );
5181
5338
  const hasMore = rows.length > limit;
@@ -5198,10 +5355,10 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5198
5355
  group[groupByFields[0]] = rowArg._id ?? null;
5199
5356
  } else {
5200
5357
  const compoundKey = (rowArg._id ?? {}) as Record<string, unknown>;
5201
- for (const fieldArg of groupByFields) {
5358
+ for (const [index, fieldArg] of groupByFields.entries()) {
5202
5359
  // Missing and explicit-null values merge into one group that is
5203
5360
  // reported as null in both the single-field and compound shapes.
5204
- group[fieldArg] = compoundKey[fieldArg] ?? null;
5361
+ group[fieldArg] = compoundKey[compositeKeyName(index)] ?? null;
5205
5362
  }
5206
5363
  }
5207
5364
  const sums: Record<string, number> = {};
@@ -5218,7 +5375,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5218
5375
  'Grouped totals count',
5219
5376
  ),
5220
5377
  sums,
5221
- } as ISmartdataGroupedTotalsRow<T>;
5378
+ } as ISmartdataGroupedTotalsRow<T, TUnwind>;
5222
5379
  });
5223
5380
  return {
5224
5381
  rows: mappedRows,