@atscript/db-postgres 0.1.147 → 0.1.148

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
@@ -3,6 +3,77 @@ let _atscript_db = require("@atscript/db");
3
3
  let _atscript_db_agg = require("@atscript/db/agg");
4
4
  let _atscript_db_sql_tools = require("@atscript/db-sql-tools");
5
5
  let _uniqu_core = require("@uniqu/core");
6
+ //#region src/insert-ignore.ts
7
+ /**
8
+ * Whether two key values are the same value written differently: a number
9
+ * and its decimal text (`5` / `"5.00"` of a NUMERIC column), a string and its
10
+ * blank-padded form (`"ab"` / `"ab "` of a CHAR(n) column), or in another
11
+ * letter case (`uuid`, `citext`).
12
+ */
13
+ function looselySame(a, b) {
14
+ const textA = typeof a === "number" || typeof a === "string" ? `${a}` : void 0;
15
+ const textB = typeof b === "number" || typeof b === "string" ? `${b}` : void 0;
16
+ if (textA === void 0 || textB === void 0) return false;
17
+ const na = Number(textA);
18
+ const nb = Number(textB);
19
+ if (textA.trim() !== "" && textB.trim() !== "" && Number.isFinite(na) && na === nb) return true;
20
+ return typeof a === "string" && typeof b === "string" && a.trimEnd().toLowerCase() === b.trimEnd().toLowerCase();
21
+ }
22
+ /** A key value the server may store in another form than it was sent (NUMERIC / decimal rounding). */
23
+ function mayNormalize(value, col, formCols) {
24
+ if (typeof value === "string" && col !== void 0 && formCols?.has(col)) return true;
25
+ return typeof value === "number" && !Number.isInteger(value) || typeof value === "string" && /^-?\d+\.\d+$/.test(value);
26
+ }
27
+ /**
28
+ * Maps the rows a batched `INSERT … ON CONFLICT DO NOTHING RETURNING <keys>`
29
+ * returned back to the batch's input rows.
30
+ *
31
+ * PostgreSQL returns the inserted rows in VALUES order and omits skipped ones,
32
+ * so `returned` is an in-order subsequence of `batch`. A row is inserted
33
+ * exactly when the next returned row carries EVERY key set the row fully
34
+ * defines (key sets with an absent / null input component are never compared:
35
+ * a generated key's value is unknown to the input). Result: per input row, the
36
+ * index of its returned row, or `-1` when it was skipped.
37
+ *
38
+ * `undefined` when the mapping is not provably exact — a row that defines no
39
+ * complete key set (it may match a returned row only by a generated value), a
40
+ * skipped-looking row whose key a server could have normalized (non-integer
41
+ * numbers / decimals), a key equal to the returned one only when written
42
+ * differently (`5` / `"5.00"`, blank-padded CHAR, a `uuid` / `citext` in another
43
+ * letter case — the server lower-cases a returned uuid, so `"AAA"` / `"aaa"` could
44
+ * swap the mapping), or returned rows left unaccounted for (counts or keys that
45
+ * do not line up with an in-order subsequence). The caller must then redo the
46
+ * chunk row by row. An empty `returned` (everything skipped) and a fully-returned
47
+ * batch (everything inserted) are exact without any matching.
48
+ *
49
+ * @param keySets Physical column names of the primary key and every unique index.
50
+ * @param formCols Key columns whose physical type is not text (uuid, inet, cidr,
51
+ * macaddr, …, `@db.pg.type` overrides): a string key there may come back
52
+ * normalized, so a skipped-looking row of such a key is ambiguous.
53
+ */
54
+ function mapIgnoredBatch(batch, returned, keySets, formCols) {
55
+ if (returned.length === 0) return batch.map(() => -1);
56
+ if (returned.length === batch.length) return batch.map((_, i) => i);
57
+ const out = [];
58
+ let next = 0;
59
+ for (const row of batch) {
60
+ const tuples = keySets.map((cols) => (0, _atscript_db.uniqueKeyTuple)(row, cols));
61
+ if (tuples.every((t) => t === void 0)) return void 0;
62
+ const candidate = returned[next];
63
+ if (candidate !== void 0) {
64
+ const strict = keySets.map((cols, k) => tuples[k] === void 0 || tuples[k] === (0, _atscript_db.uniqueKeyTuple)(candidate, cols));
65
+ if (strict.every(Boolean)) {
66
+ out.push(next++);
67
+ continue;
68
+ }
69
+ if (keySets.every((cols, k) => tuples[k] === void 0 || strict[k] || cols.every((c) => looselySame(row[c], candidate[c])))) return void 0;
70
+ }
71
+ if (keySets.some((cols, k) => tuples[k] !== void 0 && cols.some((c) => mayNormalize(row[c], c, formCols)))) return;
72
+ out.push(-1);
73
+ }
74
+ return next === returned.length ? out : void 0;
75
+ }
76
+ //#endregion
6
77
  //#region src/sql-builder.ts
7
78
  /**
8
79
  * PostgreSQL-aware default value for a given design type.
@@ -80,6 +151,25 @@ const pgDialect = {
80
151
  calendarBucket: pgCalendarBucket,
81
152
  jsonExtract: pgJsonExtract,
82
153
  castDouble: (expr) => `CAST(${expr} AS DOUBLE PRECISION)`,
154
+ anyValue: pgAnyValue,
155
+ booleanAggregates: {
156
+ min: "BOOL_AND",
157
+ max: "BOOL_OR"
158
+ },
159
+ /**
160
+ * SQLSTATE 22003 (`numeric_value_out_of_range`), a double overflow in
161
+ * aggregate arithmetic, is `INVALID_QUERY`; SQLSTATE 22023
162
+ * (`invalid_parameter_value`, `time zone "…" not recognized`), raised by a
163
+ * calendar bucket's `AT TIME ZONE '<tz>'`, is `BUCKET_TZ_UNAVAILABLE` — the
164
+ * zone passed the core's IANA validation, so the server's tz database is
165
+ * older than the runtime's.
166
+ */
167
+ mapQueryError(error, arithmetic) {
168
+ const err = error;
169
+ if (err?.code === "22003") return arithmetic ? (0, _atscript_db_sql_tools.arithOverflowError)() : (0, _atscript_db_sql_tools.numericOutOfRangeError)();
170
+ const zone = err?.code === "22023" && typeof err.message === "string" ? /time zone "([^"]*)" not recognized/.exec(err.message)?.[1] : void 0;
171
+ return zone === void 0 ? void 0 : (0, _atscript_db.bucketTimeZoneUnavailable)(`PostgreSQL does not recognize time zone "${zone}" — update the server's time zone data`);
172
+ },
83
173
  nullsSortLargest: true,
