@atscript/db-postgres 0.1.153 → 0.1.155

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
@@ -138,6 +138,12 @@ function toPgValue(value) {
138
138
  if (typeof value === "object") return JSON.stringify(value);
139
139
  return value;
140
140
  }
141
+ /**
142
+ * Text a JSON column can take as it is (`SqlDialect.jsonFromText`): an
143
+ * object / array (by its first characters), or a whole JSON string, number,
144
+ * `true`, `false` or `null` — so `42 Main St` is no number.
145
+ */
146
+ const PG_JSON_LIKE = String.raw`^\s*(\{\s*["}]|\[\s*(["{[\]tfn0-9-])|("([^"\\]|\\.)*"|-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?|true|false|null)\s*$)`;
141
147
  const pgDialect = {
142
148
  quoteIdentifier(name) {
143
149
  return qi(name);
@@ -171,6 +177,9 @@ const pgDialect = {
171
177
  },
172
178
  calendarBucket: pgCalendarBucket,
173
179
  jsonExtract: pgJsonExtract,
180
+ jsonExtractText: (quotedCol, path) => `((${quotedCol})::jsonb #>> ${pgJsonPath(path)})`,
181
+ jsonExtractValue: (quotedCol, path) => `NULLIF((${quotedCol})::jsonb #> ${pgJsonPath(path)}, 'null'::jsonb)`,
182
+ jsonFromText: (quotedCol) => `CASE WHEN ${quotedCol} ~ '${PG_JSON_LIKE}' THEN ${quotedCol} ELSE to_jsonb(${quotedCol})::text END`,
174
183
  castDouble: (expr) => `CAST(${expr} AS DOUBLE PRECISION)`,
175
184
  anyValue: pgAnyValue,
176
185
  booleanAggregates: {
@@ -204,6 +213,10 @@ const PG_JSON_LEAF_CAST = {
204
213
  number: "::double precision",
205
214
  boolean: "::boolean"
206
215
  };
216
+ /** A `#>` / `#>>` path literal (`'{"a","b"}'`). */
217
+ function pgJsonPath(path) {
218
+ return (0, _atscript_db_sql_tools.sqlStringLiteral)(`{${(0, _atscript_db_sql_tools.quotedJsonPathSegments)(path).join(",")}}`);
219
+ }
207
220
  /**
208
221
  * Typed read of a JSON leaf (`SqlDialect.jsonExtract`): `jsonb_typeof()`
209
222
  * guards the declared type, so a missing path, JSON `null` or another JSON
@@ -211,7 +224,7 @@ const PG_JSON_LEAF_CAST = {
211
224
  * native. The column is cast `::jsonb`, so JSON and JSONB storage both work.
212
225
  */
213
226
  function pgJsonExtract(quotedCol, path, type) {
214
- const p = (0, _atscript_db_sql_tools.sqlStringLiteral)(`{${(0, _atscript_db_sql_tools.quotedJsonPathSegments)(path).join(",")}}`);
227
+ const p = pgJsonPath(path);
215
228
  const j = `(${quotedCol})::jsonb`;
216
229
  const cast = PG_JSON_LEAF_CAST[type];
217
230
  return `CASE jsonb_typeof(${j} #> ${p}) WHEN '${type}' THEN ${cast ? `(${j} #>> ${p})${cast}` : `${j} #>> ${p}`} END`;
@@ -513,6 +526,13 @@ const GEO_PROBE_SQL = `SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname =
513
526
  LEFT JOIN pg_type b ON b.oid = NULLIF(t.typbasetype, 0)
514
527
  WHERE a.attrelid = to_regclass($1::text) AND a.attnum > 0 AND NOT a.attisdropped
515
528
  AND a.attname = ANY($2::text[])) AS "columns"`;
529
+ const identity = (value) => value;
530
+ /** A column type node-postgres parses on read. */
531
+ const PG_JSON_TYPE = /^\s*jsonb?\s*$/i;
532
+ /** A JSON string value as JSON text — see `formatValue`. */
533
+ function reencodeJsonString(value) {
534
+ return typeof value === "string" ? JSON.stringify(value) : value;
535
+ }
516
536
  /** A PostGIS column type — a geo value binds as EWKT. Anything else (JSONB, TEXT) takes the JSON form. */
517
537
  const POSTGIS_TYPES = new Set(["geography", "geometry"]);
518
538
  /** The SQL `$geoWithin` renders (`pgDialect.geoWithin`) — PostGIS only. */
@@ -868,6 +888,10 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
868
888
  }
869
889
  };
870
890
  }
891
+ if (field.storage === "json" && !field.encrypted && PG_JSON_TYPE.test(this.typeMapper(field))) return {
892
+ toStorage: identity,
893
+ fromStorage: reencodeJsonString
894
+ };
871
895
  }
872
896
  /**
873
897
  * Wraps an async write operation to catch PostgreSQL constraint errors
@@ -1305,6 +1329,12 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1305
1329
  this._log(sql);
1306
1330
  return (await this._exec().get(sql, []))?.present ?? false;
1307
1331
  }
1332
+ async countKeyViolations(columns, tableName) {
1333
+ const sql = (0, _atscript_db_sql_tools.buildKeyViolationCount)(pgDialect, tableName ? this._qualify(tableName) : quoteTableName(this.resolveTableName()), columns);
1334
+ this._log(sql);
1335
+ const row = await this._exec().get(sql, []);
1336
+ return Number(row?.violations ?? 0);
1337
+ }
1308
1338
  /**
1309
1339
  * Live foreign keys referencing `tableName`, via `pg_constraint` — exact for
1310
1340
  * composite keys (`conkey`/`confkey` are positionally aligned, unlike the
@@ -1350,8 +1380,9 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1350
1380
  }
1351
1381
  /**
1352
1382
  * `ALTER TABLE … DROP CONSTRAINT <pk>, ADD PRIMARY KEY (…)` in ONE statement
1353
- * (atomic). Called on an empty table only; `ADD PRIMARY KEY` makes the new
1354
- * key columns NOT NULL. A demoted identity column is handled by the
1383
+ * (atomic: rows a concurrent write made violate the new key fail it with
1384
+ * the old key in place). Called on an empty table or one whose rows satisfy
1385
+ * the new key; `ADD PRIMARY KEY` makes the new key columns NOT NULL. A demoted identity column is handled by the
1355
1386
  * `defaultChanged` diff (`DROP IDENTITY`).
1356
1387
  */
1357
1388
  async rebuildPrimaryKey(change) {
@@ -1445,10 +1476,10 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1445
1476
  this._log(ddl);
1446
1477
  await this._exec().exec(ddl);
1447
1478
  }
1448
- for (const { field } of diff.typeChanged ?? []) {
1479
+ for (const { field, existingType } of diff.typeChanged ?? []) {
1449
1480
  const sqlType = this.typeMapper(field);
1450
1481
  const col = qi(field.physicalName);
1451
- const ddl = `ALTER TABLE ${quoteTableName(tableName)} ALTER COLUMN ${col} TYPE ${sqlType} USING ${convertColumnExpr(col, field, sqlType)}`;
1482
+ const ddl = `ALTER TABLE ${quoteTableName(tableName)} ALTER COLUMN ${col} TYPE ${sqlType} USING ${convertColumnExpr(col, field, sqlType, existingType)}`;
1452
1483
  this._log(ddl);
1453
1484
  await this._exec().exec(ddl);
1454
1485
  }
@@ -1556,8 +1587,9 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1556
1587
  const selectExprs = commonFields.map((field) => {
1557
1588
  const c = qi(field.physicalName);
1558
1589
  const sqlType = this.typeMapper(field);
1559
- const changed = (0, _atscript_db.isColumnTypeChanged)(oldTypes.get(field.physicalName), sqlType);
1560
- const value = changed ? convertColumnExpr(c, field, sqlType) : c;
1590
+ const oldType = oldTypes.get(field.physicalName);
1591
+ const changed = (0, _atscript_db.isColumnTypeChanged)(oldType, sqlType);
1592
+ const value = changed ? convertColumnExpr(c, field, sqlType, oldType) : c;
1561
1593
  if (!field.optional && !field.isPrimaryKey) return `COALESCE(${value}, ${field.defaultValue?.kind === "value" ? defaultValueToSqlLiteral(field.designType, field.defaultValue.value) : defaultValueForType(field.designType)}) AS ${c}`;
1562
1594
  return changed ? `${value} AS ${c}` : c;
1563
1595
  }).join(", ");
@@ -1674,6 +1706,20 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1674
1706
  this._log(ddl);
1675
1707
  await this._exec().exec(ddl);
1676
1708
  }
1709
+ async copyFromJsonColumn(source, targets) {
1710
+ const sql = (0, _atscript_db_sql_tools.buildJsonColumnCopy)(pgDialect, this.resolveTableName(), source, targets, (expr, target) => {
1711
+ if (target.kind !== "text") return expr;
1712
+ const type = this.typeMapper(target.field);
1713
+ return /^(VARCHAR|CHAR)\b/i.test(type) ? expr : `(${expr})::${type}`;
1714
+ });
1715
+ this._log(sql);
1716
+ await this._exec().exec(sql);
1717
+ }
1718
+ async jsonifyTextColumn(column) {
1719
+ const sql = (0, _atscript_db_sql_tools.buildJsonifyText)(pgDialect, this.resolveTableName(), column);
1720
+ this._log(sql);
1721
+ await this._exec().exec(sql);
1722
+ }
1677
1723
  async dropColumns(columns) {
1678
1724
  const tableName = this.resolveTableName();
1679
1725
  const drops = columns.map((col) => `DROP COLUMN ${qi(col)}`).join(", ");
@@ -2190,6 +2236,8 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
2190
2236
  };
2191
2237
  /** A text / character type: the value is converted by the assignment into the column. */
2192
2238
  const CHARACTER_TYPE = /^\s*(?:text|varchar|character varying|char|character|bpchar)\b/i;
2239
+ /** A floating-point / decimal column type (live or mapped). */
2240
+ const FRACTIONAL_TYPE = /^\s*(?:double precision|float[48]?|real|numeric|decimal)\b/i;
2193
2241
  /**
2194
2242
  * The expression converting column `col` (quoted) to `sqlType`, the field's
2195
2243
  * new type — one rule for `ALTER COLUMN … TYPE … USING` and the recreate copy.
@@ -2197,11 +2245,19 @@ const CHARACTER_TYPE = /^\s*(?:text|varchar|character varying|char|character|bpc
2197
2245
  * fails the statement instead of being coerced. A TEXT / VARCHAR(n) / CHAR(n)
2198
2246
  * target stops at `::text`: an explicit cast to VARCHAR(n) / CHAR(n) would
2199
2247
  * truncate an over-long value, the assignment into the column rejects it
2200
- * (since 0.1.137).
2248
+ * (since 0.1.137). An epoch-ms fractional column (`existingType`) becoming
2249
+ * the `BIGINT` of `@db.default.now` (`number.timestamp` →
2250
+ * `number.timestamp.created`) is rounded (since 0.1.155): its text
2251
+ * (`1700000000123.5`) does not parse as an integer. Any other fractional
2252
+ * value still fails a change to an integer type.
2201
2253
  */
2202
- function convertColumnExpr(col, field, sqlType) {
2203
- if (field.isGeoPoint && sqlType.startsWith("geography")) return `CASE WHEN ${col} IS NULL THEN NULL ELSE ST_SetSRID(ST_MakePoint((${col}->>0)::float8, (${col}->>1)::float8), 4326)::geography END`;
2254
+ function convertColumnExpr(col, field, sqlType, existingType) {
2255
+ if (field.isGeoPoint && sqlType.startsWith("geography")) {
2256
+ const json = `${col}::jsonb`;
2257
+ return `CASE WHEN ${col} IS NULL THEN NULL ELSE ST_SetSRID(ST_MakePoint((${json}->>0)::float8, (${json}->>1)::float8), 4326)::geography END`;
2258
+ }
2204
2259
  if (CHARACTER_TYPE.test(sqlType)) return `${col}::text`;
2260
+ if (field.defaultValue?.kind === "fn" && field.defaultValue.fn === "now" && /^BIGINT$/i.test(sqlType.trim()) && existingType && FRACTIONAL_TYPE.test(existingType)) return `round(${col})::bigint`;
2205
2261
  return `${col}::text::${sqlType}`;
2206
2262
  }
2207
2263
  /**
@@ -2361,6 +2417,10 @@ function createCustomTypes(pgTypes) {
2361
2417
  return pgTypes.getTypeParser(oid, format);
2362
2418
  } };
2363
2419
  }
2420
+ /** The warning logged for a pool client whose connection broke (`57P01`, `ECONNRESET`, …). */
2421
+ function lostConnectionMessage(err, where) {
2422
+ return `[atscript/db-postgres] ${where} pool connection lost (${err.code ?? err.name}): ${err.message}`;
2423
+ }
2364
2424
  /**
2365
2425
  * {@link TPgDriver} implementation backed by `pg` (node-postgres).
2366
2426
  *
@@ -2387,6 +2447,12 @@ function createCustomTypes(pgTypes) {
2387
2447
  * const driver = new PgDriver(pool)
2388
2448
  * ```
2389
2449
  *
2450
+ * A broken pool connection (a terminated backend, failover, network reset) is
2451
+ * logged through `options.logger` instead of crashing the process (since
2452
+ * 0.1.154): idle clients of a pool the driver creates, and clients checked out
2453
+ * for a transaction. A pre-created `pg.Pool` is the caller's — attach your own
2454
+ * `pool.on('error', …)` listener, or an idle client's error crashes the process.
2455
+ *
2390
2456
  * Requires `pg` to be installed:
2391
2457
  * ```bash
2392
2458
  * pnpm add pg
@@ -2395,7 +2461,9 @@ function createCustomTypes(pgTypes) {
2395
2461
  var PgDriver = class {
2396
2462
  pool;
2397
2463
  poolInit;
2398
- constructor(poolOrConfig) {
2464
+ logger;
2465
+ constructor(poolOrConfig, options = {}) {
2466
+ this.logger = options.logger ?? console;
2399
2467
  if (typeof poolOrConfig === "object" && typeof poolOrConfig.query === "function") this.pool = poolOrConfig;
2400
2468
  else this.poolInit = import("pg").then((pg) => {
2401
2469
  const Pool = pg.default?.Pool ?? pg.Pool;
@@ -2409,6 +2477,7 @@ var PgDriver = class {
2409
2477
  ...poolOrConfig,
2410
2478
  types: customTypes
2411
2479
  });
2480
+ this.pool.on("error", (err) => this.logger.warn(lostConnectionMessage(err, "idle")));
2412
2481
  return this.pool;
2413
2482
  });
2414
2483
  }
@@ -2433,6 +2502,13 @@ var PgDriver = class {
2433
2502
  }
2434
2503
  async getConnection() {
2435
2504
  const client = await (await this.getPool()).connect();
2505
+ let lost;
2506
+ const onError = (err) => {
2507
+ if (lost) return;
2508
+ lost = err;
2509
+ this.logger.warn(lostConnectionMessage(err, "checked-out"));
2510
+ };
2511
+ client.on("error", onError);
2436
2512
  return {
2437
2513
  async run(sql, params) {
2438
2514
  const result = await client.query(sql, sanitizeParams(params));
@@ -2451,7 +2527,8 @@ var PgDriver = class {
2451
2527
  await client.query(sql);
2452
2528
  },
2453
2529
  release() {
2454
- client.release();
2530
+ client.removeListener("error", onError);
2531
+ client.release(lost);
2455
2532
  }
2456
2533
  };
2457
2534
  }
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteResult, TDbFieldMeta, TDbInsertIgnoreOptions, 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, TJsonCopyTarget, 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
 
@@ -293,6 +293,7 @@ declare class PostgresAdapter extends BaseDbAdapter {
293
293
  /** `schema.name` when the adapter targets a named schema. */
294
294
  private _qualify;
295
295
  hasRows(tableName?: string): Promise<boolean>;
296
+ countKeyViolations(columns: readonly string[], tableName?: string): Promise<number>;
296
297
  /**
297
298
  * Live foreign keys referencing `tableName`, via `pg_constraint` — exact for
298
299
  * composite keys (`conkey`/`confkey` are positionally aligned, unlike the
@@ -304,8 +305,9 @@ declare class PostgresAdapter extends BaseDbAdapter {
304
305
  dropTablesByName(tableNames: string[]): Promise<void>;
305
306
  /**
306
307
  * `ALTER TABLE … DROP CONSTRAINT <pk>, ADD PRIMARY KEY (…)` in ONE statement
307
- * (atomic). Called on an empty table only; `ADD PRIMARY KEY` makes the new
308
- * key columns NOT NULL. A demoted identity column is handled by the
308
+ * (atomic: rows a concurrent write made violate the new key fail it with
309
+ * the old key in place). Called on an empty table or one whose rows satisfy
310
+ * the new key; `ADD PRIMARY KEY` makes the new key columns NOT NULL. A demoted identity column is handled by the
309
311
  * `defaultChanged` diff (`DROP IDENTITY`).
310
312
  */
311
313
  rebuildPrimaryKey(change: TPrimaryKeyChange): Promise<void>;
@@ -346,6 +348,8 @@ declare class PostgresAdapter extends BaseDbAdapter {
346
348
  * dependents.
347
349
  */
348
350
  dropTable(): Promise<void>;
351
+ copyFromJsonColumn(source: string, targets: readonly TJsonCopyTarget[]): Promise<void>;
352
+ jsonifyTextColumn(column: string): Promise<void>;
349
353
  dropColumns(columns: string[]): Promise<void>;
350
354
  dropIndexesForColumns(columns: string[]): Promise<void>;
351
355
  dropTableByName(tableName: string): Promise<void>;
@@ -442,6 +446,14 @@ declare class PostgresAdapter extends BaseDbAdapter {
442
446
  }
443
447
  //#endregion
444
448
  //#region src/pg-driver.d.ts
449
+ /** Options of {@link PgDriver}. */
450
+ interface TPgDriverOptions {
451
+ /**
452
+ * Receives a warning when a pool connection breaks — a terminated backend,
453
+ * failover or network reset (default `console`). See {@link PgDriver}.
454
+ */
455
+ logger?: Pick<TGenericLogger, "warn">;
456
+ }
445
457
  /**
446
458
  * {@link TPgDriver} implementation backed by `pg` (node-postgres).
447
459
  *
@@ -468,6 +480,12 @@ declare class PostgresAdapter extends BaseDbAdapter {
468
480
  * const driver = new PgDriver(pool)
469
481
  * ```
470
482
  *
483
+ * A broken pool connection (a terminated backend, failover, network reset) is
484
+ * logged through `options.logger` instead of crashing the process (since
485
+ * 0.1.154): idle clients of a pool the driver creates, and clients checked out
486
+ * for a transaction. A pre-created `pg.Pool` is the caller's — attach your own
487
+ * `pool.on('error', …)` listener, or an idle client's error crashes the process.
488
+ *
471
489
  * Requires `pg` to be installed:
472
490
  * ```bash
473
491
  * pnpm add pg
@@ -476,7 +494,8 @@ declare class PostgresAdapter extends BaseDbAdapter {
476
494
  declare class PgDriver implements TPgDriver {
477
495
  private pool;
478
496
  private poolInit;
479
- constructor(poolOrConfig: string | import("pg").Pool | import("pg").PoolConfig);
497
+ private readonly logger;
498
+ constructor(poolOrConfig: string | import("pg").Pool | import("pg").PoolConfig, options?: TPgDriverOptions);
480
499
  private getPool;
481
500
  run(sql: string, params?: unknown[]): Promise<TPgRunResult>;
482
501
  all<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
@@ -517,4 +536,4 @@ declare function buildWhere(filter: FilterExpr, opts?: TFilterVisitorOptions): T
517
536
  */
518
537
  declare function createAdapter(uri: string, options?: Record<string, unknown>): DbSpace;
519
538
  //#endregion
520
- export { PgDriver, PostgresAdapter, type TPgConnection, type TPgDriver, type TPgRunResult, type TSqlFragment, buildWhere, createAdapter };
539
+ 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, TDbInsertIgnoreOptions, 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, TJsonCopyTarget, 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
 
@@ -293,6 +293,7 @@ declare class PostgresAdapter extends BaseDbAdapter {
293
293
  /** `schema.name` when the adapter targets a named schema. */
294
294
  private _qualify;
295
295
  hasRows(tableName?: string): Promise<boolean>;
296
+ countKeyViolations(columns: readonly string[], tableName?: string): Promise<number>;
296
297
  /**
297
298
  * Live foreign keys referencing `tableName`, via `pg_constraint` — exact for
298
299
  * composite keys (`conkey`/`confkey` are positionally aligned, unlike the
@@ -304,8 +305,9 @@ declare class PostgresAdapter extends BaseDbAdapter {
304
305
  dropTablesByName(tableNames: string[]): Promise<void>;
305
306
  /**
306
307
  * `ALTER TABLE … DROP CONSTRAINT <pk>, ADD PRIMARY KEY (…)` in ONE statement
307
- * (atomic). Called on an empty table only; `ADD PRIMARY KEY` makes the new
308
- * key columns NOT NULL. A demoted identity column is handled by the
308
+ * (atomic: rows a concurrent write made violate the new key fail it with
309
+ * the old key in place). Called on an empty table or one whose rows satisfy
310
+ * the new key; `ADD PRIMARY KEY` makes the new key columns NOT NULL. A demoted identity column is handled by the
309
311
  * `defaultChanged` diff (`DROP IDENTITY`).
310
312
  */
311
313
  rebuildPrimaryKey(change: TPrimaryKeyChange): Promise<void>;
@@ -346,6 +348,8 @@ declare class PostgresAdapter extends BaseDbAdapter {
346
348
  * dependents.
347
349
  */
348
350
  dropTable(): Promise<void>;
351
+ copyFromJsonColumn(source: string, targets: readonly TJsonCopyTarget[]): Promise<void>;
352
+ jsonifyTextColumn(column: string): Promise<void>;
349
353
  dropColumns(columns: string[]): Promise<void>;
350
354
  dropIndexesForColumns(columns: string[]): Promise<void>;
351
355
  dropTableByName(tableName: string): Promise<void>;
@@ -442,6 +446,14 @@ declare class PostgresAdapter extends BaseDbAdapter {
442
446
  }
443
447
  //#endregion
444
448
  //#region src/pg-driver.d.ts
449
+ /** Options of {@link PgDriver}. */
450
+ interface TPgDriverOptions {
451
+ /**
452
+ * Receives a warning when a pool connection breaks — a terminated backend,
453
+ * failover or network reset (default `console`). See {@link PgDriver}.
454
+ */
455
+ logger?: Pick<TGenericLogger, "warn">;
456
+ }
445
457
  /**
446
458
  * {@link TPgDriver} implementation backed by `pg` (node-postgres).
447
459
  *
@@ -468,6 +480,12 @@ declare class PostgresAdapter extends BaseDbAdapter {
468
480
  * const driver = new PgDriver(pool)
469
481
  * ```
470
482
  *
483
+ * A broken pool connection (a terminated backend, failover, network reset) is
484
+ * logged through `options.logger` instead of crashing the process (since
485
+ * 0.1.154): idle clients of a pool the driver creates, and clients checked out
486
+ * for a transaction. A pre-created `pg.Pool` is the caller's — attach your own
487
+ * `pool.on('error', …)` listener, or an idle client's error crashes the process.
488
+ *
471
489
  * Requires `pg` to be installed:
472
490
  * ```bash
473
491
  * pnpm add pg
@@ -476,7 +494,8 @@ declare class PostgresAdapter extends BaseDbAdapter {
476
494
  declare class PgDriver implements TPgDriver {
477
495
  private pool;
478
496
  private poolInit;
479
- constructor(poolOrConfig: string | import("pg").Pool | import("pg").PoolConfig);
497
+ private readonly logger;
498
+ constructor(poolOrConfig: string | import("pg").Pool | import("pg").PoolConfig, options?: TPgDriverOptions);
480
499
  private getPool;
481
500
  run(sql: string, params?: unknown[]): Promise<TPgRunResult>;
482
501
  all<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
@@ -517,4 +536,4 @@ declare function buildWhere(filter: FilterExpr, opts?: TFilterVisitorOptions): T
517
536
  */
518
537
  declare function createAdapter(uri: string, options?: Record<string, unknown>): DbSpace;
519
538
  //#endregion
520
- export { PgDriver, PostgresAdapter, type TPgConnection, type TPgDriver, type TPgRunResult, type TSqlFragment, buildWhere, createAdapter };
539
+ export { PgDriver, PostgresAdapter, type TPgConnection, type TPgDriver, type TPgDriverOptions, type TPgRunResult, type TSqlFragment, buildWhere, createAdapter };
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ALL_AGGREGATE_FNS, ALL_BUCKET_UNITS, ALL_VIEW_CAPABILITIES, BaseDbAdapter, DbError, DbSpace, bucketTimeZoneUnavailable, defaultFulltextIndex, describeFulltext, fkColumns, isColumnTypeChanged, searchTermInteger, splitFulltextFields, uniqueKeyTuple, vectorIndexNotFoundMessage } from "@atscript/db";
2
2
  import { resolveAggregateSearch } from "@atscript/db/agg";
3
- import { SEARCH_SOURCE_ALIAS, arithOverflowError, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildPartitionedSelect, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere as buildWhere$1, chunkInsertRows, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, finalizeParams, foreignKeySql, geoWindowFromControls, mapQueryErrors, normalizeGeoPointValue, numericOutOfRangeError, orFragment, parseRegexString, quotedJsonPathSegments, renameGeoDistance, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, stripPartitionRowNumber, vectorDistanceSource } from "@atscript/db-sql-tools";
3
+ import { SEARCH_SOURCE_ALIAS, arithOverflowError, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildJsonColumnCopy, buildJsonifyText, buildKeyViolationCount, buildPartitionedSelect, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere as buildWhere$1, chunkInsertRows, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, finalizeParams, foreignKeySql, geoWindowFromControls, mapQueryErrors, normalizeGeoPointValue, numericOutOfRangeError, orFragment, parseRegexString, quotedJsonPathSegments, renameGeoDistance, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, stripPartitionRowNumber, vectorDistanceSource } from "@atscript/db-sql-tools";
4
4
  import { BUCKET_MAX_INSTANT, BUCKET_MIN_INSTANT } from "@uniqu/core";
5
5
  //#region src/insert-ignore.ts
6
6
  /**
@@ -137,6 +137,12 @@ function toPgValue(value) {
137
137
  if (typeof value === "object") return JSON.stringify(value);
138
138
  return value;
139
139
  }
140
+ /**
141
+ * Text a JSON column can take as it is (`SqlDialect.jsonFromText`): an
142
+ * object / array (by its first characters), or a whole JSON string, number,
143
+ * `true`, `false` or `null` — so `42 Main St` is no number.
144
+ */
145
+ const PG_JSON_LIKE = String.raw`^\s*(\{\s*["}]|\[\s*(["{[\]tfn0-9-])|("([^"\\]|\\.)*"|-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?|true|false|null)\s*$)`;
140
146
  const pgDialect = {
141
147
  quoteIdentifier(name) {
142
148
  return qi(name);
@@ -170,6 +176,9 @@ const pgDialect = {
170
176
  },
171
177
  calendarBucket: pgCalendarBucket,
172
178
  jsonExtract: pgJsonExtract,
179
+ jsonExtractText: (quotedCol, path) => `((${quotedCol})::jsonb #>> ${pgJsonPath(path)})`,
180
+ jsonExtractValue: (quotedCol, path) => `NULLIF((${quotedCol})::jsonb #> ${pgJsonPath(path)}, 'null'::jsonb)`,
181
+ jsonFromText: (quotedCol) => `CASE WHEN ${quotedCol} ~ '${PG_JSON_LIKE}' THEN ${quotedCol} ELSE to_jsonb(${quotedCol})::text END`,
173
182
  castDouble: (expr) => `CAST(${expr} AS DOUBLE PRECISION)`,
174
183
  anyValue: pgAnyValue,
175
184
  booleanAggregates: {
@@ -203,6 +212,10 @@ const PG_JSON_LEAF_CAST = {
203
212
  number: "::double precision",
204
213
  boolean: "::boolean"
205
214
  };
215
+ /** A `#>` / `#>>` path literal (`'{"a","b"}'`). */
216
+ function pgJsonPath(path) {
217
+ return sqlStringLiteral(`{${quotedJsonPathSegments(path).join(",")}}`);
218
+ }
206
219
  /**
207
220
  * Typed read of a JSON leaf (`SqlDialect.jsonExtract`): `jsonb_typeof()`
208
221
  * guards the declared type, so a missing path, JSON `null` or another JSON
@@ -210,7 +223,7 @@ const PG_JSON_LEAF_CAST = {
210
223
  * native. The column is cast `::jsonb`, so JSON and JSONB storage both work.
211
224
  */
212
225
  function pgJsonExtract(quotedCol, path, type) {
213
- const p = sqlStringLiteral(`{${quotedJsonPathSegments(path).join(",")}}`);
226
+ const p = pgJsonPath(path);
214
227
  const j = `(${quotedCol})::jsonb`;
215
228
  const cast = PG_JSON_LEAF_CAST[type];
216
229
  return `CASE jsonb_typeof(${j} #> ${p}) WHEN '${type}' THEN ${cast ? `(${j} #>> ${p})${cast}` : `${j} #>> ${p}`} END`;
@@ -512,6 +525,13 @@ const GEO_PROBE_SQL = `SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname =
512
525
  LEFT JOIN pg_type b ON b.oid = NULLIF(t.typbasetype, 0)
513
526
  WHERE a.attrelid = to_regclass($1::text) AND a.attnum > 0 AND NOT a.attisdropped
514
527
  AND a.attname = ANY($2::text[])) AS "columns"`;
528
+ const identity = (value) => value;
529
+ /** A column type node-postgres parses on read. */
530
+ const PG_JSON_TYPE = /^\s*jsonb?\s*$/i;
531
+ /** A JSON string value as JSON text — see `formatValue`. */
532
+ function reencodeJsonString(value) {
533
+ return typeof value === "string" ? JSON.stringify(value) : value;
534
+ }
515
535
  /** A PostGIS column type — a geo value binds as EWKT. Anything else (JSONB, TEXT) takes the JSON form. */
516
536
  const POSTGIS_TYPES = new Set(["geography", "geometry"]);
517
537
  /** The SQL `$geoWithin` renders (`pgDialect.geoWithin`) — PostGIS only. */
@@ -867,6 +887,10 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
867
887
  }
868
888
  };
869
889
  }
890
+ if (field.storage === "json" && !field.encrypted && PG_JSON_TYPE.test(this.typeMapper(field))) return {
891
+ toStorage: identity,
892
+ fromStorage: reencodeJsonString
893
+ };
870
894
  }
871
895
  /**
872
896
  * Wraps an async write operation to catch PostgreSQL constraint errors
@@ -1304,6 +1328,12 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1304
1328
  this._log(sql);
1305
1329
  return (await this._exec().get(sql, []))?.present ?? false;
1306
1330
  }
1331
+ async countKeyViolations(columns, tableName) {
1332
+ const sql = buildKeyViolationCount(pgDialect, tableName ? this._qualify(tableName) : quoteTableName(this.resolveTableName()), columns);
1333
+ this._log(sql);
1334
+ const row = await this._exec().get(sql, []);
1335
+ return Number(row?.violations ?? 0);
1336
+ }
1307
1337
  /**
1308
1338
  * Live foreign keys referencing `tableName`, via `pg_constraint` — exact for
1309
1339
  * composite keys (`conkey`/`confkey` are positionally aligned, unlike the
@@ -1349,8 +1379,9 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1349
1379
  }
1350
1380
  /**
1351
1381
  * `ALTER TABLE … DROP CONSTRAINT <pk>, ADD PRIMARY KEY (…)` in ONE statement
1352
- * (atomic). Called on an empty table only; `ADD PRIMARY KEY` makes the new
1353
- * key columns NOT NULL. A demoted identity column is handled by the
1382
+ * (atomic: rows a concurrent write made violate the new key fail it with
1383
+ * the old key in place). Called on an empty table or one whose rows satisfy
1384
+ * the new key; `ADD PRIMARY KEY` makes the new key columns NOT NULL. A demoted identity column is handled by the
1354
1385
  * `defaultChanged` diff (`DROP IDENTITY`).
1355
1386
  */
1356
1387
  async rebuildPrimaryKey(change) {
@@ -1444,10 +1475,10 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1444
1475
  this._log(ddl);
1445
1476
  await this._exec().exec(ddl);
1446
1477
  }
1447
- for (const { field } of diff.typeChanged ?? []) {
1478
+ for (const { field, existingType } of diff.typeChanged ?? []) {
1448
1479
  const sqlType = this.typeMapper(field);
1449
1480
  const col = qi(field.physicalName);
1450
- const ddl = `ALTER TABLE ${quoteTableName(tableName)} ALTER COLUMN ${col} TYPE ${sqlType} USING ${convertColumnExpr(col, field, sqlType)}`;
1481
+ const ddl = `ALTER TABLE ${quoteTableName(tableName)} ALTER COLUMN ${col} TYPE ${sqlType} USING ${convertColumnExpr(col, field, sqlType, existingType)}`;
1451
1482
  this._log(ddl);
1452
1483
  await this._exec().exec(ddl);
1453
1484
  }
@@ -1555,8 +1586,9 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1555
1586
  const selectExprs = commonFields.map((field) => {
1556
1587
  const c = qi(field.physicalName);
1557
1588
  const sqlType = this.typeMapper(field);
1558
- const changed = isColumnTypeChanged(oldTypes.get(field.physicalName), sqlType);
1559
- const value = changed ? convertColumnExpr(c, field, sqlType) : c;
1589
+ const oldType = oldTypes.get(field.physicalName);
1590
+ const changed = isColumnTypeChanged(oldType, sqlType);
1591
+ const value = changed ? convertColumnExpr(c, field, sqlType, oldType) : c;
1560
1592
  if (!field.optional && !field.isPrimaryKey) return `COALESCE(${value}, ${field.defaultValue?.kind === "value" ? defaultValueToSqlLiteral$1(field.designType, field.defaultValue.value) : defaultValueForType$1(field.designType)}) AS ${c}`;
1561
1593
  return changed ? `${value} AS ${c}` : c;
1562
1594
  }).join(", ");
@@ -1673,6 +1705,20 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1673
1705
  this._log(ddl);
1674
1706
  await this._exec().exec(ddl);
1675
1707
  }
1708
+ async copyFromJsonColumn(source, targets) {
1709
+ const sql = buildJsonColumnCopy(pgDialect, this.resolveTableName(), source, targets, (expr, target) => {
1710
+ if (target.kind !== "text") return expr;
1711
+ const type = this.typeMapper(target.field);
1712
+ return /^(VARCHAR|CHAR)\b/i.test(type) ? expr : `(${expr})::${type}`;
1713
+ });
1714
+ this._log(sql);
1715
+ await this._exec().exec(sql);
1716
+ }
1717
+ async jsonifyTextColumn(column) {
1718
+ const sql = buildJsonifyText(pgDialect, this.resolveTableName(), column);
1719
+ this._log(sql);
1720
+ await this._exec().exec(sql);
1721
+ }
1676
1722
  async dropColumns(columns) {
1677
1723
  const tableName = this.resolveTableName();
1678
1724
  const drops = columns.map((col) => `DROP COLUMN ${qi(col)}`).join(", ");
@@ -2189,6 +2235,8 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
2189
2235
  };
2190
2236
  /** A text / character type: the value is converted by the assignment into the column. */
2191
2237
  const CHARACTER_TYPE = /^\s*(?:text|varchar|character varying|char|character|bpchar)\b/i;
2238
+ /** A floating-point / decimal column type (live or mapped). */
2239
+ const FRACTIONAL_TYPE = /^\s*(?:double precision|float[48]?|real|numeric|decimal)\b/i;
2192
2240
  /**
2193
2241
  * The expression converting column `col` (quoted) to `sqlType`, the field's
2194
2242
  * new type — one rule for `ALTER COLUMN … TYPE … USING` and the recreate copy.
@@ -2196,11 +2244,19 @@ const CHARACTER_TYPE = /^\s*(?:text|varchar|character varying|char|character|bpc
2196
2244
  * fails the statement instead of being coerced. A TEXT / VARCHAR(n) / CHAR(n)
2197
2245
  * target stops at `::text`: an explicit cast to VARCHAR(n) / CHAR(n) would
2198
2246
  * truncate an over-long value, the assignment into the column rejects it
2199
- * (since 0.1.137).
2247
+ * (since 0.1.137). An epoch-ms fractional column (`existingType`) becoming
2248
+ * the `BIGINT` of `@db.default.now` (`number.timestamp` →
2249
+ * `number.timestamp.created`) is rounded (since 0.1.155): its text
2250
+ * (`1700000000123.5`) does not parse as an integer. Any other fractional
2251
+ * value still fails a change to an integer type.
2200
2252
  */
2201
- function convertColumnExpr(col, field, sqlType) {
2202
- if (field.isGeoPoint && sqlType.startsWith("geography")) return `CASE WHEN ${col} IS NULL THEN NULL ELSE ST_SetSRID(ST_MakePoint((${col}->>0)::float8, (${col}->>1)::float8), 4326)::geography END`;
2253
+ function convertColumnExpr(col, field, sqlType, existingType) {
2254
+ if (field.isGeoPoint && sqlType.startsWith("geography")) {
2255
+ const json = `${col}::jsonb`;
2256
+ return `CASE WHEN ${col} IS NULL THEN NULL ELSE ST_SetSRID(ST_MakePoint((${json}->>0)::float8, (${json}->>1)::float8), 4326)::geography END`;
2257
+ }
2203
2258
  if (CHARACTER_TYPE.test(sqlType)) return `${col}::text`;
2259
+ if (field.defaultValue?.kind === "fn" && field.defaultValue.fn === "now" && /^BIGINT$/i.test(sqlType.trim()) && existingType && FRACTIONAL_TYPE.test(existingType)) return `round(${col})::bigint`;
2204
2260
  return `${col}::text::${sqlType}`;
2205
2261
  }
2206
2262
  /**
@@ -2360,6 +2416,10 @@ function createCustomTypes(pgTypes) {
2360
2416
  return pgTypes.getTypeParser(oid, format);
2361
2417
  } };
2362
2418
  }
2419
+ /** The warning logged for a pool client whose connection broke (`57P01`, `ECONNRESET`, …). */
2420
+ function lostConnectionMessage(err, where) {
2421
+ return `[atscript/db-postgres] ${where} pool connection lost (${err.code ?? err.name}): ${err.message}`;
2422
+ }
2363
2423
  /**
2364
2424
  * {@link TPgDriver} implementation backed by `pg` (node-postgres).
2365
2425
  *
@@ -2386,6 +2446,12 @@ function createCustomTypes(pgTypes) {
2386
2446
  * const driver = new PgDriver(pool)
2387
2447
  * ```
2388
2448
  *
2449
+ * A broken pool connection (a terminated backend, failover, network reset) is
2450
+ * logged through `options.logger` instead of crashing the process (since
2451
+ * 0.1.154): idle clients of a pool the driver creates, and clients checked out
2452
+ * for a transaction. A pre-created `pg.Pool` is the caller's — attach your own
2453
+ * `pool.on('error', …)` listener, or an idle client's error crashes the process.
2454
+ *
2389
2455
  * Requires `pg` to be installed:
2390
2456
  * ```bash
2391
2457
  * pnpm add pg
@@ -2394,7 +2460,9 @@ function createCustomTypes(pgTypes) {
2394
2460
  var PgDriver = class {
2395
2461
  pool;
2396
2462
  poolInit;
2397
- constructor(poolOrConfig) {
2463
+ logger;
2464
+ constructor(poolOrConfig, options = {}) {
2465
+ this.logger = options.logger ?? console;
2398
2466
  if (typeof poolOrConfig === "object" && typeof poolOrConfig.query === "function") this.pool = poolOrConfig;
2399
2467
  else this.poolInit = import("pg").then((pg) => {
2400
2468
  const Pool = pg.default?.Pool ?? pg.Pool;
@@ -2408,6 +2476,7 @@ var PgDriver = class {
2408
2476
  ...poolOrConfig,
2409
2477
  types: customTypes
2410
2478
  });
2479
+ this.pool.on("error", (err) => this.logger.warn(lostConnectionMessage(err, "idle")));
2411
2480
  return this.pool;
2412
2481
  });
2413
2482
  }
@@ -2432,6 +2501,13 @@ var PgDriver = class {
2432
2501
  }
2433
2502
  async getConnection() {
2434
2503
  const client = await (await this.getPool()).connect();
2504
+ let lost;
2505
+ const onError = (err) => {
2506
+ if (lost) return;
2507
+ lost = err;
2508
+ this.logger.warn(lostConnectionMessage(err, "checked-out"));
2509
+ };
2510
+ client.on("error", onError);
2435
2511
  return {
2436
2512
  async run(sql, params) {
2437
2513
  const result = await client.query(sql, sanitizeParams(params));
@@ -2450,7 +2526,8 @@ var PgDriver = class {
2450
2526
  await client.query(sql);
2451
2527
  },
2452
2528
  release() {
2453
- client.release();
2529
+ client.removeListener("error", onError);
2530
+ client.release(lost);
2454
2531
  }
2455
2532
  };
2456
2533
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/db-postgres",
3
- "version": "0.1.153",
3
+ "version": "0.1.155",
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.103",
47
- "@atscript/typescript": "^0.1.103",
46
+ "@atscript/core": "^0.1.105",
47
+ "@atscript/typescript": "^0.1.105",
48
48
  "@types/pg": "^8.11.0",
49
49
  "@uniqu/core": "^0.1.15",
50
50
  "pg": "^8.13.0",
51
- "unplugin-atscript": "^0.1.103"
51
+ "unplugin-atscript": "^0.1.105"
52
52
  },
53
53
  "peerDependencies": {
54
- "@atscript/core": "^0.1.103",
55
- "@atscript/typescript": "^0.1.103",
54
+ "@atscript/core": "^0.1.105",
55
+ "@atscript/typescript": "^0.1.105",
56
56
  "@uniqu/core": "^0.1.15",
57
57
  "pg": ">=8.0.0",
58
- "@atscript/db-sql-tools": "^0.1.153",
59
- "@atscript/db": "^0.1.153"
58
+ "@atscript/db": "^0.1.155",
59
+ "@atscript/db-sql-tools": "^0.1.155"
60
60
  },
61
61
  "peerDependenciesMeta": {
62
62
  "pg": {