@nspot/geo-engine 0.4.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 +56 -1
- package/dist/cli.js +2 -2
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +4 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/localities.d.ts +71 -0
- package/dist/localities.js +87 -0
- package/dist/packs.d.ts +14 -2
- package/dist/packs.js +10 -3
- package/dist/types.d.ts +503 -26
- package/dist/types.js +52 -1
- package/package.json +1 -1
- package/sql/009_localities.sql +665 -0
package/README.md
CHANGED
|
@@ -137,6 +137,59 @@ An empty list, or a string in place of the list, is refused with
|
|
|
137
137
|
comes from `@nspot/geo-engine/types`, which is safe to import in a browser bundle.
|
|
138
138
|
`regionsBbox` needs schema 8 (`@nspot/geo-engine` 0.4.0).
|
|
139
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
|
+
|
|
140
193
|
## CLI
|
|
141
194
|
|
|
142
195
|
The package ships a `geo-engine` bin (run it with `npx geo-engine …`, `pnpm dlx geo-engine …`,
|
|
@@ -177,7 +230,9 @@ database, and `cause` when it wraps something else (a zod `ZodError`, a fetch fa
|
|
|
177
230
|
| `invalid_input` | the pack document failed validation — `Invalid pack document: <path>: <issue>; …`, `cause` is the `ZodError` |
|
|
178
231
|
| `invalid_input` | `migrate` found a database whose schema is newer than the one this package ships |
|
|
179
232
|
| `unknown_source` | the request named a source that isn't registered |
|
|
180
|
-
| `
|
|
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 |
|
|
181
236
|
| `invalid_source` | the registered table or column doesn't exist (`42P01` / `42703`) |
|
|
182
237
|
| `database` | any other Postgres error |
|
|
183
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
package/dist/index.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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() }));
|