@atscript/db-postgres 0.1.146 → 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.mjs CHANGED
@@ -1,7 +1,78 @@
1
- import { ALL_AGGREGATE_FNS, ALL_BUCKET_UNITS, BaseDbAdapter, DbError, DbSpace, bucketTimeZoneUnavailable, 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 { buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere as buildWhere$1, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, finalizeParams, geoWindowFromControls, insertManyColumns, normalizeGeoPointValue, parseRegexString, quotedJsonPathSegments, refActionToSql, renameGeoDistance, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, 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.
@@ -78,6 +149,27 @@ const pgDialect = {
78
149
  },
79
150
  calendarBucket: pgCalendarBucket,
80
151
  jsonExtract: pgJsonExtract,
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
+ },
172
+ nullsSortLargest: true,
81
173
  createViewPrefix: "CREATE OR REPLACE VIEW",
82
174
  paramPlaceholder(index) {
83
175
  return `$${index}`;
@@ -104,33 +196,47 @@ function pgJsonExtract(quotedCol, path, type) {
104
196
  /**
105
197
  * Calendar-bucket label over an epoch-ms column (BIGINT for
106
198
  * `@db.default.now`, DOUBLE PRECISION otherwise): TEXT `'YYYY-MM-DD'` — the
107
- * local date of the bucket's first day in `b.tz` — or NULL for a NULL source
108
- * or one outside `[BUCKET_MIN_INSTANT, BUCKET_MAX_INSTANT)`.
199
+ * local date of the bucket's first day in `b.tz` — or, for `hour`,
200
+ * `'YYYY-MM-DDTHH:00'` — the local wall-clock hour — or NULL for a NULL
201
+ * source or one outside `[BUCKET_MIN_INSTANT, BUCKET_MAX_INSTANT)`.
109
202
  *
110
- * The local date is `(to_timestamp(col / 1000) AT TIME ZONE '<tz>')::date`:
111
- * `timestamptz AT TIME ZONE` yields the wall time in that zone, independent
112
- * of the session `TimeZone`. Truncation is calendar arithmetic on that date —
113
- * the week uses the generic `(isodow - weekStart + 7) % 7` formula
114
- * (`date_trunc('week')` is Monday-only). `date_trunc` and `to_char` get an
115
- * explicit `::timestamp`: a bare `date` resolves to their `timestamptz`
116
- * overloads, which would route through the session zone.
203
+ * The wall time is `to_timestamp(col / 1000) AT TIME ZONE '<tz>'`:
204
+ * `timestamptz AT TIME ZONE` yields the (zone-less) wall time in that zone,
205
+ * independent of the session `TimeZone` — so a `+05:30` zone's hour starts at
206
+ * :30 UTC and a DST fall-back's repeated hour is one label. The hour is
207
+ * formatted from it; the other units truncate its `::date` with calendar
208
+ * arithmetic — the week uses the generic `(isodow - weekStart + 7) % 7`
209
+ * formula (`date_trunc('week')` is Monday-only). `date_trunc` and `to_char`
210
+ * get an explicit `::timestamp`: a bare `date` resolves to their
211
+ * `timestamptz` overloads, which would route through the session zone.
117
212
  *
118
213
  * Parameter-free (the zone is an inlined, charset-checked literal), so the
119
214
  * SELECT and GROUP BY renderings match structurally.
120
215
  */
121
216
  function pgCalendarBucket(quotedCol, b) {
122
- const local = `(to_timestamp(${quotedCol}::double precision / 1000) AT TIME ZONE ${sqlTimeZoneLiteral(b.tz)})::date`;
123
- let first;
217
+ const wall = `(to_timestamp(${quotedCol}::double precision / 1000) AT TIME ZONE ${sqlTimeZoneLiteral(b.tz)})`;
218
+ const local = `${wall}::date`;
219
+ let value;
220
+ let format = "YYYY-MM-DD";
124
221
  switch (b.unit) {
222
+ case "hour":
223
+ value = wall;
224
+ format = "YYYY-MM-DD\"T\"HH24\":00\"";
225
+ break;
125
226
  case "day":
126
- first = local;
227
+ value = local;
127
228
  break;
128
229
  case "week":
129
- first = `(${local} - ((EXTRACT(ISODOW FROM ${local})::int - ${b.weekStartIso} + 7) % 7))`;
230
+ value = `(${local} - ((EXTRACT(ISODOW FROM ${local})::int - ${b.weekStartIso} + 7) % 7))`;
130
231
  break;
131
- default: first = `date_trunc('${b.unit}', ${local}::timestamp)`;
232
+ case "month":
233
+ case "quarter":
234
+ case "year":
235
+ value = `date_trunc('${b.unit}', ${local}::timestamp)`;
236
+ break;
237
+ default: throw new Error(`Unhandled calendar bucket unit "${String(b.unit)}"`);
132
238
  }
133
- return `CASE WHEN ${quotedCol} >= ${BUCKET_MIN_INSTANT} AND ${quotedCol} < ${BUCKET_MAX_INSTANT} THEN ${`to_char(${first}::timestamp, 'YYYY-MM-DD')`} END`;
239
+ return `CASE WHEN ${quotedCol} >= ${BUCKET_MIN_INSTANT} AND ${quotedCol} < ${BUCKET_MAX_INSTANT} THEN ${`to_char(${value}::timestamp, '${format}')`} END`;
134
240
  }
135
241
  /**
136
242
  * Distance-in-meters expression from a geography column to a query point.
@@ -240,6 +346,30 @@ function intTypeFromTags(tags) {
240
346
  if (tags?.has("int64") || tags?.has("uint64")) return "BIGINT";
241
347
  return "INTEGER";
242
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
+ }
243
373
  /**
244
374
  * Maps an Atscript field descriptor to a PostgreSQL column type.
245
375
  *
@@ -295,11 +425,11 @@ function buildCreateTable(table, fields, foreignKeys, options) {
295
425
  continue;
296
426
  }
297
427
  let def = `${qi(field.physicalName)} ${sqlType}`;
298
- if (options?.incrementFields?.has(field.physicalName)) {
428
+ if (options?.incrementFields?.has(field.path)) {
299
429
  const start = options.autoIncrementStart;
300
430
  def += start !== void 0 ? ` GENERATED BY DEFAULT AS IDENTITY (START WITH ${start})` : " GENERATED BY DEFAULT AS IDENTITY";
301
431
  }
302
- 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";
303
433
  if (field.defaultValue?.kind === "value") def += ` DEFAULT ${defaultValueToSqlLiteral$1(field.designType, field.defaultValue.value)}`;
304
434
  else if (field.defaultValue?.kind === "fn") {
305
435
  if (field.defaultValue.fn === "uuid") def += " DEFAULT gen_random_uuid()";
@@ -320,12 +450,7 @@ function buildCreateTable(table, fields, foreignKeys, options) {
320
450
  }
321
451
  if (foreignKeys) for (const fk of foreignKeys.values()) {
322
452
  if (options?.deferForeignKeysTo?.has(fk.targetTable)) continue;
323
- const localCols = fk.fields.map((f) => qi(f)).join(", ");
324
- const targetCols = fk.targetFields.map((f) => qi(f)).join(", ");
325
- let constraint = `FOREIGN KEY (${localCols}) REFERENCES ${qi(fk.targetTable)} (${targetCols})`;
326
- if (fk.onDelete) constraint += ` ON DELETE ${refActionToSql(fk.onDelete)}`;
327
- if (fk.onUpdate) constraint += ` ON UPDATE ${refActionToSql(fk.onUpdate)}`;
328
- colDefs.push(constraint);
453
+ colDefs.push(foreignKeySql(qi, fk));
329
454
  }
330
455
  return `CREATE TABLE IF NOT EXISTS ${quoteTableName(table)} (${colDefs.join(", ")})`;
331
456
  }
@@ -340,9 +465,15 @@ function buildCreateTable(table, fields, foreignKeys, options) {
340
465
  *
341
466
  * Case-insensitive columns (`@db.collate 'nocase'`) are handled by CITEXT
342
467
  * column type at the storage level — no query-side wrapping needed.
468
+ *
469
+ * Relational predicates (`$some` / `$none`) render as correlated `EXISTS`
470
+ * subqueries referencing the outer table by `opts.qualifier` (default: the
471
+ * source table) — a statement with an aliased FROM must pass its alias.
472
+ *
473
+ * @param opts - since 0.1.147
343
474
  */
344
- function buildWhere(filter) {
345
- return buildWhere$1(pgDialect, filter);
475
+ function buildWhere(filter, opts) {
476
+ return buildWhere$1(pgDialect, filter, opts);
346
477
  }
347
478
  //#endregion
348
479
  //#region src/postgres-adapter.ts
@@ -368,6 +499,8 @@ function parseCount(value) {
368
499
  * ```
369
500
  */
370
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";
371
504
  const PG_CONSTRAINT_LABELS = {
372
505
  p: "pkey",
373
506
  u: "key",
@@ -425,7 +558,7 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
425
558
  _vectorThresholds = /* @__PURE__ */ new Map();
426
559
  /**
427
560
  * Schema name for catalog queries and qualification — `@db.schema` of the
428
- * bound table, or `null` (→ `'public'`, the same default the bound path
561
+ * bound table, or `null` (→ the connection's `current_schema()`, the same default the bound path
429
562
  * uses) when the table declares none or the adapter is an administrative
430
563
  * one with no readable (the name-taking schema-sync primitives run on such
431
564
  * an adapter).
@@ -481,6 +614,15 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
481
614
  _exec() {
482
615
  return this._txConnection() ?? this.driver;
483
616
  }
617
+ /**
618
+ * Relational predicates (`$some` / `$none`) render as correlated
619
+ * `[NOT] EXISTS` subqueries — in reads and in mutation filters alike.
620
+ *
621
+ * @since 0.1.147
622
+ */
623
+ supportsRelationFilters(_mode) {
624
+ return true;
625
+ }
484
626
  /** PostgreSQL enforces FK constraints natively. */
485
627
  supportsNativeForeignKeys() {
486
628
  return true;
@@ -501,10 +643,18 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
501
643
  calendarBucketUnits() {
502
644
  return ALL_BUCKET_UNITS;
503
645
  }
504
- /** Every aggregate function, `countDistinct` included. */
646
+ /** Every aggregate function: `countDistinct`, `first` and `last` included. */
505
647
  aggregateFns() {
506
648
  return ALL_AGGREGATE_FNS;
507
649
  }
650
+ /** Arithmetic in an aggregate `$select` (`{ $expr }`, `{ $fn, $expr }`). */
651
+ supportsAggregateExpressions() {
652
+ return true;
653
+ }
654
+ /** Computed view columns and first-row joins. */
655
+ viewCapabilities() {
656
+ return ALL_VIEW_CAPABILITIES;
657
+ }
508
658
  onBeforeFlatten(_type) {}
509
659
  onAfterFlatten() {
510
660
  for (const fd of this._table.fieldDescriptors) if (fd.collate === "nocase") this._nocaseColumns.add(fd.physicalName);
@@ -570,17 +720,21 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
570
720
  try {
571
721
  return await fn();
572
722
  } catch (error) {
573
- if (error && typeof error === "object" && "code" in error) {
574
- const err = error;
575
- if (err.code === "23505") throw new DbError("CONFLICT", [{
576
- path: this._extractFieldFromConstraint(err.constraint) ?? "",
577
- message: err.detail ?? err.message
578
- }]);
579
- if (err.code === "23503") throw new DbError("FK_VIOLATION", this._mapFkError(err.detail ?? err.message, err.constraint));
580
- }
581
- throw error;
723
+ return this._mapConstraintError(error);
582
724
  }
583
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));
735
+ }
736
+ throw error;
737
+ }
584
738
  _extractFieldFromConstraint(constraint) {
585
739
  if (!constraint) return;
586
740
  const tableName = this._table.tableName;
@@ -607,7 +761,7 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
607
761
  }
608
762
  async insertOne(data) {
609
763
  let { sql, params } = buildInsert$1(this.resolveTableName(), data);
610
- const pkCols = this._table.primaryKeys.map((pk) => qi(pk));
764
+ const pkCols = this._pkColumns().map((pk) => qi(pk));
611
765
  if (pkCols.length > 0) sql += ` RETURNING ${pkCols.join(", ")}`;
612
766
  this._log(sql, params);
613
767
  const returned = (await this._wrapConstraintError(() => this._exec().run(sql, params))).rows?.[0];
@@ -620,13 +774,11 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
620
774
  };
621
775
  return this.withTransaction(async () => {
622
776
  const tableName = this.resolveTableName();
623
- const pkCols = this._table.primaryKeys;
777
+ const pkCols = this._pkColumns();
624
778
  const returningSuffix = pkCols.length > 0 ? ` RETURNING ${pkCols.map((pk) => qi(pk)).join(", ")}` : "";
625
- const columns = insertManyColumns(data);
626
- const maxRowsPerBatch = columns.length > 0 ? Math.floor(6e4 / columns.length) : data.length;
779
+ const { columns, batches } = chunkInsertRows(data);
627
780
  const allIds = [];
628
- for (let offset = 0; offset < data.length; offset += maxRowsPerBatch) {
629
- const batch = data.slice(offset, offset + maxRowsPerBatch);
781
+ for (const batch of batches) {
630
782
  const insert = buildInsertMany$1(tableName, batch, columns);
631
783
  const sql = insert.sql + returningSuffix;
632
784
  const params = insert.params;
@@ -643,6 +795,56 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
643
795
  };
644
796
  });
645
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
+ }
646
848
  async findOne(query) {
647
849
  const where = buildWhere(query.filter);
648
850
  const controls = {
@@ -659,6 +861,16 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
659
861
  this._log(sql, params);
660
862
  return this._exec().all(sql, params);
661
863
  }
864
+ /**
865
+ * `$skip` / `$limit` per partition in one statement: a `ROW_NUMBER()`
866
+ * window over `partitionBy` (the generic `$with` loader's per-parent page).
867
+ */
868
+ async findManyPerPartition(query, partitionBy) {
869
+ const where = buildWhere(query.filter);
870
+ const { sql, params } = buildPartitionedSelect(pgDialect, this.resolveTableName(), where, query.controls, partitionBy);
871
+ this._log(sql, params);
872
+ return stripPartitionRowNumber(await this._exec().all(sql, params));
873
+ }
662
874
  async count(query) {
663
875
  const where = buildWhere(query.filter);
664
876
  const { sql, params } = finalizeParams(pgDialect, {
@@ -675,28 +887,15 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
675
887
  if (query.controls.$count) {
676
888
  const { sql, params } = buildAggregateCount$1(tableName, where, query.controls);
677
889
  this._log(sql, params);
678
- 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) }];
679
891
  }
680
892
  const { sql, params } = buildAggregateSelect$1(tableName, where, query.controls);
681
893
  this._log(sql, params);
682
- return this._wrapBucketZoneError(() => this._exec().all(sql, params));
894
+ return mapQueryErrors(pgDialect, () => this._exec().all(sql, params), query.controls);
683
895
  }
684
- /**
685
- * Maps PostgreSQL's unknown-zone error — SQLSTATE 22023
686
- * (`invalid_parameter_value`), `time zone "…" not recognized` — raised by a
687
- * calendar bucket's `AT TIME ZONE '<tz>'` to `BUCKET_TZ_UNAVAILABLE`. The
688
- * zone passed the core's IANA validation, so the server's tz database is
689
- * older than the runtime's. Other errors propagate unchanged.
690
- */
691
- async _wrapBucketZoneError(fn) {
692
- try {
693
- return await fn();
694
- } catch (error) {
695
- const err = error;
696
- const zone = err?.code === "22023" && typeof err.message === "string" ? /time zone "([^"]*)" not recognized/.exec(err.message)?.[1] : void 0;
697
- if (zone !== void 0) throw bucketTimeZoneUnavailable(`PostgreSQL does not recognize time zone "${zone}" — update the server's time zone data`);
698
- throw error;
699
- }
896
+ /** Physical primary-key columns (`@db.column` renames applied). */
897
+ _pkColumns() {
898
+ return this._table.primaryKeys.map((key) => this._table.physicalPath(key));
700
899
  }
701
900
  /**
702
901
  * The predicate that narrows a single-row UPDATE/DELETE to the first row
@@ -707,7 +906,7 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
707
906
  * existing behavior rather than guess.
708
907
  */
709
908
  _limitOnePredicate(quotedTable, whereSql) {
710
- const pkCols = this._table.primaryKeys;
909
+ const pkCols = this._pkColumns();
711
910
  const quotedKeys = pkCols.length > 0 ? pkCols.map((c) => qi(c)) : ["ctid"];
712
911
  const colList = quotedKeys.join(", ");
713
912
  return `${quotedKeys.length === 1 ? `${colList} =` : `(${colList}) IN`} (SELECT ${colList} FROM ${quotedTable} WHERE ${whereSql} LIMIT 1)`;
@@ -817,7 +1016,7 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
817
1016
  JOIN pg_class cl ON cl.oid = c.conrelid
818
1017
  JOIN pg_class rcl ON rcl.oid = c.confrelid
819
1018
  JOIN pg_namespace rn ON rn.oid = rcl.relnamespace
820
- WHERE c.contype = 'f' AND rcl.relname = $1 AND rn.nspname = COALESCE($2, 'public')
1019
+ WHERE c.contype = 'f' AND rcl.relname = $1 AND rn.nspname = COALESCE($2, current_schema())
821
1020
  ORDER BY cl.relname, c.conname`, [tableName, this._schema])).map((r) => ({
822
1021
  table: r.table_name,
823
1022
  fields: r.columns ?? [],
@@ -827,7 +1026,7 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
827
1026
  async getObjectKind(name) {
828
1027
  switch ((await this._exec().get(`SELECT c.relkind FROM pg_class c
829
1028
  JOIN pg_namespace n ON n.oid = c.relnamespace
830
- WHERE c.relname = $1 AND n.nspname = COALESCE($2, 'public')
1029
+ WHERE c.relname = $1 AND n.nspname = COALESCE($2, current_schema())
831
1030
  AND c.relkind IN ('r', 'p', 'v', 'm')`, [name, this._schema]))?.relkind) {
832
1031
  case "r":
833
1032
  case "p": return "table";
@@ -855,7 +1054,7 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
855
1054
  const row = await this._exec().get(`SELECT c.conname FROM pg_constraint c
856
1055
  JOIN pg_class cl ON cl.oid = c.conrelid
857
1056
  JOIN pg_namespace n ON n.oid = cl.relnamespace
858
- WHERE c.contype = 'p' AND cl.relname = $1 AND n.nspname = COALESCE($2, 'public')`, [this._table.tableName, this._schema]);
1057
+ WHERE c.contype = 'p' AND cl.relname = $1 AND n.nspname = COALESCE($2, current_schema())`, [this._table.tableName, this._schema]);
859
1058
  if (row?.conname) clauses.push(`DROP CONSTRAINT ${qi(row.conname)}`);
860
1059
  }
861
1060
  if (change.to.length > 0) clauses.push(`ADD PRIMARY KEY (${change.to.map((c) => qi(c)).join(", ")})`);
@@ -887,8 +1086,8 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
887
1086
  ) AS is_pk
888
1087
  FROM information_schema.columns c
889
1088
  JOIN pg_attribute a ON a.attname = c.column_name
890
- AND a.attrelid = (SELECT oid FROM pg_class WHERE relname = $1 AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = COALESCE($2, 'public')))
891
- WHERE c.table_name = $1 AND c.table_schema = COALESCE($2, 'public')
1089
+ AND a.attrelid = (SELECT oid FROM pg_class WHERE relname = $1 AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = COALESCE($2, current_schema())))
1090
+ WHERE c.table_name = $1 AND c.table_schema = COALESCE($2, current_schema())
892
1091
  ORDER BY c.ordinal_position`, [tableName, schema])).map((r) => {
893
1092
  const column = {
894
1093
  name: r.column_name,
@@ -1008,10 +1207,10 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1008
1207
  CROSS JOIN LATERAL unnest(c.conkey, c.confkey) WITH ORDINALITY AS k(attnum, refattnum, ord)
1009
1208
  JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
1010
1209
  JOIN pg_attribute ra ON ra.attrelid = c.confrelid AND ra.attnum = k.refattnum
1011
- WHERE c.contype = 'f' AND rcl.relname = $2 AND rn.nspname = COALESCE($1, 'public')
1210
+ WHERE c.contype = 'f' AND rcl.relname = $2 AND rn.nspname = COALESCE($1, current_schema())
1012
1211
  ORDER BY n.nspname, cl.relname, c.conname, k.ord`, [schema, this._table.tableName]);
