From 38a0f5eca9605b47978249af5b15d060efec2387 Mon Sep 17 00:00:00 2001 From: Ibrahim Rishvan Date: Fri, 7 Aug 2026 19:48:04 +0500 Subject: [PATCH] Prepare v0.3 compatibility and query upgrades --- CHANGELOG.md | 11 ++- CONTRIBUTING.md | 2 + README.md | 41 ++++++++++ docs/API_STABILITY.md | 29 +++++++ docs/DATA_MODEL.md | 2 +- package-lock.json | 82 +++++++++++++++++++- package.json | 32 +++++--- scripts/write-cjs-package.mjs | 5 ++ src/index.ts | 7 +- src/query.ts | 129 ++++++++++++++++++++++++++++++- src/types.ts | 37 +++++++++ tests/advanced-query.test.mjs | 59 ++++++++++++++ tests/compatibility.test.mjs | 39 ++++++++++ tests/schema-validation.test.mjs | 25 ++++++ tsconfig.cjs.json | 10 +++ 15 files changed, 492 insertions(+), 18 deletions(-) create mode 100644 docs/API_STABILITY.md create mode 100644 scripts/write-cjs-package.mjs create mode 100644 tests/advanced-query.test.mjs create mode 100644 tests/compatibility.test.mjs create mode 100644 tests/schema-validation.test.mjs create mode 100644 tsconfig.cjs.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c4ca62..248e18a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,16 @@ All notable changes will be documented in this file. -## 0.2.0 - Unreleased +## 0.3.0 - Unreleased + +- Added dual ESM and CommonJS package entry points. +- Added `queryLocations` with fuzzy search, bounding-box filtering, proximity/radius queries, deterministic sorting, and pagination. +- Added explicit query validation and made unknown zone filters return no results instead of the full dataset. +- Added automated CommonJS, package-export, JSON Schema, query edge-case, spatial, pagination, and legacy-field compatibility tests. +- Added an API stability policy with deprecation guarantees and measurable 1.0 readiness gates. +- Retained Node.js 22 as the minimum supported runtime because earlier Node release lines are end-of-life. + +## 0.2.0 - 2026-08-07 - Added canonical JSON sources, stable IDs, aliases, richer Maldives zone and location types. - Added query helpers plus JSON, CSV, and GeoJSON distributions. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index afd2e3a..251ad38 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,3 +12,5 @@ npm test ~~~ Please avoid unrelated formatting changes in large data files. Generated files in "dist/" are release artifacts and should not be committed. + +Public API changes must follow [the API stability policy](docs/API_STABILITY.md). Add compatibility tests for new entry points, query behavior, schemas, or migration-sensitive fields. diff --git a/README.md b/README.md index cfdb973..9309437 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,46 @@ import { DATASET_METADATA } from "@boolean.mv/geo-data/metadata"; const nextCoordinatesReview = DATASET_METADATA.review.datasets.coordinates.nextReviewDue; ~~~ +## CommonJS + +Every JavaScript entry point also supports `require()` on supported Node.js versions: + +~~~js +const { COUNTRY_BY_ISO2, queryLocations } = require("@boolean.mv/geo-data"); +const { MV_LOCATIONS } = require("@boolean.mv/geo-data/maldives/zones/aa"); +~~~ + +## Advanced location queries + +`queryLocations` combines the existing zone, use, kind, coordinate, and deprecation filters with fuzzy search, spatial filtering, distance ordering, and pagination: + +~~~ts +import { queryLocations } from "@boolean.mv/geo-data/query"; + +const typoTolerant = queryLocations({ + search: "dangeti", + fuzzy: true, + minScore: 0.72, + limit: 10, +}); + +const nearbyResorts = queryLocations({ + use: "resort", + near: { latitude: 4.1755, longitude: 73.5093 }, + radiusKm: 50, + offset: 0, + limit: 25, +}); + +const visibleMapPoints = queryLocations({ + bounds: { south: 3.8, west: 72.8, north: 4.5, east: 73.8 }, + hasCoordinates: true, + limit: 1000, +}); +~~~ + +Results contain `location`, an optional fuzzy-match `score`, and an optional `distanceKm`. The response also includes `total`, `offset`, `limit`, and `hasMore`. Limits must be between 1 and 1,000. Spatial queries exclude records without coordinates. + ## JSON The build emits framework-neutral files at: @@ -90,6 +130,7 @@ This is reference data, not an official government service. Names, classificatio See [data provenance](docs/data-provenance.md) for ownership, redistribution terms, and contribution guidance. The [data maintenance policy](docs/DATA_MAINTENANCE.md) defines quarterly reviews, stable-ID rename handling, and deprecation behavior. Deprecation and replacement metadata is preserved in typed objects, JSON, CSV, and GeoJSON outputs. +The [API stability policy](docs/API_STABILITY.md) defines compatibility guarantees from version 0.3.0 and the gates for reaching 1.0. ## Development diff --git a/docs/API_STABILITY.md b/docs/API_STABILITY.md new file mode 100644 index 0000000..b74cfa0 --- /dev/null +++ b/docs/API_STABILITY.md @@ -0,0 +1,29 @@ +# API stability + +Although the package has not reached 1.0, Boolean Private Limited treats the public API as stable from version 0.3.0 onward. + +## Compatibility promise + +- Minor releases add exports, optional fields, query capabilities, and datasets without removing existing behavior. +- Patch releases contain compatible corrections, documentation, and maintenance changes. +- An export, field, or entry point must be deprecated for at least one minor release before removal. +- A breaking JavaScript or TypeScript API change requires a major release and a migration guide. +- Country, zone, and location IDs are never reassigned. Renames preserve the ID and retain the former name as an alias. +- Data corrections, additions, classifications, and deprecations are normal compatible updates. Applications that require an unchanged snapshot should pin an exact package version. + +The legacy `Country.dialingCode`, `Country.nationality`, `MvLocation.status`, and `MvLocation.details` fields remain supported while their richer plural or structured equivalents are available. + +## Runtime support + +The package publishes equivalent ESM and CommonJS entry points. The minimum Node.js version follows supported Node release lines and is declared in `package.json`. Browser applications should use ESM so bundlers can select lightweight dataset and per-zone entry points. + +## Path to 1.0 + +The project can move to 1.0 after all of the following are true: + +1. The API has completed at least one quarterly review cycle without a breaking redesign. +2. At least two production applications have exercised the package and query helpers. +3. ESM, CommonJS, TypeScript declarations, schemas, and documented entry points remain covered by automated compatibility tests. +4. Any remaining pre-1.0 migration guidance is incorporated into the main documentation. + +Reaching 1.0 will formalize the existing compatibility promise; it is not intended to trigger a redesign. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index ba36a9c..25807b2 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -6,4 +6,4 @@ Maldives zones distinguish administrative atolls from cities and separately reco Coordinates are optional and identify their source and precision. Government-portal matches take precedence; uncovered locations may use exact atoll-and-name matches from Boolean Private Limited's coordinate workbook. Ambiguous names, duplicate source rows, and conflicting matches are excluded. Missing coordinates mean “not confidently matched,” never zero. -Breaking schema changes require a major version after 1.0. Before 1.0, changelogs must identify migrations. +From version 0.3.0, the package follows the compatibility guarantees in [API_STABILITY.md](API_STABILITY.md), including major-version-only breaking API changes and at least one minor release of deprecation notice. diff --git a/package-lock.json b/package-lock.json index 68ca2c0..b4669a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,98 @@ { "name": "@boolean.mv/geo-data", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@boolean.mv/geo-data", - "version": "0.2.0", + "version": "0.3.0", "license": "SEE LICENSE IN LICENSE", "devDependencies": { + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", "typescript": "^5.9.2" }, "engines": { "node": ">=22" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index fed9d4f..584ed85 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@boolean.mv/geo-data", - "version": "0.2.0", + "version": "0.3.0", "description": "Typed country, nationality, and Maldives geographic reference data for web applications.", "author": "Boolean Private Limited", "keywords": [ @@ -21,36 +21,44 @@ "homepage": "https://github.com/booleanMV/GeoData#readme", "type": "module", "sideEffects": false, - "main": "./dist/index.js", + "main": "./dist/cjs/index.js", + "module": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/index.js" + "import": "./dist/index.js", + "require": "./dist/cjs/index.js" }, "./countries": { "types": "./dist/countries.d.ts", - "import": "./dist/countries.js" + "import": "./dist/countries.js", + "require": "./dist/cjs/countries.js" }, "./nationalities": { "types": "./dist/nationalities.d.ts", - "import": "./dist/nationalities.js" + "import": "./dist/nationalities.js", + "require": "./dist/cjs/nationalities.js" }, "./maldives": { "types": "./dist/maldives.d.ts", - "import": "./dist/maldives.js" + "import": "./dist/maldives.js", + "require": "./dist/cjs/maldives.js" }, "./maldives/zones/*": { "types": "./dist/maldives-zones/*.d.ts", - "import": "./dist/maldives-zones/*.js" + "import": "./dist/maldives-zones/*.js", + "require": "./dist/cjs/maldives-zones/*.js" }, "./metadata": { "types": "./dist/metadata.d.ts", - "import": "./dist/metadata.js" + "import": "./dist/metadata.js", + "require": "./dist/cjs/metadata.js" }, "./query": { "types": "./dist/query.d.ts", - "import": "./dist/query.js" + "import": "./dist/query.js", + "require": "./dist/cjs/query.js" }, "./data/*": "./dist/data/*", "./schemas/*": "./schemas/*", @@ -59,6 +67,8 @@ "files": [ "dist", "schemas", + "docs", + "CHANGELOG.md", "LICENSE", "LICENSE-DATA.md", "README.md" @@ -72,13 +82,15 @@ "check:generated": "npm run build:coordinates && npm run generate && git diff --exit-code -- data/coordinates.json data/coordinate-coverage.json src/countries.ts src/nationalities.ts src/maldives.ts src/maldives-zones src/metadata.ts", "build:coordinates": "node scripts/build-coordinates.mjs", "sync:coordinates": "node scripts/sync-official-coordinates.mjs", - "build": "npm run build:coordinates && npm run generate && tsc -p tsconfig.json && node scripts/export-json.mjs", + "build": "npm run build:coordinates && npm run generate && tsc -p tsconfig.json && tsc -p tsconfig.cjs.json && node scripts/write-cjs-package.mjs && node scripts/export-json.mjs", "clean": "rm -rf dist", "test": "npm run check:review && npm run build && node --test tests/*.test.mjs", "typecheck": "npm run generate && tsc -p tsconfig.json --noEmit", "prepublishOnly": "npm test" }, "devDependencies": { + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", "typescript": "^5.9.2" } } diff --git a/scripts/write-cjs-package.mjs b/scripts/write-cjs-package.mjs new file mode 100644 index 0000000..5f46008 --- /dev/null +++ b/scripts/write-cjs-package.mjs @@ -0,0 +1,5 @@ +import { mkdir, writeFile } from "node:fs/promises"; + +const directory = new URL("../dist/cjs/", import.meta.url); +await mkdir(directory, { recursive: true }); +await writeFile(new URL("package.json", directory), '{"type":"commonjs"}\n', "utf8"); diff --git a/src/index.ts b/src/index.ts index 834cff1..91b1d93 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,8 +7,13 @@ export type { MvZoneType, Nationality, LocationFilter, + LocationQueryMatch, + LocationQueryOptions, + LocationQueryResult, LocationUse, Coordinates, + BoundingBox, + GeoPoint, CountryScope, DatasetMetadata, DatasetReviewEntry, @@ -21,4 +26,4 @@ export { COUNTRIES, COUNTRY_BY_ISO2, COUNTRY_BY_ISO3 } from "./countries.js"; export { NATIONALITIES, NATIONALITY_BY_ISO2 } from "./nationalities.js"; export { MV_ZONES, MV_ZONE_BY_CODE, MV_ZONE_BY_ID, MV_LOCATIONS, MV_LOCATION_BY_ID } from "./maldives.js"; export { DATASET_METADATA } from "./metadata.js"; -export { getCountry, getZone, getLocation, getLocations, searchCountries, searchLocations, getNationalityOptions } from "./query.js"; +export { getCountry, getZone, getLocation, getLocations, searchCountries, searchLocations, queryLocations, getNationalityOptions } from "./query.js"; diff --git a/src/query.ts b/src/query.ts index ab2ec15..14aff87 100644 --- a/src/query.ts +++ b/src/query.ts @@ -1,9 +1,74 @@ import { COUNTRIES, COUNTRY_BY_ISO2, COUNTRY_BY_ISO3 } from "./countries.js"; import { MV_LOCATIONS, MV_LOCATION_BY_ID, MV_ZONE_BY_CODE, MV_ZONE_BY_ID, MV_ZONES } from "./maldives.js"; -import type { Country, LocationFilter, MvLocation, MvZone } from "./types.js"; +import type { + BoundingBox, + Country, + GeoPoint, + LocationFilter, + LocationQueryMatch, + LocationQueryOptions, + LocationQueryResult, + MvLocation, + MvZone, +} from "./types.js"; const normalize = (value: string) => value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "") .toLowerCase().replace(/[’']/g, "").replace(/[^a-z0-9]+/g, " ").trim(); +const searchableLocationValues = (location: MvLocation): string[] => + [location.name, location.details, location.operatorName, location.facilityName, ...location.aliases] + .filter((value): value is string => Boolean(value)).map(normalize); + +const levenshteinDistance = (left: string, right: string): number => { + if (left === right) return 0; + if (!left.length) return right.length; + if (!right.length) return left.length; + let previous = Array.from({ length: right.length + 1 }, (_, index) => index); + for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) { + const current = [leftIndex]; + for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) { + const insertion = (current[rightIndex - 1] ?? 0) + 1; + const deletion = (previous[rightIndex] ?? 0) + 1; + const substitution = (previous[rightIndex - 1] ?? 0) + + (left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1); + current.push(Math.min(insertion, deletion, substitution)); + } + previous = current; + } + return previous[right.length] ?? Math.max(left.length, right.length); +}; +const similarity = (term: string, candidate: string): number => { + if (candidate.includes(term)) return 1; + const values = [candidate, ...candidate.split(" ")]; + return Math.max(...values.map(value => 1 - levenshteinDistance(term, value) / Math.max(term.length, value.length, 1))); +}; +const validatePoint = ({ latitude, longitude }: GeoPoint, label: string): void => { + if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90 || + !Number.isFinite(longitude) || longitude < -180 || longitude > 180) { + throw new RangeError(`${label} must contain a valid latitude and longitude`); + } +}; +const validateBounds = (bounds: BoundingBox): void => { + validatePoint({ latitude: bounds.south, longitude: bounds.west }, "bounds southwest corner"); + validatePoint({ latitude: bounds.north, longitude: bounds.east }, "bounds northeast corner"); + if (bounds.south > bounds.north) throw new RangeError("bounds south must not exceed north"); +}; +const isWithinBounds = ({ latitude, longitude }: GeoPoint, bounds: BoundingBox): boolean => { + const withinLongitude = bounds.west <= bounds.east + ? longitude >= bounds.west && longitude <= bounds.east + : longitude >= bounds.west || longitude <= bounds.east; + return latitude >= bounds.south && latitude <= bounds.north && withinLongitude; +}; +const degreesToRadians = (value: number): number => value * Math.PI / 180; +const distanceKm = (left: GeoPoint, right: GeoPoint): number => { + const latitudeDelta = degreesToRadians(right.latitude - left.latitude); + const longitudeDelta = degreesToRadians(right.longitude - left.longitude); + const leftLatitude = degreesToRadians(left.latitude); + const rightLatitude = degreesToRadians(right.latitude); + const haversine = Math.sin(latitudeDelta / 2) ** 2 + + Math.cos(leftLatitude) * Math.cos(rightLatitude) * Math.sin(longitudeDelta / 2) ** 2; + const boundedHaversine = Math.min(1, Math.max(0, haversine)); + return 6371.0088 * 2 * Math.atan2(Math.sqrt(boundedHaversine), Math.sqrt(1 - boundedHaversine)); +}; export const getCountry = (code: string): Country | undefined => { const key = code.trim().toUpperCase(); @@ -15,6 +80,7 @@ export const getZone = (idOrCode: string): MvZone | undefined => export const getLocation = (id: string): MvLocation | undefined => MV_LOCATION_BY_ID[id]; export const getLocations = (filter: LocationFilter = {}): readonly MvLocation[] => { const zone = filter.zone ? getZone(filter.zone) : undefined; + if (filter.zone && !zone) return []; const source = zone ? zone.locations : MV_LOCATIONS; return source.filter(location => (filter.includeDeprecated || !location.deprecation) && @@ -31,8 +97,65 @@ export const searchCountries = (query: string): readonly Country[] => { export const searchLocations = (query: string, filter: LocationFilter = {}): readonly MvLocation[] => { const term = normalize(query); if (!term) return getLocations(filter); - return getLocations(filter).filter(location => [location.name, location.details, location.operatorName, location.facilityName, ...location.aliases] - .filter((value): value is string => Boolean(value)).some(value => normalize(value).includes(term))); + return getLocations(filter).filter(location => searchableLocationValues(location).some(value => value.includes(term))); +}; + +export const queryLocations = (options: LocationQueryOptions = {}): LocationQueryResult => { + const offset = options.offset ?? 0; + const limit = options.limit ?? 50; + if (!Number.isInteger(offset) || offset < 0) throw new RangeError("offset must be a non-negative integer"); + if (!Number.isInteger(limit) || limit < 1 || limit > 1000) { + throw new RangeError("limit must be an integer between 1 and 1000"); + } + if (options.minScore !== undefined && (!Number.isFinite(options.minScore) || options.minScore < 0 || options.minScore > 1)) { + throw new RangeError("minScore must be between 0 and 1"); + } + if (options.radiusKm !== undefined && (!Number.isFinite(options.radiusKm) || options.radiusKm < 0)) { + throw new RangeError("radiusKm must be a non-negative number"); + } + if (options.radiusKm !== undefined && !options.near) throw new TypeError("radiusKm requires near coordinates"); + if (options.bounds) validateBounds(options.bounds); + if (options.near) validatePoint(options.near, "near"); + + const term = normalize(options.search ?? ""); + const minScore = options.minScore ?? 0.72; + const filter: LocationFilter = { + zone: options.zone, + use: options.use, + kind: options.kind, + hasCoordinates: options.hasCoordinates, + includeDeprecated: options.includeDeprecated, + }; + let matches: LocationQueryMatch[] = getLocations(filter).map(location => ({ location })); + if (term) { + matches = matches.map(match => ({ + ...match, + score: Math.max(...searchableLocationValues(match.location).map(value => + options.fuzzy ? similarity(term, value) : Number(value.includes(term)))), + })).filter(match => (match.score ?? 0) >= (options.fuzzy ? minScore : 1)); + } + if (options.bounds) { + matches = matches.filter(match => match.location.coordinates && isWithinBounds(match.location.coordinates, options.bounds!)); + } + if (options.near) { + matches = matches.filter(match => match.location.coordinates).map(match => ({ + ...match, + distanceKm: distanceKm(options.near!, match.location.coordinates!), + })).filter(match => options.radiusKm === undefined || (match.distanceKm ?? Infinity) <= options.radiusKm); + } + matches.sort((left, right) => { + if (options.near) return (left.distanceKm ?? Infinity) - (right.distanceKm ?? Infinity); + if (term) return (right.score ?? 0) - (left.score ?? 0) || left.location.name.localeCompare(right.location.name); + return left.location.name.localeCompare(right.location.name); + }); + const total = matches.length; + return { + results: matches.slice(offset, offset + limit), + total, + offset, + limit, + hasMore: offset + limit < total, + }; }; export const getNationalityOptions = (): readonly { value: string; label: string; countryName: string }[] => COUNTRIES.flatMap(country => country.nationalities.map(name => ({ value: country.iso2, label: name, countryName: country.name }))); diff --git a/src/types.ts b/src/types.ts index 2993cbe..f418582 100644 --- a/src/types.ts +++ b/src/types.ts @@ -99,3 +99,40 @@ export interface LocationFilter { hasCoordinates?: boolean; includeDeprecated?: boolean; } + +export interface GeoPoint { + latitude: number; + longitude: number; +} + +export interface BoundingBox { + south: number; + west: number; + north: number; + east: number; +} + +export interface LocationQueryOptions extends LocationFilter { + search?: string; + fuzzy?: boolean; + minScore?: number; + bounds?: BoundingBox; + near?: GeoPoint; + radiusKm?: number; + offset?: number; + limit?: number; +} + +export interface LocationQueryMatch { + location: MvLocation; + score?: number; + distanceKm?: number; +} + +export interface LocationQueryResult { + results: readonly LocationQueryMatch[]; + total: number; + offset: number; + limit: number; + hasMore: boolean; +} diff --git a/tests/advanced-query.test.mjs b/tests/advanced-query.test.mjs new file mode 100644 index 0000000..e8395ce --- /dev/null +++ b/tests/advanced-query.test.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + MV_LOCATIONS, + getLocations, + queryLocations, + searchLocations, +} from "../dist/index.js"; + +test("advanced query supports typo-tolerant search without changing exact search", () => { + assert.equal(searchLocations("dangeti").length, 0); + const fuzzy = queryLocations({ search: "dangeti", fuzzy: true, limit: 5 }); + assert.ok(fuzzy.results.some(match => match.location.name === "Dhan'gethi")); + assert.ok(fuzzy.results.every(match => match.score >= 0.72)); +}); + +test("advanced query supports bounding boxes and proximity ordering", () => { + const target = MV_LOCATIONS.find(location => location.coordinates); + assert.ok(target?.coordinates); + const { latitude, longitude } = target.coordinates; + const bounded = queryLocations({ + bounds: { south: latitude - 0.001, west: longitude - 0.001, north: latitude + 0.001, east: longitude + 0.001 }, + limit: 100, + }); + assert.ok(bounded.results.some(match => match.location.id === target.id)); + assert.ok(bounded.results.every(match => match.location.coordinates)); + + const nearby = queryLocations({ near: { latitude, longitude }, radiusKm: 25, limit: 25 }); + assert.equal(nearby.results[0]?.location.id, target.id); + assert.equal(nearby.results[0]?.distanceKm, 0); + assert.ok(nearby.results.every((match, index, values) => + index === 0 || (values[index - 1]?.distanceKm ?? 0) <= (match.distanceKm ?? Infinity))); +}); + +test("advanced query combines filters and returns deterministic pagination metadata", () => { + const first = queryLocations({ zone: "AA", use: "inhabited", offset: 0, limit: 3 }); + const second = queryLocations({ zone: "AA", use: "inhabited", offset: 3, limit: 3 }); + assert.equal(first.total, 8); + assert.equal(first.results.length, 3); + assert.equal(first.hasMore, true); + assert.equal(second.offset, 3); + assert.ok(!first.results.some(left => second.results.some(right => right.location.id === left.location.id))); + assert.deepEqual( + first.results.map(match => match.location.name), + [...first.results].map(match => match.location.name).sort((left, right) => left.localeCompare(right)), + ); + assert.equal(queryLocations({ offset: MV_LOCATIONS.length, limit: 10 }).results.length, 0); +}); + +test("query validation rejects ambiguous or invalid spatial and pagination input", () => { + assert.deepEqual(getLocations({ zone: "not-a-zone" }), []); + assert.throws(() => queryLocations({ offset: -1 }), /offset/); + assert.throws(() => queryLocations({ limit: 0 }), /limit/); + assert.throws(() => queryLocations({ limit: 1001 }), /limit/); + assert.throws(() => queryLocations({ minScore: 1.1 }), /minScore/); + assert.throws(() => queryLocations({ radiusKm: 5 }), /requires near/); + assert.throws(() => queryLocations({ near: { latitude: 91, longitude: 73 } }), /valid latitude/); + assert.throws(() => queryLocations({ bounds: { south: 5, west: 72, north: 4, east: 74 } }), /south/); +}); diff --git a/tests/compatibility.test.mjs b/tests/compatibility.test.mjs new file mode 100644 index 0000000..c41f412 --- /dev/null +++ b/tests/compatibility.test.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import test from "node:test"; + +const require = createRequire(import.meta.url); + +test("package root and lightweight entry points support CommonJS", () => { + const root = require("@boolean.mv/geo-data"); + const countries = require("@boolean.mv/geo-data/countries"); + const aa = require("@boolean.mv/geo-data/maldives/zones/aa"); + const query = require("@boolean.mv/geo-data/query"); + assert.equal(root.COUNTRIES.length, 195); + assert.equal(countries.COUNTRY_BY_ISO2.MV.name, "Maldives"); + assert.equal(aa.MV_ZONE.code, "AA"); + assert.equal(typeof query.queryLocations, "function"); +}); + +test("legacy 0.1 fields and corrected zone aliases remain available", async () => { + const { COUNTRIES, MV_LOCATIONS, getZone } = await import("@boolean.mv/geo-data"); + assert.ok(COUNTRIES.every(country => country.dialingCode && country.nationality)); + assert.ok(MV_LOCATIONS.every(location => location.status && typeof location.details === "string")); + assert.equal(getZone("Adh"), getZone("ADh")); +}); + +test("all documented JavaScript entry points resolve through package exports", async () => { + const entryPoints = [ + "@boolean.mv/geo-data", + "@boolean.mv/geo-data/countries", + "@boolean.mv/geo-data/nationalities", + "@boolean.mv/geo-data/maldives", + "@boolean.mv/geo-data/maldives/zones/aa", + "@boolean.mv/geo-data/metadata", + "@boolean.mv/geo-data/query", + ]; + for (const entryPoint of entryPoints) { + assert.ok(await import(entryPoint)); + assert.ok(require(entryPoint)); + } +}); diff --git a/tests/schema-validation.test.mjs b/tests/schema-validation.test.mjs new file mode 100644 index 0000000..3b0dbb3 --- /dev/null +++ b/tests/schema-validation.test.mjs @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import Ajv2020 from "ajv/dist/2020.js"; +import addFormats from "ajv-formats"; +import { COUNTRIES, MV_LOCATIONS } from "../dist/index.js"; + +const readJson = path => readFile(new URL(path, import.meta.url), "utf8").then(JSON.parse); + +test("every published country and Maldives location validates against its JSON Schema", async () => { + const [countrySchema, locationSchema] = await Promise.all([ + readJson("../schemas/country.schema.json"), + readJson("../schemas/maldives-location.schema.json"), + ]); + const ajv = new Ajv2020({ allErrors: true }); + addFormats(ajv); + const validateCountry = ajv.compile(countrySchema); + const validateLocation = ajv.compile(locationSchema); + for (const country of COUNTRIES) { + assert.equal(validateCountry(country), true, `${country.id}: ${ajv.errorsText(validateCountry.errors)}`); + } + for (const location of MV_LOCATIONS) { + assert.equal(validateLocation(location), true, `${location.id}: ${ajv.errorsText(validateLocation.errors)}`); + } +}); diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json new file mode 100644 index 0000000..a480fc3 --- /dev/null +++ b/tsconfig.cjs.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": false, + "declarationMap": false, + "module": "CommonJS", + "moduleResolution": "Node", + "outDir": "dist/cjs" + } +}