@lossless.org/client 1.14.0 → 1.16.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,52 @@ 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
+ * Aggregates per element of one array instead of per document: names a
821
+ * declared top-level array field or a nested path declared through
822
+ * `@svDb({ arrayPaths })`, which is itself a `groupBy` or `sums` path or
823
+ * holds the element fields that `groupBy` and `sums` paths name below it
824
+ * (`lines.accountNumber` below `unwind: 'lines'`). Element fields are
825
+ * declared nested paths like any other, through `@svDb({ atomicPaths })`
826
+ * on the array's field. Every element contributes on its own, so `count`
827
+ * counts elements and a document contributes its `sums` once per element;
828
+ * a document whose array is missing, null or empty contributes nothing.
829
+ * The `filter` still selects whole documents before the array is unwound,
830
+ * and no `groupBy` or `sums` path may contain the unwound array.
788
831
  */
789
- sums?: ReadonlyArray<TSmartdataNumericDocumentPath<T>>;
832
+ unwind?: TUnwind;
833
+ /**
834
+ * Declared numeric top-level fields, or declared nested paths holding
835
+ * numbers, to sum per group.
836
+ */
837
+ sums?: ReadonlyArray<
838
+ TSmartdataNumericDocumentPath<T> | TSmartdataAtomicNestedDocumentPath<T>
839
+ >;
790
840
  filter?: MongoFilter<T>;
791
841
  /**
792
842
  * Required bound on the number of returned groups (1..10000). The group
@@ -807,18 +857,37 @@ export interface ISmartdataGroupedTotalsOptions<T> {
807
857
  session?: TSmartdataOrdinarySession;
808
858
  }
809
859
 
810
- export interface ISmartdataGroupedTotalsRow<T> {
860
+ export interface ISmartdataGroupedTotalsRow<
861
+ T,
862
+ TUnwind extends TSmartdataGroupedTotalsUnwindPath<T> = never,
863
+ > {
811
864
  /**
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.
865
+ * The group's key values, keyed by the `groupBy` paths as given. Documents
866
+ * missing a group path and documents holding an explicit null merge into
867
+ * one group reported as null. An unwound top-level array reports the
868
+ * element; nested paths report the stored value untyped.
814
869
  */
815
- group: { [TKey in TSmartdataDocumentPath<T>]?: T[TKey] | null };
870
+ group:
871
+ & {
872
+ [TKey in TSmartdataDocumentPath<T>]?:
873
+ | (TKey extends TUnwind ? TSmartdataAtomicArrayElement<T[TKey]> : T[TKey])
874
+ | null;
875
+ }
876
+ & Partial<Record<TSmartdataAtomicNestedDocumentPath<T>, unknown>>;
816
877
  count: number;
817
- sums: Partial<Record<TSmartdataNumericDocumentPath<T>, number>>;
878
+ sums: Partial<
879
+ Record<
880
+ TSmartdataNumericDocumentPath<T> | TSmartdataAtomicNestedDocumentPath<T>,
881
+ number
882
+ >
883
+ >;
818
884
  }
819
885
 