1013
1212
  const inboundFks = /* @__PURE__ */ new Map();
1014
- const ownSchema = schema ?? "public";
1213
+ const ownSchema = schema ?? (await conn.get("SELECT current_schema() AS s"))?.s ?? "public";
1015
1214
  for (const fk of fkRefs) {
1016
1215
  if (fk.table_schema === ownSchema && fk.table_name === this._table.tableName) continue;
1017
1216
  const key = `${fk.table_schema}.${fk.table_name}.${fk.constraint_name}`;
@@ -1106,7 +1305,7 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1106
1305
  FROM pg_constraint c
1107
1306
  JOIN pg_class cl ON cl.oid = c.conrelid
1108
1307
  JOIN pg_namespace n ON n.oid = cl.relnamespace
1109
- WHERE cl.relname = $1 AND n.nspname = COALESCE($2, 'public')
1308
+ WHERE cl.relname = $1 AND n.nspname = COALESCE($2, current_schema())
1110
1309
  ORDER BY c.conname`, [table, this._schema]);
1111
1310
  const taken = new Set(rows.map((r) => r.conname));
1112
1311
  for (const { conname, contype, columns } of rows) {
@@ -1130,7 +1329,7 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1130
1329
  async _relationExists(conn, name) {
1131
1330
  return (await conn.get(`SELECT true AS present FROM pg_class c
1132
1331
  JOIN pg_namespace n ON n.oid = c.relnamespace
1133
- WHERE c.relname = $1 AND n.nspname = COALESCE($2, 'public')`, [name, this._schema]))?.present ?? false;
1332
+ WHERE c.relname = $1 AND n.nspname = COALESCE($2, current_schema())`, [name, this._schema]))?.present ?? false;
1134
1333
  }
