@nspot/geo-engine 0.2.0 → 0.3.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
@@ -67,6 +67,15 @@ path. Pass `{ resolveFrom }` to resolve the package name from a directory other
67
67
  `process.cwd()`. A pack package must expose `pack.json`; if its `package.json` declares
68
68
  an `exports` map, that map must include `"./pack.json"`.
69
69
 
70
+ A pack may build on another: a region's `parent` is a slug, found first among the same
71
+ pack's regions and then among any other installed pack's (never among your local regions).
72
+ Links are remade after every install, so packs can be installed in any order, and
73
+ reinstalling a parent pack restores the links its uninstall cut. `exportPack(pool, slug,
74
+ version, { owner, slugPrefix })` exports every region, or only one owner's (`"local"` for
75
+ the regions no pack owns, or a pack's slug) and/or those under a slug prefix; a scoped
76
+ export names only the countries it needs, with no outline, so installing it never
77
+ overwrites one. Both need schema 7 (`@nspot/geo-engine` 0.3.0).
78
+
70
79
  **4. Filter your own query by region.**
71
80
 
72
81
  ```ts
@@ -178,71 +187,3 @@ writing rows or editing regions at that moment. Registering a source is as power
178
187
  writing a trigger by hand — only let database administrators call `registerSource` /
179
188
  `unregisterSource` (the underlying `geo.register_source` / `geo.unregister_source` are not
180
189
  executable by PUBLIC).
181
-
182
- ## Releasing
183
-
184
- `pnpm sdk:rehearsal` (from the repo root) packs the SDK and installs it into a throwaway
185
- project against a scratch database; run it before every release.
186
-
187
- To cut a release: bump `version` in `packages/sdk-node/package.json`, run
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`.
247
-
248
- A Python SDK does not exist yet.
package/dist/packs.d.ts CHANGED
@@ -8,7 +8,25 @@ export interface InstallPackOptions {
8
8
  /** Directory to resolve a bare package name from; defaults to process.cwd(). */
9
9
  resolveFrom?: string;
10
10
  }
11
- export declare function exportPack(db: Queryable, slug: string, version: string): Promise<Pack>;
11
+ export interface ExportPackOptions {
12
+ /**
13
+ * Whose regions to export: `"local"` for the regions no pack owns, or an installed pack's
14
+ * slug for that pack's regions. Omitted, every region is exported.
15
+ */
16
+ owner?: string;
17
+ /** Export only regions whose slug starts with this. */
18
+ slugPrefix?: string;
19
+ }
20
+ /**
21
+ * The database's regions as one pack document, named `slug`@`version`.
22
+ *
23
+ * Unscoped, it holds every region and every country with its outline. Scoped (`owner` or
24
+ * `slugPrefix`), it holds the chosen regions and only the countries they and their parents
25
+ * sit in, with no outline, so installing it never overwrites one. A region's `parent` is its
26
+ * parent's slug wherever that parent lives; a pack region whose parent is in another pack
27
+ * gets linked to it when both are installed (schema 7).
28
+ */
29
+ export declare function exportPack(db: Queryable, slug: string, version: string, opts?: ExportPackOptions): Promise<Pack>;
12
30
  /**
13
31
  * Install a pack into the database.
14
32
  *
package/dist/packs.js CHANGED
@@ -6,8 +6,22 @@ import { GeoEngineError, run } from "./errors.js";
6
6
  import { packSchema } from "./types.js";
7
7
  const DEFAULT_TIMEOUT_MS = 30_000;
8
8
  const PACKAGE_NAME_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
9
- export async function exportPack(db, slug, version) {
10
- const rows = await run(db, "SELECT geo.export_pack($1, $2) AS pack", [slug, version]);
9
+ /**
10
+ * The database's regions as one pack document, named `slug`@`version`.
11
+ *
12
+ * Unscoped, it holds every region and every country with its outline. Scoped (`owner` or
13
+ * `slugPrefix`), it holds the chosen regions and only the countries they and their parents
14
+ * sit in, with no outline, so installing it never overwrites one. A region's `parent` is its
15
+ * parent's slug wherever that parent lives; a pack region whose parent is in another pack
16
+ * gets linked to it when both are installed (schema 7).
17
+ */
18
+ export async function exportPack(db, slug, version, opts = {}) {
19
+ const rows = await run(db, "SELECT geo.export_pack($1, $2, $3, $4) AS pack", [
20
+ slug,
21
+ version,
22
+ opts.owner ?? null,
23
+ opts.slugPrefix ?? null,
24
+ ]);
11
25
  return rows[0].pack;
12
26
  }
