@lossless.org/client 1.2.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 +152 -11
- package/dist_ts/nosqldb/classes.collectiontopology.d.ts +8 -0
- package/dist_ts/nosqldb/classes.collectiontopology.js +14 -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 +130 -11
- package/dist_ts/nosqldb/classes.doc.js +464 -89
- package/dist_ts/nosqldb/classes.namespaceinspection.js +2 -2
- 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 +37 -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 +240 -8
- package/ts/nosqldb/classes.collectiontopology.ts +31 -1
- package/ts/nosqldb/classes.cursor.ts +22 -2
- package/ts/nosqldb/classes.doc.ts +769 -130
- package/ts/nosqldb/classes.namespaceinspection.ts +1 -1
- 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';
|
|
@@ -28,6 +29,7 @@ import {
|
|
|
28
29
|
} from './classes.persistence.js';
|
|
29
30
|
import { notifyCollectionReconnect } from './classes.collectionlifecycle.js';
|
|
30
31
|
import {
|
|
32
|
+
leaseOrdinarySmartdataSession,
|
|
31
33
|
runWithOrdinarySmartdataSession,
|
|
32
34
|
type TSmartdataOrdinarySession,
|
|
33
35
|
} from './classes.session.js';
|
|
@@ -69,6 +71,14 @@ export interface ICollectionBindingOptions {
|
|
|
69
71
|
* `_id === document[field]`.
|
|
70
72
|
*/
|
|
71
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>;
|
|
72
82
|
}
|
|
73
83
|
|
|
74
84
|
export type TCollectionModelIndexDirection = 1 | -1 | 'text';
|
|
@@ -112,6 +122,16 @@ export interface ICollectionModelConfig<TModel extends object = any> {
|
|
|
112
122
|
* numeric-field constraint.
|
|
113
123
|
*/
|
|
114
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>;
|
|
115
135
|
/**
|
|
116
136
|
* Persisted string identity fields with @unI()-equivalent selector and
|
|
117
137
|
* immutability semantics. Each field requires an explicit single-field,
|
|
@@ -133,6 +153,15 @@ export interface ICollectionModelConfig<TModel extends object = any> {
|
|
|
133
153
|
* Stable named MongoDB indexes. Object property order defines key order.
|
|
134
154
|
*/
|
|
135
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>;
|
|
136
165
|
}
|
|
137
166
|
|
|
138
167
|
export interface INormalizedCollectionModelSchema {
|
|
@@ -140,6 +169,8 @@ export interface INormalizedCollectionModelSchema {
|
|
|
140
169
|
readonly collectionName: string;
|
|
141
170
|
readonly persistedFields: readonly string[];
|
|
142
171
|
readonly numericFields: readonly string[];
|
|
172
|
+
/** Declared persisted fields a matching instance `save()` never overwrites. */
|
|
173
|
+
readonly atomicOnlyFields: readonly string[];
|
|
143
174
|
readonly identityFields: readonly string[];
|
|
144
175
|
/** Declared identity field stored as the document `_id`, if any. */
|
|
145
176
|
readonly identityAsDocumentId?: string;
|
|
@@ -154,6 +185,8 @@ export interface INormalizedCollectionModelSchema {
|
|
|
154
185
|
>;
|
|
155
186
|
readonly options: Readonly<IIndexOptions>;
|
|
156
187
|
}>;
|
|
188
|
+
/** Undeclared installed indexes this model accepts by name, never touches. */
|
|
189
|
+
readonly toleratedIndexNames: readonly string[];
|
|
157
190
|
readonly fingerprint: string;
|
|
158
191
|
}
|
|
159
192
|
|
|
@@ -344,10 +377,12 @@ const normalizeCollectionModelSchema = (
|
|
|
344
377
|
'collectionName',
|
|
345
378
|
'persistedFields',
|
|
346
379
|
'numericFields',
|
|
380
|
+
'atomicOnlyFields',
|
|
347
381
|
'identityFields',
|
|
348
382
|
'identityAsDocumentId',
|
|
349
383
|
'searchableFields',
|
|
350
384
|
'indexes',
|
|
385
|
+
'toleratedIndexNames',
|
|
351
386
|
]);
|
|
352
387
|
for (const key of Object.keys(configArg)) {
|
|
353
388
|
if (!allowedConfigKeys.has(key)) {
|
|
@@ -436,6 +471,37 @@ const normalizeCollectionModelSchema = (
|
|
|
436
471
|
);
|
|
437
472
|
}
|
|
438
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
|
+
}
|
|
439
505
|
const identityValueTypes: Record<string, TSmartdataIdentityValueType> = {};
|
|
440
506
|
for (const field of Object.keys(identityValueTypesArg || {})) {
|
|
441
507
|
if (!identityFields.includes(field)) {
|
|
@@ -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
|
};
|
|
@@ -840,7 +949,22 @@ const mergeDecoratorMetadata = (
|
|
|
840
949
|
for (const metadataArg of metadataArgs) {
|
|
841
950
|
const options = getOwnMetadataValue<Record<string, any>>(metadataArg, '_svDbOptions');
|
|
842
951
|
if (options) {
|
|
843
|
-
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
|
+
}
|
|
844
968
|
}
|
|
845
969
|
}
|
|
846
970
|
if (Object.keys(svDbOptions).length > 0) {
|
|
@@ -923,10 +1047,44 @@ const definedModelSchemaSymbol = Symbol.for(
|
|
|
923
1047
|
'@push.rocks/smartdata.definedCollectionModelSchema',
|
|
924
1048
|
);
|
|
925
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
|
+
|
|
926
1083
|
const schemaFromDecoratorMetadata = (
|
|
927
1084
|
constructorArg: TCollectionModelConstructor<any>,
|
|
928
1085
|
collectionNameArg: string,
|
|
929
1086
|
identityAsDocumentIdArg?: string,
|
|
1087
|
+
toleratedIndexNamesArg?: ReadonlyArray<string>,
|
|
930
1088
|
): INormalizedCollectionModelSchema => {
|
|
931
1089
|
const ownMetadata = Object.prototype.hasOwnProperty.call(
|
|
932
1090
|
constructorArg,
|
|
@@ -956,6 +1114,9 @@ const schemaFromDecoratorMetadata = (
|
|
|
956
1114
|
const numericFields = Object.entries(metadata._svDbOptions || {})
|
|
957
1115
|
.filter(([, optionsArg]) => optionsArg?.numeric === true)
|
|
958
1116
|
.map(([fieldArg]) => fieldArg);
|
|
1117
|
+
const atomicOnlyFields = readDecoratedAtomicOnlyFields(
|
|
1118
|
+
(metadata._svDbOptions as Record<string, SvDbOptions> | undefined) || {},
|
|
1119
|
+
);
|
|
959
1120
|
const indexes: ICollectionModelIndex[] = [];
|
|
960
1121
|
for (const uniqueField of metadata.uniqueIndexes || []) {
|
|
961
1122
|
const declaredIndexName = metadata.identityIndexNames?.[uniqueField];
|
|
@@ -1008,12 +1169,16 @@ const schemaFromDecoratorMetadata = (
|
|
|
1008
1169
|
collectionName: collectionNameArg,
|
|
1009
1170
|
persistedFields,
|
|
1010
1171
|
numericFields,
|
|
1172
|
+
atomicOnlyFields,
|
|
1011
1173
|
identityFields: metadata.uniqueIndexes || [],
|
|
1012
1174
|
...(identityAsDocumentIdArg !== undefined
|
|
1013
1175
|
? { identityAsDocumentId: identityAsDocumentIdArg }
|
|
1014
1176
|
: {}),
|
|
1015
1177
|
searchableFields: metadata.searchableFields || [],
|
|
1016
1178
|
indexes,
|
|
1179
|
+
...(toleratedIndexNamesArg !== undefined
|
|
1180
|
+
? { toleratedIndexNames: toleratedIndexNamesArg }
|
|
1181
|
+
: {}),
|
|
1017
1182
|
},
|
|
1018
1183
|
true,
|
|
1019
1184
|
metadata.identityValueTypes,
|
|
@@ -1138,6 +1303,30 @@ export function defineCollectionModel<
|
|
|
1138
1303
|
if (identityAsDocumentId !== undefined) {
|
|
1139
1304
|
assertIdentityDocumentIdIsOrdinary(modelArg, identityAsDocumentId);
|
|
1140
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
|
+
}
|
|
1141
1330
|
const normalizedSchema = normalizeCollectionModelSchema({
|
|
1142
1331
|
...configArg,
|
|
1143
1332
|
persistedFields: [
|
|
@@ -1148,6 +1337,14 @@ export function defineCollectionModel<
|
|
|
1148
1337
|
...(inheritedSchema?.numericFields || []),
|
|
1149
1338
|
...(configArg.numericFields || []),
|
|
1150
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
|
+
],
|
|
1151
1348
|
identityFields: [
|
|
1152
1349
|
...(inheritedSchema?.identityFields || []),
|
|
1153
1350
|
...(configArg.identityFields || []),
|
|
@@ -1403,6 +1600,7 @@ export function Collection(
|
|
|
1403
1600
|
constructor as TCollectionModelConstructor<any>,
|
|
1404
1601
|
collectionName,
|
|
1405
1602
|
identityAsDocumentId,
|
|
1603
|
+
optionsArg?.toleratedIndexNames,
|
|
1406
1604
|
);
|
|
1407
1605
|
},
|
|
1408
1606
|
) as any;
|
|
@@ -1476,6 +1674,8 @@ export function managed<TManager extends IManager>(
|
|
|
1476
1674
|
return schemaFromDecoratorMetadata(
|
|
1477
1675
|
constructor as TCollectionModelConstructor<any>,
|
|
1478
1676
|
collectionName,
|
|
1677
|
+
undefined,
|
|
1678
|
+
bindingOptions?.toleratedIndexNames,
|
|
1479
1679
|
);
|
|
1480
1680
|
},
|
|
1481
1681
|
);
|
|
@@ -1611,8 +1811,7 @@ export class SmartdataCollection<T> {
|
|
|
1611
1811
|
{
|
|
1612
1812
|
ordinaryWrite: ordinaryWriteArg,
|
|
1613
1813
|
prepared: this.isInitializedForCurrentDatabase(),
|
|
1614
|
-
|
|
1615
|
-
`Initialize collection "${this.collectionName}" before using an owned SmartData session.`,
|
|
1814
|
+
collectionName: this.collectionName,
|
|
1616
1815
|
},
|
|
1617
1816
|
operationArg,
|
|
1618
1817
|
);
|
|
@@ -2075,14 +2274,30 @@ export class SmartdataCollection<T> {
|
|
|
2075
2274
|
);
|
|
2076
2275
|
}
|
|
2077
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
|
+
*/
|
|
2078
2282
|
public async getCursor(
|
|
2079
2283
|
filterObjectArg: any,
|
|
2080
2284
|
dbDocArg: typeof SmartDataDbDoc,
|
|
2081
|
-
opts?: { session?:
|
|
2285
|
+
opts?: { session?: TSmartdataOrdinarySession }
|
|
2082
2286
|
): Promise<SmartdataDbCursor<any>> {
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
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
|
+
}
|
|
2086
2301
|
}
|
|
2087
2302
|
|
|
2088
2303
|
/**
|
|
@@ -2438,17 +2653,34 @@ export class SmartdataCollection<T> {
|
|
|
2438
2653
|
const identifiableObject = await dbDocArg.createIdentifiableObject();
|
|
2439
2654
|
this.assertConstrainingIdentifiableObject(identifiableObject, 'update');
|
|
2440
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 || []);
|
|
2441
2663
|
const updateableObject: any = {};
|
|
2664
|
+
const insertOnlyObject: any = {};
|
|
2442
2665
|
for (const key of Object.keys(saveableObject)) {
|
|
2443
2666
|
if (identifiableObject[key]) {
|
|
2444
2667
|
continue;
|
|
2445
2668
|
}
|
|
2669
|
+
if (atomicOnlyFields.has(key)) {
|
|
2670
|
+
insertOnlyObject[key] = saveableObject[key];
|
|
2671
|
+
continue;
|
|
2672
|
+
}
|
|
2446
2673
|
updateableObject[key] = saveableObject[key];
|
|
2447
2674
|
}
|
|
2448
2675
|
try {
|
|
2449
2676
|
return await this.mongoDbCollection.updateOne(
|
|
2450
2677
|
identifiableObject,
|
|
2451
|
-
{
|
|
2678
|
+
{
|
|
2679
|
+
$set: updateableObject,
|
|
2680
|
+
...(Object.keys(insertOnlyObject).length > 0
|
|
2681
|
+
? { $setOnInsert: insertOnlyObject }
|
|
2682
|
+
: {}),
|
|
2683
|
+
},
|
|
2452
2684
|
{ upsert: true, session: rawSessionArg },
|
|
2453
2685
|
);
|
|
2454
2686
|
} catch (errorArg) {
|
|
@@ -38,6 +38,8 @@ export interface ISmartdataCollectionTopologyIndex {
|
|
|
38
38
|
export interface ISmartdataExpectedCollectionTopology {
|
|
39
39
|
readonly collectionName: string;
|
|
40
40
|
readonly indexes: readonly ISmartdataCollectionTopologyIndex[];
|
|
41
|
+
/** Undeclared installed indexes the model accepts by name, never verifies. */
|
|
42
|
+
readonly toleratedIndexNames: readonly string[];
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
export interface ISmartdataCollectionTopologyInspection {
|
|
@@ -45,7 +47,13 @@ export interface ISmartdataCollectionTopologyInspection {
|
|
|
45
47
|
readonly status: TSmartdataCollectionTopologyStatus;
|
|
46
48
|
readonly reasonCodes: readonly TSmartdataCollectionTopologyReasonCode[];
|
|
47
49
|
readonly expectedIndexes: readonly ISmartdataCollectionTopologyIndex[];
|
|
50
|
+
/** Installed indexes SmartData declares and verifies. */
|
|
48
51
|
readonly actualIndexes: readonly ISmartdataCollectionTopologyIndex[];
|
|
52
|
+
/**
|
|
53
|
+
* Installed indexes the model tolerates by name. They are observed, never
|
|
54
|
+
* verified, created or dropped, and they never make the topology divergent.
|
|
55
|
+
*/
|
|
56
|
+
readonly toleratedIndexes: readonly string[];
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
type TCollectionModelSchemaResolver = () => INormalizedCollectionModelSchema;
|
|
@@ -295,6 +303,9 @@ export const getExpectedCollectionTopologyForSchema = (
|
|
|
295
303
|
return Object.freeze({
|
|
296
304
|
collectionName: schema.collectionName,
|
|
297
305
|
indexes,
|
|
306
|
+
toleratedIndexNames: Object.freeze(
|
|
307
|
+
[...schema.toleratedIndexNames].sort(compareSmartdataTopologyStrings),
|
|
308
|
+
),
|
|
298
309
|
});
|
|
299
310
|
};
|
|
300
311
|
|
|
@@ -512,6 +523,7 @@ const createInspection = (
|
|
|
512
523
|
statusArg: TSmartdataCollectionTopologyStatus,
|
|
513
524
|
reasonsArg: ReadonlySet<TSmartdataCollectionTopologyReasonCode>,
|
|
514
525
|
actualIndexesArg: readonly ISmartdataCollectionTopologyIndex[],
|
|
526
|
+
toleratedIndexesArg: readonly string[] = [],
|
|
515
527
|
): ISmartdataCollectionTopologyInspection =>
|
|
516
528
|
Object.freeze({
|
|
517
529
|
collectionName: expectedArg.collectionName,
|
|
@@ -519,6 +531,9 @@ const createInspection = (
|
|
|
519
531
|
reasonCodes: buildReasonCodes(reasonsArg),
|
|
520
532
|
expectedIndexes: expectedArg.indexes,
|
|
521
533
|
actualIndexes: sortIndexes(actualIndexesArg),
|
|
534
|
+
toleratedIndexes: Object.freeze(
|
|
535
|
+
[...toleratedIndexesArg].sort(compareSmartdataTopologyStrings),
|
|
536
|
+
),
|
|
522
537
|
});
|
|
523
538
|
|
|
524
539
|
export const inspectCollectionTopologyForModel = async (
|
|
@@ -599,8 +614,17 @@ export const inspectCollectionTopologySnapshot = (
|
|
|
599
614
|
}
|
|
600
615
|
const normalizedIndexes: ISmartdataCollectionTopologyIndex[] = [];
|
|
601
616
|
const unsupportedNames = new Set<string>();
|
|
617
|
+
const toleratedNames = new Set(expected.toleratedIndexNames);
|
|
618
|
+
const toleratedIndexes: string[] = [];
|
|
602
619
|
for (const rawIndex of rawIndexes) {
|
|
603
620
|
const normalized = normalizeActualIndex(rawIndex);
|
|
621
|
+
if (normalized.name && toleratedNames.has(normalized.name)) {
|
|
622
|
+
// A tolerated index belongs to whoever created it — a migration, an
|
|
623
|
+
// operator — so it is observed by name and nothing about its keys or
|
|
624
|
+
// options is asserted. SmartData neither creates nor drops it.
|
|
625
|
+
toleratedIndexes.push(normalized.name);
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
604
628
|
if (!normalized.supported) {
|
|
605
629
|
reasons.add('unsupported_actual_index');
|
|
606
630
|
if (normalized.name) {
|
|
@@ -660,7 +684,13 @@ export const inspectCollectionTopologySnapshot = (
|
|
|
660
684
|
)
|
|
661
685
|
? 'compatible'
|
|
662
686
|
: 'divergent';
|
|
663
|
-
return createInspection(
|
|
687
|
+
return createInspection(
|
|
688
|
+
expected,
|
|
689
|
+
status,
|
|
690
|
+
reasons,
|
|
691
|
+
normalizedIndexes,
|
|
692
|
+
toleratedIndexes,
|
|
693
|
+
);
|
|
664
694
|
} catch {
|
|
665
695
|
throw new SmartdataPersistenceError(
|
|
666
696
|
'unsupported_operation',
|
|
@@ -10,9 +10,20 @@ export class SmartdataDbCursor<T = any> {
|
|
|
10
10
|
// INSTANCE
|
|
11
11
|
public mongodbCursor: plugins.mongodb.FindCursor<T>;
|
|
12
12
|
private smartdataDbDoc: typeof SmartDataDbDoc;
|
|
13
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Releases an owned SmartData session lease the cursor holds for its whole
|
|
15
|
+
* lifetime. Supplied by the operation that created the cursor; inert for a
|
|
16
|
+
* raw driver session or no session at all.
|
|
17
|
+
*/
|
|
18
|
+
private releaseSession: () => void;
|
|
19
|
+
constructor(
|
|
20
|
+
cursorArg: plugins.mongodb.FindCursor<T>,
|
|
21
|
+
dbDocArg: typeof SmartDataDbDoc,
|
|
22
|
+
releaseSessionArg: () => void = () => {},
|
|
23
|
+
) {
|
|
14
24
|
this.mongodbCursor = cursorArg;
|
|
15
25
|
this.smartdataDbDoc = dbDocArg;
|
|
26
|
+
this.releaseSession = releaseSessionArg;
|
|
16
27
|
}
|
|
17
28
|
|
|
18
29
|
public async next(closeAtEnd = true): Promise<T | null> {
|
|
@@ -73,8 +84,17 @@ export class SmartdataDbCursor<T = any> {
|
|
|
73
84
|
return convertedResult;
|
|
74
85
|
}
|
|
75
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Closes the driver cursor and releases an owned session lease even when the
|
|
89
|
+
* driver's own cleanup rejects, so a failed close can never strand the
|
|
90
|
+
* session and block every later operation on it.
|
|
91
|
+
*/
|
|
76
92
|
public async close() {
|
|
77
|
-
|
|
93
|
+
try {
|
|
94
|
+
await this.mongodbCursor.close();
|
|
95
|
+
} finally {
|
|
96
|
+
this.releaseSession();
|
|
97
|
+
}
|
|
78
98
|
}
|
|
79
99
|
|
|
80
100
|
private async closeAfterError(errorArg: unknown): Promise<never> {
|