@lossless.org/client 1.0.0 → 1.2.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';
@@ -75,6 +78,7 @@ interface ISmartdataDecoratorMetadata extends DecoratorMetadataObject {
75
78
  saveableProperties?: string[];
76
79
  uniqueIndexes?: string[];
77
80
  identityValueTypes?: Record<string, TSmartdataIdentityValueType>;
81
+ identityIndexNames?: Record<string, string>;
78
82
  regularIndexes?: Array<{field: string, options: IIndexOptions}>;
79
83
  compoundIndexes?: Array<{
80
84
  name: string;
@@ -95,6 +99,14 @@ export interface IUnIOptions {
95
99
  * non-empty string. Numeric identities require an explicit opt-in.
96
100
  */
97
101
  valueType?: TSmartdataIdentityValueType;
102
+ /**
103
+ * Name of the single-field ascending unique index that backs this identity.
104
+ * Defaults to `<field>_1`. A collection whose unique index was created by a
105
+ * migration under another name declares that name here: MongoDB refuses a
106
+ * second index over the same key, so without it the identity could not be
107
+ * declared at all.
108
+ */
109
+ indexName?: string;
98
110
  }
99
111
 
100
112
  /**
@@ -320,11 +332,13 @@ export function unI(optionsArg: IUnIOptions = {}) {
320
332
  typeof optionsArg !== 'object'
321
333
  || optionsArg === null
322
334
  || Array.isArray(optionsArg)
323
- || Object.keys(optionsArg).some((keyArg) => keyArg !== 'valueType')
335
+ || Object.keys(optionsArg).some(
336
+ (keyArg) => keyArg !== 'valueType' && keyArg !== 'indexName',
337
+ )
324
338
  ) {
325
339
  throw new SmartdataPersistenceError(
326
340
  'invalid_configuration',
327
- 'unI options must be an object containing only valueType.',
341
+ 'unI options must be an object containing only valueType and indexName.',
328
342
  );
329
343
  }
330
344
  const valueType = optionsArg.valueType ?? 'string';
@@ -334,6 +348,25 @@ export function unI(optionsArg: IUnIOptions = {}) {
334
348
  'unI valueType must be "string" or "positiveSafeInteger".',
335
349
  );
336
350
  }
351
+ const indexName = optionsArg.indexName;
352
+ if (indexName !== undefined) {
353
+ if (
354
+ typeof indexName !== 'string'
355
+ || indexName.trim().length === 0
356
+ || indexName.includes('\0')
357
+ ) {
358
+ throw new SmartdataPersistenceError(
359
+ 'invalid_configuration',
360
+ 'unI indexName must be a non-empty index name.',
361
+ );
362
+ }
363
+ if (indexName === '_id_') {
364
+ throw new SmartdataPersistenceError(
365
+ 'invalid_configuration',
366
+ 'unI indexName "_id_" is reserved for MongoDB.',
367
+ );
368
+ }
369
+ }
337
370
  return (value: undefined, context: ClassFieldDecoratorContext) => {
338
371
  if (context.kind !== 'field') {
339
372
  throw new Error('unI can only decorate fields');
@@ -362,6 +395,24 @@ export function unI(optionsArg: IUnIOptions = {}) {
362
395
  );
363
396
  }
364
397
  metadata.identityValueTypes![propName] = valueType;
398
+ // An unnamed declaration writes no entry, so a subclass that redeclares
399
+ // the identity without a name keeps the index the base class named. Only
400
+ // a second, different name is a contradiction worth refusing.
401
+ if (indexName !== undefined) {
402
+ if (
403
+ !Object.prototype.hasOwnProperty.call(metadata, 'identityIndexNames')
404
+ ) {
405
+ metadata.identityIndexNames = { ...(metadata.identityIndexNames || {}) };
406
+ }
407
+ const existingIndexName = metadata.identityIndexNames![propName];
408
+ if (existingIndexName && existingIndexName !== indexName) {
409
+ throw new SmartdataPersistenceError(
410
+ 'invalid_configuration',
411
+ `Identity field "${propName}" has divergent indexName declarations.`,
412
+ );
413
+ }
414
+ metadata.identityIndexNames![propName] = indexName;
415
+ }
365
416
 
366
417
  // Also mark as saveable
367
418
  if (!Object.prototype.hasOwnProperty.call(metadata, 'saveableProperties')) {
@@ -590,6 +641,23 @@ export type TSmartdataNumericDocumentPath<T> = {
590
641
  NonNullable<T[TKey]> extends number ? TKey : never;
591
642
  }[TSmartdataDocumentPath<T>];
592
643
 
644
+ /** Declared top-level fields that carry a monotonic ordering: numbers and dates. */
645
+ export type TSmartdataMonotonicDocumentPath<T> = {
646
+ [TKey in TSmartdataDocumentPath<T>]:
647
+ NonNullable<T[TKey]> extends number
648
+ ? TKey
649
+ : NonNullable<T[TKey]> extends Date
650
+ ? TKey
651
+ : never;
652
+ }[TSmartdataDocumentPath<T>];
653
+
654
+ type TSmartdataAtomicMonotonic<T> =
655
+ & Partial<{
656
+ [TKey in TSmartdataMonotonicDocumentPath<T>]:
657
+ NonNullable<T[TKey]> extends Date ? Date : number;
658
+ }>
659
+ & Partial<Record<TSmartdataAtomicNestedDocumentPath<T>, number | Date>>;
660
+
593
661
  export type TSmartdataAtomicNumericFieldReference<T> =
594
662
  `$${TSmartdataNumericDocumentPath<T>}`;
595
663
 
@@ -691,6 +759,18 @@ export interface ISmartdataAtomicUpdate<T> {
691
759
  Record<TSmartdataAtomicDocumentPath<T>, '' | true | 1>
692
760
  >;
693
761
  $inc?: TSmartdataAtomicIncrement<T>;
762
+ /**
763
+ * Monotonic ceiling for a declared numeric or date field: one server-side
764
+ * compare-and-write that never lowers the stored value, so a retried or
765
+ * out-of-order writer cannot regress a counter. With `upsert: true` a
766
+ * missing field is inserted with the operand.
767
+ */
768
+ $max?: TSmartdataAtomicMonotonic<T>;
769
+ /**
770
+ * Monotonic floor for a declared numeric or date field: the mirror of
771
+ * `$max`, never raising the stored value.
772
+ */
773
+ $min?: TSmartdataAtomicMonotonic<T>;
694
774
  $setOnInsert?: TSmartdataAtomicSet<T>;
695
775
  /**
696
776
  * Appends one element to a declared top-level array field. Modifier
@@ -860,6 +940,30 @@ export interface ISmartdataAtomicUpdateManyResult {
860
940
  modifiedCount: number;
861
941
  }
862
942
 
943
+ /** One strict selector and its update, both validated before any write. */
944
+ export interface ISmartdataAtomicUpsertManyOperation<T> {
945
+ filter: TSmartdataAtomicFilter<T>;
946
+ update: ISmartdataAtomicUpdate<T>;
947
+ }
948
+
949
+ export interface ISmartdataAtomicUpsertManyOptions {
950
+ session?: TSmartdataOrdinarySession;
951
+ /** Client-side bulk operation deadline, including server selection and pool waits. */
952
+ timeoutMS?: number;
953
+ /**
954
+ * Only `false`. The batch is always unordered, so one conflicting entry
955
+ * never hides the outcome of the entries behind it.
956
+ */
957
+ ordered?: false;
958
+ }
959
+
960
+ export interface ISmartdataAtomicUpsertManyResult {
961
+ acknowledged: boolean;
962
+ matchedCount: number;
963
+ modifiedCount: number;
964
+ upsertedCount: number;
965
+ }
966
+
863
967
  const normalizeAtomicFindOneAndUpdateOptions = <
864
968
  TReturnDocument extends TSmartdataAtomicReturnDocument,
865
969
  >(
@@ -2255,6 +2359,7 @@ const normalizeStrictFilter = (
2255
2359
  operationLabelArg:
2256
2360
  | 'Atomic update'
2257
2361
  | 'Atomic update-many'
2362
+ | 'Atomic upsert-many'
2258
2363
  | 'Atomic find-one-and-update'
2259
2364
  | 'Atomic delete'
2260
2365
  | 'Atomic delete-many',
@@ -2533,6 +2638,15 @@ const normalizeAtomicUpdate = <T>(
2533
2638
  declaredNestedPathsArg: Set<string>,
2534
2639
  uniqueRootsArg: Set<string>,
2535
2640
  serializedRootsArg: Set<string>,
2641
+ /**
2642
+ * Supplied only by upserting operations. Its presence allows `$setOnInsert`
2643
+ * to seed declared identity roots on the insert branch; identities stay
2644
+ * immutable on the update branch because MongoDB never applies
2645
+ * `$setOnInsert` to an existing document.
2646
+ */
2647
+ identitySeedingArg?: {
2648
+ identityValueTypes: ReadonlyMap<string, TSmartdataIdentityValueType>;
2649
+ },
2536
2650
  ): plugins.mongodb.UpdateFilter<plugins.mongodb.Document> => {
2537
2651
  if (
2538
2652
  typeof updateArg !== 'object' ||
@@ -2552,8 +2666,11 @@ const normalizeAtomicUpdate = <T>(
2552
2666
  '$push',
2553
2667
  '$addToSet',
2554
2668
  '$pull',
2669
+ '$max',
2670
+ '$min',
2555
2671
  ]);
2556
2672
  const arrayOperators = new Set(['$push', '$addToSet', '$pull']);
2673
+ const monotonicOperators = new Set(['$max', '$min']);
2557
2674
  for (const operator of Object.keys(updateArg)) {
2558
2675
  if (!allowedOperators.has(operator)) {
2559
2676
  throw new SmartdataPersistenceError(
@@ -2595,9 +2712,30 @@ const normalizeAtomicUpdate = <T>(
2595
2712
  );
2596
2713
  }
2597
2714
  if (uniqueRootsArg.has(root)) {
2598
- throw new SmartdataPersistenceError(
2599
- 'invalid_argument',
2600
- `${operator} may not modify immutable @unI() field "${root}".`,
2715
+ if (operator !== '$setOnInsert') {
2716
+ throw new SmartdataPersistenceError(
2717
+ 'invalid_argument',
2718
+ `${operator} may not modify immutable @unI() field "${root}".`,
2719
+ );
2720
+ }
2721
+ if (!identitySeedingArg) {
2722
+ throw new SmartdataPersistenceError(
2723
+ 'invalid_argument',
2724
+ `$setOnInsert may not seed immutable @unI() field "${root}" without upsert: true.`,
2725
+ );
2726
+ }
2727
+ if (path !== root) {
2728
+ throw new SmartdataPersistenceError(
2729
+ 'invalid_argument',
2730
+ `$setOnInsert may not target a nested path below immutable @unI() field "${root}".`,
2731
+ );
2732
+ }
2733
+ // The insert branch must create a complete, addressable document, so
2734
+ // the seeded value passes the same identity validation an insert does.
2735
+ requireUsableIdentityValue(
2736
+ value,
2737
+ `$setOnInsert identity field "${root}"`,
2738
+ identitySeedingArg.identityValueTypes.get(root) ?? 'string',
2601
2739
  );
2602
2740
  }
2603
2741
  const conflict = occupiedPaths.find((occupiedPath) =>
@@ -2688,6 +2826,34 @@ const normalizeAtomicUpdate = <T>(
2688
2826
  `${operator} path "${path}" may not be undefined.`,
2689
2827
  );
2690
2828
  }
2829
+ if (monotonicOperators.has(operator)) {
2830
+ if (typeof value === 'number') {
2831
+ if (!Number.isFinite(value)) {
2832
+ throw new SmartdataPersistenceError(
2833
+ 'invalid_argument',
2834
+ `${operator} path "${path}" must be a finite number or a Date.`,
2835
+ );
2836
+ }
2837
+ normalizedOperation[path] = value;
2838
+ continue;
2839
+ }
2840
+ if (
2841
+ !(value instanceof Date)
2842
+ || !Number.isFinite(value.getTime())
2843
+ ) {
2844
+ throw new SmartdataPersistenceError(
2845
+ 'invalid_argument',
2846
+ `${operator} path "${path}" must be a finite number or a Date.`,
2847
+ );
2848
+ }
2849
+ // A custom encoder could otherwise rewrite the comparison operand at
2850
+ // serialization time, so the date is captured as an inert snapshot.
2851
+ normalizedOperation[path] = createInertAtomicBsonScalarSnapshot(
2852
+ value,
2853
+ `${operator} path "${path}"`,
2854
+ );
2855
+ continue;
2856
+ }
2691
2857
  if (
2692
2858
  operator === '$inc' &&
2693
2859
  (typeof value !== 'number' || !Number.isFinite(value))
@@ -2714,6 +2880,151 @@ const normalizeAtomicUpdate = <T>(
2714
2880
  return normalized as plugins.mongodb.UpdateFilter<plugins.mongodb.Document>;
2715
2881
  };
2716
2882
 
2883
+ /**
2884
+ * Reads a globally constraining equality value for one declared path from an
2885
+ * already normalized filter. `$or` branches are excluded because an equality
2886
+ * inside a single branch does not pin the matched document.
2887
+ */
2888
+ const readNormalizedFilterEquality = (
2889
+ filterArg: Record<string, unknown>,
2890
+ pathArg: string,
2891
+ ): { found: boolean; value?: unknown } => {
2892
+ for (const [key, value] of Object.entries(filterArg)) {
2893
+ if (key === '$and' && Array.isArray(value)) {
2894
+ for (const entry of value) {
2895
+ if (isPlainObject(entry)) {
2896
+ const nested = readNormalizedFilterEquality(
2897
+ entry as Record<string, unknown>,
2898
+ pathArg,
2899
+ );
2900
+ if (nested.found) {
2901
+ return nested;
2902
+ }
2903
+ }
2904
+ }
2905
+ continue;
2906
+ }
2907
+ if (key !== pathArg) {
2908
+ continue;
2909
+ }
2910
+ if (isPlainObject(value)) {
2911
+ const entries = Object.entries(value as Record<string, unknown>);
2912
+ if (entries.length === 1 && entries[0][0] === '$eq') {
2913
+ return { found: true, value: entries[0][1] };
2914
+ }
2915
+ continue;
2916
+ }
2917
+ return { found: true, value };
2918
+ }
2919
+ return { found: false };
2920
+ };
2921
+
2922
+ /**
2923
+ * An upsert seeds its inserted document from the filter's equality conditions
2924
+ * and from `$setOnInsert`. When both name the same identity they must agree,
2925
+ * otherwise the insert branch would create a document its own selector can
2926
+ * never match again.
2927
+ */
2928
+ const assertSeededIdentitiesMatchFilter = (
2929
+ normalizedFilterArg: Record<string, unknown>,
2930
+ normalizedUpdateArg: Record<string, unknown>,
2931
+ uniqueRootsArg: Set<string>,
2932
+ operationLabelArg: string,
2933
+ ): void => {
2934
+ const seeded = normalizedUpdateArg.$setOnInsert;
2935
+ if (!isPlainObject(seeded)) {
2936
+ return;
2937
+ }
2938
+ for (const [path, value] of Object.entries(seeded as Record<string, unknown>)) {
2939
+ if (!uniqueRootsArg.has(path)) {
2940
+ continue;
2941
+ }
2942
+ const anchored = readNormalizedFilterEquality(normalizedFilterArg, path);
2943
+ if (anchored.found && anchored.value !== value) {
2944
+ throw new SmartdataPersistenceError(
2945
+ 'invalid_argument',
2946
+ `${operationLabelArg} $setOnInsert identity "${path}" must equal the filter's equality anchor for "${path}".`,
2947
+ );
2948
+ }
2949
+ }
2950
+ };
2951
+
2952
+ /**
2953
+ * An upsert on a model whose identity owns the document `_id` must pin that
2954
+ * identity by equality: MongoDB derives the inserted primary key from the
2955
+ * filter, so an unpinned upsert would create a document with a generated `_id`
2956
+ * that contradicts the declared mapping. Returns the pinned identity value so
2957
+ * the caller can seed the stored identity field exactly as an unmapped upsert
2958
+ * would have done.
2959
+ */
2960
+ const requirePinnedDocumentIdentity = (
2961
+ normalizedFilterArg: Record<string, unknown>,
2962
+ identityFieldArg: string,
2963
+ operationLabelArg: string,
2964
+ ): unknown => {
2965
+ const anchored = readNormalizedFilterEquality(
2966
+ normalizedFilterArg,
2967
+ identityFieldArg,
2968
+ );
2969
+ if (!anchored.found) {
2970
+ throw new SmartdataPersistenceError(
2971
+ 'invalid_argument',
2972
+ `${operationLabelArg} upsert requires an equality anchor on "${identityFieldArg}", which owns the document _id.`,
2973
+ );
2974
+ }
2975
+ return anchored.value;
2976
+ };
2977
+
2978
+ /**
2979
+ * Rewrites equality and operator conditions on a model's `identityAsDocumentId`
2980
+ * field onto `_id`, so the primary key index answers the query instead of a
2981
+ * collection scan. The stored identity field keeps its own value, but every
2982
+ * identity-filtered access is answered through `_id`, so a document written
2983
+ * before the option was declared resolves only if `_id` already equals it.
2984
+ */
2985
+ const mapIdentityFilterToDocumentId = (
2986
+ filterArg: Record<string, unknown>,
2987
+ identityFieldArg: string | undefined,
2988
+ ): Record<string, unknown> => {
2989
+ if (!identityFieldArg || !isPlainObject(filterArg)) {
2990
+ return filterArg;
2991
+ }
2992
+ const carriesExplicitDocumentId = Object.prototype.hasOwnProperty.call(
2993
+ filterArg,
2994
+ '_id',
2995
+ );
2996
+ const mapped: Record<string, unknown> = {};
2997
+ for (const [key, value] of Object.entries(filterArg)) {
2998
+ if (
2999
+ (key === '$and' || key === '$or' || key === '$nor')
3000
+ && Array.isArray(value)
3001
+ ) {
3002
+ mapped[key] = value.map((entryArg) =>
3003
+ isPlainObject(entryArg)
3004
+ ? mapIdentityFilterToDocumentId(
3005
+ entryArg as Record<string, unknown>,
3006
+ identityFieldArg,
3007
+ )
3008
+ : entryArg,
3009
+ );
3010
+ continue;
3011
+ }
3012
+ if (key === '$not' && isPlainObject(value)) {
3013
+ mapped[key] = mapIdentityFilterToDocumentId(
3014
+ value as Record<string, unknown>,
3015
+ identityFieldArg,
3016
+ );
3017
+ continue;
3018
+ }
3019
+ if (key === identityFieldArg && !carriesExplicitDocumentId) {
3020
+ mapped._id = value;
3021
+ continue;
3022
+ }
3023
+ mapped[key] = value;
3024
+ }
3025
+ return mapped;
3026
+ };
3027
+
2717
3028
  const normalizeProjection = <T>(
2718
3029
  projectionArg: TSmartdataProjection<T> | undefined,
2719
3030
  declaredRootsArg: Set<string>,
@@ -3087,6 +3398,27 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
3087
3398
  return new Set(collection.getBoundModelSchema()?.numericFields || []);
3088
3399
  }
3089
3400
 
3401
+ /**
3402
+ * Returns the declared identity field this model stores as the document
3403
+ * `_id`, or undefined when SmartData owns the primary key.
3404
+ */
3405
+ private static getIdentityDocumentIdField(): string | undefined {
3406
+ return getIdentityDocumentIdField(this);
3407
+ }
3408
+
3409
+ /**
3410
+ * Converts an ordinary read filter and routes a declared document-id
3411
+ * identity onto `_id`, so reads use the primary key index.
3412
+ */
3413
+ private static normalizeReadFilter(
3414
+ filterArg: Record<string, any>,
3415
+ ): plugins.mongodb.Filter<plugins.mongodb.Document> {
3416
+ return mapIdentityFilterToDocumentId(
3417
+ convertFilterForMongoDb(filterArg),
3418
+ (this as any).getIdentityDocumentIdField() as string | undefined,
3419
+ ) as plugins.mongodb.Filter<plugins.mongodb.Document>;
3420
+ }
3421
+
3090
3422
  /**
3091
3423
  * Returns every equality key set that globally constrains a single document
3092
3424
  * of this model: each declared identity field on its own, plus the full key
@@ -3531,20 +3863,40 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
3531
3863
  'Atomic update does not support upsert when the filter contains $expr.',
3532
3864
  );
3533
3865
  }
3866
+ const upserting = normalizedOptions?.upsert === true;
3534
3867
  const normalizedUpdate = normalizeAtomicUpdate(
3535
3868
  updateArg,
3536
3869
  declaredRoots,
3537
3870
  declaredAtomicPaths,
3538
3871
  uniqueRoots,
3539
3872
  serializedRoots,
3873
+ upserting ? { identityValueTypes } : undefined,
3874
+ );
3875
+ assertSeededIdentitiesMatchFilter(
3876
+ normalizedFilter,
3877
+ normalizedUpdate,
3878
+ uniqueRoots,
3879
+ 'Atomic update',
3540
3880
  );
3541
3881
  const now = new Date().toISOString();
3542
3882
  normalizedUpdate.$set = {
3543
3883
  ...(normalizedUpdate.$set || {}),
3544
3884
  _updatedAt: now,
3545
3885
  };
3546
- if (normalizedOptions?.upsert === true) {
3886
+ const identityDocumentIdField = (this as any).getIdentityDocumentIdField() as
3887
+ | string
3888
+ | undefined;
3889
+ if (upserting) {
3547
3890
  normalizedUpdate.$setOnInsert = {
3891
+ ...(identityDocumentIdField
3892
+ ? {
3893
+ [identityDocumentIdField]: requirePinnedDocumentIdentity(
3894
+ normalizedFilter,
3895
+ identityDocumentIdField,
3896
+ 'Atomic update',
3897
+ ),
3898
+ }
3899
+ : {}),
3548
3900
  ...(normalizedUpdate.$setOnInsert || {}),
3549
3901
  _createdAt: now,
3550
3902
  };
@@ -3552,7 +3904,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
3552
3904
  const collection: SmartdataCollection<T> = (this as any).collection;
3553
3905
  const result = await executeAtomicUpdate(
3554
3906
  collection,
3555
- normalizedFilter,
3907
+ mapIdentityFilterToDocumentId(normalizedFilter, identityDocumentIdField),
3556
3908
  normalizedUpdate,
3557
3909
  normalizedOptions,
3558
3910
  );
@@ -3615,6 +3967,13 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
3615
3967
  declaredAtomicPaths,
3616
3968
  uniqueRoots,
3617
3969
  serializedRoots,
3970
+ normalizedOptions.upsert ? { identityValueTypes } : undefined,
3971
+ );
3972
+ assertSeededIdentitiesMatchFilter(
3973
+ normalizedFilter,
3974
+ normalizedUpdate,
3975
+ uniqueRoots,
3976
+ 'Atomic find-one-and-update',
3618
3977
  );
3619
3978
  if (policy?.timestamps !== 'none') {
3620
3979
  const now = new Date().toISOString();
@@ -3623,6 +3982,19 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
3623
3982
  normalizedUpdate.$setOnInsert = { ...(normalizedUpdate.$setOnInsert || {}), _createdAt: now };
3624
3983
  }
3625
3984
  }
3985
+ const identityDocumentIdField = (this as any).getIdentityDocumentIdField() as
3986
+ | string
3987
+ | undefined;
3988
+ if (normalizedOptions.upsert && identityDocumentIdField) {
3989
+ normalizedUpdate.$setOnInsert = {
3990
+ [identityDocumentIdField]: requirePinnedDocumentIdentity(
3991
+ normalizedFilter,
3992
+ identityDocumentIdField,
3993
+ 'Atomic find-one-and-update',
3994
+ ),
3995
+ ...(normalizedUpdate.$setOnInsert || {}),
3996
+ };
3997
+ }
3626
3998
  const normalizedSort = normalizeSort(
3627
3999
  normalizedOptions.sort as TSmartdataSort<T> | undefined,
3628
4000
  declaredRoots,
@@ -3631,7 +4003,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
3631
4003
  const collection: SmartdataCollection<T> = (this as any).collection;
3632
4004
  const result = await executeAtomicFindOneAndUpdate(
3633
4005
  collection,
3634
- normalizedFilter,
4006
+ mapIdentityFilterToDocumentId(normalizedFilter, identityDocumentIdField),
3635
4007
  normalizedUpdate,
3636
4008
  {
3637
4009
  returnDocument: normalizedOptions.returnDocument,
@@ -3688,7 +4060,10 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
3688
4060
  const collection: SmartdataCollection<T> = (this as any).collection;
3689
4061
  const result = await executeAtomicDelete(
3690
4062
  collection,
3691
- normalizedFilter,
4063
+ mapIdentityFilterToDocumentId(
4064
+ normalizedFilter,
4065
+ (this as any).getIdentityDocumentIdField() as string | undefined,
4066
+ ),
3692
4067
  { session: opts?.session, timeoutMS: requireBoundedInteger(opts?.timeoutMS, 'Atomic delete timeoutMS', 120_000) },
3693
4068
  );
3694
4069
  return {
@@ -3736,7 +4111,10 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
3736
4111
  const collection: SmartdataCollection<T> = (this as any).collection;
3737
4112
  const result = await executeAtomicDeleteMany(
3738
4113
  collection,
3739
- normalizedFilter,
4114
+ mapIdentityFilterToDocumentId(
4115
+ normalizedFilter,
4116
+ (this as any).getIdentityDocumentIdField() as string | undefined,
4117
+ ),
3740
4118
  { session: opts?.session, timeoutMS: requireBoundedInteger(opts?.timeoutMS, 'Atomic delete-many timeoutMS', 120_000) },
3741
4119
  );
3742
4120
  return {
@@ -3810,7 +4188,10 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
3810
4188
  const collection: SmartdataCollection<T> = (this as any).collection;
3811
4189
  const result = await executeAtomicUpdateMany(
3812
4190
  collection,
3813
- normalizedFilter,
4191
+ mapIdentityFilterToDocumentId(
4192
+ normalizedFilter,
4193
+ (this as any).getIdentityDocumentIdField() as string | undefined,
4194
+ ),
3814
4195
  normalizedUpdate,
3815
4196
  opts,
3816
4197
  );
@@ -3821,6 +4202,172 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
3821
4202
  };
3822
4203
  }
3823
4204
 
4205
+ /**
4206
+ * Atomically upserts a bounded batch of ordinary model documents in one
4207
+ * round trip. Every entry carries its own strict selector and update, and
4208
+ * each entry applies atomically; the batch as a whole is not isolated unless
4209
+ * the caller supplies a transaction session. The complete batch is validated
4210
+ * before any write, so one invalid entry rejects the call without writing.
4211
+ */
4212
+ public static async atomicUpsertMany<T>(
4213
+ this: plugins.tsclass.typeFest.Class<T>,
4214
+ operationsArg: ReadonlyArray<ISmartdataAtomicUpsertManyOperation<T>>,
4215
+ opts?: ISmartdataAtomicUpsertManyOptions,
4216
+ ): Promise<ISmartdataAtomicUpsertManyResult> {
4217
+ if (getOrdinaryPersistencePolicy(this)) {
4218
+ throw new SmartdataPersistenceError('unsupported_operation',
4219
+ 'Validated ordinary models require a transactional postimage update.');
4220
+ }
4221
+ if ((this as any)[exactPersistencePolicySymbol]) {
4222
+ throw new SmartdataPersistenceError(
4223
+ 'unsupported_operation',
4224
+ 'atomicUpsertMany is unavailable for exact-persistence models.',
4225
+ );
4226
+ }
4227
+ if (
4228
+ !Array.isArray(operationsArg)
4229
+ || operationsArg.length < 1
4230
+ || operationsArg.length > 1000
4231
+ ) {
4232
+ throw new SmartdataPersistenceError(
4233
+ 'invalid_argument',
4234
+ 'Atomic upsert-many requires 1 to 1000 operations.',
4235
+ );
4236
+ }
4237
+ if (opts !== undefined) {
4238
+ const optionsValue: unknown = opts;
4239
+ if (!isPlainObject(optionsValue) || plugins.nodeUtil.types.isProxy(optionsValue)) {
4240
+ throw new SmartdataPersistenceError(
4241
+ 'invalid_argument',
4242
+ 'Atomic upsert-many options must be an inert ordinary object.',
4243
+ );
4244
+ }
4245
+ for (const key of Reflect.ownKeys(opts)) {
4246
+ if (
4247
+ typeof key !== 'string'
4248
+ || !['session', 'timeoutMS', 'ordered'].includes(key)
4249
+ ) {
4250
+ throw new SmartdataPersistenceError(
4251
+ 'invalid_argument',
4252
+ `Atomic upsert-many received unsupported option "${String(key)}".`,
4253
+ );
4254
+ }
4255
+ }
4256
+ if (opts.ordered !== undefined && opts.ordered !== false) {
4257
+ throw new SmartdataPersistenceError(
4258
+ 'invalid_argument',
4259
+ 'Atomic upsert-many is always unordered; ordered may only be false.',
4260
+ );
4261
+ }
4262
+ }
4263
+ const timeoutMS = requireBoundedInteger(
4264
+ opts?.timeoutMS,
4265
+ 'Atomic upsert-many timeoutMS',
4266
+ 120_000,
4267
+ );
4268
+ const declaredRoots = (this as any).getDeclaredPersistedRoots() as Set<string>;
4269
+ const declaredAtomicPaths = (this as any).getDeclaredAtomicPaths() as Set<string>;
4270
+ const identityValueTypes = (this as any).getDeclaredIdentityValueTypes() as Map<
4271
+ string,
4272
+ TSmartdataIdentityValueType
4273
+ >;
4274
+ const numericRoots = (this as any).getDeclaredNumericRoots() as Set<string>;
4275
+ const uniqueRoots = new Set(identityValueTypes.keys());
4276
+ const serializedRoots = (this as any).getSerializedPersistedRoots() as Set<string>;
4277
+ const identityDocumentIdField = (this as any).getIdentityDocumentIdField() as
4278
+ | string
4279
+ | undefined;
4280
+ const now = new Date().toISOString();
4281
+ const normalizedOperations: Array<{
4282
+ filter: plugins.mongodb.Filter<plugins.mongodb.Document>;
4283
+ update: plugins.mongodb.UpdateFilter<plugins.mongodb.Document>;
4284
+ }> = [];
4285
+ let bytes = 0;
4286
+ for (const operation of operationsArg) {
4287
+ if (
4288
+ !isPlainObject(operation)
4289
+ || plugins.nodeUtil.types.isProxy(operation)
4290
+ || Object.keys(operation).some(
4291
+ (keyArg) => !['filter', 'update'].includes(keyArg),
4292
+ )
4293
+ ) {
4294
+ throw new SmartdataPersistenceError(
4295
+ 'invalid_argument',
4296
+ 'Atomic upsert-many operations must be inert objects with a filter and an update.',
4297
+ );
4298
+ }
4299
+ const normalizedFilter = normalizeStrictFilter(
4300
+ operation.filter as Record<string, unknown>,
4301
+ declaredRoots,
4302
+ declaredAtomicPaths,
4303
+ identityValueTypes,
4304
+ numericRoots,
4305
+ serializedRoots,
4306
+ 'Atomic upsert-many',
4307
+ );
4308
+ if ('$expr' in normalizedFilter) {
4309
+ throw new SmartdataPersistenceError(
4310
+ 'invalid_argument',
4311
+ 'Atomic upsert-many does not support filters that contain $expr.',
4312
+ );
4313
+ }
4314
+ const normalizedUpdate = normalizeAtomicUpdate(
4315
+ operation.update as ISmartdataAtomicUpdate<T>,
4316
+ declaredRoots,
4317
+ declaredAtomicPaths,
4318
+ uniqueRoots,
4319
+ serializedRoots,
4320
+ { identityValueTypes },
4321
+ );
4322
+ assertSeededIdentitiesMatchFilter(
4323
+ normalizedFilter,
4324
+ normalizedUpdate,
4325
+ uniqueRoots,
4326
+ 'Atomic upsert-many',
4327
+ );
4328
+ normalizedUpdate.$set = {
4329
+ ...(normalizedUpdate.$set || {}),
4330
+ _updatedAt: now,
4331
+ };
4332
+ normalizedUpdate.$setOnInsert = {
4333
+ ...(identityDocumentIdField
4334
+ ? {
4335
+ [identityDocumentIdField]: requirePinnedDocumentIdentity(
4336
+ normalizedFilter,
4337
+ identityDocumentIdField,
4338
+ 'Atomic upsert-many',
4339
+ ),
4340
+ }
4341
+ : {}),
4342
+ ...(normalizedUpdate.$setOnInsert || {}),
4343
+ _createdAt: now,
4344
+ };
4345
+ const mappedFilter = mapIdentityFilterToDocumentId(
4346
+ normalizedFilter,
4347
+ identityDocumentIdField,
4348
+ );
4349
+ bytes += plugins.mongodb.BSON.calculateObjectSize({
4350
+ q: mappedFilter,
4351
+ u: normalizedUpdate,
4352
+ });
4353
+ if (bytes > 16 * 1024 * 1024) {
4354
+ throw new SmartdataPersistenceError(
4355
+ 'invalid_argument',
4356
+ 'Atomic upsert-many accepts at most 16 MiB of serialized operations.',
4357
+ );
4358
+ }
4359
+ normalizedOperations.push({
4360
+ filter: mappedFilter as plugins.mongodb.Filter<plugins.mongodb.Document>,
4361
+ update: normalizedUpdate,
4362
+ });
4363
+ }
4364
+ const collection: SmartdataCollection<T> = (this as any).collection;
4365
+ return await executeAtomicUpsertMany(collection, normalizedOperations, {
4366
+ session: opts?.session,
4367
+ timeoutMS,
4368
+ });
4369
+ }
4370
+
3824
4371
  /**
3825
4372
  * Computes bounded grouped counts and optional numeric sums server-side.
3826
4373
  * Groups by one or two declared top-level fields, always returns a `count`
@@ -4001,7 +4548,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4001
4548
  const collection: SmartdataCollection<T> = (this as any).collection;
4002
4549
  const rows = await collection.aggregateGroupedTotals(
4003
4550
  filterOption !== undefined
4004
- ? convertFilterForMongoDb(filterOption)
4551
+ ? (this as any).normalizeReadFilter(filterOption)
4005
4552
  : undefined,
4006
4553
  groupStage,
4007
4554
  limit + 1,
@@ -4073,7 +4620,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4073
4620
  ): Promise<T[]> {
4074
4621
  // Pass session through to findAll for transactional queries
4075
4622
  const foundDocs = await (this as any).collection.findAll(
4076
- convertFilterForMongoDb(filterArg),
4623
+ (this as any).normalizeReadFilter(filterArg),
4077
4624
  { session: opts?.session },
4078
4625
  );
4079
4626
  const returnArray: T[] = [];
@@ -4122,7 +4669,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4122
4669
  ? optionsArg.limit!
4123
4670
  : 100;
4124
4671
  const limit = Math.min(requestedLimit, 1000);
4125
- const baseSelector = convertFilterForMongoDb(optionsArg.filter || {});
4672
+ const baseSelector = (this as any).normalizeReadFilter(optionsArg.filter || {});
4126
4673
 
4127
4674
  let selector: any = baseSelector;
4128
4675
  if (optionsArg.cursor) {
@@ -4194,7 +4741,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4194
4741
  const declaredRoots = (this as any).getDeclaredPersistedRoots() as Set<string>;
4195
4742
  // Retrieve one document, with optional session for transactions
4196
4743
  const foundDoc = await (this as any).collection.findOne(
4197
- convertFilterForMongoDb(filterArg),
4744
+ (this as any).normalizeReadFilter(filterArg),
4198
4745
  {
4199
4746
  projection: normalizeProjection(
4200
4747
  opts?.projection,
@@ -4226,7 +4773,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4226
4773
  },
4227
4774
  ): Promise<boolean> {
4228
4775
  const foundDoc = await (this as any).collection.findOne(
4229
- convertFilterForMongoDb(filterArg),
4776
+ (this as any).normalizeReadFilter(filterArg),
4230
4777
  {
4231
4778
  projection: { _id: 1 },
4232
4779
  maxTimeMS: requireBoundedInteger(
@@ -4291,7 +4838,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4291
4838
  const collection: SmartdataCollection<T> = (this as any).collection;
4292
4839
  await collection.init();
4293
4840
  let rawCursor: plugins.mongodb.FindCursor<any> =
4294
- collection.mongoDbCollection.find(convertFilterForMongoDb(filterArg), {
4841
+ collection.mongoDbCollection.find((this as any).normalizeReadFilter(filterArg), {
4295
4842
  projection,
4296
4843
  session,
4297
4844
  hint,
@@ -4365,7 +4912,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4365
4912
  }
4366
4913
  const timeoutMS = requireBoundedInteger(opts?.timeoutMS === undefined ? 5_000 : opts.timeoutMS, 'timeoutMS', 120_000);
4367
4914
  const maxTimeMS = requireBoundedInteger(opts?.maxTimeMS, 'maxTimeMS', 120_000);
4368
- const filter = convertFilterForMongoDb(filterArg);
4915
+ const filter = (this as any).normalizeReadFilter(filterArg);
4369
4916
  opts.signal?.throwIfAborted();
4370
4917
  const collection: SmartdataCollection<T> = (this as any).collection;
4371
4918
  if (!collection.isInitializedForCurrentDatabase()) {
@@ -4410,7 +4957,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4410
4957
  ): Promise<SmartdataDbWatcher<T>> {
4411
4958
  const collection: SmartdataCollection<T> = (this as any).collection;
4412
4959
  const watcher: SmartdataDbWatcher<T> = await collection.watch(
4413
- convertFilterForMongoDb(filterArg),
4960
+ (this as any).normalizeReadFilter(filterArg),
4414
4961
  opts || {},
4415
4962
  this as any,
4416
4963
  );
@@ -4440,7 +4987,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4440
4987
  ) {
4441
4988
  const hint = normalizeQueryHint(opts?.hint, this);
4442
4989
  const collection: SmartdataCollection<T> = (this as any).collection;
4443
- return await collection.getCount(convertFilterForMongoDb(filterArg), {
4990
+ return await collection.getCount((this as any).normalizeReadFilter(filterArg), {
4444
4991
  hint,
4445
4992
  limit: requireBoundedInteger(opts?.limit, 'limit', 10_000),
4446
4993
  maxTimeMS: requireBoundedInteger(
@@ -4913,7 +5460,14 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
4913
5460
  this.identityValueTypes,
4914
5461
  );
4915
5462
  const identifiableObject: any = {}; // is not exposed to outside, so any is ok here
5463
+ const identityDocumentIdField = getIdentityDocumentIdField(this.constructor);
4916
5464
  for (const propertyNameString of this.uniqueIndexes || []) {
5465
+ if (propertyNameString === identityDocumentIdField) {
5466
+ // The declared identity is the stored primary key, so instance writes
5467
+ // and reads address the document through _id.
5468
+ identifiableObject._id = this[propertyNameString];
5469
+ continue;
5470
+ }
4917
5471
  identifiableObject[propertyNameString] = this[propertyNameString];
4918
5472
  }
4919
5473
  if (getOrdinaryPersistencePolicy(this.constructor)?.idType === 'string') {