13
27
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nspot/geo-engine",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",
@@ -11,7 +11,7 @@
11
11
  ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
12
12
  "./types": { "types": "./dist/types.d.ts", "import": "./dist/types.js" }
13
13
  },
14
- "bin": { "geo-engine": "./dist/cli.js" },
14
+ "bin": { "geo-engine": "dist/cli.js" },
15
15
  "files": ["dist", "sql", "README.md", "LICENSE"],
16
16
  "engines": { "node": ">=20" },
17
17
  "repository": {
@@ -0,0 +1,199 @@
1
+ -- ============================================================================
2
+ -- 007 — packs that build on other packs
3
+ --
4
+ -- A pack may name a parent that lives in another pack (a commune pack whose
5
+ -- communes sit in the Romania pack's județ). Every pack region keeps the parent
6
+ -- its pack declared, and the link is made to whichever pack region carries that
7
+ -- slug: the pack's own first (a slug is unique per country, so there is only
8
+ -- one), else another pack's. A local region is never adopted, since a pack
9
+ -- cannot know about it. Links are remade after every install, so the order in
10
+ -- which packs are installed does not matter, and reinstalling a parent pack
11
+ -- restores the links its uninstall cut (ON DELETE SET NULL).
12
+ --
13
+ -- export_pack gains a scope: the regions of one owner (a pack slug, or 'local'
14
+ -- for regions no pack owns) and/or a slug prefix. A scoped pack names only the
15
+ -- countries it needs, with no outline, so installing it never overwrites the
16
+ -- outline another pack or the host set.
17
+ -- ============================================================================
18
+
19
+ ALTER TABLE geo.regions
20
+ ADD COLUMN declared_parent text,
21
+ ADD COLUMN declared_parent_country text;
22
+
23
+ COMMENT ON COLUMN geo.regions.declared_parent IS
24
+ 'For a pack region: the parent slug its pack declared, resolved by geo.link_pack_parents()';
25
+ COMMENT ON COLUMN geo.regions.declared_parent_country IS
26
+ 'For a pack region: the country code of its declared parent';
27
+
28
+ -- Link every pack region to its declared parent among pack regions. Returns how
29
+ -- many links changed.
30
+ CREATE OR REPLACE FUNCTION geo.link_pack_parents() RETURNS int
31
+ LANGUAGE plpgsql AS $$
32
+ DECLARE
33
+ n int;
34
+ BEGIN
35
+ UPDATE geo.regions r
36
+ SET parent_id = par.id
37
+ FROM geo.countries pc
38
+ JOIN geo.regions par ON par.country_id = pc.id AND par.pack_id IS NOT NULL
39
+ WHERE r.pack_id IS NOT NULL
40
+ AND r.declared_parent IS NOT NULL
41
+ AND pc.code = r.declared_parent_country
42
+ AND par.slug = r.declared_parent
43
+ AND par.id <> r.id
44
+ AND r.parent_id IS DISTINCT FROM par.id;
45
+ GET DIAGNOSTICS n = ROW_COUNT;
46
+ RETURN n;
47
+ END $$;
48
+
49
+ DROP FUNCTION geo.export_pack(text, text);
50
+ CREATE OR REPLACE FUNCTION geo.export_pack(
51
+ p_slug text,
52
+ p_version text,
53
+ p_owner text DEFAULT NULL,
54
+ p_slug_prefix text DEFAULT NULL
55
+ ) RETURNS jsonb
56
+ LANGUAGE plpgsql STABLE AS $$
57
+ DECLARE
58
+ owner_id int;
59
+ scoped boolean := p_owner IS NOT NULL OR p_slug_prefix IS NOT NULL;
60
+ BEGIN
61
+ IF p_owner IS NOT NULL AND p_owner <> 'local' THEN
62
+ SELECT id INTO owner_id FROM geo.packs WHERE slug = p_owner;
63
+ IF owner_id IS NULL THEN
64
+ RAISE EXCEPTION 'Unknown pack "%"', p_owner USING ERRCODE = 'invalid_parameter_value';
65
+ END IF;
66
+ END IF;
67
+
68
+ RETURN (
69
+ WITH sel AS (
70
+ SELECT r.*
71
+ FROM geo.regions r
72
+ WHERE (p_owner IS NULL
73
+ OR (p_owner = 'local' AND r.pack_id IS NULL)
74
+ OR r.pack_id = owner_id)
75
+ AND (p_slug_prefix IS NULL OR starts_with(r.slug, p_slug_prefix))
76
+ ),
77
+ feats AS (
78
+ SELECT c.code AS country, r.slug, jsonb_build_object(
79
+ 'type', 'Feature',
80
+ 'geometry', ST_AsGeoJSON(r.geom, 6)::jsonb,
81
+ 'properties', jsonb_build_object(
82
+ 'country', c.code, 'slug', r.slug, 'name', r.name, 'type', r.type, 'level', r.level,
83
+ 'parent', coalesce(p.slug, r.declared_parent),
84
+ 'parent_country', CASE WHEN p.id IS NOT NULL THEN pc.code ELSE r.declared_parent_country END,
85
+ 'code', r.code, 'tags', to_jsonb(r.tags), 'properties', r.properties)) AS feature,
86
+ coalesce(pc.code, r.declared_parent_country) AS parent_country
87
+ FROM sel r
88
+ JOIN geo.countries c ON c.id = r.country_id
89
+ LEFT JOIN geo.regions p ON p.id = r.parent_id
90
+ LEFT JOIN geo.countries pc ON pc.id = p.country_id
91
+ ),
92
+ needed AS (
93
+ SELECT country AS code FROM feats
94
+ UNION
95
+ SELECT parent_country FROM feats WHERE parent_country IS NOT NULL
96
+ )
97
+ SELECT jsonb_build_object(
98
+ 'pack', jsonb_build_object(
99
+ 'slug', p_slug, 'version', p_version, 'schema_version', geo.schema_version(),
100
+ 'published_at', to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')),
101
+ 'countries', coalesce((
102
+ SELECT jsonb_agg(jsonb_build_object(
103
+ 'code', c.code, 'name', c.name,
104
+ 'geometry', CASE WHEN scoped OR c.geom IS NULL THEN NULL ELSE ST_AsGeoJSON(c.geom, 6)::jsonb END
105
+ ) ORDER BY c.code)
106
+ FROM geo.countries c
107
+ WHERE NOT scoped OR c.code IN (SELECT code FROM needed)), '[]'::jsonb),
108
+ 'regions', jsonb_build_object(
109
+ 'type', 'FeatureCollection',
110
+ 'features', coalesce((SELECT jsonb_agg(feature ORDER BY country, slug) FROM feats), '[]'::jsonb)))
111
+ );
112
+ END $$;
113
+
114
+ -- install_pack as in 005, except that it records each region's declared parent
115
+ -- and links parents across packs.
116
+ CREATE OR REPLACE FUNCTION geo.install_pack(p_pack jsonb) RETURNS jsonb
117
+ LANGUAGE plpgsql AS $$
118
+ DECLARE
119
+ hdr jsonb := p_pack->'pack';
120
+ feats jsonb := coalesce(p_pack->'regions'->'features', '[]'::jsonb);
121
+ pid int;
122
+ f jsonb;
123
+ n_countries int := 0;
124
+ n_regions int := 0;
125
+ n_members bigint;
126
+ code text;
127
+ BEGIN
128
+ IF hdr IS NULL OR coalesce(hdr->>'slug', '') = '' OR coalesce(hdr->>'version', '') = '' THEN
129
+ RAISE EXCEPTION 'Pack header must have slug and version' USING ERRCODE = 'invalid_parameter_value';
130
+ END IF;
131
+ IF coalesce((hdr->>'schema_version')::int, 0) > geo.schema_version() THEN
132
+ RAISE EXCEPTION 'Pack "%" needs schema version % but this database has %',
133
+ hdr->>'slug', hdr->>'schema_version', geo.schema_version()
134
+ USING ERRCODE = 'invalid_parameter_value';
135
+ END IF;
136
+
137
+ INSERT INTO geo.packs (slug, version, schema_version)
138
+ VALUES (hdr->>'slug', hdr->>'version', coalesce((hdr->>'schema_version')::int, geo.schema_version()))
139
+ ON CONFLICT (slug) DO UPDATE
140
+ SET version = EXCLUDED.version, schema_version = EXCLUDED.schema_version, installed_at = now()
141
+ RETURNING id INTO pid;
142
+
143
+ PERFORM set_config('geo.skip_membership_triggers', 'on', true);
144
+
145
+ FOR f IN SELECT * FROM jsonb_array_elements(coalesce(p_pack->'countries', '[]'::jsonb)) LOOP
146
+ PERFORM geo.upsert_country(f->>'code', f->>'name',
147
+ CASE WHEN jsonb_typeof(f->'geometry') = 'object' THEN f->'geometry' END);
148
+ n_countries := n_countries + 1;
149
+ END LOOP;
150
+
151
+ FOR f IN SELECT * FROM jsonb_array_elements(feats) LOOP
152
+ PERFORM geo.upsert_region(
153
+ f->'properties'->>'country',
154
+ f->'properties'->>'slug',
155
+ f->'properties'->>'name',
156
+ (f->'properties'->>'type')::geo.region_type,
157
+ (f->'properties'->>'level')::int,
158
+ NULL,
159
+ f->'properties'->>'code',
160
+ ARRAY(SELECT jsonb_array_elements_text(coalesce(f->'properties'->'tags', '[]'::jsonb))),
161
+ coalesce(f->'properties'->'properties', '{}'::jsonb),
162
+ f->'geometry',
163
+ true,
164
+ pid);
165
+ n_regions := n_regions + 1;
166
+ END LOOP;
167
+
168
+ -- regions that left the pack
169
+ DELETE FROM geo.regions r
170
+ USING geo.countries c
171
+ WHERE r.pack_id = pid AND c.id = r.country_id
172
+ AND NOT EXISTS (
173
+ SELECT 1 FROM jsonb_array_elements(feats) ff
174
+ WHERE upper(ff->'properties'->>'country') = c.code AND ff->'properties'->>'slug' = r.slug);
175
+
176
+ -- the parent each region declares (a slug, in its parent_country or its own)
177
+ UPDATE geo.regions r
178
+ SET declared_parent = nullif(ff->'properties'->>'parent', ''),
179
+ declared_parent_country = CASE WHEN nullif(ff->'properties'->>'parent', '') IS NULL THEN NULL
180
+ ELSE upper(coalesce(ff->'properties'->>'parent_country', ff->'properties'->>'country')) END
181
+ FROM jsonb_array_elements(feats) ff
182
+ JOIN geo.countries c ON c.code = upper(ff->'properties'->>'country')
183
+ WHERE r.pack_id = pid AND r.country_id = c.id AND r.slug = ff->'properties'->>'slug';
184
+
185
+ -- parents among pack regions, this pack's and every other's
186
+ PERFORM geo.link_pack_parents();
187
+
188
+ FOR code IN SELECT c.code FROM geo.countries c WHERE c.geom IS NULL LOOP
189
+ PERFORM geo.derive_country_geom(code);
190
+ END LOOP;
191
+
192
+ n_members := geo.rebuild_all_memberships();
193
+ PERFORM set_config('geo.skip_membership_triggers', 'off', true);
194
+ RETURN jsonb_build_object(
195
+ 'pack', hdr->>'slug', 'version', hdr->>'version',
196
+ 'countries', n_countries, 'regions', n_regions, 'memberships', n_members);
197
+ END $$;
198
+
199
+ SELECT geo.pin_search_path();