Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/geolite/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
__tests__
jest.config.js
31 changes: 31 additions & 0 deletions packages/geolite/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
The MIT License (MIT)

Copyright (c) 2025 Dan Lynch <pyramation@gmail.com>
Copyright (c) 2025 Constructive

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

---

GeoLite2 data is provided by MaxMind under the following terms:

Database and Contents Copyright (c) MaxMind, Inc.
GeoLite2 End User License Agreement: https://www.maxmind.com/en/geolite2/eula
Creative Commons Attribution-ShareAlike 4.0 International License:
https://creativecommons.org/licenses/by-sa/4.0/
6 changes: 6 additions & 0 deletions packages/geolite/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
EXTENSION = pgpm-geolite
DATA = sql/pgpm-geolite--0.26.0.sql

PG_CONFIG = pg_config
PGXS := $(shell $(PG_CONFIG) --pgxs)
include $(PGXS)
100 changes: 100 additions & 0 deletions packages/geolite/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# @pgpm/geolite

GeoLite2 IP geolocation tables and lookup functions for PostgreSQL.

Provides a `geolite` schema with tables for MaxMind's GeoLite2 City and ASN
databases, plus convenience functions for IP→location lookups. All tables are
globally readable (`GRANT SELECT TO public`).

## Architecture

The module has two parts:

