@lossless.org/client 1.0.0 → 1.1.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.
@@ -17,6 +17,7 @@ import {
17
17
  type ISmartdataStoredEvidenceInspection,
18
18
  } from './classes.storedinspection.js';
19
19
  import {
20
+ getIdentityDocumentIdField,
20
21
  type IManager,
21
22
  type ISmartdataIndexInfo,
22
23
  SmartdataCollection,
@@ -25,7 +26,9 @@ import { SmartdataDbWatcher } from './classes.watcher.js';
25
26
  import { SmartdataLuceneAdapter } from './classes.lucene.adapter.js';
26
27
  import { executeAtomicDelete, executeAtomicDeleteMany } from './classes.atomicdelete.js';
27
28
  import { executeAtomicFindOneAndUpdate } from './classes.atomicfindoneandupdate.js';
28
- import { executeAtomicUpdate, executeAtomicUpdateMany } from './classes.atomicupdate.js';
29
+ import {
30
+ executeAtomicUpdate, executeAtomicUpdateMany, executeAtomicUpsertMany,
31
+ } from './classes.atomicupdate.js';
29
32
  import {
30
33
  SmartdataPersistenceError,
31
34
  } from './classes.persistence.js';
@@ -590,6 +593,23 @@ export type TSmartdataNumericDocumentPath<T> = {
590
593
  NonNullable<T[TKey]> extends number ? TKey : never;
591
594
  }[TSmartdataDocumentPath<T>];
592
595
 
596
+ /** Declared top-level fields that carry a monotonic ordering: numbers and dates. */
597
+ export type TSmartdataMonotonicDocumentPath<T> = {
598
+ [TKey in TSmartdataDocumentPath<T>]:
599
+ NonNullable<T[TKey]> extends number
600
+ ? TKey
601
+ : NonNullable<T[TKey]> extends Date
602
+ ? TKey
603
+ : never;
604
+ }[TSmartdataDocumentPath<T>];
605
+
606
+ type TSmartdataAtomicMonotonic<T> =
607
+ & Partial<{
608
+ [TKey in TSmartdataMonotonicDocumentPath<T>]:
609
+ NonNullable<T[TKey]> extends Date ? Date : number;
610
+ }>
611
+ & Partial<Record<TSmartdataAtomicNestedDocumentPath<T>, number | Date>>;
612
+
593
613
  export type TSmartdataAtomicNumericFieldReference<T> =
594
614
  `$${TSmartdataNumericDocumentPath<T>}`;
595
615
 
@@ -691,6 +711,18 @@ export interface ISmartdataAtomicUpdate<T> {
691
711
  Record<TSmartdataAtomicDocumentPath<T>, '' | true | 1>
692
712
  >;
693
713
  $inc?: TSmartdataAtomicIncrement<T>;
714
+ /**
715
+ * Monotonic ceiling for a declared numeric or date field: one server-side
716
+ * compare-and-write that never lowers the stored value, so a retried or
717
+ * out-of-order writer cannot regress a counter. With `upsert: true` a
718
+ * missing field is inserted with the operand.
719
+ */
720
+ $max?: TSmartdataAtomicMonotonic<T>;
721
+ /**
722
+ * Monotonic floor for a declared numeric or date field: the mirror of
723
+ * `$max`, never raising the stored value.
724
+ */
725
+ $min?: TSmartdataAtomicMonotonic<T>;
694
726
  $setOnInsert?: TSmartdataAtomicSet<T>;
695
727
  /**
696
728
  * Appends one element to a declared top-level array field. Modifier
@@ -860,6 +892,30 @@ export interface ISmartdataAtomicUpdateManyResult {
860
892
  modifiedCount: number;
861
893
  }
862
894
 
895
+ /** One strict selector and its update, both validated before any write. */
896
+ export interface ISmartdataAtomicUpsertManyOperation<T> {
897
+ filter: TSmartdataAtomicFilter<T>;
898
+ update: ISmartdataAtomicUpdate<T>;
899
+ }
900
+
901
+ export interface ISmartdataAtomicUpsertManyOptions {
902
+ session?: TSmartdataOrdinarySession;
903
+ /** Client-side bulk operation deadline, including server selection and pool waits. */
904
+ timeoutMS?: number;
905
+ /**
906
+ * Only `false`. The batch is always unordered, so one conflicting entry
907
+ * never hides the outcome of the entries behind it.
908
+ */
909
+ ordered?: false;
910
+ }
911
+
912
+ export interface ISmartdataAtomicUpsertManyResult {
913
+ acknowledged: boolean;
914
+ matchedCount: number;
915
+ modifiedCount: number;
916
+ upsertedCount: number;
917
+ }
918
+
863
919
  const normalizeAtomicFindOneAndUpdateOptions = <
864
920
  TReturnDocument extends TSmartdataAtomicReturnDocument,
865
921
  >(
@@ -2255,6 +2311,7 @@ const normalizeStrictFilter = (
2255
2311
  operationLabelArg:
2256
2312
  | 'Atomic update'
2257
2313
  | 'Atomic update-many'
2314
+ | 'Atomic upsert-many'
2258
2315
  | 'Atomic find-one-and-update'
2259
2316
  | 'Atomic delete'
2260
2317
  | 'Atomic delete-many',
@@ -2533,6 +2590,15 @@ const normalizeAtomicUpdate = <T>(
2533
2590
  declaredNestedPathsArg: Set<string>,
2534
2591
  uniqueRootsArg: Set<string>,
2535
2592
  serializedRootsArg: Set<string>,
2593
+ /**
2594
+ * Supplied only by upserting operations. Its presence allows `$setOnInsert`
2595
+ * to seed declared identity roots on the insert branch; identities stay
2596
+ * immutable on the update branch because MongoDB never applies
2597
+ * `$setOnInsert` to an existing document.
2598
+ */
2599
+ identitySeedingArg?: {
2600
+ identityValueTypes: ReadonlyMap<string, TSmartdataIdentityValueType>;
2601
+ },
2536
2602
  ): plugins.mongodb.UpdateFilter<plugins.mongodb.Document> => {
2537
2603
  if (
2538
2604
  typeof updateArg !== 'object' ||
@@ -2552,8 +2618,11 @@ const normalizeAtomicUpdate = <T>(
2552
2618
  '$push',
2553
2619
  '$addToSet',
2554
2620
  '$pull',
2621
+ '$max',
2622
+ '$min',
2555
2623
  ]);
2556
2624
  const arrayOperators = new Set(['$push', '$addToSet', '$pull']);
2625
+ const monotonicOperators = new Set(['$max', '$min']);
2557
2626
  for (const operator of Object.keys(updateArg)) {
2558
2627
  if (!allowedOperators.has(operator)) {
2559
2628
  throw new SmartdataPersistenceError(
@@ -2595,9 +2664,30 @@ const normalizeAtomicUpdate = <T>(
2595
2664
  );
2596
2665
  }
2597
2666
  if (uniqueRootsArg.has(root)) {
2598
- throw new SmartdataPersistenceError(
2599
- 'invalid_argument',
2600
- `${operator} may not modify immutable @unI() field "${root}".`,
2667
+ if (operator !== '$setOnInsert') {
2668
+ throw new SmartdataPersistenceError(
2669
+ 'invalid_argument',
2670
+ `${operator} may not modify immutable @unI() field "${root}".`,
2671
+ );
2672
+ }
2673
+ if (!identitySeedingArg) {
2674
+ throw new SmartdataPersistenceError(
2675
+ 'invalid_argument',
2676
+ `$setOnInsert may not seed immutable @unI() field "${root}" without upsert: true.`,
2677
+ );
2678
+ }
2679
+ if (path !== root) {
2680
+ throw new SmartdataPersistenceError(
2681
+ 'invalid_argument',
2682
+ `$setOnInsert may not target a nested path below immutable @unI() field "${root}".`,
2683
+ );
2684
+ }
2685
+ // The insert branch must create a complete, addressable document, so
2686
+ // the seeded value passes the same identity validation an insert does.
2687
+ requireUsableIdentityValue(
2688
+ value,
2689
+ `$setOnInsert identity field "${root}"`,
2690
+ identitySeedingArg.identityValueTypes.get(root) ?? 'string',
2601
2691
  );
2602
2692
  }
2603
2693
  const conflict = occupiedPaths.find((occupiedPath) =>
@@ -2688,6 +2778,34 @@ const normalizeAtomicUpdate = <T>(
2688
2778
  `${operator} path "${path}" may not be undefined.`,
2689
2779
  );
2690
2780
  }
2781
+ if (monotonicOperators.has(operator)) {
2782
+ if (typeof value === 'number') {
2783
+ if (!Number.isFinite(value)) {
2784
+ throw new SmartdataPersistenceError(
2785
+ 'invalid_argument',
2786
+ `${operator} path "${path}" must be a finite number or a Date.`,
2787
+ );
2788
+ }
2789
+ normalizedOperation[path] = value;
2790
+ continue;
2791
+ }
2792
+ if (
2793
+ !(value instanceof Date)
2794
+ || !Number.isFinite(value.getTime())
2795
+ ) {
2796
+ throw new SmartdataPersistenceError(
2797
+ 'invalid_argument',
2798
+ `${operator} path "${path}" must be a finite number or a Date.`,
2799
+ );
2800
+ }
2801
+ // A custom encoder could otherwise rewrite the comparison operand at
2802
+ // serialization time, so the date is captured as an inert snapshot.
2803
+ normalizedOperation[path] = createInertAtomicBsonScalarSnapshot(
2804
+ value,
2805
+ `${operator} path "${path}"`,
2806
+ );
2807
+ continue;
2808
+ }
2691
2809
  if (
2692
2810
  operator === '$inc' &&
2693
2811
  (typeof value !== 'number' || !Number.isFinite(value))
@@ -2714,6 +2832,151 @@ const normalizeAtomicUpdate = <T>(
2714
2832
  return normalized as plugins.mongodb.UpdateFilter<plugins.mongodb.Document>;
2715
2833
  };
2716
2834
 
2835
+ /**
2836
+ * Reads a globally constraining equality value for one declared path from an
2837
+ * already normalized filter. `$or` branches are excluded because an equality
2838
+ * inside a single branch does not pin the matched document.
2839
+ */
2840
+ const readNormalizedFilterEquality = (
2841
+ filterArg: Record<string, unknown>,
2842
+ pathArg: string,
2843
+ ): { found: boolean; value?: unknown } => {
2844
+ for (const [key, value] of Object.entries(filterArg)) {
2845
+ if (key === '$and' && Array.isArray(value)) {
2846
+ for (const entry of value) {
2847
+ if (isPlainObject(entry)) {
2848
+ const nested = readNormalizedFilterEquality(
2849
+ entry as Record<string, unknown>,
2850
+ pathArg,
2851
+ );
2852
+ if (nested.found) {
2853
+ return nested;
2854
+ }
2855
+ }
2856
+ }
2857
+ continue;
2858
+ }
2859
+ if (key !== pathArg) {
2860
+ continue;
2861
+ }
2862
+ if (isPlainObject(value)) {
2863
+ const entries = Object.entries(value as Record<string, unknown>);
2864
+ if (entries.length === 1 && entries[0][0] === '$eq') {
2865
+ return { found: true, value: entries[0][1] };
2866
+ }
2867
+ continue;
2868
+ }
2869
+ return { found: true, value };
2870
+ }
2871
+ return { found: false };
2872
+ };
2873
+
2874
+ /**
2875
+ * An upsert seeds its inserted document from the filter's equality conditions
2876
+ * and from `$setOnInsert`. When both name the same identity they must agree,
2877
+ * otherwise the insert branch would create a document its own selector can
2878
+ * never match again.
2879
+ */
2880
+ const assertSeededIdentitiesMatchFilter = (
2881
+ normalizedFilterArg: Record<string, unknown>,
2882
+ normalizedUpdateArg: Record<string, unknown>,
2883
+ uniqueRootsArg: Set<string>,
2884
+ operationLabelArg: string,
2885
+ ): void => {
2886
+ const seeded = normalizedUpdateArg.$setOnInsert;
2887
+ if (!isPlainObject(seeded)) {
2888
+ return;
2889
+ }
2890
+ for (const [path, value] of Object.entries(seeded as Record<string, unknown>)) {
2891
+ if (!uniqueRootsArg.has(path)) {
2892
+ continue;
2893
+ }
2894
+ const anchored = readNormalizedFilterEquality(normalizedFilterArg, path);
2895
+ if (anchored.found && anchored.value !== value) {
2896
+ throw new SmartdataPersistenceError(
2897
+ 'invalid_argument',
2898
+ `${operationLabelArg} $setOnInsert identity "${path}" must equal the filter's equality anchor for "${path}".`,
2899
+ );
2900
+ }
2901
+ }
2902
+ };
2903
+
2904
+ /**
2905
+ * An upsert on a model whose identity owns the document `_id` must pin that
2906
+ * identity by equality: MongoDB derives the inserted primary key from the
2907
+ * filter, so an unpinned upsert would create a document with a generated `_id`
2908
+ * that contradicts the declared mapping. Returns the pinned identity value so
2909
+ * the caller can seed the stored identity field exactly as an unmapped upsert
2910
+ * would have done.
2911
+ */
2912
+ const requirePinnedDocumentIdentity = (
2913
+ normalizedFilterArg: Record<string, unknown>,
2914
+ identityFieldArg: string,
2915
+ operationLabelArg: string,
2916
+ ): unknown => {
2917
+ const anchored = readNormalizedFilterEquality(
2918
+ normalizedFilterArg,
2919
+ identityFieldArg,
2920
+ );
2921
+ if (!anchored.found) {
2922
+ throw new SmartdataPersistenceError(
2923
+ 'invalid_argument',
2924
+ `${operationLabelArg} upsert requires an equality anchor on "${identityFieldArg}", which owns the document _id.`,
2925
+ );
2926
+ }
2927
+ return anchored.value;
2928
+ };
2929
+
2930
+ /**
2931
+ * Rewrites equality and operator conditions on a model's `identityAsDocumentId`
2932
+ * field onto `_id`, so the primary key index answers the query instead of a
2933
+ * collection scan. The stored identity field keeps its own value, but every
2934
+ * identity-filtered access is answered through `_id`, so a document written
2935
+ * before the option was declared resolves only if `_id` already equals it.
2936
+ */
2937
+ const mapIdentityFilterToDocumentId = (
2938
+ filterArg: Record<string, unknown>,
2939
+ identityFieldArg: string | undefined,
2940
+ ): Record<string, unknown> => {
2941
+ if (!identityFieldArg || !isPlainObject(filterArg)) {
2942
+ return filterArg;
2943
+ }
2944
+ const carriesExplicitDocumentId = Object.prototype.hasOwnProperty.call(
2945
+ filterArg,
2946
+ '_id',
2947
+ );
2948
+ const mapped: Record<string, unknown> = {};
2949
+ for (const [key, value] of Object.entries(filterArg)) {
2950
+ if (
2951
+ (key === '$and' || key === '$or' || key === '$nor')
2952
+ && Array.isArray(value)
2953
+ ) {
2954
+ mapped[key] = value.map((entryArg) =>
2955
+ isPlainObject(entryArg)
2956
+ ? mapIdentityFilterToDocumentId(
2957
+ entryArg as Record<string, unknown>,
2958
+ identityFieldArg,
2959
+ )
2960
+ : entryArg,
2961
+ );
2962
+ continue;
2963
+ }
2964
+ if (key === '$not' && isPlainObject(value)) {
2965
+ mapped[key] = mapIdentityFilterToDocumentId(
2966
+ value as Record<string, unknown>,
2967
+ identityFieldArg,
2968
+ );
2969
+ continue;
2970
+ }
2971
+ if (key === identityFieldArg && !carriesExplicitDocumentId) {
2972
+ mapped._id = value;
2973
+ continue;
2974
+ }
2975
+ mapped[key] = value;
2976
+ }
2977
+ return mapped;
2978
+ };
2979
+
2717
2980
  const normalizeProjection = <T>(
2718
2981
  projectionArg: TSmartdataProjection<T> | undefined,
2719
2982
  declaredRootsArg: Set<string>,
@@ -3087,6 +3350,27 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
3087
3350
  return new Set(collection.getBoundModelSchema()?.numericFields || []);
3088
3351
  }
3089
3352
 
3353
+ /**
3354
+ * Returns the declared identity field this model stores as the document
3355
+ * `_id`, or undefined when SmartData owns the primary key.
3356
+ */
3357
+ private static getIdentityDocumentIdField(): string | undefined {
3358
+ return getIdentityDocumentIdField(this);
3359
+ }
3360
+
3361
+ /**
3362
+ * Converts an ordinary read filter and routes a declared document-id
3363
+ * identity onto `_id`, so reads use the primary key index.
3364
+ */
3365
+ private static normalizeReadFilter(
3366
+ filterArg: Record<string, any>,
3367
+ ): plugins.mongodb.Filter<plugins.mongodb.Document> {
3368
+ return mapIdentityFilterToDocumentId(
3369
+ convertFilterForMongoDb(filterArg),
3370
+ (this as any).getIdentityDocumentIdField() as string | undefined,
3371
+ ) as plugins.mongodb.Filter<plugins.mongodb.Document>;
3372
+ }
3373
+
3090
3374
  /**
3091
3375
  * Returns every equality key set that globally constrains a single document
3092
3376
  * of this model: each declared identity field on its own, plus the full key
@@ -3531,20 +3815,40 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
3531
3815
  'Atomic update does not support upsert when the filter contains $expr.',
3532
3816
  );
3533
3817
  }
3818
+ const upserting = normalizedOptions?.upsert === true;
3534
3819
  const normalizedUpdate = normalizeAtomicUpdate(
3535
3820
  updateArg,
3536
3821
  declaredRoots,
3537
3822
  declaredAtomicPaths,
3538
3823
  uniqueRoots,
3539
3824
  serializedRoots,
3825
+ upserting ? { identityValueTypes } : undefined,
3826
+ );
3827
+ assertSeededIdentitiesMatchFilter(
3828
+ normalizedFilter,
3829
+ normalizedUpdate,
3830
+ uniqueRoots,
3831
+ 'Atomic update',
3540
3832
  );
3541
3833
  const now = new Date().toISOString();
3542
3834
  normalizedUpdate.$set = {
3543
3835
  ...(normalizedUpdate.$set || {}),
3544
3836
  _updatedAt: now,
3545
3837
  };
3546
- if (normalizedOptions?.upsert === true) {
3838
+ const identityDocumentIdField = (this as any).getIdentityDocumentIdField() as
3839
+ | string
3840
+ | undefined;
3841
+ if (upserting) {
3547
3842
  normalizedUpdate.$setOnInsert = {
3843
+ ...(identityDocumentIdField
3844
+ ? {
3845
+ [identityDocumentIdField]: requirePinnedDocumentIdentity(
3846
+ normalizedFilter,
3847
+ identityDocumentIdField,
3848
+ 'Atomic update',
3849
+ ),
3850
+ }
3851
+ : {}),
3548
3852
  ...(normalizedUpdate.$setOnInsert || {}),
3549
3853
  _createdAt: now,
3550
3854
  };
@@ -3552,7 +3856,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
3552
3856
  const collection: SmartdataCollection<T> = (this as any).collection;
3553
3857
  const result = await executeAtomicUpdate(
3554
3858
  collection,
3555
- normalizedFilter,
3859
+ mapIdentityFilterToDocumentId(normalizedFilter, identityDocumentIdField),
3556
3860
  normalizedUpdate,
3557
3861
  normalizedOptions,
3558
3862
  );
@@ -3615,6 +3919,13 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
3615
3919
  declaredAtomicPaths,
3616
3920
  uniqueRoots,
3617
3921
  serializedRoots,
3922
+ normalizedOptions.upsert ? { identityValueTypes } : undefined,
3923
+ );
3924
+ assertSeededIdentitiesMatchFilter(
3925
+ normalizedFilter,
3926
+ normalizedUpdate,
3927
+ uniqueRoots,
3928
+ 'Atomic find-one-and-update',
3618
3929
  );
3619
3930
  if (policy?.timestamps !== 'none') {
3620
3931
  const now = new Date().toISOString();
@@ -3623,6 +3934,19 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
3623
3934
  normalizedUpdate.$setOnInsert = { ...(normalizedUpdate.$setOnInsert || {}), _createdAt: now };
3624
3935
  }
3625
3936
  }
3937
+ const identityDocumentIdField = (this as any).getIdentityDocumentIdField() as
3938
+ | string
3939
+ | undefined;
3940
+ if (normalizedOptions.upsert && identityDocumentIdField) {
3941
+ normalizedUpdate.$setOnInsert = {
3942
+ [identityDocumentIdField]: requirePinnedDocumentIdentity(
3943
+ normalizedFilter,
3944
+ identityDocumentIdField,
3945
+ 'Atomic find-one-and-update',
3946
+ ),
3947
+ ...(normalizedUpdate.$setOnInsert || {}),
3948
+ };
3949
+ }
3626
3950
  const normalizedSort = normalizeSort(
3627
3951
  normalizedOptions.sort as TSmartdataSort<T> | undefined,
3628
3952
  declaredRoots,
@@ -3631,7 +3955,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
3631
3955
  const collection: SmartdataCollection<T> = (this as any).collection;
3632
3956
  const result = await executeAtomicFindOneAndUpdate(
3633
3957
  collection,
3634
- normalizedFilter,
3958
+ mapIdentityFilterToDocumentId(normalizedFilter, identityDocumentIdField),
3635
3959
  normalizedUpdate,
3636
3960
  {
3637
3961
  returnDocument: normalizedOptions.returnDocument,
@@ -3688,7 +4012,10 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
3688
4012
  const collection: SmartdataCollection<T> = (this as any).collection;
3689
4013
  const result = await executeAtomicDelete(
3690
4014
  collection,
3691
- normalizedFilter,
4015
+ mapIdentityFilterToDocumentId(
4016
+ normalizedFilter,
4017
+ (this as any).getIdentityDocumentIdField() as string | undefined,
4018
+ ),
3692
4019
  { session: opts?.session, timeoutMS: requireBoundedInteger(opts?.timeoutMS, 'Atomic delete timeoutMS', 120_000) },
3693
4020
  );
3694
4021
  return {
@@ -3736,7 +4063,10 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
3736
4063
  const collection: SmartdataCollection<T> = (this as any).collection;
3737
4064
  const result = await executeAtomicDeleteMany(
3738
4065
  collection,
3739
- normalizedFilter,
4066
+ mapIdentityFilterToDocumentId(
4067
+ normalizedFilter,
4068
+ (this as any).getIdentityDocumentIdField() as string | undefined,
4069
+ ),
3740
4070
  { session: opts?.session, timeoutMS: requireBoundedInteger(opts?.timeoutMS, 'Atomic delete-many timeoutMS', 120_000) },
3741
4071
  );
3742
4072
  return {
@@ -3810,7 +4140,10 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
3810
4140
  const collection: SmartdataCollection<T> = (this as any).collection;
3811
4141
  const result = await executeAtomicUpdateMany(
3812
4142
  collection,
3813
- normalizedFilter,
4143
+ mapIdentityFilterToDocumentId(
4144
+ normalizedFilter,
4145
+ (this as any).getIdentityDocumentIdField() as string | undefined,
4146
+ ),
3814
4147
  normalizedUpdate,
3815
4148
  opts,
3816
4149
  );
@@ -3821,6 +4154,172 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
3821
4154
  };
3822
4155
  }
3823
4156
 
4157
+ /**
4158
+ * Atomically upserts a bounded batch of ordinary model documents in one
4159
+ * round trip. Every entry carries its own strict selector and update, and
4160
+ * each entry applies atomically; the batch as a whole is not isolated unless
4161
+ * the caller supplies a transaction session. The complete batch is validated
4162
+ * before any write, so one invalid entry rejects the call without writing.
4163
+ */
4164
+ public static async atomicUpsertMany<T>(
4165
+ this: plugins.tsclass.typeFest.Class<T>,
4166
+ operationsArg: ReadonlyArray<ISmartdataAtomicUpsertManyOperation<T>>,
4167
+ opts?: ISmartdataAtomicUpsertManyOptions,
4168
+ ): Promise<ISmartdataAtomicUpsertManyResult> {
4169
+ if (getOrdinaryPersistencePolicy(this)) {
4170
+ throw new SmartdataPersistenceError('unsupported_operation',
4171
+ 'Validated ordinary models require a transactional postimage update.');
4172
+ }
4173
+ if ((this as any)[exactPersistencePolicySymbol]) {
4174
+ throw new SmartdataPersistenceError(
4175
+ 'unsupported_operation',
4176
+ 'atomicUpsertMany is unavailable for exact-persistence models.',
4177
+ );
4178
+ }
4179
+ if (
4180
+ !Array.isArray(operationsArg)
4181
+ || operationsArg.length < 1
4182
+ || operationsArg.length > 1000
4183
+ ) {
4184
+ throw new SmartdataPersistenceError(
4185
+ 'invalid_argument',
4186
+ 'Atomic upsert-many requires 1 to 1000 operations.',
4187
+ );
4188
+ }
4189
+ if (opts !== undefined) {
4190
+ const optionsValue: unknown = opts;
4191
+ if (!isPlainObject(optionsValue) || plugins.nodeUtil.types.isProxy(optionsValue)) {
4192
+ throw new SmartdataPersistenceError(
4193
+ 'invalid_argument',
4194
+ 'Atomic upsert-many options must be an inert ordinary object.',
4195
+ );
4196
+ }
4197
+ for (const key of Reflect.ownKeys(opts)) {
4198
+ if (
4199
+ typeof key !== 'string'
4200
+ || !['session', 'timeoutMS', 'ordered'].includes(key)
4201
+ ) {
4202
+ throw new SmartdataPersistenceError(
4203
+ 'invalid_argument',
4204
+ `Atomic upsert-many received unsupported option "${String(key)}".`,
4205
+ );
4206
+ }
4207
+ }
4208
+ if (opts.ordered !== undefined && opts.ordered !== false) {
4209
+ throw new SmartdataPersistenceError(
4210
+ 'invalid_argument',
4211
+ 'Atomic upsert-many is always unordered; ordered may only be false.',
4212
+ );
4213
+ }
4214
+ }
4215
+ const timeoutMS = requireBoundedInteger(
4216
+ opts?.timeoutMS,
4217
+ 'Atomic upsert-many timeoutMS',
4218
+ 120_000,
4219
+ );
4220
+ const declaredRoots = (this as any).getDeclaredPersistedRoots() as Set<string>;
4221
+ const declaredAtomicPaths = (this as any).getDeclaredAtomicPaths() as Set<string>;
4222
+ const identityValueTypes = (this as any).getDeclaredIdentityValueTypes() as Map<
4223
+ string,
4224
+ TSmartdataIdentityValueType
4225
+ >;
4226
+ const numericRoots = (this as any).getDeclaredNumericRoots() as Set<string>;
4227
+ const uniqueRoots = new Set(identityValueTypes.keys());
4228
+ const serializedRoots = (this as any).getSerializedPersistedRoots() as Set<string>;
4229
+ const identityDocumentIdField = (this as any).getIdentityDocumentIdField() as
4230
+ | string
4231
+ | undefined;
4232
+ const now = new Date().toISOString();
4233
+ const normalizedOperations: Array<{
4234
+ filter: plugins.mongodb.Filter<plugins.mongodb.Document>;
4235
+ update: plugins.mongodb.UpdateFilter<plugins.mongodb.Document>;
4236
+ }> = [];
4237
+ let bytes = 0;
4238
+ for (const operation of operationsArg) {
4239
+ if (
4240
+ !isPlainObject(operation)
4241
+ || plugins.nodeUtil.types.isProxy(operation)
4242
+ || Object.keys(operation).some(
4243
+ (keyArg) => !['filter', 'update'].includes(keyArg),
4244
+ )
4245
+ ) {
4246
+ throw new SmartdataPersistenceError(
4247
+ 'invalid_argument',
4248
+ 'Atomic upsert-many operations must be inert objects with a filter and an update.',
4249
+ );
4250
+ }
4251
+ const normalizedFilter = normalizeStrictFilter(
4252
+ operation.filter as Record<string, unknown>,
4253
+ declaredRoots,
4254
+ declaredAtomicPaths,
4255
+ identityValueTypes,
4256
+ numericRoots,
4257
+ serializedRoots,
4258
+ 'Atomic upsert-many',
4259
+ );
4260
+ if ('$expr' in normalizedFilter) {
4261
+ throw new SmartdataPersistenceError(
4262
+ 'invalid_argument',
4263
+ 'Atomic upsert-many does not support filters that contain $expr.',
4264
+ );
4265
+ }
4266
+ const normalizedUpdate = normalizeAtomicUpdate(
4267
+ operation.update as ISmartdataAtomicUpdate<T>,
4268
+ declaredRoots,
4269
+ declaredAtomicPaths,
4270
+ uniqueRoots,
4271
+ serializedRoots,
4272
+ { identityValueTypes },
4273
+ );
4274
+ assertSeededIdentitiesMatchFilter(
4275
+ normalizedFilter,
4276
+ normalizedUpdate,
4277
+ uniqueRoots,
4278
+ 'Atomic upsert-many',
4279
+ );
4280
+ normalizedUpdate.$set = {
4281
+ ...(normalizedUpdate.$set || {}),
4282
+ _updatedAt: now,
4283
+ };
4284
+ normalizedUpdate.$setOnInsert = {
4285
+ ...(identityDocumentIdField
4286
+ ? {
4287
+ [identityDocumentIdField]: requirePinnedDocumentIdentity(
4288
+ normalizedFilter,
4289
+ identityDocumentIdField,
4290
+ 'Atomic upsert-many',
4291
+ ),
4292
+ }
4293
+ : {}),
4294
+ ...(normalizedUpdate.$setOnInsert || {}),
4295
+ _createdAt: now,
4296
+ };
4297
+ const mappedFilter = mapIdentityFilterToDocumentId(
4298
+ normalizedFilter,
4299
+ identityDocumentIdField,
4300
+ );
4301
+ bytes += plugins.mongodb.BSON.calculateObjectSize({
4302
+ q: mappedFilter,
4303
+ u: normalizedUpdate,
4304
+ });
4305
+ if (bytes > 16 * 1024 * 1024) {
4306
+ throw new SmartdataPersistenceError(
4307
+ 'invalid_argument',
4308
+ 'Atomic upsert-many accepts at most 16 MiB of serialized operations.',
4309
+ );
4310
+ }
4311
+ normalizedOperations.push({
4312
+ filter: mappedFilter as plugins.mongodb.Filter<plugins.mongodb.Document>,
4313
+ update: normalizedUpdate,
4314
+ });
4315
+ }
4316
+ const collection: SmartdataCollection<T> = (this as any).collection;
4317
+ return await executeAtomicUpsertMany(collection, normalizedOperations, {
4318
+ session: opts?.session,
4319
+ timeoutMS,
4320
+ });
4321
+ }
4322
+
3824
4323
  /**
3825
4324
  * Computes bounded grouped counts and optional numeric sums server-side.
3826
4325
  * Groups by one or two declared top-level fields, always returns a `count`
@@ -4001,7 +4500,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4001
4500
  const collection: SmartdataCollection<T> = (this as any).collection;
4002
4501
  const rows = await collection.aggregateGroupedTotals(
4003
4502
  filterOption !== undefined
4004
- ? convertFilterForMongoDb(filterOption)
4503
+ ? (this as any).normalizeReadFilter(filterOption)
4005
4504
  : undefined,
4006
4505
  groupStage,
4007
4506
  limit + 1,
@@ -4073,7 +4572,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4073
4572
  ): Promise<T[]> {
4074
4573
  // Pass session through to findAll for transactional queries
4075
4574
  const foundDocs = await (this as any).collection.findAll(
4076
- convertFilterForMongoDb(filterArg),
4575
+ (this as any).normalizeReadFilter(filterArg),
4077
4576
  { session: opts?.session },
4078
4577
  );
4079
4578
  const returnArray: T[] = [];
@@ -4122,7 +4621,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4122
4621
  ? optionsArg.limit!
4123
4622
  : 100;
4124
4623
  const limit = Math.min(requestedLimit, 1000);
4125
- const baseSelector = convertFilterForMongoDb(optionsArg.filter || {});
4624
+ const baseSelector = (this as any).normalizeReadFilter(optionsArg.filter || {});
4126
4625
 
4127
4626
  let selector: any = baseSelector;
4128
4627
  if (optionsArg.cursor) {
@@ -4194,7 +4693,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4194
4693
  const declaredRoots = (this as any).getDeclaredPersistedRoots() as Set<string>;
4195
4694
  // Retrieve one document, with optional session for transactions
4196
4695
  const foundDoc = await (this as any).collection.findOne(
4197
- convertFilterForMongoDb(filterArg),
4696
+ (this as any).normalizeReadFilter(filterArg),
4198
4697
  {
4199
4698
  projection: normalizeProjection(
4200
4699
  opts?.projection,
@@ -4226,7 +4725,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4226
4725
  },
4227
4726
  ): Promise<boolean> {
4228
4727
  const foundDoc = await (this as any).collection.findOne(
4229
- convertFilterForMongoDb(filterArg),
4728
+ (this as any).normalizeReadFilter(filterArg),
4230
4729
  {
4231
4730
  projection: { _id: 1 },
4232
4731
  maxTimeMS: requireBoundedInteger(
@@ -4291,7 +4790,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4291
4790
  const collection: SmartdataCollection<T> = (this as any).collection;
4292
4791
  await collection.init();
4293
4792
  let rawCursor: plugins.mongodb.FindCursor<any> =
4294
- collection.mongoDbCollection.find(convertFilterForMongoDb(filterArg), {
4793
+ collection.mongoDbCollection.find((this as any).normalizeReadFilter(filterArg), {
4295
4794
  projection,
4296
4795
  session,
4297
4796
  hint,
@@ -4365,7 +4864,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4365
4864
  }
4366
4865
  const timeoutMS = requireBoundedInteger(opts?.timeoutMS === undefined ? 5_000 : opts.timeoutMS, 'timeoutMS', 120_000);
4367
4866
  const maxTimeMS = requireBoundedInteger(opts?.maxTimeMS, 'maxTimeMS', 120_000);
4368
- const filter = convertFilterForMongoDb(filterArg);
4867
+ const filter = (this as any).normalizeReadFilter(filterArg);
4369
4868
  opts.signal?.throwIfAborted();
4370
4869
  const collection: SmartdataCollection<T> = (this as any).collection;
4371
4870
  if (!collection.isInitializedForCurrentDatabase()) {
@@ -4410,7 +4909,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4410
4909
  ): Promise<SmartdataDbWatcher<T>> {
4411
4910
  const collection: SmartdataCollection<T> = (this as any).collection;
4412
4911
  const watcher: SmartdataDbWatcher<T> = await collection.watch(
4413
- convertFilterForMongoDb(filterArg),
4912
+ (this as any).normalizeReadFilter(filterArg),
4414
4913
  opts || {},
4415
4914
  this as any,
4416
4915
  );
@@ -4440,7 +4939,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4440
4939
  ) {
4441
4940
  const hint = normalizeQueryHint(opts?.hint, this);
4442
4941
  const collection: SmartdataCollection<T> = (this as any).collection;
4443
- return await collection.getCount(convertFilterForMongoDb(filterArg), {
4942
+ return await collection.getCount((this as any).normalizeReadFilter(filterArg), {
4444
4943
  hint,
4445
4944
  limit: requireBoundedInteger(opts?.limit, 'limit', 10_000),
4446
4945
  maxTimeMS: requireBoundedInteger(
@@ -4913,7 +5412,14 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4913
5412
  this.identityValueTypes,
4914
5413
  );
4915
5414
  const identifiableObject: any = {}; // is not exposed to outside, so any is ok here
5415
+ const identityDocumentIdField = getIdentityDocumentIdField(this.constructor);
4916
5416
  for (const propertyNameString of this.uniqueIndexes || []) {
5417
+ if (propertyNameString === identityDocumentIdField) {
5418
+ // The declared identity is the stored primary key, so instance writes
5419
+ // and reads address the document through _id.
5420
+ identifiableObject._id = this[propertyNameString];
5421
+ continue;
5422
+ }
4917
5423
  identifiableObject[propertyNameString] = this[propertyNameString];
4918
5424
  }
4919
5425
  if (getOrdinaryPersistencePolicy(this.constructor)?.idType === 'string') {