1135
1334
  async afterSyncTable() {
1136
1335
  await this._resetIdentitySequences();
@@ -1153,7 +1352,7 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1153
1352
  }
1154
1353
  }
1155
1354
  async tableExists() {
1156
- return (await this._exec().get(`SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = $1 AND table_schema = COALESCE($2, 'public')) AS "exists"`, [this._table.tableName, this._schema]))?.exists ?? false;
1355
+ return (await this._exec().get(`SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = $1 AND table_schema = COALESCE($2, current_schema())) AS "exists"`, [this._table.tableName, this._schema]))?.exists ?? false;
1157
1356
  }
1158
1357
  /**
1159
1358
  * No CASCADE: when something outside the sync inventory (a user view, an
@@ -1182,7 +1381,7 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1182
1381
  JOIN pg_class i ON i.oid = x.indexrelid
1183
1382
  JOIN pg_namespace n ON n.oid = t.relnamespace
1184
1383
  JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(x.indkey)
1185
- WHERE t.relname = $1 AND n.nspname = COALESCE($2, 'public')
1384
+ WHERE t.relname = $1 AND n.nspname = COALESCE($2, current_schema())
1186
1385
  AND i.relname LIKE 'atscript__%' AND a.attname = ANY($3)`, [
1187
1386
  tableName,
1188
1387
  schema,
@@ -1234,7 +1433,7 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1234
1433
  JOIN pg_class t ON t.oid = x.indrelid
1235
1434
  JOIN pg_class i ON i.oid = x.indexrelid
1236
1435
  JOIN pg_namespace n ON n.oid = t.relnamespace
1237
- WHERE t.relname = $1 AND n.nspname = COALESCE($2, 'public')`, [tableName, schema])).map((r) => ({
1436
+ WHERE t.relname = $1 AND n.nspname = COALESCE($2, current_schema())`, [tableName, schema])).map((r) => ({
1238
1437
  name: r.name,
1239
1438
  columns: r.columns ?? void 0
1240
1439
  }));
@@ -1280,7 +1479,7 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1280
1479
  async syncForeignKeys() {
1281
1480
  const existingByName = await this._getExistingFkConstraints();
1282
1481
  const desiredFkKeys = /* @__PURE__ */ new Set();
