@atscript/db-postgres 0.1.105 → 0.1.106

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
@@ -65,11 +65,50 @@ const pgDialect = {
65
65
  params: [pattern]
66
66
  };
67
67
  },
68
+ geoWithin(quotedCol, circle) {
69
+ return {
70
+ sql: `ST_DWithin(${quotedCol}, ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography, ?)`,
71
+ params: [
72
+ circle.center[0],
73
+ circle.center[1],
74
+ circle.radius
75
+ ]
76
+ };
77
+ },
68
78
  createViewPrefix: "CREATE OR REPLACE VIEW",
69
79
  paramPlaceholder(index) {
70
80
  return `$${index}`;
71
81
  }
72
82
  };
83
+ /**
84
+ * Distance-in-meters expression from a geography column to a query point.
85
+ * Used as the `distExpr` of the shared geo search builder.
86
+ */
87
+ function pgGeoDistanceExpr(quotedCol, point) {
88
+ return {
89
+ sql: `ST_Distance(${quotedCol}, ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography)`,
90
+ params: [point[0], point[1]]
91
+ };
92
+ }
93
+ /** Formats a `[lng, lat]` tuple as EWKT — the text input format for geography params. */
94
+ function geoPointToEwkt(point) {
95
+ return `SRID=4326;POINT(${point[0]} ${point[1]})`;
96
+ }
97
+ /**
98
+ * Parses a hex-encoded (E)WKB point — the raw wire format the pg driver
99
+ * returns for geography columns — back to a `[lng, lat]` tuple.
100
+ * Returns `undefined` for anything that isn't a WKB point.
101
+ */
102
+ function parseEwkbPointHex(hex) {
103
+ if (hex.length < 42 || !/^[0-9A-Fa-f]+$/.test(hex)) return;
104
+ const buf = Buffer.from(hex, "hex");
105
+ const littleEndian = buf.readUInt8(0) === 1;
106
+ const type = littleEndian ? buf.readUInt32LE(1) : buf.readUInt32BE(1);
107
+ if ((type & 255) !== 1) return;
108
+ const offset = (type & 536870912) !== 0 ? 9 : 5;
109
+ if (buf.length < offset + 16) return;
110
+ return [littleEndian ? buf.readDoubleLE(offset) : buf.readDoubleBE(offset), littleEndian ? buf.readDoubleLE(offset + 8) : buf.readDoubleBE(offset + 8)];
111
+ }
73
112
  function buildInsert(table, data) {
74
113
  return (0, _atscript_db_sql_tools.buildInsert)(pgDialect, table, data);
75
114
  }
@@ -274,6 +313,8 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
274
313
  _nocaseColumns = /* @__PURE__ */ new Set();
275
314
  /** Whether citext extension has been provisioned (avoids redundant round-trips). */
276
315
  _citextProvisioned = false;
316
+ /** Whether the connected PostgreSQL instance has the PostGIS extension. */
317
+ _supportsGeo;
277
318
  /** Whether the connected PostgreSQL instance has the pgvector extension. */
278
319
  _supportsVector;
279
320
  /** Vector fields: physical field name → { dimensions, similarity, indexName }. */
@@ -371,11 +412,24 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
371
412
  * invalid for the pgvector `vector` type — it expects bracket-delimited `[1,2,3]`.
372
413
  */
