@atscript/db-postgres 0.1.152 → 0.1.154

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/index.cjs CHANGED
@@ -192,6 +192,7 @@ const pgDialect = {
192
192
  return zone === void 0 ? void 0 : (0, _atscript_db.bucketTimeZoneUnavailable)(`PostgreSQL does not recognize time zone "${zone}" — update the server's time zone data`);
193
193
  },
194
194
  nullsSortLargest: true,
195
+ nullsPlacementSyntax: true,
195
196
  createViewPrefix: "CREATE OR REPLACE VIEW",
196
197
  paramPlaceholder(index) {
197
198
  return `$${index}`;
@@ -570,6 +571,12 @@ function parseCount(value) {
570
571
  /** The suffix PostgreSQL gives an auto-named constraint of each `pg_constraint.contype`. */
571
572
  /** Savepoint guarding one `insertManyIgnore` chunk (see {@link PostgresAdapter.insertManyIgnore}). */
572
573
  const IGNORE_SAVEPOINT = "atscript_ignore_chunk";
574
+ /** Retryable concurrency SQLSTATEs (see `PostgresAdapter._mapConstraintError`). */
575
+ const PG_CONTENTION_CODES = {
576
+ "40P01": "DEADLOCK",
577
+ "55P03": "LOCK_TIMEOUT",
578
+ "40001": "SERIALIZATION_FAILURE"
579
+ };
573
580
  const PG_CONSTRAINT_LABELS = {
574
581
  p: "pkey",
575
582
  u: "key",
@@ -788,6 +795,10 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
788
795
  supportsAggregateExpressions() {
789
796
  return true;
790
797
  }
798
+ /** `$nulls` / `@db.sort.nulls` NULL placement — `NULLS FIRST` / `NULLS LAST`. */
799
+ supportsNullsPlacement() {
800
+ return true;
801
+ }
791
802
  /** Computed view columns and first-row joins. */
792
803
  viewCapabilities() {
793
804
  return _atscript_db.ALL_VIEW_CAPABILITIES;
@@ -865,6 +876,9 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
865
876
  * PostgreSQL uses SQLSTATE codes:
866
877
  * - 23505 = unique_violation
867
878
  * - 23503 = foreign_key_violation
879
+ * - 40P01 = deadlock_detected → `DEADLOCK`
880
+ * - 55P03 = lock_not_available (`lock_timeout`, `NOWAIT`) → `LOCK_TIMEOUT`
881
+ * - 40001 = serialization_failure → `SERIALIZATION_FAILURE`
868
882
  */
869
883
  async _wrapConstraintError(fn) {
870
884
  try {
@@ -873,7 +887,7 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
873
887
  return this._mapConstraintError(error);
874
888
  }
875
889
  }
876
- /** Rethrows `error` as a structured `DbError` when it is a unique / FK violation, else as is. */
890
+ /** Rethrows `error` as a structured `DbError` when it is a unique / FK violation or a deadlock / lock timeout, else as is. */
877
891
  _mapConstraintError(error) {
878
892
  if (error && typeof error === "object" && "code" in error) {
879
893
  const err = error;
@@ -882,6 +896,11 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
882
896
  message: err.detail ?? err.message
883
897
  }]);
884
898
  if (err.code === "23503") throw new _atscript_db.DbError("FK_VIOLATION", this._mapFkError(err.detail ?? err.message, err.constraint));
899
+ const contention = PG_CONTENTION_CODES[err.code];
900
+ if (contention) throw new _atscript_db.DbError(contention, [{
901
+ path: "",
902
+ message: err.message
903
+ }]);
885
904
  }
886
905
  throw error;
887
906
  }
@@ -959,9 +978,21 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
959
978
  * with the same `ON CONFLICT DO NOTHING`, so the result is always exact. A
960
979
  * one-row chunk (or a table without keys) maps exactly by construction, so
961
980
  * it needs no savepoint — and a single such chunk no transaction either.
981
+ * `ON CONFLICT DO NOTHING` takes no lock on a stored conflicting row, so a
982
+ * caller's transaction is left without any. `opts.lockConflicts` (since
983
+ * 0.1.153) locks them: one `SELECT … FOR UPDATE` of the stored rows matching
984
+ * the batch's keys, in primary-key order, BEFORE the insert, and one more
985
+ * for the skipped rows a concurrent writer committed in between.
962
986
  */