1283
- for (const fk of this._table.foreignKeys.values()) desiredFkKeys.add([...fk.fields].toSorted().join(","));
1482
+ for (const fk of this._table.foreignKeys.values()) desiredFkKeys.add([...fkColumns(fk).fields].toSorted().join(","));
1284
1483
  for (const [constraintName, columns] of existingByName) {
1285
1484
  const key = [...columns].toSorted().join(",");
1286
1485
  if (!desiredFkKeys.has(key)) {
@@ -1291,13 +1490,9 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1291
1490
  }
1292
1491
  const existingKeys = new Set([...existingByName.values()].map((cols) => cols.toSorted().join(",")));
1293
1492
  for (const fk of this._table.foreignKeys.values()) {
1294
- const key = [...fk.fields].toSorted().join(",");
1493
+ const key = [...fkColumns(fk).fields].toSorted().join(",");
1295
1494
  if (!existingKeys.has(key)) {
1296
- const localCols = fk.fields.map((f) => qi(f)).join(", ");
1297
- const targetCols = fk.targetFields.map((f) => qi(f)).join(", ");
1298
- let ddl = `ALTER TABLE ${quoteTableName(this.resolveTableName())} ADD FOREIGN KEY (${localCols}) REFERENCES ${qi(fk.targetTable)} (${targetCols})`;
1299
- if (fk.onDelete) ddl += ` ON DELETE ${refActionToSql(fk.onDelete)}`;
1300
- if (fk.onUpdate) ddl += ` ON UPDATE ${refActionToSql(fk.onUpdate)}`;
1495
+ const ddl = `ALTER TABLE ${quoteTableName(this.resolveTableName())} ADD ${foreignKeySql(qi, fk)}`;
1301
1496
  this._log(ddl);
1302
1497
  await this._exec().exec(ddl);
1303
1498
  }
@@ -1324,7 +1519,7 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1324
1519
  JOIN pg_namespace n ON n.oid = cl.relnamespace
1325
1520
  CROSS JOIN LATERAL unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord)
1326
1521
  JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
1327
- WHERE c.contype = 'f' AND cl.relname = $1 AND n.nspname = COALESCE($2, 'public')
1522
+ WHERE c.contype = 'f' AND cl.relname = $1 AND n.nspname = COALESCE($2, current_schema())
1328
1523
  ORDER BY c.conname, k.ord`, [this._table.tableName, this._schema]);
1329
1524
  const byName = /* @__PURE__ */ new Map();
1330
1525
  for (const row of rows) {
@@ -1469,7 +1664,7 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1469
1664
  vec = first.value[1];
1470
1665
  }
1471
1666
  const distanceOp = similarityToPgOp(vec.similarity);
1472
- const where = buildWhere(query.filter);
1667
+ const where = buildWhere(query.filter, { qualifier: pgDialect.quoteTable(SEARCH_SOURCE_ALIAS) });
1473
1668
  const controls = query.controls || {};
1474
1669
  const threshold = this._resolveVectorThreshold(controls, vec.indexName);
1475
1670
  return {
@@ -1562,7 +1757,7 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1562
1757
  const controls = query.controls ?? {};
1563
1758
  return {
1564
1759
  tableName: this.resolveTableName(),
1565
- where: buildWhere(query.filter),
1760
+ where: buildWhere(query.filter, { qualifier: pgDialect.quoteTable(SEARCH_SOURCE_ALIAS) }),
1566
1761
  dist: pgGeoDistanceExpr(qi(column), point),
1567
1762
  window: geoWindowFromControls(controls),
1568
1763
  controls
@@ -1815,8 +2010,10 @@ var PgDriver = class {
1815
2010
  }
1816
2011
  };
1817
2012
  }
1818
- async close() {
1819
- 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());
1820
2017
  }
1821
2018
  };
1822
2019
  //#endregion
@@ -1833,7 +2030,7 @@ function createAdapter(uri, options) {
1833
2030
  connectionString: uri,
1834
2031
  ...options
1835
2032
  });
1836
- return new DbSpace(() => new PostgresAdapter(driver));
2033
+ return new DbSpace(() => new PostgresAdapter(driver), { onClose: () => driver.close() });
1837
2034
  }
1838
2035
  //#endregion
1839
2036
  export { PgDriver, PostgresAdapter, buildWhere, createAdapter };