diff --git a/.env.example b/.env.example index e98c5ed..2c79dac 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,5 @@ # mapcode-rest-service-ts environment variables. -# MAPCODE_BORDERS_PATH (required) - Path to the `borders.fgb` file. -MAPCODE_BORDERS_PATH=./data/borders.fgb - # PORT (8080) - TCP port to listen on. PORT=8080 diff --git a/README.md b/README.md index 18502c7..a9e4f7d 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,9 @@ loads, and is easier to package and deploy as a Node.js service. with Node's type stripping and needs Node.js ≥ 22.6.0. - The [`mapcode-ts`](https://www.npmjs.com/package/mapcode-ts) library, installed from npm. -- A **borders FlatGeobuf file** (`borders.fgb`) for the boundary/territory - lookups (see below). +- The [`mapcode-ts-borders`](https://www.npmjs.com/package/mapcode-ts-borders) + library, installed from npm. It bundles the OpenStreetMap border data for the + boundary/territory lookups (see below). ## Install @@ -30,25 +31,19 @@ loads, and is easier to package and deploy as a Node.js service. npm install ``` -## The borders file (`borders.fgb`) +## The borders data The `/mapcode/codes/{lat},{lon}` default response and the `/mapcode/codes/{lat},{lon}/territories` endpoint look up OpenStreetMap -administrative boundaries (point-in-polygon + ranking). These come from a -[FlatGeobuf](https://flatgeobuf.org/) file that the service loads fully into -memory on startup. +administrative boundaries (point-in-polygon + ranking). The lookup is done by +[`mapcode-ts-borders`](https://www.npmjs.com/package/mapcode-ts-borders), which +ships the border data as a `borders.mcb` file (OpenStreetMap, May 2026). The +service loads it fully into memory on startup and logs its source, for example +`borders: OpenStreetMap 2026-05`. New border data comes with a new +`mapcode-ts-borders` release. -Point the service at it with the **`MAPCODE_BORDERS_PATH`** environment -variable (required — the service refuses to start without it): - -```bash -MAPCODE_BORDERS_PATH=./data/borders.fgb -``` - -The production `borders.fgb` (~22 MB) is produced by the Java repository's -`tools/` pipeline (which ingests OSM extracts and writes the FlatGeobuf). That -pipeline is **out of scope** for this port. A small fixture used by the test -suite lives at `test/resources/borders-test.fgb`. +A small fixture used by the test suite lives at `test/resources/borders-test.mcb`. +`test/resources/README.md` explains how to regenerate it. ## Configuration @@ -56,7 +51,6 @@ Configuration is read from environment variables (via `src/config.ts`): | Variable | Default | Meaning | |---|---|---| -| `MAPCODE_BORDERS_PATH` | _(required)_ | Path to the `borders.fgb` file. | | `PORT` | `8080` | TCP port to listen on. | | `VERSION` | `package.json` version | Version string returned by `/mapcode/version`. | | `MAPCODE_DB_URL` | _(unset)_ | Optional Postgres connection string. When unset, request recording is disabled and `/mapcode/replay` is not registered. Contains credentials — never logged. **Do not put `sslmode` in the URL** (see TLS note below). | @@ -81,7 +75,6 @@ example: ```dotenv # .env -MAPCODE_BORDERS_PATH=./data/borders.fgb PORT=8080 ``` @@ -100,7 +93,8 @@ Run the built server: ```bash npm run build -MAPCODE_BORDERS_PATH=./data/borders.fgb node dist/index.js +node dist/index.js +# → borders: OpenStreetMap 2026-05 # → mapcode-rest-service-ts listening on :8080 (version ) curl localhost:8080/mapcode/version # {"version":""} @@ -185,7 +179,7 @@ bad methods) are not. A call with `allowLog=false|0|no` is recorded **anonymised**: `lat`, `lon` and `mapcode` are NULL, `anon` is true, and the `country` column holds the country of the requested (encode) or decoded (decode) point — the top-ranked -borders-file match, lifted to its parent for a subdivision (`USA-CA` → `USA`); +borders match, lifted to its parent for a subdivision (`US-CA` → `USA`); NULL at sea and for non-geo endpoints. Fully recorded rows leave `country` NULL, since it is derivable from the coordinates, and `anon` false. The `anon` flag is the only reliable marker: `country` alone cannot tell an anonymised @@ -308,13 +302,13 @@ historical meta-traffic rows, so it can exceed ``` src/ - index.ts entrypoint: load config → BoundaryService.load → buildServer → listen + index.ts entrypoint: load config → loadBorders → buildServer → listen config.ts env-based configuration errors.ts ApiError hierarchy (HTTP status + message) server.ts Fastify app: error/404/405 handlers, route registration routes/ HTTP layer (content negotiation, /xml//json/ aliases) resources/ framework-agnostic handler logic (ports of *ResourceImpl) - domain/ MapcodeService (wraps mapcode-ts), BoundaryService, geo helpers + domain/ MapcodeService (wraps mapcode-ts), borders loader, geo helpers dto/ DTO build* factories + serialization Schemas serialization/ JSON/XML serializers (byte-parity with Jackson/JAXB), formatDouble storage/ Postgres: pool, schema, request recorder, replay and stats queries @@ -327,6 +321,18 @@ Apache-2.0. ## Release Notes +### 2.12.0 + +* Border lookups now come from `mapcode-ts-borders` 1.0.0, which bundles the + OpenStreetMap data of May 2026. The responses stay the same for production + data. +* The service always uses the data bundled with `mapcode-ts-borders`. + `MAPCODE_BORDERS_PATH` is no longer used and is ignored when set, so it can + be removed from deployments. +* The FlatGeobuf files are removed, together with the `flatgeobuf` and + `flatbush` dependencies. +* The startup log names the border data source. + ### 2.11.4 * Updated `mapcode-ts` to 1.0.3. It contains the same code as 1.0.2. diff --git a/data/borders-100m.fgb b/data/borders-100m.fgb deleted file mode 100644 index 758c514..0000000 Binary files a/data/borders-100m.fgb and /dev/null differ diff --git a/data/borders-10m.fgb b/data/borders-10m.fgb deleted file mode 100644 index 8fe56db..0000000 Binary files a/data/borders-10m.fgb and /dev/null differ diff --git a/data/borders-50m.fgb b/data/borders-50m.fgb deleted file mode 100644 index 78eec3c..0000000 Binary files a/data/borders-50m.fgb and /dev/null differ diff --git a/data/borders.fgb b/data/borders.fgb deleted file mode 100644 index 758c514..0000000 Binary files a/data/borders.fgb and /dev/null differ diff --git a/package-lock.json b/package-lock.json index 9c0871b..83f90a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,18 @@ { "name": "mapcode-rest-service-ts", - "version": "2.11.4", + "version": "2.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mapcode-rest-service-ts", - "version": "2.11.4", + "version": "2.12.0", "license": "Apache-2.0", "dependencies": { "@fastify/compress": "^9.2.2", "fastify": "^5.12.7", - "flatbush": "^4.6.2", - "flatgeobuf": "^4.6.1", "mapcode-ts": "1.0.3", + "mapcode-ts-borders": "1.0.0", "pg": "^8.23.1" }, "devDependencies": { @@ -26,12 +25,6 @@ "node": ">=20" } }, - "node_modules/@bokuweb/zstd-wasm": { - "version": "0.0.27", - "resolved": "https://registry.npmjs.org/@bokuweb/zstd-wasm/-/zstd-wasm-0.0.27.tgz", - "integrity": "sha512-GDm2uOTK3ESjnYmSeLQifJnBsRCWajKLvN32D2ZcQaaCIJI/Hse9s74f7APXjHit95S10UImsRGkTsbwHmrtmg==", - "license": "MIT" - }, "node_modules/@fastify/accept-negotiator": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.1.0.tgz", @@ -219,25 +212,12 @@ "url": "https://github.com/sponsors/oxc-project" } }, - "node_modules/@petamoriken/float16": { - "version": "3.9.3", - "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz", - "integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==", - "license": "MIT", - "optional": true - }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, - "node_modules/@repeaterjs/repeater": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@repeaterjs/repeater/-/repeater-3.1.0.tgz", - "integrity": "sha512-TaoVksZRSx2KWYYpyLQtMQXXeS98VsgZImzW65xmiVgbYhXLk+aEsmzPLirqVuE4/XuUapH2iMtxUzaBNDzdSQ==", - "license": "MIT" - }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.15", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz", @@ -581,13 +561,6 @@ "pg-types": "^2.2.0" } }, - "node_modules/@types/rbush": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz", - "integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==", - "license": "MIT", - "optional": true - }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", @@ -966,17 +939,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@zarrita/storage": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.2.0.tgz", - "integrity": "sha512-855ZXqtnds7spnT8vNvD+MXa3QExP1m2GqShe8yt7uZXHnQLgJHgkpVwFjE1B0KDDRO0ki09hmk6OboTaIfPsQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "reference-spec-reader": "^0.2.0", - "unzipit": "2.0.0" - } - }, "node_modules/abstract-logging": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", @@ -1098,13 +1060,6 @@ "node": ">=8" } }, - "node_modules/earcut": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", - "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", - "license": "ISC", - "optional": true - }, "node_modules/es-module-lexer": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", @@ -1309,13 +1264,6 @@ } } }, - "node_modules/fflate": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", - "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", - "license": "MIT", - "optional": true - }, "node_modules/find-my-way": { "version": "9.6.0", "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", @@ -1330,42 +1278,6 @@ "node": ">=20" } }, - "node_modules/flatbuffers": { - "version": "25.9.23", - "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", - "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", - "license": "Apache-2.0" - }, - "node_modules/flatbush": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/flatbush/-/flatbush-4.6.2.tgz", - "integrity": "sha512-nNT7MFJ58Q4IAm3aYsEg+zgZGpdRcmR1i4U+aa8c+r91jmYZg7FTQwNnIMC0FyBqVZTbClKdAnrJkKkfp1BOvw==", - "license": "ISC", - "dependencies": { - "flatqueue": "^3.1.0" - } - }, - "node_modules/flatgeobuf": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/flatgeobuf/-/flatgeobuf-4.6.1.tgz", - "integrity": "sha512-oj1QaFZ91dxI+HdX/t4iu7sdMOz1y2wKDLdNvz+5n70WQ1qpMC19P4mjvS/j4tcllYlYioKCGPIKF3uR9RGuOg==", - "license": "BSD-3-Clause", - "dependencies": { - "@bokuweb/zstd-wasm": "0.0.27", - "@repeaterjs/repeater": "3.1.0", - "flatbuffers": "25.9.23", - "slice-source": "0.4.1" - }, - "optionalDependencies": { - "ol": ">=10" - } - }, - "node_modules/flatqueue": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/flatqueue/-/flatqueue-3.1.0.tgz", - "integrity": "sha512-Ia4qIYrrsEqIRx3c3XhkT+QDLQuUV5ovsr6ah1rIgKT5wclhoGK3lAMS1bWRAWxlx7wtlTBpV7QXB5d9fOSRxA==", - "license": "ISC" - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1382,26 +1294,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/geotiff": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/geotiff/-/geotiff-3.0.5.tgz", - "integrity": "sha512-OWcL9S9+yDZ6iAlXMt32T1iwUApJM8UiD47xbm6ZP1h33d10fqkPs14EG/ttT5EnefpZSx3G15iDFC5FxUNUwA==", - "license": "MIT", - "optional": true, - "dependencies": { - "@petamoriken/float16": "^3.9.3", - "lerc": "^3.0.0", - "pako": "^2.0.4", - "parse-headers": "^2.0.2", - "quick-lru": "^6.1.1", - "web-worker": "^1.5.0", - "xml-utils": "^1.10.2", - "zstddec": "^0.2.0" - }, - "engines": { - "node": ">=10.19" - } - }, "node_modules/ipaddr.js": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", @@ -1436,13 +1328,6 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, - "node_modules/lerc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz", - "integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==", - "license": "Apache-2.0", - "optional": true - }, "node_modules/light-my-request": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", @@ -1784,6 +1669,18 @@ "node": ">=20" } }, + "node_modules/mapcode-ts-borders": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/mapcode-ts-borders/-/mapcode-ts-borders-1.0.0.tgz", + "integrity": "sha512-AlWHwTuR38KqBhq3hj/i4Dg0C7cQ/C2oCTh8OwvEzd14eQAEcJuywD8VI1xiJxxUiyY5bneGaXkNDCIn4dfH2Q==", + "license": "Apache-2.0 AND ODbL-1.0", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "mapcode-ts": "^1.0.3" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -1813,16 +1710,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/numcodecs": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/numcodecs/-/numcodecs-0.3.2.tgz", - "integrity": "sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==", - "license": "MIT", - "optional": true, - "dependencies": { - "fflate": "^0.8.0" - } - }, "node_modules/obug": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", @@ -1837,25 +1724,6 @@ "node": ">=12.20.0" } }, - "node_modules/ol": { - "version": "10.9.0", - "resolved": "https://registry.npmjs.org/ol/-/ol-10.9.0.tgz", - "integrity": "sha512-svbbgVQUmEHaKpLQ8kRySojs59Brvgl2zYIrqG9eQNXGfsbi55rQasZIDpwpQzDL6OlzrUb0H4hQaiX9wDoGmA==", - "license": "BSD-2-Clause", - "optional": true, - "dependencies": { - "@types/rbush": "4.0.0", - "earcut": "^3.0.0", - "geotiff": "^3.0.5 || ^3.1.0-beta.0", - "pbf": "4.0.1", - "rbush": "^4.0.0", - "zarrita": "^0.7.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/openlayers" - } - }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -1865,33 +1733,6 @@ "node": ">=14.0.0" } }, - "node_modules/pako": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", - "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", - "license": "(MIT AND Zlib)", - "optional": true - }, - "node_modules/parse-headers": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz", - "integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==", - "license": "MIT", - "optional": true - }, - "node_modules/pbf": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", - "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", - "license": "BSD-3-Clause", - "optional": true, - "dependencies": { - "resolve-protobuf-schema": "^2.1.0" - }, - "bin": { - "pbf": "bin/pbf" - } - }, "node_modules/pg": { "version": "8.23.1", "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", @@ -2124,49 +1965,12 @@ ], "license": "MIT" }, - "node_modules/protocol-buffers-schema": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", - "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", - "license": "MIT", - "optional": true - }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, - "node_modules/quick-lru": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz", - "integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/quickselect": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", - "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", - "license": "ISC", - "optional": true - }, - "node_modules/rbush": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz", - "integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "quickselect": "^3.0.0" - } - }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", @@ -2176,13 +1980,6 @@ "node": ">= 12.13.0" } }, - "node_modules/reference-spec-reader": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz", - "integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==", - "license": "MIT", - "optional": true - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -2192,16 +1989,6 @@ "node": ">=0.10.0" } }, - "node_modules/resolve-protobuf-schema": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", - "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "protocol-buffers-schema": "^3.3.1" - } - }, "node_modules/ret": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", @@ -2327,12 +2114,6 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, - "node_modules/slice-source": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/slice-source/-/slice-source-0.4.1.tgz", - "integrity": "sha512-YiuPbxpCj4hD9Qs06hGAz/OZhQ0eDuALN0lRWJez0eD/RevzKqGdUx1IOMUnXgpr+sXZLq3g8ERwbAH0bCb8vg==", - "license": "BSD-3-Clause" - }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", @@ -2475,16 +2256,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unzipit": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unzipit/-/unzipit-2.0.0.tgz", - "integrity": "sha512-DVeVIWUZCAQPNzm5sB0hpsG1GygTTdBnzNtYYEpInkttx5evkyqRgZi6rTczoySqp8hO5jHVKzrH0f23X8FZLg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=18" - } - }, "node_modules/vite": { "version": "8.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", @@ -2647,13 +2418,6 @@ } } }, - "node_modules/web-worker": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", - "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", - "license": "Apache-2.0", - "optional": true - }, "node_modules/why-is-node-running": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", @@ -2667,13 +2431,6 @@ "node": ">=20.11" } }, - "node_modules/xml-utils": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz", - "integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==", - "license": "CC0-1.0", - "optional": true - }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -2682,24 +2439,6 @@ "engines": { "node": ">=0.4" } - }, - "node_modules/zarrita": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.7.3.tgz", - "integrity": "sha512-wChTQ1Ox75INoQCzKAfLWAfB70JJ4KjdW8Sz5x4ZWrFB4Dw+YZdnxHTL0xSdsrB9EmKSeK7fS1Y+I2ibhfGbkw==", - "license": "MIT", - "optional": true, - "dependencies": { - "@zarrita/storage": "^0.2.0", - "numcodecs": "^0.3.2" - } - }, - "node_modules/zstddec": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0.tgz", - "integrity": "sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==", - "license": "MIT AND BSD-3-Clause", - "optional": true } } } diff --git a/package.json b/package.json index ac0fce0..13c82de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mapcode-rest-service-ts", - "version": "2.11.4", + "version": "2.12.0", "description": "TypeScript port of the Mapcode REST API service.", "private": true, "type": "module", @@ -20,9 +20,8 @@ "dependencies": { "@fastify/compress": "^9.2.2", "fastify": "^5.12.7", - "flatbush": "^4.6.2", - "flatgeobuf": "^4.6.1", "mapcode-ts": "1.0.3", + "mapcode-ts-borders": "1.0.0", "pg": "^8.23.1" }, "devDependencies": { diff --git a/src/config.ts b/src/config.ts index 6f9dfa2..6560154 100644 --- a/src/config.ts +++ b/src/config.ts @@ -17,7 +17,6 @@ import { fileURLToPath } from "node:url"; export interface Config { port: number; - bordersPath: string; version: string; /** Postgres connection string; null → recording disabled, no replay route. */ dbUrl: string | null; @@ -104,10 +103,6 @@ export function loadEnvFileIfExists(path = ".env", env: NodeJS.ProcessEnv = proc } export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { - const bordersPath = (env.MAPCODE_BORDERS_PATH ?? "").trim(); - if (!bordersPath) { - throw new Error("MAPCODE_BORDERS_PATH is required (path to the borders.fgb file)."); - } const port = parsePort(env.PORT?.trim()); const version = (env.VERSION ?? "").trim() || packageVersion(); const dbUrl = (env.MAPCODE_DB_URL ?? "").trim() || null; @@ -117,5 +112,5 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { if (dbUrl !== null && replayToken === null) { throw new Error("MAPCODE_REPLAY_TOKEN is required when MAPCODE_DB_URL is set."); } - return { port, bordersPath, version, dbUrl, replayToken, dbCaCert }; + return { port, version, dbUrl, replayToken, dbCaCert }; } diff --git a/src/domain/boundary-service.ts b/src/domain/boundary-service.ts deleted file mode 100644 index b08b888..0000000 --- a/src/domain/boundary-service.ts +++ /dev/null @@ -1,426 +0,0 @@ -// Copyright (C) 2026, Stichting Mapcode Foundation (http://www.mapcode.com) -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { readFile } from "node:fs/promises"; -import Flatbush from "flatbush"; -import { geojson } from "flatgeobuf"; - -/** - * A single point-in-polygon match for a territory (admin boundary). - */ -export interface TerritoryMatch { - alphaCode: string; - parentAlphaCode: string | null; - adminLevel: number; - area: number; -} - -/** - * Even-odd ray-casting test for a single ring. The ring is stored in {@code coords} - * as lon,lat interleaved pairs, from vertex index {@code startVertex} (inclusive) to - * {@code endVertex} (exclusive). A closed ring (first vertex repeated as last) is fine; - * the closing edge is handled by the wrap-around. - * - * @param coords Flat lon,lat interleaved vertex array. - * @param startVertex First vertex index of the ring (vertex, not float, index). - * @param endVertex One past the last vertex index of the ring. - * @param lon Test point longitude. - * @param lat Test point latitude. - * @returns True if the point is inside the ring per the even-odd rule. - * Behavior for a point lying exactly on a polygon boundary is - * unspecified. - */ -export function pointInRing( - coords: Float64Array, - startVertex: number, - endVertex: number, - lon: number, - lat: number, -): boolean { - let inside = false; - const n = endVertex - startVertex; - if (n < 3) { - return false; - } - // Iterate edges (j, i) with j = previous vertex of i, wrapping around. - for (let i = 0, j = n - 1; i < n; j = i++) { - const xiIdx = (startVertex + i) * 2; - const xjIdx = (startVertex + j) * 2; - const xi = coords[xiIdx]; - const yi = coords[xiIdx + 1]; - const xj = coords[xjIdx]; - const yj = coords[xjIdx + 1]; - - const intersect = - yi > lat !== yj > lat && - lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi; - if (intersect) { - inside = !inside; - } - } - return inside; -} - -/** - * In-memory, memory-optimized OSM admin-boundary point-in-polygon service. - * - * Loads a FlatGeobuf borders file once at startup into flat typed arrays plus a - * flatbush spatial index, then answers ranked point-in-polygon queries. The - * GeoJSON objects produced by the reader are discarded after transcoding. - * - * CSR layout (supports MultiPolygon + holes): - * - coords: all ring vertices, lon,lat interleaved. - * - ringOffsets: start vertex index of each ring (with an end sentinel). - * - polyRingStart: first ring index of each polygon (with an end sentinel). - * - featurePolyStart: first polygon index of each feature (with an end sentinel). - */ -export class BoundaryService { - private readonly index: Flatbush; - - private readonly coords: Float64Array; - private readonly ringOffsets: Uint32Array; - private readonly polyRingStart: Uint32Array; - private readonly featurePolyStart: Uint32Array; - - private readonly alphaCode: string[]; - private readonly parentAlphaCode: (string | null)[]; - private readonly adminLevel: Uint8Array; - private readonly area: Float64Array; - private readonly minX: Float64Array; - private readonly minY: Float64Array; - private readonly maxX: Float64Array; - private readonly maxY: Float64Array; - - private constructor(args: { - index: Flatbush; - coords: Float64Array; - ringOffsets: Uint32Array; - polyRingStart: Uint32Array; - featurePolyStart: Uint32Array; - alphaCode: string[]; - parentAlphaCode: (string | null)[]; - adminLevel: Uint8Array; - area: Float64Array; - minX: Float64Array; - minY: Float64Array; - maxX: Float64Array; - maxY: Float64Array; - }) { - this.index = args.index; - this.coords = args.coords; - this.ringOffsets = args.ringOffsets; - this.polyRingStart = args.polyRingStart; - this.featurePolyStart = args.featurePolyStart; - this.alphaCode = args.alphaCode; - this.parentAlphaCode = args.parentAlphaCode; - this.adminLevel = args.adminLevel; - this.area = args.area; - this.minX = args.minX; - this.minY = args.minY; - this.maxX = args.maxX; - this.maxY = args.maxY; - } - - /** - * Loads a FlatGeobuf borders file into memory. Features lacking geometry or any - * required property (alphaCode, adminLevel, area) are skipped. An empty - * parentAlphaCode is normalised to null. - * - * @param bordersPath Path to the .fgb file. - * @returns A ready-to-query BoundaryService. - * @throws If the file cannot be read or parsed. - */ - static async load(bordersPath: string): Promise { - let bytes: Buffer; - try { - bytes = await readFile(bordersPath); - } catch (e) { - throw new Error(`Borders file not readable: ${bordersPath}`, { - cause: e, - }); - } - const u8 = new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength); - - // The reader is lazy, so parse errors surface while iterating. Its feature type - // relies on @types/geojson, which we do not install, hence the cast. - const features: GeoJsonFeature[] = []; - try { - for await (const feature of geojson.deserialize(u8)) { - features.push(feature as unknown as GeoJsonFeature); - } - } catch (e) { - throw new Error(`Failed to parse borders file: ${bordersPath}`, { - cause: e, - }); - } - - // Growable plain arrays during build; converted to typed arrays at the end. - const coordsList: number[] = []; - const ringOffsetsList: number[] = [0]; - const polyRingStartList: number[] = [0]; - const featurePolyStartList: number[] = [0]; - - const alphaCode: string[] = []; - const parentAlphaCode: (string | null)[] = []; - const adminLevelList: number[] = []; - const areaList: number[] = []; - const minXList: number[] = []; - const minYList: number[] = []; - const maxXList: number[] = []; - const maxYList: number[] = []; - - let ringCount = 0; - let polyCount = 0; - let vertexCount = 0; - - for (const feature of features) { - // Snapshot the mutable CSR build-state BEFORE touching any of it, so a feature - // that turns out to be skipped or degenerate can be rolled back to leave the - // arrays exactly as they were at the start of this iteration. - const snapCoordsLen = coordsList.length; - const snapRingOffsetsLen = ringOffsetsList.length; - const snapPolyRingStartLen = polyRingStartList.length; - const snapRingCount = ringCount; - const snapPolyCount = polyCount; - const snapVertexCount = vertexCount; - - const rollback = (): void => { - coordsList.length = snapCoordsLen; - ringOffsetsList.length = snapRingOffsetsLen; - polyRingStartList.length = snapPolyRingStartLen; - ringCount = snapRingCount; - polyCount = snapPolyCount; - vertexCount = snapVertexCount; - }; - - const geom = feature?.geometry; - const props = feature?.properties ?? {}; - if (!geom) { - continue; - } - - const aCode = props.alphaCode; - if (typeof aCode !== "string" || aCode.length === 0) { - continue; - } - let pCode: string | null = - typeof props.parentAlphaCode === "string" - ? props.parentAlphaCode - : null; - if (pCode !== null && pCode.length === 0) { - pCode = null; - } - const level = toInt(props.adminLevel); - if (level === null) { - continue; - } - const areaVal = toNumber(props.area); - if (areaVal === null) { - continue; - } - - // Collect this feature's polygons (each as an array of rings of [lon,lat]). - // A polygon is an array of rings; a ring is an array of [lon,lat] pairs. - const polygons: Ring[][] = []; - if (geom.type === "Polygon") { - polygons.push(geom.coordinates as Ring[]); - } else if (geom.type === "MultiPolygon") { - for (const poly of geom.coordinates as Ring[][]) { - polygons.push(poly); - } - } else { - continue; - } - if (polygons.length === 0) { - continue; - } - - let featMinX = Infinity; - let featMinY = Infinity; - let featMaxX = -Infinity; - let featMaxY = -Infinity; - let appendedAnyVertex = false; - - for (const poly of polygons) { - if (poly.length === 0) { - continue; - } - for (const ring of poly) { - for (const pair of ring) { - const lon = pair[0]; - const lat = pair[1]; - coordsList.push(lon, lat); - vertexCount++; - appendedAnyVertex = true; - if (lon < featMinX) featMinX = lon; - if (lon > featMaxX) featMaxX = lon; - if (lat < featMinY) featMinY = lat; - if (lat > featMaxY) featMaxY = lat; - } - ringCount++; - ringOffsetsList.push(vertexCount); - } - polyCount++; - polyRingStartList.push(ringCount); - } - - if (!appendedAnyVertex) { - // Degenerate feature (empty rings / no vertices): roll back the partial CSR - // mutations made above so the arrays are exactly as before this iteration. - rollback(); - continue; - } - - featurePolyStartList.push(polyCount); - alphaCode.push(aCode); - parentAlphaCode.push(pCode); - adminLevelList.push(level); - areaList.push(areaVal); - minXList.push(featMinX); - minYList.push(featMinY); - maxXList.push(featMaxX); - maxYList.push(featMaxY); - } - - const featureCount = alphaCode.length; - if (featureCount === 0) { - throw new Error( - `Borders file contained no usable features: ${bordersPath}`, - ); - } - - const index = new Flatbush(featureCount); - for (let i = 0; i < featureCount; i++) { - index.add(minXList[i], minYList[i], maxXList[i], maxYList[i]); - } - index.finish(); - - return new BoundaryService({ - index, - coords: Float64Array.from(coordsList), - ringOffsets: Uint32Array.from(ringOffsetsList), - polyRingStart: Uint32Array.from(polyRingStartList), - featurePolyStart: Uint32Array.from(featurePolyStartList), - alphaCode, - parentAlphaCode, - adminLevel: Uint8Array.from(adminLevelList), - area: Float64Array.from(areaList), - minX: Float64Array.from(minXList), - minY: Float64Array.from(minYList), - maxX: Float64Array.from(maxXList), - maxY: Float64Array.from(maxYList), - }); - } - - /** - * Returns the territories whose admin boundary contains the given point, ranked - * by adminLevel descending then area ascending. - * - * @param latDeg Latitude in degrees. - * @param lonDeg Longitude in degrees. - * @returns Ranked matches (possibly empty). - */ - lookup(latDeg: number, lonDeg: number): TerritoryMatch[] { - const candidates = this.index.search(lonDeg, latDeg, lonDeg, latDeg); - const hits: TerritoryMatch[] = []; - for (const fi of candidates) { - if (this.featureContains(fi, lonDeg, latDeg)) { - hits.push({ - alphaCode: this.alphaCode[fi], - parentAlphaCode: this.parentAlphaCode[fi], - adminLevel: this.adminLevel[fi], - area: this.area[fi], - }); - } - } - hits.sort((a, b) => { - if (a.adminLevel !== b.adminLevel) { - return b.adminLevel - a.adminLevel; // higher level first - } - return a.area - b.area; // smaller area first - }); - return hits; - } - - /** True if the point is inside ANY of the feature's polygons. */ - private featureContains( - featureIndex: number, - lon: number, - lat: number, - ): boolean { - const polyStart = this.featurePolyStart[featureIndex]; - const polyEnd = this.featurePolyStart[featureIndex + 1]; - for (let p = polyStart; p < polyEnd; p++) { - if (this.polygonContains(p, lon, lat)) { - return true; - } - } - return false; - } - - /** - * True if the point is inside the polygon's outer ring AND not inside any of its - * holes. The first ring of a polygon is the outer ring; the rest are holes. - */ - private polygonContains(polyIndex: number, lon: number, lat: number): boolean { - const ringStart = this.polyRingStart[polyIndex]; - const ringEnd = this.polyRingStart[polyIndex + 1]; - if (ringStart >= ringEnd) { - return false; - } - // Outer ring. - const outerStartVertex = this.ringOffsets[ringStart]; - const outerEndVertex = this.ringOffsets[ringStart + 1]; - if (!pointInRing(this.coords, outerStartVertex, outerEndVertex, lon, lat)) { - return false; - } - // Holes. - for (let r = ringStart + 1; r < ringEnd; r++) { - const holeStartVertex = this.ringOffsets[r]; - const holeEndVertex = this.ringOffsets[r + 1]; - if (pointInRing(this.coords, holeStartVertex, holeEndVertex, lon, lat)) { - return false; - } - } - return true; - } -} - -function toInt(value: unknown): number | null { - if (typeof value === "number" && Number.isFinite(value)) { - return Math.trunc(value); - } - return null; -} - -function toNumber(value: unknown): number | null { - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } - return null; -} - -// Minimal structural typing for the GeoJSON shape the reader yields. We do not -// retain these objects; they are transcoded into typed arrays and discarded. -interface GeoJsonGeometry { - type: string; - coordinates: unknown; -} -interface GeoJsonFeature { - geometry: GeoJsonGeometry | null; - properties: Record | null; -} - -// A linear ring: an ordered list of [lon, lat] coordinate pairs. -type Ring = number[][]; diff --git a/src/index.ts b/src/index.ts index 9f7344f..fac3acf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,8 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { readFile } from "node:fs/promises"; +import { Borders, bordersUrl } from "mapcode-ts-borders"; import { loadConfig, loadEnvFileIfExists } from "./config.ts"; -import { BoundaryService } from "./domain/boundary-service.ts"; import { createMapcodeService } from "./domain/mapcode-service.ts"; import { createPool, MAINTENANCE_POOL_TUNING } from "./storage/pool.ts"; import { createRequestRecorder } from "./storage/request-recorder.ts"; @@ -26,7 +27,9 @@ import { buildServer } from "./server.ts"; async function main(): Promise { loadEnvFileIfExists(); const config = loadConfig(); - const boundaryService = await BoundaryService.load(config.bordersPath); + const borders = Borders.fromBytes(await readFile(bordersUrl)); + const bordersSource = borders.getSource(); + console.log(`borders: ${bordersSource.name} ${bordersSource.snapshotDate}`); const mapcodeService = createMapcodeService(); // config.dbUrl contains a password — it must never be logged. @@ -42,7 +45,7 @@ async function main(): Promise { }); const app = buildServer({ mapcodeService, - boundaryService, + borders, version: config.version, logger: { level: "info" }, // The no-op recorder's close() is harmless, so it's still created diff --git a/src/resources/codes.ts b/src/resources/codes.ts index bcde9de..09f45b7 100644 --- a/src/resources/codes.ts +++ b/src/resources/codes.ts @@ -15,7 +15,7 @@ import type { MapcodeService } from "../domain/mapcode-service.ts"; import { Territory, Alphabet, Mapcode, Rectangle, UnknownMapcodeError } from "../domain/mapcode-service.ts"; import { decode as mapcodeDecode } from "mapcode-ts"; -import type { BoundaryService } from "../domain/boundary-service.ts"; +import { rankByBorders, type BorderMatch, type Borders } from "mapcode-ts-borders"; import { mapToLon, distanceInMeters } from "../domain/geo.ts"; import type { Schema } from "../serialization/types.ts"; import { @@ -83,7 +83,7 @@ interface MapcodeAndRectangle { // handleCodes — port of MapcodeResourceImpl.convertLatLonToMapcode (main overload). // --------------------------------------------------------------------------- -export function handleCodes(params: CodesParams, mapcodeService: MapcodeService, boundaryService: BoundaryService): CodesResult { +export function handleCodes(params: CodesParams, mapcodeService: MapcodeService, borders: Borders): CodesResult { const { latStr, lonStr, type: paramType, context, territory: paramTerritory, country: paramCountry, alphabet: paramAlphabet, include } = params; const paramPrecision = params.precision; @@ -233,41 +233,22 @@ export function handleCodes(params: CodesParams, mapcodeService: MapcodeService, if (type === null) { // Boundary-derived territory candidates (ranked). - const territoryMatches = boundaryService.lookup(latDeg, lonDeg); + const territoryMatches = borders.getTerritories(latDeg, lonDeg); const territoryCandidates = territoryMatches.length === 0 ? null - : territoryMatches.map((m) => - buildTerritoryCandidate({ alphaCode: m.alphaCode, parentAlphaCode: m.parentAlphaCode ?? undefined })); - - let effectiveLocalAndRectangle = mapcodeLocalAndRectangle; - if (territoryCandidates !== null) { - // Build a rank map (first occurrence wins). - const territoryRank = new Map(); - for (let i = 0; i < territoryMatches.length; i++) { - if (!territoryRank.has(territoryMatches[i].alphaCode)) { - territoryRank.set(territoryMatches[i].alphaCode, i); - } - } - // Stable sort by rank (codes whose territory is not present go last). - const MAX = Number.MAX_SAFE_INTEGER; - stableSort(mapcodesAndRectangles, (a, b) => { - const ra = territoryRank.get(a.mapcode.getTerritory().toString()) ?? MAX; - const rb = territoryRank.get(b.mapcode.getTerritory().toString()) ?? MAX; - return ra - rb; - }); - const topTerritoryAlpha = territoryMatches[0].alphaCode; - for (const mr of mapcodesAndRectangles) { - if (topTerritoryAlpha === mr.mapcode.getTerritory().toString()) { - effectiveLocalAndRectangle = mr; - break; - } - } - } + : territoryMatches.map(buildCandidate); + + const pairOf = new Map(mapcodesAndRectangles.map((mr) => [mr.mapcode, mr])); + const ranked = rankByBorders(mapcodesAndRectangles.map((mr) => mr.mapcode), territoryMatches); + const orderedMapcodes = ranked.mapcodes.map((m) => pairOf.get(m) as MapcodeAndRectangle); + const effectiveLocalAndRectangle = ranked.best === null + ? mapcodeLocalAndRectangle + : (pairOf.get(ranked.best) as MapcodeAndRectangle); const dto = buildMapcodes({ local: effectiveLocalAndRectangle === null ? undefined : dtoOf(effectiveLocalAndRectangle), international: dtoOf(mapcodeInternationalAndRectangle), - mapcodes: mapcodesAndRectangles.map(dtoOf), + mapcodes: orderedMapcodes.map(dtoOf), territories: territoryCandidates ?? undefined, }); return { kind: "object", dto, schema: mapcodesSchema }; @@ -362,7 +343,7 @@ function decodeForOffset(mapcode: Mapcode, precision: number): { latDeg: number; export function handleTerritoriesForLatLon( latStr: string, lonStr: string, - boundaryService: BoundaryService + borders: Borders ): { dto: Record; schema: Schema } { const latDeg = Number(emptyIfNull(latStr)); if (latStr === "" || !Number.isFinite(latDeg) || !isBetween(latDeg, API_LAT_MIN, API_LAT_MAX)) { @@ -374,11 +355,7 @@ export function handleTerritoriesForLatLon( } const lonDeg = mapToLon(lonRaw); - const matches = boundaryService.lookup(latDeg, lonDeg); - const dto = buildTerritoryCandidates({ - territories: matches.map((m) => - buildTerritoryCandidate({ alphaCode: m.alphaCode, parentAlphaCode: m.parentAlphaCode ?? undefined })), - }); + const dto = buildTerritoryCandidates({ territories: borders.getTerritories(latDeg, lonDeg).map(buildCandidate) }); return { dto, schema: territoryCandidatesSchema }; } @@ -386,6 +363,13 @@ export function handleTerritoriesForLatLon( // Helpers. // --------------------------------------------------------------------------- +function buildCandidate(m: BorderMatch) { + return buildTerritoryCandidate({ + alphaCode: m.territory.toString(), + parentAlphaCode: m.territory.getParentTerritory()?.toString(), + }); +} + function emptyIfNull(s: string | undefined | null): string { return s ?? ""; } @@ -393,13 +377,3 @@ function emptyIfNull(s: string | undefined | null): string { function isBetween(v: number, min: number, max: number): boolean { return v >= min && v <= max; } - -/** Stable sort in place (Array.prototype.sort is stable in V8, but be explicit). */ -function stableSort(arr: T[], cmp: (a: T, b: T) => number): void { - const indexed = arr.map((v, i) => [v, i] as [T, number]); - indexed.sort((a, b) => { - const c = cmp(a[0], b[0]); - return c !== 0 ? c : a[1] - b[1]; - }); - for (let i = 0; i < arr.length; i++) arr[i] = indexed[i][0]; -} diff --git a/src/routes/codes.routes.ts b/src/routes/codes.routes.ts index 46f6d97..490acaf 100644 --- a/src/routes/codes.routes.ts +++ b/src/routes/codes.routes.ts @@ -33,7 +33,7 @@ function splitLatLon(latlon: string): { latStr: string; lonStr: string } | null } export function registerCodesRoutes(app: FastifyInstance, deps: ServerDeps): void { - const { mapcodeService, boundaryService } = deps; + const { mapcodeService, borders } = deps; // ------------------------------------------------------------------------- // GET /mapcode/codes (no lat/lon) — forbidden (403). @@ -70,7 +70,7 @@ export function registerCodesRoutes(app: FastifyInstance, deps: ServerDeps): voi include: getQueryParam(query, "include") ?? "", }, mapcodeService, - boundaryService + borders ); // Coordinate source: bit-identical to the encoder's own normalization. request.recording = { kind: KIND.codes, latDeg: Number(ll.latStr), lonDeg: mapToLon(Number(ll.lonStr)) }; @@ -94,7 +94,7 @@ export function registerCodesRoutes(app: FastifyInstance, deps: ServerDeps): voi if (ll === null) { throw new ApiNotFoundError(`Route not found: ${request.method} ${request.url}`); } - const { dto, schema } = handleTerritoriesForLatLon(ll.latStr, ll.lonStr, boundaryService); + const { dto, schema } = handleTerritoriesForLatLon(ll.latStr, ll.lonStr, borders); request.recording = { kind: KIND.codesTerritories, latDeg: Number(ll.latStr), lonDeg: mapToLon(Number(ll.lonStr)) }; return respond(reply, format, dto, schema); }; @@ -134,7 +134,7 @@ export function registerCodesRoutes(app: FastifyInstance, deps: ServerDeps): voi include: getQueryParam(query, "include") ?? "", }, mapcodeService, - boundaryService + borders ); request.recording = { kind: type === "local" ? KIND.codesLocal : type === "international" ? KIND.codesInternational : KIND.codesMapcodes, diff --git a/src/routes/recording.ts b/src/routes/recording.ts index 9a1754c..426f40c 100644 --- a/src/routes/recording.ts +++ b/src/routes/recording.ts @@ -19,7 +19,7 @@ // --------------------------------------------------------------------------- import type { FastifyReply, FastifyRequest } from "fastify"; -import type { BoundaryService, TerritoryMatch } from "../domain/boundary-service.ts"; +import type { Borders } from "mapcode-ts-borders"; import type { RequestRecorder } from "../storage/request-recorder.ts"; /** Endpoint identity codes (smallint in the database, gapped for growth). */ @@ -100,15 +100,6 @@ export function allowLogDenies(allowLog: string | undefined): boolean { return value === "false" || value === "0" || value === "no"; } -/** - * The country of a boundary lookup: the top-ranked match, lifted to its parent - * when it is a subdivision (USA-CA → USA). null when nothing matched (open sea). - */ -export function countryFromMatches(matches: readonly TerritoryMatch[]): string | null { - if (matches.length === 0) return null; - return matches[0].parentAlphaCode ?? matches[0].alphaCode; -} - /** Latitude degrees → microdegrees, rounded and clamped to ±90e6. */ export function latToMicro(latDeg: number): number { return Math.min(90_000_000, Math.max(-90_000_000, Math.round(latDeg * 1e6))); @@ -187,7 +178,7 @@ export function rawQueryValue(query: unknown, name: string): string | undefined * Runs off the response path (onResponse fires after the bytes went out) and * must never throw — recording failures must never surface to callers. */ -export function createRecordingHook(recorder: RequestRecorder, boundaryService: BoundaryService) { +export function createRecordingHook(recorder: RequestRecorder, borders: Borders) { return function recordRequest(request: FastifyRequest, reply: FastifyReply, done: () => void): void { try { // Only successful calls to real API endpoints are usage: CORS preflights @@ -214,7 +205,7 @@ export function createRecordingHook(recorder: RequestRecorder, boundaryService: lat: null, lon: null, mapcode: null, - country: hasPoint ? countryFromMatches(boundaryService.lookup(stash.latDeg!, stash.lonDeg!)) : null, + country: hasPoint ? (borders.getCountry(stash.latDeg!, stash.lonDeg!)?.toString() ?? null) : null, anon: true, }); } else { diff --git a/src/server.ts b/src/server.ts index d78e906..5b61d39 100644 --- a/src/server.ts +++ b/src/server.ts @@ -21,7 +21,7 @@ import Fastify, { type FastifyServerOptions, } from "fastify"; import type { MapcodeService } from "./domain/mapcode-service.ts"; -import type { BoundaryService } from "./domain/boundary-service.ts"; +import type { Borders } from "mapcode-ts-borders"; import { ApiError } from "./errors.ts"; import { resolveFormat } from "./routes/negotiation.ts"; import { respondError } from "./serialization/respond.ts"; @@ -42,7 +42,7 @@ import type { StatsQueryFn, StorageQueryFn } from "./storage/stats-query.ts"; export interface ServerDeps { mapcodeService: MapcodeService; - boundaryService: BoundaryService; + borders: Borders; version: string; logger?: FastifyServerOptions["logger"]; /** Optional request recorder; absent → no recording hook is installed. */ @@ -66,7 +66,7 @@ export function buildServer(deps: ServerDeps): FastifyInstance { // Per-request stash the route handlers fill for the recording hook. app.decorateRequest("recording", null); if (deps.recorder !== undefined) { - app.addHook("onResponse", createRecordingHook(deps.recorder, deps.boundaryService)); + app.addHook("onResponse", createRecordingHook(deps.recorder, deps.borders)); } // ------------------------------------------------------------------------- diff --git a/test/api-alphabets.test.ts b/test/api-alphabets.test.ts index d48a22e..8a0dfe0 100644 --- a/test/api-alphabets.test.ts +++ b/test/api-alphabets.test.ts @@ -15,7 +15,7 @@ import { describe, it, expect, beforeAll } from "vitest"; import { buildServer } from "../src/server.ts"; import { createMapcodeService } from "../src/domain/mapcode-service.ts"; -import { BoundaryService } from "../src/domain/boundary-service.ts"; +import { loadTestBorders } from "./borders-fixture.ts"; import type { FastifyInstance } from "fastify"; // Ported verbatim from ApiAlphabetsTest.java. @@ -24,8 +24,8 @@ let app: FastifyInstance; beforeAll(async () => { const mapcodeService = createMapcodeService(); - const boundaryService = await BoundaryService.load("test/resources/borders-test.fgb"); - app = buildServer({ mapcodeService, boundaryService, version: "1.0" }); + const borders = await loadTestBorders(); + app = buildServer({ mapcodeService, borders, version: "1.0" }); await app.ready(); }); diff --git a/test/api-codes-territories.test.ts b/test/api-codes-territories.test.ts index b468d3d..214aadf 100644 --- a/test/api-codes-territories.test.ts +++ b/test/api-codes-territories.test.ts @@ -15,7 +15,7 @@ import { describe, it, expect, beforeAll } from "vitest"; import { buildServer } from "../src/server.ts"; import { createMapcodeService } from "../src/domain/mapcode-service.ts"; -import { BoundaryService } from "../src/domain/boundary-service.ts"; +import { loadTestBorders } from "./borders-fixture.ts"; import type { FastifyInstance } from "fastify"; // Ported verbatim from ApiCodesTerritoriesTest.java. @@ -24,8 +24,8 @@ let app: FastifyInstance; beforeAll(async () => { const mapcodeService = createMapcodeService(); - const boundaryService = await BoundaryService.load("test/resources/borders-test.fgb"); - app = buildServer({ mapcodeService, boundaryService, version: "1.0" }); + const borders = await loadTestBorders(); + app = buildServer({ mapcodeService, borders, version: "1.0" }); await app.ready(); }); @@ -42,7 +42,7 @@ describe("pointInsideCountryReturnsCountryJson", () => { }); describe("pointInsideSubdivisionReturnsSubdivisionThenCountryJson", () => { - it("(36,-120) inside USA-CA then USA", async () => { + it("(36,-120) inside US-CA then USA", async () => { const res = await app.inject({ method: "GET", url: "/mapcode/codes/36.0,-120.0/territories", @@ -51,7 +51,7 @@ describe("pointInsideSubdivisionReturnsSubdivisionThenCountryJson", () => { expect(res.statusCode).toBe(200); expect(res.body).toBe( '{"territories":[' + - '{"alphaCode":"USA-CA","parentAlphaCode":"USA"},' + + '{"alphaCode":"US-CA","parentAlphaCode":"USA"},' + '{"alphaCode":"USA"}]}' ); }); @@ -70,7 +70,7 @@ describe("pointAtSeaReturnsEmptyListJson", () => { }); describe("disputedRegionReturnsSmallerPolygonFirstJson", () => { - it("(6.5,106.5) DISPUTED-B before DISPUTED-A", async () => { + it("(6.5,106.5) HMD before BVT (nested polygons: smaller first)", async () => { const res = await app.inject({ method: "GET", url: "/mapcode/codes/6.5,106.5/territories", @@ -79,8 +79,8 @@ describe("disputedRegionReturnsSmallerPolygonFirstJson", () => { expect(res.statusCode).toBe(200); expect(res.body).toBe( '{"territories":[' + - '{"alphaCode":"DISPUTED-B"},' + - '{"alphaCode":"DISPUTED-A"}]}' + '{"alphaCode":"HMD"},' + + '{"alphaCode":"BVT"}]}' ); }); }); diff --git a/test/api-codes.test.ts b/test/api-codes.test.ts index 765b7ee..57d66a0 100644 --- a/test/api-codes.test.ts +++ b/test/api-codes.test.ts @@ -15,7 +15,7 @@ import { describe, it, expect, beforeAll } from "vitest"; import { buildServer } from "../src/server.ts"; import { createMapcodeService } from "../src/domain/mapcode-service.ts"; -import { BoundaryService } from "../src/domain/boundary-service.ts"; +import { loadTestBorders } from "./borders-fixture.ts"; import type { FastifyInstance } from "fastify"; // Ported verbatim from ApiCodesTest.java. @@ -36,8 +36,8 @@ let app: FastifyInstance; beforeAll(async () => { const mapcodeService = createMapcodeService(); - const boundaryService = await BoundaryService.load("test/resources/borders-test.fgb"); - app = buildServer({ mapcodeService, boundaryService, version: "1.0" }); + const borders = await loadTestBorders(); + app = buildServer({ mapcodeService, borders, version: "1.0" }); await app.ready(); }); diff --git a/test/api-coords.test.ts b/test/api-coords.test.ts index 02b494c..02ac1e2 100644 --- a/test/api-coords.test.ts +++ b/test/api-coords.test.ts @@ -15,7 +15,7 @@ import { describe, it, expect, beforeAll } from "vitest"; import { buildServer } from "../src/server.ts"; import { createMapcodeService } from "../src/domain/mapcode-service.ts"; -import { BoundaryService } from "../src/domain/boundary-service.ts"; +import { loadTestBorders } from "./borders-fixture.ts"; import type { FastifyInstance } from "fastify"; // Test constants (ported from ApiCoordsTest.java) @@ -27,8 +27,8 @@ let app: FastifyInstance; beforeAll(async () => { const mapcodeService = createMapcodeService(); - const boundaryService = await BoundaryService.load("test/resources/borders-test.fgb"); - app = buildServer({ mapcodeService, boundaryService, version: "1.0" }); + const borders = await loadTestBorders(); + app = buildServer({ mapcodeService, borders, version: "1.0" }); await app.ready(); }); diff --git a/test/api-negotiation.test.ts b/test/api-negotiation.test.ts index 9045207..25adda7 100644 --- a/test/api-negotiation.test.ts +++ b/test/api-negotiation.test.ts @@ -15,15 +15,15 @@ import { describe, it, expect, beforeAll } from "vitest"; import { buildServer } from "../src/server.ts"; import { createMapcodeService } from "../src/domain/mapcode-service.ts"; -import { BoundaryService } from "../src/domain/boundary-service.ts"; +import { loadTestBorders } from "./borders-fixture.ts"; import type { FastifyInstance } from "fastify"; let app: FastifyInstance; beforeAll(async () => { const mapcodeService = createMapcodeService(); - const boundaryService = await BoundaryService.load("test/resources/borders-test.fgb"); - app = buildServer({ mapcodeService, boundaryService, version: "1.0" }); + const borders = await loadTestBorders(); + app = buildServer({ mapcodeService, borders, version: "1.0" }); await app.ready(); }); diff --git a/test/api-others.test.ts b/test/api-others.test.ts index a7d1af6..06714a7 100644 --- a/test/api-others.test.ts +++ b/test/api-others.test.ts @@ -15,15 +15,15 @@ import { describe, it, expect, beforeAll } from "vitest"; import { buildServer } from "../src/server.ts"; import { createMapcodeService } from "../src/domain/mapcode-service.ts"; -import { BoundaryService } from "../src/domain/boundary-service.ts"; +import { loadTestBorders } from "./borders-fixture.ts"; import type { FastifyInstance } from "fastify"; let app: FastifyInstance; beforeAll(async () => { const mapcodeService = createMapcodeService(); - const boundaryService = await BoundaryService.load("test/resources/borders-test.fgb"); - app = buildServer({ mapcodeService, boundaryService, version: "1.0" }); + const borders = await loadTestBorders(); + app = buildServer({ mapcodeService, borders, version: "1.0" }); await app.ready(); }); diff --git a/test/api-recording.test.ts b/test/api-recording.test.ts index 55a0b92..8d202c1 100644 --- a/test/api-recording.test.ts +++ b/test/api-recording.test.ts @@ -16,7 +16,7 @@ import { describe, it, expect, beforeAll, beforeEach } from "vitest"; import type { FastifyInstance } from "fastify"; import { buildServer } from "../src/server.ts"; import { createMapcodeService } from "../src/domain/mapcode-service.ts"; -import { BoundaryService } from "../src/domain/boundary-service.ts"; +import { loadTestBorders } from "./borders-fixture.ts"; import type { RecordedRequest, RequestRecorder } from "../src/storage/request-recorder.ts"; import { KIND } from "../src/routes/recording.ts"; @@ -34,8 +34,8 @@ let mapcodeService: ReturnType; beforeAll(async () => { mapcodeService = createMapcodeService(); - const boundaryService = await BoundaryService.load("test/resources/borders-test.fgb"); - app = buildServer({ mapcodeService, boundaryService, version: "1.0", recorder: captureRecorder }); + const borders = await loadTestBorders(); + app = buildServer({ mapcodeService, borders, version: "1.0", recorder: captureRecorder }); await app.ready(); }); @@ -135,7 +135,7 @@ describe("replay endpoints are never recorded", () => { it("skips /mapcode/replay and everything below it, matched or not", async () => { const withReplay = buildServer({ mapcodeService, - boundaryService: await BoundaryService.load("test/resources/borders-test.fgb"), + borders: await loadTestBorders(), version: "1.0", recorder: captureRecorder, replay: { @@ -176,7 +176,7 @@ describe("replay endpoints are never recorded", () => { }); describe("allowLog", () => { - // The borders fixture has an NLD square around (52, 5) and a USA-CA square + // The borders fixture has an NLD square around (52, 5) and a US-CA square // (parent USA) around (36, -120); (0, 0) is open sea. it("records an anonymised row for false/0/no (case-insensitive): no coords, no mapcode, but the country", async () => { for (const v of ["false", "FALSE", "0", "no", "No"]) { @@ -242,7 +242,7 @@ describe("parity", () => { it("produces byte-identical responses with and without the recorder", async () => { const bare = buildServer({ mapcodeService, - boundaryService: await BoundaryService.load("test/resources/borders-test.fgb"), + borders: await loadTestBorders(), version: "1.0", }); await bare.ready(); diff --git a/test/api-replay.test.ts b/test/api-replay.test.ts index c684d46..cc978bc 100644 --- a/test/api-replay.test.ts +++ b/test/api-replay.test.ts @@ -17,7 +17,7 @@ import { gunzipSync } from "node:zlib"; import type { FastifyInstance } from "fastify"; import { buildServer } from "../src/server.ts"; import { createMapcodeService } from "../src/domain/mapcode-service.ts"; -import { BoundaryService } from "../src/domain/boundary-service.ts"; +import { loadTestBorders } from "./borders-fixture.ts"; import { StatsNotReadyError } from "../src/storage/stats-service.ts"; import type { ReplayColumns, ReplayQueryArgs } from "../src/storage/replay-query.ts"; import type { StatsKindCounts, StorageInfo } from "../src/storage/stats-query.ts"; @@ -36,10 +36,10 @@ let statsResult: StatsKindCounts[] | null; beforeAll(async () => { const mapcodeService = createMapcodeService(); - const boundaryService = await BoundaryService.load("test/resources/borders-test.fgb"); + const borders = await loadTestBorders(); app = buildServer({ mapcodeService, - boundaryService, + borders, version: "1.0", replay: { token: TOKEN, @@ -263,8 +263,8 @@ describe("CORS preflight", () => { describe("without replay deps", () => { it("404s GET and 405s OPTIONS on /mapcode/replay", async () => { const mapcodeService = createMapcodeService(); - const boundaryService = await BoundaryService.load("test/resources/borders-test.fgb"); - const bare = buildServer({ mapcodeService, boundaryService, version: "1.0" }); + const borders = await loadTestBorders(); + const bare = buildServer({ mapcodeService, borders, version: "1.0" }); await bare.ready(); expect((await bare.inject({ method: "GET", url: "/mapcode/replay?from=1" })).statusCode).toBe(404); expect((await bare.inject({ method: "GET", url: "/mapcode/replay/stats" })).statusCode).toBe(404); diff --git a/test/api-territories.test.ts b/test/api-territories.test.ts index eff72a4..01acff8 100644 --- a/test/api-territories.test.ts +++ b/test/api-territories.test.ts @@ -15,7 +15,7 @@ import { describe, it, expect, beforeAll } from "vitest"; import { buildServer } from "../src/server.ts"; import { createMapcodeService } from "../src/domain/mapcode-service.ts"; -import { BoundaryService } from "../src/domain/boundary-service.ts"; +import { loadTestBorders } from "./borders-fixture.ts"; import type { FastifyInstance } from "fastify"; // Ported verbatim from ApiTerritoriesTest.java. @@ -24,8 +24,8 @@ let app: FastifyInstance; beforeAll(async () => { const mapcodeService = createMapcodeService(); - const boundaryService = await BoundaryService.load("test/resources/borders-test.fgb"); - app = buildServer({ mapcodeService, boundaryService, version: "1.0" }); + const borders = await loadTestBorders(); + app = buildServer({ mapcodeService, borders, version: "1.0" }); await app.ready(); }); diff --git a/test/borders-fixture.ts b/test/borders-fixture.ts new file mode 100644 index 0000000..90f1754 --- /dev/null +++ b/test/borders-fixture.ts @@ -0,0 +1,22 @@ +// Copyright (C) 2026, Stichting Mapcode Foundation (http://www.mapcode.com) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { readFile } from "node:fs/promises"; +import { Borders } from "mapcode-ts-borders"; + +export const TEST_BORDERS_PATH = "test/resources/borders-test.mcb"; + +export async function loadTestBorders(): Promise { + return Borders.fromBytes(await readFile(TEST_BORDERS_PATH)); +} diff --git a/test/boundary-service.test.ts b/test/boundary-service.test.ts deleted file mode 100644 index 893984b..0000000 --- a/test/boundary-service.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -// Copyright (C) 2026, Stichting Mapcode Foundation (http://www.mapcode.com) -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { BoundaryService, pointInRing } from "../src/domain/boundary-service.ts"; - -// Optional per-test override for the flatgeobuf GeoJSON deserializer. When set, the -// mocked module returns this value; otherwise it delegates to the real implementation. -// This lets us exercise the load() CSR-build path with hand-built features -// without disturbing the tests that read the real .fgb fixture. -let deserializeOverride: (() => unknown) | null = null; - -vi.mock("flatgeobuf", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - geojson: { - ...actual.geojson, - deserialize: (...args: Parameters) => - deserializeOverride - ? deserializeOverride() - : (actual.geojson.deserialize as (...a: unknown[]) => unknown)(...args), - }, - }; -}); - -const FIXTURE = join( - dirname(fileURLToPath(import.meta.url)), - "resources", - "borders-test.fgb", -); - -describe("pointInRing", () => { - // A unit square (0,0)-(10,10) stored as lon,lat interleaved, closed ring. - const square = new Float64Array([ - 0, 0, 10, 0, 10, 10, 0, 10, 0, 0, - ]); - - it("returns true for a point inside the square", () => { - expect(pointInRing(square, 0, square.length / 2, 5, 5)).toBe(true); - }); - - it("returns false for a point outside the square", () => { - expect(pointInRing(square, 0, square.length / 2, 15, 5)).toBe(false); - expect(pointInRing(square, 0, square.length / 2, -1, 5)).toBe(false); - }); - - it("handles a square with a hole via two ring calls", () => { - // Outer 0..10, inner hole 4..6. - const coords = new Float64Array([ - // outer ring (5 verts) - 0, 0, 10, 0, 10, 10, 0, 10, 0, 0, - // inner ring / hole (5 verts) - 4, 4, 6, 4, 6, 6, 4, 6, 4, 4, - ]); - const outerStart = 0; - const outerEnd = 5; - const holeStart = 5; - const holeEnd = 10; - // Point in hole: inside outer but inside hole -> not in polygon. - expect(pointInRing(coords, outerStart, outerEnd, 5, 5)).toBe(true); - expect(pointInRing(coords, holeStart, holeEnd, 5, 5)).toBe(true); - // Point between hole and outer edge: inside outer, not in hole. - expect(pointInRing(coords, outerStart, outerEnd, 2, 2)).toBe(true); - expect(pointInRing(coords, holeStart, holeEnd, 2, 2)).toBe(false); - }); -}); - -describe("BoundaryService", () => { - it("loads the fixture", async () => { - const svc = await BoundaryService.load(FIXTURE); - expect(svc).toBeInstanceOf(BoundaryService); - }); - - it("returns the country only for a point inside a country", async () => { - const svc = await BoundaryService.load(FIXTURE); - const matches = svc.lookup(52.0, 5.0); // inside NLD square - expect(matches.length).toBe(1); - expect(matches[0].alphaCode).toBe("NLD"); - expect(matches[0].parentAlphaCode).toBeNull(); - expect(matches[0].adminLevel).toBe(2); - }); - - it("returns subdivision before country (ranking by adminLevel desc)", async () => { - const svc = await BoundaryService.load(FIXTURE); - const matches = svc.lookup(36.0, -120.0); // inside USA-CA square - expect(matches.length).toBe(2); - expect(matches[0].alphaCode).toBe("USA-CA"); - expect(matches[0].parentAlphaCode).toBe("USA"); - expect(matches[0].adminLevel).toBe(4); - expect(matches[1].alphaCode).toBe("USA"); - expect(matches[1].adminLevel).toBe(2); - }); - - it("returns empty list for a sea point", async () => { - const svc = await BoundaryService.load(FIXTURE); - const matches = svc.lookup(0.0, -30.0); // mid-Atlantic - expect(matches).toEqual([]); - }); - - it("returns the smaller polygon first for a disputed region (area asc)", async () => { - const svc = await BoundaryService.load(FIXTURE); - // (6.5, 106.5) is inside both DISPUTED-A (large) and DISPUTED-B (small). - const matches = svc.lookup(6.5, 106.5); - expect(matches.length).toBe(2); - expect(matches[0].alphaCode).toBe("DISPUTED-B"); - expect(matches[1].alphaCode).toBe("DISPUTED-A"); - }); - - it("reads pre-tagged subdivision alphaCode and returns it twice", async () => { - const svc = await BoundaryService.load(FIXTURE); - const matches = svc.lookup(62.0, 22.5); - expect(matches.length).toBe(2); - expect(matches[0].alphaCode).toBe("NO-MAPCODE-PARENT"); - expect(matches[0].adminLevel).toBe(4); - expect(matches[1].alphaCode).toBe("NO-MAPCODE-PARENT"); - expect(matches[1].adminLevel).toBe(2); - }); - - it("rejects a missing borders file", async () => { - await expect(BoundaryService.load("/does/not/exist.fgb")).rejects.toThrow(); - }); -}); - -describe("BoundaryService load — degenerate feature CSR rollback", () => { - afterEach(() => { - deserializeOverride = null; - }); - - it("handles a degenerate feature before a valid feature without spurious matches", async () => { - // Feed load() hand-built features where a degenerate feature (a - // non-empty polygon array whose rings are all empty -> no vertices appended) - // PRECEDES a valid feature. Without the rollback, the degenerate feature leaves - // phantom polygons/rings in the CSR arrays and desynchronizes featurePolyStart; - // with the rollback, the CSR arrays are exactly as before the iteration. - // - // NOTE: empirically the phantom polygons left by a no-vertex feature all have an - // EMPTY outer ring, which point-in-ring always rejects, so the desync corrupts - // only internal polygon counts, not lookup RESULTS reachable via the public API. - // This test is therefore a behavioral regression guard (valid feature resolves; - // the dropped feature yields no match), not a proof of the counter arithmetic; - // see task-7-report.md for the full reachability analysis. - const collection = { - type: "FeatureCollection", - features: [ - { - // Degenerate: two polygons, each a single empty ring -> contributes no - // vertices, but advances ringCount/polyCount and pushes to the offset - // lists before the rollback restores them. - geometry: { type: "MultiPolygon", coordinates: [[[]], [[]]] }, - properties: { alphaCode: "BAD", parentAlphaCode: "", adminLevel: 2, area: 1 }, - }, - { - // Valid square (0,0)-(10,10). - geometry: { - type: "Polygon", - coordinates: [ - [ - [0, 0], - [10, 0], - [10, 10], - [0, 10], - [0, 0], - ], - ], - }, - properties: { alphaCode: "GOOD", parentAlphaCode: "", adminLevel: 2, area: 100 }, - }, - ], - }; - deserializeOverride = async function* () { - yield* collection.features; - }; - - // Any path works; readFile content is ignored because deserialize is mocked. - const svc = await BoundaryService.load(FIXTURE); - - // lookup(lat, lon): point (5,5) is inside the GOOD square. - const matches = svc.lookup(5, 5); - expect(matches.length).toBe(1); - expect(matches[0].alphaCode).toBe("GOOD"); - // The dropped degenerate feature must never surface. - expect(matches.some((m) => m.alphaCode === "BAD")).toBe(false); - - // A point outside the square returns nothing. - expect(svc.lookup(50, 50)).toEqual([]); - }); -}); diff --git a/test/config.test.ts b/test/config.test.ts index f18dcca..5b96333 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -35,11 +35,10 @@ afterEach(() => { }); describe("loadConfig", () => { - it("parses port, bordersPath and version", () => { - const cfg = loadConfig({ PORT: "9090", MAPCODE_BORDERS_PATH: "/x/b.fgb", VERSION: "1.0" }); + it("parses port and version", () => { + const cfg = loadConfig({ PORT: "9090", VERSION: "1.0" }); expect(cfg).toEqual({ port: 9090, - bordersPath: "/x/b.fgb", version: "1.0", dbUrl: null, replayToken: null, @@ -48,27 +47,29 @@ describe("loadConfig", () => { }); it("defaults port to 8080 and version to a non-empty string", () => { - const cfg = loadConfig({ MAPCODE_BORDERS_PATH: "/x/b.fgb" }); + const cfg = loadConfig({}); expect(cfg.port).toBe(8080); expect(cfg.version.length).toBeGreaterThan(0); }); - it("throws when MAPCODE_BORDERS_PATH is missing", () => { - expect(() => loadConfig({})).toThrow(/MAPCODE_BORDERS_PATH/); + it("ignores a stale MAPCODE_BORDERS_PATH", () => { + const cfg = loadConfig({ MAPCODE_BORDERS_PATH: "./data/borders.fgb" }); + expect(cfg).not.toHaveProperty("bordersPath"); + expect(Object.keys(cfg).sort()).toEqual(["dbCaCert", "dbUrl", "port", "replayToken", "version"]); }); it("rejects malformed PORT values", () => { - expect(() => loadConfig({ PORT: "abc", MAPCODE_BORDERS_PATH: "/x/b.fgb" })).toThrow(/PORT/); - expect(() => loadConfig({ PORT: "123abc", MAPCODE_BORDERS_PATH: "/x/b.fgb" })).toThrow(/PORT/); + expect(() => loadConfig({ PORT: "abc" })).toThrow(/PORT/); + expect(() => loadConfig({ PORT: "123abc" })).toThrow(/PORT/); }); it("rejects PORT values outside the listenable range", () => { - expect(() => loadConfig({ PORT: "-1", MAPCODE_BORDERS_PATH: "/x/b.fgb" })).toThrow(/PORT/); - expect(() => loadConfig({ PORT: "65536", MAPCODE_BORDERS_PATH: "/x/b.fgb" })).toThrow(/PORT/); + expect(() => loadConfig({ PORT: "-1" })).toThrow(/PORT/); + expect(() => loadConfig({ PORT: "65536" })).toThrow(/PORT/); }); it("defaults dbUrl, replayToken and dbCaCert to null", () => { - const cfg = loadConfig({ MAPCODE_BORDERS_PATH: "/x/b.fgb" }); + const cfg = loadConfig({}); expect(cfg.dbUrl).toBeNull(); expect(cfg.replayToken).toBeNull(); expect(cfg.dbCaCert).toBeNull(); @@ -76,7 +77,6 @@ describe("loadConfig", () => { it("parses MAPCODE_DB_URL, MAPCODE_REPLAY_TOKEN and MAPCODE_DB_CA_CERT", () => { const cfg = loadConfig({ - MAPCODE_BORDERS_PATH: "/x/b.fgb", MAPCODE_DB_URL: "postgres://u:p@h:5432/db?sslmode=require", MAPCODE_REPLAY_TOKEN: "secret-token", MAPCODE_DB_CA_CERT: "-----BEGIN CERTIFICATE-----", @@ -87,21 +87,20 @@ describe("loadConfig", () => { }); it("treats empty/whitespace MAPCODE_DB_URL as absent", () => { - const cfg = loadConfig({ MAPCODE_BORDERS_PATH: "/x/b.fgb", MAPCODE_DB_URL: " " }); + const cfg = loadConfig({ MAPCODE_DB_URL: " " }); expect(cfg.dbUrl).toBeNull(); }); it("fails closed: MAPCODE_DB_URL without MAPCODE_REPLAY_TOKEN throws", () => { expect(() => loadConfig({ - MAPCODE_BORDERS_PATH: "/x/b.fgb", - MAPCODE_DB_URL: "postgres://u:p@h:5432/db", + MAPCODE_DB_URL: "postgres://u:p@h:5432/db", }) ).toThrow(/MAPCODE_REPLAY_TOKEN/); }); it("accepts MAPCODE_REPLAY_TOKEN without MAPCODE_DB_URL (recording simply disabled)", () => { - const cfg = loadConfig({ MAPCODE_BORDERS_PATH: "/x/b.fgb", MAPCODE_REPLAY_TOKEN: "t" }); + const cfg = loadConfig({ MAPCODE_REPLAY_TOKEN: "t" }); expect(cfg.dbUrl).toBeNull(); expect(cfg.replayToken).toBe("t"); }); @@ -110,21 +109,21 @@ describe("loadConfig", () => { describe("loadEnvFileIfExists", () => { it("loads values from an existing .env file", () => { const env: NodeJS.ProcessEnv = {}; - const envPath = writeEnvFile("MAPCODE_BORDERS_PATH=/from-file\nPORT=8081\n"); + const envPath = writeEnvFile("VERSION=from-file\nPORT=8081\n"); expect(loadEnvFileIfExists(envPath, env)).toBe(true); - expect(env.MAPCODE_BORDERS_PATH).toBe("/from-file"); + expect(env.VERSION).toBe("from-file"); expect(env.PORT).toBe("8081"); }); it("keeps deployment-provided variables ahead of .env file values", () => { - const env: NodeJS.ProcessEnv = { MAPCODE_BORDERS_PATH: "/from-deployment", PORT: "0" }; - const envPath = writeEnvFile("MAPCODE_BORDERS_PATH=/from-file\nPORT=8081\n"); + const env: NodeJS.ProcessEnv = { VERSION: "from-deployment", PORT: "0" }; + const envPath = writeEnvFile("VERSION=from-file\nPORT=8081\n"); expect(loadEnvFileIfExists(envPath, env)).toBe(true); - expect(env.MAPCODE_BORDERS_PATH).toBe("/from-deployment"); + expect(env.VERSION).toBe("from-deployment"); expect(env.PORT).toBe("0"); }); diff --git a/test/package-config.test.ts b/test/package-config.test.ts index 6ae794f..7b4b2eb 100644 --- a/test/package-config.test.ts +++ b/test/package-config.test.ts @@ -20,9 +20,9 @@ const packageLock = JSON.parse(readFileSync(new URL("../package-lock.json", impo describe("package dependency configuration", () => { it("keeps package metadata at the release version", () => { - expect(packageJson.version).toBe("2.11.4"); - expect(packageLock.version).toBe("2.11.4"); - expect(packageLock.packages[""].version).toBe("2.11.4"); + expect(packageJson.version).toBe("2.12.0"); + expect(packageLock.version).toBe("2.12.0"); + expect(packageLock.packages[""].version).toBe("2.12.0"); }); it("uses a registry-resolvable mapcode-ts dependency", () => { diff --git a/test/parity-sweep.test.ts b/test/parity-sweep.test.ts index 22ff05a..9b92a04 100644 --- a/test/parity-sweep.test.ts +++ b/test/parity-sweep.test.ts @@ -33,7 +33,7 @@ import { describe, it, expect, beforeAll } from "vitest"; import type { FastifyInstance } from "fastify"; import { buildServer } from "../src/server.ts"; import { createMapcodeService } from "../src/domain/mapcode-service.ts"; -import { BoundaryService } from "../src/domain/boundary-service.ts"; +import { loadTestBorders } from "./borders-fixture.ts"; import { Alphabet, encodeToInternational, decode } from "mapcode-ts"; import { distanceInMeters } from "../src/domain/geo.ts"; @@ -41,10 +41,10 @@ let app: FastifyInstance; beforeAll(async () => { const mapcodeService = createMapcodeService(); - const boundaryService = await BoundaryService.load("test/resources/borders-test.fgb"); + const borders = await loadTestBorders(); app = buildServer({ mapcodeService, - boundaryService, + borders, version: "1.0", recorder: { record: () => {}, flush: async () => {}, close: async () => {} }, }); diff --git a/test/recording-unit.test.ts b/test/recording-unit.test.ts index c4e5236..fe8b9d5 100644 --- a/test/recording-unit.test.ts +++ b/test/recording-unit.test.ts @@ -23,8 +23,8 @@ import { kindFromRoute, rawQueryValue, isReplayFamilyUrl, - countryFromMatches, } from "../src/routes/recording.ts"; +import { loadTestBorders } from "./borders-fixture.ts"; describe("condenseStatus", () => { it("keeps the 1st and 3rd digit", () => { @@ -146,18 +146,15 @@ describe("rawQueryValue", () => { }); }); -describe("countryFromMatches", () => { - it("takes the top match, preferring its parent (subdivision → country)", () => { - expect(countryFromMatches([{ alphaCode: "NLD", parentAlphaCode: null, adminLevel: 2, area: 1 }])).toBe("NLD"); - expect( - countryFromMatches([ - { alphaCode: "USA-CA", parentAlphaCode: "USA", adminLevel: 4, area: 1 }, - { alphaCode: "USA", parentAlphaCode: null, adminLevel: 2, area: 9 }, - ]) - ).toBe("USA"); +describe("country of a border lookup", () => { + it("takes the top match, preferring its parent (subdivision → country)", async () => { + const borders = await loadTestBorders(); + expect(borders.getCountry(52, 5)?.toString()).toBe("NLD"); + expect(borders.getCountry(36, -120)?.toString()).toBe("USA"); }); - it("returns null when nothing matched (sea, poles)", () => { - expect(countryFromMatches([])).toBeNull(); + it("is null when nothing matched (sea, poles)", async () => { + const borders = await loadTestBorders(); + expect(borders.getCountry(0, -30)).toBeNull(); }); }); diff --git a/test/resources/README.md b/test/resources/README.md new file mode 100644 index 0000000..4f74000 --- /dev/null +++ b/test/resources/README.md @@ -0,0 +1,17 @@ +# Test fixtures + +`borders-test.mcb` is the border data the tests load. It is built from +`borders-test.fgb`, a FlatGeobuf file with 7 synthetic features. Both files are +kept. Every alpha code must be one that `mapcode-ts` knows canonically, because +`mapcode-ts-borders` rejects the rest. The codes `BVT`, `HMD`, `SGS` and `PCN` +are real territories that produce no mapcodes inside the fixture polygons, so +they never change the mapcode order in a response. + +Regenerate the `.mcb` file with the converter of `mapcode-ts-borders`, from a +checkout of that repository: + +```bash +node --experimental-strip-types /scripts/convert-borders.mts \ + test/resources/borders-test.fgb test/resources/borders-test.mcb \ + --snapshot-date 2026-05 --source "mapcode-rest-service-ts test fixture" +``` diff --git a/test/resources/borders-test.fgb b/test/resources/borders-test.fgb index 2f010d3..3ab0495 100644 Binary files a/test/resources/borders-test.fgb and b/test/resources/borders-test.fgb differ diff --git a/test/resources/borders-test.mcb b/test/resources/borders-test.mcb new file mode 100644 index 0000000..170b9fe Binary files /dev/null and b/test/resources/borders-test.mcb differ diff --git a/test/server-logging.test.ts b/test/server-logging.test.ts index 57b5824..ff5ce94 100644 --- a/test/server-logging.test.ts +++ b/test/server-logging.test.ts @@ -16,7 +16,8 @@ import { describe, it, expect, beforeAll, afterEach } from "vitest"; import type { FastifyInstance } from "fastify"; import { buildServer, type ServerDeps } from "../src/server.ts"; import { createMapcodeService, type MapcodeService } from "../src/domain/mapcode-service.ts"; -import { BoundaryService } from "../src/domain/boundary-service.ts"; +import { loadTestBorders } from "./borders-fixture.ts"; +import type { Borders } from "mapcode-ts-borders"; interface LogRecord { level: number; @@ -37,12 +38,12 @@ interface LogRecord { } let mapcodeService: MapcodeService; -let boundaryService: BoundaryService; +let borders: Borders; let openApps: FastifyInstance[] = []; beforeAll(async () => { mapcodeService = createMapcodeService(); - boundaryService = await BoundaryService.load("test/resources/borders-test.fgb"); + borders = await loadTestBorders(); }); afterEach(async () => { @@ -68,7 +69,7 @@ function buildLoggedServer(replay?: ServerDeps["replay"]): { app: FastifyInstanc const app = buildServer({ mapcodeService, - boundaryService, + borders, version: "1.0", logger: { level: "info", stream }, replay,