1. **pgpm extension** — defines the schema (tables, indexes, functions). Deployed
via standard `pgpm deploy`. This is lightweight DDL only.
2. **TypeScript loader** (`src/loader.ts`) — downloads GeoLite2 `.mmdb` files from
[P3TERX/GeoLite.mmdb](https://github.com/P3TERX/GeoLite.mmdb), walks the MMDB
binary trie to extract all network→record pairs, and bulk-loads them via
`COPY`. Runs outside of pgpm's transactional deploy.

## Schema

### `geolite.network`

CIDR blocks mapped to geoname locations and coordinates (~4M rows for City IPv4).

| Column | Type | Description |
|--------|------|-------------|
| `id` | `uuid` | Primary key (uuidv7) |
| `network` | `cidr` | IPv4 or IPv6 CIDR block |
| `geoname_id` | `int` | FK to `geolite.location` |
| `latitude` | `numeric` | Approximate centroid latitude |
| `longitude` | `numeric` | Approximate centroid longitude |
| `accuracy_radius` | `int` | Accuracy in km |
| ... | | See source for full schema |

Indexed with GiST on `network` for fast `>>=` (contains) lookups.

### `geolite.location`

Location metadata keyed by `(geoname_id, locale_code)`.

| Column | Type | Description |
|--------|------|-------------|
| `id` | `uuid` | Primary key (uuidv7) |
| `geoname_id` | `int` | GeoNames identifier |
| `locale_code` | `text` | Locale (e.g. `en`) |
| `country_iso_code` | `text` | ISO 3166-1 alpha-2 |
| `country_name` | `text` | Country name |
| `city_name` | `text` | City name |
| `time_zone` | `text` | IANA time zone |
| ... | | See source for full schema |

### `geolite.asn`

ASN (Autonomous System Number) data mapping CIDR blocks to ISP/org info.

### `geolite.data_version`

Tracks which GeoLite2 release is loaded.

## Lookup Functions

```sql
-- City/country lookup for an IP
SELECT * FROM geolite.lookup('8.8.8.8'::inet);

-- ASN/ISP lookup
SELECT * FROM geolite.lookup_asn('8.8.8.8'::inet);
```

## Loading Data

After deploying the pgpm extension, run the TypeScript loader to populate tables:

```bash
# Set your database connection
export DATABASE_URL="postgresql://user:pass@localhost:5432/mydb"

# Run the loader (downloads .mmdb files automatically)
pnpm geolite:load

# Include IPv6 blocks (larger dataset, takes longer)
pnpm geolite:load -- --ipv6
```

The loader:
1. Downloads `.mmdb` files from P3TERX/GeoLite.mmdb (cached in `.data/`)
2. Walks the MMDB binary trie to extract all network/record pairs
3. Streams records into PostgreSQL via `COPY` (fast bulk insert)
4. Updates `geolite.data_version` with the current date

Re-run periodically to update (P3TERX updates weekly). The loader truncates
and reloads atomically.

## License

MIT — see [LICENSE](LICENSE).

GeoLite2 data: CC BY-SA 4.0 by MaxMind. See
[GeoLite2 EULA](https://www.maxmind.com/en/geolite2/eula).
155 changes: 155 additions & 0 deletions packages/geolite/__tests__/geolite.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { getConnections, PgTestClient, snapshot } from 'pgsql-test';

let pg: PgTestClient;
let teardown: () => Promise<void>;

describe('geolite', () => {
beforeAll(async () => {
({ pg, teardown } = await getConnections());
});

afterAll(async () => {
await teardown();
});

beforeEach(async () => {
await pg.beforeEach();
});

afterEach(async () => {
await pg.afterEach();
});

it('should have geolite schema created', async () => {
const schemas = await pg.any(
`SELECT schema_name FROM information_schema.schemata WHERE schema_name = 'geolite'`
);
expect(schemas).toHaveLength(1);
expect(schemas[0].schema_name).toBe('geolite');
});

it('should have network table with correct structure', async () => {
const columns = await pg.any(
`SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_schema = 'geolite' AND table_name = 'network'
ORDER BY ordinal_position`
);
expect(snapshot({ columns })).toMatchSnapshot();
});

it('should have location table with correct structure', async () => {
const columns = await pg.any(
`SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_schema = 'geolite' AND table_name = 'location'
ORDER BY ordinal_position`
);
expect(snapshot({ columns })).toMatchSnapshot();
});

it('should have asn table with correct structure', async () => {
const columns = await pg.any(
`SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_schema = 'geolite' AND table_name = 'asn'
ORDER BY ordinal_position`
);
expect(snapshot({ columns })).toMatchSnapshot();
});

it('should have data_version table with correct structure', async () => {
const columns = await pg.any(
`SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_schema = 'geolite' AND table_name = 'data_version'
ORDER BY ordinal_position`
);
expect(snapshot({ columns })).toMatchSnapshot();
});

it('should have GiST index on network table', async () => {
const indexes = await pg.any(
`SELECT indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'geolite' AND tablename = 'network'`
);
expect(indexes.some((i: any) => i.indexdef.includes('gist'))).toBe(true);
});

it('should have GiST index on asn table', async () => {
const indexes = await pg.any(
`SELECT indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'geolite' AND tablename = 'asn'`
);
expect(indexes.some((i: any) => i.indexdef.includes('gist'))).toBe(true);
});

it('should have lookup function', async () => {
const funcs = await pg.any(
`SELECT routine_name
FROM information_schema.routines
WHERE routine_schema = 'geolite' AND routine_name = 'lookup'`
);
expect(funcs).toHaveLength(1);
});

it('should have lookup_asn function', async () => {
const funcs = await pg.any(
`SELECT routine_name
FROM information_schema.routines
WHERE routine_schema = 'geolite' AND routine_name = 'lookup_asn'`
);
expect(funcs).toHaveLength(1);
});

it('should grant SELECT on all tables to public', async () => {
for (const table of ['network', 'location', 'asn', 'data_version']) {
const result = await pg.any(
`SELECT has_table_privilege('public', 'geolite.${table}', 'SELECT') AS has_priv`
);
expect(result[0].has_priv).toBe(true);
}
});

it('lookup should return empty when no data loaded', async () => {
const result = await pg.any(
`SELECT * FROM geolite.lookup('8.8.8.8'::inet)`
);
expect(result).toHaveLength(0);
});

it('should handle CIDR lookups on network table', async () => {
await pg.any(
`INSERT INTO geolite.network (network, geoname_id, latitude, longitude, accuracy_radius)
VALUES ('8.8.8.0/24', 6252001, 37.751, -97.822, 1000)`
);

const result = await pg.any(
`SELECT network, geoname_id, latitude, longitude
FROM geolite.network
WHERE network >>= '8.8.8.8'::inet`
);
expect(result).toHaveLength(1);
expect(result[0].geoname_id).toBe(6252001);
});

it('should join network and location via lookup function', async () => {
await pg.any(
`INSERT INTO geolite.location (geoname_id, locale_code, country_iso_code, country_name, city_name, time_zone)
VALUES (6252001, 'en', 'US', 'United States', 'Mountain View', 'America/Los_Angeles')`
);
await pg.any(
`INSERT INTO geolite.network (network, geoname_id, latitude, longitude, accuracy_radius)
VALUES ('8.8.8.0/24', 6252001, 37.386, -122.084, 1000)`
);

const result = await pg.any(
`SELECT * FROM geolite.lookup('8.8.8.8'::inet)`
);
expect(result).toHaveLength(1);
expect(result[0].country_iso_code).toBe('US');
expect(result[0].city_name).toBe('Mountain View');
});
});
50 changes: 50 additions & 0 deletions packages/geolite/deploy/schemas/geolite/procedures/lookup.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
-- Deploy schemas/geolite/procedures/lookup to pg

-- requires: schemas/geolite/schema
-- requires: schemas/geolite/tables/network/table
-- requires: schemas/geolite/tables/location/table

BEGIN;

CREATE FUNCTION geolite.lookup(ip inet)
RETURNS TABLE (
network cidr,
country_iso_code text,
country_name text,
subdivision_1_name text,
city_name text,
postal_code text,
latitude numeric,
longitude numeric,
accuracy_radius int,
time_zone text,
continent_code text,
is_in_european_union bool
)
AS $$
SELECT
n.network,
l.country_iso_code,
l.country_name,
l.subdivision_1_name,
l.city_name,
n.postal_code,
n.latitude,
n.longitude,
n.accuracy_radius,
l.time_zone,
l.continent_code,
l.is_in_european_union
FROM geolite.network n
LEFT JOIN geolite.location l
ON n.geoname_id = l.geoname_id
AND l.locale_code = 'en'
WHERE n.network >>= ip
LIMIT 1;
$$ LANGUAGE sql STABLE;

COMMENT ON FUNCTION geolite.lookup(inet) IS 'Look up city/country geolocation data for an IP address';

GRANT EXECUTE ON FUNCTION geolite.lookup(inet) TO public;

COMMIT;
25 changes: 25 additions & 0 deletions packages/geolite/deploy/schemas/geolite/procedures/lookup_asn.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
-- Deploy schemas/geolite/procedures/lookup_asn to pg

-- requires: schemas/geolite/schema
-- requires: schemas/geolite/tables/asn/table

BEGIN;

CREATE FUNCTION geolite.lookup_asn(ip inet)
RETURNS TABLE (
network cidr,
autonomous_system_number int,
autonomous_system_organization text
)
AS $$
SELECT network, autonomous_system_number, autonomous_system_organization
FROM geolite.asn
WHERE network >>= ip
LIMIT 1;
$$ LANGUAGE sql STABLE;

COMMENT ON FUNCTION geolite.lookup_asn(inet) IS 'Look up autonomous system number and organization for an IP address';

GRANT EXECUTE ON FUNCTION geolite.lookup_asn(inet) TO public;

COMMIT;
7 changes: 7 additions & 0 deletions packages/geolite/deploy/schemas/geolite/schema.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- Deploy schemas/geolite/schema to pg

BEGIN;

CREATE SCHEMA geolite;

COMMIT;
Loading
Loading