@twin.org/entity-storage-connector-postgresql 0.9.2-next.1 → 0.9.2-next.11
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 +291 -76
- package/dist/es/postgreSqlEntityStorageConnector.js.map +1 -1
- package/dist/types/models/IPostgreSqlEntityStorageConnectorConfig.d.ts +27 -0
- package/dist/types/postgreSqlEntityStorageConnector.d.ts +12 -5
- package/docs/changelog.md +161 -0
- package/docs/reference/classes/PostgreSqlEntityStorageConnector.md +35 -4
- package/docs/reference/interfaces/IPostgreSqlEntityStorageConnectorConfig.md +52 -0
- package/locales/en.json +7 -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.
|
|
69
|
+
* @internal
|
|
70
|
+
*/
|
|
71
|
+
_mutexTimeoutMs;
|
|
72
|
+
/**
|
|
73
|
+
* Unique identifier for this connector instance, used to track references in SharedStore.
|
|
58
74
|
* @internal
|
|
59
75
|
*/
|
|
60
|
-
|
|
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,21 +609,53 @@ 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.
|
|
621
|
+
* @param loggingComponentType The optional component type to use for logging skipped partition ids.
|
|
496
622
|
* @returns An array of context id objects, one per unique partition.
|
|
497
623
|
*/
|
|
498
|
-
async getPartitionContextIds() {
|
|
624
|
+
async getPartitionContextIds(loggingComponentType) {
|
|
499
625
|
if (!Is.arrayValue(this._partitionContextIds)) {
|
|
500
626
|
return undefined;
|
|
501
627
|
}
|
|
502
628
|
try {
|
|
503
|
-
const dbConnection = await this.
|
|
629
|
+
const dbConnection = await this.getClient();
|
|
504
630
|
const rows = await dbConnection.unsafe(`SELECT DISTINCT "${PostgreSqlEntityStorageConnector._PARTITION_KEY}" FROM "${this._config.tableName}"`);
|
|
505
|
-
|
|
631
|
+
const partitionIds = rows
|
|
506
632
|
.map(row => row[PostgreSqlEntityStorageConnector._PARTITION_KEY])
|
|
507
|
-
.filter((id) => Is.stringValue(id))
|
|
508
|
-
|
|
633
|
+
.filter((id) => Is.stringValue(id));
|
|
634
|
+
const contextIds = [];
|
|
635
|
+
const skipped = [];
|
|
636
|
+
for (const partitionId of partitionIds) {
|
|
637
|
+
const split = EntityStorageHelper.tryShortSplit(this._partitionContextIds ?? [], partitionId);
|
|
638
|
+
if (Is.undefined(split)) {
|
|
639
|
+
skipped.push(partitionId);
|
|
640
|
+
}
|
|
641
|
+
else {
|
|
642
|
+
contextIds.push(split);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
if (Is.arrayValue(skipped)) {
|
|
646
|
+
const nodeLogging = ComponentFactory.getIfExists(loggingComponentType);
|
|
647
|
+
await nodeLogging?.log({
|
|
648
|
+
level: "warn",
|
|
649
|
+
source: PostgreSqlEntityStorageConnector.CLASS_NAME,
|
|
650
|
+
ts: Date.now(),
|
|
651
|
+
message: "partitionIdsSkipped",
|
|
652
|
+
data: {
|
|
653
|
+
expected: this._partitionContextIds?.length,
|
|
654
|
+
partitionIds: skipped.join(", ")
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
return contextIds;
|
|
509
659
|
}
|
|
510
660
|
catch (err) {
|
|
511
661
|
throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "getPartitionContextIdsFailed", undefined, err);
|
|
@@ -536,7 +686,7 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
536
686
|
async finalizeMigration(targetConnector, options, loggingComponentType) {
|
|
537
687
|
// Teardown the existing table with the original name to free up the name for the new table
|
|
538
688
|
await this.teardown(loggingComponentType);
|
|
539
|
-
const dbConnection = await targetConnector.
|
|
689
|
+
const dbConnection = await targetConnector.getClient();
|
|
540
690
|
await dbConnection.unsafe(`ALTER TABLE "${targetConnector._config.tableName}" RENAME TO "${this._config.tableName}"`);
|
|
541
691
|
const finalConnector = new PostgreSqlEntityStorageConnector({
|
|
542
692
|
entitySchema: targetConnector._entitySchemaName,
|
|
@@ -583,23 +733,61 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
583
733
|
let sql = "";
|
|
584
734
|
try {
|
|
585
735
|
const returnSize = limit ?? PostgreSqlEntityStorageConnector._DEFAULT_LIMIT;
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
736
|
+
const pkPropName = String(this._primaryKeyProperty.property);
|
|
737
|
+
const sortsByPK = Is.array(sortProperties) && sortProperties.some(s => String(s.property) === pkPropName);
|
|
738
|
+
const keySetCols = [];
|
|
739
|
+
if (Is.array(sortProperties)) {
|
|
740
|
+
for (const s of sortProperties) {
|
|
741
|
+
keySetCols.push({
|
|
742
|
+
prop: String(s.property),
|
|
743
|
+
asc: s.sortDirection === SortDirection.Ascending
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
if (!sortsByPK) {
|
|
748
|
+
keySetCols.push({ prop: pkPropName, asc: true });
|
|
749
|
+
}
|
|
750
|
+
const requestedProps = properties ? new Set(properties.map(p => String(p))) : undefined;
|
|
751
|
+
const internallyAdded = new Set();
|
|
752
|
+
let selectClause;
|
|
753
|
+
if (requestedProps) {
|
|
754
|
+
const selectSet = new Set(requestedProps);
|
|
755
|
+
for (const col of keySetCols) {
|
|
756
|
+
if (!selectSet.has(col.prop)) {
|
|
757
|
+
selectSet.add(col.prop);
|
|
758
|
+
internallyAdded.add(col.prop);
|
|
759
|
+
}
|
|
592
760
|
}
|
|
593
|
-
|
|
761
|
+
selectClause = [...selectSet].map(p => `"${p}"`).join(", ");
|
|
762
|
+
}
|
|
763
|
+
else {
|
|
764
|
+
selectClause = "*";
|
|
594
765
|
}
|
|
766
|
+
const orderByClause = `ORDER BY ${keySetCols.map(c => `"${c.prop}" ${c.asc ? "ASC" : "DESC"}`).join(", ")}`;
|
|
595
767
|
const { whereClauses, values } = this.buildWhereClause(conditions, partitionKey);
|
|
596
|
-
|
|
597
|
-
|
|
768
|
+
if (Is.stringBase64(cursor)) {
|
|
769
|
+
const parsedCursor = ObjectHelper.fromBytes(Converter.base64ToBytes(cursor));
|
|
770
|
+
const lastValues = [...(parsedCursor.sv ?? []), parsedCursor.i];
|
|
771
|
+
const orParts = [];
|
|
772
|
+
for (let i = 0; i < keySetCols.length; i++) {
|
|
773
|
+
const parts = [];
|
|
774
|
+
for (let j = 0; j < i; j++) {
|
|
775
|
+
values.push(lastValues[j]);
|
|
776
|
+
parts.push(`"${keySetCols[j].prop}" = $${values.length}`);
|
|
777
|
+
}
|
|
778
|
+
const op = keySetCols[i].asc ? ">" : "<";
|
|
779
|
+
values.push(lastValues[i]);
|
|
780
|
+
parts.push(`"${keySetCols[i].prop}" ${op} $${values.length}`);
|
|
781
|
+
orParts.push(parts.length === 1 ? parts[0] : `(${parts.join(" AND ")})`);
|
|
782
|
+
}
|
|
783
|
+
whereClauses.push(`(${orParts.join(" OR ")})`);
|
|
784
|
+
}
|
|
785
|
+
sql = `SELECT ${selectClause} FROM "${this._config.tableName}"`;
|
|
598
786
|
if (whereClauses.length > 0) {
|
|
599
787
|
sql += ` WHERE ${whereClauses.join(" AND ")}`;
|
|
600
788
|
}
|
|
601
|
-
sql += ` ${orderByClause} LIMIT ${returnSize + 1}
|
|
602
|
-
const dbConnection = await this.
|
|
789
|
+
sql += ` ${orderByClause} LIMIT ${returnSize + 1}`;
|
|
790
|
+
const dbConnection = await this.getClient();
|
|
603
791
|
const rows = await dbConnection.unsafe(sql, values);
|
|
604
792
|
if (this._entitySchema.properties) {
|
|
605
793
|
for (const row of rows) {
|
|
@@ -630,15 +818,27 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
630
818
|
const hasMore = Is.array(rows) && rows.length > returnSize;
|
|
631
819
|
const resultRows = hasMore ? rows.slice(0, returnSize) : rows;
|
|
632
820
|
const entities = resultRows;
|
|
821
|
+
let nextCursor;
|
|
822
|
+
if (hasMore && entities.length > 0) {
|
|
823
|
+
const lastRow = entities[entities.length - 1];
|
|
824
|
+
const sortValues = keySetCols
|
|
825
|
+
.slice(0, -1)
|
|
826
|
+
.map(c => ObjectHelper.propertyGet(lastRow, c.prop));
|
|
827
|
+
const lastId = ObjectHelper.propertyGet(lastRow, pkPropName);
|
|
828
|
+
if (Is.stringValue(lastId)) {
|
|
829
|
+
const cursorData = sortValues.length > 0 ? { i: lastId, sv: sortValues } : { i: lastId };
|
|
830
|
+
nextCursor = Converter.bytesToBase64(ObjectHelper.toBytes(cursorData));
|
|
831
|
+
}
|
|
832
|
+
}
|
|
633
833
|
for (let i = 0; i < entities.length; i++) {
|
|
634
834
|
entities[i] = EntityStorageHelper.unPrepareEntity(entities[i], [
|
|
635
835
|
PostgreSqlEntityStorageConnector._PARTITION_KEY
|
|
636
836
|
]);
|
|
837
|
+
for (const col of internallyAdded) {
|
|
838
|
+
ObjectHelper.propertyDelete(entities[i], col);
|
|
839
|
+
}
|
|
637
840
|
}
|
|
638
|
-
return {
|
|
639
|
-
entities,
|
|
640
|
-
cursor: hasMore ? Coerce.string(startIndex + returnSize) : undefined
|
|
641
|
-
};
|
|
841
|
+
return { entities, cursor: nextCursor };
|
|
642
842
|
}
|
|
643
843
|
catch (err) {
|
|
644
844
|
throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "queryFailed", { sql }, err);
|
|
@@ -653,7 +853,7 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
653
853
|
EntityStorageHelper.validateConditionProperties(this._entitySchema, conditions);
|
|
654
854
|
let queryStr;
|
|
655
855
|
try {
|
|
656
|
-
const dbConnection = await this.
|
|
856
|
+
const dbConnection = await this.getClient();
|
|
657
857
|
const contextIds = await ContextIdStore.getContextIds();
|
|
658
858
|
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
659
859
|
const { whereClauses, values } = this.buildWhereClause(conditions, partitionKey);
|
|
@@ -675,7 +875,7 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
675
875
|
*/
|
|
676
876
|
async databaseExists() {
|
|
677
877
|
try {
|
|
678
|
-
const dbConnection = await this.
|
|
878
|
+
const dbConnection = await this.getClient();
|
|
679
879
|
const res = await dbConnection.unsafe("SELECT datname FROM pg_catalog.pg_database WHERE datname = $1", [this._config.database]);
|
|
680
880
|
return res.length > 0;
|
|
681
881
|
}
|
|
@@ -704,7 +904,7 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
704
904
|
*/
|
|
705
905
|
async tableExists() {
|
|
706
906
|
try {
|
|
707
|
-
const dbConnection = await this.
|
|
907
|
+
const dbConnection = await this.getClient();
|
|
708
908
|
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
909
|
return res.length > 0;
|
|
710
910
|
}
|
|
@@ -741,15 +941,12 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
741
941
|
}
|
|
742
942
|
}
|
|
743
943
|
/**
|
|
744
|
-
*
|
|
745
|
-
* @returns The
|
|
944
|
+
* Retrieve (or lazily create) the shared postgres connection for this endpoint.
|
|
945
|
+
* @returns The shared connection.
|
|
746
946
|
* @internal
|
|
747
947
|
*/
|
|
748
|
-
async
|
|
749
|
-
|
|
750
|
-
this._connection = postgres(this.createConnectionConfig());
|
|
751
|
-
}
|
|
752
|
-
return this._connection;
|
|
948
|
+
async getClient() {
|
|
949
|
+
return ConnectionHelper.openClient("postgreSqlConnections", `${this._config.host}|${this._config.port ?? 5432}|${this._config.user}`, this._instanceId, this._mutexTimeoutMs, async () => postgres(this.createConnectionConfig()));
|
|
753
950
|
}
|
|
754
951
|
/**
|
|
755
952
|
* Create a new DB connection configuration.
|
|
@@ -757,12 +954,20 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
757
954
|
* @internal
|
|
758
955
|
*/
|
|
759
956
|
createConnectionConfig() {
|
|
760
|
-
|
|
957
|
+
const opts = {
|
|
761
958
|
host: this._config.host,
|
|
762
959
|
port: this._config.port ?? 5432,
|
|
763
960
|
user: this._config.user,
|
|
764
|
-
password: this._config.password
|
|
961
|
+
password: this._config.password,
|
|
962
|
+
max: this._config?.pool?.max,
|
|
963
|
+
// eslint-disable-next-line camelcase
|
|
964
|
+
idle_timeout: this._config?.pool?.idleTimeout,
|
|
965
|
+
// eslint-disable-next-line camelcase
|
|
966
|
+
connect_timeout: this._config?.pool?.connectTimeout,
|
|
967
|
+
// eslint-disable-next-line camelcase
|
|
968
|
+
max_lifetime: this._config?.pool?.maxLifetime
|
|
765
969
|
};
|
|
970
|
+
return opts;
|
|
766
971
|
}
|
|
767
972
|
/**
|
|
768
973
|
* Build where clause arrays for a query, combining partition key and optional conditions.
|
|
@@ -845,7 +1050,7 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
845
1050
|
if (comparator.comparison === ComparisonOperator.In) {
|
|
846
1051
|
const inValues = Is.array(comparator.value) ? comparator.value : [comparator.value];
|
|
847
1052
|
if (inValues.length === 0) {
|
|
848
|
-
// PostgreSQL rejects `IN ()` as a syntax error
|
|
1053
|
+
// PostgreSQL rejects `IN ()` as a syntax error - short-circuit to a condition
|
|
849
1054
|
// that is always false so the query returns zero rows cleanly (#141).
|
|
850
1055
|
return "1 = 0";
|
|
851
1056
|
}
|
|
@@ -853,7 +1058,7 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
853
1058
|
const placeholders = inValues.map((value, index) => `$${valueIndex + index}`).join(", ");
|
|
854
1059
|
return `"${prop}" IN (${placeholders})`;
|
|
855
1060
|
}
|
|
856
|
-
// null/undefined must use IS NULL / IS NOT NULL
|
|
1061
|
+
// null/undefined must use IS NULL / IS NOT NULL - never a parameterised placeholder.
|
|
857
1062
|
// Passing undefined through propertyToDbValue() coerces it to NaN for number fields
|
|
858
1063
|
// (Number(undefined) === NaN), and null coerces to 0 (Number(null) === 0), both of
|
|
859
1064
|
// which produce semantically wrong or invalid SQL.
|
|
@@ -1021,6 +1226,16 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
1021
1226
|
verifyConditions(conditions, obj) {
|
|
1022
1227
|
return conditions.every(condition => ObjectHelper.propertyGet(obj, condition.property) === condition.value);
|
|
1023
1228
|
}
|
|
1229
|
+
/**
|
|
1230
|
+
* Build a mutex key for optimistic-locking critical sections.
|
|
1231
|
+
* @param partitionKey The resolved partition key.
|
|
1232
|
+
* @param id The entity id.
|
|
1233
|
+
* @returns The mutex key.
|
|
1234
|
+
* @internal
|
|
1235
|
+
*/
|
|
1236
|
+
buildOptimisticMutexKey(partitionKey, id) {
|
|
1237
|
+
return `${PostgreSqlEntityStorageConnector.CLASS_NAME}:optimistic:${this._config.tableName}:${partitionKey ?? PostgreSqlEntityStorageConnector._PARTITION_KEY_VALUE}:${id}`;
|
|
1238
|
+
}
|
|
1024
1239
|
/**
|
|
1025
1240
|
* Map entity schema properties to SQL properties.
|
|
1026
1241
|
* @param entitySchema The schema of the entity.
|