84
174
  createViewPrefix: "CREATE OR REPLACE VIEW",
85
175
  paramPlaceholder(index) {
@@ -257,6 +347,30 @@ function intTypeFromTags(tags) {
257
347
  if (tags?.has("int64") || tags?.has("uint64")) return "BIGINT";
258
348
  return "INTEGER";
259
349
  }
350
+ /** Physical types `MIN` aggregates (base name, lower-cased): the streaming pick applies. */
351
+ const MIN_ORDERED_TYPE = /^(smallint|int2|integer|int|int4|bigint|int8|real|float4|double precision|float8|numeric|decimal|money|text|varchar|character varying|char|character|bpchar|date|time|timetz|timestamp|timestamptz|interval|time (with|without) time zone|timestamp (with|without) time zone)\b/;
352
+ /** Text key types: a value comes back in the form it was sent (modulo case for `citext`), so a returned key maps to its input row. */
353
+ const TEXT_KEY_TYPE = /^(text|varchar|character varying|char|character|bpchar|citext)\b/;
354
+ /** `true` when the physical column type `type` is a text type (see {@link TEXT_KEY_TYPE}). */
355
+ function isTextKeyType(type) {
356
+ return TEXT_KEY_TYPE.test(type.trim().toLowerCase());
357
+ }
358
+ /**
359
+ * The "any value of the group" pick of a `first` / `last` derived column
360
+ * (constant within its group) over a PostgreSQL column: `MIN` (streaming — one
361
+ * accumulator per group) for the ordered types and for every array column
362
+ * (`MIN(anyarray)`; `ARRAY_AGG` over arrays builds a 2-D array and rejects NULL /
363
+ * empty ones), `BOOL_AND` for a boolean, and `(ARRAY_AGG(x))[1]` only where the
364
+ * physical type has no `MIN` (uuid, bytea, point, json / jsonb, citext,
365
+ * `@db.pg.type` overrides, …) or is unknown.
366
+ */
367
+ function pgAnyValue(expr, field) {
368
+ const type = field ? pgTypeFromField(field).trim().toLowerCase() : "";
369
+ if (type.endsWith("[]")) return `MIN(${expr})`;
370
+ if (/^bool(ean)?\b/.test(type)) return `BOOL_AND(${expr})`;
371
+ if (MIN_ORDERED_TYPE.test(type)) return `MIN(${expr})`;
372
+ return `(ARRAY_AGG(${expr}))[1]`;
373
+ }
260
374
  /**
261
375
  * Maps an Atscript field descriptor to a PostgreSQL column type.
262
376
  *
@@ -312,11 +426,11 @@ function buildCreateTable(table, fields, foreignKeys, options) {
312
426
  continue;
313
427
  }
314
428
  let def = `${qi(field.physicalName)} ${sqlType}`;
315
- if (options?.incrementFields?.has(field.physicalName)) {
429
+ if (options?.incrementFields?.has(field.path)) {
316
430
  const start = options.autoIncrementStart;
317
431
  def += start !== void 0 ? ` GENERATED BY DEFAULT AS IDENTITY (START WITH ${start})` : " GENERATED BY DEFAULT AS IDENTITY";
318
432
  }
319
- if (!field.optional && !field.isPrimaryKey && !options?.incrementFields?.has(field.physicalName)) def += " NOT NULL";
433
+ if (!field.optional && !field.isPrimaryKey && !options?.incrementFields?.has(field.path)) def += " NOT NULL";
320
434
  if (field.defaultValue?.kind === "value") def += ` DEFAULT ${defaultValueToSqlLiteral(field.designType, field.defaultValue.value)}`;
321
435
  else if (field.defaultValue?.kind === "fn") {
322
436
  if (field.defaultValue.fn === "uuid") def += " DEFAULT gen_random_uuid()";
@@ -386,6 +500,8 @@ function parseCount(value) {
386
500
  * ```
387
501
  */
388
502
  /** The suffix PostgreSQL gives an auto-named constraint of each `pg_constraint.contype`. */
503
+ /** Savepoint guarding one `insertManyIgnore` chunk (see {@link PostgresAdapter.insertManyIgnore}). */
504
+ const IGNORE_SAVEPOINT = "atscript_ignore_chunk";
389
505
  const PG_CONSTRAINT_LABELS = {
390
506
  p: "pkey",
391
507
  u: "key",
@@ -528,10 +644,14 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
528
644
  calendarBucketUnits() {
529
645
  return _atscript_db.ALL_BUCKET_UNITS;
530
646
  }
531
- /** Every aggregate function, `countDistinct` included. */
647
+ /** Every aggregate function: `countDistinct`, `first` and `last` included. */
532
648
  aggregateFns() {
533
649
  return _atscript_db.ALL_AGGREGATE_FNS;
534
650
  }
651
+ /** Arithmetic in an aggregate `$select` (`{ $expr }`, `{ $fn, $expr }`). */
652
+ supportsAggregateExpressions() {
653
+ return true;
654
+ }
535
655
  /** Computed view columns and first-row joins. */
536
656
  viewCapabilities() {
537
657
  return _atscript_db.ALL_VIEW_CAPABILITIES;
@@ -601,16 +721,20 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
601
721
  try {
602
722
  return await fn();
603
723
  } catch (error) {
604
- if (error && typeof error === "object" && "code" in error) {
605
- const err = error;
606
- if (err.code === "23505") throw new _atscript_db.DbError("CONFLICT", [{
607
- path: this._extractFieldFromConstraint(err.constraint) ?? "",
608
- message: err.detail ?? err.message
609
- }]);
610
- if (err.code === "23503") throw new _atscript_db.DbError("FK_VIOLATION", this._mapFkError(err.detail ?? err.message, err.constraint));
611
- }
612
- throw error;
724
+ return this._mapConstraintError(error);
725
+ }
726
+ }
727
+ /** Rethrows `error` as a structured `DbError` when it is a unique / FK violation, else as is. */
728
+ _mapConstraintError(error) {
729
+ if (error && typeof error === "object" && "code" in error) {
730
+ const err = error;
731
+ if (err.code === "23505") throw new _atscript_db.DbError("CONFLICT", [{
732
+ path: this._extractFieldFromConstraint(err.constraint) ?? "",
733
+ message: err.detail ?? err.message
734
+ }]);
735
+ if (err.code === "23503") throw new _atscript_db.DbError("FK_VIOLATION", this._mapFkError(err.detail ?? err.message, err.constraint));
613
736
  }
737
+ throw error;
614
738
  }
615
739
  _extractFieldFromConstraint(constraint) {
616
740
  if (!constraint) return;
@@ -653,11 +777,9 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
653
777
  const tableName = this.resolveTableName();
654
778
  const pkCols = this._pkColumns();
655
779
  const returningSuffix = pkCols.length > 0 ? ` RETURNING ${pkCols.map((pk) => qi(pk)).join(", ")}` : "";
656
- const columns = (0, _atscript_db_sql_tools.insertManyColumns)(data);
657
- const maxRowsPerBatch = columns.length > 0 ? Math.floor(6e4 / columns.length) : data.length;
780
+ const { columns, batches } = (0, _atscript_db_sql_tools.chunkInsertRows)(data);
658
781
  const allIds = [];
659
- for (let offset = 0; offset < data.length; offset += maxRowsPerBatch) {
660
- const batch = data.slice(offset, offset + maxRowsPerBatch);
782
+ for (const batch of batches) {
661
783
  const insert = buildInsertMany(tableName, batch, columns);
662
784
  const sql = insert.sql + returningSuffix;
663
785
  const params = insert.params;
@@ -674,6 +796,56 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
674
796
  };
675
797
  });
676
798
  }
799
+ supportsInsertIgnore() {
800
+ return true;
801
+ }
802
+ /**
803
+ * Batched `INSERT … VALUES (…), (…) ON CONFLICT DO NOTHING RETURNING <pk +
804
+ * unique key columns>`: one statement per chunk (a conflict never raises, so
805
+ * the surrounding transaction survives). Skipped rows are the ones missing
806
+ * from RETURNING — mapped back by key values, see {@link mapIgnoredBatch}.
807
+ * Each chunk runs inside a SAVEPOINT: when the mapping is ambiguous (a key
808
+ * the server returns in another form than it was sent) the chunk is rolled
809
+ * back to the savepoint and redone row by row with the same
810
+ * `ON CONFLICT DO NOTHING`, so the result is always exact.
811
+ */
812
+ async insertManyIgnore(data) {
813
+ if (data.length === 0) return [];
814
+ return this.withTransaction(async () => {
815
+ const tableName = this.resolveTableName();
816
+ const pkCols = this._pkColumns();
817
+ const keySets = this._table.uniqueKeySets;
818
+ const returning = [...new Set(keySets.flat())];
819
+ const returningCols = new Set(returning);
820
+ const nonTextKeyCols = new Set(this._table.fieldDescriptors.filter((f) => returningCols.has(f.physicalName) && !isTextKeyType(this.typeMapper(f))).map((f) => f.physicalName));
821
+ const returningSuffix = returning.length > 0 ? ` RETURNING ${returning.map((c) => qi(c)).join(", ")}` : "";
822
+ const insertedId = (row, returned) => ({ insertedId: this._resolveInsertedId(row, pkCols.length > 0 ? returned?.[pkCols[0]] : void 0) });
823
+ const { columns, batches } = (0, _atscript_db_sql_tools.chunkInsertRows)(data);
824
+ const slots = [];
825
+ for (const batch of batches) {
826
+ const insert = buildInsertMany(tableName, batch, columns);
827
+ const sql = `${insert.sql} ON CONFLICT DO NOTHING${returningSuffix}`;
828
+ await this._exec().run(`SAVEPOINT ${IGNORE_SAVEPOINT}`);
829
+ this._log(sql, insert.params);
830
+ const returned = (await this._wrapConstraintError(() => this._exec().run(sql, insert.params))).rows ?? [];
831
+ const mapping = returning.length === 0 ? batch.map((_, i) => i) : mapIgnoredBatch(batch, returned, keySets, nonTextKeyCols);
832
+ if (mapping) {
833
+ await this._exec().run(`RELEASE SAVEPOINT ${IGNORE_SAVEPOINT}`);
834
+ mapping.forEach((hit, i) => slots.push(hit < 0 ? null : insertedId(batch[i], returned[hit])));
835
+ continue;
836
+ }
837
+ await this._exec().run(`ROLLBACK TO SAVEPOINT ${IGNORE_SAVEPOINT}`);
838
+ for (const row of batch) {
839
+ const single = buildInsert(tableName, row);
840
+ const rowSql = `${single.sql} ON CONFLICT DO NOTHING${returningSuffix}`;
841
+ this._log(rowSql, single.params);
842
+ const rowResult = await this._wrapConstraintError(() => this._exec().run(rowSql, single.params));
843
+ slots.push(rowResult.rows?.length ? insertedId(row, rowResult.rows[0]) : null);
844
+ }
845
+ }
846
+ return slots;
847
+ });
848
+ }
677
849
  async findOne(query) {
678
850
  const where = buildWhere(query.filter);
679
851
  const controls = {
@@ -716,28 +888,11 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
716
888
  if (query.controls.$count) {
717
889
  const { sql, params } = buildAggregateCount(tableName, where, query.controls);
718
890
  this._log(sql, params);
719
- return [{ count: parseCount((await this._wrapBucketZoneError(() => this._exec().get(sql, params)))?.count) }];
891
+ return [{ count: parseCount((await (0, _atscript_db_sql_tools.mapQueryErrors)(pgDialect, () => this._exec().get(sql, params), query.controls))?.count) }];
720
892
  }
721
893
  const { sql, params } = buildAggregateSelect(tableName, where, query.controls);
722
894
  this._log(sql, params);
723
- return this._wrapBucketZoneError(() => this._exec().all(sql, params));
724
- }
725
- /**
726
- * Maps PostgreSQL's unknown-zone error — SQLSTATE 22023
727
- * (`invalid_parameter_value`), `time zone "…" not recognized` — raised by a
728
- * calendar bucket's `AT TIME ZONE '<tz>'` to `BUCKET_TZ_UNAVAILABLE`. The
729
- * zone passed the core's IANA validation, so the server's tz database is
730
- * older than the runtime's. Other errors propagate unchanged.
731
- */
732
- async _wrapBucketZoneError(fn) {
733
- try {
734
- return await fn();
735
- } catch (error) {
736
- const err = error;
737
- const zone = err?.code === "22023" && typeof err.message === "string" ? /time zone "([^"]*)" not recognized/.exec(err.message)?.[1] : void 0;
738
- if (zone !== void 0) throw (0, _atscript_db.bucketTimeZoneUnavailable)(`PostgreSQL does not recognize time zone "${zone}" — update the server's time zone data`);
739
- throw error;
740
- }
895
+ return (0, _atscript_db_sql_tools.mapQueryErrors)(pgDialect, () => this._exec().all(sql, params), query.controls);
741
896
  }
