@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.
@@ -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.
58
69
  * @internal
59
70
  */
60
- _connection;
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.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,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.createConnection();
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.createConnection();
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
- 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}`);
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
- const startIndex = Coerce.number(cursor) ?? 0;
597
- sql = `SELECT ${properties ? properties.map(p => `"${String(p)}"`).join(", ") : "*"} FROM "${this._config.tableName}"`;
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} OFFSET ${startIndex}`;
602
- const dbConnection = await this.createConnection();
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.createConnection();
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.createConnection();
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.createConnection();
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
- * Create a new DB connection.
745
- * @returns The PostgreSql connection.
919
+ * Retrieve (or lazily create) the shared postgres connection for this endpoint.
920
+ * @returns The shared connection.
746
921
  * @internal
747
922
  */
748
- async createConnection() {
749
- if (Is.empty(this._connection)) {
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
- return {
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 — short-circuit to a condition
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 — never a parameterised placeholder.
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.