tina4-nodejs 3.13.103 → 3.13.105

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.
Files changed (33) hide show
  1. package/CLAUDE.md +16 -2
  2. package/package.json +1 -1
  3. package/packages/cli/dist/bin.js +747 -57
  4. package/packages/core/dist/index.js +750 -57
  5. package/packages/core/src/authGate.ts +6 -0
  6. package/packages/core/src/index.ts +2 -0
  7. package/packages/core/src/queue.ts +75 -18
  8. package/packages/core/src/queueBackends/liteBackend.ts +17 -1
  9. package/packages/core/src/queueBackends/mongoBackend.ts +80 -14
  10. package/packages/core/src/server.ts +5 -0
  11. package/packages/core/src/sso.ts +285 -0
  12. package/packages/orm/dist/index.js +755 -62
  13. package/packages/orm/src/adapters/postgres.ts +2 -0
  14. package/packages/orm/src/baseModel.ts +63 -10
  15. package/packages/orm/src/index.ts +2 -0
  16. package/packages/orm/src/migration.ts +14 -5
  17. package/packages/orm/src/point.ts +105 -0
  18. package/packages/orm/src/queryBuilder.ts +66 -5
  19. package/packages/orm/src/sqlTranslator.ts +63 -0
  20. package/packages/orm/src/types.ts +5 -1
  21. package/packages/swagger/dist/index.js +11 -1
  22. package/packages/swagger/src/generator.ts +11 -1
  23. package/types/core/src/index.d.ts +2 -0
  24. package/types/core/src/queue.d.ts +41 -4
  25. package/types/core/src/queueBackends/liteBackend.d.ts +7 -1
  26. package/types/core/src/queueBackends/mongoBackend.d.ts +7 -2
  27. package/types/core/src/sso.d.ts +55 -0
  28. package/types/orm/src/baseModel.d.ts +13 -5
  29. package/types/orm/src/index.d.ts +2 -0
  30. package/types/orm/src/point.d.ts +24 -0
  31. package/types/orm/src/queryBuilder.d.ts +10 -1
  32. package/types/orm/src/sqlTranslator.d.ts +13 -0
  33. package/types/orm/src/types.d.ts +5 -1
