@lossless.org/client 1.1.0 → 1.3.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.
- package/.smartconfig.json +1 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/nosqldb/classes.atomicdelete.js +3 -3
- package/dist_ts/nosqldb/classes.atomicfindoneandupdate.d.ts +20 -0
- package/dist_ts/nosqldb/classes.atomicfindoneandupdate.js +45 -2
- package/dist_ts/nosqldb/classes.atomicupdate.js +4 -4
- package/dist_ts/nosqldb/classes.collection.d.ts +37 -1
- package/dist_ts/nosqldb/classes.collection.js +235 -30
- package/dist_ts/nosqldb/classes.collectiontopology.d.ts +19 -0
- package/dist_ts/nosqldb/classes.collectiontopology.js +29 -3
- package/dist_ts/nosqldb/classes.cursor.d.ts +12 -1
- package/dist_ts/nosqldb/classes.cursor.js +20 -3
- package/dist_ts/nosqldb/classes.doc.d.ts +138 -11
- package/dist_ts/nosqldb/classes.doc.js +490 -91
- package/dist_ts/nosqldb/classes.exactpersistence.js +10 -2
- package/dist_ts/nosqldb/classes.namespaceinspection.js +2 -2
- package/dist_ts/nosqldb/classes.persistence.d.ts +6 -0
- package/dist_ts/nosqldb/classes.persistence.js +13 -1
- package/dist_ts/nosqldb/classes.session.d.ts +15 -1
- package/dist_ts/nosqldb/classes.session.js +32 -6
- package/package.json +4 -1
- package/readme.md +38 -2
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/nosqldb/classes.atomicdelete.ts +2 -4
- package/ts/nosqldb/classes.atomicfindoneandupdate.ts +69 -2
- package/ts/nosqldb/classes.atomicupdate.ts +3 -6
- package/ts/nosqldb/classes.collection.ts +372 -35
- package/ts/nosqldb/classes.collectiontopology.ts +56 -1
- package/ts/nosqldb/classes.cursor.ts +22 -2
- package/ts/nosqldb/classes.doc.ts +819 -132
- package/ts/nosqldb/classes.exactpersistence.ts +9 -1
- package/ts/nosqldb/classes.namespaceinspection.ts +1 -1
- package/ts/nosqldb/classes.persistence.ts +17 -0
- package/ts/nosqldb/classes.session.ts +52 -13
|
@@ -15,6 +15,7 @@ import { SmartdataDbCursor } from './classes.cursor.js';
|
|
|
15
15
|
import {
|
|
16
16
|
SmartDataDbDoc,
|
|
17
17
|
type IIndexOptions,
|
|
18
|
+
type SvDbOptions,
|
|
18
19
|
type TSmartdataNumericDocumentPath,
|
|
19
20
|
type TSmartdataIdentityValueType,
|
|
20
21
|
} from './classes.doc.js';
|
|
@@ -23,10 +24,12 @@ import { CollectionFactory } from './classes.collectionfactory.js';
|
|
|
23
24
|
import { logger } from './logging.js';
|
|
24
25
|
import {
|
|
25
26
|
SmartdataPersistenceError,
|
|
27
|
+
isMongoIndexConflictError,
|
|
26
28
|
normalizeOrdinaryPersistenceError,
|
|
27
29
|
} from './classes.persistence.js';
|
|
28
30
|
import { notifyCollectionReconnect } from './classes.collectionlifecycle.js';
|
|
29
31
|
import {
|
|
32
|
+
leaseOrdinarySmartdataSession,
|
|
30
33
|
runWithOrdinarySmartdataSession,
|
|
31
34
|
type TSmartdataOrdinarySession,
|
|
32
35
|
} from './classes.session.js';
|
|
@@ -34,6 +37,8 @@ import {
|
|
|
34
37
|
collectionModelSchemaResolverSymbol,
|
|
35
38
|
collectionTopologyDbInspectionSymbol,
|
|
36
39
|
getExpectedCollectionTopologyForSchema,
|
|
40
|
+
getIdentityIndexName,
|
|
41
|
+
isIdentityIndexFor,
|
|
37
42
|
compareSmartdataTopologyStrings,
|
|
38
43
|
} from './classes.collectiontopology.js';
|
|
39
44
|
|
|
@@ -66,6 +71,14 @@ export interface ICollectionBindingOptions {
|
|
|
66
71
|
* `_id === document[field]`.
|
|
67
72
|
*/
|
|
68
73
|
identityAsDocumentId?: string;
|
|
74
|
+
/**
|
|
75
|
+
* Names of installed indexes this model accepts without declaring them —
|
|
76
|
+
* typically migration-owned indexes SmartData does not express, such as
|
|
77
|
+
* partial or filtered unique indexes. A tolerated index is never created,
|
|
78
|
+
* never dropped and never verified beyond its name, and it no longer makes
|
|
79
|
+
* the collection topology divergent.
|
|
80
|
+
*/
|
|
81
|
+
toleratedIndexNames?: ReadonlyArray<string>;
|
|
69
82
|
}
|
|
70
83
|
|
|
71
84
|
export type TCollectionModelIndexDirection = 1 | -1 | 'text';
|
|
@@ -109,6 +122,16 @@ export interface ICollectionModelConfig<TModel extends object = any> {
|
|
|
109
122
|
* numeric-field constraint.
|
|
110
123
|
*/
|
|
111
124
|
numericFields?: ReadonlyArray<TSmartdataNumericDocumentPath<TModel>>;
|
|
125
|
+
/**
|
|
126
|
+
* Persisted top-level fields that only atomic operations may overwrite. Every
|
|
127
|
+
* write that creates a document stores them — `insert()`, `insertMany()`,
|
|
128
|
+
* `insertManyIfAbsent()` and the insert branch of `save()` — and they read
|
|
129
|
+
* like any other declared field, but a `save()` that matches a stored
|
|
130
|
+
* document leaves them untouched, so a stale instance cannot overwrite a
|
|
131
|
+
* value concurrent atomic writers own. An identity field cannot be
|
|
132
|
+
* atomic-only.
|
|
133
|
+
*/
|
|
134
|
+
atomicOnlyFields?: ReadonlyArray<keyof TModel & string>;
|
|
112
135
|
/**
|
|
113
136
|
* Persisted string identity fields with @unI()-equivalent selector and
|
|
114
137
|
* immutability semantics. Each field requires an explicit single-field,
|
|
@@ -130,6 +153,15 @@ export interface ICollectionModelConfig<TModel extends object = any> {
|
|
|
130
153
|
* Stable named MongoDB indexes. Object property order defines key order.
|
|
131
154
|
*/
|
|
132
155
|
indexes?: ReadonlyArray<ICollectionModelIndex>;
|
|
156
|
+
/**
|
|
157
|
+
* Names of installed indexes this model does not declare but accepts —
|
|
158
|
+
* typically indexes a migration owns, such as partial or filtered unique
|
|
159
|
+
* indexes SmartData does not express. A tolerated index is never created,
|
|
160
|
+
* never dropped and never verified beyond its name, and it no longer makes
|
|
161
|
+
* the collection topology divergent. Every undeclared index outside this
|
|
162
|
+
* list still does.
|
|
163
|
+
*/
|
|
164
|
+
toleratedIndexNames?: ReadonlyArray<string>;
|
|
133
165
|
}
|
|
134
166
|
|
|
135
167
|
export interface INormalizedCollectionModelSchema {
|
|
@@ -137,6 +169,8 @@ export interface INormalizedCollectionModelSchema {
|
|
|
137
169
|
readonly collectionName: string;
|
|
138
170
|
readonly persistedFields: readonly string[];
|
|
139
171
|
readonly numericFields: readonly string[];
|
|
172
|
+
/** Declared persisted fields a matching instance `save()` never overwrites. */
|
|
173
|
+
readonly atomicOnlyFields: readonly string[];
|
|
140
174
|
readonly identityFields: readonly string[];
|
|
141
175
|
/** Declared identity field stored as the document `_id`, if any. */
|
|
142
176
|
readonly identityAsDocumentId?: string;
|
|
@@ -151,6 +185,8 @@ export interface INormalizedCollectionModelSchema {
|
|
|
151
185
|
>;
|
|
152
186
|
readonly options: Readonly<IIndexOptions>;
|
|
153
187
|
}>;
|
|
188
|
+
/** Undeclared installed indexes this model accepts by name, never touches. */
|
|
189
|
+
readonly toleratedIndexNames: readonly string[];
|
|
154
190
|
readonly fingerprint: string;
|
|
155
191
|
}
|
|
156
192
|
|
|
@@ -180,6 +216,7 @@ interface ISmartdataDecoratorMetadata {
|
|
|
180
216
|
saveableProperties?: string[];
|
|
181
217
|
uniqueIndexes?: string[];
|
|
182
218
|
identityValueTypes?: Record<string, TSmartdataIdentityValueType>;
|
|
219
|
+
identityIndexNames?: Record<string, string>;
|
|
183
220
|
regularIndexes?: Array<{field: string, options: IIndexOptions}>;
|
|
184
221
|
compoundIndexes?: ICompoundIndexDefinition[];
|
|
185
222
|
namedIndexes?: INamedIndexDefinition[];
|
|
@@ -340,10 +377,12 @@ const normalizeCollectionModelSchema = (
|
|
|
340
377
|
'collectionName',
|
|
341
378
|
'persistedFields',
|
|
342
379
|
'numericFields',
|
|
380
|
+
'atomicOnlyFields',
|
|
343
381
|
'identityFields',
|
|
344
382
|
'identityAsDocumentId',
|
|
345
383
|
'searchableFields',
|
|
346
384
|
'indexes',
|
|
385
|
+
'toleratedIndexNames',
|
|
347
386
|
]);
|
|
348
387
|
for (const key of Object.keys(configArg)) {
|
|
349
388
|
if (!allowedConfigKeys.has(key)) {
|
|
@@ -432,6 +471,37 @@ const normalizeCollectionModelSchema = (
|
|
|
432
471
|
);
|
|
433
472
|
}
|
|
434
473
|
}
|
|
474
|
+
if (
|
|
475
|
+
configArg.atomicOnlyFields !== undefined &&
|
|
476
|
+
!Array.isArray(configArg.atomicOnlyFields)
|
|
477
|
+
) {
|
|
478
|
+
throw new SmartdataPersistenceError(
|
|
479
|
+
'invalid_configuration',
|
|
480
|
+
'Collection model atomicOnlyFields must be an array.',
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
const atomicOnlyFields: string[] = [];
|
|
484
|
+
for (const field of configArg.atomicOnlyFields || []) {
|
|
485
|
+
requireSafeFieldName(field, 'An atomic-only field');
|
|
486
|
+
if (!persistedFields.includes(field)) {
|
|
487
|
+
throw new SmartdataPersistenceError(
|
|
488
|
+
'invalid_configuration',
|
|
489
|
+
`Atomic-only field "${field}" is not a declared persisted field.`,
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
if (identityFields.includes(field)) {
|
|
493
|
+
// An identity is immutable and addresses the document, so it is written
|
|
494
|
+
// by the insert that creates it and by nothing else afterwards. Making
|
|
495
|
+
// it atomic-only would promise an atomic write that is always refused.
|
|
496
|
+
throw new SmartdataPersistenceError(
|
|
497
|
+
'invalid_configuration',
|
|
498
|
+
`Identity field "${field}" cannot be atomic-only.`,
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
if (!atomicOnlyFields.includes(field)) {
|
|
502
|
+
atomicOnlyFields.push(field);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
435
505
|
const identityValueTypes: Record<string, TSmartdataIdentityValueType> = {};
|
|
436
506
|
for (const field of Object.keys(identityValueTypesArg || {})) {
|
|
437
507
|
if (!identityFields.includes(field)) {
|
|
@@ -594,12 +664,8 @@ const normalizeCollectionModelSchema = (
|
|
|
594
664
|
);
|
|
595
665
|
}
|
|
596
666
|
for (const identityField of identityFields) {
|
|
597
|
-
const hasIdentityIndex = [...indexesByName.values()].some(
|
|
598
|
-
(indexArg)
|
|
599
|
-
indexArg.options.unique === true &&
|
|
600
|
-
indexArg.key.length === 1 &&
|
|
601
|
-
indexArg.key[0][0] === identityField &&
|
|
602
|
-
indexArg.key[0][1] === 1,
|
|
667
|
+
const hasIdentityIndex = [...indexesByName.values()].some((indexArg) =>
|
|
668
|
+
isIdentityIndexFor(indexArg, identityField),
|
|
603
669
|
);
|
|
604
670
|
if (identityField === identityAsDocumentId) {
|
|
605
671
|
// The primary key is the only uniqueness authority for this field. A
|
|
@@ -627,16 +693,55 @@ const normalizeCollectionModelSchema = (
|
|
|
627
693
|
);
|
|
628
694
|
}
|
|
629
695
|
}
|
|
696
|
+
if (
|
|
697
|
+
configArg.toleratedIndexNames !== undefined &&
|
|
698
|
+
!Array.isArray(configArg.toleratedIndexNames)
|
|
699
|
+
) {
|
|
700
|
+
throw new SmartdataPersistenceError(
|
|
701
|
+
'invalid_configuration',
|
|
702
|
+
'Collection model toleratedIndexNames must be an array.',
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
const toleratedIndexNames: string[] = [];
|
|
706
|
+
for (const name of configArg.toleratedIndexNames || []) {
|
|
707
|
+
if (typeof name !== 'string' || name.trim().length === 0 || name.includes('\0')) {
|
|
708
|
+
throw new SmartdataPersistenceError(
|
|
709
|
+
'invalid_configuration',
|
|
710
|
+
'Every tolerated index name must be a non-empty safe name.',
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
if (name === '_id_') {
|
|
714
|
+
// The primary-key index is verified for every collection; tolerating it
|
|
715
|
+
// would accept a changed _id index in silence.
|
|
716
|
+
throw new SmartdataPersistenceError(
|
|
717
|
+
'invalid_configuration',
|
|
718
|
+
'Index name "_id_" cannot be tolerated; SmartData always verifies the primary-key index.',
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
if (indexesByName.has(name)) {
|
|
722
|
+
// A declared index is created and verified. Tolerating the same name
|
|
723
|
+
// would state both contracts for one index.
|
|
724
|
+
throw new SmartdataPersistenceError(
|
|
725
|
+
'invalid_configuration',
|
|
726
|
+
`Index "${name}" cannot be tolerated and declared at the same time.`,
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
if (!toleratedIndexNames.includes(name)) {
|
|
730
|
+
toleratedIndexNames.push(name);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
630
733
|
const normalizedCore = {
|
|
631
734
|
ordinaryPersistence: ordinaryPolicyArg,
|
|
632
735
|
collectionName,
|
|
633
736
|
persistedFields: Object.freeze([...persistedFields]),
|
|
634
737
|
numericFields: Object.freeze([...numericFields]),
|
|
738
|
+
atomicOnlyFields: Object.freeze([...atomicOnlyFields]),
|
|
635
739
|
identityFields: Object.freeze([...identityFields]),
|
|
636
740
|
...(identityAsDocumentId !== undefined ? { identityAsDocumentId } : {}),
|
|
637
741
|
identityValueTypes: Object.freeze({ ...identityValueTypes }),
|
|
638
742
|
searchableFields: Object.freeze([...searchableFields]),
|
|
639
743
|
indexes: Object.freeze([...indexesByName.values()]),
|
|
744
|
+
toleratedIndexNames: Object.freeze([...toleratedIndexNames]),
|
|
640
745
|
};
|
|
641
746
|
return Object.freeze({
|
|
642
747
|
...normalizedCore,
|
|
@@ -647,6 +752,7 @@ const normalizeCollectionModelSchema = (
|
|
|
647
752
|
collectionName,
|
|
648
753
|
persistedFields: [...persistedFields].sort(compareSmartdataTopologyStrings),
|
|
649
754
|
numericFields: [...numericFields].sort(compareSmartdataTopologyStrings),
|
|
755
|
+
atomicOnlyFields: [...atomicOnlyFields].sort(compareSmartdataTopologyStrings),
|
|
650
756
|
identityFields: [...identityFields].sort(compareSmartdataTopologyStrings),
|
|
651
757
|
...(identityAsDocumentId !== undefined ? { identityAsDocumentId } : {}),
|
|
652
758
|
identityValueTypes,
|
|
@@ -656,6 +762,9 @@ const normalizeCollectionModelSchema = (
|
|
|
656
762
|
indexes: [...indexesByName.values()].sort((leftArg, rightArg) =>
|
|
657
763
|
compareSmartdataTopologyStrings(leftArg.name, rightArg.name),
|
|
658
764
|
),
|
|
765
|
+
toleratedIndexNames: [...toleratedIndexNames].sort(
|
|
766
|
+
compareSmartdataTopologyStrings,
|
|
767
|
+
),
|
|
659
768
|
}),
|
|
660
769
|
});
|
|
661
770
|
};
|
|
@@ -682,22 +791,26 @@ const mergeStringArrays = (...arrays: Array<string[] | undefined>): string[] =>
|
|
|
682
791
|
return merged;
|
|
683
792
|
};
|
|
684
793
|
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
794
|
+
/**
|
|
795
|
+
* Merges a per-identity-field declaration across an inheritance chain.
|
|
796
|
+
* Divergence is refused instead of resolved by declaration order, because
|
|
797
|
+
* either winner would silently change a subclass's stored contract.
|
|
798
|
+
*/
|
|
799
|
+
const mergeIdentityDeclarations = <TDeclaration extends string>(
|
|
800
|
+
declarationArg: 'valueType' | 'indexName',
|
|
801
|
+
...declarationMapsArg: Array<Record<string, TDeclaration> | undefined>
|
|
802
|
+
): Record<string, TDeclaration> => {
|
|
803
|
+
const merged: Record<string, TDeclaration> = {};
|
|
804
|
+
for (const declarationMap of declarationMapsArg) {
|
|
805
|
+
for (const [field, declaration] of Object.entries(declarationMap || {})) {
|
|
693
806
|
const existing = merged[field];
|
|
694
|
-
if (existing && existing !==
|
|
807
|
+
if (existing && existing !== declaration) {
|
|
695
808
|
throw new SmartdataPersistenceError(
|
|
696
809
|
'invalid_configuration',
|
|
697
|
-
`Identity field "${field}" has divergent inherited
|
|
810
|
+
`Identity field "${field}" has divergent inherited ${declarationArg} declarations.`,
|
|
698
811
|
);
|
|
699
812
|
}
|
|
700
|
-
merged[field] =
|
|
813
|
+
merged[field] = declaration;
|
|
701
814
|
}
|
|
702
815
|
}
|
|
703
816
|
return merged;
|
|
@@ -795,7 +908,8 @@ const mergeDecoratorMetadata = (
|
|
|
795
908
|
merged.uniqueIndexes = mergeStringArrays(
|
|
796
909
|
...metadataArgs.map((metadataArg) => getOwnMetadataValue<string[]>(metadataArg, 'uniqueIndexes')),
|
|
797
910
|
);
|
|
798
|
-
merged.identityValueTypes =
|
|
911
|
+
merged.identityValueTypes = mergeIdentityDeclarations(
|
|
912
|
+
'valueType',
|
|
799
913
|
...metadataArgs.map((metadataArg) =>
|
|
800
914
|
getOwnMetadataValue<Record<string, TSmartdataIdentityValueType>>(
|
|
801
915
|
metadataArg,
|
|
@@ -803,6 +917,15 @@ const mergeDecoratorMetadata = (
|
|
|
803
917
|
),
|
|
804
918
|
),
|
|
805
919
|
);
|
|
920
|
+
merged.identityIndexNames = mergeIdentityDeclarations(
|
|
921
|
+
'indexName',
|
|
922
|
+
...metadataArgs.map((metadataArg) =>
|
|
923
|
+
getOwnMetadataValue<Record<string, string>>(
|
|
924
|
+
metadataArg,
|
|
925
|
+
'identityIndexNames',
|
|
926
|
+
),
|
|
927
|
+
),
|
|
928
|
+
);
|
|
806
929
|
merged.searchableFields = mergeStringArrays(
|
|
807
930
|
...metadataArgs.map((metadataArg) => getOwnMetadataValue<string[]>(metadataArg, 'searchableFields')),
|
|
808
931
|
);
|
|
@@ -826,7 +949,22 @@ const mergeDecoratorMetadata = (
|
|
|
826
949
|
for (const metadataArg of metadataArgs) {
|
|
827
950
|
const options = getOwnMetadataValue<Record<string, any>>(metadataArg, '_svDbOptions');
|
|
828
951
|
if (options) {
|
|
829
|
-
Object.
|
|
952
|
+
for (const [field, fieldOptions] of Object.entries(options)) {
|
|
953
|
+
const inherited = svDbOptions[field];
|
|
954
|
+
// A later declaration replaces the inherited options wholesale, which
|
|
955
|
+
// would silently drop an inherited atomic-only contract and let a
|
|
956
|
+
// subclass's save() overwrite an atomically owned value.
|
|
957
|
+
if (
|
|
958
|
+
inherited
|
|
959
|
+
&& Boolean(inherited.atomicOnly) !== Boolean(fieldOptions?.atomicOnly)
|
|
960
|
+
) {
|
|
961
|
+
throw new SmartdataPersistenceError(
|
|
962
|
+
'invalid_configuration',
|
|
963
|
+
`Persisted field "${field}" has divergent inherited atomicOnly declarations.`,
|
|
964
|
+
);
|
|
965
|
+
}
|
|
966
|
+
svDbOptions[field] = fieldOptions;
|
|
967
|
+
}
|
|
830
968
|
}
|
|
831
969
|
}
|
|
832
970
|
if (Object.keys(svDbOptions).length > 0) {
|
|
@@ -909,10 +1047,44 @@ const definedModelSchemaSymbol = Symbol.for(
|
|
|
909
1047
|
'@push.rocks/smartdata.definedCollectionModelSchema',
|
|
910
1048
|
);
|
|
911
1049
|
|
|
1050
|
+
/**
|
|
1051
|
+
* Reads a model's merged `@svDb()` field options from decorator metadata.
|
|
1052
|
+
* Unlike the constructor's `_svDbOptions`, this is available before the first
|
|
1053
|
+
* instance exists, so a programmatic declaration can be checked against the
|
|
1054
|
+
* decorators on the same class.
|
|
1055
|
+
*/
|
|
1056
|
+
const readDeclaredSvDbOptions = (
|
|
1057
|
+
constructorArg: TCollectionModelConstructor<any>,
|
|
1058
|
+
): Record<string, SvDbOptions> => {
|
|
1059
|
+
const ownMetadata = Object.prototype.hasOwnProperty.call(
|
|
1060
|
+
constructorArg,
|
|
1061
|
+
(Symbol as any).metadata,
|
|
1062
|
+
)
|
|
1063
|
+
? ((constructorArg as any)[(Symbol as any).metadata] as
|
|
1064
|
+
| ISmartdataDecoratorMetadata
|
|
1065
|
+
| undefined)
|
|
1066
|
+
: undefined;
|
|
1067
|
+
return (
|
|
1068
|
+
mergeDecoratorMetadata(
|
|
1069
|
+
collectInheritedDecoratorMetadata(constructorArg),
|
|
1070
|
+
ownMetadata,
|
|
1071
|
+
)._svDbOptions as Record<string, SvDbOptions> | undefined
|
|
1072
|
+
) || {};
|
|
1073
|
+
};
|
|
1074
|
+
|
|
1075
|
+
/** Declared persisted fields a model marks `@svDb({ atomicOnly: true })`. */
|
|
1076
|
+
const readDecoratedAtomicOnlyFields = (
|
|
1077
|
+
svDbOptionsArg: Record<string, SvDbOptions>,
|
|
1078
|
+
): string[] =>
|
|
1079
|
+
Object.entries(svDbOptionsArg)
|
|
1080
|
+
.filter(([, optionsArg]) => optionsArg?.atomicOnly === true)
|
|
1081
|
+
.map(([fieldArg]) => fieldArg);
|
|
1082
|
+
|
|
912
1083
|
const schemaFromDecoratorMetadata = (
|
|
913
1084
|
constructorArg: TCollectionModelConstructor<any>,
|
|
914
1085
|
collectionNameArg: string,
|
|
915
1086
|
identityAsDocumentIdArg?: string,
|
|
1087
|
+
toleratedIndexNamesArg?: ReadonlyArray<string>,
|
|
916
1088
|
): INormalizedCollectionModelSchema => {
|
|
917
1089
|
const ownMetadata = Object.prototype.hasOwnProperty.call(
|
|
918
1090
|
constructorArg,
|
|
@@ -942,15 +1114,25 @@ const schemaFromDecoratorMetadata = (
|
|
|
942
1114
|
const numericFields = Object.entries(metadata._svDbOptions || {})
|
|
943
1115
|
.filter(([, optionsArg]) => optionsArg?.numeric === true)
|
|
944
1116
|
.map(([fieldArg]) => fieldArg);
|
|
1117
|
+
const atomicOnlyFields = readDecoratedAtomicOnlyFields(
|
|
1118
|
+
(metadata._svDbOptions as Record<string, SvDbOptions> | undefined) || {},
|
|
1119
|
+
);
|
|
945
1120
|
const indexes: ICollectionModelIndex[] = [];
|
|
946
1121
|
for (const uniqueField of metadata.uniqueIndexes || []) {
|
|
1122
|
+
const declaredIndexName = metadata.identityIndexNames?.[uniqueField];
|
|
947
1123
|
if (uniqueField === identityAsDocumentIdArg) {
|
|
948
1124
|
// The document _id enforces this identity; a derived unique index would
|
|
949
1125
|
// duplicate the primary key and require an index build on live data.
|
|
1126
|
+
if (declaredIndexName !== undefined) {
|
|
1127
|
+
throw new SmartdataPersistenceError(
|
|
1128
|
+
'invalid_configuration',
|
|
1129
|
+
`Identity field "${uniqueField}" owns the document _id and must not name a unique index.`,
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
950
1132
|
continue;
|
|
951
1133
|
}
|
|
952
1134
|
indexes.push({
|
|
953
|
-
name: `${uniqueField}_1`,
|
|
1135
|
+
name: declaredIndexName ?? `${uniqueField}_1`,
|
|
954
1136
|
key: { [uniqueField]: 1 },
|
|
955
1137
|
options: { unique: true },
|
|
956
1138
|
});
|
|
@@ -987,12 +1169,16 @@ const schemaFromDecoratorMetadata = (
|
|
|
987
1169
|
collectionName: collectionNameArg,
|
|
988
1170
|
persistedFields,
|
|
989
1171
|
numericFields,
|
|
1172
|
+
atomicOnlyFields,
|
|
990
1173
|
identityFields: metadata.uniqueIndexes || [],
|
|
991
1174
|
...(identityAsDocumentIdArg !== undefined
|
|
992
1175
|
? { identityAsDocumentId: identityAsDocumentIdArg }
|
|
993
1176
|
: {}),
|
|
994
1177
|
searchableFields: metadata.searchableFields || [],
|
|
995
1178
|
indexes,
|
|
1179
|
+
...(toleratedIndexNamesArg !== undefined
|
|
1180
|
+
? { toleratedIndexNames: toleratedIndexNamesArg }
|
|
1181
|
+
: {}),
|
|
996
1182
|
},
|
|
997
1183
|
true,
|
|
998
1184
|
metadata.identityValueTypes,
|
|
@@ -1117,6 +1303,30 @@ export function defineCollectionModel<
|
|
|
1117
1303
|
if (identityAsDocumentId !== undefined) {
|
|
1118
1304
|
assertIdentityDocumentIdIsOrdinary(modelArg, identityAsDocumentId);
|
|
1119
1305
|
}
|
|
1306
|
+
// Where a field states `atomicOnly` explicitly through @svDb(), the
|
|
1307
|
+
// programmatic declaration must say the same, so neither reader of the model
|
|
1308
|
+
// can be misled about which fields instance save() writes. A plain @svDb()
|
|
1309
|
+
// states nothing about it and leaves the decision to this configuration.
|
|
1310
|
+
const declaredSvDbOptions = readDeclaredSvDbOptions(modelArg);
|
|
1311
|
+
const configuredAtomicOnly = new Set<string>([
|
|
1312
|
+
...(inheritedSchema?.atomicOnlyFields || []),
|
|
1313
|
+
...(configArg.atomicOnlyFields || []),
|
|
1314
|
+
]);
|
|
1315
|
+
for (const [field, fieldOptions] of Object.entries(declaredSvDbOptions)) {
|
|
1316
|
+
if (
|
|
1317
|
+
!fieldOptions
|
|
1318
|
+
|| !Object.prototype.hasOwnProperty.call(fieldOptions, 'atomicOnly')
|
|
1319
|
+
|| fieldOptions.atomicOnly === configuredAtomicOnly.has(field)
|
|
1320
|
+
) {
|
|
1321
|
+
continue;
|
|
1322
|
+
}
|
|
1323
|
+
throw new SmartdataPersistenceError(
|
|
1324
|
+
'invalid_configuration',
|
|
1325
|
+
fieldOptions.atomicOnly
|
|
1326
|
+
? `Persisted field "${field}" is declared atomicOnly by @svDb() but missing from atomicOnlyFields.`
|
|
1327
|
+
: `Atomic-only field "${field}" is declared by @svDb({ atomicOnly: false }).`,
|
|
1328
|
+
);
|
|
1329
|
+
}
|
|
1120
1330
|
const normalizedSchema = normalizeCollectionModelSchema({
|
|
1121
1331
|
...configArg,
|
|
1122
1332
|
persistedFields: [
|
|
@@ -1127,6 +1337,14 @@ export function defineCollectionModel<
|
|
|
1127
1337
|
...(inheritedSchema?.numericFields || []),
|
|
1128
1338
|
...(configArg.numericFields || []),
|
|
1129
1339
|
] as Array<TSmartdataNumericDocumentPath<TModel>>,
|
|
1340
|
+
atomicOnlyFields: [
|
|
1341
|
+
...(inheritedSchema?.atomicOnlyFields || []),
|
|
1342
|
+
...(configArg.atomicOnlyFields || []),
|
|
1343
|
+
] as Array<keyof TModel & string>,
|
|
1344
|
+
toleratedIndexNames: [
|
|
1345
|
+
...(inheritedSchema?.toleratedIndexNames || []),
|
|
1346
|
+
...(configArg.toleratedIndexNames || []),
|
|
1347
|
+
],
|
|
1130
1348
|
identityFields: [
|
|
1131
1349
|
...(inheritedSchema?.identityFields || []),
|
|
1132
1350
|
...(configArg.identityFields || []),
|
|
@@ -1382,6 +1600,7 @@ export function Collection(
|
|
|
1382
1600
|
constructor as TCollectionModelConstructor<any>,
|
|
1383
1601
|
collectionName,
|
|
1384
1602
|
identityAsDocumentId,
|
|
1603
|
+
optionsArg?.toleratedIndexNames,
|
|
1385
1604
|
);
|
|
1386
1605
|
},
|
|
1387
1606
|
) as any;
|
|
@@ -1455,6 +1674,8 @@ export function managed<TManager extends IManager>(
|
|
|
1455
1674
|
return schemaFromDecoratorMetadata(
|
|
1456
1675
|
constructor as TCollectionModelConstructor<any>,
|
|
1457
1676
|
collectionName,
|
|
1677
|
+
undefined,
|
|
1678
|
+
bindingOptions?.toleratedIndexNames,
|
|
1458
1679
|
);
|
|
1459
1680
|
},
|
|
1460
1681
|
);
|
|
@@ -1479,6 +1700,79 @@ export function managed<TManager extends IManager>(
|
|
|
1479
1700
|
*/
|
|
1480
1701
|
export const Manager = managed;
|
|
1481
1702
|
|
|
1703
|
+
/**
|
|
1704
|
+
* The index properties MongoDB weighs besides the key when it decides whether
|
|
1705
|
+
* two indexes collide. An existing index over the declared key that differs in
|
|
1706
|
+
* any of them is not interchangeable with the declaration, so adopting its name
|
|
1707
|
+
* would only collide again.
|
|
1708
|
+
*/
|
|
1709
|
+
const indexEquivalenceSignature = (indexArg: {
|
|
1710
|
+
readonly unique?: unknown;
|
|
1711
|
+
readonly sparse?: unknown;
|
|
1712
|
+
readonly expireAfterSeconds?: unknown;
|
|
1713
|
+
readonly partialFilterExpression?: unknown;
|
|
1714
|
+
}): string =>
|
|
1715
|
+
stableValue({
|
|
1716
|
+
unique: indexArg.unique === true,
|
|
1717
|
+
sparse: indexArg.sparse === true,
|
|
1718
|
+
expireAfterSeconds: indexArg.expireAfterSeconds ?? null,
|
|
1719
|
+
partialFilterExpression: indexArg.partialFilterExpression ?? null,
|
|
1720
|
+
});
|
|
1721
|
+
|
|
1722
|
+
/**
|
|
1723
|
+
* MongoDB reports an index collision without naming the index already in the
|
|
1724
|
+
* namespace, yet the remedy depends on it: adopt that name in the declaration
|
|
1725
|
+
* or drop the index. Only the collision path pays for the extra catalog read.
|
|
1726
|
+
*/
|
|
1727
|
+
const describeIndexCollision = async (
|
|
1728
|
+
targetCollectionArg: plugins.mongodb.Collection,
|
|
1729
|
+
collectionNameArg: string,
|
|
1730
|
+
expectedArg: INormalizedCollectionModelSchema['indexes'][number],
|
|
1731
|
+
errorArg: unknown,
|
|
1732
|
+
): Promise<unknown> => {
|
|
1733
|
+
if (!isMongoIndexConflictError(errorArg)) {
|
|
1734
|
+
return errorArg;
|
|
1735
|
+
}
|
|
1736
|
+
let actualIndexes: plugins.mongodb.Document[];
|
|
1737
|
+
try {
|
|
1738
|
+
actualIndexes = await targetCollectionArg.listIndexes().toArray();
|
|
1739
|
+
} catch {
|
|
1740
|
+
// Without the catalog the collision cannot be attributed, and the driver's
|
|
1741
|
+
// own error stays the best evidence the caller can act on.
|
|
1742
|
+
return errorArg;
|
|
1743
|
+
}
|
|
1744
|
+
const equivalentIndex = actualIndexes.find(
|
|
1745
|
+
(actualArg) =>
|
|
1746
|
+
actualArg.name !== expectedArg.name &&
|
|
1747
|
+
stableValue(Object.entries(actualArg.key || {})) ===
|
|
1748
|
+
stableValue(expectedArg.key) &&
|
|
1749
|
+
indexEquivalenceSignature(actualArg) ===
|
|
1750
|
+
indexEquivalenceSignature(expectedArg.options),
|
|
1751
|
+
);
|
|
1752
|
+
if (equivalentIndex) {
|
|
1753
|
+
return new SmartdataPersistenceError(
|
|
1754
|
+
'invalid_configuration',
|
|
1755
|
+
`Collection "${collectionNameArg}" already carries the equivalent index ` +
|
|
1756
|
+
`"${equivalentIndex.name}", so declared index "${expectedArg.name}" ` +
|
|
1757
|
+
'cannot be created. Declare the existing name — an identity declares ' +
|
|
1758
|
+
'it with @unI({ indexName }) — or drop the existing index.',
|
|
1759
|
+
{ cause: errorArg },
|
|
1760
|
+
);
|
|
1761
|
+
}
|
|
1762
|
+
if (actualIndexes.some((actualArg) => actualArg.name === expectedArg.name)) {
|
|
1763
|
+
return new SmartdataPersistenceError(
|
|
1764
|
+
'invalid_configuration',
|
|
1765
|
+
`Collection "${collectionNameArg}" already carries an index named ` +
|
|
1766
|
+
`"${expectedArg.name}" with a different key or options.`,
|
|
1767
|
+
{ cause: errorArg },
|
|
1768
|
+
);
|
|
1769
|
+
}
|
|
1770
|
+
// An index over the same key that is not interchangeable with the
|
|
1771
|
+
// declaration cannot be adopted by name, and MongoDB's own error already
|
|
1772
|
+
// reports the options that differ.
|
|
1773
|
+
return errorArg;
|
|
1774
|
+
};
|
|
1775
|
+
|
|
1482
1776
|
export class SmartdataCollection<T> {
|
|
1483
1777
|
/**
|
|
1484
1778
|
* the collection that is used
|
|
@@ -1517,8 +1811,7 @@ export class SmartdataCollection<T> {
|
|
|
1517
1811
|
{
|
|
1518
1812
|
ordinaryWrite: ordinaryWriteArg,
|
|
1519
1813
|
prepared: this.isInitializedForCurrentDatabase(),
|
|
1520
|
-
|
|
1521
|
-
`Initialize collection "${this.collectionName}" before using an owned SmartData session.`,
|
|
1814
|
+
collectionName: this.collectionName,
|
|
1522
1815
|
},
|
|
1523
1816
|
operationArg,
|
|
1524
1817
|
);
|
|
@@ -1716,13 +2009,22 @@ export class SmartdataCollection<T> {
|
|
|
1716
2009
|
return;
|
|
1717
2010
|
}
|
|
1718
2011
|
for (const index of modelSchema.indexes) {
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
2012
|
+
try {
|
|
2013
|
+
await targetCollection.createIndex(
|
|
2014
|
+
Object.fromEntries(index.key) as plugins.mongodb.IndexSpecification,
|
|
2015
|
+
{
|
|
2016
|
+
...index.options,
|
|
2017
|
+
name: index.name,
|
|
2018
|
+
},
|
|
2019
|
+
);
|
|
2020
|
+
} catch (errorArg) {
|
|
2021
|
+
throw await describeIndexCollision(
|
|
2022
|
+
targetCollection,
|
|
2023
|
+
this.collectionName,
|
|
2024
|
+
index,
|
|
2025
|
+
errorArg,
|
|
2026
|
+
);
|
|
2027
|
+
}
|
|
1726
2028
|
}
|
|
1727
2029
|
const actualIndexes = await targetCollection.listIndexes().toArray();
|
|
1728
2030
|
for (const expected of modelSchema.indexes) {
|
|
@@ -1849,7 +2151,9 @@ export class SmartdataCollection<T> {
|
|
|
1849
2151
|
try {
|
|
1850
2152
|
await this.mongoDbCollection.createIndex({ [key]: 1 }, {
|
|
1851
2153
|
unique: true,
|
|
1852
|
-
|
|
2154
|
+
// A bound model may back its identity with a differently named
|
|
2155
|
+
// index; creating `<field>_1` would duplicate that key.
|
|
2156
|
+
name: getIdentityIndexName(this.modelSchema, key) ?? `${key}_1`,
|
|
1853
2157
|
});
|
|
1854
2158
|
this.uniqueIndexes.push(key);
|
|
1855
2159
|
} catch (err: any) {
|
|
@@ -1970,14 +2274,30 @@ export class SmartdataCollection<T> {
|
|
|
1970
2274
|
);
|
|
1971
2275
|
}
|
|
1972
2276
|
|
|
2277
|
+
/**
|
|
2278
|
+
* An owned session stays leased until the returned cursor is closed, because
|
|
2279
|
+
* the cursor outlives this call and the driver forbids a parallel operation
|
|
2280
|
+
* on the same session.
|
|
2281
|
+
*/
|
|
1973
2282
|
public async getCursor(
|
|
1974
2283
|
filterObjectArg: any,
|
|
1975
2284
|
dbDocArg: typeof SmartDataDbDoc,
|
|
1976
|
-
opts?: { session?:
|
|
2285
|
+
opts?: { session?: TSmartdataOrdinarySession }
|
|
1977
2286
|
): Promise<SmartdataDbCursor<any>> {
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
2287
|
+
const lease = leaseOrdinarySmartdataSession(opts?.session, this.smartdataDb, {
|
|
2288
|
+
prepared: this.isInitializedForCurrentDatabase(),
|
|
2289
|
+
collectionName: this.collectionName,
|
|
2290
|
+
});
|
|
2291
|
+
try {
|
|
2292
|
+
await this.init();
|
|
2293
|
+
const cursor = this.mongoDbCollection.find(filterObjectArg, {
|
|
2294
|
+
session: lease.rawSession,
|
|
2295
|
+
});
|
|
2296
|
+
return new SmartdataDbCursor(cursor, dbDocArg, lease.release);
|
|
2297
|
+
} catch (cursorCreationError) {
|
|
2298
|
+
lease.release();
|
|
2299
|
+
throw cursorCreationError;
|
|
2300
|
+
}
|
|
1981
2301
|
}
|
|
1982
2302
|
|
|
1983
2303
|
/**
|
|
@@ -2333,17 +2653,34 @@ export class SmartdataCollection<T> {
|
|
|
2333
2653
|
const identifiableObject = await dbDocArg.createIdentifiableObject();
|
|
2334
2654
|
this.assertConstrainingIdentifiableObject(identifiableObject, 'update');
|
|
2335
2655
|
const saveableObject = await dbDocArg.createSavableObject() as any;
|
|
2656
|
+
// Atomic-only fields belong to concurrent atomic writers, so this
|
|
2657
|
+
// instance's value never overwrites a stored one. It still seeds them
|
|
2658
|
+
// when this upsert inserts: a document created here must be as
|
|
2659
|
+
// complete as one created by insert(), or a later guard on a field
|
|
2660
|
+
// that was never stored would fail closed forever. $set and
|
|
2661
|
+
// $setOnInsert stay disjoint by construction, so MongoDB accepts both.
|
|
2662
|
+
const atomicOnlyFields = new Set(this.modelSchema?.atomicOnlyFields || []);
|
|
2336
2663
|
const updateableObject: any = {};
|
|
2664
|
+
const insertOnlyObject: any = {};
|
|
2337
2665
|
for (const key of Object.keys(saveableObject)) {
|
|
2338
2666
|
if (identifiableObject[key]) {
|
|
2339
2667
|
continue;
|
|
2340
2668
|
}
|
|
2669
|
+
if (atomicOnlyFields.has(key)) {
|
|
2670
|
+
insertOnlyObject[key] = saveableObject[key];
|
|
2671
|
+
continue;
|
|
2672
|
+
}
|
|
2341
2673
|
updateableObject[key] = saveableObject[key];
|
|
2342
2674
|
}
|
|
2343
2675
|
try {
|
|
2344
2676
|
return await this.mongoDbCollection.updateOne(
|
|
2345
2677
|
identifiableObject,
|
|
2346
|
-
{
|
|
2678
|
+
{
|
|
2679
|
+
$set: updateableObject,
|
|
2680
|
+
...(Object.keys(insertOnlyObject).length > 0
|
|
2681
|
+
? { $setOnInsert: insertOnlyObject }
|
|
2682
|
+
: {}),
|
|
2683
|
+
},
|
|
2347
2684
|
{ upsert: true, session: rawSessionArg },
|
|
2348
2685
|
);
|
|
2349
2686
|
} catch (errorArg) {
|