@nspot/geo-engine 0.1.0 → 0.2.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/README.md CHANGED
@@ -55,7 +55,17 @@ import { installPack } from "@nspot/geo-engine";
55
55
  await installPack(pool, "https://example.com/packs/romania-1.0.0.json");
56
56
  ```
57
57
 
58
- `installPack` also accepts a file path or an already-parsed pack object.
58
+ `installPack` also accepts a file path, an already-parsed pack object, or a bare npm
59
+ package name, resolved like `require()` to `<package>/pack.json`:
60
+
61
+ ```ts
62
+ await installPack(pool, "@nspot/geo-pack-romania");
63
+ ```
64
+
65
+ so hosts can `npm i @nspot/geo-pack-romania` and install it without knowing a URL or file
66
+ path. Pass `{ resolveFrom }` to resolve the package name from a directory other than
67
+ `process.cwd()`. A pack package must expose `pack.json`; if its `package.json` declares
68
+ an `exports` map, that map must include `"./pack.json"`.
59
69
 
60
70
  **4. Filter your own query by region.**
61
71
 
@@ -114,7 +124,7 @@ usage: geo-engine <command> [options]
114
124
  register --name N --table [S.]T --id C (--geometry G | --lng X --lat Y)
115
125
  unregister --name N
116
126
  rebuild [--source N] recompute memberships (one source or all)
117
- pack install <url|file>
127
+ pack install <url|file|package>
118
128
  pack list
119
129
  pack uninstall <slug>
120
130
 
@@ -136,7 +146,7 @@ database, and `cause` when it wraps something else (a zod `ZodError`, a fetch fa
136
146
  | `invalid_input` | Postgres rejected a value (`22023`) — e.g. a malformed geometry or invalid enum value |
137
147
  | `invalid_input` | `idType` is not a plain SQL type name (`inRegions` / `notInRegions`), raised before any SQL is built |
138
148
  | `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) |
149
+ | `invalid_input` | `installPack` could not download, read, resolve or JSON-parse the pack (`cause` is the underlying error) |
140
150
  | `invalid_input` | the pack document failed validation — `Invalid pack document: <path>: <issue>; …`, `cause` is the `ZodError` |
141
151
  | `invalid_input` | `migrate` found a database whose schema is newer than the one this package ships |
142
152
  | `unknown_source` | the request named a source that isn't registered |
@@ -175,16 +185,64 @@ executable by PUBLIC).
175
185
  project against a scratch database; run it before every release.
176
186
 
177
187
  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.
188
+ `pnpm sdk:rehearsal` and confirm it passes, and open a pull request with the bump. There is
189
+ no release tag to push by hand — when the pull request merges and CI passes on `main`,
190
+ `.github/workflows/publish-sdk.yml` publishes it to npm, tags the commit
191
+ `sdk-node-v<version>` and creates a GitHub release for it.
192
+
193
+ That workflow publishes *every* publishable package in the repository whose version is not
194
+ on npm yet, not only the SDK. `node scripts/publishable.mjs --all` lists every publishable
195
+ package (one JSON line each — `name`, `version`, `dir`, `tag`, `published`), and the job
196
+ walks that list in order:
197
+
198
+ - a package whose version is **not** on npm is published (`npm publish --access public`);
199
+ - every package that is on npm — published just now or long since — then has its tag
200
+ (`sdk-node-v<version>` for the SDK, `pack-<slug>-v<version>` for a region pack) and its
201
+ GitHub release created if they are missing, and left alone if they are not.
202
+
203
+ A merge that bumps nothing publishes nothing; the job still runs, finds everything in
204
+ order, and exits green.
205
+
206
+ What "idempotent" means here is worth being precise about, because a re-run does more than
207
+ skip work. Re-running the job **repairs** a half-finished release: a package that published
208
+ successfully but whose tag push or release creation failed gets its tag and release on the
209
+ next run, instead of being skipped forever because it is now on npm. And one package
210
+ failing no longer stops the rest — a failure is recorded and the loop continues to the next
211
+ package, with the job failing at the end and naming everything that went wrong. So it is
212
+ always safe, and often useful, to re-run by hand from the Actions tab
213
+ (`workflow_dispatch`).
214
+
215
+ A manual run publishes **the current `main`**, not the commit of an older run: it checks
216
+ out `main`'s head, and the job refuses to run on any other branch. There is no way to
217
+ re-publish a past commit from the Actions tab; to release again, merge another bump.
218
+
219
+ Only one publish runs at a time (`concurrency: publish`, never cancelled mid-flight). Two
220
+ merges landing within a minute can therefore drop the middle run — a queued run is
221
+ superseded by a newer one. That is harmless: the gate is "is this version on npm", not "did
222
+ this commit run", so a version bump superseded before it published simply publishes on the
223
+ next merge.
224
+
225
+ The workflow publishes through npm Trusted Publishing (OpenID Connect), so there is no token
226
+ and no repository secret. npm binds a trusted publisher to a workflow **filename**, which is
227
+ why the file is still called `publish-sdk.yml` now that it publishes the packs too.
228
+
229
+ Publishing does *not* pass `--provenance`: npm only accepts provenance attestations from a
230
+ public source repository, and this repository is private, so asking for one fails the
231
+ publish outright. Trusted publishing itself is unaffected. If the repository is ever made
232
+ public, trusted publishing generates provenance on its own — no flag needed.
233
+
234
+ The first version of a *new* package cannot be published by CI, because a trusted publisher
235
+ can only be configured on a package that already exists. For each new package, once:
236
+
237
+ 1. Publish the first version from your machine: in the package directory run `npm login` and
238
+ `npm publish --access public` (for the SDK, npm runs `prepack`, which syncs the SQL and
239
+ builds; it asks for a one-time 2FA code).
240
+ 2. On npmjs.com open the package, Settings, Trusted Publisher, GitHub Actions: organisation
241
+ `NSpot-Games`, repository `geo-engine`, workflow filename `publish-sdk.yml`, environment
242
+ empty.
243
+
244
+ Until both are done for a package, the publish job runs and fails at `npm publish` for it.
245
+ `@nspot/geo-engine` is past that point: 0.1.0 was published by hand on 2026-09-16 and its
246
+ trusted publisher is configured, so every later version comes from a merge to `main`.
189
247
 
