@twin.org/entity-storage-connector-cosmosdb 0.9.1 → 0.9.2-next.10
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/es/cosmosDbEntityStorageConnector.js +282 -71
- package/dist/es/cosmosDbEntityStorageConnector.js.map +1 -1
- package/dist/es/models/ICosmosDbEntityStorageConnectorConfig.js.map +1 -1
- package/dist/types/cosmosDbEntityStorageConnector.d.ts +14 -3
- package/dist/types/models/ICosmosDbEntityStorageConnectorConfig.d.ts +4 -0
- package/docs/changelog.md +206 -0
- package/docs/reference/classes/CosmosDbEntityStorageConnector.md +48 -3
- package/docs/reference/interfaces/ICosmosDbEntityStorageConnectorConfig.md +8 -0
- package/locales/en.json +3 -1
- package/package.json +8 -7
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// Copyright 2024 IOTA Stiftung.
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0.
|
|
3
3
|
import { BulkOperationType, CosmosClient, PartitionKeyKind } from "@azure/cosmos";
|
|
4
|
+
import { HealthCategory, HealthStatus } from "@twin.org/api-models";
|
|
4
5
|
import { ContextIdHelper, ContextIdStore } from "@twin.org/context";
|
|
5
|
-
import { BaseError, Coerce, ComponentFactory, GeneralError, Guards,
|
|
6
|
+
import { BaseError, Coerce, ComponentFactory, ConflictError, GeneralError, Guards, Is, Mutex, ObjectHelper, RandomHelper, Validation } from "@twin.org/core";
|
|
6
7
|
import { ComparisonOperator, EntitySchemaFactory, EntitySchemaHelper, LogicalOperator, SortDirection } from "@twin.org/entity";
|
|
7
|
-
import { EntityStorageHelper } from "@twin.org/entity-storage-models";
|
|
8
|
+
import { ConnectionHelper, EntityStorageHelper } from "@twin.org/entity-storage-models";
|
|
8
9
|
/**
|
|
9
10
|
* Class for performing entity storage operations using Cosmos DB.
|
|
10
11
|
*/
|
|
@@ -28,6 +29,16 @@ export class CosmosDbEntityStorageConnector {
|
|
|
28
29
|
* @internal
|
|
29
30
|
*/
|
|
30
31
|
static _PARTITION_KEY_VALUE = "root";
|
|
32
|
+
/**
|
|
33
|
+
* Batch chunk size for bulk write operations.
|
|
34
|
+
* @internal
|
|
35
|
+
*/
|
|
36
|
+
static _BATCH_CHUNK_SIZE = 1000;
|
|
37
|
+
/**
|
|
38
|
+
* Number of bulk operation chunks to dispatch concurrently in setBatch.
|
|
39
|
+
* @internal
|
|
40
|
+
*/
|
|
41
|
+
static _WRITE_CONCURRENCY = 10;
|
|
31
42
|
/**
|
|
32
43
|
* The name for the schema.
|
|
33
44
|
* @internal
|
|
@@ -48,21 +59,26 @@ export class CosmosDbEntityStorageConnector {
|
|
|
48
59
|
* @internal
|
|
49
60
|
*/
|
|
50
61
|
_primaryKey;
|
|
62
|
+
/**
|
|
63
|
+
* The name of the version property, if any.
|
|
64
|
+
* @internal
|
|
65
|
+
*/
|
|
66
|
+
_versionKey;
|
|
51
67
|
/**
|
|
52
68
|
* The configuration for the connector.
|
|
53
69
|
* @internal
|
|
54
70
|
*/
|
|
55
71
|
_config;
|
|
56
72
|
/**
|
|
57
|
-
*
|
|
73
|
+
* Milliseconds to wait for optimistic-lock mutexes before throwing.
|
|
58
74
|
* @internal
|
|
59
75
|
*/
|
|
60
|
-
|
|
76
|
+
_mutexTimeoutMs;
|
|
61
77
|
/**
|
|
62
|
-
*
|
|
78
|
+
* Unique identifier for this connector instance, used to track references in SharedStore.
|
|
63
79
|
* @internal
|
|
64
80
|
*/
|
|
65
|
-
|
|
81
|
+
_instanceId;
|
|
66
82
|
/**
|
|
67
83
|
* Create a new instance of CosmosDbEntityStorageConnector.
|
|
68
84
|
* @param options The options for the connector.
|
|
@@ -79,17 +95,18 @@ export class CosmosDbEntityStorageConnector {
|
|
|
79
95
|
this._entitySchemaName = options.entitySchema;
|
|
80
96
|
this._partitionContextIds = options.partitionContextIds;
|
|
81
97
|
this._primaryKey = EntitySchemaHelper.getPrimaryKey(this._entitySchema);
|
|
98
|
+
this._versionKey = EntitySchemaHelper.findVersionProperty(this._entitySchema);
|
|
82
99
|
this._config = options.config;
|
|
83
|
-
this.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
100
|
+
this._mutexTimeoutMs = Coerce.integer(options.config.mutexTimeoutMs);
|
|
101
|
+
this._instanceId = RandomHelper.generateUuidV7("compact");
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The component needs to be stopped when the node is closed.
|
|
105
|
+
* @param nodeLoggingComponentType The node logging component type.
|
|
106
|
+
* @returns Nothing.
|
|
107
|
+
*/
|
|
108
|
+
async stop(nodeLoggingComponentType) {
|
|
109
|
+
await ConnectionHelper.closeClient("cosmosDbClients", this.createClientId(), this._instanceId, this._mutexTimeoutMs, async (client) => client.dispose());
|
|
93
110
|
}
|
|
94
111
|
/**
|
|
95
112
|
* Initialize the Cosmos DB environment.
|
|
@@ -98,6 +115,7 @@ export class CosmosDbEntityStorageConnector {
|
|
|
98
115
|
*/
|
|
99
116
|
async bootstrap(nodeLoggingComponentType) {
|
|
100
117
|
const nodeLogging = ComponentFactory.getIfExists(nodeLoggingComponentType);
|
|
118
|
+
const client = await this.getClient();
|
|
101
119
|
// Create the database if it does not exist
|
|
102
120
|
try {
|
|
103
121
|
const databaseExists = await this.databaseExists();
|
|
@@ -122,7 +140,7 @@ export class CosmosDbEntityStorageConnector {
|
|
|
122
140
|
databaseId: this._config.databaseId
|
|
123
141
|
}
|
|
124
142
|
});
|
|
125
|
-
await
|
|
143
|
+
await client.databases.create({
|
|
126
144
|
id: this._config.databaseId
|
|
127
145
|
});
|
|
128
146
|
await this.waitForDatabaseExists();
|
|
@@ -165,12 +183,13 @@ export class CosmosDbEntityStorageConnector {
|
|
|
165
183
|
containerId: this._config.containerId
|
|
166
184
|
}
|
|
167
185
|
});
|
|
168
|
-
await
|
|
186
|
+
await client.database(this._config.databaseId).containers.create({
|
|
169
187
|
id: this._config.containerId,
|
|
170
188
|
partitionKey: {
|
|
171
189
|
kind: PartitionKeyKind.Hash,
|
|
172
190
|
paths: [`/${CosmosDbEntityStorageConnector._PARTITION_KEY}`]
|
|
173
|
-
}
|
|
191
|
+
},
|
|
192
|
+
indexingPolicy: this.buildIndexingPolicy()
|
|
174
193
|
}, { offerThroughput: this._config.offerThroughput });
|
|
175
194
|
await this.waitForContainerExists();
|
|
176
195
|
}
|
|
@@ -203,13 +222,12 @@ export class CosmosDbEntityStorageConnector {
|
|
|
203
222
|
*/
|
|
204
223
|
async health() {
|
|
205
224
|
try {
|
|
206
|
-
await this.
|
|
207
|
-
|
|
208
|
-
.container(this._config.containerId)
|
|
209
|
-
.read();
|
|
225
|
+
const container = await this.getContainer();
|
|
226
|
+
await container.read();
|
|
210
227
|
return [
|
|
211
228
|
{
|
|
212
229
|
source: CosmosDbEntityStorageConnector.CLASS_NAME,
|
|
230
|
+
category: HealthCategory.Connectivity,
|
|
213
231
|
status: HealthStatus.Ok,
|
|
214
232
|
description: "healthDescription",
|
|
215
233
|
data: { databaseId: this._config.databaseId, containerId: this._config.containerId }
|
|
@@ -220,6 +238,7 @@ export class CosmosDbEntityStorageConnector {
|
|
|
220
238
|
return [
|
|
221
239
|
{
|
|
222
240
|
source: CosmosDbEntityStorageConnector.CLASS_NAME,
|
|
241
|
+
category: HealthCategory.Connectivity,
|
|
223
242
|
status: HealthStatus.Error,
|
|
224
243
|
description: "healthDescription",
|
|
225
244
|
message: "connectionFailed",
|
|
@@ -248,11 +267,15 @@ export class CosmosDbEntityStorageConnector {
|
|
|
248
267
|
const contextIds = await ContextIdStore.getContextIds();
|
|
249
268
|
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
250
269
|
try {
|
|
270
|
+
const container = await this.getContainer();
|
|
251
271
|
// No secondary index or conditions
|
|
252
272
|
if (Is.empty(secondaryIndex) && !Is.arrayValue(conditions)) {
|
|
253
|
-
const { resource: item } = await
|
|
273
|
+
const { resource: item } = await container
|
|
254
274
|
.item(id, partitionKey ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE)
|
|
255
275
|
.read();
|
|
276
|
+
if (Is.empty(item)) {
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
256
279
|
return this.itemToEntity(item);
|
|
257
280
|
}
|
|
258
281
|
const conditionValues = [];
|
|
@@ -286,7 +309,7 @@ export class CosmosDbEntityStorageConnector {
|
|
|
286
309
|
query: `SELECT * FROM c WHERE ${whereQuery.join(" AND ")}`,
|
|
287
310
|
parameters: conditionValues
|
|
288
311
|
};
|
|
289
|
-
const { resources: items } = await
|
|
312
|
+
const { resources: items } = await container.items.query(query).fetchAll();
|
|
290
313
|
if (items.length === 1) {
|
|
291
314
|
return this.itemToEntity(items[0]);
|
|
292
315
|
}
|
|
@@ -314,25 +337,66 @@ export class CosmosDbEntityStorageConnector {
|
|
|
314
337
|
EntityStorageHelper.validateConditions(this._entitySchema, conditions);
|
|
315
338
|
const contextIds = await ContextIdStore.getContextIds();
|
|
316
339
|
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
340
|
+
const submittedVersion = Is.stringValue(this._versionKey)
|
|
341
|
+
? ObjectHelper.propertyGet(entity, this._versionKey)
|
|
342
|
+
: undefined;
|
|
343
|
+
const hasVersionCheck = !Is.empty(this._versionKey) && !Is.empty(submittedVersion) && submittedVersion > 0;
|
|
317
344
|
const prepared = EntityStorageHelper.prepareEntity(entity, this._entitySchema, undefined, {
|
|
318
345
|
nullBehavior: "omit"
|
|
319
346
|
});
|
|
320
347
|
const id = prepared[this._primaryKey.property];
|
|
348
|
+
const optimisticMutexKey = Is.stringValue(this._versionKey)
|
|
349
|
+
? this.buildOptimisticMutexKey(partitionKey, id)
|
|
350
|
+
: undefined;
|
|
351
|
+
if (Is.stringValue(optimisticMutexKey)) {
|
|
352
|
+
await Mutex.lock(optimisticMutexKey, {
|
|
353
|
+
throwOnTimeout: true,
|
|
354
|
+
timeoutMs: this._mutexTimeoutMs
|
|
355
|
+
});
|
|
356
|
+
}
|
|
321
357
|
try {
|
|
322
|
-
|
|
323
|
-
|
|
358
|
+
const container = await this.getContainer();
|
|
359
|
+
let itemEtag;
|
|
360
|
+
if (Is.stringValue(this._versionKey) || Is.arrayValue(conditions)) {
|
|
361
|
+
const item = container.item(id, partitionKey ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE);
|
|
324
362
|
const { resource: itemData } = await item.read();
|
|
325
|
-
if (Is.
|
|
326
|
-
|
|
363
|
+
if (!Is.empty(itemData)) {
|
|
364
|
+
if (hasVersionCheck) {
|
|
365
|
+
const storedVersion = ObjectHelper.propertyGet(itemData, this._versionKey) ?? 0;
|
|
366
|
+
if (storedVersion !== submittedVersion) {
|
|
367
|
+
throw new ConflictError(CosmosDbEntityStorageConnector.CLASS_NAME, "optimisticLockFailed", id);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (Is.arrayValue(conditions) && !this.verifyConditions(conditions, itemData)) {
|
|
371
|
+
if (Is.stringValue(this._versionKey)) {
|
|
372
|
+
throw new ConflictError(CosmosDbEntityStorageConnector.CLASS_NAME, "conditionFailed", id);
|
|
373
|
+
}
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
itemEtag = ObjectHelper.propertyGet(itemData, "_etag");
|
|
377
|
+
}
|
|
378
|
+
if (Is.stringValue(this._versionKey)) {
|
|
379
|
+
const storedVersion = !Is.empty(itemData)
|
|
380
|
+
? (ObjectHelper.propertyGet(itemData, this._versionKey) ?? 0)
|
|
381
|
+
: 0;
|
|
382
|
+
ObjectHelper.propertySet(prepared, this._versionKey, storedVersion + 1);
|
|
327
383
|
}
|
|
328
384
|
}
|
|
329
|
-
await
|
|
385
|
+
await container.items.upsert({
|
|
330
386
|
id,
|
|
331
387
|
[CosmosDbEntityStorageConnector._PARTITION_KEY]: partitionKey ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE,
|
|
332
388
|
...prepared
|
|
333
|
-
})
|
|
389
|
+
}, Is.stringValue(itemEtag)
|
|
390
|
+
? { accessCondition: { type: "IfMatch", condition: itemEtag } }
|
|
391
|
+
: undefined);
|
|
334
392
|
}
|
|
335
393
|
catch (err) {
|
|
394
|
+
if (BaseError.isErrorName(err, ConflictError.CLASS_NAME)) {
|
|
395
|
+
throw err;
|
|
396
|
+
}
|
|
397
|
+
if (Is.object(err) && err.code === 412) {
|
|
398
|
+
throw new ConflictError(CosmosDbEntityStorageConnector.CLASS_NAME, "optimisticLockFailed", id);
|
|
399
|
+
}
|
|
336
400
|
if (BaseError.isAggregateError(err)) {
|
|
337
401
|
const errors = BaseError.fromAggregate(err);
|
|
338
402
|
if (BaseError.someErrorCode(errors, "ResourceNotFoundException")) {
|
|
@@ -345,6 +409,11 @@ export class CosmosDbEntityStorageConnector {
|
|
|
345
409
|
id
|
|
346
410
|
}, err);
|
|
347
411
|
}
|
|
412
|
+
finally {
|
|
413
|
+
if (Is.stringValue(optimisticMutexKey)) {
|
|
414
|
+
Mutex.unlock(optimisticMutexKey);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
348
417
|
}
|
|
349
418
|
/**
|
|
350
419
|
* Set multiple entities in a batch.
|
|
@@ -359,15 +428,30 @@ export class CosmosDbEntityStorageConnector {
|
|
|
359
428
|
nullBehavior: "omit"
|
|
360
429
|
}));
|
|
361
430
|
try {
|
|
362
|
-
await this.
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
431
|
+
const container = await this.getContainer();
|
|
432
|
+
const pk = partitionKey ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE;
|
|
433
|
+
const chunkSize = CosmosDbEntityStorageConnector._BATCH_CHUNK_SIZE;
|
|
434
|
+
const concurrency = CosmosDbEntityStorageConnector._WRITE_CONCURRENCY;
|
|
435
|
+
const windowSize = chunkSize * concurrency;
|
|
436
|
+
for (let offset = 0; offset < preparedEntities.length; offset += windowSize) {
|
|
437
|
+
const window = preparedEntities.slice(offset, offset + windowSize);
|
|
438
|
+
const sends = [];
|
|
439
|
+
for (let j = 0; j < window.length; j += chunkSize) {
|
|
440
|
+
const chunk = window.slice(j, j + chunkSize);
|
|
441
|
+
sends.push((async () => {
|
|
442
|
+
await container.items.executeBulkOperations(chunk.map(prepared => ({
|
|
443
|
+
operationType: BulkOperationType.Upsert,
|
|
444
|
+
partitionKey: pk,
|
|
445
|
+
resourceBody: {
|
|
446
|
+
id: prepared[this._primaryKey.property],
|
|
447
|
+
[CosmosDbEntityStorageConnector._PARTITION_KEY]: pk,
|
|
448
|
+
...prepared
|
|
449
|
+
}
|
|
450
|
+
})));
|
|
451
|
+
})());
|
|
369
452
|
}
|
|
370
|
-
|
|
453
|
+
await Promise.all(sends);
|
|
454
|
+
}
|
|
371
455
|
}
|
|
372
456
|
catch (err) {
|
|
373
457
|
throw new GeneralError(CosmosDbEntityStorageConnector.CLASS_NAME, "setBatchFailed", undefined, err);
|
|
@@ -382,10 +466,11 @@ export class CosmosDbEntityStorageConnector {
|
|
|
382
466
|
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
383
467
|
const pk = partitionKey ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE;
|
|
384
468
|
try {
|
|
469
|
+
const container = await this.getContainer();
|
|
385
470
|
let continuationToken;
|
|
386
471
|
do {
|
|
387
472
|
const feedOptions = { maxItemCount: 100, continuationToken };
|
|
388
|
-
const { resources, continuationToken: nextToken } = await
|
|
473
|
+
const { resources, continuationToken: nextToken } = await container.items
|
|
389
474
|
.query({
|
|
390
475
|
query: `SELECT c.id FROM c WHERE c.${CosmosDbEntityStorageConnector._PARTITION_KEY} = @pk`,
|
|
391
476
|
parameters: [{ name: "@pk", value: pk }]
|
|
@@ -398,7 +483,7 @@ export class CosmosDbEntityStorageConnector {
|
|
|
398
483
|
id: r.id,
|
|
399
484
|
partitionKey: pk
|
|
400
485
|
}));
|
|
401
|
-
await
|
|
486
|
+
await container.items.executeBulkOperations(operations);
|
|
402
487
|
}
|
|
403
488
|
} while (Is.stringValue(continuationToken));
|
|
404
489
|
}
|
|
@@ -417,17 +502,33 @@ export class CosmosDbEntityStorageConnector {
|
|
|
417
502
|
EntityStorageHelper.validateConditions(this._entitySchema, conditions);
|
|
418
503
|
const contextIds = await ContextIdStore.getContextIds();
|
|
419
504
|
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
505
|
+
const optimisticMutexKey = Is.stringValue(this._versionKey)
|
|
506
|
+
? this.buildOptimisticMutexKey(partitionKey, id)
|
|
507
|
+
: undefined;
|
|
508
|
+
if (Is.stringValue(optimisticMutexKey)) {
|
|
509
|
+
await Mutex.lock(optimisticMutexKey, {
|
|
510
|
+
throwOnTimeout: true,
|
|
511
|
+
timeoutMs: this._mutexTimeoutMs
|
|
512
|
+
});
|
|
513
|
+
}
|
|
420
514
|
try {
|
|
421
|
-
const
|
|
515
|
+
const container = await this.getContainer();
|
|
516
|
+
const item = container.item(id, partitionKey ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE);
|
|
422
517
|
const { resource: itemData } = await item.read();
|
|
423
518
|
if (Is.notEmpty(itemData)) {
|
|
424
519
|
if (Is.arrayValue(conditions) && !this.verifyConditions(conditions, itemData)) {
|
|
520
|
+
if (Is.stringValue(this._versionKey)) {
|
|
521
|
+
throw new ConflictError(CosmosDbEntityStorageConnector.CLASS_NAME, "conditionFailed", id);
|
|
522
|
+
}
|
|
425
523
|
return;
|
|
426
524
|
}
|
|
427
525
|
await item.delete();
|
|
428
526
|
}
|
|
429
527
|
}
|
|
430
528
|
catch (err) {
|
|
529
|
+
if (BaseError.isErrorName(err, ConflictError.CLASS_NAME)) {
|
|
530
|
+
throw err;
|
|
531
|
+
}
|
|
431
532
|
if (BaseError.fromError(err) &&
|
|
432
533
|
Is.object(err) &&
|
|
433
534
|
err.body?.code === "NotFound") {
|
|
@@ -437,6 +538,11 @@ export class CosmosDbEntityStorageConnector {
|
|
|
437
538
|
id
|
|
438
539
|
}, err);
|
|
439
540
|
}
|
|
541
|
+
finally {
|
|
542
|
+
if (Is.stringValue(optimisticMutexKey)) {
|
|
543
|
+
Mutex.unlock(optimisticMutexKey);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
440
546
|
}
|
|
441
547
|
/**
|
|
442
548
|
* Remove multiple entities by id.
|
|
@@ -448,12 +554,13 @@ export class CosmosDbEntityStorageConnector {
|
|
|
448
554
|
const contextIds = await ContextIdStore.getContextIds();
|
|
449
555
|
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
450
556
|
try {
|
|
557
|
+
const container = await this.getContainer();
|
|
451
558
|
const operations = ids.map(id => ({
|
|
452
559
|
operationType: BulkOperationType.Delete,
|
|
453
560
|
id,
|
|
454
561
|
partitionKey: partitionKey ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE
|
|
455
562
|
}));
|
|
456
|
-
await
|
|
563
|
+
await container.items.executeBulkOperations(operations);
|
|
457
564
|
}
|
|
458
565
|
catch (err) {
|
|
459
566
|
throw new GeneralError(CosmosDbEntityStorageConnector.CLASS_NAME, "removeBatchFailed", undefined, err);
|
|
@@ -474,8 +581,9 @@ export class CosmosDbEntityStorageConnector {
|
|
|
474
581
|
data: { containerId: this._config.containerId }
|
|
475
582
|
});
|
|
476
583
|
try {
|
|
584
|
+
const container = await this.getContainer();
|
|
477
585
|
if (await this.containerExists()) {
|
|
478
|
-
await
|
|
586
|
+
await container.delete();
|
|
479
587
|
await this.waitForContainerNotExists();
|
|
480
588
|
}
|
|
481
589
|
await nodeLogging?.log({
|
|
@@ -515,22 +623,35 @@ export class CosmosDbEntityStorageConnector {
|
|
|
515
623
|
EntityStorageHelper.validateSortProperties(this._entitySchema, sortProperties);
|
|
516
624
|
EntityStorageHelper.validateProperties(this._entitySchema, properties);
|
|
517
625
|
EntityStorageHelper.validateConditionProperties(this._entitySchema, conditions);
|
|
626
|
+
// Only the sort shapes covered by the composite indexes from buildIndexingPolicy are accepted.
|
|
627
|
+
if (Is.arrayValue(sortProperties)) {
|
|
628
|
+
const nonPrimarySorts = sortProperties.filter(sortProperty => sortProperty.property !== this._primaryKey.property);
|
|
629
|
+
if (nonPrimarySorts.length > 1) {
|
|
630
|
+
throw new GeneralError(CosmosDbEntityStorageConnector.CLASS_NAME, "sortUnsupported", {
|
|
631
|
+
properties: sortProperties.map(sortProperty => sortProperty.property),
|
|
632
|
+
primaryKey: this._primaryKey.property
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
}
|
|
518
636
|
if (!Is.empty(limit)) {
|
|
519
637
|
const validationFailures = [];
|
|
520
638
|
Validation.integer("limit", limit, validationFailures, undefined, { minValue: 1 });
|
|
521
639
|
Validation.asValidationError(CosmosDbEntityStorageConnector.CLASS_NAME, "query", validationFailures);
|
|
522
640
|
}
|
|
523
641
|
try {
|
|
642
|
+
const container = await this.getContainer();
|
|
524
643
|
const returnSize = limit ?? CosmosDbEntityStorageConnector._DEFAULT_LIMIT;
|
|
525
644
|
let orderByClause = "";
|
|
526
|
-
if (
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
645
|
+
if (Is.arrayValue(sortProperties)) {
|
|
646
|
+
// The primary key is unique so no property after its first occurrence can affect
|
|
647
|
+
// the order, truncating keeps the ORDER BY within the provisioned composite indexes.
|
|
648
|
+
const primaryKeyIndex = sortProperties.findIndex(sortProperty => sortProperty.property === this._primaryKey.property);
|
|
649
|
+
const effectiveSorts = primaryKeyIndex === -1 ? sortProperties : sortProperties.slice(0, primaryKeyIndex + 1);
|
|
650
|
+
const orderClauses = effectiveSorts.map(sortProperty => {
|
|
531
651
|
const direction = sortProperty.sortDirection === SortDirection.Ascending ? "asc" : "desc";
|
|
532
|
-
|
|
533
|
-
}
|
|
652
|
+
return `c.${String(sortProperty.property)} ${direction}`;
|
|
653
|
+
});
|
|
654
|
+
orderByClause = `ORDER BY ${orderClauses.join(", ")}`;
|
|
534
655
|
}
|
|
535
656
|
const attributeNames = {};
|
|
536
657
|
const attributeValues = {};
|
|
@@ -548,13 +669,13 @@ export class CosmosDbEntityStorageConnector {
|
|
|
548
669
|
...Object.keys(attributeValues).map(key => ({ name: `@${key}`, value: attributeValues[key] }))
|
|
549
670
|
];
|
|
550
671
|
const queryPartitionKey = partitionKey ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE;
|
|
551
|
-
// For sorted queries use OFFSET LIMIT
|
|
672
|
+
// For sorted queries use OFFSET LIMIT - CosmosDB continuation tokens are
|
|
552
673
|
// not reliably returned for ORDER BY queries across all service versions.
|
|
553
674
|
// The cursor is a numeric offset encoded as a string (same as SQL connectors).
|
|
554
675
|
const startIndex = Coerce.number(cursor) ?? 0;
|
|
555
676
|
sql = `${baseQuery} ${orderByClause} OFFSET ${startIndex} LIMIT ${returnSize + 1}`;
|
|
556
677
|
const sortedQuerySpecs = { query: sql, parameters: queryParameters };
|
|
557
|
-
const sortedResponse = await
|
|
678
|
+
const sortedResponse = await container.items
|
|
558
679
|
.query(sortedQuerySpecs, {
|
|
559
680
|
partitionKey: queryPartitionKey,
|
|
560
681
|
maxItemCount: returnSize + 1
|
|
@@ -579,6 +700,7 @@ export class CosmosDbEntityStorageConnector {
|
|
|
579
700
|
async count(conditions) {
|
|
580
701
|
EntityStorageHelper.validateConditionProperties(this._entitySchema, conditions);
|
|
581
702
|
try {
|
|
703
|
+
const container = await this.getContainer();
|
|
582
704
|
const contextIds = await ContextIdStore.getContextIds();
|
|
583
705
|
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
584
706
|
const attributeNames = {};
|
|
@@ -599,7 +721,7 @@ export class CosmosDbEntityStorageConnector {
|
|
|
599
721
|
...Object.keys(attributeValues).map(key => ({ name: `@${key}`, value: attributeValues[key] }))
|
|
600
722
|
]
|
|
601
723
|
};
|
|
602
|
-
const { resources } = await
|
|
724
|
+
const { resources } = await container.items.query(querySpec).fetchAll();
|
|
603
725
|
return resources[0] ?? 0;
|
|
604
726
|
}
|
|
605
727
|
catch (err) {
|
|
@@ -613,10 +735,11 @@ export class CosmosDbEntityStorageConnector {
|
|
|
613
735
|
async getPartitionContextIds() {
|
|
614
736
|
const partitionContextIds = this._partitionContextIds;
|
|
615
737
|
if (!Is.arrayValue(partitionContextIds)) {
|
|
616
|
-
return
|
|
738
|
+
return undefined;
|
|
617
739
|
}
|
|
618
740
|
try {
|
|
619
|
-
const
|
|
741
|
+
const container = await this.getContainer();
|
|
742
|
+
const { resources: partitionIds } = await container.items
|
|
620
743
|
.query({
|
|
621
744
|
query: `SELECT DISTINCT VALUE c.${CosmosDbEntityStorageConnector._PARTITION_KEY} FROM c`
|
|
622
745
|
})
|
|
@@ -629,6 +752,13 @@ export class CosmosDbEntityStorageConnector {
|
|
|
629
752
|
throw new GeneralError(CosmosDbEntityStorageConnector.CLASS_NAME, "getPartitionContextIdsFailed", undefined, err);
|
|
630
753
|
}
|
|
631
754
|
}
|
|
755
|
+
/**
|
|
756
|
+
* Get the connector implementation version.
|
|
757
|
+
* @returns The connector implementation version.
|
|
758
|
+
*/
|
|
759
|
+
connectorVersion() {
|
|
760
|
+
return 0;
|
|
761
|
+
}
|
|
632
762
|
/**
|
|
633
763
|
* Create the target connector for performing the migration using a temporary container.
|
|
634
764
|
* @param newEntitySchema The name of the new entity schema to create the connector for.
|
|
@@ -689,22 +819,24 @@ export class CosmosDbEntityStorageConnector {
|
|
|
689
819
|
* @internal
|
|
690
820
|
*/
|
|
691
821
|
async bulkCopy(sourceConnector, destConnector, partitions, batchSize) {
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
partitionList = [];
|
|
698
|
-
}
|
|
699
|
-
else {
|
|
700
|
-
partitionList = [{}];
|
|
822
|
+
// undefined → not partitioned: one pass with no partition key.
|
|
823
|
+
// [] → partitioned but empty: nothing to copy, return early.
|
|
824
|
+
// [{…}, …] → partitioned with data: iterate over each partition.
|
|
825
|
+
if (partitions?.length === 0) {
|
|
826
|
+
return;
|
|
701
827
|
}
|
|
828
|
+
const partitionList = partitions ?? [{}];
|
|
702
829
|
for (let i = 0; i < partitionList.length; i++) {
|
|
703
|
-
|
|
830
|
+
// Values from getPartitionContextIds are already short-form, so we join them
|
|
831
|
+
// directly rather than using combinedContextKey, which expects long-form input
|
|
832
|
+
// and calls guardAll (throwing if a registered handler rejects short-form values).
|
|
833
|
+
const partitionKey = Is.arrayValue(sourceConnector._partitionContextIds)
|
|
834
|
+
? sourceConnector._partitionContextIds.map(k => partitionList[i][k]).join("/")
|
|
835
|
+
: CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE;
|
|
704
836
|
let continuationToken;
|
|
705
837
|
do {
|
|
706
838
|
const feedOptions = { maxItemCount: batchSize, continuationToken };
|
|
707
|
-
const { resources, continuationToken: nextToken } = await sourceConnector.
|
|
839
|
+
const { resources, continuationToken: nextToken } = await (await sourceConnector.getContainer()).items
|
|
708
840
|
.query({
|
|
709
841
|
query: `SELECT * FROM c WHERE c.${CosmosDbEntityStorageConnector._PARTITION_KEY} = @partitionId`,
|
|
710
842
|
parameters: [{ name: "@partitionId", value: partitionKey }]
|
|
@@ -717,7 +849,7 @@ export class CosmosDbEntityStorageConnector {
|
|
|
717
849
|
partitionKey,
|
|
718
850
|
resourceBody: rest
|
|
719
851
|
}));
|
|
720
|
-
await destConnector.
|
|
852
|
+
await (await destConnector.getContainer()).items.executeBulkOperations(operations);
|
|
721
853
|
}
|
|
722
854
|
} while (Is.stringValue(continuationToken));
|
|
723
855
|
}
|
|
@@ -786,7 +918,7 @@ export class CosmosDbEntityStorageConnector {
|
|
|
786
918
|
else if (Is.array(comparator.value)) {
|
|
787
919
|
const dbValues = comparator.value.map(v => this.propertyToDbValue(v, type));
|
|
788
920
|
if (dbValues.length === 0 && comparator.comparison === ComparisonOperator.In) {
|
|
789
|
-
// CosmosDB rejects `IN ()`
|
|
921
|
+
// CosmosDB rejects `IN ()` - return always-false sentinel (#141).
|
|
790
922
|
return "1=0";
|
|
791
923
|
}
|
|
792
924
|
const arrAttributeNames = [];
|
|
@@ -945,6 +1077,16 @@ export class CosmosDbEntityStorageConnector {
|
|
|
945
1077
|
verifyConditions(conditions, obj) {
|
|
946
1078
|
return conditions.every(condition => ObjectHelper.propertyGet(obj, condition.property) === condition.value);
|
|
947
1079
|
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Build a mutex key for optimistic-locking critical sections.
|
|
1082
|
+
* @param partitionKey The resolved partition key.
|
|
1083
|
+
* @param id The entity id.
|
|
1084
|
+
* @returns The mutex key.
|
|
1085
|
+
* @internal
|
|
1086
|
+
*/
|
|
1087
|
+
buildOptimisticMutexKey(partitionKey, id) {
|
|
1088
|
+
return `${CosmosDbEntityStorageConnector.CLASS_NAME}:optimistic:${this._config.databaseId}:${this._config.containerId}:${partitionKey ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE}:${id}`;
|
|
1089
|
+
}
|
|
948
1090
|
/**
|
|
949
1091
|
* Convert an entity to an item.
|
|
950
1092
|
* @param item The item to convert.
|
|
@@ -961,6 +1103,38 @@ export class CosmosDbEntityStorageConnector {
|
|
|
961
1103
|
"_ts"
|
|
962
1104
|
]);
|
|
963
1105
|
}
|
|
1106
|
+
/**
|
|
1107
|
+
* Retrieve (or lazily create) the shared Cosmos DB client for this endpoint.
|
|
1108
|
+
* @returns The shared client.
|
|
1109
|
+
* @internal
|
|
1110
|
+
*/
|
|
1111
|
+
async getClient() {
|
|
1112
|
+
return ConnectionHelper.openClient("cosmosDbClients", this.createClientId(), this._instanceId, this._mutexTimeoutMs, async () => new CosmosClient({
|
|
1113
|
+
endpoint: this._config.endpoint,
|
|
1114
|
+
key: this._config.key,
|
|
1115
|
+
connectionPolicy: {
|
|
1116
|
+
enableEndpointDiscovery: !this._config.disableEndpointDiscovery
|
|
1117
|
+
}
|
|
1118
|
+
}));
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* Get the container for this connector's configured database and container.
|
|
1122
|
+
* @returns The container reference.
|
|
1123
|
+
* @internal
|
|
1124
|
+
*/
|
|
1125
|
+
async getContainer() {
|
|
1126
|
+
return (await this.getClient())
|
|
1127
|
+
.database(this._config.databaseId)
|
|
1128
|
+
.container(this._config.containerId);
|
|
1129
|
+
}
|
|
1130
|
+
/**
|
|
1131
|
+
* Build a stable cache key for the shared client based on connection parameters.
|
|
1132
|
+
* @returns The client cache key.
|
|
1133
|
+
* @internal
|
|
1134
|
+
*/
|
|
1135
|
+
createClientId() {
|
|
1136
|
+
return `${this._config.endpoint}|${this._config.databaseId}|${this._config.containerId}`;
|
|
1137
|
+
}
|
|
964
1138
|
/**
|
|
965
1139
|
* Check if the database exists.
|
|
966
1140
|
* @returns True if the database exists, false otherwise.
|
|
@@ -968,7 +1142,8 @@ export class CosmosDbEntityStorageConnector {
|
|
|
968
1142
|
*/
|
|
969
1143
|
async databaseExists() {
|
|
970
1144
|
try {
|
|
971
|
-
const
|
|
1145
|
+
const client = await this.getClient();
|
|
1146
|
+
const { resources: databaseList } = await client.databases.readAll().fetchAll();
|
|
972
1147
|
return databaseList.some((db) => db.id === this._config.databaseId);
|
|
973
1148
|
}
|
|
974
1149
|
catch {
|
|
@@ -989,6 +1164,41 @@ export class CosmosDbEntityStorageConnector {
|
|
|
989
1164
|
await new Promise(resolve => setTimeout(resolve, 250));
|
|
990
1165
|
}
|
|
991
1166
|
}
|
|
1167
|
+
/**
|
|
1168
|
+
* Build the composite indexes needed to serve multi-property ORDER BY queries.
|
|
1169
|
+
* Pairing each sortable property with the primary key in both directions covers all
|
|
1170
|
+
* four direction combinations, as Cosmos DB also serves each index reversed.
|
|
1171
|
+
* @returns The indexing policy for the container, or undefined if the schema has no
|
|
1172
|
+
* sortable properties.
|
|
1173
|
+
* @internal
|
|
1174
|
+
*/
|
|
1175
|
+
buildIndexingPolicy() {
|
|
1176
|
+
const primaryKeyPath = `/${this._primaryKey.property}`;
|
|
1177
|
+
const compositeIndexes = [];
|
|
1178
|
+
if (Is.arrayValue(this._entitySchema.properties)) {
|
|
1179
|
+
for (const prop of this._entitySchema.properties) {
|
|
1180
|
+
if (!prop.isPrimary && (Is.stringValue(prop.sortDirection) || prop.isSecondary)) {
|
|
1181
|
+
const propertyPath = `/${prop.property}`;
|
|
1182
|
+
compositeIndexes.push([
|
|
1183
|
+
{ path: propertyPath, order: "ascending" },
|
|
1184
|
+
{ path: primaryKeyPath, order: "ascending" }
|
|
1185
|
+
]);
|
|
1186
|
+
compositeIndexes.push([
|
|
1187
|
+
{ path: propertyPath, order: "ascending" },
|
|
1188
|
+
{ path: primaryKeyPath, order: "descending" }
|
|
1189
|
+
]);
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
return compositeIndexes.length > 0
|
|
1194
|
+
? {
|
|
1195
|
+
indexingMode: "consistent",
|
|
1196
|
+
automatic: true,
|
|
1197
|
+
includedPaths: [{ path: "/*" }],
|
|
1198
|
+
compositeIndexes
|
|
1199
|
+
}
|
|
1200
|
+
: undefined;
|
|
1201
|
+
}
|
|
992
1202
|
/**
|
|
993
1203
|
* Check if the container exists.
|
|
994
1204
|
* @returns True if the container exists, false otherwise.
|
|
@@ -996,7 +1206,8 @@ export class CosmosDbEntityStorageConnector {
|
|
|
996
1206
|
*/
|
|
997
1207
|
async containerExists() {
|
|
998
1208
|
try {
|
|
999
|
-
const
|
|
1209
|
+
const client = await this.getClient();
|
|
1210
|
+
const { resources: containers } = await client
|
|
1000
1211
|
.database(this._config.databaseId)
|
|
1001
1212
|
.containers.readAll()
|
|
1002
1213
|
.fetchAll();
|