963
- async insertManyIgnore(data) {
987
+ async insertManyIgnore(data, opts) {
964
988
  if (data.length === 0) return [];
989
+ if (opts?.lockConflicts && this._table.uniqueKeySets.length > 0) return this.withTransaction(async () => {
990
+ const locked = await this._lockKeyedRows(data);
991
+ const slots = await this.insertManyIgnore(data);
992
+ const raced = data.filter((_, i) => slots[i] === null && !locked.has(i));
993
+ if (raced.length > 0) await this._lockKeyedRows(raced);
994
+ return slots;
995
+ });
965
996
  const plan = this._ignorePlan();
966
997
  const { columns, batches } = (0, _atscript_db_sql_tools.chunkInsertRows)(data);
967
998
  const needsSavepoint = (batch) => batch.length > 1 && plan.returning.length > 0;
@@ -998,6 +1029,52 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
998
1029
  return batches.length === 1 && !needsSavepoint(batches[0]) ? run() : this.withTransaction(run);
999
1030
  }
1000
1031
  /**
1032
+ * `SELECT <key columns> … FOR UPDATE` of the stored rows sharing a primary /
1033
+ * unique key tuple with one of `rows`, ordered by the primary key (so
1034
+ * concurrent lockers queue instead of deadlocking); PostgreSQL locks only
1035
+ * the rows returned. Returns the indices of `rows` a locked row matched.
1036
+ */
1037
+ async _lockKeyedRows(rows) {
1038
+ const keySets = this._table.uniqueKeySets.filter((f) => f.length > 0);
1039
+ const rowTuples = rows.map((row) => keySets.map((fields) => (0, _atscript_db.uniqueKeyTuple)(row, fields)));
1040
+ const order = (this._pk().cols.length > 0 ? this._pk().cols : keySets[0]).map((c) => qi(c)).join(", ");
1041
+ const selectCols = [...new Set(keySets.flat())].map((c) => qi(c)).join(", ");
1042
+ const lockedTuples = keySets.map(() => /* @__PURE__ */ new Set());
1043
+ const width = keySets.reduce((n, f) => n + f.length, 0);
1044
+ const sliceSize = Math.max(1, Math.floor(3e4 / width));
1045
+ for (let offset = 0; offset < rows.length; offset += sliceSize) {
1046
+ const end = Math.min(rows.length, offset + sliceSize);
1047
+ const clauses = [];
1048
+ const params = [];
1049
+ const ph = (value) => {
1050
+ params.push(pgDialect.toValue(value));
1051
+ return `$${params.length}`;
1052
+ };
1053
+ keySets.forEach((fields, k) => {
1054
+ const tuples = [];
1055
+ for (let i = offset; i < end; i++) {
1056
+ if (rowTuples[i][k] === void 0) continue;
1057
+ const row = rows[i];
1058
+ tuples.push(`(${fields.map((f) => ph(row[f])).join(", ")})`);
1059
+ }
1060
+ if (tuples.length > 0) clauses.push(`(${fields.map((f) => qi(f)).join(", ")}) IN (${tuples.join(", ")})`);
1061
+ });
1062
+ if (clauses.length === 0) continue;
1063
+ const sql = `SELECT ${selectCols} FROM ${quoteTableName(this.resolveTableName())} WHERE ${clauses.join(" OR ")} ORDER BY ${order} FOR UPDATE`;
1064
+ this._log(sql, params);
1065
+ const found = await this._wrapConstraintError(() => this._exec().all(sql, params));
1066
+ for (const doc of found) keySets.forEach((fields, k) => {
1067
+ const tuple = (0, _atscript_db.uniqueKeyTuple)(doc, fields);
1068
+ if (tuple !== void 0) lockedTuples[k].add(tuple);
1069
+ });
1070
+ }
1071
+ const locked = /* @__PURE__ */ new Set();
1072
+ rowTuples.forEach((tuples, i) => {
1073
+ if (tuples.some((t, k) => t !== void 0 && lockedTuples[k].has(t))) locked.add(i);
1074
+ });
1075
+ return locked;
1076
+ }
1077
+ /**
1001
1078
  * The key columns of a conflict-ignoring insert (built once per table):
1002
1079
  * every primary / unique key set, their RETURNING list, and the key columns
1003
1080
  * whose physical type is not text (see {@link mapIgnoredBatch}). Keyed on the
@@ -2284,6 +2361,10 @@ function createCustomTypes(pgTypes) {
2284
2361
  return pgTypes.getTypeParser(oid, format);
2285
2362
  } };
2286
2363
  }
2364
+ /** The warning logged for a pool client whose connection broke (`57P01`, `ECONNRESET`, …). */
2365
+ function lostConnectionMessage(err, where) {
2366
+ return `[atscript/db-postgres] ${where} pool connection lost (${err.code ?? err.name}): ${err.message}`;
2367
+ }
2287
2368
  /**
2288
2369
  * {@link TPgDriver} implementation backed by `pg` (node-postgres).
2289
2370
  *
@@ -2310,6 +2391,12 @@ function createCustomTypes(pgTypes) {
2310
2391
  * const driver = new PgDriver(pool)
2311
2392
  * ```
2312
2393
  *
2394
+ * A broken pool connection (a terminated backend, failover, network reset) is
2395
+ * logged through `options.logger` instead of crashing the process (since
2396
+ * 0.1.154): idle clients of a pool the driver creates, and clients checked out
2397
+ * for a transaction. A pre-created `pg.Pool` is the caller's — attach your own
2398
+ * `pool.on('error', …)` listener, or an idle client's error crashes the process.
2399
+ *
2313
2400
  * Requires `pg` to be installed:
2314
2401
  * ```bash
2315
2402
  * pnpm add pg
@@ -2318,7 +2405,9 @@ function createCustomTypes(pgTypes) {
2318
2405
  var PgDriver = class {
2319
2406
  pool;
2320
2407
  poolInit;
2321
- constructor(poolOrConfig) {
2408
+ logger;
2409
+ constructor(poolOrConfig, options = {}) {
2410
+ this.logger = options.logger ?? console;
2322
2411
  if (typeof poolOrConfig === "object" && typeof poolOrConfig.query === "function") this.pool = poolOrConfig;
2323
2412
  else this.poolInit = import("pg").then((pg) => {
2324
2413
  const Pool = pg.default?.Pool ?? pg.Pool;
@@ -2332,6 +2421,7 @@ var PgDriver = class {
2332
2421
  ...poolOrConfig,
2333
2422
  types: customTypes
2334
2423
  });
2424
+ this.pool.on("error", (err) => this.logger.warn(lostConnectionMessage(err, "idle")));
2335
2425
  return this.pool;
2336
2426
  });
2337
2427
  }
@@ -2356,6 +2446,13 @@ var PgDriver = class {
2356
2446
  }
2357
2447
  async getConnection() {
2358
2448
  const client = await (await this.getPool()).connect();
2449
+ let lost;
2450
+ const onError = (err) => {
2451
+ if (lost) return;
2452
+ lost = err;
2453
+ this.logger.warn(lostConnectionMessage(err, "checked-out"));
2454
+ };
2455
+ client.on("error", onError);
2359
2456
  return {
2360
2457
  async run(sql, params) {
2361
2458
  const result = await client.query(sql, sanitizeParams(params));
@@ -2374,7 +2471,8 @@ var PgDriver = class {
2374
2471
  await client.query(sql);
2375
2472
  },
2376
2473
  release() {
2377
- client.release();
2474
+ client.removeListener("error", onError);
2475
+ client.release(lost);
2378
2476
  }
2379
2477
  };
2380
2478
  }
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteResult, TDbFieldMeta, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateOptions, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TFieldOps, TPrimaryKeyChange, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TValueFormatterPair, TViewCapability } from "@atscript/db";
1
+ import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteResult, TDbFieldMeta, TDbInsertIgnoreOptions, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateOptions, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TFieldOps, TGenericLogger, TPrimaryKeyChange, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TValueFormatterPair, TViewCapability } from "@atscript/db";
2
2
  import { TMetadataMap } from "@atscript/typescript/utils";
3
3
  import { TFilterVisitorOptions, TSqlFragment } from "@atscript/db-sql-tools";
4
4
 
@@ -176,6 +176,8 @@ declare class PostgresAdapter extends BaseDbAdapter {
176
176
  aggregateFns(): ReadonlySet<AggregateFn>;
177
177
  /** Arithmetic in an aggregate `$select` (`{ $expr }`, `{ $fn, $expr }`). */
178
178
  supportsAggregateExpressions(): boolean;
179
+ /** `$nulls` / `@db.sort.nulls` NULL placement — `NULLS FIRST` / `NULLS LAST`. */
180
+ supportsNullsPlacement(): boolean;
179
181
  /** Computed view columns and first-row joins. */
180
182
  viewCapabilities(): ReadonlySet<TViewCapability>;
181
183
  onBeforeFlatten(_type: unknown): void;
@@ -196,9 +198,12 @@ declare class PostgresAdapter extends BaseDbAdapter {
196
198
  * PostgreSQL uses SQLSTATE codes:
197
199
  * - 23505 = unique_violation
198
200
  * - 23503 = foreign_key_violation
201
+ * - 40P01 = deadlock_detected → `DEADLOCK`
202
+ * - 55P03 = lock_not_available (`lock_timeout`, `NOWAIT`) → `LOCK_TIMEOUT`
203
+ * - 40001 = serialization_failure → `SERIALIZATION_FAILURE`
199
204
  */
200
205
  private _wrapConstraintError;
201
- /** Rethrows `error` as a structured `DbError` when it is a unique / FK violation, else as is. */
206
+ /** Rethrows `error` as a structured `DbError` when it is a unique / FK violation or a deadlock / lock timeout, else as is. */
202
207
  private _mapConstraintError;
203
208
  private _extractFieldFromConstraint;
204
209
  private _mapFkError;
@@ -216,8 +221,20 @@ declare class PostgresAdapter extends BaseDbAdapter {
216
221
  * with the same `ON CONFLICT DO NOTHING`, so the result is always exact. A
217
222
  * one-row chunk (or a table without keys) maps exactly by construction, so
218
223
  * it needs no savepoint — and a single such chunk no transaction either.
224
+ * `ON CONFLICT DO NOTHING` takes no lock on a stored conflicting row, so a
225
+ * caller's transaction is left without any. `opts.lockConflicts` (since
226
+ * 0.1.153) locks them: one `SELECT … FOR UPDATE` of the stored rows matching
227
+ * the batch's keys, in primary-key order, BEFORE the insert, and one more
228
+ * for the skipped rows a concurrent writer committed in between.
219
229
  */
220
- insertManyIgnore(data: Array<Record<string, unknown>>): Promise<TDbInsertIgnoreSlot[]>;
230
+ insertManyIgnore(data: Array<Record<string, unknown>>, opts?: TDbInsertIgnoreOptions): Promise<TDbInsertIgnoreSlot[]>;
231
+ /**
232
+ * `SELECT <key columns> … FOR UPDATE` of the stored rows sharing a primary /
233
+ * unique key tuple with one of `rows`, ordered by the primary key (so
234
+ * concurrent lockers queue instead of deadlocking); PostgreSQL locks only
235
+ * the rows returned. Returns the indices of `rows` a locked row matched.
236
+ */
237
+ private _lockKeyedRows;
221
238
  /**
222
239
  * The key columns of a conflict-ignoring insert (built once per table):
223
240
  * every primary / unique key set, their RETURNING list, and the key columns
@@ -425,6 +442,14 @@ declare class PostgresAdapter extends BaseDbAdapter {
425
442
  }
426
443
  //#endregion
427
444
  //#region src/pg-driver.d.ts
445
+ /** Options of {@link PgDriver}. */
446
+ interface TPgDriverOptions {
447
+ /**
448
+ * Receives a warning when a pool connection breaks — a terminated backend,
449
+ * failover or network reset (default `console`). See {@link PgDriver}.
450
+ */
451
+ logger?: Pick<TGenericLogger, "warn">;
452
+ }
428
453
  /**
429
454
  * {@link TPgDriver} implementation backed by `pg` (node-postgres).
430
455
  *
@@ -451,6 +476,12 @@ declare class PostgresAdapter extends BaseDbAdapter {
451
476
  * const driver = new PgDriver(pool)
452
477
  * ```
453
478
  *
479
+ * A broken pool connection (a terminated backend, failover, network reset) is
480
+ * logged through `options.logger` instead of crashing the process (since
481
+ * 0.1.154): idle clients of a pool the driver creates, and clients checked out
482
+ * for a transaction. A pre-created `pg.Pool` is the caller's — attach your own
483
+ * `pool.on('error', …)` listener, or an idle client's error crashes the process.
484
+ *
454
485
  * Requires `pg` to be installed:
455
486
  * ```bash
456
487
  * pnpm add pg
@@ -459,7 +490,8 @@ declare class PostgresAdapter extends BaseDbAdapter {
459
490
  declare class PgDriver implements TPgDriver {
460
491
  private pool;
461
492
  private poolInit;
462
- constructor(poolOrConfig: string | import("pg").Pool | import("pg").PoolConfig);
493
+ private readonly logger;
494
+ constructor(poolOrConfig: string | import("pg").Pool | import("pg").PoolConfig, options?: TPgDriverOptions);
463
495
  private getPool;
464
496
  run(sql: string, params?: unknown[]): Promise<TPgRunResult>;
465
497
  all<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
@@ -500,4 +532,4 @@ declare function buildWhere(filter: FilterExpr, opts?: TFilterVisitorOptions): T
500
532
  */
501
533
  declare function createAdapter(uri: string, options?: Record<string, unknown>): DbSpace;
502
534
  //#endregion
503
- export { PgDriver, PostgresAdapter, type TPgConnection, type TPgDriver, type TPgRunResult, type TSqlFragment, buildWhere, createAdapter };
535
+ export { PgDriver, PostgresAdapter, type TPgConnection, type TPgDriver, type TPgDriverOptions, type TPgRunResult, type TSqlFragment, buildWhere, createAdapter };
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteResult, TDbFieldMeta, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateOptions, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TFieldOps, TPrimaryKeyChange, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TValueFormatterPair, TViewCapability } from "@atscript/db";
1
+ import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteResult, TDbFieldMeta, TDbInsertIgnoreOptions, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateOptions, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TFieldOps, TGenericLogger, TPrimaryKeyChange, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TValueFormatterPair, TViewCapability } from "@atscript/db";
2
2
  import { TFilterVisitorOptions, TSqlFragment } from "@atscript/db-sql-tools";
