@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.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/nosqldb/classes.atomicupdate.d.ts +21 -0
- package/dist_ts/nosqldb/classes.atomicupdate.js +41 -1
- package/dist_ts/nosqldb/classes.collection.d.ts +31 -0
- package/dist_ts/nosqldb/classes.collection.js +203 -32
- package/dist_ts/nosqldb/classes.collectiontopology.d.ts +11 -0
- package/dist_ts/nosqldb/classes.collectiontopology.js +16 -1
- package/dist_ts/nosqldb/classes.doc.d.ts +66 -0
- package/dist_ts/nosqldb/classes.doc.js +313 -24
- package/dist_ts/nosqldb/classes.exactpersistence.js +10 -2
- package/dist_ts/nosqldb/classes.ordinarypersistence.d.ts +6 -0
- package/dist_ts/nosqldb/classes.ordinarypersistence.js +7 -1
- package/dist_ts/nosqldb/classes.persistence.d.ts +6 -0
- package/dist_ts/nosqldb/classes.persistence.js +13 -1
- package/package.json +1 -1
- package/readme.md +11 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/nosqldb/classes.atomicupdate.ts +67 -0
- package/ts/nosqldb/classes.collection.ts +313 -37
- package/ts/nosqldb/classes.collectiontopology.ts +25 -0
- package/ts/nosqldb/classes.doc.ts +575 -21
- package/ts/nosqldb/classes.exactpersistence.ts +9 -1
- package/ts/nosqldb/classes.ordinarypersistence.ts +8 -0
- package/ts/nosqldb/classes.persistence.ts +17 -0
|
@@ -6,7 +6,8 @@ import {
|
|
|
6
6
|
type ISmartdataCollectionPreparationOptions,
|
|
7
7
|
} from './classes.collectionpreparation.js';
|
|
8
8
|
import {
|
|
9
|
-
getOrdinaryPersistencePolicy,
|
|
9
|
+
getOrdinaryPersistencePolicy, hasExactPersistencePolicy,
|
|
10
|
+
validateOrdinaryStoredDocument,
|
|
10
11
|
type IOrdinaryPersistencePolicy,
|
|
11
12
|
} from './classes.ordinarypersistence.js';
|
|
12
13
|
import { SmartdataDb } from './classes.db.js';
|
|
@@ -22,6 +23,7 @@ import { CollectionFactory } from './classes.collectionfactory.js';
|
|
|
22
23
|
import { logger } from './logging.js';
|
|
23
24
|
import {
|
|
24
25
|
SmartdataPersistenceError,
|
|
26
|
+
isMongoIndexConflictError,
|
|
25
27
|
normalizeOrdinaryPersistenceError,
|
|
26
28
|
} from './classes.persistence.js';
|
|
27
29
|
import { notifyCollectionReconnect } from './classes.collectionlifecycle.js';
|
|
@@ -33,6 +35,8 @@ import {
|
|
|
33
35
|
collectionModelSchemaResolverSymbol,
|
|
34
36
|
collectionTopologyDbInspectionSymbol,
|
|
35
37
|
getExpectedCollectionTopologyForSchema,
|
|
38
|
+
getIdentityIndexName,
|
|
39
|
+
isIdentityIndexFor,
|
|
36
40
|
compareSmartdataTopologyStrings,
|
|
37
41
|
} from './classes.collectiontopology.js';
|
|
38
42
|
|
|
@@ -57,6 +61,14 @@ export interface ICollectionBindingOptions {
|
|
|
57
61
|
* its legacy collection without changing stored data.
|
|
58
62
|
*/
|
|
59
63
|
collectionName?: string;
|
|
64
|
+
/**
|
|
65
|
+
* Stores one declared `@unI()` string identity as the document `_id`, making
|
|
66
|
+
* the primary key itself the uniqueness authority. The field keeps its own
|
|
67
|
+
* stored value and receives no separate unique index. Irreversible for an
|
|
68
|
+
* existing collection: every stored document must already satisfy
|
|
69
|
+
* `_id === document[field]`.
|
|
70
|
+
*/
|
|
71
|
+
identityAsDocumentId?: string;
|
|
60
72
|
}
|
|
61
73
|
|
|
62
74
|
export type TCollectionModelIndexDirection = 1 | -1 | 'text';
|
|
@@ -106,6 +118,13 @@ export interface ICollectionModelConfig<TModel extends object = any> {
|
|
|
106
118
|
* ascending unique index in `indexes`.
|
|
107
119
|
*/
|
|
108
120
|
identityFields?: ReadonlyArray<TStringFieldKey<TModel>>;
|
|
121
|
+
/**
|
|
122
|
+
* Stores one declared string identity as the document `_id`. The primary key
|
|
123
|
+
* becomes the uniqueness authority, so the field must not declare its own
|
|
124
|
+
* unique index. Irreversible for an existing collection: every stored
|
|
125
|
+
* document must already satisfy `_id === document[field]`.
|
|
126
|
+
*/
|
|
127
|
+
identityAsDocumentId?: TStringFieldKey<TModel>;
|
|
109
128
|
/**
|
|
110
129
|
* Persisted fields used by SmartData's search helpers.
|
|
111
130
|
*/
|
|
@@ -122,6 +141,8 @@ export interface INormalizedCollectionModelSchema {
|
|
|
122
141
|
readonly persistedFields: readonly string[];
|
|
123
142
|
readonly numericFields: readonly string[];
|
|
124
143
|
readonly identityFields: readonly string[];
|
|
144
|
+
/** Declared identity field stored as the document `_id`, if any. */
|
|
145
|
+
readonly identityAsDocumentId?: string;
|
|
125
146
|
readonly identityValueTypes: Readonly<
|
|
126
147
|
Record<string, TSmartdataIdentityValueType>
|
|
127
148
|
>;
|
|
@@ -162,6 +183,7 @@ interface ISmartdataDecoratorMetadata {
|
|
|
162
183
|
saveableProperties?: string[];
|
|
163
184
|
uniqueIndexes?: string[];
|
|
164
185
|
identityValueTypes?: Record<string, TSmartdataIdentityValueType>;
|
|
186
|
+
identityIndexNames?: Record<string, string>;
|
|
165
187
|
regularIndexes?: Array<{field: string, options: IIndexOptions}>;
|
|
166
188
|
compoundIndexes?: ICompoundIndexDefinition[];
|
|
167
189
|
namedIndexes?: INamedIndexDefinition[];
|
|
@@ -323,6 +345,7 @@ const normalizeCollectionModelSchema = (
|
|
|
323
345
|
'persistedFields',
|
|
324
346
|
'numericFields',
|
|
325
347
|
'identityFields',
|
|
348
|
+
'identityAsDocumentId',
|
|
326
349
|
'searchableFields',
|
|
327
350
|
'indexes',
|
|
328
351
|
]);
|
|
@@ -395,6 +418,24 @@ const normalizeCollectionModelSchema = (
|
|
|
395
418
|
identityFields.push(field);
|
|
396
419
|
}
|
|
397
420
|
}
|
|
421
|
+
const identityAsDocumentId = configArg.identityAsDocumentId;
|
|
422
|
+
if (identityAsDocumentId !== undefined) {
|
|
423
|
+
if (
|
|
424
|
+
typeof identityAsDocumentId !== 'string' ||
|
|
425
|
+
!identityFields.includes(identityAsDocumentId)
|
|
426
|
+
) {
|
|
427
|
+
throw new SmartdataPersistenceError(
|
|
428
|
+
'invalid_configuration',
|
|
429
|
+
'Collection model identityAsDocumentId must name a declared identity field.',
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
if (ordinaryPolicyArg?.idType === 'string') {
|
|
433
|
+
throw new SmartdataPersistenceError(
|
|
434
|
+
'invalid_configuration',
|
|
435
|
+
`Identity field "${identityAsDocumentId}" cannot own the document _id together with a string ordinary persistence _id.`,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
398
439
|
const identityValueTypes: Record<string, TSmartdataIdentityValueType> = {};
|
|
399
440
|
for (const field of Object.keys(identityValueTypesArg || {})) {
|
|
400
441
|
if (!identityFields.includes(field)) {
|
|
@@ -557,13 +598,28 @@ const normalizeCollectionModelSchema = (
|
|
|
557
598
|
);
|
|
558
599
|
}
|
|
559
600
|
for (const identityField of identityFields) {
|
|
560
|
-
const hasIdentityIndex = [...indexesByName.values()].some(
|
|
561
|
-
(indexArg)
|
|
562
|
-
indexArg.options.unique === true &&
|
|
563
|
-
indexArg.key.length === 1 &&
|
|
564
|
-
indexArg.key[0][0] === identityField &&
|
|
565
|
-
indexArg.key[0][1] === 1,
|
|
601
|
+
const hasIdentityIndex = [...indexesByName.values()].some((indexArg) =>
|
|
602
|
+
isIdentityIndexFor(indexArg, identityField),
|
|
566
603
|
);
|
|
604
|
+
if (identityField === identityAsDocumentId) {
|
|
605
|
+
// The primary key is the only uniqueness authority for this field. A
|
|
606
|
+
// second unique index would have to be built on a live collection and
|
|
607
|
+
// could disagree with _id, so the declaration is refused rather than
|
|
608
|
+
// silently ignored.
|
|
609
|
+
if (hasIdentityIndex) {
|
|
610
|
+
throw new SmartdataPersistenceError(
|
|
611
|
+
'invalid_configuration',
|
|
612
|
+
`Identity field "${identityField}" owns the document _id and must not declare its own unique index.`,
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
if (identityValueTypes[identityField] !== 'string') {
|
|
616
|
+
throw new SmartdataPersistenceError(
|
|
617
|
+
'invalid_configuration',
|
|
618
|
+
`Identity field "${identityField}" must be a string identity to own the document _id.`,
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
567
623
|
if (!hasIdentityIndex) {
|
|
568
624
|
throw new SmartdataPersistenceError(
|
|
569
625
|
'invalid_configuration',
|
|
@@ -577,6 +633,7 @@ const normalizeCollectionModelSchema = (
|
|
|
577
633
|
persistedFields: Object.freeze([...persistedFields]),
|
|
578
634
|
numericFields: Object.freeze([...numericFields]),
|
|
579
635
|
identityFields: Object.freeze([...identityFields]),
|
|
636
|
+
...(identityAsDocumentId !== undefined ? { identityAsDocumentId } : {}),
|
|
580
637
|
identityValueTypes: Object.freeze({ ...identityValueTypes }),
|
|
581
638
|
searchableFields: Object.freeze([...searchableFields]),
|
|
582
639
|
indexes: Object.freeze([...indexesByName.values()]),
|
|
@@ -591,6 +648,7 @@ const normalizeCollectionModelSchema = (
|
|
|
591
648
|
persistedFields: [...persistedFields].sort(compareSmartdataTopologyStrings),
|
|
592
649
|
numericFields: [...numericFields].sort(compareSmartdataTopologyStrings),
|
|
593
650
|
identityFields: [...identityFields].sort(compareSmartdataTopologyStrings),
|
|
651
|
+
...(identityAsDocumentId !== undefined ? { identityAsDocumentId } : {}),
|
|
594
652
|
identityValueTypes,
|
|
595
653
|
searchableFields: [...searchableFields].sort(
|
|
596
654
|
compareSmartdataTopologyStrings,
|
|
@@ -624,22 +682,26 @@ const mergeStringArrays = (...arrays: Array<string[] | undefined>): string[] =>
|
|
|
624
682
|
return merged;
|
|
625
683
|
};
|
|
626
684
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
685
|
+
/**
|
|
686
|
+
* Merges a per-identity-field declaration across an inheritance chain.
|
|
687
|
+
* Divergence is refused instead of resolved by declaration order, because
|
|
688
|
+
* either winner would silently change a subclass's stored contract.
|
|
689
|
+
*/
|
|
690
|
+
const mergeIdentityDeclarations = <TDeclaration extends string>(
|
|
691
|
+
declarationArg: 'valueType' | 'indexName',
|
|
692
|
+
...declarationMapsArg: Array<Record<string, TDeclaration> | undefined>
|
|
693
|
+
): Record<string, TDeclaration> => {
|
|
694
|
+
const merged: Record<string, TDeclaration> = {};
|
|
695
|
+
for (const declarationMap of declarationMapsArg) {
|
|
696
|
+
for (const [field, declaration] of Object.entries(declarationMap || {})) {
|
|
635
697
|
const existing = merged[field];
|
|
636
|
-
if (existing && existing !==
|
|
698
|
+
if (existing && existing !== declaration) {
|
|
637
699
|
throw new SmartdataPersistenceError(
|
|
638
700
|
'invalid_configuration',
|
|
639
|
-
`Identity field "${field}" has divergent inherited
|
|
701
|
+
`Identity field "${field}" has divergent inherited ${declarationArg} declarations.`,
|
|
640
702
|
);
|
|
641
703
|
}
|
|
642
|
-
merged[field] =
|
|
704
|
+
merged[field] = declaration;
|
|
643
705
|
}
|
|
644
706
|
}
|
|
645
707
|
return merged;
|
|
@@ -737,7 +799,8 @@ const mergeDecoratorMetadata = (
|
|
|
737
799
|
merged.uniqueIndexes = mergeStringArrays(
|
|
738
800
|
...metadataArgs.map((metadataArg) => getOwnMetadataValue<string[]>(metadataArg, 'uniqueIndexes')),
|
|
739
801
|
);
|
|
740
|
-
merged.identityValueTypes =
|
|
802
|
+
merged.identityValueTypes = mergeIdentityDeclarations(
|
|
803
|
+
'valueType',
|
|
741
804
|
...metadataArgs.map((metadataArg) =>
|
|
742
805
|
getOwnMetadataValue<Record<string, TSmartdataIdentityValueType>>(
|
|
743
806
|
metadataArg,
|
|
@@ -745,6 +808,15 @@ const mergeDecoratorMetadata = (
|
|
|
745
808
|
),
|
|
746
809
|
),
|
|
747
810
|
);
|
|
811
|
+
merged.identityIndexNames = mergeIdentityDeclarations(
|
|
812
|
+
'indexName',
|
|
813
|
+
...metadataArgs.map((metadataArg) =>
|
|
814
|
+
getOwnMetadataValue<Record<string, string>>(
|
|
815
|
+
metadataArg,
|
|
816
|
+
'identityIndexNames',
|
|
817
|
+
),
|
|
818
|
+
),
|
|
819
|
+
);
|
|
748
820
|
merged.searchableFields = mergeStringArrays(
|
|
749
821
|
...metadataArgs.map((metadataArg) => getOwnMetadataValue<string[]>(metadataArg, 'searchableFields')),
|
|
750
822
|
);
|
|
@@ -854,6 +926,7 @@ const definedModelSchemaSymbol = Symbol.for(
|
|
|
854
926
|
const schemaFromDecoratorMetadata = (
|
|
855
927
|
constructorArg: TCollectionModelConstructor<any>,
|
|
856
928
|
collectionNameArg: string,
|
|
929
|
+
identityAsDocumentIdArg?: string,
|
|
857
930
|
): INormalizedCollectionModelSchema => {
|
|
858
931
|
const ownMetadata = Object.prototype.hasOwnProperty.call(
|
|
859
932
|
constructorArg,
|
|
@@ -885,8 +958,20 @@ const schemaFromDecoratorMetadata = (
|
|
|
885
958
|
.map(([fieldArg]) => fieldArg);
|
|
886
959
|
const indexes: ICollectionModelIndex[] = [];
|
|
887
960
|
for (const uniqueField of metadata.uniqueIndexes || []) {
|
|
961
|
+
const declaredIndexName = metadata.identityIndexNames?.[uniqueField];
|
|
962
|
+
if (uniqueField === identityAsDocumentIdArg) {
|
|
963
|
+
// The document _id enforces this identity; a derived unique index would
|
|
964
|
+
// duplicate the primary key and require an index build on live data.
|
|
965
|
+
if (declaredIndexName !== undefined) {
|
|
966
|
+
throw new SmartdataPersistenceError(
|
|
967
|
+
'invalid_configuration',
|
|
968
|
+
`Identity field "${uniqueField}" owns the document _id and must not name a unique index.`,
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
888
973
|
indexes.push({
|
|
889
|
-
name: `${uniqueField}_1`,
|
|
974
|
+
name: declaredIndexName ?? `${uniqueField}_1`,
|
|
890
975
|
key: { [uniqueField]: 1 },
|
|
891
976
|
options: { unique: true },
|
|
892
977
|
});
|
|
@@ -924,6 +1009,9 @@ const schemaFromDecoratorMetadata = (
|
|
|
924
1009
|
persistedFields,
|
|
925
1010
|
numericFields,
|
|
926
1011
|
identityFields: metadata.uniqueIndexes || [],
|
|
1012
|
+
...(identityAsDocumentIdArg !== undefined
|
|
1013
|
+
? { identityAsDocumentId: identityAsDocumentIdArg }
|
|
1014
|
+
: {}),
|
|
927
1015
|
searchableFields: metadata.searchableFields || [],
|
|
928
1016
|
indexes,
|
|
929
1017
|
},
|
|
@@ -933,6 +1021,35 @@ const schemaFromDecoratorMetadata = (
|
|
|
933
1021
|
);
|
|
934
1022
|
};
|
|
935
1023
|
|
|
1024
|
+
/**
|
|
1025
|
+
* Exact persistence owns the stored `_id` as a MongoDB ObjectId, so a model
|
|
1026
|
+
* cannot also hand the primary key to a declared identity field.
|
|
1027
|
+
*/
|
|
1028
|
+
const assertIdentityDocumentIdIsOrdinary = (
|
|
1029
|
+
modelArg: TCollectionModelConstructor<any>,
|
|
1030
|
+
identityAsDocumentIdArg: string,
|
|
1031
|
+
): void => {
|
|
1032
|
+
if (hasExactPersistencePolicy(modelArg)) {
|
|
1033
|
+
throw new SmartdataPersistenceError(
|
|
1034
|
+
'invalid_configuration',
|
|
1035
|
+
`Identity field "${identityAsDocumentIdArg}" cannot own the document _id for an exact-persistence model.`,
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
};
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* Returns the declared identity field a model stores as its document `_id`.
|
|
1042
|
+
* Reading the bound schema resolver never resolves a database.
|
|
1043
|
+
*/
|
|
1044
|
+
export const getIdentityDocumentIdField = (modelArg: any): string | undefined => {
|
|
1045
|
+
const resolver = modelArg?.[collectionModelSchemaResolverSymbol] as
|
|
1046
|
+
| (() => INormalizedCollectionModelSchema)
|
|
1047
|
+
| undefined;
|
|
1048
|
+
return typeof resolver === 'function'
|
|
1049
|
+
? resolver().identityAsDocumentId
|
|
1050
|
+
: undefined;
|
|
1051
|
+
};
|
|
1052
|
+
|
|
936
1053
|
const installCollectionBinding = <
|
|
937
1054
|
TModel extends SmartDataDbDoc<any, any>,
|
|
938
1055
|
>(
|
|
@@ -1016,6 +1133,11 @@ export function defineCollectionModel<
|
|
|
1016
1133
|
}
|
|
1017
1134
|
| undefined;
|
|
1018
1135
|
const inheritedSchema = inheritedConstructor?.[definedModelSchemaSymbol];
|
|
1136
|
+
const identityAsDocumentId =
|
|
1137
|
+
configArg.identityAsDocumentId ?? inheritedSchema?.identityAsDocumentId;
|
|
1138
|
+
if (identityAsDocumentId !== undefined) {
|
|
1139
|
+
assertIdentityDocumentIdIsOrdinary(modelArg, identityAsDocumentId);
|
|
1140
|
+
}
|
|
1019
1141
|
const normalizedSchema = normalizeCollectionModelSchema({
|
|
1020
1142
|
...configArg,
|
|
1021
1143
|
persistedFields: [
|
|
@@ -1030,6 +1152,9 @@ export function defineCollectionModel<
|
|
|
1030
1152
|
...(inheritedSchema?.identityFields || []),
|
|
1031
1153
|
...(configArg.identityFields || []),
|
|
1032
1154
|
] as Array<keyof TModel & string>,
|
|
1155
|
+
...(identityAsDocumentId !== undefined
|
|
1156
|
+
? { identityAsDocumentId: identityAsDocumentId as TStringFieldKey<TModel> }
|
|
1157
|
+
: {}),
|
|
1033
1158
|
searchableFields: [
|
|
1034
1159
|
...(inheritedSchema?.searchableFields || []),
|
|
1035
1160
|
...(configArg.searchableFields || []),
|
|
@@ -1254,13 +1379,30 @@ export function Collection(
|
|
|
1254
1379
|
const collectionName = normalizeCollectionName(
|
|
1255
1380
|
optionsArg?.collectionName ?? constructor.name,
|
|
1256
1381
|
);
|
|
1382
|
+
const identityAsDocumentId = optionsArg?.identityAsDocumentId;
|
|
1383
|
+
if (
|
|
1384
|
+
identityAsDocumentId !== undefined
|
|
1385
|
+
&& (typeof identityAsDocumentId !== 'string' || identityAsDocumentId.length === 0)
|
|
1386
|
+
) {
|
|
1387
|
+
throw new SmartdataPersistenceError(
|
|
1388
|
+
'invalid_configuration',
|
|
1389
|
+
'Collection identityAsDocumentId must name a declared @unI() identity field.',
|
|
1390
|
+
);
|
|
1391
|
+
}
|
|
1257
1392
|
return installCollectionBinding(
|
|
1258
1393
|
constructor as TCollectionModelConstructor<any>,
|
|
1259
1394
|
() => (dbArg instanceof SmartdataDb ? dbArg : dbArg()),
|
|
1260
1395
|
() => {
|
|
1396
|
+
if (identityAsDocumentId !== undefined) {
|
|
1397
|
+
assertIdentityDocumentIdIsOrdinary(
|
|
1398
|
+
constructor as TCollectionModelConstructor<any>,
|
|
1399
|
+
identityAsDocumentId,
|
|
1400
|
+
);
|
|
1401
|
+
}
|
|
1261
1402
|
return schemaFromDecoratorMetadata(
|
|
1262
1403
|
constructor as TCollectionModelConstructor<any>,
|
|
1263
1404
|
collectionName,
|
|
1405
|
+
identityAsDocumentId,
|
|
1264
1406
|
);
|
|
1265
1407
|
},
|
|
1266
1408
|
) as any;
|
|
@@ -1358,6 +1500,79 @@ export function managed<TManager extends IManager>(
|
|
|
1358
1500
|
*/
|
|
1359
1501
|
export const Manager = managed;
|
|
1360
1502
|
|
|
1503
|
+
/**
|
|
1504
|
+
* The index properties MongoDB weighs besides the key when it decides whether
|
|
1505
|
+
* two indexes collide. An existing index over the declared key that differs in
|
|
1506
|
+
* any of them is not interchangeable with the declaration, so adopting its name
|
|
1507
|
+
* would only collide again.
|
|
1508
|
+
*/
|
|
1509
|
+
const indexEquivalenceSignature = (indexArg: {
|
|
1510
|
+
readonly unique?: unknown;
|
|
1511
|
+
readonly sparse?: unknown;
|
|
1512
|
+
readonly expireAfterSeconds?: unknown;
|
|
1513
|
+
readonly partialFilterExpression?: unknown;
|
|
1514
|
+
}): string =>
|
|
1515
|
+
stableValue({
|
|
1516
|
+
unique: indexArg.unique === true,
|
|
1517
|
+
sparse: indexArg.sparse === true,
|
|
1518
|
+
expireAfterSeconds: indexArg.expireAfterSeconds ?? null,
|
|
1519
|
+
partialFilterExpression: indexArg.partialFilterExpression ?? null,
|
|
1520
|
+
});
|
|
1521
|
+
|
|
1522
|
+
/**
|
|
1523
|
+
* MongoDB reports an index collision without naming the index already in the
|
|
1524
|
+
* namespace, yet the remedy depends on it: adopt that name in the declaration
|
|
1525
|
+
* or drop the index. Only the collision path pays for the extra catalog read.
|
|
1526
|
+
*/
|
|
1527
|
+
const describeIndexCollision = async (
|
|
1528
|
+
targetCollectionArg: plugins.mongodb.Collection,
|
|
1529
|
+
collectionNameArg: string,
|
|
1530
|
+
expectedArg: INormalizedCollectionModelSchema['indexes'][number],
|
|
1531
|
+
errorArg: unknown,
|
|
1532
|
+
): Promise<unknown> => {
|
|
1533
|
+
if (!isMongoIndexConflictError(errorArg)) {
|
|
1534
|
+
return errorArg;
|
|
1535
|
+
}
|
|
1536
|
+
let actualIndexes: plugins.mongodb.Document[];
|
|
1537
|
+
try {
|
|
1538
|
+
actualIndexes = await targetCollectionArg.listIndexes().toArray();
|
|
1539
|
+
} catch {
|
|
1540
|
+
// Without the catalog the collision cannot be attributed, and the driver's
|
|
1541
|
+
// own error stays the best evidence the caller can act on.
|
|
1542
|
+
return errorArg;
|
|
1543
|
+
}
|
|
1544
|
+
const equivalentIndex = actualIndexes.find(
|
|
1545
|
+
(actualArg) =>
|
|
1546
|
+
actualArg.name !== expectedArg.name &&
|
|
1547
|
+
stableValue(Object.entries(actualArg.key || {})) ===
|
|
1548
|
+
stableValue(expectedArg.key) &&
|
|
1549
|
+
indexEquivalenceSignature(actualArg) ===
|
|
1550
|
+
indexEquivalenceSignature(expectedArg.options),
|
|
1551
|
+
);
|
|
1552
|
+
if (equivalentIndex) {
|
|
1553
|
+
return new SmartdataPersistenceError(
|
|
1554
|
+
'invalid_configuration',
|
|
1555
|
+
`Collection "${collectionNameArg}" already carries the equivalent index ` +
|
|
1556
|
+
`"${equivalentIndex.name}", so declared index "${expectedArg.name}" ` +
|
|
1557
|
+
'cannot be created. Declare the existing name — an identity declares ' +
|
|
1558
|
+
'it with @unI({ indexName }) — or drop the existing index.',
|
|
1559
|
+
{ cause: errorArg },
|
|
1560
|
+
);
|
|
1561
|
+
}
|
|
1562
|
+
if (actualIndexes.some((actualArg) => actualArg.name === expectedArg.name)) {
|
|
1563
|
+
return new SmartdataPersistenceError(
|
|
1564
|
+
'invalid_configuration',
|
|
1565
|
+
`Collection "${collectionNameArg}" already carries an index named ` +
|
|
1566
|
+
`"${expectedArg.name}" with a different key or options.`,
|
|
1567
|
+
{ cause: errorArg },
|
|
1568
|
+
);
|
|
1569
|
+
}
|
|
1570
|
+
// An index over the same key that is not interchangeable with the
|
|
1571
|
+
// declaration cannot be adopted by name, and MongoDB's own error already
|
|
1572
|
+
// reports the options that differ.
|
|
1573
|
+
return errorArg;
|
|
1574
|
+
};
|
|
1575
|
+
|
|
1361
1576
|
export class SmartdataCollection<T> {
|
|
1362
1577
|
/**
|
|
1363
1578
|
* the collection that is used
|
|
@@ -1595,13 +1810,22 @@ export class SmartdataCollection<T> {
|
|
|
1595
1810
|
return;
|
|
1596
1811
|
}
|
|
1597
1812
|
for (const index of modelSchema.indexes) {
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1813
|
+
try {
|
|
1814
|
+
await targetCollection.createIndex(
|
|
1815
|
+
Object.fromEntries(index.key) as plugins.mongodb.IndexSpecification,
|
|
1816
|
+
{
|
|
1817
|
+
...index.options,
|
|
1818
|
+
name: index.name,
|
|
1819
|
+
},
|
|
1820
|
+
);
|
|
1821
|
+
} catch (errorArg) {
|
|
1822
|
+
throw await describeIndexCollision(
|
|
1823
|
+
targetCollection,
|
|
1824
|
+
this.collectionName,
|
|
1825
|
+
index,
|
|
1826
|
+
errorArg,
|
|
1827
|
+
);
|
|
1828
|
+
}
|
|
1605
1829
|
}
|
|
1606
1830
|
const actualIndexes = await targetCollection.listIndexes().toArray();
|
|
1607
1831
|
for (const expected of modelSchema.indexes) {
|
|
@@ -1719,11 +1943,18 @@ export class SmartdataCollection<T> {
|
|
|
1719
1943
|
*/
|
|
1720
1944
|
public async markUniqueIndexes(keyArrayArg: string[] = []) {
|
|
1721
1945
|
for (const key of keyArrayArg) {
|
|
1946
|
+
if (key === this.modelSchema?.identityAsDocumentId) {
|
|
1947
|
+
// The primary key already enforces this identity; building a second
|
|
1948
|
+
// unique index on a live collection is exactly what the option avoids.
|
|
1949
|
+
continue;
|
|
1950
|
+
}
|
|
1722
1951
|
if (!this.uniqueIndexes.includes(key)) {
|
|
1723
1952
|
try {
|
|
1724
1953
|
await this.mongoDbCollection.createIndex({ [key]: 1 }, {
|
|
1725
1954
|
unique: true,
|
|
1726
|
-
|
|
1955
|
+
// A bound model may back its identity with a differently named
|
|
1956
|
+
// index; creating `<field>_1` would duplicate that key.
|
|
1957
|
+
name: getIdentityIndexName(this.modelSchema, key) ?? `${key}_1`,
|
|
1727
1958
|
});
|
|
1728
1959
|
this.uniqueIndexes.push(key);
|
|
1729
1960
|
} catch (err: any) {
|
|
@@ -1959,6 +2190,7 @@ export class SmartdataCollection<T> {
|
|
|
1959
2190
|
* create an object in the database
|
|
1960
2191
|
*/
|
|
1961
2192
|
private prepareOrdinaryInsert(documentArg: any): any {
|
|
2193
|
+
documentArg = this.applyIdentityDocumentId(documentArg);
|
|
1962
2194
|
const policy = this.modelSchema?.ordinaryPersistence;
|
|
1963
2195
|
if (!policy) return documentArg;
|
|
1964
2196
|
if (policy.idType === 'objectId' && !Object.prototype.hasOwnProperty.call(documentArg, '_id')) {
|
|
@@ -1967,11 +2199,46 @@ export class SmartdataCollection<T> {
|
|
|
1967
2199
|
return validateOrdinaryStoredDocument(documentArg, policy, this.modelSchema!.persistedFields);
|
|
1968
2200
|
}
|
|
1969
2201
|
|
|
2202
|
+
/**
|
|
2203
|
+
* Keys a stored document by its declared identity when the model opted into
|
|
2204
|
+
* `identityAsDocumentId`. The identity keeps its own stored field, but reads
|
|
2205
|
+
* address `_id`, so a document written before the option was declared is
|
|
2206
|
+
* reachable only if its `_id` already equals that identity.
|
|
2207
|
+
*/
|
|
2208
|
+
private applyIdentityDocumentId(documentArg: any): any {
|
|
2209
|
+
const identityField = this.modelSchema?.identityAsDocumentId;
|
|
2210
|
+
if (!identityField) return documentArg;
|
|
2211
|
+
const identityValue = documentArg?.[identityField];
|
|
2212
|
+
if (typeof identityValue !== 'string' || identityValue.trim().length === 0) {
|
|
2213
|
+
throw new SmartdataPersistenceError(
|
|
2214
|
+
'invalid_argument',
|
|
2215
|
+
`Identity field "${identityField}" must contain a non-empty string identity value to key the document.`,
|
|
2216
|
+
);
|
|
2217
|
+
}
|
|
2218
|
+
if (
|
|
2219
|
+
Object.prototype.hasOwnProperty.call(documentArg, '_id')
|
|
2220
|
+
&& documentArg._id !== identityValue
|
|
2221
|
+
) {
|
|
2222
|
+
throw new SmartdataPersistenceError(
|
|
2223
|
+
'invalid_argument',
|
|
2224
|
+
`Identity field "${identityField}" owns the document _id and cannot be stored with a divergent _id.`,
|
|
2225
|
+
);
|
|
2226
|
+
}
|
|
2227
|
+
return { ...documentArg, _id: identityValue };
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
/** True when stored documents need SmartData-owned preparation before a write. */
|
|
2231
|
+
private get preparesStoredDocuments(): boolean {
|
|
2232
|
+
return Boolean(
|
|
2233
|
+
this.modelSchema?.ordinaryPersistence || this.modelSchema?.identityAsDocumentId,
|
|
2234
|
+
);
|
|
2235
|
+
}
|
|
2236
|
+
|
|
1970
2237
|
public async insert(
|
|
1971
2238
|
dbDocArg: T & SmartDataDbDoc<T, unknown>,
|
|
1972
2239
|
opts?: { session?: TSmartdataOrdinarySession }
|
|
1973
2240
|
): Promise<any> {
|
|
1974
|
-
const preparedObject = this.
|
|
2241
|
+
const preparedObject = this.preparesStoredDocuments
|
|
1975
2242
|
? this.prepareOrdinaryInsert(await dbDocArg.createSavableObject()) : undefined;
|
|
1976
2243
|
return this.runWithOrdinarySession(
|
|
1977
2244
|
opts?.session,
|
|
@@ -2049,7 +2316,7 @@ export class SmartdataCollection<T> {
|
|
|
2049
2316
|
`insertMany requires a non-empty document batch for collection "${this.collectionName}".`,
|
|
2050
2317
|
);
|
|
2051
2318
|
}
|
|
2052
|
-
const preparedObjects: any[] | undefined = this.
|
|
2319
|
+
const preparedObjects: any[] | undefined = this.preparesStoredDocuments ? [] : undefined;
|
|
2053
2320
|
if (preparedObjects) {
|
|
2054
2321
|
for (const dbDocArg of dbDocsArg) {
|
|
2055
2322
|
preparedObjects.push(this.prepareOrdinaryInsert(await dbDocArg.createSavableObject()));
|
|
@@ -2118,14 +2385,23 @@ export class SmartdataCollection<T> {
|
|
|
2118
2385
|
const first = documentsArg[0];
|
|
2119
2386
|
await this.markUniqueIndexes(first.uniqueIndexes);
|
|
2120
2387
|
await this.createRegularIndexes(first.regularIndexes || []);
|
|
2388
|
+
// A document keyed by its identity is addressed through _id, and the
|
|
2389
|
+
// seeded body must not repeat the immutable primary key: MongoDB derives
|
|
2390
|
+
// it from the filter's equality condition on the insert branch.
|
|
2391
|
+
const documentIdIdentity = this.modelSchema?.identityAsDocumentId === identityFieldArg;
|
|
2121
2392
|
try {
|
|
2122
|
-
const result = await this.mongoDbCollection.bulkWrite(prepared.map((document) =>
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2393
|
+
const result = await this.mongoDbCollection.bulkWrite(prepared.map((document) => {
|
|
2394
|
+
const { _id: storedId, ...body } = document as Record<string, unknown>;
|
|
2395
|
+
return {
|
|
2396
|
+
updateOne: {
|
|
2397
|
+
filter: documentIdIdentity
|
|
2398
|
+
? { _id: storedId as plugins.mongodb.Condition<plugins.mongodb.ObjectId> }
|
|
2399
|
+
: { [identityFieldArg]: document[identityFieldArg] },
|
|
2400
|
+
update: { $setOnInsert: documentIdIdentity ? body : document },
|
|
2401
|
+
upsert: true,
|
|
2402
|
+
},
|
|
2403
|
+
};
|
|
2404
|
+
}), { ordered: false, session: rawSessionArg, timeoutMS: optsArg.timeoutMS });
|
|
2129
2405
|
if (result.upsertedCount + result.matchedCount !== prepared.length) {
|
|
2130
2406
|
throw new SmartdataPersistenceError('unsupported_operation',
|
|
2131
2407
|
'insertManyIfAbsent requires an acknowledged result for every identity.');
|
|
@@ -298,6 +298,31 @@ export const getExpectedCollectionTopologyForSchema = (
|
|
|
298
298
|
});
|
|
299
299
|
};
|
|
300
300
|
|
|
301
|
+
/**
|
|
302
|
+
* @internal A single-field ascending unique index is what makes a declared
|
|
303
|
+
* identity globally constraining, whatever the index is called.
|
|
304
|
+
*/
|
|
305
|
+
export const isIdentityIndexFor = (
|
|
306
|
+
indexArg: INormalizedCollectionModelSchema['indexes'][number],
|
|
307
|
+
fieldArg: string,
|
|
308
|
+
): boolean =>
|
|
309
|
+
indexArg.options.unique === true &&
|
|
310
|
+
indexArg.key.length === 1 &&
|
|
311
|
+
indexArg.key[0][0] === fieldArg &&
|
|
312
|
+
indexArg.key[0][1] === 1;
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* @internal Name of the unique index that backs an identity field in a bound
|
|
316
|
+
* schema. Index-creation fallbacks resolve it here so that a model naming its
|
|
317
|
+
* own backing index never gets a second unique index over the same key.
|
|
318
|
+
*/
|
|
319
|
+
export const getIdentityIndexName = (
|
|
320
|
+
schemaArg: INormalizedCollectionModelSchema | undefined,
|
|
321
|
+
fieldArg: string,
|
|
322
|
+
): string | undefined =>
|
|
323
|
+
schemaArg?.indexes.find((indexArg) => isIdentityIndexFor(indexArg, fieldArg))
|
|
324
|
+
?.name;
|
|
325
|
+
|
|
301
326
|
const normalizeActualIndex = (
|
|
302
327
|
rawIndexArg: unknown,
|
|
303
328
|
): IActualIndexNormalization => {
|