742
897
  /** Physical primary-key columns (`@db.column` renames applied). */
743
898
  _pkColumns() {
@@ -1856,8 +2011,10 @@ var PgDriver = class {
1856
2011
  }
1857
2012
  };
1858
2013
  }
1859
- async close() {
1860
- await (await this.getPool()).end();
2014
+ _closing;
2015
+ /** Idempotent: every call returns the first call's promise. */
2016
+ close() {
2017
+ return this._closing ??= Promise.resolve(this.getPool()).then((pool) => pool.end());
1861
2018
  }
1862
2019
  };
1863
2020
  //#endregion
@@ -1874,7 +2031,7 @@ function createAdapter(uri, options) {
1874
2031
  connectionString: uri,
1875
2032
  ...options
1876
2033
  });
1877
- return new _atscript_db.DbSpace(() => new PostgresAdapter(driver));
2034
+ return new _atscript_db.DbSpace(() => new PostgresAdapter(driver), { onClose: () => driver.close() });
1878
2035
  }
1879
2036
  //#endregion
1880
2037
  exports.PgDriver = PgDriver;
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteResult, TDbFieldMeta, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, 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, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TFieldOps, 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
 
@@ -125,8 +125,10 @@ declare class PostgresAdapter extends BaseDbAdapter {
125
125
  * server's tz database (an unknown one maps to `BUCKET_TZ_UNAVAILABLE`).
126
126
  */
127
127
  calendarBucketUnits(): ReadonlySet<BucketUnit>;
128
- /** Every aggregate function, `countDistinct` included. */
128
+ /** Every aggregate function: `countDistinct`, `first` and `last` included. */
129
129
  aggregateFns(): ReadonlySet<AggregateFn>;
130
+ /** Arithmetic in an aggregate `$select` (`{ $expr }`, `{ $fn, $expr }`). */
131
+ supportsAggregateExpressions(): boolean;
130
132
  /** Computed view columns and first-row joins. */
131
133
  viewCapabilities(): ReadonlySet<TViewCapability>;
132
134
  onBeforeFlatten(_type: unknown): void;
@@ -149,10 +151,24 @@ declare class PostgresAdapter extends BaseDbAdapter {
149
151
  * - 23503 = foreign_key_violation
150
152
  */
151
153
  private _wrapConstraintError;
154
+ /** Rethrows `error` as a structured `DbError` when it is a unique / FK violation, else as is. */
155
+ private _mapConstraintError;
152
156
  private _extractFieldFromConstraint;
153
157
  private _mapFkError;
154
158
  insertOne(data: Record<string, unknown>): Promise<TDbInsertResult>;
155
159
  insertMany(data: Array<Record<string, unknown>>): Promise<TDbInsertManyResult>;
160
+ supportsInsertIgnore(): boolean;
161
+ /**
162
+ * Batched `INSERT … VALUES (…), (…) ON CONFLICT DO NOTHING RETURNING <pk +
163
+ * unique key columns>`: one statement per chunk (a conflict never raises, so
164
+ * the surrounding transaction survives). Skipped rows are the ones missing
165
+ * from RETURNING — mapped back by key values, see {@link mapIgnoredBatch}.
166
+ * Each chunk runs inside a SAVEPOINT: when the mapping is ambiguous (a key
167
+ * the server returns in another form than it was sent) the chunk is rolled
168
+ * back to the savepoint and redone row by row with the same
169
+ * `ON CONFLICT DO NOTHING`, so the result is always exact.
170
+ */
171
+ insertManyIgnore(data: Array<Record<string, unknown>>): Promise<TDbInsertIgnoreSlot[]>;
156
172
  findOne(query: DbQuery): Promise<Record<string, unknown> | null>;
157
173
  findMany(query: DbQuery): Promise<Array<Record<string, unknown>>>;
158
174
  /**
@@ -162,14 +178,6 @@ declare class PostgresAdapter extends BaseDbAdapter {
162
178
  findManyPerPartition(query: DbQuery, partitionBy: readonly string[]): Promise<Array<Record<string, unknown>>>;
163
179
  count(query: DbQuery): Promise<number>;
164
180
  aggregate(query: DbQuery): Promise<Array<Record<string, unknown>>>;
165
- /**
166
- * Maps PostgreSQL's unknown-zone error — SQLSTATE 22023
167
- * (`invalid_parameter_value`), `time zone "…" not recognized` — raised by a
168
- * calendar bucket's `AT TIME ZONE '<tz>'` to `BUCKET_TZ_UNAVAILABLE`. The
169
- * zone passed the core's IANA validation, so the server's tz database is
170
- * older than the runtime's. Other errors propagate unchanged.
171
- */
172
- private _wrapBucketZoneError;
173
181
  /** Physical primary-key columns (`@db.column` renames applied). */
174
182
  private _pkColumns;
175
183
  /**
@@ -345,6 +353,8 @@ declare class PgDriver implements TPgDriver {
345
353
  get<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T | null>;
346
354
  exec(sql: string): Promise<void>;
347
355
  getConnection(): Promise<TPgConnection>;
356
+ private _closing?;
357
+ /** Idempotent: every call returns the first call's promise. */
348
358
  close(): Promise<void>;
349
359
  }
350
360
  //#endregion
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteResult, TDbFieldMeta, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, 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, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TFieldOps, 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
 
@@ -125,8 +125,10 @@ declare class PostgresAdapter extends BaseDbAdapter {
125
125
  * server's tz database (an unknown one maps to `BUCKET_TZ_UNAVAILABLE`).
126
126
  */
127
127
  calendarBucketUnits(): ReadonlySet<BucketUnit>;
128
- /** Every aggregate function, `countDistinct` included. */
128
+ /** Every aggregate function: `countDistinct`, `first` and `last` included. */
129
129
  aggregateFns(): ReadonlySet<AggregateFn>;
130
+ /** Arithmetic in an aggregate `$select` (`{ $expr }`, `{ $fn, $expr }`). */
131
+ supportsAggregateExpressions(): boolean;
130
132
  /** Computed view columns and first-row joins. */
131
133
  viewCapabilities(): ReadonlySet<TViewCapability>;
132
134
  onBeforeFlatten(_type: unknown): void;
@@ -149,10 +151,24 @@ declare class PostgresAdapter extends BaseDbAdapter {
149
151
  * - 23503 = foreign_key_violation
150
152
  */
151
153
  private _wrapConstraintError;
154
+ /** Rethrows `error` as a structured `DbError` when it is a unique / FK violation, else as is. */
155
+ private _mapConstraintError;
152
156
  private _extractFieldFromConstraint;
153
157
  private _mapFkError;
154
158
  insertOne(data: Record<string, unknown>): Promise<TDbInsertResult>;
155
159
  insertMany(data: Array<Record<string, unknown>>): Promise<TDbInsertManyResult>;
160
+ supportsInsertIgnore(): boolean;
161
+ /**
162
+ * Batched `INSERT … VALUES (…), (…) ON CONFLICT DO NOTHING RETURNING <pk +
163
+ * unique key columns>`: one statement per chunk (a conflict never raises, so
164
+ * the surrounding transaction survives). Skipped rows are the ones missing
165
+ * from RETURNING — mapped back by key values, see {@link mapIgnoredBatch}.
166
+ * Each chunk runs inside a SAVEPOINT: when the mapping is ambiguous (a key
167
+ * the server returns in another form than it was sent) the chunk is rolled
168
+ * back to the savepoint and redone row by row with the same
169
+ * `ON CONFLICT DO NOTHING`, so the result is always exact.
170
+ */
171
+ insertManyIgnore(data: Array<Record<string, unknown>>): Promise<TDbInsertIgnoreSlot[]>;
156
172
  findOne(query: DbQuery): Promise<Record<string, unknown> | null>;
157
173
  findMany(query: DbQuery): Promise<Array<Record<string, unknown>>>;
158
174
  /**
@@ -162,14 +178,6 @@ declare class PostgresAdapter extends BaseDbAdapter {
162
178
  findManyPerPartition(query: DbQuery, partitionBy: readonly string[]): Promise<Array<Record<string, unknown>>>;
163
179
  count(query: DbQuery): Promise<number>;
164
180
  aggregate(query: DbQuery): Promise<Array<Record<string, unknown>>>;
165
- /**
166
- * Maps PostgreSQL's unknown-zone error — SQLSTATE 22023
167
- * (`invalid_parameter_value`), `time zone "…" not recognized` — raised by a
168
- * calendar bucket's `AT TIME ZONE '<tz>'` to `BUCKET_TZ_UNAVAILABLE`. The
169
- * zone passed the core's IANA validation, so the server's tz database is
170
- * older than the runtime's. Other errors propagate unchanged.
171
- */
172
- private _wrapBucketZoneError;
173
181
  /** Physical primary-key columns (`@db.column` renames applied). */
174
182
  private _pkColumns;
175
183
  /**
@@ -345,6 +353,8 @@ declare class PgDriver implements TPgDriver {
345
353
  get<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T | null>;
346
354
  exec(sql: string): Promise<void>;
347
355
  getConnection(): Promise<TPgConnection>;
356
+ private _closing?;
357
+ /** Idempotent: every call returns the first call's promise. */
348
358
  close(): Promise<void>;
349
359
  }
350
360
  //#endregion
package/dist/index.mjs CHANGED
@@ -1,7 +1,78 @@
1
- import { ALL_AGGREGATE_FNS, ALL_BUCKET_UNITS, ALL_VIEW_CAPABILITIES, BaseDbAdapter, DbError, DbSpace, bucketTimeZoneUnavailable, fkColumns, isColumnTypeChanged, vectorIndexNotFoundMessage } from "@atscript/db";
1
+ import { ALL_AGGREGATE_FNS, ALL_BUCKET_UNITS, ALL_VIEW_CAPABILITIES, BaseDbAdapter, DbError, DbSpace, bucketTimeZoneUnavailable, fkColumns, isColumnTypeChanged, uniqueKeyTuple, vectorIndexNotFoundMessage } from "@atscript/db";
2
2
  import { resolveAggregateSearch } from "@atscript/db/agg";
3
- import { SEARCH_SOURCE_ALIAS, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildPartitionedSelect, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere as buildWhere$1, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, finalizeParams, foreignKeySql, geoWindowFromControls, insertManyColumns, normalizeGeoPointValue, 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, buildPartitionedSelect, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere as buildWhere$1, chunkInsertRows, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, finalizeParams, foreignKeySql, geoWindowFromControls, mapQueryErrors, normalizeGeoPointValue, numericOutOfRangeError, 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
+ //#region src/insert-ignore.ts
6
+ /**
7
+ * Whether two key values are the same value written differently: a number
8
+ * and its decimal text (`5` / `"5.00"` of a NUMERIC column), a string and its
9
+ * blank-padded form (`"ab"` / `"ab "` of a CHAR(n) column), or in another
10
+ * letter case (`uuid`, `citext`).
11
+ */
12
+ function looselySame(a, b) {
13
+ const textA = typeof a === "number" || typeof a === "string" ? `${a}` : void 0;
14
+ const textB = typeof b === "number" || typeof b === "string" ? `${b}` : void 0;
15
+ if (textA === void 0 || textB === void 0) return false;
16
+ const na = Number(textA);
17
+ const nb = Number(textB);
18
+ if (textA.trim() !== "" && textB.trim() !== "" && Number.isFinite(na) && na === nb) return true;
19
+ return typeof a === "string" && typeof b === "string" && a.trimEnd().toLowerCase() === b.trimEnd().toLowerCase();
20
+ }
21
+ /** A key value the server may store in another form than it was sent (NUMERIC / decimal rounding). */
22
+ function mayNormalize(value, col, formCols) {
23
+ if (typeof value === "string" && col !== void 0 && formCols?.has(col)) return true;
24
+ return typeof value === "number" && !Number.isInteger(value) || typeof value === "string" && /^-?\d+\.\d+$/.test(value);
25
+ }
26
+ /**
27
+ * Maps the rows a batched `INSERT … ON CONFLICT DO NOTHING RETURNING <keys>`
28
+ * returned back to the batch's input rows.
29
+ *
30
+ * PostgreSQL returns the inserted rows in VALUES order and omits skipped ones,
31
+ * so `returned` is an in-order subsequence of `batch`. A row is inserted
32
+ * exactly when the next returned row carries EVERY key set the row fully
33
+ * defines (key sets with an absent / null input component are never compared:
34
+ * a generated key's value is unknown to the input). Result: per input row, the
35
+ * index of its returned row, or `-1` when it was skipped.
36
+ *
37
+ * `undefined` when the mapping is not provably exact — a row that defines no
38
+ * complete key set (it may match a returned row only by a generated value), a
39
+ * skipped-looking row whose key a server could have normalized (non-integer
40
+ * numbers / decimals), a key equal to the returned one only when written
41
+ * differently (`5` / `"5.00"`, blank-padded CHAR, a `uuid` / `citext` in another
42
+ * letter case — the server lower-cases a returned uuid, so `"AAA"` / `"aaa"` could
43
+ * swap the mapping), or returned rows left unaccounted for (counts or keys that
44
+ * do not line up with an in-order subsequence). The caller must then redo the
45
+ * chunk row by row. An empty `returned` (everything skipped) and a fully-returned
46
+ * batch (everything inserted) are exact without any matching.
47
+ *
48
+ * @param keySets Physical column names of the primary key and every unique index.
49
+ * @param formCols Key columns whose physical type is not text (uuid, inet, cidr,
50
+ * macaddr, …, `@db.pg.type` overrides): a string key there may come back
51
+ * normalized, so a skipped-looking row of such a key is ambiguous.
52
+ */
53
+ function mapIgnoredBatch(batch, returned, keySets, formCols) {
54
+ if (returned.length === 0) return batch.map(() => -1);
55
+ if (returned.length === batch.length) return batch.map((_, i) => i);
56
+ const out = [];
57
+ let next = 0;
58
+ for (const row of batch) {
59
+ const tuples = keySets.map((cols) => uniqueKeyTuple(row, cols));
60
+ if (tuples.every((t) => t === void 0)) return void 0;
61
+ const candidate = returned[next];
62
+ if (candidate !== void 0) {
63
+ const strict = keySets.map((cols, k) => tuples[k] === void 0 || tuples[k] === uniqueKeyTuple(candidate, cols));
64
+ if (strict.every(Boolean)) {
65
+ out.push(next++);
66
+ continue;
67
+ }
68
+ if (keySets.every((cols, k) => tuples[k] === void 0 || strict[k] || cols.every((c) => looselySame(row[c], candidate[c])))) return void 0;
69
+ }
70
+ if (keySets.some((cols, k) => tuples[k] !== void 0 && cols.some((c) => mayNormalize(row[c], c, formCols)))) return;
71
+ out.push(-1);
72
+ }
73
+ return next === returned.length ? out : void 0;
74
+ }
75
+ //#endregion
5
76
  //#region src/sql-builder.ts
6
77
  /**
7
78
  * PostgreSQL-aware default value for a given design type.
@@ -79,6 +150,25 @@ const pgDialect = {
79
150
  calendarBucket: pgCalendarBucket,
80
151
  jsonExtract: pgJsonExtract,
81
152
  castDouble: (expr) => `CAST(${expr} AS DOUBLE PRECISION)`,
153
+ anyValue: pgAnyValue,
154
+ booleanAggregates: {
155
+ min: "BOOL_AND",
156
+ max: "BOOL_OR"
157
+ },
158
+ /**
159
+ * SQLSTATE 22003 (`numeric_value_out_of_range`), a double overflow in
160
+ * aggregate arithmetic, is `INVALID_QUERY`; SQLSTATE 22023
161
+ * (`invalid_parameter_value`, `time zone "…" not recognized`), raised by a
162
+ * calendar bucket's `AT TIME ZONE '<tz>'`, is `BUCKET_TZ_UNAVAILABLE` — the
163
+ * zone passed the core's IANA validation, so the server's tz database is
164
+ * older than the runtime's.
165
+ */
166
+ mapQueryError(error, arithmetic) {
167
+ const err = error;
168
+ if (err?.code === "22003") return arithmetic ? arithOverflowError() : numericOutOfRangeError();
169
+ const zone = err?.code === "22023" && typeof err.message === "string" ? /time zone "([^"]*)" not recognized/.exec(err.message)?.[1] : void 0;
170
+ return zone === void 0 ? void 0 : bucketTimeZoneUnavailable(`PostgreSQL does not recognize time zone "${zone}" — update the server's time zone data`);
171
+ },
82
172
  nullsSortLargest: true,
83
173
  createViewPrefix: "CREATE OR REPLACE VIEW",
84
174
  paramPlaceholder(index) {
@@ -256,6 +346,30 @@ function intTypeFromTags(tags) {
256
346
  if (tags?.has("int64") || tags?.has("uint64")) return "BIGINT";
257
347
  return "INTEGER";
258
348
  }
349
+ /** Physical types `MIN` aggregates (base name, lower-cased): the streaming pick applies. */
350
+ const MIN_ORDERED_TYPE = /^(smallint|int2|integer|int|int4|bigint|int8|real|float4|double precision|float8|numeric|decimal|money|text|varchar|character varying|char|character|bpchar|date|time|timetz|timestamp|timestamptz|interval|time (with|without) time zone|timestamp (with|without) time zone)\b/;
351
+ /** Text key types: a value comes back in the form it was sent (modulo case for `citext`), so a returned key maps to its input row. */
352
+ const TEXT_KEY_TYPE = /^(text|varchar|character varying|char|character|bpchar|citext)\b/;
353
+ /** `true` when the physical column type `type` is a text type (see {@link TEXT_KEY_TYPE}). */
354
+ function isTextKeyType(type) {
355
+ return TEXT_KEY_TYPE.test(type.trim().toLowerCase());
356
+ }
357
+ /**
358
+ * The "any value of the group" pick of a `first` / `last` derived column
359
+ * (constant within its group) over a PostgreSQL column: `MIN` (streaming — one
360
+ * accumulator per group) for the ordered types and for every array column
361
+ * (`MIN(anyarray)`; `ARRAY_AGG` over arrays builds a 2-D array and rejects NULL /
362
+ * empty ones), `BOOL_AND` for a boolean, and `(ARRAY_AGG(x))[1]` only where the
363
+ * physical type has no `MIN` (uuid, bytea, point, json / jsonb, citext,
364
+ * `@db.pg.type` overrides, …) or is unknown.
365
+ */
366
+ function pgAnyValue(expr, field) {
367
+ const type = field ? pgTypeFromField(field).trim().toLowerCase() : "";
368
+ if (type.endsWith("[]")) return `MIN(${expr})`;
369
+ if (/^bool(ean)?\b/.test(type)) return `BOOL_AND(${expr})`;
370
+ if (MIN_ORDERED_TYPE.test(type)) return `MIN(${expr})`;
371
+ return `(ARRAY_AGG(${expr}))[1]`;
372
+ }
259
373
  /**
260
374
  * Maps an Atscript field descriptor to a PostgreSQL column type.
261
375
  *
@@ -311,11 +425,11 @@ function buildCreateTable(table, fields, foreignKeys, options) {
311
425
  continue;
312
426
  }
313
427
  let def = `${qi(field.physicalName)} ${sqlType}`;
314
- if (options?.incrementFields?.has(field.physicalName)) {
428
+ if (options?.incrementFields?.has(field.path)) {
315
429
  const start = options.autoIncrementStart;
316
430
  def += start !== void 0 ? ` GENERATED BY DEFAULT AS IDENTITY (START WITH ${start})` : " GENERATED BY DEFAULT AS IDENTITY";
317
431
  }
318
- if (!field.optional && !field.isPrimaryKey && !options?.incrementFields?.has(field.physicalName)) def += " NOT NULL";
432
+ if (!field.optional && !field.isPrimaryKey && !options?.incrementFields?.has(field.path)) def += " NOT NULL";
319
433
  if (field.defaultValue?.kind === "value") def += ` DEFAULT ${defaultValueToSqlLiteral$1(field.designType, field.defaultValue.value)}`;
320
434
  else if (field.defaultValue?.kind === "fn") {
321
435
  if (field.defaultValue.fn === "uuid") def += " DEFAULT gen_random_uuid()";
@@ -385,6 +499,8 @@ function parseCount(value) {
385
499
  * ```
386
500
  */
387
501
  /** The suffix PostgreSQL gives an auto-named constraint of each `pg_constraint.contype`. */
502
+ /** Savepoint guarding one `insertManyIgnore` chunk (see {@link PostgresAdapter.insertManyIgnore}). */
503
+ const IGNORE_SAVEPOINT = "atscript_ignore_chunk";
388
504
  const PG_CONSTRAINT_LABELS = {
389
505
  p: "pkey",
390
506
  u: "key",
@@ -527,10 +643,14 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
527
643
  calendarBucketUnits() {
528
644
  return ALL_BUCKET_UNITS;
529
645
  }
530
- /** Every aggregate function, `countDistinct` included. */
646
+ /** Every aggregate function: `countDistinct`, `first` and `last` included. */
531
647
  aggregateFns() {
532
648
  return ALL_AGGREGATE_FNS;
533
649
  }
650
+ /** Arithmetic in an aggregate `$select` (`{ $expr }`, `{ $fn, $expr }`). */
651
+ supportsAggregateExpressions() {
652
+ return true;
653
+ }
534
654
  /** Computed view columns and first-row joins. */
535
655
  viewCapabilities() {
536
656
  return ALL_VIEW_CAPABILITIES;
@@ -600,16 +720,20 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
600
720
  try {
601
721
  return await fn();
602
722
  } catch (error) {
603
- if (error && typeof error === "object" && "code" in error) {
604
- const err = error;
605
- if (err.code === "23505") throw new DbError("CONFLICT", [{
606
- path: this._extractFieldFromConstraint(err.constraint) ?? "",
607
- message: err.detail ?? err.message
608
- }]);
609
- if (err.code === "23503") throw new DbError("FK_VIOLATION", this._mapFkError(err.detail ?? err.message, err.constraint));
610
- }
611
- throw error;
723
+ return this._mapConstraintError(error);
724
+ }
725
+ }
726
+ /** Rethrows `error` as a structured `DbError` when it is a unique / FK violation, else as is. */
727
+ _mapConstraintError(error) {
728
+ if (error && typeof error === "object" && "code" in error) {
729
+ const err = error;
730
+ if (err.code === "23505") throw new DbError("CONFLICT", [{
731
+ path: this._extractFieldFromConstraint(err.constraint) ?? "",
732
+ message: err.detail ?? err.message
733
+ }]);
734
+ if (err.code === "23503") throw new DbError("FK_VIOLATION", this._mapFkError(err.detail ?? err.message, err.constraint));
612
735
  }
736
+ throw error;
613
737
  }
614
738
  _extractFieldFromConstraint(constraint) {
615
739
  if (!constraint) return;
@@ -652,11 +776,9 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
652
776
  const tableName = this.resolveTableName();
653
777
  const pkCols = this._pkColumns();
654
778
  const returningSuffix = pkCols.length > 0 ? ` RETURNING ${pkCols.map((pk) => qi(pk)).join(", ")}` : "";
655
- const columns = insertManyColumns(data);
656
- const maxRowsPerBatch = columns.length > 0 ? Math.floor(6e4 / columns.length) : data.length;
779
+ const { columns, batches } = chunkInsertRows(data);
657
780
  const allIds = [];
658
- for (let offset = 0; offset < data.length; offset += maxRowsPerBatch) {
659
- const batch = data.slice(offset, offset + maxRowsPerBatch);
781
+ for (const batch of batches) {
660
782
  const insert = buildInsertMany$1(tableName, batch, columns);
661
783
  const sql = insert.sql + returningSuffix;
662
784
  const params = insert.params;
@@ -673,6 +795,56 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
673
795
  };
674
796
  });
675
797
  }
798
+ supportsInsertIgnore() {
799
+ return true;
800
+ }
801
+ /**
802
+ * Batched `INSERT … VALUES (…), (…) ON CONFLICT DO NOTHING RETURNING <pk +
803
+ * unique key columns>`: one statement per chunk (a conflict never raises, so
804
+ * the surrounding transaction survives). Skipped rows are the ones missing
805
+ * from RETURNING — mapped back by key values, see {@link mapIgnoredBatch}.
806
+ * Each chunk runs inside a SAVEPOINT: when the mapping is ambiguous (a key
807
+ * the server returns in another form than it was sent) the chunk is rolled
808
+ * back to the savepoint and redone row by row with the same
809
+ * `ON CONFLICT DO NOTHING`, so the result is always exact.
810
+ */
811
+ async insertManyIgnore(data) {
812
+ if (data.length === 0) return [];
813
+ return this.withTransaction(async () => {
814
+ const tableName = this.resolveTableName();
815
+ const pkCols = this._pkColumns();
816
+ const keySets = this._table.uniqueKeySets;
817
+ const returning = [...new Set(keySets.flat())];
818
+ const returningCols = new Set(returning);
819
+ const nonTextKeyCols = new Set(this._table.fieldDescriptors.filter((f) => returningCols.has(f.physicalName) && !isTextKeyType(this.typeMapper(f))).map((f) => f.physicalName));
820
+ const returningSuffix = returning.length > 0 ? ` RETURNING ${returning.map((c) => qi(c)).join(", ")}` : "";
821
+ const insertedId = (row, returned) => ({ insertedId: this._resolveInsertedId(row, pkCols.length > 0 ? returned?.[pkCols[0]] : void 0) });
822
+ const { columns, batches } = chunkInsertRows(data);
823
+ const slots = [];
824
+ for (const batch of batches) {
825
+ const insert = buildInsertMany$1(tableName, batch, columns);
826
+ const sql = `${insert.sql} ON CONFLICT DO NOTHING${returningSuffix}`;
827
+ await this._exec().run(`SAVEPOINT ${IGNORE_SAVEPOINT}`);
828
+ this._log(sql, insert.params);
829
+ const returned = (await this._wrapConstraintError(() => this._exec().run(sql, insert.params))).rows ?? [];
830
+ const mapping = returning.length === 0 ? batch.map((_, i) => i) : mapIgnoredBatch(batch, returned, keySets, nonTextKeyCols);
831
+ if (mapping) {
832
+ await this._exec().run(`RELEASE SAVEPOINT ${IGNORE_SAVEPOINT}`);
833
+ mapping.forEach((hit, i) => slots.push(hit < 0 ? null : insertedId(batch[i], returned[hit])));
834
+ continue;
835
+ }
836
+ await this._exec().run(`ROLLBACK TO SAVEPOINT ${IGNORE_SAVEPOINT}`);
837
+ for (const row of batch) {
838
+ const single = buildInsert$1(tableName, row);
839
+ const rowSql = `${single.sql} ON CONFLICT DO NOTHING${returningSuffix}`;
840
+ this._log(rowSql, single.params);
841
+ const rowResult = await this._wrapConstraintError(() => this._exec().run(rowSql, single.params));
842
+ slots.push(rowResult.rows?.length ? insertedId(row, rowResult.rows[0]) : null);
843
+ }
844
+ }
845
+ return slots;
846
+ });
847
+ }
676
848
  async findOne(query) {
677
849
  const where = buildWhere(query.filter);
678
850
  const controls = {
@@ -715,28 +887,11 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
715
887
  if (query.controls.$count) {
716
888
  const { sql, params } = buildAggregateCount$1(tableName, where, query.controls);
717
889
  this._log(sql, params);
718
- return [{ count: parseCount((await this._wrapBucketZoneError(() => this._exec().get(sql, params)))?.count) }];
890
+ return [{ count: parseCount((await mapQueryErrors(pgDialect, () => this._exec().get(sql, params), query.controls))?.count) }];
719
891
  }
720
892
  const { sql, params } = buildAggregateSelect$1(tableName, where, query.controls);
721
893
  this._log(sql, params);
722
- return this._wrapBucketZoneError(() => this._exec().all(sql, params));
723
- }
724
- /**
725
- * Maps PostgreSQL's unknown-zone error — SQLSTATE 22023
726
- * (`invalid_parameter_value`), `time zone "…" not recognized` — raised by a
727
- * calendar bucket's `AT TIME ZONE '<tz>'` to `BUCKET_TZ_UNAVAILABLE`. The
728
- * zone passed the core's IANA validation, so the server's tz database is
729
- * older than the runtime's. Other errors propagate unchanged.
730
- */
731
- async _wrapBucketZoneError(fn) {
732
- try {
733
- return await fn();
734
- } catch (error) {
735
- const err = error;
736
- const zone = err?.code === "22023" && typeof err.message === "string" ? /time zone "([^"]*)" not recognized/.exec(err.message)?.[1] : void 0;
737
- if (zone !== void 0) throw bucketTimeZoneUnavailable(`PostgreSQL does not recognize time zone "${zone}" — update the server's time zone data`);
738
- throw error;
739
- }
894
+ return mapQueryErrors(pgDialect, () => this._exec().all(sql, params), query.controls);
740
895
  }
741
896
  /** Physical primary-key columns (`@db.column` renames applied). */
742
897
  _pkColumns() {
@@ -1855,8 +2010,10 @@ var PgDriver = class {
1855
2010
  }
1856
2011
  };
1857
2012
  }
