@lossless.org/client 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +120 -13
- package/dist_ts/nosqldb/classes.doc.d.ts +58 -0
- package/dist_ts/nosqldb/classes.doc.js +287 -22
- package/dist_ts/nosqldb/classes.ordinarypersistence.d.ts +6 -0
- package/dist_ts/nosqldb/classes.ordinarypersistence.js +7 -1
- package/package.json +1 -1
- package/readme.md +10 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/nosqldb/classes.atomicupdate.ts +67 -0
- package/ts/nosqldb/classes.collection.ts +181 -10
- package/ts/nosqldb/classes.doc.ts +525 -19
- package/ts/nosqldb/classes.ordinarypersistence.ts +8 -0
|
@@ -11,6 +11,12 @@ export interface IOrdinaryPersistencePolicy {
|
|
|
11
11
|
readonly timestamps: 'managed' | 'none';
|
|
12
12
|
readonly assertStoredDocument: (valueArg: unknown) => void;
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Reports whether a model opted into exact persistence. Exact persistence owns
|
|
16
|
+
* the stored `_id` as a MongoDB ObjectId, so options that hand the primary key
|
|
17
|
+
* to a declared identity field must refuse those models by name.
|
|
18
|
+
*/
|
|
19
|
+
export declare const hasExactPersistencePolicy: (modelArg: any) => boolean;
|
|
14
20
|
export declare const getOrdinaryPersistencePolicy: (modelArg: any) => IOrdinaryPersistencePolicy | undefined;
|
|
15
21
|
/** Opt into immutable whole-stored-document validation for an ordinary model. */
|
|
16
22
|
export declare const ordinaryPersistence: (opts: IOrdinaryPersistenceOptions) => <T extends new (...args: any[]) => any>(modelArg: T, _contextArg?: ClassDecoratorContext<T>) => T;
|
|
@@ -3,6 +3,12 @@ import { SmartdataPersistenceError } from './classes.persistence.js';
|
|
|
3
3
|
import { assertBsonWithoutMutation } from './classes.bsonassertion.js';
|
|
4
4
|
const policySymbol = Symbol.for('@push.rocks/smartdata.ordinaryPersistencePolicy');
|
|
5
5
|
const exactSymbol = Symbol.for('@push.rocks/smartdata.exactPersistencePolicy');
|
|
6
|
+
/**
|
|
7
|
+
* Reports whether a model opted into exact persistence. Exact persistence owns
|
|
8
|
+
* the stored `_id` as a MongoDB ObjectId, so options that hand the primary key
|
|
9
|
+
* to a declared identity field must refuse those models by name.
|
|
10
|
+
*/
|
|
11
|
+
export const hasExactPersistencePolicy = (modelArg) => Boolean(modelArg?.[exactSymbol]);
|
|
6
12
|
export const getOrdinaryPersistencePolicy = (modelArg) => {
|
|
7
13
|
const policy = modelArg?.[policySymbol];
|
|
8
14
|
if (policy && modelArg[exactSymbol]) {
|
|
@@ -68,4 +74,4 @@ export const validateOrdinaryStoredDocument = (documentArg, policyArg, persisted
|
|
|
68
74
|
// The assertion must not retain authority to mutate the returned document.
|
|
69
75
|
return plugins.mongodb.BSON.deserialize(plugins.mongodb.BSON.serialize(clone));
|
|
70
76
|
};
|
|
71
|
-
//# sourceMappingURL=data:application/json;base64,
|
|
77
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2xhc3Nlcy5vcmRpbmFyeXBlcnNpc3RlbmNlLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vdHMvbm9zcWxkYi9jbGFzc2VzLm9yZGluYXJ5cGVyc2lzdGVuY2UudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxLQUFLLE9BQU8sTUFBTSxjQUFjLENBQUM7QUFDeEMsT0FBTyxFQUFFLHlCQUF5QixFQUFFLE1BQU0sMEJBQTBCLENBQUM7QUFDckUsT0FBTyxFQUFFLHlCQUF5QixFQUFFLE1BQU0sNEJBQTRCLENBQUM7QUFpQnZFLE1BQU0sWUFBWSxHQUFHLE1BQU0sQ0FBQyxHQUFHLENBQUMsaURBQWlELENBQUMsQ0FBQztBQUNuRixNQUFNLFdBQVcsR0FBRyxNQUFNLENBQUMsR0FBRyxDQUFDLDhDQUE4QyxDQUFDLENBQUM7QUFFL0U7Ozs7R0FJRztBQUNILE1BQU0sQ0FBQyxNQUFNLHlCQUF5QixHQUFHLENBQUMsUUFBYSxFQUFXLEVBQUUsQ0FDbEUsT0FBTyxDQUFDLFFBQVEsRUFBRSxDQUFDLFdBQVcsQ0FBQyxDQUFDLENBQUM7QUFFbkMsTUFBTSxDQUFDLE1BQU0sNEJBQTRCLEdBQUcsQ0FBQyxRQUFhLEVBQTBDLEVBQUU7SUFDcEcsTUFBTSxNQUFNLEdBQUcsUUFBUSxFQUFFLENBQUMsWUFBWSxDQUEyQyxDQUFDO0lBQ2xGLElBQUksTUFBTSxJQUFJLFFBQVEsQ0FBQyxXQUFXLENBQUMsRUFBRSxDQUFDO1FBQ3BDLE1BQU0sSUFBSSx5QkFBeUIsQ0FBQyx1QkFBdUIsRUFDekQsNkRBQTZELENBQUMsQ0FBQztJQUNuRSxDQUFDO0lBQ0QsT0FBTyxNQUFNLENBQUM7QUFDaEIsQ0FBQyxDQUFDO0FBRUYsaUZBQWlGO0FBQ2pGLE1BQU0sQ0FBQyxNQUFNLG1CQUFtQixHQUFHLENBQUMsSUFBaUMsRUFBRSxFQUFFO0lBQ3ZFLElBQUksQ0FBQyxJQUFJLElBQUksTUFBTSxDQUFDLGNBQWMsQ0FBQyxJQUFJLENBQUMsS0FB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
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -78,6 +78,15 @@ Literal connection names and backend discriminators determine the return types.
|
|
|
78
78
|
|
|
79
79
|
Family imports preserve the established SmartData and SmartBucket names and constructors. Their implementations live here; the client does not depend on the old packages. The aggregate loads only configured families. Direct family entry points do not initialize unrelated connections.
|
|
80
80
|
|
|
81
|
+
| `@lossless.org/client/nosqldb` addition | API |
|
|
82
|
+
| --- | --- |
|
|
83
|
+
| Monotonic counters and timestamps | `$max` / `$min` in `ISmartdataAtomicUpdate`, for declared numeric and date fields |
|
|
84
|
+
| Race-free registration on a second unique key | `$setOnInsert` may seed a declared `@unI()` identity when `upsert: true` |
|
|
85
|
+
| Bounded plural upsert | `Model.atomicUpsertMany(operations, opts?)` — 1..1000 per-document filter/update pairs in one unordered round trip |
|
|
86
|
+
| Content-addressed primary keys | model option `identityAsDocumentId` on `defineCollectionModel()` and `@Collection()` |
|
|
87
|
+
|
|
88
|
+
`docs/source/smartdata/readme.md` documents each one with its refusals.
|
|
89
|
+
|
|
81
90
|
## Relational SQL
|
|
82
91
|
|
|
83
92
|
`SqlConnection` uses the official MariaDB connector for parameters, protocol, TLS and decoding. This client owns cancellable admission and TCP sockets from authentication onward. A transaction holds one physical session; releasing a session resets its state before reuse.
|
|
@@ -144,7 +153,7 @@ SmartClickHouse migration changes: replace RxJS `next/complete` insertion with a
|
|
|
144
153
|
|
|
145
154
|
`@lossless.org/client/nosqldb` is a fork of @push.rocks/smartdata 11.14.2 (commit `9868817a63de72caef787bd8e68bac3deb9d6131`), taken on 2026-09-10; this repository continues that package's git history, so every upstream fix up to and including 11.14.2 is present here. `nosqldbLineage`, exported from the family entry point, carries the same baseline for runtime and tooling checks, and `docs/source/smartdata` keeps the upstream readme, hints and changelog.
|
|
146
155
|
|
|
147
|
-
The fork departs from that baseline in three places: `NoSqlConnection` adds the client backend, capability and readiness contract; the family entry point exports it together with `nosqldbLineage`; and `watch()` refuses to open a change stream when the connected backend reports change streams as unsupported. The package version file was dropped, because versioning belongs to `@lossless.org/client`.
|
|
156
|
+
The fork departs from that baseline in three places: `NoSqlConnection` adds the client backend, capability and readiness contract; the family entry point exports it together with `nosqldbLineage`; and `watch()` refuses to open a change stream when the connected backend reports change streams as unsupported. The package version file was dropped, because versioning belongs to `@lossless.org/client`. Document-model capabilities added after the fork — `$max`/`$min`, identity seeding through `$setOnInsert`, `atomicUpsertMany()` and `identityAsDocumentId` — are this package's own additions and have no upstream counterpart.
|
|
148
157
|
|
|
149
158
|
Fixes land here. @push.rocks/smartdata is no longer a release channel for this implementation, so a later upstream version is not a source to merge from and not an upgrade path for consumers of this package.
|
|
150
159
|
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -52,6 +52,73 @@ export const executeAtomicUpdate = async <T>(
|
|
|
52
52
|
);
|
|
53
53
|
};
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Executes a bounded batch of model-validated atomic upserts against MongoDB.
|
|
57
|
+
*
|
|
58
|
+
* This helper is package-internal. `SmartDataDbDoc.atomicUpsertMany()` owns the
|
|
59
|
+
* public API and must validate and normalize every filter and update before
|
|
60
|
+
* calling this raw persistence boundary. Each entry applies atomically; the
|
|
61
|
+
* batch as a whole is not isolated unless the caller supplies a transaction
|
|
62
|
+
* session.
|
|
63
|
+
*/
|
|
64
|
+
export const executeAtomicUpsertMany = async <T>(
|
|
65
|
+
collectionArg: SmartdataCollection<T>,
|
|
66
|
+
operationsArg: ReadonlyArray<{
|
|
67
|
+
filter: plugins.mongodb.Filter<plugins.mongodb.Document>;
|
|
68
|
+
update: plugins.mongodb.UpdateFilter<plugins.mongodb.Document>;
|
|
69
|
+
}>,
|
|
70
|
+
optsArg?: {
|
|
71
|
+
session?: TSmartdataOrdinarySession;
|
|
72
|
+
timeoutMS?: number;
|
|
73
|
+
},
|
|
74
|
+
): Promise<{
|
|
75
|
+
acknowledged: boolean;
|
|
76
|
+
matchedCount: number;
|
|
77
|
+
modifiedCount: number;
|
|
78
|
+
upsertedCount: number;
|
|
79
|
+
}> => {
|
|
80
|
+
return runWithOrdinarySmartdataSession(
|
|
81
|
+
optsArg?.session,
|
|
82
|
+
collectionArg.smartdataDb,
|
|
83
|
+
{
|
|
84
|
+
ordinaryWrite: true,
|
|
85
|
+
prepared: collectionArg.isInitializedForCurrentDatabase(),
|
|
86
|
+
preparationMessage:
|
|
87
|
+
`Initialize collection "${collectionArg.collectionName}" before using an owned SmartData session.`,
|
|
88
|
+
},
|
|
89
|
+
async (rawSessionArg) => {
|
|
90
|
+
await collectionArg.init();
|
|
91
|
+
try {
|
|
92
|
+
const result = await collectionArg.mongoDbCollection.bulkWrite(
|
|
93
|
+
operationsArg.map((operationArg) => ({
|
|
94
|
+
updateOne: {
|
|
95
|
+
filter: operationArg.filter,
|
|
96
|
+
update: operationArg.update,
|
|
97
|
+
upsert: true,
|
|
98
|
+
},
|
|
99
|
+
})),
|
|
100
|
+
{
|
|
101
|
+
ordered: false,
|
|
102
|
+
session: rawSessionArg,
|
|
103
|
+
timeoutMS: optsArg?.timeoutMS,
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
return {
|
|
107
|
+
acknowledged: result.isOk(),
|
|
108
|
+
matchedCount: result.matchedCount,
|
|
109
|
+
modifiedCount: result.modifiedCount,
|
|
110
|
+
upsertedCount: result.upsertedCount,
|
|
111
|
+
};
|
|
112
|
+
} catch (errorArg) {
|
|
113
|
+
return normalizeOrdinaryPersistenceError(
|
|
114
|
+
errorArg,
|
|
115
|
+
`Atomic upsert-many conflicts with a unique index in collection "${collectionArg.collectionName}".`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
};
|
|
121
|
+
|
|
55
122
|
/**
|
|
56
123
|
* Executes a model-validated atomic update-many against MongoDB.
|
|
57
124
|
*
|
|
@@ -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';
|
|
@@ -57,6 +58,14 @@ export interface ICollectionBindingOptions {
|
|
|
57
58
|
* its legacy collection without changing stored data.
|
|
58
59
|
*/
|
|
59
60
|
collectionName?: string;
|
|
61
|
+
/**
|
|
62
|
+
* Stores one declared `@unI()` string identity as the document `_id`, making
|
|
63
|
+
* the primary key itself the uniqueness authority. The field keeps its own
|
|
64
|
+
* stored value and receives no separate unique index. Irreversible for an
|
|
65
|
+
* existing collection: every stored document must already satisfy
|
|
66
|
+
* `_id === document[field]`.
|
|
67
|
+
*/
|
|
68
|
+
identityAsDocumentId?: string;
|
|
60
69
|
}
|
|
61
70
|
|
|
62
71
|
export type TCollectionModelIndexDirection = 1 | -1 | 'text';
|
|
@@ -106,6 +115,13 @@ export interface ICollectionModelConfig<TModel extends object = any> {
|
|
|
106
115
|
* ascending unique index in `indexes`.
|
|
107
116
|
*/
|
|
108
117
|
identityFields?: ReadonlyArray<TStringFieldKey<TModel>>;
|
|
118
|
+
/**
|
|
119
|
+
* Stores one declared string identity as the document `_id`. The primary key
|
|
120
|
+
* becomes the uniqueness authority, so the field must not declare its own
|
|
121
|
+
* unique index. Irreversible for an existing collection: every stored
|
|
122
|
+
* document must already satisfy `_id === document[field]`.
|
|
123
|
+
*/
|
|
124
|
+
identityAsDocumentId?: TStringFieldKey<TModel>;
|
|
109
125
|
/**
|
|
110
126
|
* Persisted fields used by SmartData's search helpers.
|
|
111
127
|
*/
|
|
@@ -122,6 +138,8 @@ export interface INormalizedCollectionModelSchema {
|
|
|
122
138
|
readonly persistedFields: readonly string[];
|
|
123
139
|
readonly numericFields: readonly string[];
|
|
124
140
|
readonly identityFields: readonly string[];
|
|
141
|
+
/** Declared identity field stored as the document `_id`, if any. */
|
|
142
|
+
readonly identityAsDocumentId?: string;
|
|
125
143
|
readonly identityValueTypes: Readonly<
|
|
126
144
|
Record<string, TSmartdataIdentityValueType>
|
|
127
145
|
>;
|
|
@@ -323,6 +341,7 @@ const normalizeCollectionModelSchema = (
|
|
|
323
341
|
'persistedFields',
|
|
324
342
|
'numericFields',
|
|
325
343
|
'identityFields',
|
|
344
|
+
'identityAsDocumentId',
|
|
326
345
|
'searchableFields',
|
|
327
346
|
'indexes',
|
|
328
347
|
]);
|
|
@@ -395,6 +414,24 @@ const normalizeCollectionModelSchema = (
|
|
|
395
414
|
identityFields.push(field);
|
|
396
415
|
}
|
|
397
416
|
}
|
|
417
|
+
const identityAsDocumentId = configArg.identityAsDocumentId;
|
|
418
|
+
if (identityAsDocumentId !== undefined) {
|
|
419
|
+
if (
|
|
420
|
+
typeof identityAsDocumentId !== 'string' ||
|
|
421
|
+
!identityFields.includes(identityAsDocumentId)
|
|
422
|
+
) {
|
|
423
|
+
throw new SmartdataPersistenceError(
|
|
424
|
+
'invalid_configuration',
|
|
425
|
+
'Collection model identityAsDocumentId must name a declared identity field.',
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
if (ordinaryPolicyArg?.idType === 'string') {
|
|
429
|
+
throw new SmartdataPersistenceError(
|
|
430
|
+
'invalid_configuration',
|
|
431
|
+
`Identity field "${identityAsDocumentId}" cannot own the document _id together with a string ordinary persistence _id.`,
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
398
435
|
const identityValueTypes: Record<string, TSmartdataIdentityValueType> = {};
|
|
399
436
|
for (const field of Object.keys(identityValueTypesArg || {})) {
|
|
400
437
|
if (!identityFields.includes(field)) {
|
|
@@ -564,6 +601,25 @@ const normalizeCollectionModelSchema = (
|
|
|
564
601
|
indexArg.key[0][0] === identityField &&
|
|
565
602
|
indexArg.key[0][1] === 1,
|
|
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,
|
|
@@ -854,6 +912,7 @@ const definedModelSchemaSymbol = Symbol.for(
|
|
|
854
912
|
const schemaFromDecoratorMetadata = (
|
|
855
913
|
constructorArg: TCollectionModelConstructor<any>,
|
|
856
914
|
collectionNameArg: string,
|
|
915
|
+
identityAsDocumentIdArg?: string,
|
|
857
916
|
): INormalizedCollectionModelSchema => {
|
|
858
917
|
const ownMetadata = Object.prototype.hasOwnProperty.call(
|
|
859
918
|
constructorArg,
|
|
@@ -885,6 +944,11 @@ const schemaFromDecoratorMetadata = (
|
|
|
885
944
|
.map(([fieldArg]) => fieldArg);
|
|
886
945
|
const indexes: ICollectionModelIndex[] = [];
|
|
887
946
|
for (const uniqueField of metadata.uniqueIndexes || []) {
|
|
947
|
+
if (uniqueField === identityAsDocumentIdArg) {
|
|
948
|
+
// The document _id enforces this identity; a derived unique index would
|
|
949
|
+
// duplicate the primary key and require an index build on live data.
|
|
950
|
+
continue;
|
|
951
|
+
}
|
|
888
952
|
indexes.push({
|
|
889
953
|
name: `${uniqueField}_1`,
|
|
890
954
|
key: { [uniqueField]: 1 },
|
|
@@ -924,6 +988,9 @@ const schemaFromDecoratorMetadata = (
|
|
|
924
988
|
persistedFields,
|
|
925
989
|
numericFields,
|
|
926
990
|
identityFields: metadata.uniqueIndexes || [],
|
|
991
|
+
...(identityAsDocumentIdArg !== undefined
|
|
992
|
+
? { identityAsDocumentId: identityAsDocumentIdArg }
|
|
993
|
+
: {}),
|
|
927
994
|
searchableFields: metadata.searchableFields || [],
|
|
928
995
|
indexes,
|
|
929
996
|
},
|
|
@@ -933,6 +1000,35 @@ const schemaFromDecoratorMetadata = (
|
|
|
933
1000
|
);
|
|
934
1001
|
};
|
|
935
1002
|
|
|
1003
|
+
/**
|
|
1004
|
+
* Exact persistence owns the stored `_id` as a MongoDB ObjectId, so a model
|
|
1005
|
+
* cannot also hand the primary key to a declared identity field.
|
|
1006
|
+
*/
|
|
1007
|
+
const assertIdentityDocumentIdIsOrdinary = (
|
|
1008
|
+
modelArg: TCollectionModelConstructor<any>,
|
|
1009
|
+
identityAsDocumentIdArg: string,
|
|
1010
|
+
): void => {
|
|
1011
|
+
if (hasExactPersistencePolicy(modelArg)) {
|
|
1012
|
+
throw new SmartdataPersistenceError(
|
|
1013
|
+
'invalid_configuration',
|
|
1014
|
+
`Identity field "${identityAsDocumentIdArg}" cannot own the document _id for an exact-persistence model.`,
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
};
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Returns the declared identity field a model stores as its document `_id`.
|
|
1021
|
+
* Reading the bound schema resolver never resolves a database.
|
|
1022
|
+
*/
|
|
1023
|
+
export const getIdentityDocumentIdField = (modelArg: any): string | undefined => {
|
|
1024
|
+
const resolver = modelArg?.[collectionModelSchemaResolverSymbol] as
|
|
1025
|
+
| (() => INormalizedCollectionModelSchema)
|
|
1026
|
+
| undefined;
|
|
1027
|
+
return typeof resolver === 'function'
|
|
1028
|
+
? resolver().identityAsDocumentId
|
|
1029
|
+
: undefined;
|
|
1030
|
+
};
|
|
1031
|
+
|
|
936
1032
|
const installCollectionBinding = <
|
|
937
1033
|
TModel extends SmartDataDbDoc<any, any>,
|
|
938
1034
|
>(
|
|
@@ -1016,6 +1112,11 @@ export function defineCollectionModel<
|
|
|
1016
1112
|
}
|
|
1017
1113
|
| undefined;
|
|
1018
1114
|
const inheritedSchema = inheritedConstructor?.[definedModelSchemaSymbol];
|
|
1115
|
+
const identityAsDocumentId =
|
|
1116
|
+
configArg.identityAsDocumentId ?? inheritedSchema?.identityAsDocumentId;
|
|
1117
|
+
if (identityAsDocumentId !== undefined) {
|
|
1118
|
+
assertIdentityDocumentIdIsOrdinary(modelArg, identityAsDocumentId);
|
|
1119
|
+
}
|
|
1019
1120
|
const normalizedSchema = normalizeCollectionModelSchema({
|
|
1020
1121
|
...configArg,
|
|
1021
1122
|
persistedFields: [
|
|
@@ -1030,6 +1131,9 @@ export function defineCollectionModel<
|
|
|
1030
1131
|
...(inheritedSchema?.identityFields || []),
|
|
1031
1132
|
...(configArg.identityFields || []),
|
|
1032
1133
|
] as Array<keyof TModel & string>,
|
|
1134
|
+
...(identityAsDocumentId !== undefined
|
|
1135
|
+
? { identityAsDocumentId: identityAsDocumentId as TStringFieldKey<TModel> }
|
|
1136
|
+
: {}),
|
|
1033
1137
|
searchableFields: [
|
|
1034
1138
|
...(inheritedSchema?.searchableFields || []),
|
|
1035
1139
|
...(configArg.searchableFields || []),
|
|
@@ -1254,13 +1358,30 @@ export function Collection(
|
|
|
1254
1358
|
const collectionName = normalizeCollectionName(
|
|
1255
1359
|
optionsArg?.collectionName ?? constructor.name,
|
|
1256
1360
|
);
|
|
1361
|
+
const identityAsDocumentId = optionsArg?.identityAsDocumentId;
|
|
1362
|
+
if (
|
|
1363
|
+
identityAsDocumentId !== undefined
|
|
1364
|
+
&& (typeof identityAsDocumentId !== 'string' || identityAsDocumentId.length === 0)
|
|
1365
|
+
) {
|
|
1366
|
+
throw new SmartdataPersistenceError(
|
|
1367
|
+
'invalid_configuration',
|
|
1368
|
+
'Collection identityAsDocumentId must name a declared @unI() identity field.',
|
|
1369
|
+
);
|
|
1370
|
+
}
|
|
1257
1371
|
return installCollectionBinding(
|
|
1258
1372
|
constructor as TCollectionModelConstructor<any>,
|
|
1259
1373
|
() => (dbArg instanceof SmartdataDb ? dbArg : dbArg()),
|
|
1260
1374
|
() => {
|
|
1375
|
+
if (identityAsDocumentId !== undefined) {
|
|
1376
|
+
assertIdentityDocumentIdIsOrdinary(
|
|
1377
|
+
constructor as TCollectionModelConstructor<any>,
|
|
1378
|
+
identityAsDocumentId,
|
|
1379
|
+
);
|
|
1380
|
+
}
|
|
1261
1381
|
return schemaFromDecoratorMetadata(
|
|
1262
1382
|
constructor as TCollectionModelConstructor<any>,
|
|
1263
1383
|
collectionName,
|
|
1384
|
+
identityAsDocumentId,
|
|
1264
1385
|
);
|
|
1265
1386
|
},
|
|
1266
1387
|
) as any;
|
|
@@ -1719,6 +1840,11 @@ export class SmartdataCollection<T> {
|
|
|
1719
1840
|
*/
|
|
1720
1841
|
public async markUniqueIndexes(keyArrayArg: string[] = []) {
|
|
1721
1842
|
for (const key of keyArrayArg) {
|
|
1843
|
+
if (key === this.modelSchema?.identityAsDocumentId) {
|
|
1844
|
+
// The primary key already enforces this identity; building a second
|
|
1845
|
+
// unique index on a live collection is exactly what the option avoids.
|
|
1846
|
+
continue;
|
|
1847
|
+
}
|
|
1722
1848
|
if (!this.uniqueIndexes.includes(key)) {
|
|
1723
1849
|
try {
|
|
1724
1850
|
await this.mongoDbCollection.createIndex({ [key]: 1 }, {
|
|
@@ -1959,6 +2085,7 @@ export class SmartdataCollection<T> {
|
|
|
1959
2085
|
* create an object in the database
|
|
1960
2086
|
*/
|
|
1961
2087
|
private prepareOrdinaryInsert(documentArg: any): any {
|
|
2088
|
+
documentArg = this.applyIdentityDocumentId(documentArg);
|
|
1962
2089
|
const policy = this.modelSchema?.ordinaryPersistence;
|
|
1963
2090
|
if (!policy) return documentArg;
|
|
1964
2091
|
if (policy.idType === 'objectId' && !Object.prototype.hasOwnProperty.call(documentArg, '_id')) {
|
|
@@ -1967,11 +2094,46 @@ export class SmartdataCollection<T> {
|
|
|
1967
2094
|
return validateOrdinaryStoredDocument(documentArg, policy, this.modelSchema!.persistedFields);
|
|
1968
2095
|
}
|
|
1969
2096
|
|
|
2097
|
+
/**
|
|
2098
|
+
* Keys a stored document by its declared identity when the model opted into
|
|
2099
|
+
* `identityAsDocumentId`. The identity keeps its own stored field, but reads
|
|
2100
|
+
* address `_id`, so a document written before the option was declared is
|
|
2101
|
+
* reachable only if its `_id` already equals that identity.
|
|
2102
|
+
*/
|
|
2103
|
+
private applyIdentityDocumentId(documentArg: any): any {
|
|
2104
|
+
const identityField = this.modelSchema?.identityAsDocumentId;
|
|
2105
|
+
if (!identityField) return documentArg;
|
|
2106
|
+
const identityValue = documentArg?.[identityField];
|
|
2107
|
+
if (typeof identityValue !== 'string' || identityValue.trim().length === 0) {
|
|
2108
|
+
throw new SmartdataPersistenceError(
|
|
2109
|
+
'invalid_argument',
|
|
2110
|
+
`Identity field "${identityField}" must contain a non-empty string identity value to key the document.`,
|
|
2111
|
+
);
|
|
2112
|
+
}
|
|
2113
|
+
if (
|
|
2114
|
+
Object.prototype.hasOwnProperty.call(documentArg, '_id')
|
|
2115
|
+
&& documentArg._id !== identityValue
|
|
2116
|
+
) {
|
|
2117
|
+
throw new SmartdataPersistenceError(
|
|
2118
|
+
'invalid_argument',
|
|
2119
|
+
`Identity field "${identityField}" owns the document _id and cannot be stored with a divergent _id.`,
|
|
2120
|
+
);
|
|
2121
|
+
}
|
|
2122
|
+
return { ...documentArg, _id: identityValue };
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
/** True when stored documents need SmartData-owned preparation before a write. */
|
|
2126
|
+
private get preparesStoredDocuments(): boolean {
|
|
2127
|
+
return Boolean(
|
|
2128
|
+
this.modelSchema?.ordinaryPersistence || this.modelSchema?.identityAsDocumentId,
|
|
2129
|
+
);
|
|
2130
|
+
}
|
|
2131
|
+
|
|
1970
2132
|
public async insert(
|
|
1971
2133
|
dbDocArg: T & SmartDataDbDoc<T, unknown>,
|
|
1972
2134
|
opts?: { session?: TSmartdataOrdinarySession }
|
|
1973
2135
|
): Promise<any> {
|
|
1974
|
-
const preparedObject = this.
|
|
2136
|
+
const preparedObject = this.preparesStoredDocuments
|
|
1975
2137
|
? this.prepareOrdinaryInsert(await dbDocArg.createSavableObject()) : undefined;
|
|
1976
2138
|
return this.runWithOrdinarySession(
|
|
1977
2139
|
opts?.session,
|
|
@@ -2049,7 +2211,7 @@ export class SmartdataCollection<T> {
|
|
|
2049
2211
|
`insertMany requires a non-empty document batch for collection "${this.collectionName}".`,
|
|
2050
2212
|
);
|
|
2051
2213
|
}
|
|
2052
|
-
const preparedObjects: any[] | undefined = this.
|
|
2214
|
+
const preparedObjects: any[] | undefined = this.preparesStoredDocuments ? [] : undefined;
|
|
2053
2215
|
if (preparedObjects) {
|
|
2054
2216
|
for (const dbDocArg of dbDocsArg) {
|
|
2055
2217
|
preparedObjects.push(this.prepareOrdinaryInsert(await dbDocArg.createSavableObject()));
|
|
@@ -2118,14 +2280,23 @@ export class SmartdataCollection<T> {
|
|
|
2118
2280
|
const first = documentsArg[0];
|
|
2119
2281
|
await this.markUniqueIndexes(first.uniqueIndexes);
|
|
2120
2282
|
await this.createRegularIndexes(first.regularIndexes || []);
|
|
2283
|
+
// A document keyed by its identity is addressed through _id, and the
|
|
2284
|
+
// seeded body must not repeat the immutable primary key: MongoDB derives
|
|
2285
|
+
// it from the filter's equality condition on the insert branch.
|
|
2286
|
+
const documentIdIdentity = this.modelSchema?.identityAsDocumentId === identityFieldArg;
|
|
2121
2287
|
try {
|
|
2122
|
-
const result = await this.mongoDbCollection.bulkWrite(prepared.map((document) =>
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2288
|
+
const result = await this.mongoDbCollection.bulkWrite(prepared.map((document) => {
|
|
2289
|
+
const { _id: storedId, ...body } = document as Record<string, unknown>;
|
|
2290
|
+
return {
|
|
2291
|
+
updateOne: {
|
|
2292
|
+
filter: documentIdIdentity
|
|
2293
|
+
? { _id: storedId as plugins.mongodb.Condition<plugins.mongodb.ObjectId> }
|
|
2294
|
+
: { [identityFieldArg]: document[identityFieldArg] },
|
|
2295
|
+
update: { $setOnInsert: documentIdIdentity ? body : document },
|
|
2296
|
+
upsert: true,
|
|
2297
|
+
},
|
|
2298
|
+
};
|
|
2299
|
+
}), { ordered: false, session: rawSessionArg, timeoutMS: optsArg.timeoutMS });
|
|
2129
2300
|
if (result.upsertedCount + result.matchedCount !== prepared.length) {
|
|
2130
2301
|
throw new SmartdataPersistenceError('unsupported_operation',
|
|
2131
2302
|
'insertManyIfAbsent requires an acknowledged result for every identity.');
|