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.
- package/CLAUDE.md +16 -2
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +747 -57
- package/packages/core/dist/index.js +750 -57
- package/packages/core/src/authGate.ts +6 -0
- package/packages/core/src/index.ts +2 -0
- package/packages/core/src/queue.ts +75 -18
- package/packages/core/src/queueBackends/liteBackend.ts +17 -1
- package/packages/core/src/queueBackends/mongoBackend.ts +80 -14
- package/packages/core/src/server.ts +5 -0
- package/packages/core/src/sso.ts +285 -0
- package/packages/orm/dist/index.js +755 -62
- package/packages/orm/src/adapters/postgres.ts +2 -0
- package/packages/orm/src/baseModel.ts +63 -10
- package/packages/orm/src/index.ts +2 -0
- package/packages/orm/src/migration.ts +14 -5
- package/packages/orm/src/point.ts +105 -0
- package/packages/orm/src/queryBuilder.ts +66 -5
- package/packages/orm/src/sqlTranslator.ts +63 -0
- package/packages/orm/src/types.ts +5 -1
- package/packages/swagger/dist/index.js +11 -1
- package/packages/swagger/src/generator.ts +11 -1
- package/types/core/src/index.d.ts +2 -0
- package/types/core/src/queue.d.ts +41 -4
- package/types/core/src/queueBackends/liteBackend.d.ts +7 -1
- package/types/core/src/queueBackends/mongoBackend.d.ts +7 -2
- package/types/core/src/sso.d.ts +55 -0
- package/types/orm/src/baseModel.d.ts +13 -5
- package/types/orm/src/index.d.ts +2 -0
- package/types/orm/src/point.d.ts +24 -0
- package/types/orm/src/queryBuilder.d.ts +10 -1
- package/types/orm/src/sqlTranslator.d.ts +13 -0
- 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 &&
|
|
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
|
-
|
|
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
|
|
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
|
|
1349
|
-
* this table is busted too
|
|
1350
|
-
* never touches this table is left intact
|
|
1351
|
-
*
|
|
1352
|
-
*
|
|
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
|
-
|
|
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
|
|
359
|
+
batch INTEGER DEFAULT 1 NOT NULL,
|
|
351
360
|
executed_at VARCHAR(50) NOT NULL,
|
|
352
|
-
passed INTEGER NOT NULL
|
|
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<{
|
|
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]?.
|
|
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
|
-
|
|
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
|
-
|
|
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";
|