@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 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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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
+ });
@@ -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
+ }
@@ -0,0 +1,7 @@
1
+ export * from "./types.js";
2
+ export * from "./paths.js";
3
+ export * from "./migrate.js";
4
+ export * from "./sources.js";
5
+ export * from "./packs.js";
6
+ export * from "./errors.js";
7
+ export * from "./query.js";
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export * from "./types.js";
2
+ export * from "./paths.js";
3
+ export * from "./migrate.js";
4
+ export * from "./sources.js";
5
+ export * from "./packs.js";
6
+ export * from "./errors.js";
7
+ export * from "./query.js";
@@ -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>;
@@ -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
+ }
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ /** Package root: works both from src/ (tsx) and dist/ (compiled). */
2
+ export declare const PACKAGE_ROOT: string;
3
+ /** The migration files shipped with this package (a copy of packages/db/sql). */
4
+ export declare const SQL_DIR: string;