3
3
  import { TMetadataMap } from "@atscript/typescript/utils";
4
4
 
@@ -176,6 +176,8 @@ declare class PostgresAdapter extends BaseDbAdapter {
176
176
  aggregateFns(): ReadonlySet<AggregateFn>;
177
177
  /** Arithmetic in an aggregate `$select` (`{ $expr }`, `{ $fn, $expr }`). */
178
178
  supportsAggregateExpressions(): boolean;
179
+ /** `$nulls` / `@db.sort.nulls` NULL placement — `NULLS FIRST` / `NULLS LAST`. */
180
+ supportsNullsPlacement(): boolean;
179
181
  /** Computed view columns and first-row joins. */
180
182
  viewCapabilities(): ReadonlySet<TViewCapability>;
181
183
  onBeforeFlatten(_type: unknown): void;
@@ -196,9 +198,12 @@ declare class PostgresAdapter extends BaseDbAdapter {
196
198
  * PostgreSQL uses SQLSTATE codes:
197
199
  * - 23505 = unique_violation
198
200
  * - 23503 = foreign_key_violation
201
+ * - 40P01 = deadlock_detected → `DEADLOCK`
202
+ * - 55P03 = lock_not_available (`lock_timeout`, `NOWAIT`) → `LOCK_TIMEOUT`
203
+ * - 40001 = serialization_failure → `SERIALIZATION_FAILURE`
199
204
  */
200
205
  private _wrapConstraintError;
201
- /** Rethrows `error` as a structured `DbError` when it is a unique / FK violation, else as is. */
206
+ /** Rethrows `error` as a structured `DbError` when it is a unique / FK violation or a deadlock / lock timeout, else as is. */
202
207
  private _mapConstraintError;
203
208
  private _extractFieldFromConstraint;
204
209
  private _mapFkError;
@@ -216,8 +221,20 @@ declare class PostgresAdapter extends BaseDbAdapter {
216
221
  * with the same `ON CONFLICT DO NOTHING`, so the result is always exact. A
217
222
  * one-row chunk (or a table without keys) maps exactly by construction, so
218
223
  * it needs no savepoint — and a single such chunk no transaction either.
224
+ * `ON CONFLICT DO NOTHING` takes no lock on a stored conflicting row, so a
225
+ * caller's transaction is left without any. `opts.lockConflicts` (since
226
+ * 0.1.153) locks them: one `SELECT … FOR UPDATE` of the stored rows matching
227
+ * the batch's keys, in primary-key order, BEFORE the insert, and one more
228
+ * for the skipped rows a concurrent writer committed in between.
219
229
  */
220
- insertManyIgnore(data: Array<Record<string, unknown>>): Promise<TDbInsertIgnoreSlot[]>;
230
+ insertManyIgnore(data: Array<Record<string, unknown>>, opts?: TDbInsertIgnoreOptions): Promise<TDbInsertIgnoreSlot[]>;
231
+ /**
232
+ * `SELECT <key columns> … FOR UPDATE` of the stored rows sharing a primary /
233
+ * unique key tuple with one of `rows`, ordered by the primary key (so
234
+ * concurrent lockers queue instead of deadlocking); PostgreSQL locks only
235
+ * the rows returned. Returns the indices of `rows` a locked row matched.
236
+ */
237
+ private _lockKeyedRows;
221
238
  /**
222
239
  * The key columns of a conflict-ignoring insert (built once per table):
223
240
  * every primary / unique key set, their RETURNING list, and the key columns
@@ -425,6 +442,14 @@ declare class PostgresAdapter extends BaseDbAdapter {
425
442
  }
426
443
  //#endregion
427
444
  //#region src/pg-driver.d.ts
445
+ /** Options of {@link PgDriver}. */
446
+ interface TPgDriverOptions {
447
+ /**
448
+ * Receives a warning when a pool connection breaks — a terminated backend,
449
+ * failover or network reset (default `console`). See {@link PgDriver}.
450
+ */
451
+ logger?: Pick<TGenericLogger, "warn">;
452
+ }
428
453
  /**
429
454
  * {@link TPgDriver} implementation backed by `pg` (node-postgres).
430
455
  *
@@ -451,6 +476,12 @@ declare class PostgresAdapter extends BaseDbAdapter {
451
476
  * const driver = new PgDriver(pool)
452
477
  * ```
453
478
  *
479
+ * A broken pool connection (a terminated backend, failover, network reset) is
480
+ * logged through `options.logger` instead of crashing the process (since
481
+ * 0.1.154): idle clients of a pool the driver creates, and clients checked out
482
+ * for a transaction. A pre-created `pg.Pool` is the caller's — attach your own
483
+ * `pool.on('error', …)` listener, or an idle client's error crashes the process.
484
+ *
454
485
  * Requires `pg` to be installed:
455
486
  * ```bash
456
487
  * pnpm add pg
@@ -459,7 +490,8 @@ declare class PostgresAdapter extends BaseDbAdapter {
459
490
  declare class PgDriver implements TPgDriver {
460
491
  private pool;
461
492
  private poolInit;
462
- constructor(poolOrConfig: string | import("pg").Pool | import("pg").PoolConfig);
493
+ private readonly logger;
494
+ constructor(poolOrConfig: string | import("pg").Pool | import("pg").PoolConfig, options?: TPgDriverOptions);
463
495
  private getPool;
464
496
  run(sql: string, params?: unknown[]): Promise<TPgRunResult>;
465
497
  all<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
@@ -500,4 +532,4 @@ declare function buildWhere(filter: FilterExpr, opts?: TFilterVisitorOptions): T
500
532
  */
501
533
  declare function createAdapter(uri: string, options?: Record<string, unknown>): DbSpace;
502
534
  //#endregion
503
- export { PgDriver, PostgresAdapter, type TPgConnection, type TPgDriver, type TPgRunResult, type TSqlFragment, buildWhere, createAdapter };
535
+ export { PgDriver, PostgresAdapter, type TPgConnection, type TPgDriver, type TPgDriverOptions, type TPgRunResult, type TSqlFragment, buildWhere, createAdapter };
package/dist/index.mjs CHANGED
@@ -191,6 +191,7 @@ const pgDialect = {
191
191
  return zone === void 0 ? void 0 : bucketTimeZoneUnavailable(`PostgreSQL does not recognize time zone "${zone}" — update the server's time zone data`);
192
192
  },
193
193
  nullsSortLargest: true,
194
+ nullsPlacementSyntax: true,
194
195
  createViewPrefix: "CREATE OR REPLACE VIEW",
195
196
  paramPlaceholder(index) {
196
197
  return `$${index}`;
@@ -569,6 +570,12 @@ function parseCount(value) {
569
570
  /** The suffix PostgreSQL gives an auto-named constraint of each `pg_constraint.contype`. */
570
571
  /** Savepoint guarding one `insertManyIgnore` chunk (see {@link PostgresAdapter.insertManyIgnore}). */
571
572
  const IGNORE_SAVEPOINT = "atscript_ignore_chunk";
573
+ /** Retryable concurrency SQLSTATEs (see `PostgresAdapter._mapConstraintError`). */
574
+ const PG_CONTENTION_CODES = {
575
+ "40P01": "DEADLOCK",
576
+ "55P03": "LOCK_TIMEOUT",
577
+ "40001": "SERIALIZATION_FAILURE"
578
+ };
572
579
  const PG_CONSTRAINT_LABELS = {
573
580
  p: "pkey",
574
581
  u: "key",
@@ -787,6 +794,10 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
787
794
  supportsAggregateExpressions() {
788
795
  return true;
789
796
  }
797
+ /** `$nulls` / `@db.sort.nulls` NULL placement — `NULLS FIRST` / `NULLS LAST`. */
798
+ supportsNullsPlacement() {
799
+ return true;
800
+ }
790
801
  /** Computed view columns and first-row joins. */
791
802
  viewCapabilities() {
792
803
  return ALL_VIEW_CAPABILITIES;
@@ -864,6 +875,9 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
864
875
  * PostgreSQL uses SQLSTATE codes:
865
876
  * - 23505 = unique_violation
866
877
  * - 23503 = foreign_key_violation
878
+ * - 40P01 = deadlock_detected → `DEADLOCK`
879
+ * - 55P03 = lock_not_available (`lock_timeout`, `NOWAIT`) → `LOCK_TIMEOUT`
880
+ * - 40001 = serialization_failure → `SERIALIZATION_FAILURE`
867
881
  */
868
882
  async _wrapConstraintError(fn) {
869
883
  try {
@@ -872,7 +886,7 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
872
886
  return this._mapConstraintError(error);
873
887
  }
874
888
  }
875
- /** Rethrows `error` as a structured `DbError` when it is a unique / FK violation, else as is. */
889
+ /** Rethrows `error` as a structured `DbError` when it is a unique / FK violation or a deadlock / lock timeout, else as is. */
876
890
  _mapConstraintError(error) {
877
891
  if (error && typeof error === "object" && "code" in error) {
878
892
  const err = error;
@@ -881,6 +895,11 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
881
895
  message: err.detail ?? err.message
882
896
  }]);
883
897
  if (err.code === "23503") throw new DbError("FK_VIOLATION", this._mapFkError(err.detail ?? err.message, err.constraint));
898
+ const contention = PG_CONTENTION_CODES[err.code];
899
+ if (contention) throw new DbError(contention, [{
900
+ path: "",
901
+ message: err.message
902
+ }]);
884
903
  }
885
904
  throw error;
886
905
  }
@@ -958,9 +977,21 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
958
977
  * with the same `ON CONFLICT DO NOTHING`, so the result is always exact. A
959
978
  * one-row chunk (or a table without keys) maps exactly by construction, so
960
979
  * it needs no savepoint — and a single such chunk no transaction either.
980
+ * `ON CONFLICT DO NOTHING` takes no lock on a stored conflicting row, so a
981
+ * caller's transaction is left without any. `opts.lockConflicts` (since
982
+ * 0.1.153) locks them: one `SELECT … FOR UPDATE` of the stored rows matching
983
+ * the batch's keys, in primary-key order, BEFORE the insert, and one more
984
+ * for the skipped rows a concurrent writer committed in between.
961
985
  */
962
- async insertManyIgnore(data) {
986
+ async insertManyIgnore(data, opts) {
963
987
  if (data.length === 0) return [];
988
+ if (opts?.lockConflicts && this._table.uniqueKeySets.length > 0) return this.withTransaction(async () => {
989
+ const locked = await this._lockKeyedRows(data);
990
+ const slots = await this.insertManyIgnore(data);
991
+ const raced = data.filter((_, i) => slots[i] === null && !locked.has(i));
992
+ if (raced.length > 0) await this._lockKeyedRows(raced);
993
+ return slots;
994
+ });
964
995
  const plan = this._ignorePlan();
965
996
  const { columns, batches } = chunkInsertRows(data);
966
997
  const needsSavepoint = (batch) => batch.length > 1 && plan.returning.length > 0;
@@ -997,6 +1028,52 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
997
1028
  return batches.length === 1 && !needsSavepoint(batches[0]) ? run() : this.withTransaction(run);
998
1029
  }
999
1030
  /**
1031
+ * `SELECT <key columns> … FOR UPDATE` of the stored rows sharing a primary /
1032
+ * unique key tuple with one of `rows`, ordered by the primary key (so
1033
+ * concurrent lockers queue instead of deadlocking); PostgreSQL locks only
1034
+ * the rows returned. Returns the indices of `rows` a locked row matched.
1035
+ */
1036
+ async _lockKeyedRows(rows) {
1037
+ const keySets = this._table.uniqueKeySets.filter((f) => f.length > 0);
1038
+ const rowTuples = rows.map((row) => keySets.map((fields) => uniqueKeyTuple(row, fields)));
1039
+ const order = (this._pk().cols.length > 0 ? this._pk().cols : keySets[0]).map((c) => qi(c)).join(", ");
1040
+ const selectCols = [...new Set(keySets.flat())].map((c) => qi(c)).join(", ");
1041
+ const lockedTuples = keySets.map(() => /* @__PURE__ */ new Set());
1042
+ const width = keySets.reduce((n, f) => n + f.length, 0);
1043
+ const sliceSize = Math.max(1, Math.floor(3e4 / width));
1044
+ for (let offset = 0; offset < rows.length; offset += sliceSize) {
1045
+ const end = Math.min(rows.length, offset + sliceSize);
1046
+ const clauses = [];
1047
+ const params = [];
1048
+ const ph = (value) => {
1049
+ params.push(pgDialect.toValue(value));
1050
+ return `$${params.length}`;
1051
+ };
1052
+ keySets.forEach((fields, k) => {
1053
+ const tuples = [];
1054
+ for (let i = offset; i < end; i++) {
1055
+ if (rowTuples[i][k] === void 0) continue;
1056
+ const row = rows[i];
1057
+ tuples.push(`(${fields.map((f) => ph(row[f])).join(", ")})`);
1058
+ }
1059
+ if (tuples.length > 0) clauses.push(`(${fields.map((f) => qi(f)).join(", ")}) IN (${tuples.join(", ")})`);
1060
+ });
1061
+ if (clauses.length === 0) continue;
1062
+ const sql = `SELECT ${selectCols} FROM ${quoteTableName(this.resolveTableName())} WHERE ${clauses.join(" OR ")} ORDER BY ${order} FOR UPDATE`;
1063
+ this._log(sql, params);
1064
+ const found = await this._wrapConstraintError(() => this._exec().all(sql, params));
1065
+ for (const doc of found) keySets.forEach((fields, k) => {
1066
+ const tuple = uniqueKeyTuple(doc, fields);
1067
+ if (tuple !== void 0) lockedTuples[k].add(tuple);
1068
+ });
1069
+ }
1070
+ const locked = /* @__PURE__ */ new Set();
1071
+ rowTuples.forEach((tuples, i) => {
1072
+ if (tuples.some((t, k) => t !== void 0 && lockedTuples[k].has(t))) locked.add(i);
1073
+ });
1074
+ return locked;
1075
+ }
1076
+ /**
1000
1077
  * The key columns of a conflict-ignoring insert (built once per table):
1001
1078
  * every primary / unique key set, their RETURNING list, and the key columns
1002
1079
  * whose physical type is not text (see {@link mapIgnoredBatch}). Keyed on the
@@ -2283,6 +2360,10 @@ function createCustomTypes(pgTypes) {
2283
2360
  return pgTypes.getTypeParser(oid, format);
2284
2361
  } };
2285
2362
  }
2363
+ /** The warning logged for a pool client whose connection broke (`57P01`, `ECONNRESET`, …). */
2364
+ function lostConnectionMessage(err, where) {
2365
+ return `[atscript/db-postgres] ${where} pool connection lost (${err.code ?? err.name}): ${err.message}`;
2366
+ }
2286
2367
  /**
2287
2368
  * {@link TPgDriver} implementation backed by `pg` (node-postgres).
2288
2369
  *
@@ -2309,6 +2390,12 @@ function createCustomTypes(pgTypes) {
2309
2390
  * const driver = new PgDriver(pool)
2310
2391
  * ```
2311
2392
  *
2393
+ * A broken pool connection (a terminated backend, failover, network reset) is
2394
+ * logged through `options.logger` instead of crashing the process (since
2395
+ * 0.1.154): idle clients of a pool the driver creates, and clients checked out
2396
+ * for a transaction. A pre-created `pg.Pool` is the caller's — attach your own
2397
+ * `pool.on('error', …)` listener, or an idle client's error crashes the process.
2398
+ *
2312
2399
  * Requires `pg` to be installed:
2313
2400
  * ```bash
2314
2401
  * pnpm add pg
@@ -2317,7 +2404,9 @@ function createCustomTypes(pgTypes) {
2317
2404
  var PgDriver = class {
2318
2405
  pool;
2319
2406
  poolInit;
2320
- constructor(poolOrConfig) {
2407
+ logger;
2408
+ constructor(poolOrConfig, options = {}) {
2409
+ this.logger = options.logger ?? console;
2321
2410
  if (typeof poolOrConfig === "object" && typeof poolOrConfig.query === "function") this.pool = poolOrConfig;
2322
2411
  else this.poolInit = import("pg").then((pg) => {
2323
2412
  const Pool = pg.default?.Pool ?? pg.Pool;
@@ -2331,6 +2420,7 @@ var PgDriver = class {
2331
2420
  ...poolOrConfig,
2332
2421
  types: customTypes
2333
2422
  });
2423
+ this.pool.on("error", (err) => this.logger.warn(lostConnectionMessage(err, "idle")));
2334
2424
  return this.pool;
2335
2425
  });
2336
2426
  }
@@ -2355,6 +2445,13 @@ var PgDriver = class {
2355
2445
  }
2356
2446
  async getConnection() {
2357
2447
  const client = await (await this.getPool()).connect();
2448
+ let lost;
2449
+ const onError = (err) => {
2450
+ if (lost) return;
2451
+ lost = err;
2452
+ this.logger.warn(lostConnectionMessage(err, "checked-out"));
2453
+ };
2454
+ client.on("error", onError);
2358
2455
  return {
2359
2456
  async run(sql, params) {
2360
2457
  const result = await client.query(sql, sanitizeParams(params));
@@ -2373,7 +2470,8 @@ var PgDriver = class {
2373
2470
  await client.query(sql);
2374
2471
  },
2375
2472
  release() {
2376
- client.release();
2473
+ client.removeListener("error", onError);
2474
+ client.release(lost);
2377
2475
  }
2378
2476
  };
2379
2477
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/db-postgres",
3
- "version": "0.1.152",
3
+ "version": "0.1.154",
4
4
  "description": "PostgreSQL adapter for @atscript/db with pg (node-postgres) driver support.",
5
5
  "keywords": [
6
6
  "atscript",
@@ -43,20 +43,20 @@
43
43
  "access": "public"
44
44
  },
45
45
  "devDependencies": {
46
- "@atscript/core": "^0.1.102",
47
- "@atscript/typescript": "^0.1.102",
46
+ "@atscript/core": "^0.1.103",
47
+ "@atscript/typescript": "^0.1.103",
48
48
  "@types/pg": "^8.11.0",
49
- "@uniqu/core": "^0.1.14",
49
+ "@uniqu/core": "^0.1.15",
50
50
  "pg": "^8.13.0",
51
- "unplugin-atscript": "^0.1.102"
51
+ "unplugin-atscript": "^0.1.103"
52
52
  },
53
53
  "peerDependencies": {
54
- "@atscript/core": "^0.1.102",
55
- "@atscript/typescript": "^0.1.102",
56
- "@uniqu/core": "^0.1.14",
54
+ "@atscript/core": "^0.1.103",
55
+ "@atscript/typescript": "^0.1.103",
56
+ "@uniqu/core": "^0.1.15",
57
57
  "pg": ">=8.0.0",
58
- "@atscript/db": "^0.1.152",
59
- "@atscript/db-sql-tools": "^0.1.152"
58
+ "@atscript/db": "^0.1.154",
59
+ "@atscript/db-sql-tools": "^0.1.154"
60
60
  },
61
61
  "peerDependenciesMeta": {
62
62
  "pg": {