820
- export interface ISmartdataGroupedTotalsPage<T> {
821
- rows: Array<ISmartdataGroupedTotalsRow<T>>;
886
+ export interface ISmartdataGroupedTotalsPage<
887
+ T,
888
+ TUnwind extends TSmartdataGroupedTotalsUnwindPath<T> = never,
889
+ > {
890
+ rows: Array<ISmartdataGroupedTotalsRow<T, TUnwind>>;
822
891
  hasMore: boolean;
823
892
  }
824
893
 
@@ -830,12 +899,19 @@ export type TSmartdataAtomicArrayPath<T> = {
830
899
  type TSmartdataAtomicArrayElement<TValue> =
831
900
  NonNullable<TValue> extends ReadonlyArray<infer TElement> ? TElement : never;
832
901
 
833
- type TSmartdataAtomicArrayOperation<T> = Partial<{
834
- [TKey in TSmartdataAtomicArrayPath<T>]: TSmartdataAtomicArrayElement<T[TKey]>;
835
- }>;
902
+ /**
903
+ * Array operators take a declared top-level array field, typed by its element,
904
+ * or a nested path below a declared field. A nested path is accepted at run
905
+ * time only when the model declares it through `@svDb({ arrayPaths })`.
906
+ */
907
+ type TSmartdataAtomicArrayOperation<T> =
908
+ & Partial<{
909
+ [TKey in TSmartdataAtomicArrayPath<T>]: TSmartdataAtomicArrayElement<T[TKey]>;
910
+ }>
911
+ & Partial<Record<TSmartdataAtomicNestedDocumentPath<T>, unknown>>;
836
912
 
837
- type TSmartdataAtomicPullScalar<TValue> = Extract<
838
- TValue,
913
+ /** The scalar equality operands `$pull` accepts. */
914
+ type TSmartdataAtomicPullScalarValue =
839
915
  | string
840
916
  | number
841
917
  | boolean
@@ -847,14 +923,22 @@ type TSmartdataAtomicPullScalar<TValue> = Extract<
847
923
  | plugins.mongodb.Long
848
924
  | plugins.mongodb.Int32
849
925
  | plugins.mongodb.Double
850
- | plugins.mongodb.Timestamp
926
+ | plugins.mongodb.Timestamp;
927
+
928
+ type TSmartdataAtomicPullScalar<TValue> = Extract<
929
+ TValue,
930
+ TSmartdataAtomicPullScalarValue
851
931
  >;
852
932
 
853
- type TSmartdataAtomicArrayPullOperation<T> = Partial<{
854
- [TKey in TSmartdataAtomicArrayPath<T>]: TSmartdataAtomicPullScalar<
855
- TSmartdataAtomicArrayElement<T[TKey]>
933
+ type TSmartdataAtomicArrayPullOperation<T> =
934
+ & Partial<{
935
+ [TKey in TSmartdataAtomicArrayPath<T>]: TSmartdataAtomicPullScalar<
936
+ TSmartdataAtomicArrayElement<T[TKey]>
937
+ >;
938
+ }>
939
+ & Partial<
940
+ Record<TSmartdataAtomicNestedDocumentPath<T>, TSmartdataAtomicPullScalarValue>
856
941
  >;
857
- }>;
858
942
 
859
943
  export interface ISmartdataAtomicUpdate<T> {
860
944
  $set?: TSmartdataAtomicSet<T>;
@@ -876,19 +960,21 @@ export interface ISmartdataAtomicUpdate<T> {
876
960
  $min?: TSmartdataAtomicMonotonic<T>;
877
961
  $setOnInsert?: TSmartdataAtomicSet<T>;
878
962
  /**
879
- * Appends one element to a declared top-level array field. Modifier
880
- * documents ($each, $position, $slice, $sort) are rejected.
963
+ * Appends one element to a declared top-level array field or a nested
964
+ * path declared through `@svDb({ arrayPaths })`. Modifier documents
965
+ * ($each, $position, $slice, $sort) are rejected.
881
966
  */
882
967
  $push?: TSmartdataAtomicArrayOperation<T>;
883
968
  /**
884
- * Adds one element to a declared top-level array field unless an equal
885
- * element is already present. Modifier documents are rejected.
969
+ * Adds one element to a declared top-level array field or a declared
970
+ * nested array path unless an equal element is already present. Modifier
971
+ * documents are rejected.
886
972
  */
887
973
  $addToSet?: TSmartdataAtomicArrayOperation<T>;
888
974
  /**
889
975
  * 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.
976
+ * top-level array field or a declared nested array path. Operator
977
+ * documents, plain objects, arrays, and regular expressions are rejected.
892
978
  */
893
979
  $pull?: TSmartdataAtomicArrayPullOperation<T>;
894
980
  }
@@ -3051,6 +3137,8 @@ const normalizeAtomicUpdate = <T>(
3051
3137
  updateArg: ISmartdataAtomicUpdate<T>,
3052
3138
  declaredRootsArg: Set<string>,
3053
3139
  declaredNestedPathsArg: Set<string>,
3140
+ /** Nested paths declared through `@svDb({ arrayPaths })`; array operators may target them. */
3141
+ declaredArrayPathsArg: Set<string>,
3054
3142
  uniqueRootsArg: Set<string>,
3055
3143
  serializedRootsArg: Set<string>,
3056
3144
  /**
@@ -3164,10 +3252,13 @@ const normalizeAtomicUpdate = <T>(
3164
3252
  }
3165
3253
  occupiedPaths.push({ path, operator });
3166
3254
  if (arrayOperators.has(operator)) {
3167
- if (path !== root) {
3255
+ if (path !== root && !declaredArrayPathsArg.has(path)) {
3256
+ // The shape of a nested value is known only from its declaration, so
3257
+ // a nested path that is not declared as an array is refused rather
3258
+ // than left to fail on the stored value.
3168
3259
  throw new SmartdataPersistenceError(
3169
3260
  'invalid_argument',
3170
- `${operator} may only target declared top-level array fields; "${path}" is a nested path.`,
3261
+ `${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
3262
  );
3172
3263
  }
3173
3264
  if (value === undefined) {
@@ -3903,21 +3994,41 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
3903
3994
  return keySets;
3904
3995
  }
3905
3996
 
3997
+ /**
3998
+ * Every nested path the model declares below a persisted field, through
3999
+ * `@svDb({ atomicPaths })` or `@svDb({ arrayPaths })`.
4000
+ */
3906
4001
  private static getDeclaredAtomicPaths(): Set<string> {
4002
+ return new Set([
4003
+ ...(this as any).readDeclaredNestedPaths('atomicPaths') as Set<string>,
4004
+ ...(this as any).readDeclaredNestedPaths('arrayPaths') as Set<string>,
4005
+ ]);
4006
+ }
4007
+
4008
+ /** The nested paths declared through `@svDb({ arrayPaths })` as holding arrays. */
4009
+ private static getDeclaredArrayPaths(): Set<string> {
4010
+ return (this as any).readDeclaredNestedPaths('arrayPaths') as Set<string>;
4011
+ }
4012
+
4013
+ private static readDeclaredNestedPaths(
4014
+ declarationArg: 'atomicPaths' | 'arrayPaths',
4015
+ ): Set<string> {
3907
4016
  const declaredRoots = (this as any).getDeclaredPersistedRoots() as Set<string>;
3908
4017
  const uniqueRoots = (this as any).getDeclaredUniqueRoots() as Set<string>;
3909
4018
  const options = (this as any)._svDbOptions as
3910
4019
  | Record<string, SvDbOptions>
3911
4020
  | undefined;
3912
- const atomicPaths = new Set<string>();
4021
+ const label = declarationArg === 'atomicPaths' ? 'Atomic' : 'Array';
4022
+ const nestedPaths = new Set<string>();
3913
4023
  for (const [root, fieldOptions] of Object.entries(options || {})) {
3914
- if (fieldOptions.atomicPaths === undefined) {
4024
+ const relativePaths = fieldOptions[declarationArg];
4025
+ if (relativePaths === undefined) {
3915
4026
  continue;
3916
4027
  }
3917
- if (!Array.isArray(fieldOptions.atomicPaths)) {
4028
+ if (!Array.isArray(relativePaths)) {
3918
4029
  throw new SmartdataPersistenceError(
3919
4030
  'invalid_configuration',
3920
- `Atomic paths for persisted field "${root}" must be an array.`,
4031
+ `${label} paths for persisted field "${root}" must be an array.`,
3921
4032
  );
3922
4033
  }
3923
4034
  if (
@@ -3926,20 +4037,20 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
3926
4037
  ) {
3927
4038
  throw new SmartdataPersistenceError(
3928
4039
  'invalid_configuration',
3929
- `Serialized persisted field "${root}" may not declare atomic nested paths.`,
4040
+ `Serialized persisted field "${root}" may not declare ${label.toLowerCase()} nested paths.`,
3930
4041
  );
3931
4042
  }
3932
4043
  if (uniqueRoots.has(root)) {
3933
4044
  throw new SmartdataPersistenceError(
3934
4045
  'invalid_configuration',
3935
- `Immutable @unI() field "${root}" may not declare atomic nested paths.`,
4046
+ `Immutable @unI() field "${root}" may not declare ${label.toLowerCase()} nested paths.`,
3936
4047
  );
3937
4048
  }
3938
- for (const relativePath of fieldOptions.atomicPaths) {
4049
+ for (const relativePath of relativePaths) {
3939
4050
  if (typeof relativePath !== 'string' || relativePath.length === 0) {
3940
4051
  throw new SmartdataPersistenceError(
3941
4052
  'invalid_configuration',
3942
- `Atomic paths for persisted field "${root}" must be non-empty strings.`,
4053
+ `${label} paths for persisted field "${root}" must be non-empty strings.`,
3943
4054
  );
3944
4055
  }
3945
4056
  const fullPath = `${root}.${relativePath}`;
@@ -3947,20 +4058,20 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
3947
4058
  requireDeclaredPath(
3948
4059
  fullPath,
3949
4060
  declaredRoots,
3950
- 'Atomic path declaration',
4061
+ `${label} path declaration`,
3951
4062
  new Set([fullPath]),
3952
4063
  );
3953
4064
  } catch (errorArg) {
3954
4065
  throw new SmartdataPersistenceError(
3955
4066
  'invalid_configuration',
3956
- `Atomic path declaration "${fullPath}" is invalid.`,
4067
+ `${label} path declaration "${fullPath}" is invalid.`,
3957
4068
  { cause: errorArg },
3958
4069
  );
3959
4070
  }
3960
- atomicPaths.add(fullPath);
4071
+ nestedPaths.add(fullPath);
3961
4072
  }
3962
4073
  }
3963
- return atomicPaths;
4074
+ return nestedPaths;
3964
4075
  }
3965
4076
 
3966
4077
  private static getSerializedPersistedRoots(): Set<string> {
@@ -4297,6 +4408,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4297
4408
  }
4298
4409
  const declaredRoots = (this as any).getDeclaredPersistedRoots() as Set<string>;
4299
4410
  const declaredAtomicPaths = (this as any).getDeclaredAtomicPaths() as Set<string>;
4411
+ const declaredArrayPaths = (this as any).getDeclaredArrayPaths() as Set<string>;
4300
4412
  const identityValueTypes = (this as any).getDeclaredIdentityValueTypes() as Map<
4301
4413
  string,
4302
4414
  TSmartdataIdentityValueType
@@ -4332,6 +4444,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4332
4444
  updateArg,
4333
4445
  declaredRoots,
4334
4446
  declaredAtomicPaths,
4447
+ declaredArrayPaths,
4335
4448
  uniqueRoots,
4336
4449
  serializedRoots,
4337
4450
  upserting ? { identityValueTypes } : undefined,
@@ -4407,6 +4520,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4407
4520
  const policy = getOrdinaryPersistencePolicy(this);
4408
4521
  const declaredRoots = (this as any).getDeclaredPersistedRoots() as Set<string>;
4409
4522
  const declaredAtomicPaths = (this as any).getDeclaredAtomicPaths() as Set<string>;
4523
+ const declaredArrayPaths = (this as any).getDeclaredArrayPaths() as Set<string>;
4410
4524
  const identityValueTypes = (this as any).getDeclaredIdentityValueTypes() as Map<
4411
4525
  string,
4412
4526
  TSmartdataIdentityValueType
@@ -4433,6 +4547,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4433
4547
  updateArg,
4434
4548
  declaredRoots,
4435
4549
  declaredAtomicPaths,
4550
+ declaredArrayPaths,
4436
4551
  uniqueRoots,
4437
4552
  serializedRoots,
4438
4553
  normalizedOptions.upsert ? { identityValueTypes } : undefined,
@@ -4763,6 +4878,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4763
4878
  const timeoutMS = requireBoundedInteger(opts?.timeoutMS, 'Atomic update-many timeoutMS', 120_000);
4764
4879
  const declaredRoots = (this as any).getDeclaredPersistedRoots() as Set<string>;
4765
4880
  const declaredAtomicPaths = (this as any).getDeclaredAtomicPaths() as Set<string>;
4881
+ const declaredArrayPaths = (this as any).getDeclaredArrayPaths() as Set<string>;
4766
4882
  const uniqueRoots = (this as any).getDeclaredUniqueRoots() as Set<string>;
4767
4883
  const identityValueTypes = (this as any).getDeclaredIdentityValueTypes() as Map<
4768
4884
  string,
@@ -4783,6 +4899,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4783
4899
  updateArg,
4784
4900
  declaredRoots,
4785
4901
  declaredAtomicPaths,
4902
+ declaredArrayPaths,
4786
4903
  uniqueRoots,
4787
4904
  serializedRoots,
4788
4905
  );
@@ -4873,6 +4990,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4873
4990
  );
4874
4991
  const declaredRoots = (this as any).getDeclaredPersistedRoots() as Set<string>;
4875
4992
  const declaredAtomicPaths = (this as any).getDeclaredAtomicPaths() as Set<string>;
4993
+ const declaredArrayPaths = (this as any).getDeclaredArrayPaths() as Set<string>;
4876
4994
  const identityValueTypes = (this as any).getDeclaredIdentityValueTypes() as Map<
4877
4995
  string,
4878
4996
  TSmartdataIdentityValueType
@@ -4921,6 +5039,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4921
5039
  operation.update as ISmartdataAtomicUpdate<T>,
4922
5040
  declaredRoots,
4923
5041
  declaredAtomicPaths,
5042
+ declaredArrayPaths,
4924
5043
  uniqueRoots,
4925
5044
  serializedRoots,
4926
5045
  { identityValueTypes },
@@ -4976,18 +5095,25 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4976
5095
 
4977
5096
  /**
4978
5097
  * 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.
5098
+ * Groups by one or two declared paths — top-level fields or nested paths
5099
+ * declared through `@svDb({ atomicPaths })` or `@svDb({ arrayPaths })` —
5100
+ * 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.
4984
5107
  */
4985
- public static async getGroupedTotals<T>(
5108
+ public static async getGroupedTotals<
5109
+ T,
5110
+ TUnwind extends TSmartdataGroupedTotalsUnwindPath<T> = never,
5111
+ >(
4986
5112
  this: plugins.tsclass.typeFest.Class<T>,
4987
- optionsArg: ISmartdataGroupedTotalsOptions<T>,
4988
- ): Promise<Array<ISmartdataGroupedTotalsRow<T>>> {
5113
+ optionsArg: ISmartdataGroupedTotalsOptions<T, TUnwind>,
5114
+ ): Promise<Array<ISmartdataGroupedTotalsRow<T, TUnwind>>> {
4989
5115
  const page = await (this as any).getGroupedTotalsPage(optionsArg) as
4990
- ISmartdataGroupedTotalsPage<T>;
5116
+ ISmartdataGroupedTotalsPage<T, TUnwind>;
4991
5117
  return page.rows;
4992
5118
  }
4993
5119
 
@@ -4996,10 +5122,13 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4996
5122
  * reports whether at least one additional group exists beyond the requested
4997
5123
  * row limit.
4998
5124
  */
4999
- public static async getGroupedTotalsPage<T>(
5125
+ public static async getGroupedTotalsPage<
5126
+ T,
5127
+ TUnwind extends TSmartdataGroupedTotalsUnwindPath<T> = never,
5128
+ >(
5000
5129
  this: plugins.tsclass.typeFest.Class<T>,
5001
- optionsArg: ISmartdataGroupedTotalsOptions<T>,
5002
- ): Promise<ISmartdataGroupedTotalsPage<T>> {
5130
+ optionsArg: ISmartdataGroupedTotalsOptions<T, TUnwind>,
5131
+ ): Promise<ISmartdataGroupedTotalsPage<T, TUnwind>> {
5003
5132
  if ((this as any)[exactPersistencePolicySymbol]) {
5004
5133
  throw new SmartdataPersistenceError(
5005
5134
  'unsupported_operation',
@@ -5019,6 +5148,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5019
5148
  }
5020
5149
  const allowedKeys = new Set([
5021
5150
  'groupBy',
5151
+ 'unwind',
5022
5152
  'sums',
5023
5153
  'filter',
5024
5154
  'limit',
@@ -5047,6 +5177,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5047
5177
  }
5048
5178
  }
5049
5179
  const groupByOption = optionsArg.groupBy;
5180
+ const unwindOption = optionsArg.unwind;
5050
5181
  const sumsOption = optionsArg.sums;
5051
5182
  const filterOption = optionsArg.filter;
5052
5183
  const limitOption = optionsArg.limit;
@@ -5059,6 +5190,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5059
5190
  signalOption?.throwIfAborted();
5060
5191
  const sessionOption = optionsArg.session;
5061
5192
  const declaredRoots = (this as any).getDeclaredPersistedRoots() as Set<string>;
5193
+ const declaredNestedPaths = (this as any).getDeclaredAtomicPaths() as Set<string>;
5062
5194
  const serializedRoots = (this as any).getSerializedPersistedRoots() as Set<string>;
5063
5195
  const requireDeclaredGroupField = (
5064
5196
  fieldArg: unknown,
@@ -5067,23 +5199,22 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5067
5199
  if (typeof fieldArg !== 'string') {
5068
5200
  throw new SmartdataPersistenceError(
5069
5201
  '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.`,
5202
+ `${labelArg} must name a declared top-level field or a declared nested path.`,
5078
5203
  );
5079
5204
  }
5205
+ const root = requireDeclaredPath(
5206
+ fieldArg,
5207
+ declaredRoots,
5208
+ labelArg,
5209
+ declaredNestedPaths,
5210
+ );
5080
5211
  if (serializedRoots.has(root)) {
5081
5212
  throw new SmartdataPersistenceError(
5082
5213
  'unsupported_operation',
5083
5214
  `${labelArg} cannot target serialized field "${root}".`,
5084
5215
  );
5085
5216
  }
5086
- return root;
5217
+ return fieldArg;
5087
5218
  };
5088
5219
  if (
5089
5220
  !Array.isArray(groupByOption)
@@ -5101,11 +5232,14 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5101
5232
  if (
5102
5233
  groupByFields.length < 1
5103
5234
  || groupByFields.length > 2
5104
- || new Set(groupByFields).size !== groupByFields.length
5235
+ || (
5236
+ groupByFields.length === 2
5237
+ && pathsConflict(groupByFields[0], groupByFields[1])
5238
+ )
5105
5239
  ) {
5106
5240
  throw new SmartdataPersistenceError(
5107
5241
  'invalid_argument',
5108
- 'Grouped totals groupBy fields must be one or two distinct fields.',
5242
+ 'Grouped totals groupBy fields must be one or two distinct fields, neither nested in the other.',
5109
5243
  );
5110
5244
  }
5111
5245
  if (sumsOption !== undefined && !Array.isArray(sumsOption)) {
@@ -5123,6 +5257,53 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5123
5257
  'Grouped totals sum fields must be distinct.',
5124
5258
  );
5125
5259
  }
5260
+ let unwindField: string | undefined;
5261
+ if (unwindOption !== undefined) {
5262
+ if (typeof unwindOption !== 'string') {
5263
+ throw new SmartdataPersistenceError(
5264
+ 'invalid_argument',
5265
+ 'Grouped totals unwind must name a declared array path.',
5266
+ );
5267
+ }
5268
+ requireDeclaredGroupField(unwindOption, 'Grouped totals unwind path');
5269
+ const aggregatedFields = [...groupByFields, ...sumFields];
5270
+ // The unwound array either is a groupBy or sum path, or holds the
5271
+ // element fields a groupBy or sum path names below it.
5272
+ if (
5273
+ !aggregatedFields.some((fieldArg) =>
5274
+ fieldArg === unwindOption || fieldArg.startsWith(unwindOption + '.'),
5275
+ )
5276
+ ) {
5277
+ throw new SmartdataPersistenceError(
5278
+ 'invalid_argument',
5279
+ 'Grouped totals unwind must name a groupBy or sum path, or the array holding one of them.',
5280
+ );
5281
+ }
5282
+ // A path that contains the unwound array would read a document whose
5283
+ // array has been replaced by one element: neither the document's value
5284
+ // nor the element's.
5285
+ const containingField = aggregatedFields.find((fieldArg) =>
5286
+ unwindOption.startsWith(fieldArg + '.'),
5287
+ );
5288
+ if (containingField !== undefined) {
5289
+ throw new SmartdataPersistenceError(
5290
+ 'invalid_argument',
5291
+ `Grouped totals path "${containingField}" contains the unwound array "${unwindOption}".`,
5292
+ );
5293
+ }
5294
+ if (
5295
+ unwindOption.includes('.')
5296
+ && !((this as any).getDeclaredArrayPaths() as Set<string>).has(unwindOption)
5297
+ ) {
5298
+ // A nested value's shape is known only from its declaration; unwinding
5299
+ // a path not declared as an array would count scalars as elements.
5300
+ throw new SmartdataPersistenceError(
5301
+ 'invalid_argument',
5302
+ `Grouped totals unwind path "${unwindOption}" is not declared through @svDb({ arrayPaths }).`,
5303
+ );
5304
+ }
5305
+ unwindField = unwindOption;
5306
+ }
5126
5307
  const limit = requireBoundedInteger(
5127
5308
  limitOption,
5128
5309
  'Grouped totals limit',
@@ -5154,11 +5335,17 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5154
5335
  + `call, such as groupBy: ["${groupByFields[0]}"].`,
5155
5336
  );
5156
5337
  }
5338
+ // A composite key names its members by position: a group path may be
5339
+ // dotted, and an expression object's field names may not contain a dot.
5340
+ const compositeKeyName = (indexArg: number): string => 'k' + indexArg;
5157
5341
  const groupStage: Record<string, unknown> = {
5158
5342
  _id: groupByFields.length === 1
5159
5343
  ? '$' + groupByFields[0]
5160
5344
  : Object.fromEntries(
5161
- groupByFields.map((fieldArg) => [fieldArg, '$' + fieldArg]),
5345
+ groupByFields.map((fieldArg, indexArg) => [
5346
+ compositeKeyName(indexArg),
5347
+ '$' + fieldArg,
5348
+ ]),
5162
5349
  ),
5163
5350
  __groupedTotalsCount: { $sum: 1 },
5164
5351
  };
@@ -5176,6 +5363,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5176
5363
  ...(maxTimeMS !== undefined ? { maxTimeMS } : {}),
5177
5364
  ...(timeoutMS !== undefined ? { timeoutMS } : {}),
5178
5365
  ...(signalOption !== undefined ? { signal: signalOption } : {}),
5366
+ ...(unwindField !== undefined ? { unwindPath: unwindField } : {}),
5179
5367
  },
5180
5368
  );
5181
5369
  const hasMore = rows.length > limit;
@@ -5198,10 +5386,10 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5198
5386
  group[groupByFields[0]] = rowArg._id ?? null;
5199
5387
  } else {
5200
5388
  const compoundKey = (rowArg._id ?? {}) as Record<string, unknown>;
5201
- for (const fieldArg of groupByFields) {
5389
+ for (const [index, fieldArg] of groupByFields.entries()) {
5202
5390
  // Missing and explicit-null values merge into one group that is
5203
5391
  // reported as null in both the single-field and compound shapes.
5204
- group[fieldArg] = compoundKey[fieldArg] ?? null;
5392
+ group[fieldArg] = compoundKey[compositeKeyName(index)] ?? null;
5205
5393
  }
5206
5394
  }
5207
5395
  const sums: Record<string, number> = {};
@@ -5218,7 +5406,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
5218
5406
  'Grouped totals count',
5219
5407
  ),
5220
5408
  sums,
5221
- } as ISmartdataGroupedTotalsRow<T>;
5409
+ } as ISmartdataGroupedTotalsRow<T, TUnwind>;
5222
5410
  });
5223
5411
  return {
5224
5412
  rows: mappedRows,