190
248
  A Python SDK does not exist yet.
package/dist/cli.js CHANGED
@@ -9,7 +9,7 @@ const USAGE = `usage: geo-engine <command> [options]
9
9
  register --name N --table [S.]T --id C (--geometry G | --lng X --lat Y)
10
10
  unregister --name N
11
11
  rebuild [--source N] recompute memberships (one source or all)
12
- pack install <url|file>
12
+ pack install <url|file|package>
13
13
  pack list
14
14
  pack uninstall <slug>
15
15
 
@@ -92,7 +92,7 @@ async function run() {
92
92
  else if (command === "pack") {
93
93
  const sub = positionals[1];
94
94
  if (sub === "install") {
95
- const source = positionals[2] ?? usageError("usage: geo-engine pack install <url|file>");
95
+ const source = positionals[2] ?? usageError("usage: geo-engine pack install <url|file|package>");
96
96
  const r = await installPack(pool, source);
97
97
  console.log(`installed ${r.pack}@${r.version}: ${r.regions} regions, ${r.countries} countries, ${r.memberships} memberships`);
98
98
  }
package/dist/packs.d.ts CHANGED
@@ -5,9 +5,24 @@ export interface InstallPackOptions {
5
5
  fetch?: typeof fetch;
6
6
  /** Abort an http(s) pack download after this many milliseconds; defaults to 30000. */
7
7
  timeoutMs?: number;
8
+ /** Directory to resolve a bare package name from; defaults to process.cwd(). */
9
+ resolveFrom?: string;
8
10
  }
9
11
  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. */
12
+ /**
13
+ * Install a pack into the database.
14
+ *
15
+ * `source` is a parsed pack document, or a string in one of three forms:
16
+ *
17
+ * - **an npm package name** — `"@nspot/geo-pack-romania"`, or an unscoped `"geo-pack-x"` —
18
+ * resolved to that package's `pack.json` the way `require()` would, starting from
19
+ * `opts.resolveFrom` (default: the current working directory). This is how first-party
20
+ * packs are distributed: `npm i @nspot/geo-pack-romania`, then pass the name.
21
+ * - **a file path** — `"./packs/romania/pack.json"`, relative to the current working
22
+ * directory, or absolute. An existing file always wins: a path that happens to look like
23
+ * a package name is read as a file when the file is there.
24
+ * - **an `http(s)` URL**, downloaded with a timeout (`opts.timeoutMs`, default 30s).
25
+ */
11
26
  export declare function installPack(db: Queryable, source: Pack | string, opts?: InstallPackOptions): Promise<InstallPackResult>;
12
27
  /** Removes the pack's regions and the pack row; returns how many regions were removed. */
13
28
  export declare function uninstallPack(db: Queryable, slug: string): Promise<number>;
package/dist/packs.js CHANGED
@@ -1,12 +1,29 @@
1
+ import { statSync } from "node:fs";
1
2
  import { readFile } from "node:fs/promises";
3
+ import { createRequire } from "node:module";
4
+ import path from "node:path";
2
5
  import { GeoEngineError, run } from "./errors.js";
3
6
  import { packSchema } from "./types.js";
4
7
  const DEFAULT_TIMEOUT_MS = 30_000;
8
+ const PACKAGE_NAME_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
5
9
  export async function exportPack(db, slug, version) {
6
10
  const rows = await run(db, "SELECT geo.export_pack($1, $2) AS pack", [slug, version]);
7
11
  return rows[0].pack;
8
12
  }
9
- /** Install a pack given as a parsed document, a file path, or an http(s) URL. */
13
+ /**
14
+ * Install a pack into the database.
15
+ *
16
+ * `source` is a parsed pack document, or a string in one of three forms:
17
+ *
18
+ * - **an npm package name** — `"@nspot/geo-pack-romania"`, or an unscoped `"geo-pack-x"` —
19
+ * resolved to that package's `pack.json` the way `require()` would, starting from
20
+ * `opts.resolveFrom` (default: the current working directory). This is how first-party
21
+ * packs are distributed: `npm i @nspot/geo-pack-romania`, then pass the name.
22
+ * - **a file path** — `"./packs/romania/pack.json"`, relative to the current working
23
+ * directory, or absolute. An existing file always wins: a path that happens to look like
24
+ * a package name is read as a file when the file is there.
25
+ * - **an `http(s)` URL**, downloaded with a timeout (`opts.timeoutMs`, default 30s).
26
+ */
10
27
  export async function installPack(db, source, opts = {}) {
11
28
  const raw = typeof source === "string" ? await loadPack(source, opts) : source;
12
29
  const doc = parsePack(raw);
@@ -43,21 +60,55 @@ async function loadPack(source, opts) {
43
60
  throw new GeoEngineError("invalid_input", `Pack at ${source} is not valid JSON: ${messageOf(cause)}`, { cause });
44
61
  }
45
62
  }
63
+ let file = source;
64
+ if (!isFile(source) && PACKAGE_NAME_RE.test(source)) {
65
+ try {
66
+ // path.resolve, not path.join: resolveFrom may be relative, and createRequire only
67
+ // accepts an absolute path or a file URL.
68
+ const req = createRequire(path.resolve(opts.resolveFrom ?? process.cwd(), "noop.js"));
69
+ file = req.resolve(`${source}/pack.json`);
70
+ }
71
+ catch (cause) {
72
+ // "pack.json" is a syntactically valid package name, so a source ending in .json that
73
+ // got this far was probably meant as a file path that is simply not there. Say so,
74
+ // rather than leaving the caller with a resolver error about a package they never had.
75
+ const alsoNotAFile = /\.json$/i.test(source)
76
+ ? ` (and no file named ${path.resolve(source)} exists either)`
77
+ : "";
78
+ throw new GeoEngineError("invalid_input", `Failed to resolve pack package ${source}${alsoNotAFile}: ${messageOf(cause)}`, { cause });
79
+ }
80
+ }
46
81
  let text;
47
82
  try {
48
- text = await readFile(source, "utf8");
83
+ text = await readFile(file, "utf8");
49
84
  }
50
85
  catch (cause) {
51
- throw new GeoEngineError("invalid_input", `Failed to read pack file ${source}: ${messageOf(cause)}`, { cause });
86
+ throw new GeoEngineError("invalid_input", `Failed to read pack file ${file}: ${messageOf(cause)}`, { cause });
52
87
  }
53
88
  try {
54
89
  return JSON.parse(text);
55
90
  }
56
91
  catch (cause) {
57
- throw new GeoEngineError("invalid_input", `Pack file ${source} is not valid JSON: ${messageOf(cause)}`, { cause });
92
+ throw new GeoEngineError("invalid_input", `Pack file ${file} is not valid JSON: ${messageOf(cause)}`, { cause });
58
93
  }
59
94
  }
60
95
  const messageOf = (err) => (err instanceof Error ? err.message : String(err));
96
+ /**
97
+ * Does this path point at an existing file?
98
+ *
99
+ * `throwIfNoEntry: false` only covers ENOENT; a path component that is not a directory
100
+ * (ENOTDIR), an unreadable parent (EACCES) or a symlink loop (ELOOP) still throw. None of
101
+ * those is a file, and none of them should stop us trying the package-name interpretation,
102
+ * so every throw means "no".
103
+ */
104
+ function isFile(p) {
105
+ try {
106
+ return statSync(p, { throwIfNoEntry: false })?.isFile() ?? false;
107
+ }
108
+ catch {
109
+ return false;
110
+ }
111
+ }
61
112
  /** Removes the pack's regions and the pack row; returns how many regions were removed. */
62
113
  export async function uninstallPack(db, slug) {
63
114
  const rows = await run(db, "SELECT geo.uninstall_pack($1) AS n", [slug]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nspot/geo-engine",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Region membership for your own PostGIS tables: install the geo schema, register tables, install region packs, query by region.",
5
5
  "license": "MIT",
6
6
  "type": "module",