1858
- async close() {
1859
- await (await this.getPool()).end();
2013
+ _closing;
2014
+ /** Idempotent: every call returns the first call's promise. */
2015
+ close() {
2016
+ return this._closing ??= Promise.resolve(this.getPool()).then((pool) => pool.end());
1860
2017
  }
1861
2018
  };
1862
2019
  //#endregion
@@ -1873,7 +2030,7 @@ function createAdapter(uri, options) {
1873
2030
  connectionString: uri,
1874
2031
  ...options
1875
2032
  });
1876
- return new DbSpace(() => new PostgresAdapter(driver));
2033
+ return new DbSpace(() => new PostgresAdapter(driver), { onClose: () => driver.close() });
1877
2034
  }
1878
2035
  //#endregion
1879
2036
  export { PgDriver, PostgresAdapter, buildWhere, createAdapter };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/db-postgres",
3
- "version": "0.1.147",
3
+ "version": "0.1.148",
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.99",
47
- "@atscript/typescript": "^0.1.99",
46
+ "@atscript/core": "^0.1.100",
47
+ "@atscript/typescript": "^0.1.100",
48
48
  "@types/pg": "^8.11.0",
49
- "@uniqu/core": "^0.1.12",
49
+ "@uniqu/core": "^0.1.13",
50
50
  "pg": "^8.13.0",
51
- "unplugin-atscript": "^0.1.99"
51
+ "unplugin-atscript": "^0.1.100"
52
52
  },
53
53
  "peerDependencies": {
54
- "@atscript/core": "^0.1.99",
55
- "@atscript/typescript": "^0.1.99",
56
- "@uniqu/core": "^0.1.12",
54
+ "@atscript/core": "^0.1.100",
55
+ "@atscript/typescript": "^0.1.100",
56
+ "@uniqu/core": "^0.1.13",
57
57
  "pg": ">=8.0.0",
58
- "@atscript/db": "^0.1.147",
59
- "@atscript/db-sql-tools": "^0.1.147"
58
+ "@atscript/db": "^0.1.148",
59
+ "@atscript/db-sql-tools": "^0.1.148"
60
60
  },
61
61
  "peerDependenciesMeta": {
62
62
  "pg": {