@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.
@@ -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, HealthStatus, Is, ObjectHelper, Validation } from "@twin.org/core";
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
- * The configuration for the connector.
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
- _connection;
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.createConnection();
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
- * Get the health of the component.
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.createConnection();
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
- if (this._connection) {
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.createConnection();
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 (Is.arrayValue(conditions)) {
301
- const itemData = await this.get(id);
302
- if (Is.notEmpty(itemData) && !this.verifyConditions(conditions, itemData)) {
303
- return;
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
- sql += ` DO UPDATE SET ${keys.map(key => `"${key}" = EXCLUDED."${key}"`).join(", ")};`;
323
- const dbConnection = await this.createConnection();
324
- await dbConnection.unsafe(sql, values);
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 allValues = [];
355
- const rowPlaceholders = [];
356
- for (const prepared of preparedEntities) {
357
- const rowValues = [];
358
- for (const prop of props) {
359
- const val = prepared[prop.property];
360
- allValues.push(Is.empty(val) ? null : val);
361
- rowValues.push(`$${allValues.length}`);
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
- rowPlaceholders.push(`(${rowValues.join(", ")})`);
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.createConnection();
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.createConnection();
511
+ const dbConnection = await this.getClient();
408
512
  const itemData = await this.get(id);
409
- if (Is.notEmpty(itemData)) {
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.createConnection();
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.createConnection();
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.createConnection();
629
+ const dbConnection = await this.getClient();
504
630
  const rows = await dbConnection.unsafe(`SELECT DISTINCT "${PostgreSqlEntityStorageConnector._PARTITION_KEY}" FROM "${this._config.tableName}"`);
505
- return rows
631
+ const partitionIds = rows
506
632
  .map(row => row[PostgreSqlEntityStorageConnector._PARTITION_KEY])
507
- .filter((id) => Is.stringValue(id))
508
- .map(id => ContextIdHelper.shortSplit(this._partitionContextIds ?? [], id));
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.createConnection();
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
- let orderByClause = "";
587
- if (Is.arrayValue(sortProperties)) {
588
- const orderClauses = [];
589
- for (const sortProperty of sortProperties) {
590
- const direction = sortProperty.sortDirection === SortDirection.Ascending ? "ASC" : "DESC";
591
- orderClauses.push(`"${String(sortProperty.property)}" ${direction}`);
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
- orderByClause = `ORDER BY ${orderClauses.join(", ")}`;
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
- const startIndex = Coerce.number(cursor) ?? 0;
597
- sql = `SELECT ${properties ? properties.map(p => `"${String(p)}"`).join(", ") : "*"} FROM "${this._config.tableName}"`;
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} OFFSET ${startIndex}`;
602
- const dbConnection = await this.createConnection();
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.createConnection();
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.createConnection();
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.createConnection();
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
- * Create a new DB connection.
745
- * @returns The PostgreSql connection.
944
+ * Retrieve (or lazily create) the shared postgres connection for this endpoint.
945
+ * @returns The shared connection.
746
946
  * @internal
747
947
  */
748
- async createConnection() {
749
- if (Is.empty(this._connection)) {
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
- return {
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 — short-circuit to a condition
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 — never a parameterised placeholder.
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.