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