@nspot/geo-engine 0.3.0 → 0.5.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
@@ -120,6 +120,76 @@ from request input — use a literal, or pick from a fixed list in your own code
120
120
  additionally checked against a plain-identifier pattern and rejected with
121
121
  `GeoEngineError("invalid_input", …)` when it is anything else.
122
122
 
123
+ **6. Fit a map to regions.**
124
+
125
+ ```ts
126
+ import { regionsBbox } from "@nspot/geo-engine";
127
+
128
+ const bbox = await regionsBbox(pool, ["bran-bv", "moieciu-bv"], { country: "RO" });
129
+ // [west, south, east, north], or null when no slug names a region
130
+ ```
131
+
132
+ `regionsBbox` gives the box of the union of the named regions, exact to the coordinate, ready
133
+ for a map's `fitBounds`. Unknown slugs among known ones are ignored. Slugs are unique per
134
+ country, not globally, so without `country` a slug two countries share gives the box of both.
135
+ An empty list, or a string in place of the list, is refused with
136
+ `GeoEngineError("invalid_input", …)` before the database is reached. The `Bbox` type also
137
+ comes from `@nspot/geo-engine/types`, which is safe to import in a browser bundle.
138
+ `regionsBbox` needs schema 8 (`@nspot/geo-engine` 0.4.0).
139
+
140
+ ## Localities
141
+
142
+ A locality is a named point: a village, a town, a city. Packs at schema 9 may carry a
143
+ `localities` section beside `regions`, and your own code may write local ones. Each is keyed
144
+ by `(country, code)` (for Romania, the SIRUTA code, bare: `40642`), which is the same in every
145
+ database, so your own tables reference it with a foreign key:
146
+
147
+ ```sql
148
+ ALTER TABLE public.hotels ADD COLUMN locality_country char(2), ADD COLUMN locality_code text,
149
+ ADD FOREIGN KEY (locality_country, locality_code) REFERENCES geo.localities (country, code);
150
+ ```
151
+
152
+ Every read answers `LocalitySummary` objects:
153
+
154
+ ```ts
155
+ interface LocalitySummary {
156
+ country: string; code: string; name: string;
157
+ point: [number, number] | null; // the label point, [lng, lat]
158
+ point_gap: string | null; // why there is no point, when there is none
159
+ parent: { slug: string; name: string } | null;
160
+ tags: string[]; properties: Record<string, unknown>;
161
+ pack: string | null; // the owning pack's slug; null for a local locality
162
+ distance_m?: number; // localitiesNear only
163
+ }
164
+ ```
165
+
166
+ ```ts
167
+ import { localitiesByCode, localitiesIn, localitiesNear, searchLocalities } from "@nspot/geo-engine";
168
+
169
+ await localitiesByCode(pool, ["40642"], { country: "RO" }); // in the order given; unknown codes absent
170
+ await localitiesIn(pool, ["sibiu"]); // by parent, through every level below
171
+ await localitiesIn(pool, ["bran-moeciu"], { via: "point" }); // by point, for a region that is nobody's parent
172
+ await searchLocalities(pool, "saliste", { within: ["sibiu"], limit: 20 }); // case and diacritics folded
173
+ await localitiesNear(pool, 23.98, 45.79, { limit: 10, maxMeters: 5000 }); // nearest first, with distance_m
174
+ ```
175
+
176
+ `upsertLocality(pool, { country, code, name, parent?, parentCountry?, point?, pointGap?, tags?,
177
+ properties? })` writes a local locality (give exactly one of `point`, a GeoJSON Point, and
178
+ `pointGap`) and answers its summary; `deleteLocality(pool, country, code)` answers whether one
179
+ was deleted. Both refuse a pack's locality with `conflict`.
180
+
181
+ A list argument must be a non-empty array: a string in its place (`"40642"`, which iterated
182
+ would name one-digit codes) is refused with `invalid_input` before the database is reached.
183
+
184
+ **`in_use`.** While one of your rows references a locality, nothing removes it:
185
+ `deleteLocality`, `uninstallPack`, and an `installPack` upgrade that drops the code all reject
186
+ with `GeoEngineError("in_use", …)` (`sqlState` `23503`), change nothing, and name the
187
+ localities in the message; `detail` carries Postgres's own key and table. Remap your rows,
188
+ then retry.
189
+
190
+ `exportPack`'s `include` option (`"all"`, the default, `"regions"` or `"localities"`) picks
191
+ the sections, and `listPacks` gives each pack's `locality_count`. Localities need schema 9. In 0.5.0, `listPacks` and `exportPack` need schema 9 too, so run `migrate` after upgrading.
192
+
123
193
  ## CLI
