Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,14 @@ await db.insertInto("dataSource").values({ config: JSON.stringify({ type: "csv",

### PointPlugin

The `PointPlugin` handles serialisation of PostGIS geometry/geography columns:
PostGIS geography columns are handled in two halves:

- **Writing**: pass `{ lat: number, lng: number }` and the plugin converts it to `SRID=4326;POINT(lng lat)` WKT automatically. The same applies to `Polygon` and `MultiPolygon` GeoJSON objects.
- **Reading**: WKB hex strings returned by PostGIS are automatically parsed back to `{ lat, lng }` (or GeoJSON Polygon/MultiPolygon).
- **Reading**: the `PointPlugin` parses WKB hex strings returned by PostGIS back to `{ lat, lng }` (or GeoJSON Polygon/MultiPolygon for the `polygon` and `geography` columns).
- **Writing**: never pass a plain `{ lat, lng }` object. Build the value with `toGeography(point)` from `@/server/services/database/geography`, which returns a `ST_SetSRID(ST_MakePoint(...), 4326)::geography` expression. Polygons are written with an explicit `ST_GeomFromGeoJSON(...)` expression (see `upsertTurf`).

If a new PostGIS geometry column does not appear to be working (values come back as raw hex strings, or writes fail silently), check whether the column name/type is covered by the plugin's detection logic in `src/server/services/database/plugins/PointPlugin.ts`.
Geometry is deliberately **not** inferred from a value's shape: data record JSON can legitimately contain `lat` and `lng` keys, and guessing turned that JSON into a point. Type every geography column as `GeographyColumn<...>` (from `@/server/models/geography`) in its `*Table` type so that TypeScript rejects a raw object at the write site.

If a new PostGIS geometry column does not appear to be working on read (values come back as raw hex strings), check whether the column name/type is covered by the parsing logic in `src/server/services/database/plugins/PointPlugin.ts`.

## tRPC

Expand Down
10 changes: 8 additions & 2 deletions src/server/commands/populateGeocodeCache.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { sql } from "kysely";
import { GeocodingType } from "@/models/DataSource";
import { db } from "@/server/services/database";
import { toGeography } from "@/server/services/database/geography";
import logger from "@/server/services/logger";
import type { AddressGeocodingConfig } from "@/models/DataSource";
import type { Point } from "@/models/shared";
Expand Down Expand Up @@ -36,7 +37,7 @@ export default async function populateGeocodeCache() {
.trim();
if (!address) continue;

entries.push({ address, point: record.geocodePoint as Point | null });
entries.push({ address, point: record.geocodePoint });
}

if (entries.length === 0) {
Expand All @@ -55,7 +56,12 @@ export default async function populateGeocodeCache() {
const batch = deduplicated.slice(i, i + batchSize);
await db
.insertInto("geocodeCache")
.values(batch)
.values(
batch.map((entry) => ({
...entry,
point: toGeography(entry.point),
})),
)
.onConflict((oc) => oc.column("address").doNothing())
.execute();
inserted += batch.length;
Expand Down
3 changes: 1 addition & 2 deletions src/server/jobs/importDataRecords.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import logger from "@/server/services/logger";
import { batchAsync } from "../utils";
import { importBatch, inferColumnSemanticTypes } from "./importDataSource";
import type { GeocodeResult } from "@/models/DataRecord";
import type { Point } from "@/models/shared";

const importDataRecords = async (args: object | null): Promise<boolean> => {
if (!args || !("dataSourceId" in args)) {
Expand Down Expand Up @@ -60,7 +59,7 @@ const importDataRecords = async (args: object | null): Promise<boolean> => {
{
json: r.json as Record<string, unknown>,
geocodeResult: r.geocodeResult as GeocodeResult | null,
geocodePoint: r.geocodePoint as Point | null,
geocodePoint: r.geocodePoint,
},
]),
);
Expand Down
6 changes: 4 additions & 2 deletions src/server/mapping/geocode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
findAreasByPoint,
} from "@/server/repositories/Area";
import { db } from "@/server/services/database";
import { toGeography } from "@/server/services/database/geography";
import logger from "@/server/services/logger";
import { geojsonPointToPoint } from "../utils/geo";
import type { GeocodeContext, GeocodeResult } from "@/models/DataRecord";
Expand Down Expand Up @@ -439,13 +440,14 @@ const mapboxGeocode = async (
: null;
const context = feature ? parseContext(feature.properties?.context) : null;

const pointExpr = toGeography(point);
await db
.insertInto("geocodeCache")
.values({ address, point, context })
.values({ address, point: pointExpr, context })
.onConflict((oc) =>
oc
.column("address")
.doUpdateSet({ point, context, createdAt: sql`now()` }),
.doUpdateSet({ point: pointExpr, context, createdAt: sql`now()` }),
)
.execute();

Expand Down
22 changes: 19 additions & 3 deletions src/server/models/DataRecord.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,25 @@
import type { DataRecord } from "@/models/DataRecord";
import type { Point } from "@/models/shared";
import type { GeographyColumn } from "@/server/models/geography";
import type { ColumnType, Generated, Insertable, Updateable } from "kysely";

export type DataRecordTable = DataRecord & {
export type DataRecordTable = Omit<DataRecord, "geocodePoint"> & {
id: Generated<string>;
createdAt: ColumnType<Date, string | undefined, never>;
geocodePoint: GeographyColumn<Point | null>;
};

// Repositories take the point as a plain `{ lat, lng }` and convert it with
// `toGeography` themselves, so callers never build the SQL expression.
export type NewDataRecord = Omit<
Insertable<DataRecordTable>,
"geocodePoint"
> & {
geocodePoint?: Point | null;
};
export type DataRecordUpdate = Omit<
Updateable<DataRecordTable>,
"geocodePoint"
> & {
geocodePoint?: Point | null;
};
export type NewDataRecord = Insertable<DataRecordTable>;
export type DataRecordUpdate = Updateable<DataRecordTable>;
3 changes: 2 additions & 1 deletion src/server/models/GeocodeCache.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { GeocodeContext } from "@/models/DataRecord";
import type { Point } from "@/models/shared";
import type { GeographyColumn } from "@/server/models/geography";
import type { ColumnType, Insertable } from "kysely";

export interface GeocodeCacheTable {
address: string;
point: Point | null;
point: GeographyColumn<Point | null>;
context: GeocodeContext | null;
createdAt: ColumnType<Date, Date | undefined, Date>;
}
Expand Down
19 changes: 16 additions & 3 deletions src/server/models/PlacedMarker.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
import type { PlacedMarker } from "@/models/PlacedMarker";
import type { Point } from "@/models/shared";
import type { GeographyColumn } from "@/server/models/geography";
import type { Generated, Insertable, Updateable } from "kysely";

export type PlacedMarkerTable = PlacedMarker & {
export type PlacedMarkerTable = Omit<PlacedMarker, "point"> & {
id: Generated<string>;
point: GeographyColumn<Point>;
};

// Repositories take the point as a plain `{ lat, lng }` and convert it with
// `toGeography` themselves, so callers never build the SQL expression.
export type NewPlacedMarker = Omit<Insertable<PlacedMarkerTable>, "point"> & {
point: Point;
};
export type PlacedMarkerUpdate = Omit<
Updateable<PlacedMarkerTable>,
"point"
> & {
point?: Point;
};
export type NewPlacedMarker = Insertable<PlacedMarkerTable>;
export type PlacedMarkerUpdate = Updateable<PlacedMarkerTable>;
23 changes: 23 additions & 0 deletions src/server/models/geography.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { Expression } from "kysely";
import type { ColumnType } from "kysely";

/**
* Insert/update type for a PostGIS geography column: an explicit SQL
* expression (see `toGeography` in `@/server/services/database/geography`),
* never a plain object. Nullable columns also accept `null`.
*/
export type GeographyValue<Read> = null extends Read
? Expression<string> | null
: Expression<string>;

/**
* A PostGIS geography column. Reads come back parsed by the PointPlugin
* (e.g. `{ lat, lng }`); writes must go through `toGeography`, so that a
* plain `{ lat, lng }` object can never be mistaken for geometry when it is
* actually destined for a JSONB column.
*/
export type GeographyColumn<Read> = ColumnType<
Read,
GeographyValue<Read>,
GeographyValue<Read>
>;
8 changes: 7 additions & 1 deletion src/server/repositories/DataRecord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import { FilterOperator, FilterType } from "@/models/MapView";
import { InspectorComparisonStat } from "@/models/shared";
import { db } from "@/server/services/database";
import { toGeography } from "@/server/services/database/geography";
import { monthKeyRangeToDates } from "@/utils/dataRecord";
import type { ExternalRecordUpdate } from "@/models/DataRecord";
import type { RecordFilterInput, SortInput } from "@/models/MapView";
Expand Down Expand Up @@ -412,7 +413,12 @@ export function upsertDataRecords(dataRecords: NewDataRecord[]) {
if (dataRecords.length === 0) return [];
return db
.insertInto("dataRecord")
.values(dataRecords)
.values(
dataRecords.map((record) => ({
...record,
geocodePoint: toGeography(record.geocodePoint),
})),
)
.onConflict((oc) =>
oc.columns(["externalId", "dataSourceId"]).doUpdateSet((eb) => ({
json: eb.ref("excluded.json"),
Expand Down
9 changes: 7 additions & 2 deletions src/server/repositories/PlacedMarker.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { db } from "@/server/services/database";
import { toGeography } from "@/server/services/database/geography";
import type { NewPlacedMarker } from "@/server/models/PlacedMarker";

export function findPlacedMarkersByMapId(mapId: string) {
Expand All @@ -23,10 +24,14 @@ export async function deletePlacedMarkersByFolderId(folderId: string) {
}

export async function upsertPlacedMarker(placedMarker: NewPlacedMarker) {
const values = {
...placedMarker,
point: toGeography(placedMarker.point),
};
return db
.insertInto("placedMarker")
.values(placedMarker)
.onConflict((oc) => oc.columns(["id"]).doUpdateSet(placedMarker))
.values(values)
.onConflict((oc) => oc.columns(["id"]).doUpdateSet(values))
.returningAll()
.executeTakeFirstOrThrow();
}
22 changes: 22 additions & 0 deletions src/server/services/database/geography.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { sql } from "kysely";
import type { Point } from "@/models/shared";
import type { RawBuilder } from "kysely";

/**
* Build the SQL expression for writing a `{ lat, lng }` point to a PostGIS
* geography column. Geometry is never inferred from a value's shape (a data
* record's JSON can legitimately contain `lat` and `lng` keys), so every
* geography write goes through this helper.
*/
export function toGeography(point: Point): RawBuilder<string>;
export function toGeography(
point: Point | null | undefined,
): RawBuilder<string> | null;
export function toGeography(
point: Point | null | undefined,
): RawBuilder<string> | null {
if (!point) {
return null;
}
return sql<string>`ST_SetSRID(ST_MakePoint(${point.lng}, ${point.lat}), 4326)::geography`;
}
5 changes: 3 additions & 2 deletions src/server/services/database/plugins/JSONPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ import type {

/**
* Better handling of JSON serialization. Only works if *all* object/array fields
* in the database are `jsonb` columns (excluding geometry types which are handled
* by the higher priority PointPlugin).
* in the database are `jsonb` columns. Geography columns are never written as
* plain objects: use `toGeography` from `@/server/services/database/geography`,
* which produces a SQL expression this plugin leaves untouched.
*
* See: https://github.com/kysely-org/kysely/pull/138
*/
Expand Down
Loading
Loading