@nspot/geo-engine 0.1.0
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/LICENSE +21 -0
- package/README.md +190 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +137 -0
- package/dist/errors.d.ts +17 -0
- package/dist/errors.js +41 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/migrate.d.ts +14 -0
- package/dist/migrate.js +128 -0
- package/dist/packs.d.ts +14 -0
- package/dist/packs.js +73 -0
- package/dist/paths.d.ts +4 -0
- package/dist/paths.js +7 -0
- package/dist/query.d.ts +43 -0
- package/dist/query.js +52 -0
- package/dist/sources.d.ts +30 -0
- package/dist/sources.js +32 -0
- package/dist/types.d.ts +323 -0
- package/dist/types.js +58 -0
- package/package.json +45 -0
- package/sql/001_init.sql +188 -0
- package/sql/002_geojson_null_geometry.sql +26 -0
- package/sql/003_geo_schema.sql +364 -0
- package/sql/004_query_functions.sql +95 -0
- package/sql/005_packs.sql +200 -0
- package/sql/006_search_path.sql +71 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NSpot Games
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# @nspot/geo-engine
|
|
2
|
+
|
|
3
|
+
Region membership for your own PostGIS tables, as a library. `@nspot/geo-engine` installs
|
|
4
|
+
the `geo` schema into any Postgres 14+ database with PostGIS, registers any table that has a
|
|
5
|
+
row id and a geometry (or a lng/lat pair) so it gets a maintained region-membership cache,
|
|
6
|
+
installs portable "region pack" JSON documents (countries + regions you can move between
|
|
7
|
+
databases), and gives you typed query helpers plus a `geo-engine` CLI for all of the above.
|
|
8
|
+
It has no dependency on the rest of this monorepo — `@geo/shared` and `@geo/db` in this repo
|
|
9
|
+
are themselves built on top of it.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm i @nspot/geo-engine pg
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The package is ESM-only (`import`, no `require`) and needs Node 20 or newer.
|
|
18
|
+
|
|
19
|
+
`pg` is a peer-ish runtime dependency: every function here takes a `pg.Pool` or
|
|
20
|
+
`pg.PoolClient` as its first argument (typed as `Queryable`) and you own the connection.
|
|
21
|
+
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
**1. Apply the schema.**
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import pg from "pg";
|
|
28
|
+
import { migrate } from "@nspot/geo-engine";
|
|
29
|
+
|
|
30
|
+
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
|
|
31
|
+
await migrate(pool);
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**2. Register one of your own tables.**
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { registerSource } from "@nspot/geo-engine";
|
|
38
|
+
|
|
39
|
+
await registerSource(pool, {
|
|
40
|
+
name: "hotels",
|
|
41
|
+
table: "public.hotels",
|
|
42
|
+
idColumn: "id",
|
|
43
|
+
geometry: { lng: "lng", lat: "lat" },
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
This installs a row trigger that keeps `hotels` rows matched against every region as rows
|
|
48
|
+
or regions change, and computes the initial memberships.
|
|
49
|
+
|
|
50
|
+
**3. Install a region pack.**
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { installPack } from "@nspot/geo-engine";
|
|
54
|
+
|
|
55
|
+
await installPack(pool, "https://example.com/packs/romania-1.0.0.json");
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`installPack` also accepts a file path or an already-parsed pack object.
|
|
59
|
+
|
|
60
|
+
**4. Filter your own query by region.**
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
import { inRegions } from "@nspot/geo-engine";
|
|
64
|
+
|
|
65
|
+
const frag = inRegions(
|
|
66
|
+
"hotels",
|
|
67
|
+
{ regions: ["transilvania"], exclude: ["sibiu"] },
|
|
68
|
+
{ column: "h.id", idType: "int", offset: 1 }, // $1 is already used below
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const { rows } = await pool.query(
|
|
72
|
+
`SELECT h.* FROM hotels h WHERE h.price < $1 AND ${frag.text}`,
|
|
73
|
+
[100, ...frag.values],
|
|
74
|
+
);
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`offset` shifts the fragment's own placeholders (`$2`-`$5` here) past any you already used
|
|
78
|
+
in the surrounding statement; `frag.values` must be appended in the same order.
|
|
79
|
+
|
|
80
|
+
**5. Look up the regions covering one row.**
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
import { regionsOf } from "@nspot/geo-engine";
|
|
84
|
+
|
|
85
|
+
const regions = await regionsOf(pool, "hotels", 42);
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`query.ts` also exports `notInRegions` (rows in none of the listed regions, including rows
|
|
89
|
+
in no region at all), `ids` (external ids matching a filter, without writing SQL yourself)
|
|
90
|
+
and `regionsAt(db, lng, lat)` (regions covering a point).
|
|
91
|
+
|
|
92
|
+
`notInRegions` builds a SQL `NOT IN`, which is null-propagating: if the `column` you give it
|
|
93
|
+
can be NULL — a LEFT JOINed id, for example — the predicate is NULL rather than true for
|
|
94
|
+
that row and the row does not come back. Add an explicit `OR <column> IS NULL` when you want
|
|
95
|
+
those rows too.
|
|
96
|
+
|
|
97
|
+
**Trust model for `FragmentOptions.column`.** `column` (and `idType`) are interpolated into
|
|
98
|
+
the fragment's SQL verbatim: they are raw SQL authored by you, not values. Never build them
|
|
99
|
+
from request input — use a literal, or pick from a fixed list in your own code. Only
|
|
100
|
+
`source`, the region slugs and the exclusions travel as bound parameters. `idType` is
|
|
101
|
+
additionally checked against a plain-identifier pattern and rejected with
|
|
102
|
+
`GeoEngineError("invalid_input", …)` when it is anything else.
|
|
103
|
+
|
|
104
|
+
## CLI
|
|
105
|
+
|
|
106
|
+
The package ships a `geo-engine` bin (run it with `npx geo-engine …`, `pnpm dlx geo-engine …`,
|
|
107
|
+
or from a workspace with `pnpm exec geo-engine …`):
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
usage: geo-engine <command> [options]
|
|
111
|
+
|
|
112
|
+
check list objects in public that would block a fresh install
|
|
113
|
+
migrate apply pending migrations
|
|
114
|
+
register --name N --table [S.]T --id C (--geometry G | --lng X --lat Y)
|
|
115
|
+
unregister --name N
|
|
116
|
+
rebuild [--source N] recompute memberships (one source or all)
|
|
117
|
+
pack install <url|file>
|
|
118
|
+
pack list
|
|
119
|
+
pack uninstall <slug>
|
|
120
|
+
|
|
121
|
+
Connection: DATABASE_URL or --url.
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Every command reads `DATABASE_URL` from the environment, or takes an explicit `--url`.
|
|
125
|
+
|
|
126
|
+
## Errors
|
|
127
|
+
|
|
128
|
+
Every failure raised by this package is a `GeoEngineError` (`instanceof Error`) — with no
|
|
129
|
+
exceptions: validation done before the database is reached raises one too. Each carries a
|
|
130
|
+
stable `code`, plus the raw Postgres `sqlState` and `detail` when the failure came from the
|
|
131
|
+
database, and `cause` when it wraps something else (a zod `ZodError`, a fetch failure, an
|
|
132
|
+
`fs` error):
|
|
133
|
+
|
|
134
|
+
| code | when |
|
|
135
|
+
| ---------------- | ---------------------------------------------------------------------------------- |
|
|
136
|
+
| `invalid_input` | Postgres rejected a value (`22023`) — e.g. a malformed geometry or invalid enum value |
|
|
137
|
+
| `invalid_input` | `idType` is not a plain SQL type name (`inRegions` / `notInRegions`), raised before any SQL is built |
|
|
138
|
+
| `invalid_input` | `filter.regions` is empty in `inRegions` / `ids`, raised before the database is touched |
|
|
139
|
+
| `invalid_input` | `installPack` could not download, read or JSON-parse the pack (`cause` is the underlying error) |
|
|
140
|
+
| `invalid_input` | the pack document failed validation — `Invalid pack document: <path>: <issue>; …`, `cause` is the `ZodError` |
|
|
141
|
+
| `invalid_input` | `migrate` found a database whose schema is newer than the one this package ships |
|
|
142
|
+
| `unknown_source` | the request named a source that isn't registered |
|
|
143
|
+
| `conflict` | a unique-key violation (`23505`) — e.g. registering a name or pack slug that already exists |
|
|
144
|
+
| `invalid_source` | the registered table or column doesn't exist (`42P01` / `42703`) |
|
|
145
|
+
| `database` | any other Postgres error |
|
|
146
|
+
|
|
147
|
+
`installPack` aborts an `http(s)` download after 30 seconds; pass `{ timeoutMs }` to change
|
|
148
|
+
that.
|
|
149
|
+
|
|
150
|
+
## Prerequisites and reserved names
|
|
151
|
+
|
|
152
|
+
PostgreSQL 14+ with PostGIS installed (any schema — Supabase's `extensions`, for example).
|
|
153
|
+
While `migrate` applies the migrations, the connecting role's `search_path` must include the
|
|
154
|
+
PostGIS schema; after that, every `geo` function pins its own `search_path`, so only
|
|
155
|
+
`geom_expr` fragments you pass to `registerSource` need to qualify any non-PostGIS functions
|
|
156
|
+
they call. A fresh install also refuses to run if certain names already exist in `public`
|
|
157
|
+
(`findReservedPublicObjects(db)` checks this ahead of time). See "Installing into your own
|
|
158
|
+
database" in the
|
|
159
|
+
[repo README](https://github.com/NSpot-Games/geo-engine#installing-into-your-own-database)
|
|
160
|
+
for the full explanation, the exact reserved names, and the refusal message.
|
|
161
|
+
|
|
162
|
+
## The `geom_expr` trust model
|
|
163
|
+
|
|
164
|
+
The geometry expression you pass to `registerSource` (a raw geometry column name, or the
|
|
165
|
+
lng/lat pair used to build one) is stored in `geo.sources` and evaluated later, inside the
|
|
166
|
+
membership triggers and `rebuildMemberships`, with the privileges of whichever role is
|
|
167
|
+
writing rows or editing regions at that moment. Registering a source is as powerful as
|
|
168
|
+
writing a trigger by hand — only let database administrators call `registerSource` /
|
|
169
|
+
`unregisterSource` (the underlying `geo.register_source` / `geo.unregister_source` are not
|
|
170
|
+
executable by PUBLIC).
|
|
171
|
+
|
|
172
|
+
## Releasing
|
|
173
|
+
|
|
174
|
+
`pnpm sdk:rehearsal` (from the repo root) packs the SDK and installs it into a throwaway
|
|
175
|
+
project against a scratch database; run it before every release.
|
|
176
|
+
|
|
177
|
+
To cut a release: bump `version` in `packages/sdk-node/package.json`, run
|
|
178
|
+
`pnpm sdk:rehearsal` and confirm it passes, commit the version bump, tag the commit
|
|
179
|
+
`sdk-node-v<version>` (e.g. `sdk-node-v0.1.0`), and push the tag. Pushing a tag matching
|
|
180
|
+
`sdk-node-v*` runs `.github/workflows/publish-sdk.yml`, which checks the tag against
|
|
181
|
+
`package.json`'s version, builds the package, runs the full SDK test suite against a
|
|
182
|
+
PostGIS service container, and publishes to npm with provenance.
|
|
183
|
+
|
|
184
|
+
One-time setup, before the first release: create the `@nspot` organisation on npm,
|
|
185
|
+
generate an automation token scoped to it, and add that token as the repository secret
|
|
186
|
+
`NPM_TOKEN`. Without the secret the workflow is not inert: pushing a matching tag still
|
|
187
|
+
runs it, and it goes all the way through the checks, build and tests before failing at
|
|
188
|
+
the publish step. Add the secret before pushing the first tag.
|
|
189
|
+
|
|
190
|
+
A Python SDK does not exist yet.
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import pg from "pg";
|
|
4
|
+
import { GeoEngineError, findReservedPublicObjects, installPack, listPacks, migrate, rebuildMemberships, registerSource, uninstallPack, unregisterSource, } from "./index.js";
|
|
5
|
+
const USAGE = `usage: geo-engine <command> [options]
|
|
6
|
+
|
|
7
|
+
check list objects in public that would block a fresh install
|
|
8
|
+
migrate apply pending migrations
|
|
9
|
+
register --name N --table [S.]T --id C (--geometry G | --lng X --lat Y)
|
|
10
|
+
unregister --name N
|
|
11
|
+
rebuild [--source N] recompute memberships (one source or all)
|
|
12
|
+
pack install <url|file>
|
|
13
|
+
pack list
|
|
14
|
+
pack uninstall <slug>
|
|
15
|
+
|
|
16
|
+
Connection: DATABASE_URL or --url.`;
|
|
17
|
+
class UsageError extends Error {
|
|
18
|
+
}
|
|
19
|
+
function usageError(message) {
|
|
20
|
+
throw new UsageError(message ? `${message}\n\n${USAGE}` : USAGE);
|
|
21
|
+
}
|
|
22
|
+
function need(v, flag) {
|
|
23
|
+
if (!v)
|
|
24
|
+
usageError(`missing --${flag}`);
|
|
25
|
+
return v;
|
|
26
|
+
}
|
|
27
|
+
function parseCliArgs() {
|
|
28
|
+
try {
|
|
29
|
+
return parseArgs({
|
|
30
|
+
allowPositionals: true,
|
|
31
|
+
options: {
|
|
32
|
+
url: { type: "string" },
|
|
33
|
+
name: { type: "string" },
|
|
34
|
+
table: { type: "string" },
|
|
35
|
+
id: { type: "string" },
|
|
36
|
+
geometry: { type: "string" },
|
|
37
|
+
lng: { type: "string" },
|
|
38
|
+
lat: { type: "string" },
|
|
39
|
+
source: { type: "string" },
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
usageError(err instanceof Error ? err.message : String(err));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function run() {
|
|
48
|
+
const { positionals, values } = parseCliArgs();
|
|
49
|
+
const command = positionals[0];
|
|
50
|
+
const known = ["check", "migrate", "register", "unregister", "rebuild", "pack"];
|
|
51
|
+
if (!command || !known.includes(command))
|
|
52
|
+
usageError(command ? `unknown command: ${command}` : undefined);
|
|
53
|
+
const url = values.url ?? process.env.DATABASE_URL;
|
|
54
|
+
if (!url)
|
|
55
|
+
usageError("missing --url (or set DATABASE_URL)");
|
|
56
|
+
const pool = new pg.Pool({ connectionString: url });
|
|
57
|
+
try {
|
|
58
|
+
if (command === "check") {
|
|
59
|
+
const conflicts = await findReservedPublicObjects(pool);
|
|
60
|
+
if (conflicts.length === 0)
|
|
61
|
+
console.log("no reserved objects in public");
|
|
62
|
+
else {
|
|
63
|
+
console.log(conflicts.join(", "));
|
|
64
|
+
process.exitCode = 1;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
else if (command === "migrate") {
|
|
68
|
+
await migrate(pool, { log: (m) => console.log(m.replace(/^applied migration /, "applied ")) });
|
|
69
|
+
console.log("migrations up to date");
|
|
70
|
+
}
|
|
71
|
+
else if (command === "register") {
|
|
72
|
+
const name = need(values.name, "name");
|
|
73
|
+
const table = need(values.table, "table");
|
|
74
|
+
const id = need(values.id, "id");
|
|
75
|
+
if (values.geometry && (values.lng || values.lat))
|
|
76
|
+
usageError("provide either --geometry or --lng/--lat, not both");
|
|
77
|
+
if (!values.geometry && !(values.lng && values.lat))
|
|
78
|
+
usageError("provide --geometry, or both --lng and --lat");
|
|
79
|
+
const geometry = values.geometry ?? { lng: values.lng, lat: values.lat };
|
|
80
|
+
const s = await registerSource(pool, { name, table, idColumn: id, geometry });
|
|
81
|
+
console.log(`registered ${s.name} (${s.table_schema}.${s.table_name}, id ${s.id_column} ${s.id_type})`);
|
|
82
|
+
}
|
|
83
|
+
else if (command === "unregister") {
|
|
84
|
+
const name = need(values.name, "name");
|
|
85
|
+
await unregisterSource(pool, name);
|
|
86
|
+
console.log(`unregistered ${name}`);
|
|
87
|
+
}
|
|
88
|
+
else if (command === "rebuild") {
|
|
89
|
+
const n = await rebuildMemberships(pool, values.source);
|
|
90
|
+
console.log(`rebuilt ${n} memberships`);
|
|
91
|
+
}
|
|
92
|
+
else if (command === "pack") {
|
|
93
|
+
const sub = positionals[1];
|
|
94
|
+
if (sub === "install") {
|
|
95
|
+
const source = positionals[2] ?? usageError("usage: geo-engine pack install <url|file>");
|
|
96
|
+
const r = await installPack(pool, source);
|
|
97
|
+
console.log(`installed ${r.pack}@${r.version}: ${r.regions} regions, ${r.countries} countries, ${r.memberships} memberships`);
|
|
98
|
+
}
|
|
99
|
+
else if (sub === "list") {
|
|
100
|
+
const packs = await listPacks(pool);
|
|
101
|
+
if (packs.length === 0)
|
|
102
|
+
console.log("no packs installed");
|
|
103
|
+
else
|
|
104
|
+
for (const p of packs)
|
|
105
|
+
console.log(`${p.slug} ${p.version} ${p.region_count} regions ${p.installed_at}`);
|
|
106
|
+
}
|
|
107
|
+
else if (sub === "uninstall") {
|
|
108
|
+
const slug = positionals[2] ?? usageError("usage: geo-engine pack uninstall <slug>");
|
|
109
|
+
const n = await uninstallPack(pool, slug);
|
|
110
|
+
console.log(`removed ${n} regions of ${slug}`);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
usageError(sub ? `unknown pack subcommand: ${sub}` : "usage: geo-engine pack <install|list|uninstall>");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
if (err instanceof UsageError)
|
|
119
|
+
throw err;
|
|
120
|
+
const message = err instanceof GeoEngineError || err instanceof Error ? err.message : String(err);
|
|
121
|
+
console.error(message);
|
|
122
|
+
process.exitCode = 1;
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
await pool.end();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
run().catch((err) => {
|
|
129
|
+
if (err instanceof UsageError) {
|
|
130
|
+
console.error(err.message);
|
|
131
|
+
process.exitCode = 2;
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
135
|
+
process.exitCode = 1;
|
|
136
|
+
}
|
|
137
|
+
});
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Queryable } from "./migrate.js";
|
|
2
|
+
export type GeoEngineErrorCode = "invalid_input" | "unknown_source" | "conflict" | "invalid_source" | "database";
|
|
3
|
+
/** Every failure raised by this package. `sqlState` is the Postgres error code when there was one. */
|
|
4
|
+
export declare class GeoEngineError extends Error {
|
|
5
|
+
name: string;
|
|
6
|
+
readonly code: GeoEngineErrorCode;
|
|
7
|
+
readonly sqlState?: string;
|
|
8
|
+
readonly detail?: string;
|
|
9
|
+
constructor(code: GeoEngineErrorCode, message: string, opts?: {
|
|
10
|
+
sqlState?: string;
|
|
11
|
+
detail?: string;
|
|
12
|
+
cause?: unknown;
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
export declare function wrapPgError(err: unknown): GeoEngineError;
|
|
16
|
+
/** Run one parameterised statement and return its rows, translating failures. */
|
|
17
|
+
export declare function run<T>(db: Queryable, text: string, values?: unknown[]): Promise<T[]>;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Every failure raised by this package. `sqlState` is the Postgres error code when there was one. */
|
|
2
|
+
export class GeoEngineError extends Error {
|
|
3
|
+
name = "GeoEngineError";
|
|
4
|
+
code;
|
|
5
|
+
sqlState;
|
|
6
|
+
detail;
|
|
7
|
+
constructor(code, message, opts = {}) {
|
|
8
|
+
super(message, opts.cause !== undefined ? { cause: opts.cause } : undefined);
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.sqlState = opts.sqlState;
|
|
11
|
+
this.detail = opts.detail;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const BY_STATE = {
|
|
15
|
+
"22023": "invalid_input",
|
|
16
|
+
"23505": "conflict",
|
|
17
|
+
"42P01": "invalid_source",
|
|
18
|
+
"42703": "invalid_source",
|
|
19
|
+
};
|
|
20
|
+
export function wrapPgError(err) {
|
|
21
|
+
if (err instanceof GeoEngineError)
|
|
22
|
+
return err;
|
|
23
|
+
const e = err;
|
|
24
|
+
const sqlState = typeof e?.code === "string" ? e.code : undefined;
|
|
25
|
+
const message = typeof e?.message === "string" ? e.message : String(err);
|
|
26
|
+
const detail = typeof e?.detail === "string" ? e.detail : undefined;
|
|
27
|
+
let code = (sqlState && BY_STATE[sqlState]) || "database";
|
|
28
|
+
if (code === "invalid_input" && /^Unknown source/.test(message))
|
|
29
|
+
code = "unknown_source";
|
|
30
|
+
return new GeoEngineError(code, message, { sqlState, detail, cause: err });
|
|
31
|
+
}
|
|
32
|
+
/** Run one parameterised statement and return its rows, translating failures. */
|
|
33
|
+
export async function run(db, text, values = []) {
|
|
34
|
+
try {
|
|
35
|
+
const { rows } = await db.query(text, values);
|
|
36
|
+
return rows;
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
throw wrapPgError(err);
|
|
40
|
+
}
|
|
41
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import pg from "pg";
|
|
2
|
+
export type { Pool, PoolClient } from "pg";
|
|
3
|
+
export type Queryable = pg.Pool | pg.PoolClient;
|
|
4
|
+
export interface MigrateOptions {
|
|
5
|
+
log?: (msg: string) => void;
|
|
6
|
+
/** Directory of *.sql migration files; defaults to the files shipped with this package. */
|
|
7
|
+
sqlDir?: string;
|
|
8
|
+
}
|
|
9
|
+
/** Objects in `public` whose names a fresh geo-engine install would take over. */
|
|
10
|
+
export declare function findReservedPublicObjects(db: Queryable): Promise<string[]>;
|
|
11
|
+
/** Apply pending migrations in filename order. Returns the file names applied by this call. */
|
|
12
|
+
export declare function migrate(db: Queryable, opts?: MigrateOptions): Promise<string[]>;
|
|
13
|
+
/** Highest applied migration number, 0 before the first install. */
|
|
14
|
+
export declare function schemaVersion(db: Queryable): Promise<number>;
|
package/dist/migrate.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { GeoEngineError, run, wrapPgError } from "./errors.js";
|
|
4
|
+
import { SQL_DIR } from "./paths.js";
|
|
5
|
+
/**
|
|
6
|
+
* Names 001/002 create in `public` and 003 then moves into `geo` (or drops).
|
|
7
|
+
* A host database that already owns any of them would have its own objects
|
|
8
|
+
* hijacked or dropped by a fresh install, so we refuse to start.
|
|
9
|
+
* `schema_migrations` is deliberately absent: that one is ours. The demo
|
|
10
|
+
* table (`locations` and friends) is no longer part of this core set: it is
|
|
11
|
+
* applied separately by @geo/db from packages/db/sql-demo.
|
|
12
|
+
*/
|
|
13
|
+
const RESERVED_PUBLIC_TYPES = ["region_type"];
|
|
14
|
+
const RESERVED_PUBLIC_TABLES = ["countries", "regions"];
|
|
15
|
+
const RESERVED_PUBLIC_FUNCTIONS = [
|
|
16
|
+
"geom_from_geojson_area",
|
|
17
|
+
"set_updated_at",
|
|
18
|
+
"upsert_country",
|
|
19
|
+
"upsert_region",
|
|
20
|
+
"derive_country_geom",
|
|
21
|
+
"regions_at_point",
|
|
22
|
+
];
|
|
23
|
+
/** Migration number encoded in a file name (`006_search_path.sql` → 6); 0 when absent. */
|
|
24
|
+
function migrationNumber(name) {
|
|
25
|
+
const n = Number.parseInt(name.split("_", 1)[0], 10);
|
|
26
|
+
return Number.isFinite(n) ? n : 0;
|
|
27
|
+
}
|
|
28
|
+
/** Objects in `public` whose names a fresh geo-engine install would take over. */
|
|
29
|
+
export async function findReservedPublicObjects(db) {
|
|
30
|
+
const rows = await run(db, `SELECT t.typname AS name
|
|
31
|
+
FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace
|
|
32
|
+
WHERE n.nspname = 'public' AND t.typname = ANY($1::text[])
|
|
33
|
+
UNION
|
|
34
|
+
SELECT c.relname
|
|
35
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
36
|
+
WHERE n.nspname = 'public' AND c.relname = ANY($2::text[])
|
|
37
|
+
UNION
|
|
38
|
+
SELECT p.proname
|
|
39
|
+
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
40
|
+
WHERE n.nspname = 'public' AND p.proname = ANY($3::text[])
|
|
41
|
+
ORDER BY 1`, [RESERVED_PUBLIC_TYPES, RESERVED_PUBLIC_TABLES, RESERVED_PUBLIC_FUNCTIONS]);
|
|
42
|
+
return rows.map((r) => r.name);
|
|
43
|
+
}
|
|
44
|
+
/** Apply pending migrations in filename order. Returns the file names applied by this call. */
|
|
45
|
+
export async function migrate(db, opts = {}) {
|
|
46
|
+
const log = opts.log ?? (() => { });
|
|
47
|
+
const sqlDir = opts.sqlDir ?? SQL_DIR;
|
|
48
|
+
const applied = [];
|
|
49
|
+
// One connection for the whole run: BEGIN / set_config / body / COMMIT must not be
|
|
50
|
+
// scattered over different connections of a pool.
|
|
51
|
+
//
|
|
52
|
+
// `db instanceof pg.Pool` would be wrong here: a host application can easily end up with
|
|
53
|
+
// a second copy of `pg` in its tree (a different version resolved for another dependency,
|
|
54
|
+
// a bundled build, a pool subclass from a wrapper library), and a Pool created from that
|
|
55
|
+
// copy is not `instanceof` *this* module's `pg.Pool` — it would be mistaken for a
|
|
56
|
+
// PoolClient and `client.release()` would be missing. Duck-type on the one member that
|
|
57
|
+
// actually differs: a `PoolClient` has `release`, a `Pool` does not. A bare `pg.Client`
|
|
58
|
+
// is not a `Queryable` and is not supported here: pass a Pool or a checked-out PoolClient.
|
|
59
|
+
const isClient = typeof db.release === "function";
|
|
60
|
+
const client = isClient ? db : await db.connect();
|
|
61
|
+
try {
|
|
62
|
+
await client.query("CREATE SCHEMA IF NOT EXISTS geo");
|
|
63
|
+
// databases migrated before the geo schema existed keep their history in public
|
|
64
|
+
await client.query(`
|
|
65
|
+
DO $$ BEGIN
|
|
66
|
+
IF to_regclass('public.schema_migrations') IS NOT NULL AND to_regclass('geo.schema_migrations') IS NULL THEN
|
|
67
|
+
ALTER TABLE public.schema_migrations SET SCHEMA geo;
|
|
68
|
+
END IF;
|
|
69
|
+
END $$`);
|
|
70
|
+
await client.query(`
|
|
71
|
+
CREATE TABLE IF NOT EXISTS geo.schema_migrations (
|
|
72
|
+
name text PRIMARY KEY,
|
|
73
|
+
applied_at timestamptz NOT NULL DEFAULT now()
|
|
74
|
+
)`);
|
|
75
|
+
const files = (await readdir(sqlDir)).filter((f) => f.endsWith(".sql")).sort();
|
|
76
|
+
const { rows } = await client.query("SELECT name FROM geo.schema_migrations");
|
|
77
|
+
const alreadyApplied = new Set(rows.map((r) => r.name));
|
|
78
|
+
// A database migrated by a newer @nspot/geo-engine may contain objects this copy does
|
|
79
|
+
// not know about; applying our (older) files on top would be at best a no-op and at
|
|
80
|
+
// worst a downgrade, so refuse rather than guess.
|
|
81
|
+
const dbMax = [...alreadyApplied].reduce((max, name) => Math.max(max, migrationNumber(name)), 0);
|
|
82
|
+
const sdkMax = files.reduce((max, name) => Math.max(max, migrationNumber(name)), 0);
|
|
83
|
+
if (dbMax > sdkMax) {
|
|
84
|
+
throw new GeoEngineError("invalid_input", `Database schema version ${dbMax} is newer than this package's ${sdkMax}; upgrade @nspot/geo-engine`);
|
|
85
|
+
}
|
|
86
|
+
if (alreadyApplied.size === 0) {
|
|
87
|
+
const conflicts = await findReservedPublicObjects(client);
|
|
88
|
+
if (conflicts.length > 0) {
|
|
89
|
+
throw new GeoEngineError("invalid_input", `Refusing to install: these objects already exist in schema public and would be taken over by the geo-engine migrations: ${conflicts.join(", ")}. Rename or drop them, or install geo-engine into a separate database.`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
for (const file of files) {
|
|
93
|
+
if (alreadyApplied.has(file))
|
|
94
|
+
continue;
|
|
95
|
+
const sql = await readFile(path.join(sqlDir, file), "utf8");
|
|
96
|
+
await client.query("BEGIN");
|
|
97
|
+
try {
|
|
98
|
+
// unqualified DDL in 001/002 must land in public even when the role's name matches
|
|
99
|
+
// a schema (the dev role is `geo`); keep the rest of the path so PostGIS resolves
|
|
100
|
+
// wherever the host installed it
|
|
101
|
+
await client.query("SELECT set_config('search_path', 'public, ' || current_setting('search_path'), true)");
|
|
102
|
+
await client.query(sql);
|
|
103
|
+
await client.query("INSERT INTO geo.schema_migrations (name) VALUES ($1)", [file]);
|
|
104
|
+
await client.query("COMMIT");
|
|
105
|
+
applied.push(file);
|
|
106
|
+
log(`applied migration ${file}`);
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
await client.query("ROLLBACK");
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
// every failure leaving this package is a GeoEngineError
|
|
116
|
+
throw wrapPgError(err);
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
if (!isClient)
|
|
120
|
+
client.release();
|
|
121
|
+
}
|
|
122
|
+
return applied;
|
|
123
|
+
}
|
|
124
|
+
/** Highest applied migration number, 0 before the first install. */
|
|
125
|
+
export async function schemaVersion(db) {
|
|
126
|
+
const rows = await run(db, "SELECT coalesce(max(split_part(name, '_', 1)::int), 0)::int AS v FROM geo.schema_migrations");
|
|
127
|
+
return rows[0].v;
|
|
128
|
+
}
|
package/dist/packs.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Queryable } from "./migrate.js";
|
|
2
|
+
import { type InstallPackResult, type Pack, type PackInfo } from "./types.js";
|
|
3
|
+
export interface InstallPackOptions {
|
|
4
|
+
/** Override for tests; defaults to the global fetch. */
|
|
5
|
+
fetch?: typeof fetch;
|
|
6
|
+
/** Abort an http(s) pack download after this many milliseconds; defaults to 30000. */
|
|
7
|
+
timeoutMs?: number;
|
|
8
|
+
}
|
|
9
|
+
export declare function exportPack(db: Queryable, slug: string, version: string): Promise<Pack>;
|
|
10
|
+
/** Install a pack given as a parsed document, a file path, or an http(s) URL. */
|
|
11
|
+
export declare function installPack(db: Queryable, source: Pack | string, opts?: InstallPackOptions): Promise<InstallPackResult>;
|
|
12
|
+
/** Removes the pack's regions and the pack row; returns how many regions were removed. */
|
|
13
|
+
export declare function uninstallPack(db: Queryable, slug: string): Promise<number>;
|
|
14
|
+
export declare function listPacks(db: Queryable): Promise<PackInfo[]>;
|
package/dist/packs.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { GeoEngineError, run } from "./errors.js";
|
|
3
|
+
import { packSchema } from "./types.js";
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
5
|
+
export async function exportPack(db, slug, version) {
|
|
6
|
+
const rows = await run(db, "SELECT geo.export_pack($1, $2) AS pack", [slug, version]);
|
|
7
|
+
return rows[0].pack;
|
|
8
|
+
}
|
|
9
|
+
/** Install a pack given as a parsed document, a file path, or an http(s) URL. */
|
|
10
|
+
export async function installPack(db, source, opts = {}) {
|
|
11
|
+
const raw = typeof source === "string" ? await loadPack(source, opts) : source;
|
|
12
|
+
const doc = parsePack(raw);
|
|
13
|
+
const rows = await run(db, "SELECT geo.install_pack($1::jsonb) AS r", [JSON.stringify(doc)]);
|
|
14
|
+
return rows[0].r;
|
|
15
|
+
}
|
|
16
|
+
/** Validate a pack document, reporting every zod issue as one `invalid_input` message. */
|
|
17
|
+
function parsePack(raw) {
|
|
18
|
+
const result = packSchema.safeParse(raw);
|
|
19
|
+
if (result.success)
|
|
20
|
+
return result.data;
|
|
21
|
+
const issues = result.error.issues
|
|
22
|
+
.map((i) => `${i.path.join(".") || "document"}: ${i.message}`)
|
|
23
|
+
.join("; ");
|
|
24
|
+
throw new GeoEngineError("invalid_input", `Invalid pack document: ${issues}`, { cause: result.error });
|
|
25
|
+
}
|
|
26
|
+
async function loadPack(source, opts) {
|
|
27
|
+
if (/^https?:\/\//i.test(source)) {
|
|
28
|
+
const fetchImpl = opts.fetch ?? fetch;
|
|
29
|
+
let res;
|
|
30
|
+
try {
|
|
31
|
+
res = await fetchImpl(source, { signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS) });
|
|
32
|
+
}
|
|
33
|
+
catch (cause) {
|
|
34
|
+
throw new GeoEngineError("invalid_input", `Failed to download pack from ${source}: ${messageOf(cause)}`, { cause });
|
|
35
|
+
}
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
throw new GeoEngineError("invalid_input", `Failed to download pack from ${source}: HTTP ${res.status}`);
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
return await res.json();
|
|
41
|
+
}
|
|
42
|
+
catch (cause) {
|
|
43
|
+
throw new GeoEngineError("invalid_input", `Pack at ${source} is not valid JSON: ${messageOf(cause)}`, { cause });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
let text;
|
|
47
|
+
try {
|
|
48
|
+
text = await readFile(source, "utf8");
|
|
49
|
+
}
|
|
50
|
+
catch (cause) {
|
|
51
|
+
throw new GeoEngineError("invalid_input", `Failed to read pack file ${source}: ${messageOf(cause)}`, { cause });
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(text);
|
|
55
|
+
}
|
|
56
|
+
catch (cause) {
|
|
57
|
+
throw new GeoEngineError("invalid_input", `Pack file ${source} is not valid JSON: ${messageOf(cause)}`, { cause });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const messageOf = (err) => (err instanceof Error ? err.message : String(err));
|
|
61
|
+
/** Removes the pack's regions and the pack row; returns how many regions were removed. */
|
|
62
|
+
export async function uninstallPack(db, slug) {
|
|
63
|
+
const rows = await run(db, "SELECT geo.uninstall_pack($1) AS n", [slug]);
|
|
64
|
+
return rows[0].n;
|
|
65
|
+
}
|
|
66
|
+
export async function listPacks(db) {
|
|
67
|
+
const rows = await run(db, `
|
|
68
|
+
SELECT p.id, p.slug, p.version, p.schema_version, p.installed_at,
|
|
69
|
+
(SELECT count(*) FROM geo.regions r WHERE r.pack_id = p.id)::int AS region_count
|
|
70
|
+
FROM geo.packs p ORDER BY p.slug`);
|
|
71
|
+
// pg hands back a Date for timestamptz; PackInfo.installed_at is an ISO string
|
|
72
|
+
return rows.map((row) => ({ ...row, installed_at: new Date(row.installed_at).toISOString() }));
|
|
73
|
+
}
|
package/dist/paths.d.ts
ADDED