@twin.org/entity-storage-connector-postgresql 0.9.2-next.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/models/IPostgreSqlEntityStorageConnectorConfig.js.map +1 -1
- package/dist/es/postgreSqlEntityStorageConnector.js +262 -72
- package/dist/es/postgreSqlEntityStorageConnector.js.map +1 -1
- package/dist/types/models/IPostgreSqlEntityStorageConnectorConfig.d.ts +27 -0
- package/dist/types/postgreSqlEntityStorageConnector.d.ts +10 -4
- package/docs/changelog.md +145 -0
- package/docs/reference/classes/PostgreSqlEntityStorageConnector.md +26 -3
- package/docs/reference/interfaces/IPostgreSqlEntityStorageConnectorConfig.md +52 -0
- package/locales/en.json +2 -0
- package/package.json +3 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"IPostgreSqlEntityStorageConnectorConfig.js","sourceRoot":"","sources":["../../../src/models/IPostgreSqlEntityStorageConnectorConfig.ts"],"names":[],"mappings":"AAAA,gCAAgC;AAChC,uCAAuC","sourcesContent":["// Copyright 2024 IOTA Stiftung.\n// SPDX-License-Identifier: Apache-2.0.\n\n/**\n * Configuration for the PostgreSql Entity Storage Connector.\n */\nexport interface IPostgreSqlEntityStorageConnectorConfig {\n\t/**\n\t * The host for the PostgreSql instance.\n\t */\n\thost: string;\n\n\t/**\n\t * The port for the PostgreSql instance.\n\t */\n\tport?: number;\n\n\t/**\n\t * The user for the PostgreSql instance.\n\t */\n\tuser: string;\n\n\t/**\n\t * The password for the PostgreSql instance.\n\t */\n\tpassword: string;\n\n\t/**\n\t * The name of the database to be used.\n\t */\n\tdatabase: string;\n\n\t/**\n\t * The name of the table to be used.\n\t */\n\ttableName: string;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"IPostgreSqlEntityStorageConnectorConfig.js","sourceRoot":"","sources":["../../../src/models/IPostgreSqlEntityStorageConnectorConfig.ts"],"names":[],"mappings":"AAAA,gCAAgC;AAChC,uCAAuC","sourcesContent":["// Copyright 2024 IOTA Stiftung.\n// SPDX-License-Identifier: Apache-2.0.\n\n/**\n * Configuration for the PostgreSql Entity Storage Connector.\n */\nexport interface IPostgreSqlEntityStorageConnectorConfig {\n\t/**\n\t * The host for the PostgreSql instance.\n\t */\n\thost: string;\n\n\t/**\n\t * The port for the PostgreSql instance.\n\t */\n\tport?: number;\n\n\t/**\n\t * The user for the PostgreSql instance.\n\t */\n\tuser: string;\n\n\t/**\n\t * The password for the PostgreSql instance.\n\t */\n\tpassword: string;\n\n\t/**\n\t * The name of the database to be used.\n\t */\n\tdatabase: string;\n\n\t/**\n\t * The name of the table to be used.\n\t */\n\ttableName: string;\n\n\t/**\n\t * Optional connection pool configuration.\n\t */\n\tpool?: {\n\t\t/**\n\t\t * Maximum number of connections in the pool.\n\t\t * @default 10\n\t\t */\n\t\tmax?: number;\n\n\t\t/**\n\t\t * Seconds a connection can remain idle before being closed.\n\t\t */\n\t\tidleTimeout?: number;\n\n\t\t/**\n\t\t * Seconds to wait when establishing a connection.\n\t\t * @default 30\n\t\t */\n\t\tconnectTimeout?: number;\n\n\t\t/**\n\t\t * Maximum seconds a connection can remain open.\n\t\t */\n\t\tmaxLifetime?: number;\n\t};\n\n\t/**\n\t * Milliseconds to wait for connector mutex locks before throwing.\n\t */\n\tmutexTimeoutMs?: number;\n}\n"]}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// Copyright 2024 IOTA Stiftung.
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0.
|
|
3
|
+
import { HealthCategory, HealthStatus } from "@twin.org/api-models";
|
|
3
4
|
import { ContextIdHelper, ContextIdStore } from "@twin.org/context";
|
|
4
|
-
import { BaseError, Coerce, ComponentFactory, GeneralError, Guards,
|
|
5
|
+
import { BaseError, Coerce, ComponentFactory, ConflictError, Converter, GeneralError, Guards, Is, Mutex, ObjectHelper, RandomHelper, Validation } from "@twin.org/core";
|
|
5
6
|
import { ComparisonOperator, EntitySchemaFactory, EntitySchemaHelper, EntitySchemaPropertyType, LogicalOperator, SortDirection } from "@twin.org/entity";
|
|
6
|
-
import { EntityStorageHelper } from "@twin.org/entity-storage-models";
|
|
7
|
+
import { ConnectionHelper, EntityStorageHelper, IndexHelper } from "@twin.org/entity-storage-models";
|
|
7
8
|
import postgres from "postgres";
|
|
8
9
|
/**
|
|
9
10
|
* Class for performing entity storage operations using ql.
|
|
@@ -28,6 +29,11 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
28
29
|
* @internal
|
|
29
30
|
*/
|
|
30
31
|
static _PARTITION_KEY_VALUE = "root";
|
|
32
|
+
/**
|
|
33
|
+
* Maximum number of rows per INSERT statement in setBatch.
|
|
34
|
+
* @internal
|
|
35
|
+
*/
|
|
36
|
+
static _BATCH_CHUNK_SIZE = 1000;
|
|
31
37
|
/**
|
|
32
38
|
* The name for the schema.
|
|
33
39
|
* @internal
|
|
@@ -48,16 +54,26 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
48
54
|
* @internal
|
|
49
55
|
*/
|
|
50
56
|
_primaryKeyProperty;
|
|
57
|
+
/**
|
|
58
|
+
* The name of the version property, if any.
|
|
59
|
+
* @internal
|
|
60
|
+
*/
|
|
61
|
+
_versionKey;
|
|
51
62
|
/**
|
|
52
63
|
* The configuration for the connector.
|
|
53
64
|
* @internal
|
|
54
65
|
*/
|
|
55
66
|
_config;
|
|
56
67
|
/**
|
|
57
|
-
*
|
|
68
|
+
* Milliseconds to wait for optimistic-lock mutexes before throwing.
|
|
58
69
|
* @internal
|
|
59
70
|
*/
|
|
60
|
-
|
|
71
|
+
_mutexTimeoutMs;
|
|
72
|
+
/**
|
|
73
|
+
* Unique identifier for this connector instance, used to track references in SharedStore.
|
|
74
|
+
* @internal
|
|
75
|
+
*/
|
|
76
|
+
_instanceId;
|
|
61
77
|
/**
|
|
62
78
|
* Create a new instance of PostgreSqlEntityStorageConnector.
|
|
63
79
|
* @param options The options for the connector.
|
|
@@ -71,11 +87,26 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
71
87
|
Guards.stringValue(PostgreSqlEntityStorageConnector.CLASS_NAME, "options.config.password", options.config.password);
|
|
72
88
|
Guards.stringValue(PostgreSqlEntityStorageConnector.CLASS_NAME, "options.config.database", options.config.database);
|
|
73
89
|
Guards.stringValue(PostgreSqlEntityStorageConnector.CLASS_NAME, "options.config.tableName", options.config.tableName);
|
|
90
|
+
if (!Is.empty(options.config.pool?.connectTimeout)) {
|
|
91
|
+
Guards.integer(PostgreSqlEntityStorageConnector.CLASS_NAME, "options.config.pool.connectTimeout", options.config.pool?.connectTimeout);
|
|
92
|
+
}
|
|
93
|
+
if (!Is.empty(options.config.pool?.idleTimeout)) {
|
|
94
|
+
Guards.integer(PostgreSqlEntityStorageConnector.CLASS_NAME, "options.config.pool.idleTimeout", options.config.pool?.idleTimeout);
|
|
95
|
+
}
|
|
96
|
+
if (!Is.empty(options.config.pool?.max)) {
|
|
97
|
+
Guards.integer(PostgreSqlEntityStorageConnector.CLASS_NAME, "options.config.pool.max", options.config.pool?.max);
|
|
98
|
+
}
|
|
99
|
+
if (!Is.empty(options.config.pool?.maxLifetime)) {
|
|
100
|
+
Guards.integer(PostgreSqlEntityStorageConnector.CLASS_NAME, "options.config.pool.maxLifetime", options.config.pool?.maxLifetime);
|
|
101
|
+
}
|
|
74
102
|
this._entitySchemaName = options.entitySchema;
|
|
75
103
|
this._entitySchema = EntitySchemaFactory.get(options.entitySchema);
|
|
76
104
|
this._partitionContextIds = options.partitionContextIds;
|
|
77
105
|
this._primaryKeyProperty = EntitySchemaHelper.getPrimaryKey(this._entitySchema);
|
|
106
|
+
this._versionKey = EntitySchemaHelper.findVersionProperty(this._entitySchema);
|
|
78
107
|
this._config = options.config;
|
|
108
|
+
this._mutexTimeoutMs = Coerce.integer(options.config.mutexTimeoutMs);
|
|
109
|
+
this._instanceId = RandomHelper.generateUuidV7("compact");
|
|
79
110
|
}
|
|
80
111
|
/**
|
|
81
112
|
* Initialize the PostgreSql environment.
|
|
@@ -85,7 +116,7 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
85
116
|
async bootstrap(nodeLoggingComponentType) {
|
|
86
117
|
const nodeLogging = ComponentFactory.getIfExists(nodeLoggingComponentType);
|
|
87
118
|
try {
|
|
88
|
-
const dbConnection = await this.
|
|
119
|
+
const dbConnection = await this.getClient();
|
|
89
120
|
const databaseExists = await this.databaseExists();
|
|
90
121
|
if (!databaseExists) {
|
|
91
122
|
await nodeLogging?.log({
|
|
@@ -137,6 +168,15 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
137
168
|
}
|
|
138
169
|
});
|
|
139
170
|
}
|
|
171
|
+
for (const prop of this._entitySchema.properties ?? []) {
|
|
172
|
+
if ((prop.isSecondary === true || !Is.empty(prop.sortDirection)) &&
|
|
173
|
+
prop.type !== EntitySchemaPropertyType.Object &&
|
|
174
|
+
prop.type !== EntitySchemaPropertyType.Array) {
|
|
175
|
+
const columnName = String(prop.property);
|
|
176
|
+
const indexName = IndexHelper.generateName(this._config.tableName, columnName);
|
|
177
|
+
await dbConnection.unsafe(`CREATE INDEX IF NOT EXISTS "${indexName}" ON "${this._config.tableName}" ("${columnName}")`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
140
180
|
}
|
|
141
181
|
catch (error) {
|
|
142
182
|
await nodeLogging?.log({
|
|
@@ -161,16 +201,17 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
161
201
|
return PostgreSqlEntityStorageConnector.CLASS_NAME;
|
|
162
202
|
}
|
|
163
203
|
/**
|
|
164
|
-
*
|
|
165
|
-
* @returns The health of the component.
|
|
204
|
+
* Returns the health status of the component.
|
|
205
|
+
* @returns The health status of the component.
|
|
166
206
|
*/
|
|
167
207
|
async health() {
|
|
168
208
|
try {
|
|
169
|
-
const sql = await this.
|
|
209
|
+
const sql = await this.getClient();
|
|
170
210
|
await sql `SELECT 1 FROM ${sql(this._config.tableName)} LIMIT 0`;
|
|
171
211
|
return [
|
|
172
212
|
{
|
|
173
213
|
source: PostgreSqlEntityStorageConnector.CLASS_NAME,
|
|
214
|
+
category: HealthCategory.Connectivity,
|
|
174
215
|
status: HealthStatus.Ok,
|
|
175
216
|
description: "healthDescription",
|
|
176
217
|
data: { tableName: this._config.tableName }
|
|
@@ -181,6 +222,7 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
181
222
|
return [
|
|
182
223
|
{
|
|
183
224
|
source: PostgreSqlEntityStorageConnector.CLASS_NAME,
|
|
225
|
+
category: HealthCategory.Connectivity,
|
|
184
226
|
status: HealthStatus.Error,
|
|
185
227
|
description: "healthDescription",
|
|
186
228
|
message: "connectionFailed",
|
|
@@ -194,10 +236,7 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
194
236
|
* @returns Nothing.
|
|
195
237
|
*/
|
|
196
238
|
async stop() {
|
|
197
|
-
|
|
198
|
-
await this._connection.end();
|
|
199
|
-
this._connection = undefined;
|
|
200
|
-
}
|
|
239
|
+
await ConnectionHelper.closeClient("postgreSqlConnections", `${this._config.host}|${this._config.port ?? 5432}|${this._config.user}`, this._instanceId, this._mutexTimeoutMs, async (sql) => sql.end());
|
|
201
240
|
}
|
|
202
241
|
/**
|
|
203
242
|
* Get the schema for the entities.
|
|
@@ -219,7 +258,7 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
219
258
|
const contextIds = await ContextIdStore.getContextIds();
|
|
220
259
|
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
221
260
|
try {
|
|
222
|
-
const dbConnection = await this.
|
|
261
|
+
const dbConnection = await this.getClient();
|
|
223
262
|
const whereClauses = [];
|
|
224
263
|
const values = [];
|
|
225
264
|
whereClauses.push(`"${PostgreSqlEntityStorageConnector._PARTITION_KEY}" = $1`);
|
|
@@ -283,12 +322,17 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
283
322
|
* @param entity The entity to set.
|
|
284
323
|
* @param conditions The optional conditions to match for the entities.
|
|
285
324
|
* @returns The id of the entity.
|
|
325
|
+
* @throws ConflictError when the entity exists but the supplied conditions or version do not match the stored state.
|
|
286
326
|
*/
|
|
287
327
|
async set(entity, conditions) {
|
|
288
328
|
Guards.object(PostgreSqlEntityStorageConnector.CLASS_NAME, "entity", entity);
|
|
289
329
|
EntityStorageHelper.validateConditions(this._entitySchema, conditions);
|
|
290
330
|
const contextIds = await ContextIdStore.getContextIds();
|
|
291
331
|
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
332
|
+
const submittedVersion = Is.stringValue(this._versionKey)
|
|
333
|
+
? Coerce.integer(ObjectHelper.propertyGet(entity, this._versionKey))
|
|
334
|
+
: undefined;
|
|
335
|
+
const hasVersionCheck = !Is.empty(this._versionKey) && !Is.empty(submittedVersion) && submittedVersion > 0;
|
|
292
336
|
const prepared = EntityStorageHelper.prepareEntity(entity, this._entitySchema, [
|
|
293
337
|
{
|
|
294
338
|
property: PostgreSqlEntityStorageConnector._PARTITION_KEY,
|
|
@@ -296,11 +340,40 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
296
340
|
}
|
|
297
341
|
], { nullBehavior: "nullify" });
|
|
298
342
|
const id = prepared[this._primaryKeyProperty.property];
|
|
343
|
+
const optimisticMutexKey = Is.stringValue(this._versionKey)
|
|
344
|
+
? this.buildOptimisticMutexKey(partitionKey, id)
|
|
345
|
+
: undefined;
|
|
346
|
+
if (Is.stringValue(optimisticMutexKey)) {
|
|
347
|
+
await Mutex.lock(optimisticMutexKey, {
|
|
348
|
+
throwOnTimeout: true,
|
|
349
|
+
timeoutMs: this._mutexTimeoutMs
|
|
350
|
+
});
|
|
351
|
+
}
|
|
299
352
|
try {
|
|
300
|
-
if (
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
353
|
+
if (hasVersionCheck) {
|
|
354
|
+
if (Is.arrayValue(conditions)) {
|
|
355
|
+
const currentEntity = await this.get(id);
|
|
356
|
+
if (!Is.empty(currentEntity) && !this.verifyConditions(conditions, currentEntity)) {
|
|
357
|
+
throw new ConflictError(PostgreSqlEntityStorageConnector.CLASS_NAME, "conditionFailed", id);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
ObjectHelper.propertySet(prepared, this._versionKey, submittedVersion + 1);
|
|
361
|
+
}
|
|
362
|
+
else if (this._versionKey || Is.arrayValue(conditions)) {
|
|
363
|
+
const currentEntity = await this.get(id);
|
|
364
|
+
if (!Is.empty(currentEntity)) {
|
|
365
|
+
if (Is.arrayValue(conditions) && !this.verifyConditions(conditions, currentEntity)) {
|
|
366
|
+
if (Is.stringValue(this._versionKey)) {
|
|
367
|
+
throw new ConflictError(PostgreSqlEntityStorageConnector.CLASS_NAME, "conditionFailed", id);
|
|
368
|
+
}
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (Is.stringValue(this._versionKey)) {
|
|
373
|
+
const storedVersion = Coerce.integer(!Is.empty(currentEntity)
|
|
374
|
+
? ObjectHelper.propertyGet(currentEntity, this._versionKey)
|
|
375
|
+
: 0) ?? 0;
|
|
376
|
+
ObjectHelper.propertySet(prepared, this._versionKey, storedVersion + 1);
|
|
304
377
|
}
|
|
305
378
|
}
|
|
306
379
|
const props = [...(this._entitySchema.properties ?? [])];
|
|
@@ -319,15 +392,33 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
319
392
|
sql += ` (${keys.map(key => `"${key}"`).join(", ")})`;
|
|
320
393
|
sql += ` VALUES (${values.map((value, i) => `$${i + 1}`).join(", ")})`;
|
|
321
394
|
sql += ` ON CONFLICT ("${PostgreSqlEntityStorageConnector._PARTITION_KEY}", "${this._primaryKeyProperty.property}")`;
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
395
|
+
if (hasVersionCheck) {
|
|
396
|
+
sql += ` DO UPDATE SET ${keys.map(key => `"${key}" = EXCLUDED."${key}"`).join(", ")}`;
|
|
397
|
+
sql += ` WHERE "${this._config.tableName}"."${this._versionKey}" = $${values.length + 1}`;
|
|
398
|
+
values.push(submittedVersion);
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
sql += ` DO UPDATE SET ${keys.map(key => `"${key}" = EXCLUDED."${key}"`).join(", ")};`;
|
|
402
|
+
}
|
|
403
|
+
const dbConnection = await this.getClient();
|
|
404
|
+
const result = await dbConnection.unsafe(sql, values);
|
|
405
|
+
if (hasVersionCheck && result.count === 0) {
|
|
406
|
+
throw new ConflictError(PostgreSqlEntityStorageConnector.CLASS_NAME, "optimisticLockFailed", id);
|
|
407
|
+
}
|
|
325
408
|
}
|
|
326
409
|
catch (err) {
|
|
410
|
+
if (BaseError.isErrorName(err, ConflictError.CLASS_NAME)) {
|
|
411
|
+
throw err;
|
|
412
|
+
}
|
|
327
413
|
throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "setFailed", {
|
|
328
414
|
id
|
|
329
415
|
}, err);
|
|
330
416
|
}
|
|
417
|
+
finally {
|
|
418
|
+
if (Is.stringValue(optimisticMutexKey)) {
|
|
419
|
+
Mutex.unlock(optimisticMutexKey);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
331
422
|
}
|
|
332
423
|
/**
|
|
333
424
|
* Set multiple entities in a batch.
|
|
@@ -351,24 +442,28 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
351
442
|
type: EntitySchemaPropertyType.String
|
|
352
443
|
});
|
|
353
444
|
const keys = props.map(p => p.property);
|
|
354
|
-
const
|
|
355
|
-
const
|
|
356
|
-
for (
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
rowValues
|
|
445
|
+
const dbConnection = await this.getClient();
|
|
446
|
+
const chunkSize = PostgreSqlEntityStorageConnector._BATCH_CHUNK_SIZE;
|
|
447
|
+
for (let offset = 0; offset < preparedEntities.length; offset += chunkSize) {
|
|
448
|
+
const chunk = preparedEntities.slice(offset, offset + chunkSize);
|
|
449
|
+
const allValues = [];
|
|
450
|
+
const rowPlaceholders = [];
|
|
451
|
+
for (const prepared of chunk) {
|
|
452
|
+
const rowValues = [];
|
|
453
|
+
for (const prop of props) {
|
|
454
|
+
const val = prepared[prop.property];
|
|
455
|
+
allValues.push(Is.empty(val) ? null : val);
|
|
456
|
+
rowValues.push(`$${allValues.length}`);
|
|
457
|
+
}
|
|
458
|
+
rowPlaceholders.push(`(${rowValues.join(", ")})`);
|
|
362
459
|
}
|
|
363
|
-
|
|
460
|
+
let sql = `INSERT INTO "${this._config.tableName}"`;
|
|
461
|
+
sql += ` (${keys.map(key => `"${key}"`).join(", ")})`;
|
|
462
|
+
sql += ` VALUES ${rowPlaceholders.join(", ")}`;
|
|
463
|
+
sql += ` ON CONFLICT ("${PostgreSqlEntityStorageConnector._PARTITION_KEY}", "${this._primaryKeyProperty.property}")`;
|
|
464
|
+
sql += ` DO UPDATE SET ${keys.map(key => `"${key}" = EXCLUDED."${key}"`).join(", ")};`;
|
|
465
|
+
await dbConnection.unsafe(sql, allValues);
|
|
364
466
|
}
|
|
365
|
-
let sql = `INSERT INTO "${this._config.tableName}"`;
|
|
366
|
-
sql += ` (${keys.map(key => `"${key}"`).join(", ")})`;
|
|
367
|
-
sql += ` VALUES ${rowPlaceholders.join(", ")}`;
|
|
368
|
-
sql += ` ON CONFLICT ("${PostgreSqlEntityStorageConnector._PARTITION_KEY}", "${this._primaryKeyProperty.property}")`;
|
|
369
|
-
sql += ` DO UPDATE SET ${keys.map(key => `"${key}" = EXCLUDED."${key}"`).join(", ")};`;
|
|
370
|
-
const dbConnection = await this.createConnection();
|
|
371
|
-
await dbConnection.unsafe(sql, allValues);
|
|
372
467
|
}
|
|
373
468
|
catch (err) {
|
|
374
469
|
throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "setBatchFailed", undefined, err);
|
|
@@ -383,7 +478,7 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
383
478
|
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
384
479
|
try {
|
|
385
480
|
const sql = `DELETE FROM "${this._config.tableName}" WHERE "${PostgreSqlEntityStorageConnector._PARTITION_KEY}" = $1`;
|
|
386
|
-
const dbConnection = await this.
|
|
481
|
+
const dbConnection = await this.getClient();
|
|
387
482
|
await dbConnection.unsafe(sql, [
|
|
388
483
|
partitionKey ?? PostgreSqlEntityStorageConnector._PARTITION_KEY_VALUE
|
|
389
484
|
]);
|
|
@@ -403,10 +498,25 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
403
498
|
EntityStorageHelper.validateConditions(this._entitySchema, conditions);
|
|
404
499
|
const contextIds = await ContextIdStore.getContextIds();
|
|
405
500
|
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
501
|
+
const optimisticMutexKey = Is.stringValue(this._versionKey)
|
|
502
|
+
? this.buildOptimisticMutexKey(partitionKey, id)
|
|
503
|
+
: undefined;
|
|
504
|
+
if (Is.stringValue(optimisticMutexKey)) {
|
|
505
|
+
await Mutex.lock(optimisticMutexKey, {
|
|
506
|
+
throwOnTimeout: true,
|
|
507
|
+
timeoutMs: this._mutexTimeoutMs
|
|
508
|
+
});
|
|
509
|
+
}
|
|
406
510
|
try {
|
|
407
|
-
const dbConnection = await this.
|
|
511
|
+
const dbConnection = await this.getClient();
|
|
408
512
|
const itemData = await this.get(id);
|
|
409
|
-
if (Is.
|
|
513
|
+
if (!Is.empty(itemData)) {
|
|
514
|
+
if (Is.arrayValue(conditions) && !this.verifyConditions(conditions, itemData)) {
|
|
515
|
+
if (Is.stringValue(this._versionKey)) {
|
|
516
|
+
throw new ConflictError(PostgreSqlEntityStorageConnector.CLASS_NAME, "conditionFailed", id);
|
|
517
|
+
}
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
410
520
|
const values = [];
|
|
411
521
|
const whereClauses = [];
|
|
412
522
|
whereClauses.push(`"${this._primaryKeyProperty.property}" = $${values.length + 1}`);
|
|
@@ -424,10 +534,18 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
424
534
|
}
|
|
425
535
|
}
|
|
426
536
|
catch (err) {
|
|
537
|
+
if (BaseError.isErrorName(err, ConflictError.CLASS_NAME)) {
|
|
538
|
+
throw err;
|
|
539
|
+
}
|
|
427
540
|
throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "removeFailed", {
|
|
428
541
|
id
|
|
429
542
|
}, err);
|
|
430
543
|
}
|
|
544
|
+
finally {
|
|
545
|
+
if (Is.stringValue(optimisticMutexKey)) {
|
|
546
|
+
Mutex.unlock(optimisticMutexKey);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
431
549
|
}
|
|
432
550
|
/**
|
|
433
551
|
* Remove multiple entities by their primary key IDs.
|
|
@@ -440,7 +558,7 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
440
558
|
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
441
559
|
try {
|
|
442
560
|
const sql = `DELETE FROM "${this._config.tableName}" WHERE "${PostgreSqlEntityStorageConnector._PARTITION_KEY}" = $1 AND "${this._primaryKeyProperty.property}" = ANY($2)`;
|
|
443
|
-
const dbConnection = await this.
|
|
561
|
+
const dbConnection = await this.getClient();
|
|
444
562
|
await dbConnection.unsafe(sql, [
|
|
445
563
|
partitionKey ?? PostgreSqlEntityStorageConnector._PARTITION_KEY_VALUE,
|
|
446
564
|
ids
|
|
@@ -467,7 +585,7 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
467
585
|
try {
|
|
468
586
|
const tableExists = await this.tableExists();
|
|
469
587
|
if (tableExists) {
|
|
470
|
-
const dbConnection = await this.
|
|
588
|
+
const dbConnection = await this.getClient();
|
|
471
589
|
await dbConnection.unsafe(`DROP TABLE "${this._config.tableName}";`);
|
|
472
590
|
await this.waitForTableNotExists();
|
|
473
591
|
}
|
|
@@ -491,6 +609,13 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
491
609
|
return false;
|
|
492
610
|
}
|
|
493
611
|
}
|
|
612
|
+
/**
|
|
613
|
+
* Get the connector implementation version.
|
|
614
|
+
* @returns The connector implementation version.
|
|
615
|
+
*/
|
|
616
|
+
connectorVersion() {
|
|
617
|
+
return 0;
|
|
618
|
+
}
|
|
494
619
|
/**
|
|
495
620
|
* Get all the distinct partition context ids from the storage.
|
|
496
621
|
* @returns An array of context id objects, one per unique partition.
|
|
@@ -500,7 +625,7 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
500
625
|
return undefined;
|
|
501
626
|
}
|
|
502
627
|
try {
|
|
503
|
-
const dbConnection = await this.
|
|
628
|
+
const dbConnection = await this.getClient();
|
|
504
629
|
const rows = await dbConnection.unsafe(`SELECT DISTINCT "${PostgreSqlEntityStorageConnector._PARTITION_KEY}" FROM "${this._config.tableName}"`);
|
|
505
630
|
return rows
|
|
506
631
|
.map(row => row[PostgreSqlEntityStorageConnector._PARTITION_KEY])
|
|
@@ -536,7 +661,7 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
536
661
|
async finalizeMigration(targetConnector, options, loggingComponentType) {
|
|
537
662
|
// Teardown the existing table with the original name to free up the name for the new table
|
|
538
663
|
await this.teardown(loggingComponentType);
|
|
539
|
-
const dbConnection = await targetConnector.
|
|
664
|
+
const dbConnection = await targetConnector.getClient();
|
|
540
665
|
await dbConnection.unsafe(`ALTER TABLE "${targetConnector._config.tableName}" RENAME TO "${this._config.tableName}"`);
|
|
541
666
|
const finalConnector = new PostgreSqlEntityStorageConnector({
|
|
542
667
|
entitySchema: targetConnector._entitySchemaName,
|
|
@@ -583,23 +708,61 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
583
708
|
let sql = "";
|
|
584
709
|
try {
|
|
585
710
|
const returnSize = limit ?? PostgreSqlEntityStorageConnector._DEFAULT_LIMIT;
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
711
|
+
const pkPropName = String(this._primaryKeyProperty.property);
|
|
712
|
+
const sortsByPK = Is.array(sortProperties) && sortProperties.some(s => String(s.property) === pkPropName);
|
|
713
|
+
const keySetCols = [];
|
|
714
|
+
if (Is.array(sortProperties)) {
|
|
715
|
+
for (const s of sortProperties) {
|
|
716
|
+
keySetCols.push({
|
|
717
|
+
prop: String(s.property),
|
|
718
|
+
asc: s.sortDirection === SortDirection.Ascending
|
|
719
|
+
});
|
|
592
720
|
}
|
|
593
|
-
orderByClause = `ORDER BY ${orderClauses.join(", ")}`;
|
|
594
721
|
}
|
|
722
|
+
if (!sortsByPK) {
|
|
723
|
+
keySetCols.push({ prop: pkPropName, asc: true });
|
|
724
|
+
}
|
|
725
|
+
const requestedProps = properties ? new Set(properties.map(p => String(p))) : undefined;
|
|
726
|
+
const internallyAdded = new Set();
|
|
727
|
+
let selectClause;
|
|
728
|
+
if (requestedProps) {
|
|
729
|
+
const selectSet = new Set(requestedProps);
|
|
730
|
+
for (const col of keySetCols) {
|
|
731
|
+
if (!selectSet.has(col.prop)) {
|
|
732
|
+
selectSet.add(col.prop);
|
|
733
|
+
internallyAdded.add(col.prop);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
selectClause = [...selectSet].map(p => `"${p}"`).join(", ");
|
|
737
|
+
}
|
|
738
|
+
else {
|
|
739
|
+
selectClause = "*";
|
|
740
|
+
}
|
|
741
|
+
const orderByClause = `ORDER BY ${keySetCols.map(c => `"${c.prop}" ${c.asc ? "ASC" : "DESC"}`).join(", ")}`;
|
|
595
742
|
const { whereClauses, values } = this.buildWhereClause(conditions, partitionKey);
|
|
596
|
-
|
|
597
|
-
|
|
743
|
+
if (Is.stringBase64(cursor)) {
|
|
744
|
+
const parsedCursor = ObjectHelper.fromBytes(Converter.base64ToBytes(cursor));
|
|
745
|
+
const lastValues = [...(parsedCursor.sv ?? []), parsedCursor.i];
|
|
746
|
+
const orParts = [];
|
|
747
|
+
for (let i = 0; i < keySetCols.length; i++) {
|
|
748
|
+
const parts = [];
|
|
749
|
+
for (let j = 0; j < i; j++) {
|
|
750
|
+
values.push(lastValues[j]);
|
|
751
|
+
parts.push(`"${keySetCols[j].prop}" = $${values.length}`);
|
|
752
|
+
}
|
|
753
|
+
const op = keySetCols[i].asc ? ">" : "<";
|
|
754
|
+
values.push(lastValues[i]);
|
|
755
|
+
parts.push(`"${keySetCols[i].prop}" ${op} $${values.length}`);
|
|
756
|
+
orParts.push(parts.length === 1 ? parts[0] : `(${parts.join(" AND ")})`);
|
|
757
|
+
}
|
|
758
|
+
whereClauses.push(`(${orParts.join(" OR ")})`);
|
|
759
|
+
}
|
|
760
|
+
sql = `SELECT ${selectClause} FROM "${this._config.tableName}"`;
|
|
598
761
|
if (whereClauses.length > 0) {
|
|
599
762
|
sql += ` WHERE ${whereClauses.join(" AND ")}`;
|
|
600
763
|
}
|
|
601
|
-
sql += ` ${orderByClause} LIMIT ${returnSize + 1}
|
|
602
|
-
const dbConnection = await this.
|
|
764
|
+
sql += ` ${orderByClause} LIMIT ${returnSize + 1}`;
|
|
765
|
+
const dbConnection = await this.getClient();
|
|
603
766
|
const rows = await dbConnection.unsafe(sql, values);
|
|
604
767
|
if (this._entitySchema.properties) {
|
|
605
768
|
for (const row of rows) {
|
|
@@ -630,15 +793,27 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
630
793
|
const hasMore = Is.array(rows) && rows.length > returnSize;
|
|
631
794
|
const resultRows = hasMore ? rows.slice(0, returnSize) : rows;
|
|
632
795
|
const entities = resultRows;
|
|
796
|
+
let nextCursor;
|
|
797
|
+
if (hasMore && entities.length > 0) {
|
|
798
|
+
const lastRow = entities[entities.length - 1];
|
|
799
|
+
const sortValues = keySetCols
|
|
800
|
+
.slice(0, -1)
|
|
801
|
+
.map(c => ObjectHelper.propertyGet(lastRow, c.prop));
|
|
802
|
+
const lastId = ObjectHelper.propertyGet(lastRow, pkPropName);
|
|
803
|
+
if (Is.stringValue(lastId)) {
|
|
804
|
+
const cursorData = sortValues.length > 0 ? { i: lastId, sv: sortValues } : { i: lastId };
|
|
805
|
+
nextCursor = Converter.bytesToBase64(ObjectHelper.toBytes(cursorData));
|
|
806
|
+
}
|
|
807
|
+
}
|
|
633
808
|
for (let i = 0; i < entities.length; i++) {
|
|
634
809
|
entities[i] = EntityStorageHelper.unPrepareEntity(entities[i], [
|
|
635
810
|
PostgreSqlEntityStorageConnector._PARTITION_KEY
|
|
636
811
|
]);
|
|
812
|
+
for (const col of internallyAdded) {
|
|
813
|
+
ObjectHelper.propertyDelete(entities[i], col);
|
|
814
|
+
}
|
|
637
815
|
}
|
|
638
|
-
return {
|
|
639
|
-
entities,
|
|
640
|
-
cursor: hasMore ? Coerce.string(startIndex + returnSize) : undefined
|
|
641
|
-
};
|
|
816
|
+
return { entities, cursor: nextCursor };
|
|
642
817
|
}
|
|
643
818
|
catch (err) {
|
|
644
819
|
throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "queryFailed", { sql }, err);
|
|
@@ -653,7 +828,7 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
653
828
|
EntityStorageHelper.validateConditionProperties(this._entitySchema, conditions);
|
|
654
829
|
let queryStr;
|
|
655
830
|
try {
|
|
656
|
-
const dbConnection = await this.
|
|
831
|
+
const dbConnection = await this.getClient();
|
|
657
832
|
const contextIds = await ContextIdStore.getContextIds();
|
|
658
833
|
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
659
834
|
const { whereClauses, values } = this.buildWhereClause(conditions, partitionKey);
|
|
@@ -675,7 +850,7 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
675
850
|
*/
|
|
676
851
|
async databaseExists() {
|
|
677
852
|
try {
|
|
678
|
-
const dbConnection = await this.
|
|
853
|
+
const dbConnection = await this.getClient();
|
|
679
854
|
const res = await dbConnection.unsafe("SELECT datname FROM pg_catalog.pg_database WHERE datname = $1", [this._config.database]);
|
|
680
855
|
return res.length > 0;
|
|
681
856
|
}
|
|
@@ -704,7 +879,7 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
704
879
|
*/
|
|
705
880
|
async tableExists() {
|
|
706
881
|
try {
|
|
707
|
-
const dbConnection = await this.
|
|
882
|
+
const dbConnection = await this.getClient();
|
|
708
883
|
const res = await dbConnection.unsafe("SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1 LIMIT 1", [this._config.tableName]);
|
|
709
884
|
return res.length > 0;
|
|
710
885
|
}
|
|
@@ -741,15 +916,12 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
741
916
|
}
|
|
742
917
|
}
|
|
743
918
|
/**
|
|
744
|
-
*
|
|
745
|
-
* @returns The
|
|
919
|
+
* Retrieve (or lazily create) the shared postgres connection for this endpoint.
|
|
920
|
+
* @returns The shared connection.
|
|
746
921
|
* @internal
|
|
747
922
|
*/
|
|
748
|
-
async
|
|
749
|
-
|
|
750
|
-
this._connection = postgres(this.createConnectionConfig());
|
|
751
|
-
}
|
|
752
|
-
return this._connection;
|
|
923
|
+
async getClient() {
|
|
924
|
+
return ConnectionHelper.openClient("postgreSqlConnections", `${this._config.host}|${this._config.port ?? 5432}|${this._config.user}`, this._instanceId, this._mutexTimeoutMs, async () => postgres(this.createConnectionConfig()));
|
|
753
925
|
}
|
|
754
926
|
/**
|
|
755
927
|
* Create a new DB connection configuration.
|
|
@@ -757,12 +929,20 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
757
929
|
* @internal
|
|
758
930
|
*/
|
|
759
931
|
createConnectionConfig() {
|
|
760
|
-
|
|
932
|
+
const opts = {
|
|
761
933
|
host: this._config.host,
|
|
762
934
|
port: this._config.port ?? 5432,
|
|
763
935
|
user: this._config.user,
|
|
764
|
-
password: this._config.password
|
|
936
|
+
password: this._config.password,
|
|
937
|
+
max: this._config?.pool?.max,
|
|
938
|
+
// eslint-disable-next-line camelcase
|
|
939
|
+
idle_timeout: this._config?.pool?.idleTimeout,
|
|
940
|
+
// eslint-disable-next-line camelcase
|
|
941
|
+
connect_timeout: this._config?.pool?.connectTimeout,
|
|
942
|
+
// eslint-disable-next-line camelcase
|
|
943
|
+
max_lifetime: this._config?.pool?.maxLifetime
|
|
765
944
|
};
|
|
945
|
+
return opts;
|
|
766
946
|
}
|
|
767
947
|
/**
|
|
768
948
|
* Build where clause arrays for a query, combining partition key and optional conditions.
|
|
@@ -845,7 +1025,7 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
845
1025
|
if (comparator.comparison === ComparisonOperator.In) {
|
|
846
1026
|
const inValues = Is.array(comparator.value) ? comparator.value : [comparator.value];
|
|
847
1027
|
if (inValues.length === 0) {
|
|
848
|
-
// PostgreSQL rejects `IN ()` as a syntax error
|
|
1028
|
+
// PostgreSQL rejects `IN ()` as a syntax error - short-circuit to a condition
|
|
849
1029
|
// that is always false so the query returns zero rows cleanly (#141).
|
|
850
1030
|
return "1 = 0";
|
|
851
1031
|
}
|
|
@@ -853,7 +1033,7 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
853
1033
|
const placeholders = inValues.map((value, index) => `$${valueIndex + index}`).join(", ");
|
|
854
1034
|
return `"${prop}" IN (${placeholders})`;
|
|
855
1035
|
}
|
|
856
|
-
// null/undefined must use IS NULL / IS NOT NULL
|
|
1036
|
+
// null/undefined must use IS NULL / IS NOT NULL - never a parameterised placeholder.
|
|
857
1037
|
// Passing undefined through propertyToDbValue() coerces it to NaN for number fields
|
|
858
1038
|
// (Number(undefined) === NaN), and null coerces to 0 (Number(null) === 0), both of
|
|
859
1039
|
// which produce semantically wrong or invalid SQL.
|
|
@@ -1021,6 +1201,16 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
1021
1201
|
verifyConditions(conditions, obj) {
|
|
1022
1202
|
return conditions.every(condition => ObjectHelper.propertyGet(obj, condition.property) === condition.value);
|
|
1023
1203
|
}
|
|
1204
|
+
/**
|
|
1205
|
+
* Build a mutex key for optimistic-locking critical sections.
|
|
1206
|
+
* @param partitionKey The resolved partition key.
|
|
1207
|
+
* @param id The entity id.
|
|
1208
|
+
* @returns The mutex key.
|
|
1209
|
+
* @internal
|
|
1210
|
+
*/
|
|
1211
|
+
buildOptimisticMutexKey(partitionKey, id) {
|
|
1212
|
+
return `${PostgreSqlEntityStorageConnector.CLASS_NAME}:optimistic:${this._config.tableName}:${partitionKey ?? PostgreSqlEntityStorageConnector._PARTITION_KEY_VALUE}:${id}`;
|
|
1213
|
+
}
|
|
1024
1214
|
/**
|
|
1025
1215
|
* Map entity schema properties to SQL properties.
|
|
1026
1216
|
* @param entitySchema The schema of the entity.
|