124
194
 
125
195
  The package ships a `geo-engine` bin (run it with `npx geo-engine …`, `pnpm dlx geo-engine …`,
@@ -155,11 +225,14 @@ database, and `cause` when it wraps something else (a zod `ZodError`, a fetch fa
155
225
  | `invalid_input` | Postgres rejected a value (`22023`) — e.g. a malformed geometry or invalid enum value |
156
226
  | `invalid_input` | `idType` is not a plain SQL type name (`inRegions` / `notInRegions`), raised before any SQL is built |
157
227
  | `invalid_input` | `filter.regions` is empty in `inRegions` / `ids`, raised before the database is touched |
228
+ | `invalid_input` | `slugs` is empty, or not an array, in `regionsBbox`, raised before the database is touched |
158
229
  | `invalid_input` | `installPack` could not download, read, resolve or JSON-parse the pack (`cause` is the underlying error) |
159
230
  | `invalid_input` | the pack document failed validation — `Invalid pack document: <path>: <issue>; …`, `cause` is the `ZodError` |
160
231
  | `invalid_input` | `migrate` found a database whose schema is newer than the one this package ships |
161
232
  | `unknown_source` | the request named a source that isn't registered |
162
- | `conflict` | a unique-key violation (`23505`) — e.g. registering a name or pack slug that already exists |
233
+ | `invalid_input` | a list argument of a locality read is empty or not an array, or a search query is blank, raised before the database is touched |
234
+ | `conflict` | a unique-key violation (`23505`) — e.g. registering a name or pack slug that already exists, or writing a pack's locality |
235
+ | `in_use` | a host row still references a locality that would be removed (`23503`); nothing changed |
163
236
  | `invalid_source` | the registered table or column doesn't exist (`42P01` / `42703`) |
164
237
  | `database` | any other Postgres error |
165
238
 
package/dist/cli.js CHANGED
@@ -94,7 +94,7 @@ async function run() {
94
94
  if (sub === "install") {
95
95
  const source = positionals[2] ?? usageError("usage: geo-engine pack install <url|file|package>");
96
96
  const r = await installPack(pool, source);
97
- console.log(`installed ${r.pack}@${r.version}: ${r.regions} regions, ${r.countries} countries, ${r.memberships} memberships`);
97
+ console.log(`installed ${r.pack}@${r.version}: ${r.regions} regions, ${r.countries} countries, ${r.memberships} memberships${r.localities !== undefined ? `, ${r.localities} localities` : ""}`);
98
98
  }
99
99
  else if (sub === "list") {
100
100
  const packs = await listPacks(pool);
@@ -102,7 +102,7 @@ async function run() {
102
102
  console.log("no packs installed");
103
103
  else
104
104
  for (const p of packs)
105
- console.log(`${p.slug} ${p.version} ${p.region_count} regions ${p.installed_at}`);
105
+ console.log(`${p.slug} ${p.version} ${p.region_count} regions ${p.locality_count} localities ${p.installed_at}`);
106
106
  }
107
107
  else if (sub === "uninstall") {
108
108
  const slug = positionals[2] ?? usageError("usage: geo-engine pack uninstall <slug>");
package/dist/errors.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Queryable } from "./migrate.js";
2
- export type GeoEngineErrorCode = "invalid_input" | "unknown_source" | "conflict" | "invalid_source" | "database";
2
+ export type GeoEngineErrorCode = "invalid_input" | "unknown_source" | "conflict" | "in_use" | "invalid_source" | "database";
3
3
  /** Every failure raised by this package. `sqlState` is the Postgres error code when there was one. */
4
4
  export declare class GeoEngineError extends Error {
5
5
  name: string;
package/dist/errors.js CHANGED
@@ -27,6 +27,10 @@ export function wrapPgError(err) {
27
27
  let code = (sqlState && BY_STATE[sqlState]) || "database";
28
28
  if (code === "invalid_input" && /^Unknown source/.test(message))
29
29
  code = "unknown_source";
30
+ // geo raises 23503 for other things too (upsert_region's unknown country): only the wording of
31
+ // delete_pack_localities and delete_locality means a host row still references a locality
32
+ if (sqlState === "23503" && message.includes("a host still references"))
33
+ code = "in_use";
30
34
  return new GeoEngineError(code, message, { sqlState, detail, cause: err });
31
35
  }
32
36
  /** Run one parameterised statement and return its rows, translating failures. */
package/dist/index.d.ts CHANGED
@@ -5,3 +5,4 @@ export * from "./sources.js";
5
5
  export * from "./packs.js";
6
6
  export * from "./errors.js";
7
7
  export * from "./query.js";
8
+ export * from "./localities.js";
package/dist/index.js CHANGED
@@ -5,3 +5,4 @@ export * from "./sources.js";
5
5
  export * from "./packs.js";
6
6
  export * from "./errors.js";
7
7
  export * from "./query.js";
8
+ export * from "./localities.js";
@@ -0,0 +1,71 @@
1
+ import type { Queryable } from "./migrate.js";
2
+ import type { LocalitySummary, PointGeometry } from "./types.js";
3
+ export interface LocalitiesByCodeOptions {
4
+ /** A country's ISO code, in either case: only that country's localities. */
5
+ country?: string;
6
+ }
7
+ /**
8
+ * The localities with these codes, in the order given; an unknown code is absent, and a code
9
+ * given twice answers once. Without `country`, a code two countries share answers both.
10
+ */
11
+ export declare function localitiesByCode(db: Queryable, codes: readonly string[], opts?: LocalitiesByCodeOptions): Promise<LocalitySummary[]>;
12
+ export interface LocalitiesInOptions {
13
+ /**
14
+ * `"parent"` (the default): localities whose parent is one of the regions or below one, gaps
15
+ * included. `"point"`: localities whose point lies in one of the regions, gaps left out, for a
16
+ * region that is nobody's parent.
17
+ */
18
+ via?: "parent" | "point";
19
+ }
20
+ /** The localities in the regions (slugs or ids), ordered by name. An unknown region is `invalid_input`. */
21
+ export declare function localitiesIn(db: Queryable, regions: readonly string[], opts?: LocalitiesInOptions): Promise<LocalitySummary[]>;
22
+ export interface SearchLocalitiesOptions {
23
+ /** Only localities whose parent chain reaches one of these regions (slugs or ids). */
24
+ within?: readonly string[];
25
+ /** 1 to 1000; defaults to 20. */
26
+ limit?: number;
27
+ }
28
+ /**
29
+ * Localities by name. Case, diacritics (ş and ș alike), hyphens and spaces are folded on both
30
+ * sides; an equal name ranks first, a prefix second, a substring third, then by name and code.
31
+ */
32
+ export declare function searchLocalities(db: Queryable, query: string, opts?: SearchLocalitiesOptions): Promise<LocalitySummary[]>;
33
+ export interface LocalitiesNearOptions {
34
+ /** 1 to 1000; defaults to 10. */
35
+ limit?: number;
36
+ /** Leave out any locality further than this many metres (geodesic). */
37
+ maxMeters?: number;
38
+ }
39
+ /**
40
+ * The localities nearest a point, nearest first, each with `distance_m` (geodesic metres, to
41
+ * 0.1 m). Localities with a `point_gap` have no point and never answer.
42
+ */
43
+ export declare function localitiesNear(db: Queryable, lng: number, lat: number, opts?: LocalitiesNearOptions): Promise<(LocalitySummary & {
44
+ distance_m: number;
45
+ })[]>;
46
+ export interface UpsertLocalityInput {
47
+ /** A country's ISO code, in either case; the country must exist. */
48
+ country: string;
49
+ code: string;
50
+ name: string;
51
+ /** A region slug, resolved now to any region, local or a pack's. */
52
+ parent?: string | null;
53
+ /** The parent's country, when it is not `country`. */
54
+ parentCountry?: string | null;
55
+ /** The label point. Give exactly one of `point` and `pointGap`. */
56
+ point?: PointGeometry | null;
57
+ /** Why there is no point. */
58
+ pointGap?: string | null;
59
+ tags?: string[];
60
+ properties?: Record<string, unknown>;
61
+ }
62
+ /**
63
+ * Insert or update a local locality (one no pack owns) and return its summary. A pack's
64
+ * locality is refused with `conflict`; update or uninstall the pack instead.
65
+ */
66
+ export declare function upsertLocality(db: Queryable, input: UpsertLocalityInput): Promise<LocalitySummary>;
67
+ /**
68
+ * Delete a local locality; true when one was deleted, false when there was none. A pack's
69
+ * locality is refused with `conflict`, and one a host row still references with `in_use`.
70
+ */
71
+ export declare function deleteLocality(db: Queryable, country: string, code: string): Promise<boolean>;
@@ -0,0 +1,87 @@
1
+ import { GeoEngineError, run } from "./errors.js";
2
+ // Localities (schema 9): named points, keyed by (country, code), that a host's own tables
3
+ // reference with a foreign key. Every read answers LocalitySummary rows, one per row of the
4
+ // SQL function it wraps.
5
+ /**
6
+ * A list argument must be a non-empty array. A string is not a list: iterated, "40642" would
7
+ * name five one-digit codes and answer nothing, so it is refused before any query.
8
+ */
9
+ function requireList(value, message) {
10
+ if (!Array.isArray(value) || value.length === 0)
11
+ throw new GeoEngineError("invalid_input", message);
12
+ }
13
+ /**
14
+ * The localities with these codes, in the order given; an unknown code is absent, and a code
15
+ * given twice answers once. Without `country`, a code two countries share answers both.
16
+ */
17
+ export async function localitiesByCode(db, codes, opts = {}) {
18
+ requireList(codes, "codes must be a list naming at least one code");
19
+ const rows = await run(db, "SELECT t.s FROM geo.localities_by_code($1::text[], $2::text) AS t(s)", [
20
+ codes,
21
+ opts.country ?? null,
22
+ ]);
23
+ return rows.map((r) => r.s);
24
+ }
25
+ /** The localities in the regions (slugs or ids), ordered by name. An unknown region is `invalid_input`. */
26
+ export async function localitiesIn(db, regions, opts = {}) {
27
+ requireList(regions, "regions must be a list naming at least one region");
28
+ const rows = await run(db, "SELECT t.s FROM geo.localities_in($1::text[], $2::text) AS t(s)", [
29
+ regions,
30
+ opts.via ?? "parent",
31
+ ]);
32
+ return rows.map((r) => r.s);
33
+ }
34
+ /**
35
+ * Localities by name. Case, diacritics (ş and ș alike), hyphens and spaces are folded on both
36
+ * sides; an equal name ranks first, a prefix second, a substring third, then by name and code.
37
+ */
38
+ export async function searchLocalities(db, query, opts = {}) {
39
+ if (typeof query !== "string" || query.trim() === "") {
40
+ throw new GeoEngineError("invalid_input", "query must be a non-empty string");
41
+ }
42
+ if (opts.within !== undefined)
43
+ requireList(opts.within, "within must be a list naming at least one region, or left out");
44
+ const rows = await run(db, "SELECT t.s FROM geo.search_localities($1, $2::text[], $3) AS t(s)", [
45
+ query,
46
+ opts.within ?? null,
47
+ opts.limit ?? 20,
48
+ ]);
49
+ return rows.map((r) => r.s);
50
+ }
51
+ /**
52
+ * The localities nearest a point, nearest first, each with `distance_m` (geodesic metres, to
53
+ * 0.1 m). Localities with a `point_gap` have no point and never answer.
54
+ */
55
+ export async function localitiesNear(db, lng, lat, opts = {}) {
56
+ const rows = await run(db, "SELECT t.s FROM geo.localities_near($1, $2, $3, $4) AS t(s)", [lng, lat, opts.limit ?? 10, opts.maxMeters ?? null]);
57
+ return rows.map((r) => r.s);
58
+ }
59
+ /**
60
+ * Insert or update a local locality (one no pack owns) and return its summary. A pack's
61
+ * locality is refused with `conflict`; update or uninstall the pack instead.
62
+ */
63
+ export async function upsertLocality(db, input) {
64
+ if (input.tags !== undefined && !Array.isArray(input.tags)) {
65
+ throw new GeoEngineError("invalid_input", "tags must be a list of strings");
66
+ }
67
+ const rows = await run(db, "SELECT geo.upsert_locality($1, $2, $3, $4, $5, $6::jsonb, $7, $8::text[], $9::jsonb) AS s", [
68
+ input.country,
69
+ input.code,
70
+ input.name,
71
+ input.parent ?? null,
72
+ input.parentCountry ?? null,
73
+ input.point ? JSON.stringify(input.point) : null,
74
+ input.pointGap ?? null,
75
+ input.tags ?? [],
76
+ JSON.stringify(input.properties ?? {}),
77
+ ]);
78
+ return rows[0].s;
79
+ }
80
+ /**
81
+ * Delete a local locality; true when one was deleted, false when there was none. A pack's
82
+ * locality is refused with `conflict`, and one a host row still references with `in_use`.
83
+ */
84
+ export async function deleteLocality(db, country, code) {
85
+ const rows = await run(db, "SELECT geo.delete_locality($1, $2) AS d", [country, code]);
86
+ return rows[0].d;
87
+ }
package/dist/packs.d.ts CHANGED
@@ -14,8 +14,15 @@ export interface ExportPackOptions {
14
14
  * slug for that pack's regions. Omitted, every region is exported.
15
15
  */
16
16
  owner?: string;
17
- /** Export only regions whose slug starts with this. */
17
+ /** Export only regions whose slug starts with this, and the localities whose parent it selects. */
18
18
  slugPrefix?: string;
19
+ /**
20
+ * What to export (schema 9). `"all"` (the default): the regions, and a `localities` section
21
+ * only when there are localities to write, so a pack without them exports as before.
22
+ * `"regions"`: no `localities` section. `"localities"`: no regions, and the section always
23
+ * written, even empty (installing an empty section removes the pack's localities).
24
+ */
25
+ include?: "all" | "regions" | "localities";
19
26
  }
20
27
  /**
21
28
  * The database's regions as one pack document, named `slug`@`version`.
@@ -42,6 +49,11 @@ export declare function exportPack(db: Queryable, slug: string, version: string,
42
49
  * - **an `http(s)` URL**, downloaded with a timeout (`opts.timeoutMs`, default 30s).
43
50
  */
44
51
  export declare function installPack(db: Queryable, source: Pack | string, opts?: InstallPackOptions): Promise<InstallPackResult>;
45
- /** Removes the pack's regions and the pack row; returns how many regions were removed. */
52
+ /**
53
+ * Removes the pack's regions, its localities and the pack row; returns how many regions were
54
+ * removed. Rejects with `in_use`, removing nothing, while a host row still references one of
55
+ * its localities. (`installPack` does the same for an upgrade that drops such a locality.)
56
+ */
46
57
  export declare function uninstallPack(db: Queryable, slug: string): Promise<number>;
58
+ /** The installed packs by slug, each with how many regions and localities it holds. */
47
59
  export declare function listPacks(db: Queryable): Promise<PackInfo[]>;
package/dist/packs.js CHANGED
@@ -16,11 +16,12 @@ const PACKAGE_NAME_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/
16
16
  * gets linked to it when both are installed (schema 7).
17
17
  */
18
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", [
19
+ const rows = await run(db, "SELECT geo.export_pack($1, $2, $3, $4, $5) AS pack", [
20
20
  slug,
21
21
  version,
22
22
  opts.owner ?? null,
23
23
  opts.slugPrefix ?? null,
24
+ opts.include ?? "all",
24
25
  ]);
25
26
  return rows[0].pack;
26
27
  }
@@ -123,15 +124,21 @@ function isFile(p) {
123
124
  return false;
124
125
  }
125
126
  }
126
- /** Removes the pack's regions and the pack row; returns how many regions were removed. */
127
+ /**
128
+ * Removes the pack's regions, its localities and the pack row; returns how many regions were
129
+ * removed. Rejects with `in_use`, removing nothing, while a host row still references one of
130
+ * its localities. (`installPack` does the same for an upgrade that drops such a locality.)
131
+ */
127
132
  export async function uninstallPack(db, slug) {
128
133
  const rows = await run(db, "SELECT geo.uninstall_pack($1) AS n", [slug]);
129
134
  return rows[0].n;
130
135
  }
136
+ /** The installed packs by slug, each with how many regions and localities it holds. */
131
137
  export async function listPacks(db) {
132
138
  const rows = await run(db, `
133
139
  SELECT p.id, p.slug, p.version, p.schema_version, p.installed_at,
134
- (SELECT count(*) FROM geo.regions r WHERE r.pack_id = p.id)::int AS region_count
140
+ (SELECT count(*) FROM geo.regions r WHERE r.pack_id = p.id)::int AS region_count,
141
+ (SELECT count(*) FROM geo.localities l WHERE l.pack_id = p.id)::int AS locality_count
135
142
  FROM geo.packs p ORDER BY p.slug`);
136
143
  // pg hands back a Date for timestamptz; PackInfo.installed_at is an ISO string
137
144
  return rows.map((row) => ({ ...row, installed_at: new Date(row.installed_at).toISOString() }));
package/dist/query.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Queryable } from "./migrate.js";
2
- import type { RegionSummary } from "./types.js";
2
+ import type { Bbox, RegionSummary } from "./types.js";
3
3
  export interface RegionFilter {
4
4
  /** Region slugs or numeric ids; at least one. */
5
5
  regions: string[];
@@ -41,3 +41,15 @@ export declare function notInRegions(source: string, regions: string[], opts: Fr
41
41
  export declare function ids(db: Queryable, source: string, filter: RegionFilter): Promise<string[]>;
42
42
  export declare function regionsOf(db: Queryable, source: string, externalId: string | number): Promise<RegionSummary[]>;
43
43
  export declare function regionsAt(db: Queryable, lng: number, lat: number): Promise<RegionSummary[]>;
44
+ export interface RegionsBboxOptions {
45
+ /** A country's ISO code, in either case: match the slugs in that country only. */
46
+ country?: string;
47
+ }
48
+ /**
49
+ * The box holding the named regions, for a map that fits its camera to them: [west, south,
50
+ * east, north] of the union of their envelopes, exact to the coordinate, or null when no slug
51
+ * names a region. Unknown slugs among known ones are ignored. Slugs are unique per country,
52
+ * not globally, so without `country` a slug two countries share gives the box of both.
53
+ * Needs schema 8 (`geo.regions_bbox`).
54
+ */
55
+ export declare function regionsBbox(db: Queryable, slugs: readonly string[], opts?: RegionsBboxOptions): Promise<Bbox | null>;
package/dist/query.js CHANGED
@@ -50,3 +50,18 @@ export async function regionsOf(db, source, externalId) {
50
50
  export async function regionsAt(db, lng, lat) {
51
51
  return run(db, "SELECT id, slug, name, type, level, parent_id, code FROM geo.regions_at_point($1, $2)", [lng, lat]);
52
52
  }
53
+ /**
54
+ * The box holding the named regions, for a map that fits its camera to them: [west, south,
55
+ * east, north] of the union of their envelopes, exact to the coordinate, or null when no slug
56
+ * names a region. Unknown slugs among known ones are ignored. Slugs are unique per country,
57
+ * not globally, so without `country` a slug two countries share gives the box of both.
58
+ * Needs schema 8 (`geo.regions_bbox`).
59
+ */
60
+ export async function regionsBbox(db, slugs, opts = {}) {
61
+ // a string is not a list: iterated, it would name one-letter slugs and answer null
62
+ if (!Array.isArray(slugs) || slugs.length === 0) {
63
+ throw new GeoEngineError("invalid_input", "slugs must name at least one region");
64
+ }
65
+ const rows = await run(db, "SELECT geo.regions_bbox($1::text[], $2::text) AS b", [slugs, opts.country ?? null]);
66
+ return rows[0].b;
67
+ }