373
414
  formatValue(field) {
374
- if (!this._vectorFields.has(field.path)) return;
375
- return {
415
+ if (this._vectorFields.has(field.path)) return {
376
416
  toStorage: (value) => Array.isArray(value) ? `[${value.join(",")}]` : value,
377
417
  fromStorage: (value) => typeof value === "string" ? JSON.parse(value) : value
378
418
  };
419
+ if (field.isGeoPoint && !field.encrypted) return {
420
+ toStorage: (value) => {
421
+ if (!this._supportsGeo) return value;
422
+ const point = (0, _atscript_db_sql_tools.normalizeGeoPointValue)(value);
423
+ return point ? geoPointToEwkt(point) : value;
424
+ },
425
+ fromStorage: (value) => {
426
+ if (typeof value === "string") {
427
+ const point = parseEwkbPointHex(value);
428
+ if (point) return point;
429
+ }
430
+ return value;
431
+ }
432
+ };
379
433
  }
380
434
  /**
381
435
  * Wraps an async write operation to catch PostgreSQL constraint errors
@@ -564,6 +618,14 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
564
618
  this._log(sql, params);
565
619
  return { deletedCount: (await this._wrapConstraintError(() => this._exec().run(sql, params))).affectedRows };
566
620
  }
621
+ async prepareTypeMapper() {
622
+ if (this._supportsVector === void 0 && this._vectorFields.size > 0) await this._detectVectorSupport();
623
+ if (this._supportsGeo === void 0 && this._hasGeoPointFields()) await this._detectGeoSupport();
624
+ }
625
+ /** Whether the table declares any `db.geoPoint` fields (unencrypted). */
626
+ _hasGeoPointFields() {
627
+ return this._table.fieldDescriptors.some((fd) => fd.isGeoPoint && !fd.encrypted);
628
+ }
567
629
  async ensureTable() {
568
630
  if (this._nocaseColumns.size > 0 && !this._citextProvisioned) try {
569
631
  await this._exec().exec("CREATE EXTENSION IF NOT EXISTS citext");
@@ -572,7 +634,8 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
572
634
  const msg = err instanceof Error ? err.message : String(err);
573
635
  throw new Error(`Failed to create citext extension for @db.collate 'nocase' columns: ${msg}. Either run 'CREATE EXTENSION citext' as a superuser, or use @db.pg.type "CITEXT" after provisioning the extension manually.`, { cause: err });
574
636
  }
575
- if (this._supportsVector === void 0 && this._vectorFields.size > 0) await this._detectVectorSupport();
637
+ await this.prepareTypeMapper();
638
+ if (this._schema) await this._exec().exec(`CREATE SCHEMA IF NOT EXISTS ${qi(this._schema)}`);
576
639
  if (this._table instanceof _atscript_db.AtscriptDbView) return this._ensureView();
577
640
  const sql = buildCreateTable(this.resolveTableName(), this._table.fieldDescriptors, this._table.foreignKeys, {
578
641
  incrementFields: this._incrementFields,
@@ -653,7 +716,9 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
653
716
  for (const { field } of diff.typeChanged ?? []) {
654
717
  const sqlType = this.typeMapper(field);
655
718
  const col = qi(field.physicalName);
656
- const ddl = `ALTER TABLE ${quoteTableName(tableName)} ALTER COLUMN ${col} TYPE ${sqlType} USING ${col}::text::${sqlType}`;
719
+ let ddl;
720
+ if (field.isGeoPoint && sqlType.startsWith("geography")) ddl = `ALTER TABLE ${quoteTableName(tableName)} ALTER COLUMN ${col} TYPE ${sqlType} USING CASE WHEN ${col} IS NULL THEN NULL ELSE ST_SetSRID(ST_MakePoint((${col}->>0)::float8, (${col}->>1)::float8), 4326)::geography END`;
721
+ else ddl = `ALTER TABLE ${quoteTableName(tableName)} ALTER COLUMN ${col} TYPE ${sqlType} USING ${col}::text::${sqlType}`;
657
722
  this._log(ddl);
658
723
  await this._exec().exec(ddl);
659
724
  }
@@ -814,6 +879,27 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
814
879
  this._log(ddl);
815
880
  await this._exec().exec(ddl);
816
881
  }
882
+ async dropIndexesForColumns(columns) {
883
+ const tableName = this._table.tableName;
884
+ const schema = this._schema;
885
+ const rows = await this._exec().all(`SELECT DISTINCT i.relname AS name
886
+ FROM pg_index x
887
+ JOIN pg_class t ON t.oid = x.indrelid
888
+ JOIN pg_class i ON i.oid = x.indexrelid
889
+ JOIN pg_namespace n ON n.oid = t.relnamespace
890
+ JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(x.indkey)
891
+ WHERE t.relname = $1 AND n.nspname = COALESCE($2, 'public')
892
+ AND i.relname LIKE 'atscript__%' AND a.attname = ANY($3)`, [
893
+ tableName,
894
+ schema,
895
+ columns
896
+ ]);
897
+ for (const row of rows) {
898
+ const sql = `DROP INDEX IF EXISTS ${schema ? `${qi(schema)}.` : ""}${qi(row.name)}`;
899
+ this._log(sql);
900
+ await this._exec().exec(sql);
901
+ }
902
+ }
817
903
  async dropTableByName(tableName) {
818
904
  const ddl = `DROP TABLE IF EXISTS ${quoteTableName(tableName)} CASCADE`;
819
905
  this._log(ddl);
@@ -836,14 +922,29 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
836
922
  const vec = this._vectorFields.get(field.path);
837
923
  return this._supportsVector ? `vector(${vec.dimensions})` : "JSONB";
838
924
  }
925
+ if (field.isGeoPoint) return this._supportsGeo ? "geography(Point,4326)" : pgTypeFromField(field);
839
926
  return pgTypeFromField(field);
840
927
  }
841
928
  async syncIndexes() {
842
929
  const tableName = this._table.tableName;
843
930
  const schema = this._schema;
931
+ await this.prepareTypeMapper();
844
932
  await this.syncIndexesWithDiff({
845
- listExisting: async () => this._exec().all(`SELECT indexname AS name FROM pg_indexes
846
- WHERE tablename = $1 AND schemaname = COALESCE($2, 'public')`, [tableName, schema]),
933
+ listExisting: async () => {
934
+ return (await this._exec().all(`SELECT i.relname AS name,
935
+ (SELECT array_agg(a.attname::text ORDER BY k.ord)
936
+ FROM unnest(x.indkey) WITH ORDINALITY AS k(attnum, ord)
937
+ JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum
938
+ WHERE k.attnum > 0) AS columns
939
+ FROM pg_index x
940
+ JOIN pg_class t ON t.oid = x.indrelid
941
+ JOIN pg_class i ON i.oid = x.indexrelid
942
+ JOIN pg_namespace n ON n.oid = t.relnamespace
943
+ WHERE t.relname = $1 AND n.nspname = COALESCE($2, 'public')`, [tableName, schema])).map((r) => ({
944
+ name: r.name,
945
+ columns: r.columns ?? void 0
946
+ }));
947
+ },
847
948
  createIndex: async (index) => {
848
949
  if (index.type === "fulltext") {
849
950
  const tsvectorExpr = this._buildTsvectorExpr(index.fields);
@@ -852,6 +953,16 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
852
953
  await this._exec().exec(sql);
853
954
  return;
854
955
  }
956
+ if (index.type === "geo") {
957
+ if (!this._supportsGeo) {
958
+ this.logger.warn(`[postgres] geo index "${index.name}" declared but PostGIS is not available — skipped`);
959
+ return;
960
+ }
961
+ const sql = `CREATE INDEX IF NOT EXISTS ${qi(index.key)} ON ${quoteTableName(this.resolveTableName())} USING gist (${qi(index.fields[0].name)})`;
962
+ this._log(sql);
963
+ await this._exec().exec(sql);
964
+ return;
965
+ }
855
966
  const unique = index.type === "unique" ? "UNIQUE " : "";
856
967
  const cols = index.fields.map((f) => `${qi(f.name)} ${f.sort === "desc" ? "DESC" : "ASC"}`).join(", ");
857
968
  const sql = `CREATE ${unique}INDEX IF NOT EXISTS ${qi(index.key)} ON ${quoteTableName(this.resolveTableName())} (${cols})`;
@@ -862,10 +973,6 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
862
973
  const sql = `DROP INDEX IF EXISTS ${schema ? `${qi(schema)}.` : ""}${qi(name)}`;
863
974
  this._log(sql);
864
975
  await this._exec().exec(sql);
865
- },
866
- warnUnsupportedTypes: {
867
- adapter: "postgres",
868
- types: ["geo"]
869
976
  }
870
977
  });
871
978
  if (this._supportsVector) for (const [field, vec] of this._vectorFields) {
@@ -1114,6 +1221,65 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1114
1221
  if (queryThreshold !== void 0) return queryThreshold;
1115
1222
  return this._vectorThresholds.get(indexName);
1116
1223
  }
1224
+ /**
1225
+ * Detects PostGIS support by attempting to enable the extension.
1226
+ * Idempotent — safe to call multiple times.
1227
+ */
1228
+ async _detectGeoSupport() {
1229
+ if (this._supportsGeo !== void 0) return this._supportsGeo;
1230
+ try {
1231
+ await this._exec().exec("CREATE EXTENSION IF NOT EXISTS postgis");
1232
+ this._supportsGeo = true;
1233
+ } catch {
1234
+ this._supportsGeo = false;
1235
+ this.logger.warn("[postgres] PostGIS extension not available — db.geoPoint fields are stored as JSONB (no geo search).");
1236
+ }
1237
+ return this._supportsGeo;
1238
+ }
1239
+ isGeoSearchable() {
1240
+ return this._supportsGeo === true;
1241
+ }
1242
+ async geoSearch(point, query, indexName) {
1243
+ await this._detectGeoSupport();
1244
+ const { sql, params } = this._buildGeoSearchSelect(this._prepareGeoSearch(point, query, indexName));
1245
+ this._log(sql, params);
1246
+ return (await this._exec().all(sql, params)).map((row) => (0, _atscript_db_sql_tools.renameGeoDistance)(row));
1247
+ }
1248
+ async geoSearchWithCount(point, query, indexName) {
1249
+ await this._detectGeoSupport();
1250
+ const ctx = this._prepareGeoSearch(point, query, indexName);
1251
+ const { sql, params } = this._buildGeoSearchSelect(ctx);
1252
+ const countFrag = (0, _atscript_db_sql_tools.buildGeoSearchCount)(pgDialect, ctx.tableName, ctx.where, ctx.dist, ctx.window);
1253
+ this._log(sql, params);
1254
+ this._log(countFrag.sql, countFrag.params);
1255
+ const [rows, countRow] = await Promise.all([this._exec().all(sql, params), this._exec().get(countFrag.sql, countFrag.params)]);
1256
+ return {
1257
+ data: rows.map((row) => (0, _atscript_db_sql_tools.renameGeoDistance)(row)),
1258
+ count: parseCount(countRow?.cnt)
1259
+ };
1260
+ }
1261
+ /** Resolves the shared parts of a geo search once (column, filter, distance, window). */
1262
+ _prepareGeoSearch(point, query, indexName) {
1263
+ if (!this._supportsGeo) throw new _atscript_db.DbError("GEO_NOT_SUPPORTED", [{
1264
+ path: "",
1265
+ message: "Geo search requires the PostGIS extension"
1266
+ }]);
1267
+ const column = this._resolveGeoColumn(indexName);
1268
+ const controls = query.controls ?? {};
1269
+ return {
1270
+ tableName: this.resolveTableName(),
1271
+ where: buildWhere(query.filter),
1272
+ dist: pgGeoDistanceExpr(qi(column), point),
1273
+ window: (0, _atscript_db_sql_tools.geoWindowFromControls)(controls),
1274
+ controls
1275
+ };
1276
+ }
1277
+ _buildGeoSearchSelect(ctx) {
1278
+ return (0, _atscript_db_sql_tools.buildGeoSearchSelect)(pgDialect, ctx.tableName, ctx.where, ctx.dist, ctx.window, {
1279
+ $limit: ctx.controls.$limit,
1280
+ $skip: ctx.controls.$skip
1281
+ });
1282
+ }
1117
1283
  };
1118
1284
  /**
1119
1285
  * Normalizes PostgreSQL information_schema data_type values
@@ -1138,6 +1304,7 @@ function normalizePgType(dataType, maxLength, numericPrecision, numericScale, ud
1138
1304
  case "user-defined":
1139
1305
  if (udtName === "vector") return formattedType;
1140
1306
  if (udtName === "citext") return "CITEXT";
1307
+ if (udtName === "geography") return formattedType;
1141
1308
  return udtName?.toUpperCase() ?? "USER-DEFINED";
1142
1309
  default: return dataType.toUpperCase();
1143
1310
  }
package/dist/index.d.cts CHANGED
@@ -95,6 +95,8 @@ declare class PostgresAdapter extends BaseDbAdapter {
95
95
  private _nocaseColumns;
96
96
  /** Whether citext extension has been provisioned (avoids redundant round-trips). */
97
97
  private _citextProvisioned;
98
+ /** Whether the connected PostgreSQL instance has the PostGIS extension. */
99
+ private _supportsGeo;
98
100
  /** Whether the connected PostgreSQL instance has the pgvector extension. */
99
101
  private _supportsVector;
100
102
  /** Vector fields: physical field name → { dimensions, similarity, indexName }. */
@@ -151,6 +153,9 @@ declare class PostgresAdapter extends BaseDbAdapter {
151
153
  replaceMany(filter: FilterExpr, data: Record<string, unknown>): Promise<TDbUpdateResult>;
152
154
  deleteOne(filter: FilterExpr): Promise<TDbDeleteResult>;
153
155
  deleteMany(filter: FilterExpr): Promise<TDbDeleteResult>;
156
+ prepareTypeMapper(): Promise<void>;
157
+ /** Whether the table declares any `db.geoPoint` fields (unencrypted). */
158
+ private _hasGeoPointFields;
154
159
  ensureTable(): Promise<void>;
155
160
  private _ensureView;
156
161
  getExistingColumns(): Promise<TExistingColumn[]>;
@@ -168,6 +173,7 @@ declare class PostgresAdapter extends BaseDbAdapter {
168
173
  tableExists(): Promise<boolean>;
169
174
  dropTable(): Promise<void>;
170
175
  dropColumns(columns: string[]): Promise<void>;
176
+ dropIndexesForColumns(columns: string[]): Promise<void>;
171
177
  dropTableByName(tableName: string): Promise<void>;
172
178
  dropViewByName(viewName: string): Promise<void>;
173
179
  renameTable(oldName: string): Promise<void>;
@@ -204,6 +210,20 @@ declare class PostgresAdapter extends BaseDbAdapter {
204
210
  private _buildVectorSearchCountQuery;
205
211
  /** Resolves threshold: query-time $threshold > schema-level @db.search.vector.threshold. */
206
212
  private _resolveVectorThreshold;
213
+ /**
214
+ * Detects PostGIS support by attempting to enable the extension.
215
+ * Idempotent — safe to call multiple times.
216
+ */
217
+ private _detectGeoSupport;
218
+ isGeoSearchable(): boolean;
219
+ geoSearch(point: [number, number], query: DbQuery, indexName?: string): Promise<Array<Record<string, unknown>>>;
220
+ geoSearchWithCount(point: [number, number], query: DbQuery, indexName?: string): Promise<{
221
+ data: Array<Record<string, unknown>>;
222
+ count: number;
223
+ }>;
224
+ /** Resolves the shared parts of a geo search once (column, filter, distance, window). */
225
+ private _prepareGeoSearch;
226
+ private _buildGeoSearchSelect;
207
227
  }
208
228
  //#endregion
209
229
  //#region src/pg-driver.d.ts
package/dist/index.d.mts CHANGED
@@ -95,6 +95,8 @@ declare class PostgresAdapter extends BaseDbAdapter {
95
95
  private _nocaseColumns;
96
96
  /** Whether citext extension has been provisioned (avoids redundant round-trips). */
97
97
  private _citextProvisioned;
98
+ /** Whether the connected PostgreSQL instance has the PostGIS extension. */
99
+ private _supportsGeo;
98
100
  /** Whether the connected PostgreSQL instance has the pgvector extension. */
99
101
  private _supportsVector;
100
102
  /** Vector fields: physical field name → { dimensions, similarity, indexName }. */
@@ -151,6 +153,9 @@ declare class PostgresAdapter extends BaseDbAdapter {
151
153
  replaceMany(filter: FilterExpr, data: Record<string, unknown>): Promise<TDbUpdateResult>;
152
154
  deleteOne(filter: FilterExpr): Promise<TDbDeleteResult>;
153
155
  deleteMany(filter: FilterExpr): Promise<TDbDeleteResult>;
156
+ prepareTypeMapper(): Promise<void>;
157
+ /** Whether the table declares any `db.geoPoint` fields (unencrypted). */
158
+ private _hasGeoPointFields;
154
159
  ensureTable(): Promise<void>;
155
160
  private _ensureView;
156
161
  getExistingColumns(): Promise<TExistingColumn[]>;
@@ -168,6 +173,7 @@ declare class PostgresAdapter extends BaseDbAdapter {
168
173
  tableExists(): Promise<boolean>;
169
174
  dropTable(): Promise<void>;
170
175
  dropColumns(columns: string[]): Promise<void>;
176
+ dropIndexesForColumns(columns: string[]): Promise<void>;
171
177
  dropTableByName(tableName: string): Promise<void>;
172
178
  dropViewByName(viewName: string): Promise<void>;
173
179
  renameTable(oldName: string): Promise<void>;
@@ -204,6 +210,20 @@ declare class PostgresAdapter extends BaseDbAdapter {
204
210
  private _buildVectorSearchCountQuery;
205
211
  /** Resolves threshold: query-time $threshold > schema-level @db.search.vector.threshold. */
206
212
  private _resolveVectorThreshold;
213
+ /**
214
+ * Detects PostGIS support by attempting to enable the extension.
215
+ * Idempotent — safe to call multiple times.
216
+ */
217
+ private _detectGeoSupport;
218
+ isGeoSearchable(): boolean;
219
+ geoSearch(point: [number, number], query: DbQuery, indexName?: string): Promise<Array<Record<string, unknown>>>;
220
+ geoSearchWithCount(point: [number, number], query: DbQuery, indexName?: string): Promise<{
221
+ data: Array<Record<string, unknown>>;
222
+ count: number;
223
+ }>;
224
+ /** Resolves the shared parts of a geo search once (column, filter, distance, window). */
225
+ private _prepareGeoSearch;
226
+ private _buildGeoSearchSelect;
207
227
  }
208
228
  //#endregion
209
229
  //#region src/pg-driver.d.ts
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { AtscriptDbView, BaseDbAdapter, DbError, DbSpace } from "@atscript/db";
2
- import { buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildInsert, buildSelect, buildUpdate, buildWhere as buildWhere$1, defaultValueForType, defaultValueToSqlLiteral, finalizeParams, parseRegexString, refActionToSql } from "@atscript/db-sql-tools";
2
+ import { buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildSelect, buildUpdate, buildWhere as buildWhere$1, defaultValueForType, defaultValueToSqlLiteral, finalizeParams, geoWindowFromControls, normalizeGeoPointValue, parseRegexString, refActionToSql, renameGeoDistance } from "@atscript/db-sql-tools";
3
3
  //#region src/sql-builder.ts
4
4
  /**
5
5
  * PostgreSQL-aware default value for a given design type.
@@ -64,11 +64,50 @@ const pgDialect = {
64
64
  params: [pattern]
65
65
  };
66
66
  },
67
+ geoWithin(quotedCol, circle) {
68
+ return {
69
+ sql: `ST_DWithin(${quotedCol}, ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography, ?)`,
70
+ params: [
71
+ circle.center[0],
72
+ circle.center[1],
73
+ circle.radius
74
+ ]
75
+ };
76
+ },
67
77
  createViewPrefix: "CREATE OR REPLACE VIEW",
68
78
  paramPlaceholder(index) {
69
79
  return `$${index}`;
70
80
  }
71
81
  };
82
+ /**
83
+ * Distance-in-meters expression from a geography column to a query point.
84
+ * Used as the `distExpr` of the shared geo search builder.
85
+ */
86
+ function pgGeoDistanceExpr(quotedCol, point) {
87
+ return {
88
+ sql: `ST_Distance(${quotedCol}, ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography)`,
89
+ params: [point[0], point[1]]
90
+ };
91
+ }
92
+ /** Formats a `[lng, lat]` tuple as EWKT — the text input format for geography params. */
93
+ function geoPointToEwkt(point) {
94
+ return `SRID=4326;POINT(${point[0]} ${point[1]})`;
95
+ }
96
+ /**
97
+ * Parses a hex-encoded (E)WKB point — the raw wire format the pg driver
98
+ * returns for geography columns — back to a `[lng, lat]` tuple.
99
+ * Returns `undefined` for anything that isn't a WKB point.
100
+ */
101
+ function parseEwkbPointHex(hex) {
102
+ if (hex.length < 42 || !/^[0-9A-Fa-f]+$/.test(hex)) return;
103
+ const buf = Buffer.from(hex, "hex");
104
+ const littleEndian = buf.readUInt8(0) === 1;
105
+ const type = littleEndian ? buf.readUInt32LE(1) : buf.readUInt32BE(1);
106
+ if ((type & 255) !== 1) return;
107
+ const offset = (type & 536870912) !== 0 ? 9 : 5;
108
+ if (buf.length < offset + 16) return;
109
+ return [littleEndian ? buf.readDoubleLE(offset) : buf.readDoubleBE(offset), littleEndian ? buf.readDoubleLE(offset + 8) : buf.readDoubleBE(offset + 8)];
110
+ }
72
111
  function buildInsert$1(table, data) {
73
112
  return buildInsert(pgDialect, table, data);
74
113
  }
@@ -273,6 +312,8 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
273
312
  _nocaseColumns = /* @__PURE__ */ new Set();
274
313
  /** Whether citext extension has been provisioned (avoids redundant round-trips). */
275
314
  _citextProvisioned = false;
315
+ /** Whether the connected PostgreSQL instance has the PostGIS extension. */
316
+ _supportsGeo;
276
317
  /** Whether the connected PostgreSQL instance has the pgvector extension. */
277
318
  _supportsVector;
278
319
  /** Vector fields: physical field name → { dimensions, similarity, indexName }. */
@@ -370,11 +411,24 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
370
411
  * invalid for the pgvector `vector` type — it expects bracket-delimited `[1,2,3]`.
371
412
  */
372
413
  formatValue(field) {
373
- if (!this._vectorFields.has(field.path)) return;
374
- return {
414
+ if (this._vectorFields.has(field.path)) return {
375
415
  toStorage: (value) => Array.isArray(value) ? `[${value.join(",")}]` : value,
376
416
  fromStorage: (value) => typeof value === "string" ? JSON.parse(value) : value
377
417
  };
418
+ if (field.isGeoPoint && !field.encrypted) return {
419
+ toStorage: (value) => {
420
+ if (!this._supportsGeo) return value;
421
+ const point = normalizeGeoPointValue(value);
422
+ return point ? geoPointToEwkt(point) : value;
423
+ },
424
+ fromStorage: (value) => {
425
+ if (typeof value === "string") {
426
+ const point = parseEwkbPointHex(value);
427
+ if (point) return point;
428
+ }
429
+ return value;
430
+ }
431
+ };
378
432
  }
379
433
  /**
380
434
  * Wraps an async write operation to catch PostgreSQL constraint errors
@@ -563,6 +617,14 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
563
617
  this._log(sql, params);
564
618
  return { deletedCount: (await this._wrapConstraintError(() => this._exec().run(sql, params))).affectedRows };
565
619
  }
620
+ async prepareTypeMapper() {
621
+ if (this._supportsVector === void 0 && this._vectorFields.size > 0) await this._detectVectorSupport();
622
+ if (this._supportsGeo === void 0 && this._hasGeoPointFields()) await this._detectGeoSupport();
623
+ }
624
+ /** Whether the table declares any `db.geoPoint` fields (unencrypted). */
625
+ _hasGeoPointFields() {
626
+ return this._table.fieldDescriptors.some((fd) => fd.isGeoPoint && !fd.encrypted);
627
+ }
566
628
  async ensureTable() {
567
629
  if (this._nocaseColumns.size > 0 && !this._citextProvisioned) try {
568
630
  await this._exec().exec("CREATE EXTENSION IF NOT EXISTS citext");
@@ -571,7 +633,8 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
571
633
  const msg = err instanceof Error ? err.message : String(err);
572
634
  throw new Error(`Failed to create citext extension for @db.collate 'nocase' columns: ${msg}. Either run 'CREATE EXTENSION citext' as a superuser, or use @db.pg.type "CITEXT" after provisioning the extension manually.`, { cause: err });
573
635
  }
574
- if (this._supportsVector === void 0 && this._vectorFields.size > 0) await this._detectVectorSupport();
636
+ await this.prepareTypeMapper();
637
+ if (this._schema) await this._exec().exec(`CREATE SCHEMA IF NOT EXISTS ${qi(this._schema)}`);
575
638
  if (this._table instanceof AtscriptDbView) return this._ensureView();
576
639
  const sql = buildCreateTable(this.resolveTableName(), this._table.fieldDescriptors, this._table.foreignKeys, {
577
640
  incrementFields: this._incrementFields,
@@ -652,7 +715,9 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
652
715
  for (const { field } of diff.typeChanged ?? []) {
653
716
  const sqlType = this.typeMapper(field);
654
717
  const col = qi(field.physicalName);
655
- const ddl = `ALTER TABLE ${quoteTableName(tableName)} ALTER COLUMN ${col} TYPE ${sqlType} USING ${col}::text::${sqlType}`;
718
+ let ddl;
719
+ if (field.isGeoPoint && sqlType.startsWith("geography")) ddl = `ALTER TABLE ${quoteTableName(tableName)} ALTER COLUMN ${col} TYPE ${sqlType} USING CASE WHEN ${col} IS NULL THEN NULL ELSE ST_SetSRID(ST_MakePoint((${col}->>0)::float8, (${col}->>1)::float8), 4326)::geography END`;
720
+ else ddl = `ALTER TABLE ${quoteTableName(tableName)} ALTER COLUMN ${col} TYPE ${sqlType} USING ${col}::text::${sqlType}`;
656
721
  this._log(ddl);
657
722
  await this._exec().exec(ddl);
658
723
  }
@@ -813,6 +878,27 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
813
878
  this._log(ddl);
814
879
  await this._exec().exec(ddl);
815
880
  }
881
+ async dropIndexesForColumns(columns) {
882
+ const tableName = this._table.tableName;
883
+ const schema = this._schema;
884
+ const rows = await this._exec().all(`SELECT DISTINCT i.relname AS name
885
+ FROM pg_index x
886
+ JOIN pg_class t ON t.oid = x.indrelid
887
+ JOIN pg_class i ON i.oid = x.indexrelid
888
+ JOIN pg_namespace n ON n.oid = t.relnamespace
889
+ JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(x.indkey)
890
+ WHERE t.relname = $1 AND n.nspname = COALESCE($2, 'public')
891
+ AND i.relname LIKE 'atscript__%' AND a.attname = ANY($3)`, [
892
+ tableName,
893
+ schema,
894
+ columns
895
+ ]);
896
+ for (const row of rows) {
897
+ const sql = `DROP INDEX IF EXISTS ${schema ? `${qi(schema)}.` : ""}${qi(row.name)}`;
898
+ this._log(sql);
899
+ await this._exec().exec(sql);
900
+ }
901
+ }
816
902
  async dropTableByName(tableName) {
817
903
  const ddl = `DROP TABLE IF EXISTS ${quoteTableName(tableName)} CASCADE`;
818
904
  this._log(ddl);
@@ -835,14 +921,29 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
835
921
  const vec = this._vectorFields.get(field.path);
836
922
  return this._supportsVector ? `vector(${vec.dimensions})` : "JSONB";
837
923
  }
924
+ if (field.isGeoPoint) return this._supportsGeo ? "geography(Point,4326)" : pgTypeFromField(field);
838
925
  return pgTypeFromField(field);
839
926
  }
840
927
  async syncIndexes() {
841
928
  const tableName = this._table.tableName;
842
929
  const schema = this._schema;
930
+ await this.prepareTypeMapper();
843
931
  await this.syncIndexesWithDiff({
844
- listExisting: async () => this._exec().all(`SELECT indexname AS name FROM pg_indexes
845
- WHERE tablename = $1 AND schemaname = COALESCE($2, 'public')`, [tableName, schema]),
932
+ listExisting: async () => {
933
+ return (await this._exec().all(`SELECT i.relname AS name,
934
+ (SELECT array_agg(a.attname::text ORDER BY k.ord)
935
+ FROM unnest(x.indkey) WITH ORDINALITY AS k(attnum, ord)
936
+ JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum
937
+ WHERE k.attnum > 0) AS columns
938
+ FROM pg_index x
939
+ JOIN pg_class t ON t.oid = x.indrelid
940
+ JOIN pg_class i ON i.oid = x.indexrelid
941
+ JOIN pg_namespace n ON n.oid = t.relnamespace
942
+ WHERE t.relname = $1 AND n.nspname = COALESCE($2, 'public')`, [tableName, schema])).map((r) => ({
943
+ name: r.name,
944
+ columns: r.columns ?? void 0
945
+ }));
946
+ },
846
947
  createIndex: async (index) => {
847
948
  if (index.type === "fulltext") {
848
949
  const tsvectorExpr = this._buildTsvectorExpr(index.fields);
@@ -851,6 +952,16 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
851
952
  await this._exec().exec(sql);
852
953
  return;
853
954
  }
955
+ if (index.type === "geo") {
956
+ if (!this._supportsGeo) {
957
+ this.logger.warn(`[postgres] geo index "${index.name}" declared but PostGIS is not available — skipped`);
958
+ return;
959
+ }
960
+ const sql = `CREATE INDEX IF NOT EXISTS ${qi(index.key)} ON ${quoteTableName(this.resolveTableName())} USING gist (${qi(index.fields[0].name)})`;
961
+ this._log(sql);
962
+ await this._exec().exec(sql);
963
+ return;
964
+ }
854
965
  const unique = index.type === "unique" ? "UNIQUE " : "";
855
966
  const cols = index.fields.map((f) => `${qi(f.name)} ${f.sort === "desc" ? "DESC" : "ASC"}`).join(", ");
856
967
  const sql = `CREATE ${unique}INDEX IF NOT EXISTS ${qi(index.key)} ON ${quoteTableName(this.resolveTableName())} (${cols})`;
@@ -861,10 +972,6 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
861
972
  const sql = `DROP INDEX IF EXISTS ${schema ? `${qi(schema)}.` : ""}${qi(name)}`;
862
973
  this._log(sql);
863
974
  await this._exec().exec(sql);
864
- },
865
- warnUnsupportedTypes: {
866
- adapter: "postgres",
867
- types: ["geo"]
868
975
  }
869
976
  });
870
977
  if (this._supportsVector) for (const [field, vec] of this._vectorFields) {
@@ -1113,6 +1220,65 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1113
1220
  if (queryThreshold !== void 0) return queryThreshold;
1114
1221
  return this._vectorThresholds.get(indexName);
1115
1222
  }
1223
+ /**
1224
+ * Detects PostGIS support by attempting to enable the extension.
1225
+ * Idempotent — safe to call multiple times.
1226
+ */
1227
+ async _detectGeoSupport() {
1228
+ if (this._supportsGeo !== void 0) return this._supportsGeo;
1229
+ try {
1230
+ await this._exec().exec("CREATE EXTENSION IF NOT EXISTS postgis");
1231
+ this._supportsGeo = true;
1232
+ } catch {
1233
+ this._supportsGeo = false;
1234
+ this.logger.warn("[postgres] PostGIS extension not available — db.geoPoint fields are stored as JSONB (no geo search).");
1235
+ }
1236
+ return this._supportsGeo;
1237
+ }
1238
+ isGeoSearchable() {
1239
+ return this._supportsGeo === true;
1240
+ }
1241
+ async geoSearch(point, query, indexName) {
1242
+ await this._detectGeoSupport();
1243
+ const { sql, params } = this._buildGeoSearchSelect(this._prepareGeoSearch(point, query, indexName));
1244
+ this._log(sql, params);
1245
+ return (await this._exec().all(sql, params)).map((row) => renameGeoDistance(row));
1246
+ }
1247
+ async geoSearchWithCount(point, query, indexName) {
1248
+ await this._detectGeoSupport();
1249
+ const ctx = this._prepareGeoSearch(point, query, indexName);
1250
+ const { sql, params } = this._buildGeoSearchSelect(ctx);
1251
+ const countFrag = buildGeoSearchCount(pgDialect, ctx.tableName, ctx.where, ctx.dist, ctx.window);
1252
+ this._log(sql, params);
1253
+ this._log(countFrag.sql, countFrag.params);
1254
+ const [rows, countRow] = await Promise.all([this._exec().all(sql, params), this._exec().get(countFrag.sql, countFrag.params)]);
1255
+ return {
1256
+ data: rows.map((row) => renameGeoDistance(row)),
1257
+ count: parseCount(countRow?.cnt)
1258
+ };
1259
+ }
1260
+ /** Resolves the shared parts of a geo search once (column, filter, distance, window). */
1261
+ _prepareGeoSearch(point, query, indexName) {
1262
+ if (!this._supportsGeo) throw new DbError("GEO_NOT_SUPPORTED", [{
1263
+ path: "",
1264
+ message: "Geo search requires the PostGIS extension"
1265
+ }]);
1266
+ const column = this._resolveGeoColumn(indexName);
1267
+ const controls = query.controls ?? {};
1268
+ return {
1269
+ tableName: this.resolveTableName(),
1270
+ where: buildWhere(query.filter),
1271
+ dist: pgGeoDistanceExpr(qi(column), point),
1272
+ window: geoWindowFromControls(controls),
1273
+ controls
1274
+ };
1275
+ }
1276
+ _buildGeoSearchSelect(ctx) {
1277
+ return buildGeoSearchSelect(pgDialect, ctx.tableName, ctx.where, ctx.dist, ctx.window, {
1278
+ $limit: ctx.controls.$limit,
1279
+ $skip: ctx.controls.$skip
1280
+ });
1281
+ }
1116
1282
  };
1117
1283
  /**
1118
1284
  * Normalizes PostgreSQL information_schema data_type values
@@ -1137,6 +1303,7 @@ function normalizePgType(dataType, maxLength, numericPrecision, numericScale, ud
1137
1303
  case "user-defined":
1138
1304
  if (udtName === "vector") return formattedType;
1139
1305
  if (udtName === "citext") return "CITEXT";
1306
+ if (udtName === "geography") return formattedType;
1140
1307
  return udtName?.toUpperCase() ?? "USER-DEFINED";
1141
1308
  default: return dataType.toUpperCase();
1142
1309
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/db-postgres",
3
- "version": "0.1.105",
3
+ "version": "0.1.106",
4
4
  "description": "PostgreSQL adapter for @atscript/db with pg (node-postgres) driver support.",
5
5
  "keywords": [
6
6
  "atscript",
@@ -55,8 +55,8 @@
55
55
  "@atscript/typescript": "^0.1.76",
56
56
  "@uniqu/core": "^0.1.6",
57
57
  "pg": ">=8.0.0",
58
- "@atscript/db": "^0.1.105",
59
- "@atscript/db-sql-tools": "^0.1.105"
58
+ "@atscript/db": "^0.1.106",
59
+ "@atscript/db-sql-tools": "^0.1.106"
60
60
  },
61
61
  "peerDependenciesMeta": {
62
62
  "pg": {