@atscript/db-postgres 0.1.161 → 0.1.162

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
@@ -152,6 +152,7 @@ const pgDialect = {
152
152
  return quoteTableName(name);
153
153
  },
154
154
  unlimitedLimit: "ALL",
155
+ stringLiteral: _atscript_db_sql_tools.sqlStringLiteral,
155
156
  toValue: toPgValue,
156
157
  toParam(value) {
157
158
  if (value === void 0) return null;
@@ -310,8 +311,8 @@ function buildInsert(table, data) {
310
311
  function buildInsertMany(table, rows, columns) {
311
312
  return (0, _atscript_db_sql_tools.buildInsertMany)(pgDialect, table, rows, columns);
312
313
  }
313
- function buildSelect(table, where, controls) {
314
- return (0, _atscript_db_sql_tools.buildSelect)(pgDialect, table, where, controls);
314
+ function buildSelect(table, where, controls, lock) {
315
+ return (0, _atscript_db_sql_tools.buildSelect)(pgDialect, table, where, controls, lock);
315
316
  }
316
317
  function buildUpdate(table, data, where, limit, ops, versionColumn, expectedVersion) {
317
318
  return (0, _atscript_db_sql_tools.buildUpdate)(pgDialect, table, data, where, limit, ops, versionColumn, expectedVersion);
@@ -613,6 +614,8 @@ function truncateBytes(s, bytes) {
613
614
  while (byteLength(out) > bytes) out = out.slice(0, -1);
614
615
  return out;
615
616
  }
617
+ /** Name prefix of the pgvector HNSW indexes (managed outside the model's `indexes`). */
618
+ const PG_VECTOR_INDEX_PREFIX = "atscript__vec_";
616
619
  /**
617
620
  * The name PostgreSQL generates for an unnamed constraint (`makeObjectName`):
618
621
  * `<table>_<columns joined by _>_<label>`, with the table and column parts
@@ -708,11 +711,12 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
708
711
  _txConnection() {
709
712
  return this._getTransactionState();
710
713
  }
711
- async _beginTransaction() {
714
+ async _beginTransaction(opts) {
712
715
  const conn = await this.driver.getConnection();
713
716
  try {
714
- await conn.exec("BEGIN");
715
- this._log("BEGIN");
717
+ const sql = opts?.isolation ? `BEGIN ISOLATION LEVEL ${opts.isolation.toUpperCase()}` : "BEGIN";
718
+ await conn.exec(sql);
719
+ this._log(sql);
716
720
  return conn;
717
721
  } catch (err) {
718
722
  conn.release();
@@ -819,6 +823,18 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
819
823
  supportsNullsPlacement() {
820
824
  return true;
821
825
  }
826
+ /**
827
+ * Locking reads: `SELECT … FOR UPDATE` / `FOR SHARE` [`NOWAIT` /
828
+ * `SKIP LOCKED`]. Under REPEATABLE READ / SERIALIZABLE, locking a row that
829
+ * changed since the snapshot fails with `SERIALIZATION_FAILURE`. @since 0.1.162
830
+ */
831
+ supportsReadLocks() {
832
+ return true;
833
+ }
834
+ /** Every level, via `BEGIN ISOLATION LEVEL …`. @since 0.1.162 */
835
+ isolationLevels() {
836
+ return _atscript_db.ALL_ISOLATION_LEVELS;
837
+ }
822
838
  /** Computed view columns and first-row joins. */
823
839
  viewCapabilities() {
824
840
  return _atscript_db.ALL_VIEW_CAPABILITIES;
@@ -1086,7 +1102,7 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1086
1102
  if (clauses.length === 0) continue;
1087
1103
  const sql = `SELECT ${selectCols} FROM ${quoteTableName(this.resolveTableName())} WHERE ${clauses.join(" OR ")} ORDER BY ${order} FOR UPDATE`;
1088
1104
  this._log(sql, params);
1089
- const found = await this._wrapConstraintError(() => this._exec().all(sql, params));
1105
+ const found = await this._exec().all(sql, params).catch((error) => this._mapConstraintError(error));
1090
1106
  for (const doc of found) keySets.forEach((fields, k) => {
1091
1107
  const tuple = (0, _atscript_db.uniqueKeyTuple)(doc, fields);
1092
1108
  if (tuple !== void 0) lockedTuples[k].add(tuple);
@@ -1128,15 +1144,15 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1128
1144
  ...query.controls,
1129
1145
  $limit: 1
1130
1146
  };
1131
- const { sql, params } = buildSelect(this.resolveTableName(), where, controls);
1147
+ const { sql, params } = buildSelect(this.resolveTableName(), where, controls, query.lock);
1132
1148
  this._log(sql, params);
1133
- return this._exec().get(sql, params);
1149
+ return this._exec().get(sql, params).catch((error) => this._mapConstraintError(error));
1134
1150
  }
1135
1151
  async findMany(query) {
1136
1152
  const where = buildWhere(query.filter);
1137
- const { sql, params } = buildSelect(this.resolveTableName(), where, query.controls);
1153
+ const { sql, params } = buildSelect(this.resolveTableName(), where, query.controls, query.lock);
1138
1154
  this._log(sql, params);
1139
- return this._exec().all(sql, params);
1155
+ return this._exec().all(sql, params).catch((error) => this._mapConstraintError(error));
1140
1156
  }
1141
1157
  /**
1142
1158
  * `$skip` / `$limit` per partition in one statement: a `ROW_NUMBER()`
@@ -1241,8 +1257,21 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1241
1257
  modifiedCount: result.affectedRows
1242
1258
  };
1243
1259
  }
1260
+ /**
1261
+ * Bounded writes (since 0.1.162): PostgreSQL has no `UPDATE/DELETE … LIMIT`,
1262
+ * so the statement is re-keyed on the primary key through an ordered,
1263
+ * limited subquery — the `deleteOne` / `updateOne` pattern with `n` rows.
1264
+ */
1265
+ supportsWriteLimit() {
1266
+ return true;
1267
+ }
1268
+ /** `where` narrowed to a bounded write's rows, or `where` itself when unbounded. */
1269
+ _boundedWhere(where, bound) {
1270
+ if (!bound) return where;
1271
+ return (0, _atscript_db_sql_tools.writeLimitWhere)(pgDialect, quoteTableName(this.resolveTableName()), this._pk().cols.map((c) => qi(c)), where, bound);
1272
+ }
1244
1273
  async updateMany(filter, data, ops, opts) {
1245
- const where = buildWhere(filter);
1274
+ const where = this._boundedWhere(buildWhere(filter), opts?.limit);
1246
1275
  const versionColumn = this._versionColumnFor(opts);
1247
1276
  const { sql, params } = buildUpdate(this.resolveTableName(), data, where, void 0, ops, versionColumn);
1248
1277
  this._log(sql, params);
@@ -1253,8 +1282,8 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1253
1282
  };
1254
1283
  }
1255
1284
  async replaceOne(filter, data, expectedVersion) {
1256
- const full = (0, _atscript_db_sql_tools.fillReplacePayload)(data, this._replaceColumns(), this._table.versionColumnPhysical);
1257
- return this.updateOne(filter, full, void 0, expectedVersion);
1285
+ const full = (0, _atscript_db_sql_tools.fillReplacePayload)(data, this._replaceColumns(), this._table.versionColumnPhysical, this._table.onUpdateIncPhysical);
1286
+ return this.updateOne(filter, full, this._onUpdateIncOps(), expectedVersion);
1258
1287
  }
1259
1288
  /** The columns a full replace assigns (`replaceColumnsFor`), built once per table. */
1260
1289
  _replaceColumns() {
@@ -1267,7 +1296,7 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1267
1296
  return memo.cols;
1268
1297
  }
1269
1298
  async replaceMany(filter, data) {
1270
- return this.updateMany(filter, data);
1299
+ return this.updateMany(filter, data, this._onUpdateIncOps());
1271
1300
  }
1272
1301
  async deleteOne(filter) {
1273
1302
  const where = buildWhere(filter);
@@ -1280,8 +1309,8 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1280
1309
  this._log(sql, params);
1281
1310
  return { deletedCount: (await this._wrapConstraintError(() => this._exec().run(sql, params))).affectedRows };
1282
1311
  }
1283
- async deleteMany(filter) {
1284
- return this._deleteWhere(buildWhere(filter));
1312
+ async deleteMany(filter, opts) {
1313
+ return this._deleteWhere(this._boundedWhere(buildWhere(filter), opts?.limit));
1285
1314
  }
1286
1315
  async _deleteWhere(where) {
1287
1316
  const { sql, params } = buildDelete(this.resolveTableName(), where);
@@ -1773,13 +1802,23 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1773
1802
  if (field.isGeoPoint) return this._supportsGeo ? "geography(Point,4326)" : pgTypeFromField(field);
1774
1803
  return pgTypeFromField(field);
1775
1804
  }
1776
- async syncIndexes() {
1805
+ async _syncIndexes(dryRun) {
1777
1806
  const tableName = this._table.tableName;
1778
1807
  const schema = this._schema;
1779
1808
  await this.prepareTypeMapper();
1780
- await this.syncIndexesWithDiff({
1809
+ const dropIndex = async (name) => {
1810
+ const sql = `DROP INDEX IF EXISTS ${schema ? `${qi(schema)}.` : ""}${qi(name)}`;
1811
+ this._log(sql);
1812
+ await this._exec().exec(sql);
1813
+ };
1814
+ /** Every index name on the table (read by `listExisting`) — the vector pass checks it. */
1815
+ let liveNames = /* @__PURE__ */ new Set();
1816
+ const changes = await this.syncIndexesWithDiff({
1817
+ dryRun,
1818
+ isUnmanaged: (name) => name.startsWith(PG_VECTOR_INDEX_PREFIX),
1819
+ skipIndex: (index) => index.type === "geo" && !this._supportsGeo ? `[postgres] geo index "${index.name}" declared but PostGIS is not available — skipped` : void 0,
1781
1820
  listExisting: async () => {
1782
- return (await this._exec().all(`SELECT i.relname AS name,
1821
+ const rows = await this._exec().all(`SELECT i.relname AS name,
1783
1822
  (SELECT array_agg(a.attname::text ORDER BY k.ord)
1784
1823
  FROM unnest(x.indkey) WITH ORDINALITY AS k(attnum, ord)
1785
1824
  JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum
@@ -1788,7 +1827,9 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1788
1827
  JOIN pg_class t ON t.oid = x.indrelid
1789
1828
  JOIN pg_class i ON i.oid = x.indexrelid
1790
1829
  JOIN pg_namespace n ON n.oid = t.relnamespace
1791
- WHERE t.relname = $1 AND n.nspname = COALESCE($2, current_schema())`, [tableName, schema])).map((r) => ({
1830
+ WHERE t.relname = $1 AND n.nspname = COALESCE($2, current_schema())`, [tableName, schema]);
1831
+ liveNames = new Set(rows.map((r) => r.name));
1832
+ return rows.map((r) => ({
1792
1833
  name: r.name,
1793
1834
  columns: r.columns ?? void 0
1794
1835
  }));
@@ -1804,10 +1845,6 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1804
1845
  return;
1805
1846
  }
1806
1847
  if (index.type === "geo") {
1807
- if (!this._supportsGeo) {
1808
- this.logger.warn(`[postgres] geo index "${index.name}" declared but PostGIS is not available — skipped`);
1809
- return;
1810
- }
1811
1848
  const sql = `CREATE INDEX IF NOT EXISTS ${qi(index.key)} ON ${quoteTableName(this.resolveTableName())} USING gist (${qi(index.fields[0].name)})`;
1812
1849
  this._log(sql);
1813
1850
  await this._exec().exec(sql);
@@ -1819,54 +1856,57 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1819
1856
  this._log(sql);
1820
1857
  await this._exec().exec(sql);
1821
1858
  },
1822
- dropIndex: async (name) => {
1823
- const sql = `DROP INDEX IF EXISTS ${schema ? `${qi(schema)}.` : ""}${qi(name)}`;
1824
- this._log(sql);
1825
- await this._exec().exec(sql);
1826
- }
1859
+ dropIndex
1827
1860
  });
1861
+ const desiredVector = new Set(this._supportsVector ? [...this._vectorFields.values()].map((v) => `${PG_VECTOR_INDEX_PREFIX}${v.indexName}`) : []);
1862
+ const vector = (0, _atscript_db.createChangeRecorder)(dryRun);
1863
+ for (const name of liveNames) if (name.startsWith(PG_VECTOR_INDEX_PREFIX) && !desiredVector.has(name)) await vector.apply({
1864
+ name,
1865
+ action: "drop"
1866
+ }, name, () => dropIndex(name));
1828
1867
  if (this._supportsVector) for (const [field, vec] of this._vectorFields) {
1829
- const indexName = `atscript__vec_${vec.indexName}`;
1830
- const opsClass = similarityToPgOps(vec.similarity);
1831
- const sql = `CREATE INDEX IF NOT EXISTS ${qi(indexName)} ON ${quoteTableName(this.resolveTableName())} USING hnsw (${qi(field)} ${opsClass})`;
1832
- this._log(sql);
1833
- await this._exec().exec(sql);
1868
+ const indexName = `${PG_VECTOR_INDEX_PREFIX}${vec.indexName}`;
1869
+ if (liveNames.has(indexName)) continue;
1870
+ await vector.apply({
1871
+ name: indexName,
1872
+ action: "add"
1873
+ }, indexName, async () => {
1874
+ const opsClass = similarityToPgOps(vec.similarity);
1875
+ const sql = `CREATE INDEX IF NOT EXISTS ${qi(indexName)} ON ${quoteTableName(this.resolveTableName())} USING hnsw (${qi(field)} ${opsClass})`;
1876
+ this._log(sql);
1877
+ await this._exec().exec(sql);
1878
+ });
1834
1879
  }
1880
+ return [...changes, ...vector.changes];
1835
1881
  }
1836
1882
  async syncForeignKeys() {
1837
- const existingByName = await this._getExistingFkConstraints();
1838
- const desiredFkKeys = /* @__PURE__ */ new Set();
1839
- for (const fk of this._table.foreignKeys.values()) desiredFkKeys.add([...(0, _atscript_db.fkColumns)(fk).fields].toSorted().join(","));
1840
- for (const [constraintName, columns] of existingByName) {
1841
- const key = [...columns].toSorted().join(",");
1842
- if (!desiredFkKeys.has(key)) {
1843
- const ddl = `ALTER TABLE ${quoteTableName(this.resolveTableName())} DROP CONSTRAINT ${qi(constraintName)}`;
1844
- this._log(ddl);
1845
- await this._exec().exec(ddl);
1846
- }
1847
- }
1848
- const existingKeys = new Set([...existingByName.values()].map((cols) => cols.toSorted().join(",")));
1849
- for (const fk of this._table.foreignKeys.values()) {
1850
- const key = [...(0, _atscript_db.fkColumns)(fk).fields].toSorted().join(",");
1851
- if (!existingKeys.has(key)) {
1883
+ return this._syncForeignKeys(false);
1884
+ }
1885
+ /** The FK changes {@link syncForeignKeys} would make — no DDL. */
1886
+ async planForeignKeys() {
1887
+ return this._syncForeignKeys(true);
1888
+ }
1889
+ _syncForeignKeys(dryRun) {
1890
+ return this.syncForeignKeysWithDiff({
1891
+ dryRun,
1892
+ listExisting: () => this._getExistingFkConstraints(),
1893
+ dropConstraint: (name) => this._dropFkConstraint(name),
1894
+ addConstraint: async (fk) => {
1852
1895
  const ddl = `ALTER TABLE ${quoteTableName(this.resolveTableName())} ADD ${(0, _atscript_db_sql_tools.foreignKeySql)(qi, fk)}`;
1853
1896
  this._log(ddl);
1854
1897
  await this._exec().exec(ddl);
1855
1898
  }
1856
- }
1899
+ });
1857
1900
  }
1858
1901
  async dropForeignKeys(fkFieldKeys) {
1859
1902
  if (fkFieldKeys.length === 0) return;
1860
1903
  const keySet = new Set(fkFieldKeys);
1861
- const existingByName = await this._getExistingFkConstraints();
1862
- for (const [constraintName, cols] of existingByName) {
1863
- const key = cols.toSorted().join(",");
1864
- if (keySet.has(key)) {
1865
- const ddl = `ALTER TABLE ${quoteTableName(this.resolveTableName())} DROP CONSTRAINT ${qi(constraintName)}`;
1866
- this._log(ddl);
1867
- await this._exec().exec(ddl);
1868
- }
1869
- }
1904
+ for (const [constraintName, cols] of await this._getExistingFkConstraints()) if (keySet.has((0, _atscript_db.fkKey)(cols))) await this._dropFkConstraint(constraintName);
1905
+ }
1906
+ async _dropFkConstraint(name) {
1907
+ const ddl = `ALTER TABLE ${quoteTableName(this.resolveTableName())} DROP CONSTRAINT ${qi(name)}`;
1908
+ this._log(ddl);
1909
+ await this._exec().exec(ddl);
1870
1910
  }
1871
1911
  /** Queries information_schema for existing FK constraints. */
1872
1912
  async _getExistingFkConstraints() {
@@ -2304,10 +2344,8 @@ function normalizePgDefault(value, isIdentity) {
2304
2344
  if (lower.includes("extract") && lower.includes("epoch") && lower.includes("now()")) return "fn:now";
2305
2345
  if (lower === "gen_random_uuid()") return "fn:uuid";
2306
2346
  if (lower.startsWith("nextval(")) return "fn:increment";
2307
- const castMatch = cleaned.match(/^'(.*)'::[\w\s]+$/);
2308
- if (castMatch) return castMatch[1].replace(/''/g, "'");
2309
- if (cleaned.startsWith("'") && cleaned.endsWith("'")) return cleaned.slice(1, -1).replace(/''/g, "'");
2310
- return cleaned;
2347
+ const castMatch = cleaned.match(/^('.*')::[\w\s]+$/);
2348
+ return (0, _atscript_db_sql_tools.unquoteSqlStringLiteral)(castMatch ? castMatch[1] : cleaned) ?? cleaned;
2311
2349
  }
2312
2350
  /**
2313
2351
  * Converts a normalized similarity threshold (0-1) to a pgvector max distance.
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteResult, TDbFieldMeta, TDbInsertIgnoreOptions, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateOptions, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TFieldOps, TGenericLogger, TJsonCopyTarget, TPrimaryKeyChange, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TValueFormatterPair, TViewCapability } from "@atscript/db";
1
+ import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteManyOptions, TDbDeleteResult, TDbFieldMeta, TDbInsertIgnoreOptions, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbIsolationLevel, TDbObjectKind, TDbUpdateOptions, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TFieldOps, TForeignKeyChange, TGenericLogger, TIndexChange, TJsonCopyTarget, TPrimaryKeyChange, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TTransactionOptions, 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
 
@@ -134,7 +134,7 @@ declare class PostgresAdapter extends BaseDbAdapter {
134
134
  protected _transactionOwner(): unknown;
135
135
  /** The dedicated connection of this pool's open transaction, if any. */
136
136
  private _txConnection;
137
- protected _beginTransaction(): Promise<TPgConnection>;
137
+ protected _beginTransaction(opts?: TTransactionOptions): Promise<TPgConnection>;
138
138
  protected _commitTransaction(state: unknown): Promise<void>;
139
139
  protected _rollbackTransaction(state: unknown): Promise<void>;
140
140
  /**
@@ -178,6 +178,14 @@ declare class PostgresAdapter extends BaseDbAdapter {
178
178
  supportsAggregateExpressions(): boolean;
179
179
  /** `$nulls` / `@db.sort.nulls` NULL placement — `NULLS FIRST` / `NULLS LAST`. */
180
180
  supportsNullsPlacement(): boolean;
181
+ /**
182
+ * Locking reads: `SELECT … FOR UPDATE` / `FOR SHARE` [`NOWAIT` /
183
+ * `SKIP LOCKED`]. Under REPEATABLE READ / SERIALIZABLE, locking a row that
184
+ * changed since the snapshot fails with `SERIALIZATION_FAILURE`. @since 0.1.162
185
+ */
186
+ supportsReadLocks(): boolean;
187
+ /** Every level, via `BEGIN ISOLATION LEVEL …`. @since 0.1.162 */
188
+ isolationLevels(): ReadonlySet<TDbIsolationLevel>;
181
189
  /** Computed view columns and first-row joins. */
182
190
  viewCapabilities(): ReadonlySet<TViewCapability>;
183
191
  onBeforeFlatten(_type: unknown): void;
@@ -274,13 +282,21 @@ declare class PostgresAdapter extends BaseDbAdapter {
274
282
  */
275
283
  private _limitOnePredicate;
276
284
  updateOne(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps, expectedVersion?: number, opts?: TDbUpdateOptions): Promise<TDbUpdateResult>;
285
+ /**
286
+ * Bounded writes (since 0.1.162): PostgreSQL has no `UPDATE/DELETE … LIMIT`,
287
+ * so the statement is re-keyed on the primary key through an ordered,
288
+ * limited subquery — the `deleteOne` / `updateOne` pattern with `n` rows.
289
+ */
290
+ supportsWriteLimit(): boolean;
291
+ /** `where` narrowed to a bounded write's rows, or `where` itself when unbounded. */
292
+ private _boundedWhere;
277
293
  updateMany(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps, opts?: TDbUpdateOptions): Promise<TDbUpdateResult>;
278
294
  replaceOne(filter: FilterExpr, data: Record<string, unknown>, expectedVersion?: number): Promise<TDbUpdateResult>;
279
295
  /** The columns a full replace assigns (`replaceColumnsFor`), built once per table. */
280
296
  private _replaceColumns;
281
297
  replaceMany(filter: FilterExpr, data: Record<string, unknown>): Promise<TDbUpdateResult>;
282
298
  deleteOne(filter: FilterExpr): Promise<TDbDeleteResult>;
283
- deleteMany(filter: FilterExpr): Promise<TDbDeleteResult>;
299
+ deleteMany(filter: FilterExpr, opts?: TDbDeleteManyOptions): Promise<TDbDeleteResult>;
284
300
  private _deleteWhere;
285
301
  prepareTypeMapper(): Promise<void>;
286
302
  /**
@@ -356,9 +372,13 @@ declare class PostgresAdapter extends BaseDbAdapter {
356
372
  dropViewByName(viewName: string): Promise<void>;
357
373
  renameTable(oldName: string): Promise<void>;
358
374
  typeMapper(field: TDbFieldMeta): string;
359
- syncIndexes(): Promise<void>;
360
- syncForeignKeys(): Promise<void>;
375
+ protected _syncIndexes(dryRun: boolean): Promise<TIndexChange[]>;
376
+ syncForeignKeys(): Promise<TForeignKeyChange[]>;
377
+ /** The FK changes {@link syncForeignKeys} would make — no DDL. */
378
+ planForeignKeys(): Promise<TForeignKeyChange[]>;
379
+ private _syncForeignKeys;
361
380
  dropForeignKeys(fkFieldKeys: string[]): Promise<void>;
381
+ private _dropFkConstraint;
362
382
  /** Queries information_schema for existing FK constraints. */
363
383
  private _getExistingFkConstraints;
364
384
  getSearchIndexes(): TSearchIndexInfo[];
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteResult, TDbFieldMeta, TDbInsertIgnoreOptions, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateOptions, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TFieldOps, TGenericLogger, TJsonCopyTarget, TPrimaryKeyChange, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TValueFormatterPair, TViewCapability } from "@atscript/db";
1
+ import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteManyOptions, TDbDeleteResult, TDbFieldMeta, TDbInsertIgnoreOptions, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbIsolationLevel, TDbObjectKind, TDbUpdateOptions, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TFieldOps, TForeignKeyChange, TGenericLogger, TIndexChange, TJsonCopyTarget, TPrimaryKeyChange, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TTransactionOptions, 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
 
@@ -134,7 +134,7 @@ declare class PostgresAdapter extends BaseDbAdapter {
134
134
  protected _transactionOwner(): unknown;
135
135
  /** The dedicated connection of this pool's open transaction, if any. */
136
136
  private _txConnection;
137
- protected _beginTransaction(): Promise<TPgConnection>;
137
+ protected _beginTransaction(opts?: TTransactionOptions): Promise<TPgConnection>;
138
138
  protected _commitTransaction(state: unknown): Promise<void>;
139
139
  protected _rollbackTransaction(state: unknown): Promise<void>;
140
140
  /**
@@ -178,6 +178,14 @@ declare class PostgresAdapter extends BaseDbAdapter {
178
178
  supportsAggregateExpressions(): boolean;
179
179
  /** `$nulls` / `@db.sort.nulls` NULL placement — `NULLS FIRST` / `NULLS LAST`. */
180
180
  supportsNullsPlacement(): boolean;
181
+ /**
182
+ * Locking reads: `SELECT … FOR UPDATE` / `FOR SHARE` [`NOWAIT` /
183
+ * `SKIP LOCKED`]. Under REPEATABLE READ / SERIALIZABLE, locking a row that
184
+ * changed since the snapshot fails with `SERIALIZATION_FAILURE`. @since 0.1.162
185
+ */
186
+ supportsReadLocks(): boolean;
187
+ /** Every level, via `BEGIN ISOLATION LEVEL …`. @since 0.1.162 */
188
+ isolationLevels(): ReadonlySet<TDbIsolationLevel>;
181
189
  /** Computed view columns and first-row joins. */
182
190
  viewCapabilities(): ReadonlySet<TViewCapability>;
183
191
  onBeforeFlatten(_type: unknown): void;
@@ -274,13 +282,21 @@ declare class PostgresAdapter extends BaseDbAdapter {
274
282
  */
275
283
  private _limitOnePredicate;
276
284
  updateOne(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps, expectedVersion?: number, opts?: TDbUpdateOptions): Promise<TDbUpdateResult>;
285
+ /**
286
+ * Bounded writes (since 0.1.162): PostgreSQL has no `UPDATE/DELETE … LIMIT`,
287
+ * so the statement is re-keyed on the primary key through an ordered,
288
+ * limited subquery — the `deleteOne` / `updateOne` pattern with `n` rows.
289
+ */
290
+ supportsWriteLimit(): boolean;
291
+ /** `where` narrowed to a bounded write's rows, or `where` itself when unbounded. */
292
+ private _boundedWhere;
277
293
  updateMany(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps, opts?: TDbUpdateOptions): Promise<TDbUpdateResult>;
278
294
  replaceOne(filter: FilterExpr, data: Record<string, unknown>, expectedVersion?: number): Promise<TDbUpdateResult>;
279
295
  /** The columns a full replace assigns (`replaceColumnsFor`), built once per table. */
280
296
  private _replaceColumns;
281
297
  replaceMany(filter: FilterExpr, data: Record<string, unknown>): Promise<TDbUpdateResult>;
282
298
  deleteOne(filter: FilterExpr): Promise<TDbDeleteResult>;
283
- deleteMany(filter: FilterExpr): Promise<TDbDeleteResult>;
299
+ deleteMany(filter: FilterExpr, opts?: TDbDeleteManyOptions): Promise<TDbDeleteResult>;
284
300
  private _deleteWhere;
285
301
  prepareTypeMapper(): Promise<void>;
286
302
  /**
@@ -356,9 +372,13 @@ declare class PostgresAdapter extends BaseDbAdapter {
356
372
  dropViewByName(viewName: string): Promise<void>;
357
373
  renameTable(oldName: string): Promise<void>;
358
374
  typeMapper(field: TDbFieldMeta): string;
359
- syncIndexes(): Promise<void>;
360
- syncForeignKeys(): Promise<void>;
375
+ protected _syncIndexes(dryRun: boolean): Promise<TIndexChange[]>;
376
+ syncForeignKeys(): Promise<TForeignKeyChange[]>;
377
+ /** The FK changes {@link syncForeignKeys} would make — no DDL. */
378
+ planForeignKeys(): Promise<TForeignKeyChange[]>;
379
+ private _syncForeignKeys;
361
380
  dropForeignKeys(fkFieldKeys: string[]): Promise<void>;
381
+ private _dropFkConstraint;
362
382
  /** Queries information_schema for existing FK constraints. */
363
383
  private _getExistingFkConstraints;
364
384
  getSearchIndexes(): TSearchIndexInfo[];
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import { ALL_AGGREGATE_FNS, ALL_BUCKET_UNITS, ALL_VIEW_CAPABILITIES, BaseDbAdapter, DbError, DbSpace, bucketTimeZoneUnavailable, defaultFulltextIndex, describeFulltext, fkColumns, isColumnTypeChanged, searchTermInteger, splitFulltextFields, uniqueKeyTuple, vectorIndexNotFoundMessage } from "@atscript/db";
1
+ import { ALL_AGGREGATE_FNS, ALL_BUCKET_UNITS, ALL_ISOLATION_LEVELS, ALL_VIEW_CAPABILITIES, BaseDbAdapter, DbError, DbSpace, bucketTimeZoneUnavailable, createChangeRecorder, defaultFulltextIndex, describeFulltext, fkKey, isColumnTypeChanged, searchTermInteger, splitFulltextFields, uniqueKeyTuple, vectorIndexNotFoundMessage } from "@atscript/db";
2
2
  import { resolveAggregateSearch } from "@atscript/db/agg";
3
- import { SEARCH_SOURCE_ALIAS, arithOverflowError, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildJsonColumnCopy, buildJsonifyText, buildKeyViolationCount, buildPartitionedSelect, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere as buildWhere$1, chunkInsertRows, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, finalizeParams, foreignKeySql, geoWindowFromControls, mapQueryErrors, normalizeGeoPointValue, numericOutOfRangeError, orFragment, parseRegexString, quotedJsonPathSegments, renameGeoDistance, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, stripPartitionRowNumber, vectorDistanceSource } from "@atscript/db-sql-tools";
3
+ import { SEARCH_SOURCE_ALIAS, arithOverflowError, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildJsonColumnCopy, buildJsonifyText, buildKeyViolationCount, buildPartitionedSelect, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere as buildWhere$1, chunkInsertRows, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, finalizeParams, foreignKeySql, geoWindowFromControls, mapQueryErrors, normalizeGeoPointValue, numericOutOfRangeError, orFragment, parseRegexString, quotedJsonPathSegments, renameGeoDistance, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, stripPartitionRowNumber, unquoteSqlStringLiteral, vectorDistanceSource, writeLimitWhere } from "@atscript/db-sql-tools";
4
4
  import { BUCKET_MAX_INSTANT, BUCKET_MIN_INSTANT } from "@uniqu/core";
5
5
  //#region src/insert-ignore.ts
6
6
  /**
@@ -151,6 +151,7 @@ const pgDialect = {
151
151
  return quoteTableName(name);
152
152
  },
153
153
  unlimitedLimit: "ALL",
154
+ stringLiteral: sqlStringLiteral,
154
155
  toValue: toPgValue,
155
156
  toParam(value) {
156
157
  if (value === void 0) return null;
@@ -309,8 +310,8 @@ function buildInsert$1(table, data) {
309
310
  function buildInsertMany$1(table, rows, columns) {
310
311
  return buildInsertMany(pgDialect, table, rows, columns);
311
312
  }
312
- function buildSelect$1(table, where, controls) {
313
- return buildSelect(pgDialect, table, where, controls);
313
+ function buildSelect$1(table, where, controls, lock) {
314
+ return buildSelect(pgDialect, table, where, controls, lock);
314
315
  }
315
316
  function buildUpdate$1(table, data, where, limit, ops, versionColumn, expectedVersion) {
316
317
  return buildUpdate(pgDialect, table, data, where, limit, ops, versionColumn, expectedVersion);
@@ -612,6 +613,8 @@ function truncateBytes(s, bytes) {
612
613
  while (byteLength(out) > bytes) out = out.slice(0, -1);
613
614
  return out;
614
615
  }
616
+ /** Name prefix of the pgvector HNSW indexes (managed outside the model's `indexes`). */
617
+ const PG_VECTOR_INDEX_PREFIX = "atscript__vec_";
615
618
  /**
616
619
  * The name PostgreSQL generates for an unnamed constraint (`makeObjectName`):
617
620
  * `<table>_<columns joined by _>_<label>`, with the table and column parts
@@ -707,11 +710,12 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
707
710
  _txConnection() {
708
711
  return this._getTransactionState();
709
712
  }
710
- async _beginTransaction() {
713
+ async _beginTransaction(opts) {
711
714
  const conn = await this.driver.getConnection();
712
715
  try {
713
- await conn.exec("BEGIN");
714
- this._log("BEGIN");
716
+ const sql = opts?.isolation ? `BEGIN ISOLATION LEVEL ${opts.isolation.toUpperCase()}` : "BEGIN";
717
+ await conn.exec(sql);
718
+ this._log(sql);
715
719
  return conn;
716
720
  } catch (err) {
717
721
  conn.release();
@@ -818,6 +822,18 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
818
822
  supportsNullsPlacement() {
819
823
  return true;
820
824
  }
825
+ /**
826
+ * Locking reads: `SELECT … FOR UPDATE` / `FOR SHARE` [`NOWAIT` /
827
+ * `SKIP LOCKED`]. Under REPEATABLE READ / SERIALIZABLE, locking a row that
828
+ * changed since the snapshot fails with `SERIALIZATION_FAILURE`. @since 0.1.162
829
+ */
830
+ supportsReadLocks() {
831
+ return true;
832
+ }
833
+ /** Every level, via `BEGIN ISOLATION LEVEL …`. @since 0.1.162 */
834
+ isolationLevels() {
835
+ return ALL_ISOLATION_LEVELS;
836
+ }
821
837
  /** Computed view columns and first-row joins. */
822
838
  viewCapabilities() {
823
839
  return ALL_VIEW_CAPABILITIES;
@@ -1085,7 +1101,7 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1085
1101
  if (clauses.length === 0) continue;
1086
1102
  const sql = `SELECT ${selectCols} FROM ${quoteTableName(this.resolveTableName())} WHERE ${clauses.join(" OR ")} ORDER BY ${order} FOR UPDATE`;
1087
1103
  this._log(sql, params);
1088
- const found = await this._wrapConstraintError(() => this._exec().all(sql, params));
1104
+ const found = await this._exec().all(sql, params).catch((error) => this._mapConstraintError(error));
1089
1105
  for (const doc of found) keySets.forEach((fields, k) => {
1090
1106
  const tuple = uniqueKeyTuple(doc, fields);
1091
1107
  if (tuple !== void 0) lockedTuples[k].add(tuple);
@@ -1127,15 +1143,15 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1127
1143
  ...query.controls,
1128
1144
  $limit: 1
1129
1145
  };
1130
- const { sql, params } = buildSelect$1(this.resolveTableName(), where, controls);
1146
+ const { sql, params } = buildSelect$1(this.resolveTableName(), where, controls, query.lock);
1131
1147
  this._log(sql, params);
1132
- return this._exec().get(sql, params);
1148
+ return this._exec().get(sql, params).catch((error) => this._mapConstraintError(error));
1133
1149
  }
1134
1150
  async findMany(query) {
1135
1151
  const where = buildWhere(query.filter);
1136
- const { sql, params } = buildSelect$1(this.resolveTableName(), where, query.controls);
1152
+ const { sql, params } = buildSelect$1(this.resolveTableName(), where, query.controls, query.lock);
1137
1153
  this._log(sql, params);
1138
- return this._exec().all(sql, params);
1154
+ return this._exec().all(sql, params).catch((error) => this._mapConstraintError(error));
1139
1155
  }
1140
1156
  /**
1141
1157
  * `$skip` / `$limit` per partition in one statement: a `ROW_NUMBER()`
@@ -1240,8 +1256,21 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1240
1256
  modifiedCount: result.affectedRows
1241
1257
  };
1242
1258
  }
1259
+ /**
1260
+ * Bounded writes (since 0.1.162): PostgreSQL has no `UPDATE/DELETE … LIMIT`,
1261
+ * so the statement is re-keyed on the primary key through an ordered,
1262
+ * limited subquery — the `deleteOne` / `updateOne` pattern with `n` rows.
1263
+ */
1264
+ supportsWriteLimit() {
1265
+ return true;
1266
+ }
1267
+ /** `where` narrowed to a bounded write's rows, or `where` itself when unbounded. */
1268
+ _boundedWhere(where, bound) {
1269
+ if (!bound) return where;
1270
+ return writeLimitWhere(pgDialect, quoteTableName(this.resolveTableName()), this._pk().cols.map((c) => qi(c)), where, bound);
1271
+ }
1243
1272
  async updateMany(filter, data, ops, opts) {
1244
- const where = buildWhere(filter);
1273
+ const where = this._boundedWhere(buildWhere(filter), opts?.limit);
1245
1274
  const versionColumn = this._versionColumnFor(opts);
1246
1275
  const { sql, params } = buildUpdate$1(this.resolveTableName(), data, where, void 0, ops, versionColumn);
1247
1276
  this._log(sql, params);
@@ -1252,8 +1281,8 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1252
1281
  };
1253
1282
  }
1254
1283
  async replaceOne(filter, data, expectedVersion) {
1255
- const full = fillReplacePayload(data, this._replaceColumns(), this._table.versionColumnPhysical);
1256
- return this.updateOne(filter, full, void 0, expectedVersion);
1284
+ const full = fillReplacePayload(data, this._replaceColumns(), this._table.versionColumnPhysical, this._table.onUpdateIncPhysical);
1285
+ return this.updateOne(filter, full, this._onUpdateIncOps(), expectedVersion);
1257
1286
  }
1258
1287
  /** The columns a full replace assigns (`replaceColumnsFor`), built once per table. */
1259
1288
  _replaceColumns() {
@@ -1266,7 +1295,7 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1266
1295
  return memo.cols;
1267
1296
  }
1268
1297
  async replaceMany(filter, data) {
1269
- return this.updateMany(filter, data);
1298
+ return this.updateMany(filter, data, this._onUpdateIncOps());
1270
1299
  }
1271
1300
  async deleteOne(filter) {
1272
1301
  const where = buildWhere(filter);
@@ -1279,8 +1308,8 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1279
1308
  this._log(sql, params);
1280
1309
  return { deletedCount: (await this._wrapConstraintError(() => this._exec().run(sql, params))).affectedRows };
1281
1310
  }
1282
- async deleteMany(filter) {
1283
- return this._deleteWhere(buildWhere(filter));
1311
+ async deleteMany(filter, opts) {
1312
+ return this._deleteWhere(this._boundedWhere(buildWhere(filter), opts?.limit));
1284
1313
  }
1285
1314
  async _deleteWhere(where) {
1286
1315
  const { sql, params } = buildDelete$1(this.resolveTableName(), where);
@@ -1772,13 +1801,23 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1772
1801
  if (field.isGeoPoint) return this._supportsGeo ? "geography(Point,4326)" : pgTypeFromField(field);
1773
1802
  return pgTypeFromField(field);
1774
1803
  }
1775
- async syncIndexes() {
1804
+ async _syncIndexes(dryRun) {
1776
1805
  const tableName = this._table.tableName;
1777
1806
  const schema = this._schema;
1778
1807
  await this.prepareTypeMapper();
1779
- await this.syncIndexesWithDiff({
1808
+ const dropIndex = async (name) => {
1809
+ const sql = `DROP INDEX IF EXISTS ${schema ? `${qi(schema)}.` : ""}${qi(name)}`;
1810
+ this._log(sql);
1811
+ await this._exec().exec(sql);
1812
+ };
1813
+ /** Every index name on the table (read by `listExisting`) — the vector pass checks it. */
1814
+ let liveNames = /* @__PURE__ */ new Set();
1815
+ const changes = await this.syncIndexesWithDiff({
1816
+ dryRun,
1817
+ isUnmanaged: (name) => name.startsWith(PG_VECTOR_INDEX_PREFIX),
1818
+ skipIndex: (index) => index.type === "geo" && !this._supportsGeo ? `[postgres] geo index "${index.name}" declared but PostGIS is not available — skipped` : void 0,
1780
1819
  listExisting: async () => {
1781
- return (await this._exec().all(`SELECT i.relname AS name,
1820
+ const rows = await this._exec().all(`SELECT i.relname AS name,
1782
1821
  (SELECT array_agg(a.attname::text ORDER BY k.ord)
1783
1822
  FROM unnest(x.indkey) WITH ORDINALITY AS k(attnum, ord)
1784
1823
  JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum
@@ -1787,7 +1826,9 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1787
1826
  JOIN pg_class t ON t.oid = x.indrelid
1788
1827
  JOIN pg_class i ON i.oid = x.indexrelid
1789
1828
  JOIN pg_namespace n ON n.oid = t.relnamespace
1790
- WHERE t.relname = $1 AND n.nspname = COALESCE($2, current_schema())`, [tableName, schema])).map((r) => ({
1829
+ WHERE t.relname = $1 AND n.nspname = COALESCE($2, current_schema())`, [tableName, schema]);
1830
+ liveNames = new Set(rows.map((r) => r.name));
1831
+ return rows.map((r) => ({
1791
1832
  name: r.name,
1792
1833
  columns: r.columns ?? void 0
1793
1834
  }));
@@ -1803,10 +1844,6 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1803
1844
  return;
1804
1845
  }
1805
1846
  if (index.type === "geo") {
1806
- if (!this._supportsGeo) {
1807
- this.logger.warn(`[postgres] geo index "${index.name}" declared but PostGIS is not available — skipped`);
1808
- return;
1809
- }
1810
1847
  const sql = `CREATE INDEX IF NOT EXISTS ${qi(index.key)} ON ${quoteTableName(this.resolveTableName())} USING gist (${qi(index.fields[0].name)})`;
1811
1848
  this._log(sql);
1812
1849
  await this._exec().exec(sql);
@@ -1818,54 +1855,57 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1818
1855
  this._log(sql);
1819
1856
  await this._exec().exec(sql);
1820
1857
  },
1821
- dropIndex: async (name) => {
1822
- const sql = `DROP INDEX IF EXISTS ${schema ? `${qi(schema)}.` : ""}${qi(name)}`;
1823
- this._log(sql);
1824
- await this._exec().exec(sql);
1825
- }
1858
+ dropIndex
1826
1859
  });
1860
+ const desiredVector = new Set(this._supportsVector ? [...this._vectorFields.values()].map((v) => `${PG_VECTOR_INDEX_PREFIX}${v.indexName}`) : []);
1861
+ const vector = createChangeRecorder(dryRun);
1862
+ for (const name of liveNames) if (name.startsWith(PG_VECTOR_INDEX_PREFIX) && !desiredVector.has(name)) await vector.apply({
1863
+ name,
1864
+ action: "drop"
1865
+ }, name, () => dropIndex(name));
1827
1866
  if (this._supportsVector) for (const [field, vec] of this._vectorFields) {
1828
- const indexName = `atscript__vec_${vec.indexName}`;
1829
- const opsClass = similarityToPgOps(vec.similarity);
1830
- const sql = `CREATE INDEX IF NOT EXISTS ${qi(indexName)} ON ${quoteTableName(this.resolveTableName())} USING hnsw (${qi(field)} ${opsClass})`;
1831
- this._log(sql);
1832
- await this._exec().exec(sql);
1867
+ const indexName = `${PG_VECTOR_INDEX_PREFIX}${vec.indexName}`;
1868
+ if (liveNames.has(indexName)) continue;
1869
+ await vector.apply({
1870
+ name: indexName,
1871
+ action: "add"
1872
+ }, indexName, async () => {
1873
+ const opsClass = similarityToPgOps(vec.similarity);
1874
+ const sql = `CREATE INDEX IF NOT EXISTS ${qi(indexName)} ON ${quoteTableName(this.resolveTableName())} USING hnsw (${qi(field)} ${opsClass})`;
1875
+ this._log(sql);
1876
+ await this._exec().exec(sql);
1877
+ });
1833
1878
  }
1879
+ return [...changes, ...vector.changes];
1834
1880
  }
1835
1881
  async syncForeignKeys() {
1836
- const existingByName = await this._getExistingFkConstraints();
1837
- const desiredFkKeys = /* @__PURE__ */ new Set();
1838
- for (const fk of this._table.foreignKeys.values()) desiredFkKeys.add([...fkColumns(fk).fields].toSorted().join(","));
1839
- for (const [constraintName, columns] of existingByName) {
1840
- const key = [...columns].toSorted().join(",");
1841
- if (!desiredFkKeys.has(key)) {
1842
- const ddl = `ALTER TABLE ${quoteTableName(this.resolveTableName())} DROP CONSTRAINT ${qi(constraintName)}`;
1843
- this._log(ddl);
1844
- await this._exec().exec(ddl);
1845
- }
1846
- }
1847
- const existingKeys = new Set([...existingByName.values()].map((cols) => cols.toSorted().join(",")));
1848
- for (const fk of this._table.foreignKeys.values()) {
1849
- const key = [...fkColumns(fk).fields].toSorted().join(",");
1850
- if (!existingKeys.has(key)) {
1882
+ return this._syncForeignKeys(false);
1883
+ }
1884
+ /** The FK changes {@link syncForeignKeys} would make — no DDL. */
1885
+ async planForeignKeys() {
1886
+ return this._syncForeignKeys(true);
1887
+ }
1888
+ _syncForeignKeys(dryRun) {
1889
+ return this.syncForeignKeysWithDiff({
1890
+ dryRun,
1891
+ listExisting: () => this._getExistingFkConstraints(),
1892
+ dropConstraint: (name) => this._dropFkConstraint(name),
1893
+ addConstraint: async (fk) => {
1851
1894
  const ddl = `ALTER TABLE ${quoteTableName(this.resolveTableName())} ADD ${foreignKeySql(qi, fk)}`;
1852
1895
  this._log(ddl);
1853
1896
  await this._exec().exec(ddl);
1854
1897
  }
1855
- }
1898
+ });
1856
1899
  }
1857
1900
  async dropForeignKeys(fkFieldKeys) {
1858
1901
  if (fkFieldKeys.length === 0) return;
1859
1902
  const keySet = new Set(fkFieldKeys);
1860
- const existingByName = await this._getExistingFkConstraints();
1861
- for (const [constraintName, cols] of existingByName) {
1862
- const key = cols.toSorted().join(",");
1863
- if (keySet.has(key)) {
1864
- const ddl = `ALTER TABLE ${quoteTableName(this.resolveTableName())} DROP CONSTRAINT ${qi(constraintName)}`;
1865
- this._log(ddl);
1866
- await this._exec().exec(ddl);
1867
- }
1868
- }
1903
+ for (const [constraintName, cols] of await this._getExistingFkConstraints()) if (keySet.has(fkKey(cols))) await this._dropFkConstraint(constraintName);
1904
+ }
1905
+ async _dropFkConstraint(name) {
1906
+ const ddl = `ALTER TABLE ${quoteTableName(this.resolveTableName())} DROP CONSTRAINT ${qi(name)}`;
1907
+ this._log(ddl);
1908
+ await this._exec().exec(ddl);
1869
1909
  }
1870
1910
  /** Queries information_schema for existing FK constraints. */
1871
1911
  async _getExistingFkConstraints() {
@@ -2303,10 +2343,8 @@ function normalizePgDefault(value, isIdentity) {
2303
2343
  if (lower.includes("extract") && lower.includes("epoch") && lower.includes("now()")) return "fn:now";
2304
2344
  if (lower === "gen_random_uuid()") return "fn:uuid";
2305
2345
  if (lower.startsWith("nextval(")) return "fn:increment";
2306
- const castMatch = cleaned.match(/^'(.*)'::[\w\s]+$/);
2307
- if (castMatch) return castMatch[1].replace(/''/g, "'");
2308
- if (cleaned.startsWith("'") && cleaned.endsWith("'")) return cleaned.slice(1, -1).replace(/''/g, "'");
2309
- return cleaned;
2346
+ const castMatch = cleaned.match(/^('.*')::[\w\s]+$/);
2347
+ return unquoteSqlStringLiteral(castMatch ? castMatch[1] : cleaned) ?? cleaned;
2310
2348
  }
2311
2349
  /**
2312
2350
  * Converts a normalized similarity threshold (0-1) to a pgvector max distance.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/db-postgres",
3
- "version": "0.1.161",
3
+ "version": "0.1.162",
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.108",
47
- "@atscript/typescript": "^0.1.108",
46
+ "@atscript/core": "^0.1.109",
47
+ "@atscript/typescript": "^0.1.109",
48
48
  "@types/pg": "^8.11.0",
49
49
  "@uniqu/core": "^0.1.15",
50
50
  "pg": "^8.13.0",
51
- "unplugin-atscript": "^0.1.108"
51
+ "unplugin-atscript": "^0.1.109"
52
52
  },
53
53
  "peerDependencies": {
54
- "@atscript/core": "^0.1.108",
55
- "@atscript/typescript": "^0.1.108",
54
+ "@atscript/core": "^0.1.109",
55
+ "@atscript/typescript": "^0.1.109",
56
56
  "@uniqu/core": "^0.1.15",
57
57
  "pg": ">=8.0.0",
58
- "@atscript/db": "^0.1.161",
59
- "@atscript/db-sql-tools": "^0.1.161"
58
+ "@atscript/db": "^0.1.162",
59
+ "@atscript/db-sql-tools": "^0.1.162"
60
60
  },
61
61
  "peerDependenciesMeta": {
62
62
  "pg": {