@@ -585,6 +585,8 @@ function fieldTypeToPostgres(def: FieldDefinition): string {
585
585
  return "TEXT";
586
586
  case "json":
587
587
  return "JSONB";
588
+ case "point":
589
+ return SQLTranslator.pointColumnType("postgres", def.srid ?? 4326);
588
590
  case "string":
589
591
  return def.maxLength ? `VARCHAR(${def.maxLength})` : "VARCHAR(255)";
590
592
  default:
@@ -11,6 +11,7 @@ import { SQLiteAdapter } from "./adapters/sqlite.js";
11
11
  import { QueryCache, SQLTranslator } from "./sqlTranslator.js";
12
12
  import { Log } from "../../core/src/index.js";
13
13
  import type { DatabaseAdapter, FieldDefinition, RelationshipDefinition } from "./types.js";
14
+ import { Point, DEFAULT_SRID, SpatialNotSupportedError } from "./point.js";
14
15
 
15
16
  /**
16
17
  * Convert a snake_case name to camelCase.
@@ -40,6 +41,11 @@ export function toDbFieldValue(def: FieldDefinition | undefined, value: unknown)
40
41
  if (def?.type === "json" && value !== null && value !== undefined && typeof value !== "string") {
41
42
  return JSON.stringify(value);
42
43
  }
44
+ if (def?.type === "point" && value !== null && value !== undefined) {
45
+ const point = Point.parse(value, def.srid ?? DEFAULT_SRID);
46
+ if (point.srid !== (def.srid ?? DEFAULT_SRID)) throw new TypeError(`Point field expects SRID ${def.srid ?? DEFAULT_SRID}; received ${point.srid}`);
47
+ return point.ewkt;
48
+ }
43
49
  return value;
44
50
  }
45
51
 
@@ -59,6 +65,11 @@ export function fromDbFieldValue(def: FieldDefinition | undefined, value: unknow
59
65
  return value; // leave the raw string in place
60
66
  }
61
67
  }
68
+ if (def?.type === "point" && value !== null && value !== undefined) {
69
+ const point = Point.parse(value, def.srid ?? DEFAULT_SRID);
70
+ if (point.srid !== (def.srid ?? DEFAULT_SRID)) throw new TypeError(`Point field expects SRID ${def.srid ?? DEFAULT_SRID}; received ${point.srid}`);
71
+ return point;
72
+ }
62
73
  return value;
63
74
  }
64
75
 
@@ -231,7 +242,9 @@ export class BaseModel {
231
242
  // the same object (e.g. a json field `default: {}` — mutating a.meta must
232
243
  // not leak into b.meta). Parity with the Python master's per-instance
233
244
  // deepcopy and Ruby's Marshal round-trip.
234
- if (dv !== null && typeof dv === "object") dv = structuredClone(dv);
245
+ if (def.type === "point" && dv !== null && dv !== undefined) {
246
+ dv = fromDbFieldValue(def, dv);
247
+ } else if (dv !== null && typeof dv === "object") dv = structuredClone(dv);
235
248
  this[name] = dv;
236
249
  }
237
250
 
@@ -356,7 +369,7 @@ export class BaseModel {
356
369
  * @returns A QueryBuilder instance bound to this model's table and database.
357
370
  */
358
371
  static query(): QueryBuilder {
359
- return QueryBuilder.fromTable(this.tableName, this.getDb());
372
+ return QueryBuilder.fromTable(this.tableName, this.getDb(), this.getPkColumn());
360
373
  }
361
374
 
362
375
  /**
@@ -1019,7 +1032,7 @@ export class BaseModel {
1019
1032
  for (const key of Object.keys(ModelClass.fields)) {
1020
1033
  if (this[key] !== undefined) {
1021
1034
  const outKey = case_ === "snake" ? (ModelClass.fieldMapping[key] ?? key) : key;
1022
- result[outKey] = this[key];
1035
+ result[outKey] = this[key] instanceof Point ? (this[key] as Point).geojson : this[key];
1023
1036
  }
1024
1037
  }
1025
1038
  // Include soft delete field
@@ -1100,6 +1113,21 @@ export class BaseModel {
1100
1113
  return result;
1101
1114
  }
1102
1115
 
1116
+ toFeature(geometryField?: string, include?: string[]): Record<string, unknown> {
1117
+ const ModelClass = this.constructor as typeof BaseModel;
1118
+ const pointFields = Object.entries(ModelClass.fields).filter(([, def]) => def.type === "point").map(([name]) => name);
1119
+ const field = geometryField ?? pointFields[0];
1120
+ if (!field || !pointFields.includes(field)) throw new Error("toFeature() needs a declared point field");
1121
+ const properties = this.toDict(include, "camel");
1122
+ const geometry = properties[field] ?? null;
1123
+ delete properties[field];
1124
+ return { type: "Feature", geometry, properties };
1125
+ }
1126
+
1127
+ static featureCollection(models: BaseModel[], geometryField?: string, include?: string[]): Record<string, unknown> {
1128
+ return { type: "FeatureCollection", features: models.map((model) => model.toFeature(geometryField, include)) };
1129
+ }
1130
+
1103
1131
  /**
1104
1132
  * Convert to an associative object (alias for toDict).
1105
1133
  */
@@ -1159,7 +1187,10 @@ export class BaseModel {
1159
1187
  */
1160
1188
  static async createTable(): Promise<boolean> {
1161
1189
  const db = this.getDb();
1162
- if (await adapterTableExists(db, this.tableName)) return true;
1190
+ const pointFields = Object.entries(this.fields).filter(([, def]) => def.type === "point");
1191
+ const engine = db.getDatabaseType();
1192
+ if (pointFields.length > 0) SQLTranslator.requireSpatial(engine, "PointField");
1193
+ if (await adapterTableExists(db, this.tableName)) return this.createSpatialIndexes(db, pointFields);
1163
1194
 
1164
1195
  // Prefer the adapter's createTable — every adapter implements it and the
1165
1196
  // async variants (PostgreSQL/MySQL/MSSQL/Firebird) emit engine-aware DDL
@@ -1190,7 +1221,7 @@ export class BaseModel {
1190
1221
  mappedFields["is_deleted"] = { type: "integer", default: 0 };
1191
1222
  }
1192
1223
  await adapterCreateTable(db, this.tableName, mappedFields);
1193
- return true;
1224
+ return this.createSpatialIndexes(db, pointFields);
1194
1225
  }
1195
1226
 
1196
1227
  // Fallback: build SQL manually (SQLite-only dialect — used only when an
@@ -1257,6 +1288,15 @@ export class BaseModel {
1257
1288
  return true;
1258
1289
  }
1259
1290
 
1291
+ private static async createSpatialIndexes(db: DatabaseAdapter, fields: Array<[string, FieldDefinition]>): Promise<boolean> {
1292
+ for (const [fieldName, def] of fields) {
1293
+ SQLTranslator.pointColumnType(db.getDatabaseType(), def.srid ?? DEFAULT_SRID);
1294
+ if (def.spatialIndex === false) continue;
1295
+ await adapterExecute(db, SQLTranslator.spatialIndex(db.getDatabaseType(), this.tableName, this.getDbColumn(fieldName)));
1296
+ }
1297
+ return true;
1298
+ }
1299
+
1260
1300
  /**
1261
1301
  * Find a record by primary key or throw an error if not found.
1262
1302
  */
@@ -1345,15 +1385,28 @@ export class BaseModel {
1345
1385
  /**
1346
1386
  * Invalidate every cached query that touches this model's table.
1347
1387
  *
1348
- * Tag-scoped, NOT a wholesale flush: a cached JOIN on another model that reads
1349
- * this table is busted too (it carries this table's tag), while a query that
1350
- * never touches this table is left intact. Called after every ORM write
1351
- * (save/delete/forceDelete/restore) so a read-after-write never serves a
1352
- * stale/deleted row (CACHE-DEC-01).
1388
+ * Tag-scoped in the ORM layer (a cached JOIN on another model that reads
1389
+ * this table is busted too because it carries this table's tag; a query
1390
+ * that never touches this table is left intact), then cascaded to the
1391
+ * DB layer on this model's bound connection so an out-of-band write /
1392
+ * deliberate refresh / race-with-another-process cannot leave stale rows
1393
+ * in db.fetch()'s persistent cache. Called after every ORM write
1394
+ * (save/delete/forceDelete/restore) so a read-after-write never serves
1395
+ * a stale/deleted row (CACHE-DEC-01). PY-06-22 (3.13.105) added the
1396
+ * DB-layer cascade -- previously the two cache layers disagreed under
1397
+ * TINA4_AUTO_CACHING=true + TINA4_DB_CACHE=true.
1353
1398
  */
1354
1399
  static clearCache(): void {
1355
1400
  const ModelClass = this as unknown as typeof BaseModel;
1356
1401
  modelQueryCache.clearTag((ModelClass.tableName ?? "").toLowerCase());
1402
+ try {
1403
+ const db: any = ModelClass.getDb();
1404
+ if (typeof db?.cacheClear === "function") db.cacheClear();
1405
+ } catch {
1406
+ // A resolvable DB is not guaranteed at every clearCache() call site
1407
+ // (module-import time in odd bootstraps, tests that mutate bindings);
1408
+ // never let a cache-clear crash a save/delete.
1409
+ }
1357
1410
  }
1358
1411
 
1359
1412
  /**
@@ -56,6 +56,8 @@ export type { ValidationError } from "./validation.js";
56
56
  export { BaseModel, snakeToCamel, camelToSnake } from "./baseModel.js";
57
57
  export { QueryBuilder } from "./queryBuilder.js";
58
58
  export { SQLTranslator, QueryCache } from "./sqlTranslator.js";
59
+ export { Point, SpatialNotSupportedError, DEFAULT_SRID } from "./point.js";
60
+ export type { GeoJsonPoint } from "./point.js";
59
61
  export {
60
62
  DEFAULT_DATABASE_CONNECT_TIMEOUT_SECONDS,
61
63
  CONNECT_TIMEOUT_TOLERANCE_MS,
@@ -297,7 +297,16 @@ const MIGRATION_TABLE = "tina4_migration";
297
297
  * sql_mode, so they are always correct there.
298
298
  */
299
299
  function mt(db: DatabaseAdapter): string {
300
- return engineOf(db) === "mysql" ? `\`${MIGRATION_TABLE}\`` : `"${MIGRATION_TABLE}"`;
300
+ const engine = engineOf(db);
301
+
302
+ // Firebird: leave it UNQUOTED so it folds to the upper-case TINA4_MIGRATION
303
+ // that the PHP and Python masters create. A quoted lower-case identifier is a
304
+ // DIFFERENT, case-sensitive table there, so a quoted spelling cannot see a
305
+ // ledger written by another Tina4 language — while tableExists() matches
306
+ // case-insensitively and reports it present, so the INSERT fails alone.
307
+ if (engine === "firebird") return MIGRATION_TABLE;
308
+
309
+ return engine === "mysql" ? `\`${MIGRATION_TABLE}\`` : `"${MIGRATION_TABLE}"`;
301
310
  }
302
311
 
303
312
  /**
@@ -347,9 +356,9 @@ async function ensureMigrationTableOn(db: DatabaseAdapter): Promise<void> {
347
356
  id INTEGER NOT NULL PRIMARY KEY,
348
357
  migration_name VARCHAR(500) NOT NULL UNIQUE,
349
358
  description VARCHAR(500),
350
- batch INTEGER NOT NULL DEFAULT 1,
359
+ batch INTEGER DEFAULT 1 NOT NULL,
351
360
  executed_at VARCHAR(50) NOT NULL,
352
- passed INTEGER NOT NULL DEFAULT 1
361
+ passed INTEGER DEFAULT 1 NOT NULL
353
362
  )`);
354
363
  } else {
355
364
  // Engine-aware bookkeeping DDL (non-Firebird). Each engine spells an
@@ -517,11 +526,11 @@ async function recordApplied(
517
526
 
518
527
  if (isFirebirdAdapter(db)) {
519
528
  // Firebird: generate the id from the sequence.
520
- const rows = await adapterQuery<{ NEXT_ID: number }>(db,
529
+ const rows = await adapterQuery<{ next_id: number }>(db,
521
530
  "SELECT GEN_ID(GEN_TINA4_MIGRATION_ID, 1) AS NEXT_ID FROM RDB$DATABASE",
522
531
  );
523
532
  insertCols.unshift("id");
524
- values.unshift(rows[0]?.NEXT_ID ?? 1);
533
+ values.unshift(rows[0]?.next_id ?? 1);
525
534
  }
526
535
 
527
536
  const placeholders = insertCols.map(() => "?").join(", ");
@@ -0,0 +1,105 @@
1
+ export const DEFAULT_SRID = 4326;
2
+
3
+ export class SpatialNotSupportedError extends Error {
4
+ constructor(message: string) {
5
+ super(message);
6
+ this.name = "SpatialNotSupportedError";
7
+ }
8
+ }
9
+
10
+ export type GeoJsonPoint = { type: "Point"; coordinates: [number, number] };
11
+
12
+ /** Immutable SRID-aware longitude/latitude point (ADR-0057). */
13
+ export class Point {
14
+ readonly lon: number;
15
+ readonly lat: number;
16
+ readonly srid: number;
17
+
18
+ constructor(lon: unknown, lat: unknown, srid: unknown = DEFAULT_SRID) {
19
+ if (typeof lon === "boolean" || typeof lat === "boolean" || typeof srid === "boolean") {
20
+ throw new TypeError("Point longitude, latitude and SRID must be numbers");
21
+ }
22
+ this.lon = Number(lon);
23
+ this.lat = Number(lat);
24
+ this.srid = Number(srid);
25
+ if (!Number.isFinite(this.lon) || !Number.isFinite(this.lat) || !Number.isInteger(this.srid)) {
26
+ throw new TypeError("Point longitude and latitude must be finite numbers and SRID must be an integer");
27
+ }
28
+ if (this.srid === DEFAULT_SRID) {
29
+ if (this.lon < -180 || this.lon > 180) throw new RangeError(`Point longitude ${this.lon} is outside -180..180; Tina4 uses longitude, latitude order`);
30
+ if (this.lat < -90 || this.lat > 90) throw new RangeError(`Point latitude ${this.lat} is outside -90..90; Tina4 uses longitude, latitude order`);
31
+ }
32
+ Object.freeze(this);
33
+ }
34
+
35
+ get wkt(): string { return `POINT(${formatCoordinate(this.lon)} ${formatCoordinate(this.lat)})`; }
36
+ get ewkt(): string { return `SRID=${this.srid};${this.wkt}`; }
37
+ get geojson(): GeoJsonPoint { return { type: "Point", coordinates: [this.lon, this.lat] }; }
38
+ toJSON(): GeoJsonPoint { return this.geojson; }
39
+ toArray(): [number, number] { return [this.lon, this.lat]; }
40
+
41
+ static parse(value: unknown, srid = DEFAULT_SRID): Point {
42
+ if (value instanceof Point) return value;
43
+ if (Array.isArray(value)) {
44
+ if (value.length < 2) throw new TypeError("Point coordinate pair needs longitude and latitude");
45
+ return new Point(value[0], value[1], srid);
46
+ }
47
+ if (value && typeof value === "object" && !(value instanceof Uint8Array)) {
48
+ return Point.fromGeoJson(value as Record<string, unknown>, srid);
49
+ }
50
+ if (value instanceof Uint8Array) return Point.fromWkb(value, srid);
51
+ if (typeof value === "string") {
52
+ const text = value.trim();
53
+ const match = /^(?:SRID\s*=\s*(\d+)\s*;\s*)?POINT\s*(?:Z|M|ZM)?\s*\(\s*([-+0-9.eE]+)\s+([-+0-9.eE]+)(?:\s+[-+0-9.eE]+)*\s*\)$/i.exec(text);
54
+ if (match) return new Point(match[2], match[3], match[1] ? Number(match[1]) : srid);
55
+ if (text.length >= 42 && text.length % 2 === 0 && /^[0-9a-f]+$/i.test(text)) {
56
+ return Point.fromWkb(Uint8Array.from(Buffer.from(text, "hex")), srid);
57
+ }
58
+ }
59
+ throw new TypeError("Point must be Point, [longitude, latitude], WKT/EWKT, GeoJSON or WKB/EWKB");
60
+ }
61
+
62
+ static geometryBinding(value: unknown, srid = DEFAULT_SRID): [string, "ewkt" | "geojson"] {
63
+ if (value instanceof Point || Array.isArray(value)) return [Point.parse(value, srid).ewkt, "ewkt"];
64
+ if (value && typeof value === "object") {
65
+ const candidate = value as Record<string, unknown>;
66
+ const geometry = String(candidate.type).toLowerCase() === "feature"
67
+ ? candidate.geometry as Record<string, unknown> : candidate;
68
+ const allowed = new Set(["point", "linestring", "polygon", "multipoint", "multilinestring", "multipolygon", "geometrycollection"]);
69
+ if (!geometry || !allowed.has(String(geometry.type).toLowerCase())) throw new TypeError("GeoJSON geometry has an unsupported type");
70
+ return [JSON.stringify(geometry), "geojson"];
71
+ }
72
+ if (typeof value === "string" && /^\s*(?:SRID\s*=\s*\d+\s*;\s*)?(?:POINT|LINESTRING|POLYGON|MULTIPOINT|MULTILINESTRING|MULTIPOLYGON|GEOMETRYCOLLECTION)\b/i.test(value)) {
73
+ return [/^\s*SRID/i.test(value) ? value.trim() : `SRID=${srid};${value.trim()}`, "ewkt"];
74
+ }
75
+ throw new TypeError("Geometry must be Point, coordinate pair, WKT/EWKT or GeoJSON");
76
+ }
77
+
78
+ private static fromGeoJson(data: Record<string, unknown>, srid: number): Point {
79
+ const geometry = String(data.type).toLowerCase() === "feature"
80
+ ? data.geometry as Record<string, unknown> : data;
81
+ if (!geometry || String(geometry.type).toLowerCase() !== "point") throw new TypeError("Point GeoJSON type must be Point");
82
+ const coordinates = geometry.coordinates;
83
+ if (!Array.isArray(coordinates) || coordinates.length < 2) throw new TypeError("Point GeoJSON coordinates must be [longitude, latitude]");
84
+ return new Point(coordinates[0], coordinates[1], srid);
85
+ }
86
+
87
+ private static fromWkb(raw: Uint8Array, srid: number): Point {
88
+ if (raw.byteLength < 21) throw new TypeError("Point WKB is too short");
89
+ const view = new DataView(raw.buffer, raw.byteOffset, raw.byteLength);
90
+ const little = raw[0] === 1;
91
+ const typeWord = view.getUint32(1, little);
92
+ let offset = 5;
93
+ if ((typeWord & 0x20000000) !== 0) {
94
+ srid = view.getUint32(5, little);
95
+ offset = 9;
96
+ }
97
+ const code = (typeWord & ~(0x20000000 | 0x40000000 | 0x80000000)) % 1000;
98
+ if (code !== 1 || raw.byteLength < offset + 16) throw new TypeError("WKB geometry is not a Point");
99
+ return new Point(view.getFloat64(offset, little), view.getFloat64(offset + 8, little), srid);
100
+ }
101
+ }
102
+
103
+ function formatCoordinate(value: number): string {
104
+ return Object.is(value, -0) ? "0" : Number(value.toPrecision(15)).toString();
105
+ }
@@ -20,11 +20,14 @@
20
20
  import type { DatabaseAdapter } from "./types.js";
21
21
  import { getAdapter, adapterFetch, adapterFetchOne, probeTotal } from "./database.js";
22
22
  import { DatabaseResult } from "./databaseResult.js";
23
+ import { Point, DEFAULT_SRID } from "./point.js";
24
+ import { SQLTranslator } from "./sqlTranslator.js";
23
25
 
24
26
  export class QueryBuilder {
25
27
  private table: string;
26
28
  private db: DatabaseAdapter | undefined;
27
29
  private columns: string[] = ["*"];
30
+ private selectParams: unknown[] = [];
28
31
  private wheres: [string, string][] = [];
29
32
  private params: unknown[] = [];
30
33
  private joinClauses: string[] = [];
@@ -32,15 +35,18 @@ export class QueryBuilder {
32
35
  private havings: string[] = [];
33
36
  private havingParams: unknown[] = [];
34
37
  private orderByCols: string[] = [];
38
+ private orderByParams: unknown[] = [];
39
+ private primaryKey: string | undefined;
35
40
  private limitVal: number | undefined;
36
41
  private offsetVal: number | undefined;
37
42
 
38
43
  /**
39
44
  * Private constructor — use static factory methods.
40
45
  */
41
- private constructor(table: string, db?: DatabaseAdapter) {
46
+ private constructor(table: string, db?: DatabaseAdapter, primaryKey?: string) {
42
47
  this.table = table;
43
48
  this.db = db;
49
+ this.primaryKey = primaryKey;
44
50
  }
45
51
 
46
52
  /**
@@ -50,8 +56,8 @@ export class QueryBuilder {
50
56
  * @param db - Optional database adapter.
51
57
  * @returns A new QueryBuilder instance.
52
58
  */
53
- static fromTable(tableName: string, db?: DatabaseAdapter): QueryBuilder {
54
- return new QueryBuilder(tableName, db);
59
+ static fromTable(tableName: string, db?: DatabaseAdapter, primaryKey?: string): QueryBuilder {
60
+ return new QueryBuilder(tableName, db, primaryKey);
55
61
  }
56
62
 
57
63
  /**
@@ -63,6 +69,7 @@ export class QueryBuilder {
63
69
  select(...cols: string[]): QueryBuilder {
64
70
  if (cols.length > 0) {
65
71
  this.columns = cols;
72
+ this.selectParams = [];
66
73
  }
67
74
  return this;
68
75
  }
@@ -152,6 +159,46 @@ export class QueryBuilder {
152
159
  return this;
153
160
  }
154
161
 
162
+ withinDistance(column: string, pointValue: unknown, radiusMetres: number, srid = DEFAULT_SRID): QueryBuilder {
163
+ const radius = Number(radiusMetres);
164
+ if (!Number.isFinite(radius) || radius < 0) throw new RangeError("Spatial radius must be finite and greater than or equal to zero");
165
+ const point = Point.parse(pointValue, srid);
166
+ return this.where(SQLTranslator.withinDistance(this.engine(), column, point.srid), [point.lon, point.lat, radius]);
167
+ }
168
+
169
+ intersects(column: string, geometry: unknown, srid = DEFAULT_SRID): QueryBuilder {
170
+ const [bound, form] = Point.geometryBinding(geometry, srid);
171
+ return this.where(SQLTranslator.intersects(this.engine(), column, form, srid), [bound]);
172
+ }
173
+
174
+ bbox(column: string, minLon: unknown, minLat: unknown, maxLon: unknown, maxLat: unknown, srid = DEFAULT_SRID): QueryBuilder {
175
+ const values = [minLon, minLat, maxLon, maxLat].map(Number);
176
+ if (!values.every(Number.isFinite)) throw new TypeError("Bounding-box coordinates must be finite numbers");
177
+ const [west, south, east, north] = values;
178
+ new Point(west, south, srid);
179
+ new Point(east, north, srid);
180
+ if (west > east || south > north) throw new RangeError("Bounding box must be ordered west, south, east, north");
181
+ return this.where(SQLTranslator.bbox(this.engine(), column, srid), values);
182
+ }
183
+
184
+ selectDistance(column: string, pointValue: unknown, alias = "distance", srid = DEFAULT_SRID): QueryBuilder {
185
+ const point = Point.parse(pointValue, srid);
186
+ this.columns.push(SQLTranslator.distanceAs(this.engine(), column, alias, point.srid));
187
+ this.selectParams.push(point.lon, point.lat);
188
+ return this;
189
+ }
190
+
191
+ orderByDistance(column: string, pointValue: unknown, direction: "ASC" | "DESC" = "ASC", srid = DEFAULT_SRID): QueryBuilder {
192
+ const order = direction.toUpperCase();
193
+ if (order !== "ASC" && order !== "DESC") throw new TypeError("Distance order direction must be ASC or DESC");
194
+ if (!this.primaryKey) throw new Error("Stable spatial ordering needs a primary key; use BaseModel.query() or pass one to fromTable()");
195
+ const point = Point.parse(pointValue, srid);
196
+ this.orderByCols.push(`${SQLTranslator.distance(this.engine(), column, point.srid)} ${order}`);
197
+ this.orderByParams.push(point.lon, point.lat);
198
+ this.orderByCols.push(`${SQLTranslator.spatialIdentifier(this.primaryKey, "primary key")} ASC`);
199
+ return this;
200
+ }
201
+
155
202
  /**
156
203
  * Set LIMIT and optional OFFSET.
157
204
  *
@@ -225,7 +272,7 @@ export class QueryBuilder {
225
272
  async get(): Promise<DatabaseResult> {
226
273
  this.ensureDb();
227
274
  const sql = this.toSql();
228
- const allParams = [...this.params, ...this.havingParams];
275
+ const allParams = [...this.selectParams, ...this.params, ...this.havingParams, ...this.orderByParams];
229
276
 
230
277
  const queryParams = allParams.length > 0 ? allParams : undefined;
231
278
  const rows = await adapterFetch(
@@ -265,7 +312,7 @@ export class QueryBuilder {
265
312
  async first<T = Record<string, unknown>>(): Promise<T | null> {
266
313
  this.ensureDb();
267
314
  const sql = this.toSql();
268
- const allParams = [...this.params, ...this.havingParams];
315
+ const allParams = [...this.selectParams, ...this.params, ...this.havingParams, ...this.orderByParams];
269
316
 
270
317
  return adapterFetchOne<T>(
271
318
  this.db!,
@@ -284,9 +331,18 @@ export class QueryBuilder {
284
331
 
285
332
  // Build a count query by replacing columns
286
333
  const original = this.columns;
334
+ const originalSelectParams = this.selectParams;
335
+ const originalOrder = this.orderByCols;
336
+ const originalOrderParams = this.orderByParams;
287
337
  this.columns = ["COUNT(*) as cnt"];
338
+ this.selectParams = [];
339
+ this.orderByCols = [];
340
+ this.orderByParams = [];
288
341
  const sql = this.toSql();
289
342
  this.columns = original;
343
+ this.selectParams = originalSelectParams;
344
+ this.orderByCols = originalOrder;
345
+ this.orderByParams = originalOrderParams;
290
346
 
291
347
  const allParams = [...this.params, ...this.havingParams];
292
348
 
@@ -544,4 +600,9 @@ export class QueryBuilder {
544
600
  }
545
601
  }
546
602
  }
603
+
604
+ private engine(): string {
605
+ this.ensureDb();
606
+ return this.db!.getDatabaseType();
607
+ }
547
608
  }
@@ -20,7 +20,70 @@
20
20
  // ── SQL Translator ───────────────────────────────────────────
21
21
 
22
22
  import { DatabaseUrl } from "./databaseUrl.js";
23
+ import { DEFAULT_SRID, SpatialNotSupportedError } from "./point.js";
23
24
  export class SQLTranslator {
25
+ private static readonly SPATIAL_ENGINES = new Set(["postgres", "postgresql"]);
26
+ private static readonly SPATIAL_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/;
27
+
28
+ static requireSpatial(engine: string, feature: string): string {
29
+ const name = String(engine || "unknown").toLowerCase();
30
+ if (!SQLTranslator.SPATIAL_ENGINES.has(name)) {
31
+ throw new SpatialNotSupportedError(
32
+ `${feature} is not supported on the '${name}' database engine. ` +
33
+ "Tina4 GIS support is PostGIS-first: use PostgreSQL with CREATE EXTENSION postgis. " +
34
+ "Tina4 will not replace a spatial query with an approximate coordinate query.",
35
+ );
36
+ }
37
+ return name;
38
+ }
39
+
40
+ static spatialIdentifier(name: string, what = "column"): string {
41
+ if (!SQLTranslator.SPATIAL_IDENTIFIER.test(name)) throw new TypeError(`Spatial ${what} is not a valid SQL identifier: ${name}`);
42
+ return name;
43
+ }
44
+
45
+ static pointColumnType(engine: string, srid = DEFAULT_SRID): string {
46
+ SQLTranslator.requireSpatial(engine, "PointField");
47
+ return `geography(Point,${srid})`;
48
+ }
49
+
50
+ static spatialIndex(engine: string, table: string, column: string): string {
51
+ SQLTranslator.requireSpatial(engine, "spatial index creation");
52
+ table = SQLTranslator.spatialIdentifier(table, "table");
53
+ column = SQLTranslator.spatialIdentifier(column);
54
+ return `CREATE INDEX IF NOT EXISTS ${table.replaceAll(".", "_")}_${column}_gist ON ${table} USING GIST (${column})`;
55
+ }
56
+
57
+ static pointLiteral(engine: string, srid = DEFAULT_SRID): string {
58
+ SQLTranslator.requireSpatial(engine, "spatial predicates");
59
+ return `ST_SetSRID(ST_MakePoint(?, ?), ${srid})::geography`;
60
+ }
61
+
62
+ static withinDistance(engine: string, column: string, srid = DEFAULT_SRID): string {
63
+ return `ST_DWithin(${SQLTranslator.spatialIdentifier(column)}, ${SQLTranslator.pointLiteral(engine, srid)}, ?)`;
64
+ }
65
+
66
+ static distance(engine: string, column: string, srid = DEFAULT_SRID): string {
67
+ return `ST_Distance(${SQLTranslator.spatialIdentifier(column)}, ${SQLTranslator.pointLiteral(engine, srid)})`;
68
+ }
69
+
70
+ static distanceAs(engine: string, column: string, alias: string, srid = DEFAULT_SRID): string {
71
+ return `${SQLTranslator.distance(engine, column, srid)} AS ${SQLTranslator.spatialIdentifier(alias, "result alias")}`;
72
+ }
73
+
74
+ static geometryLiteral(engine: string, form: "ewkt" | "geojson", srid = DEFAULT_SRID): string {
75
+ SQLTranslator.requireSpatial(engine, "spatial predicates");
76
+ return form === "ewkt" ? "ST_GeogFromText(?)" : `ST_SetSRID(ST_GeomFromGeoJSON(?), ${srid})::geography`;
77
+ }
78
+
79
+ static intersects(engine: string, column: string, form: "ewkt" | "geojson" = "ewkt", srid = DEFAULT_SRID): string {
80
+ return `ST_Intersects(${SQLTranslator.spatialIdentifier(column)}, ${SQLTranslator.geometryLiteral(engine, form, srid)})`;
81
+ }
82
+
83
+ static bbox(engine: string, column: string, srid = DEFAULT_SRID): string {
84
+ SQLTranslator.requireSpatial(engine, "bbox");
85
+ return `ST_Intersects(${SQLTranslator.spatialIdentifier(column)}, ST_MakeEnvelope(?, ?, ?, ?, ${srid})::geography)`;
86
+ }
24
87
  /**
25
88
  * Convert LIMIT/OFFSET to Firebird ROWS...TO syntax.
26
89
  *
@@ -1,4 +1,4 @@
1
- export type FieldType = "string" | "integer" | "number" | "numeric" | "decimal" | "boolean" | "datetime" | "text" | "json" | "foreignKey";
1
+ export type FieldType = "string" | "integer" | "number" | "numeric" | "decimal" | "boolean" | "datetime" | "text" | "json" | "foreignKey" | "point";
2
2
 
3
3
  export interface FieldDefinition {
4
4
  type: FieldType;
@@ -24,6 +24,10 @@ export interface FieldDefinition {
24
24
  references?: string;
25
25
  /** For type "foreignKey": override the has-many property name on the referenced model */
26
26
  relatedName?: string;
27
+ /** For type "point": spatial reference id (default WGS 84 / 4326). */
28
+ srid?: number;
29
+ /** For type "point": create the provider's spatial index (default true). */
30
+ spatialIndex?: boolean;
27
31
  }
28
32
 
29
33
  export interface RelationshipDefinition {
@@ -34,6 +34,14 @@ function resolveSecuritySchemes() {
34
34
  const apiKeyIn = ["header", "query", "cookie"].includes(rawIn) ? rawIn : "header";
35
35
  schemes.apiKeyAuth = { type: "apiKey", name: apiKeyName, in: apiKeyIn };
36
36
  }
37
+ const ssoIssuer = (process.env.TINA4_SSO_ISSUER ?? "").replace(/\/$/, "");
38
+ if (ssoIssuer) {
39
+ schemes.oidc = {
40
+ type: "openIdConnect",
41
+ openIdConnectUrl: `${ssoIssuer}/.well-known/openid-configuration`
42
+ };
43
+ schemes.ssoSession = { type: "apiKey", in: "cookie", name: "tina4_session" };
44
+ }
37
45
  for (const [name, def] of Object.entries(registeredSchemes)) {
38
46
  schemes[name] = def;
39
47
  }
@@ -215,7 +223,9 @@ function generate(routes, models = []) {
215
223
  if (!responses["401"]) responses["401"] = { description: "Unauthorized" };
216
224
  }
217
225
  } else if (routeRequiresAuth(route, method)) {
218
- operation.security = sanitizeSecurity([{ [defaultScheme]: [] }], schemes);
226
+ const requirements = [{ [defaultScheme]: [] }];
227
+ if (defaultScheme === "bearerAuth" && schemes.ssoSession) requirements.push({ ssoSession: [] });
228
+ operation.security = sanitizeSecurity(requirements, schemes);
219
229
  const responses = operation.responses;
220
230
  if (!responses["401"]) responses["401"] = { description: "Unauthorized" };
221
231
  }
@@ -92,6 +92,14 @@ function resolveSecuritySchemes(): Record<string, Record<string, unknown>> {
92
92
  const apiKeyIn = ["header", "query", "cookie"].includes(rawIn) ? rawIn : "header";
93
93
  schemes.apiKeyAuth = { type: "apiKey", name: apiKeyName, in: apiKeyIn };
94
94
  }
95
+ const ssoIssuer = (process.env.TINA4_SSO_ISSUER ?? "").replace(/\/$/, "");
96
+ if (ssoIssuer) {
97
+ schemes.oidc = {
98
+ type: "openIdConnect",
99
+ openIdConnectUrl: `${ssoIssuer}/.well-known/openid-configuration`,
100
+ };
101
+ schemes.ssoSession = { type: "apiKey", in: "cookie", name: "tina4_session" };
102
+ }
95
103
  // Registered schemes win (let an app override bearerAuth or add oauth2).
96
104
  for (const [name, def] of Object.entries(registeredSchemes)) {
97
105
  schemes[name] = def;
@@ -348,7 +356,9 @@ export function generate(
348
356
  if (!responses["401"]) responses["401"] = { description: "Unauthorized" };
349
357
  }
350
358
  } else if (routeRequiresAuth(route, method)) {
351
- operation.security = sanitizeSecurity([{ [defaultScheme]: [] }], schemes);
359
+ const requirements = [{ [defaultScheme]: [] }];
360
+ if (defaultScheme === "bearerAuth" && schemes.ssoSession) requirements.push({ ssoSession: [] });
361
+ operation.security = sanitizeSecurity(requirements, schemes);
352
362
  const responses = operation.responses as Record<string, unknown>;
353
363
  if (!responses["401"]) responses["401"] = { description: "Unauthorized" };
354
364
  }
@@ -55,6 +55,8 @@ export { HtmlElement, htmlElement, addHtmlHelpers, Raw, SafeString } from "./htm
55
55
  export { renderErrorOverlay, isDebugMode } from "./errorOverlay.js";
56
56
  export { AI_TOOLS, isInstalled, showMenu, installSelected, installAll, generateContext } from "./ai.js";
57
57
  export type { AiTool } from "./ai.js";
58
+ export { Sso, SSO, SsoError } from "./sso.js";
59
+ export type { SsoOptions } from "./sso.js";
58
60
  export { Ai, AiError, AiConfigError, AiHTTPError, AiTimeoutError, AiParseError } from "./aiClient.js";
59
61
  export type { ChatResponse, AiMessage, AiChatOptions, AiEmbedOptions } from "./aiClient.js";
60
62
  export type { ImapMessage, ImapFullMessage, ImapAttachment } from "./messenger.js";