@fonderie/geo 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/brain/outcomes.md +1 -0
- package/brain/signatures.md +9 -1
- package/dist/index.cjs +97 -68
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +19 -13
- package/dist/index.d.ts +19 -13
- package/dist/index.js +96 -69
- package/dist/index.js.map +1 -1
- package/dist/migrations/sql/001_geo.sql +3 -0
- package/package.json +2 -2
package/brain/outcomes.md
CHANGED
package/brain/signatures.md
CHANGED
|
@@ -11,7 +11,7 @@ new PostgresGeoProvider(store: Queryable): PostgresGeoProvider
|
|
|
11
11
|
.name: "postgres"
|
|
12
12
|
.lookup(ip: string): Promise<GeoLocation | null>
|
|
13
13
|
|
|
14
|
-
function loadMaxMindCity(store:
|
|
14
|
+
function loadMaxMindCity(store: TxStore, files: { locationsPath: string; blocksV4Path?: string; blocksV6Path?: string; }): Promise<{ names: number; blocks: number; }>
|
|
15
15
|
|
|
16
16
|
function ingestNames(store: Queryable, rows: NameRow[]): Promise<number>
|
|
17
17
|
|
|
@@ -23,6 +23,10 @@ function parseLocationsCsv(text: string): NameRow[]
|
|
|
23
23
|
|
|
24
24
|
function parseCsvLine(line: string): string[]
|
|
25
25
|
|
|
26
|
+
function blockRowFromLine(line: string): BlockRow | null
|
|
27
|
+
|
|
28
|
+
function nameRowFromLine(line: string): NameRow | null
|
|
29
|
+
|
|
26
30
|
interface BlockRow {
|
|
27
31
|
network: string;
|
|
28
32
|
geonameId: number | null;
|
|
@@ -42,6 +46,10 @@ interface NameRow {
|
|
|
42
46
|
timeZone: string | null;
|
|
43
47
|
}
|
|
44
48
|
|
|
49
|
+
interface TxStore extends Queryable {
|
|
50
|
+
transaction<T>(fn: (tx: Queryable) => Promise<T>): Promise<T>;
|
|
51
|
+
}
|
|
52
|
+
|
|
45
53
|
interface GeoLocation {
|
|
46
54
|
country: string | null;
|
|
47
55
|
countryName: string | null;
|
package/dist/index.cjs
CHANGED
|
@@ -21,9 +21,11 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
23
|
PostgresGeoProvider: () => PostgresGeoProvider,
|
|
24
|
+
blockRowFromLine: () => blockRowFromLine,
|
|
24
25
|
ingestBlocks: () => ingestBlocks,
|
|
25
26
|
ingestNames: () => ingestNames,
|
|
26
27
|
loadMaxMindCity: () => loadMaxMindCity,
|
|
28
|
+
nameRowFromLine: () => nameRowFromLine,
|
|
27
29
|
parseBlocksCsv: () => parseBlocksCsv,
|
|
28
30
|
parseCsvLine: () => parseCsvLine,
|
|
29
31
|
parseLocationsCsv: () => parseLocationsCsv
|
|
@@ -32,6 +34,12 @@ module.exports = __toCommonJS(index_exports);
|
|
|
32
34
|
|
|
33
35
|
// src/provider.ts
|
|
34
36
|
var LOOKS_LIKE_IP = /^[0-9a-fA-F:.]+$/;
|
|
37
|
+
var V4_MAPPED = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i;
|
|
38
|
+
var PG_INVALID_TEXT = "22P02";
|
|
39
|
+
function normalizeIp(ip) {
|
|
40
|
+
const m = V4_MAPPED.exec(ip);
|
|
41
|
+
return m ? m[1] : ip;
|
|
42
|
+
}
|
|
35
43
|
var PostgresGeoProvider = class {
|
|
36
44
|
constructor(store) {
|
|
37
45
|
this.store = store;
|
|
@@ -39,8 +47,9 @@ var PostgresGeoProvider = class {
|
|
|
39
47
|
store;
|
|
40
48
|
name = "postgres";
|
|
41
49
|
async lookup(ip) {
|
|
42
|
-
const
|
|
43
|
-
if (!
|
|
50
|
+
const raw = (ip ?? "").trim();
|
|
51
|
+
if (!raw || raw.length > 45 || !LOOKS_LIKE_IP.test(raw)) return null;
|
|
52
|
+
const addr = normalizeIp(raw);
|
|
44
53
|
try {
|
|
45
54
|
const rows = await this.store.query(
|
|
46
55
|
`SELECT n.country_iso, n.country_name, n.subdivision_iso, n.subdivision_name,
|
|
@@ -67,7 +76,10 @@ var PostgresGeoProvider = class {
|
|
|
67
76
|
longitude: r.longitude != null ? Number(r.longitude) : null,
|
|
68
77
|
accuracyRadius: r.accuracy_radius != null ? Number(r.accuracy_radius) : null
|
|
69
78
|
};
|
|
70
|
-
} catch {
|
|
79
|
+
} catch (err) {
|
|
80
|
+
if (err?.code !== PG_INVALID_TEXT) {
|
|
81
|
+
console.error("@fonderie/geo: lookup failed (returning null):", err);
|
|
82
|
+
}
|
|
71
83
|
return null;
|
|
72
84
|
}
|
|
73
85
|
}
|
|
@@ -75,6 +87,7 @@ var PostgresGeoProvider = class {
|
|
|
75
87
|
|
|
76
88
|
// src/ingest.ts
|
|
77
89
|
var import_node_fs = require("fs");
|
|
90
|
+
var import_node_readline = require("readline");
|
|
78
91
|
function parseCsvLine(line) {
|
|
79
92
|
const out = [];
|
|
80
93
|
let field = "";
|
|
@@ -103,94 +116,110 @@ var num = (s) => {
|
|
|
103
116
|
return Number.isFinite(n) ? n : null;
|
|
104
117
|
};
|
|
105
118
|
var str = (s) => s == null || s === "" ? null : s;
|
|
119
|
+
function blockRowFromLine(line) {
|
|
120
|
+
const c = parseCsvLine(line);
|
|
121
|
+
if (!c[0]) return null;
|
|
122
|
+
return {
|
|
123
|
+
network: c[0],
|
|
124
|
+
// Fall back to the registered-country geoname when the block has no
|
|
125
|
+
// city-level geoname (MaxMind's documented behavior) — otherwise a large
|
|
126
|
+
// slice of the address space loses all country resolution.
|
|
127
|
+
geonameId: num(c[1]) ?? num(c[2]),
|
|
128
|
+
latitude: num(c[7]),
|
|
129
|
+
longitude: num(c[8]),
|
|
130
|
+
accuracyRadius: num(c[9])
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function nameRowFromLine(line) {
|
|
134
|
+
const c = parseCsvLine(line);
|
|
135
|
+
const id = num(c[0]);
|
|
136
|
+
if (id == null) return null;
|
|
137
|
+
return {
|
|
138
|
+
geonameId: id,
|
|
139
|
+
continentCode: str(c[2]),
|
|
140
|
+
countryIso: str(c[4]),
|
|
141
|
+
countryName: str(c[5]),
|
|
142
|
+
subdivisionIso: str(c[6]),
|
|
143
|
+
subdivisionName: str(c[7]),
|
|
144
|
+
cityName: str(c[10]),
|
|
145
|
+
timeZone: str(c[12])
|
|
146
|
+
};
|
|
147
|
+
}
|
|
106
148
|
function parseBlocksCsv(text) {
|
|
107
|
-
|
|
108
|
-
const lines = text.split(/\r?\n/);
|
|
109
|
-
for (let i = 1; i < lines.length; i++) {
|
|
110
|
-
const line = lines[i];
|
|
111
|
-
if (!line) continue;
|
|
112
|
-
const c = parseCsvLine(line);
|
|
113
|
-
if (!c[0]) continue;
|
|
114
|
-
rows.push({
|
|
115
|
-
network: c[0],
|
|
116
|
-
geonameId: num(c[1]),
|
|
117
|
-
latitude: num(c[7]),
|
|
118
|
-
longitude: num(c[8]),
|
|
119
|
-
accuracyRadius: num(c[9])
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
return rows;
|
|
149
|
+
return text.split(/\r?\n/).slice(1).map(blockRowFromLine).filter((r) => r !== null);
|
|
123
150
|
}
|
|
124
151
|
function parseLocationsCsv(text) {
|
|
125
|
-
|
|
126
|
-
const lines = text.split(/\r?\n/);
|
|
127
|
-
for (let i = 1; i < lines.length; i++) {
|
|
128
|
-
const line = lines[i];
|
|
129
|
-
if (!line) continue;
|
|
130
|
-
const c = parseCsvLine(line);
|
|
131
|
-
const id = num(c[0]);
|
|
132
|
-
if (id == null) continue;
|
|
133
|
-
rows.push({
|
|
134
|
-
geonameId: id,
|
|
135
|
-
continentCode: str(c[2]),
|
|
136
|
-
countryIso: str(c[4]),
|
|
137
|
-
countryName: str(c[5]),
|
|
138
|
-
subdivisionIso: str(c[6]),
|
|
139
|
-
subdivisionName: str(c[7]),
|
|
140
|
-
cityName: str(c[10]),
|
|
141
|
-
timeZone: str(c[12])
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
return rows;
|
|
152
|
+
return text.split(/\r?\n/).slice(1).map(nameRowFromLine).filter((r) => r !== null);
|
|
145
153
|
}
|
|
146
154
|
var CHUNK = 500;
|
|
147
|
-
async function insertChunked(store, rows, cols, sqlHead, toParams) {
|
|
155
|
+
async function insertChunked(store, rows, cols, sqlHead, onConflict, toParams) {
|
|
148
156
|
let n = 0;
|
|
149
157
|
for (let i = 0; i < rows.length; i += CHUNK) {
|
|
150
158
|
const batch = rows.slice(i, i + CHUNK);
|
|
151
159
|
const values = batch.map((_, b) => `(${Array.from({ length: cols }, (_2, k) => `$${b * cols + k + 1}`).join(", ")})`).join(", ");
|
|
152
|
-
|
|
153
|
-
await store.query(`${sqlHead} VALUES ${values}`, params);
|
|
160
|
+
await store.query(`${sqlHead} VALUES ${values} ${onConflict}`, batch.flatMap(toParams));
|
|
154
161
|
n += batch.length;
|
|
155
162
|
}
|
|
156
163
|
return n;
|
|
157
164
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
return c;
|
|
167
|
-
});
|
|
165
|
+
var NAMES_HEAD = "INSERT INTO geo_names (geoname_id, continent_code, country_iso, country_name, subdivision_iso, subdivision_name, city_name, time_zone)";
|
|
166
|
+
var NAMES_CONFLICT = "ON CONFLICT (geoname_id) DO UPDATE SET continent_code = EXCLUDED.continent_code, country_iso = EXCLUDED.country_iso, country_name = EXCLUDED.country_name, subdivision_iso = EXCLUDED.subdivision_iso, subdivision_name = EXCLUDED.subdivision_name, city_name = EXCLUDED.city_name, time_zone = EXCLUDED.time_zone";
|
|
167
|
+
var nameParams = (r) => [r.geonameId, r.continentCode, r.countryIso, r.countryName, r.subdivisionIso, r.subdivisionName, r.cityName, r.timeZone];
|
|
168
|
+
var BLOCKS_HEAD = "INSERT INTO geo_blocks (network, geoname_id, latitude, longitude, accuracy_radius)";
|
|
169
|
+
var BLOCKS_CONFLICT = "ON CONFLICT (network) DO UPDATE SET geoname_id = EXCLUDED.geoname_id, latitude = EXCLUDED.latitude, longitude = EXCLUDED.longitude, accuracy_radius = EXCLUDED.accuracy_radius";
|
|
170
|
+
var blockParams = (r) => [r.network, r.geonameId, r.latitude, r.longitude, r.accuracyRadius];
|
|
171
|
+
function ingestNames(store, rows) {
|
|
172
|
+
return insertChunked(store, rows, 8, NAMES_HEAD, NAMES_CONFLICT, nameParams);
|
|
168
173
|
}
|
|
169
|
-
|
|
170
|
-
return insertChunked(
|
|
171
|
-
store,
|
|
172
|
-
rows,
|
|
173
|
-
5,
|
|
174
|
-
`INSERT INTO geo_blocks (network, geoname_id, latitude, longitude, accuracy_radius)`,
|
|
175
|
-
(r) => [r.network, r.geonameId, r.latitude, r.longitude, r.accuracyRadius]
|
|
176
|
-
);
|
|
174
|
+
function ingestBlocks(store, rows) {
|
|
175
|
+
return insertChunked(store, rows, 5, BLOCKS_HEAD, BLOCKS_CONFLICT, blockParams);
|
|
177
176
|
}
|
|
178
|
-
async function
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
let
|
|
183
|
-
for (const
|
|
184
|
-
if (
|
|
177
|
+
async function streamInto(path, map, sink) {
|
|
178
|
+
const rl = (0, import_node_readline.createInterface)({ input: (0, import_node_fs.createReadStream)(path, { encoding: "utf8" }), crlfDelay: Infinity });
|
|
179
|
+
let total = 0;
|
|
180
|
+
let batch = [];
|
|
181
|
+
let first = true;
|
|
182
|
+
for await (const line of rl) {
|
|
183
|
+
if (first) {
|
|
184
|
+
first = false;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (!line) continue;
|
|
188
|
+
const row = map(line);
|
|
189
|
+
if (!row) continue;
|
|
190
|
+
batch.push(row);
|
|
191
|
+
if (batch.length >= CHUNK) {
|
|
192
|
+
await sink(batch);
|
|
193
|
+
total += batch.length;
|
|
194
|
+
batch = [];
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (batch.length) {
|
|
198
|
+
await sink(batch);
|
|
199
|
+
total += batch.length;
|
|
185
200
|
}
|
|
186
|
-
return
|
|
201
|
+
return total;
|
|
202
|
+
}
|
|
203
|
+
async function loadMaxMindCity(store, files) {
|
|
204
|
+
return store.transaction(async (tx) => {
|
|
205
|
+
await tx.query("TRUNCATE geo_blocks");
|
|
206
|
+
await tx.query("TRUNCATE geo_names");
|
|
207
|
+
const names = await streamInto(files.locationsPath, nameRowFromLine, (b) => ingestNames(tx, b));
|
|
208
|
+
let blocks = 0;
|
|
209
|
+
for (const p of [files.blocksV4Path, files.blocksV6Path]) {
|
|
210
|
+
if (p) blocks += await streamInto(p, blockRowFromLine, (b) => ingestBlocks(tx, b));
|
|
211
|
+
}
|
|
212
|
+
return { names, blocks };
|
|
213
|
+
});
|
|
187
214
|
}
|
|
188
215
|
// Annotate the CommonJS export names for ESM import in node:
|
|
189
216
|
0 && (module.exports = {
|
|
190
217
|
PostgresGeoProvider,
|
|
218
|
+
blockRowFromLine,
|
|
191
219
|
ingestBlocks,
|
|
192
220
|
ingestNames,
|
|
193
221
|
loadMaxMindCity,
|
|
222
|
+
nameRowFromLine,
|
|
194
223
|
parseBlocksCsv,
|
|
195
224
|
parseCsvLine,
|
|
196
225
|
parseLocationsCsv
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/provider.ts","../src/ingest.ts"],"sourcesContent":["// @fonderie/geo — self-hosted IP → location.\n//\n// The default PostgresGeoProvider resolves against a table of MaxMind/HE CIDR\n// blocks (native inet/GiST — IPv4 + IPv6, no external API). IGeoProvider is the\n// swap seam for a hosted source later. A signal source for @fonderie/risk and a\n// day-one \"where is this request from\" for any Fonderie app.\n//\n// Run getMigrationsPath()'s SQL with your store's migration runner, then load\n// data with loadMaxMindCity() (or the parse*/ingest* pieces).\nexport { PostgresGeoProvider } from './provider.js';\nexport {\n\tloadMaxMindCity,\n\tingestNames,\n\tingestBlocks,\n\tparseBlocksCsv,\n\tparseLocationsCsv,\n\tparseCsvLine,\n} from './ingest.js';\nexport type { BlockRow, NameRow } from './ingest.js';\nexport type { GeoLocation, IGeoProvider, Queryable } from './types.js';\n","import type { GeoLocation, IGeoProvider, Queryable } from './types.js';\n\n// Cheap guard so obvious garbage never reaches the ::inet cast (which would\n// throw). Not a full validator — the cast is the real gate; this just avoids a\n// round-trip (and an error log) for empty / clearly-non-IP input.\nconst LOOKS_LIKE_IP = /^[0-9a-fA-F:.]+$/;\n\n/**\n * The default, self-hosted provider: resolves an IP against the geo_blocks /\n * geo_names tables loaded from MaxMind/HE CSVs. The lookup is a CIDR\n * containment — the most-specific block that contains the address wins —\n * handling IPv4 and IPv6 uniformly via Postgres's native `cidr`/`inet`.\n */\nexport class PostgresGeoProvider implements IGeoProvider {\n\treadonly name = 'postgres';\n\n\tconstructor(private readonly store: Queryable) {}\n\n\tasync lookup(ip: string): Promise<GeoLocation | null> {\n\t\tconst addr = (ip ?? '').trim();\n\t\tif (!addr || addr.length > 45 || !LOOKS_LIKE_IP.test(addr)) return null;\n\t\ttry {\n\t\t\tconst rows = await this.store.query<{\n\t\t\t\tcountry_iso: string | null;\n\t\t\t\tcountry_name: string | null;\n\t\t\t\tsubdivision_iso: string | null;\n\t\t\t\tsubdivision_name: string | null;\n\t\t\t\tcity_name: string | null;\n\t\t\t\tcontinent_code: string | null;\n\t\t\t\ttime_zone: string | null;\n\t\t\t\tlatitude: number | null;\n\t\t\t\tlongitude: number | null;\n\t\t\t\taccuracy_radius: number | null;\n\t\t\t}>(\n\t\t\t\t`SELECT n.country_iso, n.country_name, n.subdivision_iso, n.subdivision_name,\n\t\t\t\t n.city_name, n.continent_code, n.time_zone,\n\t\t\t\t b.latitude, b.longitude, b.accuracy_radius\n\t\t\t\t FROM geo_blocks b\n\t\t\t\t LEFT JOIN geo_names n ON n.geoname_id = b.geoname_id\n\t\t\t\t WHERE b.network >>= $1::inet\n\t\t\t\t ORDER BY masklen(b.network) DESC\n\t\t\t\t LIMIT 1`,\n\t\t\t\t[addr],\n\t\t\t);\n\t\t\tconst r = rows[0];\n\t\t\tif (!r) return null;\n\t\t\treturn {\n\t\t\t\tcountry: r.country_iso ?? null,\n\t\t\t\tcountryName: r.country_name ?? null,\n\t\t\t\tsubdivision: r.subdivision_iso ?? null,\n\t\t\t\tsubdivisionName: r.subdivision_name ?? null,\n\t\t\t\tcity: r.city_name ?? null,\n\t\t\t\tcontinent: r.continent_code ?? null,\n\t\t\t\ttimeZone: r.time_zone ?? null,\n\t\t\t\tlatitude: r.latitude != null ? Number(r.latitude) : null,\n\t\t\t\tlongitude: r.longitude != null ? Number(r.longitude) : null,\n\t\t\t\taccuracyRadius: r.accuracy_radius != null ? Number(r.accuracy_radius) : null,\n\t\t\t};\n\t\t} catch {\n\t\t\t// Invalid inet (bad cast) or a transient store error → unknown, not a throw.\n\t\t\treturn null;\n\t\t}\n\t}\n}\n","// Load MaxMind GeoLite2 City CSVs (the same files the prior arbinuity importer\n// used) into geo_blocks + geo_names. Hurricane Electric / other sources work\n// too as long as rows map to {network, geoname_id, lat, lng, accuracy} and\n// {geoname_id, country, subdivision, city}.\nimport { readFileSync } from 'node:fs';\nimport type { Queryable } from './types.js';\n\nexport interface BlockRow {\n\tnetwork: string;\n\tgeonameId: number | null;\n\tlatitude: number | null;\n\tlongitude: number | null;\n\taccuracyRadius: number | null;\n}\n\nexport interface NameRow {\n\tgeonameId: number;\n\tcontinentCode: string | null;\n\tcountryIso: string | null;\n\tcountryName: string | null;\n\tsubdivisionIso: string | null;\n\tsubdivisionName: string | null;\n\tcityName: string | null;\n\ttimeZone: string | null;\n}\n\n/** RFC4180-ish single-line parser: handles quoted fields, embedded commas, and\n * \"\" escaped quotes (MaxMind city names like \"Washington, D.C.\" need this). */\nexport function parseCsvLine(line: string): string[] {\n\tconst out: string[] = [];\n\tlet field = '';\n\tlet inQuotes = false;\n\tfor (let i = 0; i < line.length; i++) {\n\t\tconst c = line[i];\n\t\tif (inQuotes) {\n\t\t\tif (c === '\"') {\n\t\t\t\tif (line[i + 1] === '\"') { field += '\"'; i++; } else inQuotes = false;\n\t\t\t} else field += c;\n\t\t} else if (c === '\"') inQuotes = true;\n\t\telse if (c === ',') { out.push(field); field = ''; }\n\t\telse field += c;\n\t}\n\tout.push(field);\n\treturn out;\n}\n\nconst num = (s: string | undefined): number | null => {\n\tif (s == null || s === '') return null;\n\tconst n = Number(s);\n\treturn Number.isFinite(n) ? n : null;\n};\nconst str = (s: string | undefined): string | null => (s == null || s === '' ? null : s);\n\n/** Parse a GeoLite2-City-Blocks-IPv4/IPv6 CSV (header row skipped). Columns:\n * network, geoname_id, registered_country_geoname_id, represented_country_geoname_id,\n * is_anonymous_proxy, is_satellite_provider, postal_code, latitude, longitude, accuracy_radius. */\nexport function parseBlocksCsv(text: string): BlockRow[] {\n\tconst rows: BlockRow[] = [];\n\tconst lines = text.split(/\\r?\\n/);\n\tfor (let i = 1; i < lines.length; i++) {\n\t\tconst line = lines[i];\n\t\tif (!line) continue;\n\t\tconst c = parseCsvLine(line);\n\t\tif (!c[0]) continue;\n\t\trows.push({\n\t\t\tnetwork: c[0],\n\t\t\tgeonameId: num(c[1]),\n\t\t\tlatitude: num(c[7]),\n\t\t\tlongitude: num(c[8]),\n\t\t\taccuracyRadius: num(c[9]),\n\t\t});\n\t}\n\treturn rows;\n}\n\n/** Parse a GeoLite2-City-Locations-<locale> CSV (header row skipped). Columns:\n * geoname_id, locale_code, continent_code, continent_name, country_iso_code,\n * country_name, subdivision_1_iso_code, subdivision_1_name, subdivision_2_iso_code,\n * subdivision_2_name, city_name, metro_code, time_zone, is_in_european_union. */\nexport function parseLocationsCsv(text: string): NameRow[] {\n\tconst rows: NameRow[] = [];\n\tconst lines = text.split(/\\r?\\n/);\n\tfor (let i = 1; i < lines.length; i++) {\n\t\tconst line = lines[i];\n\t\tif (!line) continue;\n\t\tconst c = parseCsvLine(line);\n\t\tconst id = num(c[0]);\n\t\tif (id == null) continue;\n\t\trows.push({\n\t\t\tgeonameId: id,\n\t\t\tcontinentCode: str(c[2]),\n\t\t\tcountryIso: str(c[4]),\n\t\t\tcountryName: str(c[5]),\n\t\t\tsubdivisionIso: str(c[6]),\n\t\t\tsubdivisionName: str(c[7]),\n\t\t\tcityName: str(c[10]),\n\t\t\ttimeZone: str(c[12]),\n\t\t});\n\t}\n\treturn rows;\n}\n\nconst CHUNK = 500;\n\nasync function insertChunked<T>(\n\tstore: Queryable,\n\trows: T[],\n\tcols: number,\n\tsqlHead: string,\n\ttoParams: (r: T) => unknown[],\n): Promise<number> {\n\tlet n = 0;\n\tfor (let i = 0; i < rows.length; i += CHUNK) {\n\t\tconst batch = rows.slice(i, i + CHUNK);\n\t\tconst values = batch\n\t\t\t.map((_, b) => `(${Array.from({ length: cols }, (_, k) => `$${b * cols + k + 1}`).join(', ')})`)\n\t\t\t.join(', ');\n\t\tconst params = batch.flatMap(toParams);\n\t\tawait store.query(`${sqlHead} VALUES ${values}`, params);\n\t\tn += batch.length;\n\t}\n\treturn n;\n}\n\nexport async function ingestNames(store: Queryable, rows: NameRow[]): Promise<number> {\n\treturn insertChunked(\n\t\tstore,\n\t\trows,\n\t\t8,\n\t\t`INSERT INTO geo_names (geoname_id, continent_code, country_iso, country_name, subdivision_iso, subdivision_name, city_name, time_zone)`,\n\t\t(r) => [r.geonameId, r.continentCode, r.countryIso, r.countryName, r.subdivisionIso, r.subdivisionName, r.cityName, r.timeZone],\n\t).then(async (c) => {\n\t\t// geo_names is a PK table; a re-run would conflict. Caller truncates first\n\t\t// for a full reload; this keeps ingest itself simple and idempotent-free.\n\t\treturn c;\n\t});\n}\n\nexport async function ingestBlocks(store: Queryable, rows: BlockRow[]): Promise<number> {\n\treturn insertChunked(\n\t\tstore,\n\t\trows,\n\t\t5,\n\t\t`INSERT INTO geo_blocks (network, geoname_id, latitude, longitude, accuracy_radius)`,\n\t\t(r) => [r.network, r.geonameId, r.latitude, r.longitude, r.accuracyRadius],\n\t);\n}\n\n/** Full load from MaxMind City CSV files. Truncates first so a reload is a\n * clean replace (the dataset is a full snapshot, not a delta). */\nexport async function loadMaxMindCity(\n\tstore: Queryable,\n\tfiles: { locationsPath: string; blocksV4Path?: string; blocksV6Path?: string },\n): Promise<{ names: number; blocks: number }> {\n\tawait store.query('TRUNCATE geo_blocks');\n\tawait store.query('TRUNCATE geo_names');\n\tconst names = await ingestNames(store, parseLocationsCsv(readFileSync(files.locationsPath, 'utf8')));\n\tlet blocks = 0;\n\tfor (const p of [files.blocksV4Path, files.blocksV6Path]) {\n\t\tif (p) blocks += await ingestBlocks(store, parseBlocksCsv(readFileSync(p, 'utf8')));\n\t}\n\treturn { names, blocks };\n}\n"],"mappings":"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","names":["_"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/provider.ts","../src/ingest.ts"],"sourcesContent":["// @fonderie/geo — self-hosted IP → location.\n//\n// The default PostgresGeoProvider resolves against a table of MaxMind/HE CIDR\n// blocks (native inet/GiST — IPv4 + IPv6, no external API). IGeoProvider is the\n// swap seam for a hosted source later. A signal source for @fonderie/risk and a\n// day-one \"where is this request from\" for any Fonderie app.\n//\n// Run getMigrationsPath()'s SQL with your store's migration runner, then load\n// data with loadMaxMindCity() (or the parse*/ingest* pieces).\nexport { PostgresGeoProvider } from './provider.js';\nexport {\n\tloadMaxMindCity,\n\tingestNames,\n\tingestBlocks,\n\tparseBlocksCsv,\n\tparseLocationsCsv,\n\tparseCsvLine,\n\tblockRowFromLine,\n\tnameRowFromLine,\n} from './ingest.js';\nexport type { BlockRow, NameRow, TxStore } from './ingest.js';\nexport type { GeoLocation, IGeoProvider, Queryable } from './types.js';\n","import type { GeoLocation, IGeoProvider, Queryable } from './types.js';\n\n// Cheap guard so obvious garbage never reaches the ::inet cast (which would\n// throw). Not a full validator — the cast is the real gate; this just avoids a\n// round-trip for empty / clearly-non-IP input.\nconst LOOKS_LIKE_IP = /^[0-9a-fA-F:.]+$/;\n// IPv4-mapped IPv6 (::ffff:a.b.c.d) is an IPv4 address wearing a v6 hat. Node's\n// socket.remoteAddress returns this on dual-stack servers, and it casts to an\n// AF_INET6 inet that never matches IPv4 `cidr` blocks — so without this the\n// lookup silently returns null for the bulk of real traffic. Mirrors\n// @fonderie/core's resolveClientIp, which strips the same prefix upstream.\nconst V4_MAPPED = /^::ffff:(\\d{1,3}(?:\\.\\d{1,3}){3})$/i;\n// Postgres 22P02 = invalid_text_representation — i.e. a bad ::inet cast. That\n// is an \"unknown/invalid IP\" (expected → quiet null); anything else is a real\n// fault we must NOT hide behind null.\nconst PG_INVALID_TEXT = '22P02';\n\nfunction normalizeIp(ip: string): string {\n\tconst m = V4_MAPPED.exec(ip);\n\treturn m ? (m[1] as string) : ip;\n}\n\n/**\n * The default, self-hosted provider: resolves an IP against the geo_blocks /\n * geo_names tables loaded from MaxMind/HE CSVs. The lookup is a CIDR\n * containment — the most-specific block that contains the address wins —\n * handling IPv4 and IPv6 uniformly via Postgres's native `cidr`/`inet`.\n */\nexport class PostgresGeoProvider implements IGeoProvider {\n\treadonly name = 'postgres';\n\n\tconstructor(private readonly store: Queryable) {}\n\n\tasync lookup(ip: string): Promise<GeoLocation | null> {\n\t\tconst raw = (ip ?? '').trim();\n\t\tif (!raw || raw.length > 45 || !LOOKS_LIKE_IP.test(raw)) return null;\n\t\tconst addr = normalizeIp(raw); // ::ffff:1.2.3.4 → 1.2.3.4, so it matches IPv4 blocks\n\t\ttry {\n\t\t\tconst rows = await this.store.query<{\n\t\t\t\tcountry_iso: string | null;\n\t\t\t\tcountry_name: string | null;\n\t\t\t\tsubdivision_iso: string | null;\n\t\t\t\tsubdivision_name: string | null;\n\t\t\t\tcity_name: string | null;\n\t\t\t\tcontinent_code: string | null;\n\t\t\t\ttime_zone: string | null;\n\t\t\t\tlatitude: number | null;\n\t\t\t\tlongitude: number | null;\n\t\t\t\taccuracy_radius: number | null;\n\t\t\t}>(\n\t\t\t\t`SELECT n.country_iso, n.country_name, n.subdivision_iso, n.subdivision_name,\n\t\t\t\t n.city_name, n.continent_code, n.time_zone,\n\t\t\t\t b.latitude, b.longitude, b.accuracy_radius\n\t\t\t\t FROM geo_blocks b\n\t\t\t\t LEFT JOIN geo_names n ON n.geoname_id = b.geoname_id\n\t\t\t\t WHERE b.network >>= $1::inet\n\t\t\t\t ORDER BY masklen(b.network) DESC\n\t\t\t\t LIMIT 1`,\n\t\t\t\t[addr],\n\t\t\t);\n\t\t\tconst r = rows[0];\n\t\t\tif (!r) return null;\n\t\t\treturn {\n\t\t\t\tcountry: r.country_iso ?? null,\n\t\t\t\tcountryName: r.country_name ?? null,\n\t\t\t\tsubdivision: r.subdivision_iso ?? null,\n\t\t\t\tsubdivisionName: r.subdivision_name ?? null,\n\t\t\t\tcity: r.city_name ?? null,\n\t\t\t\tcontinent: r.continent_code ?? null,\n\t\t\t\ttimeZone: r.time_zone ?? null,\n\t\t\t\tlatitude: r.latitude != null ? Number(r.latitude) : null,\n\t\t\t\tlongitude: r.longitude != null ? Number(r.longitude) : null,\n\t\t\t\taccuracyRadius: r.accuracy_radius != null ? Number(r.accuracy_radius) : null,\n\t\t\t};\n\t\t} catch (err) {\n\t\t\t// A bad ::inet cast means the input wasn't a real IP → quiet null (expected).\n\t\t\t// Anything else (connection drop, a latent query bug) is a real fault: we\n\t\t\t// still return null so a caller isn't crashed, but we LOG it — silently\n\t\t\t// swallowing it would let a risk gate fail open during an outage with no\n\t\t\t// trace, and would hide query regressions the fake-store unit tests can't.\n\t\t\tif ((err as { code?: string })?.code !== PG_INVALID_TEXT) {\n\t\t\t\tconsole.error('@fonderie/geo: lookup failed (returning null):', err);\n\t\t\t}\n\t\t\treturn null;\n\t\t}\n\t}\n}\n","// Load MaxMind GeoLite2 City CSVs (the same files the prior arbinuity importer\n// used) into geo_blocks + geo_names. Hurricane Electric / other sources work\n// too as long as rows map to {network, geoname_id, lat, lng, accuracy} and\n// {geoname_id, country, subdivision, city}.\n//\n// NOTE: parsing is line-oriented (split on newlines, then parse quotes per\n// line). MaxMind City fields never contain newlines, so this is safe for that\n// data; a source with RFC4180 newlines *inside* quoted fields is not supported.\nimport { createReadStream } from 'node:fs';\nimport { createInterface } from 'node:readline';\nimport type { Queryable } from './types.js';\n\n/** A store that can open a single-connection transaction — required for an\n * atomic reload (truncate + insert must not half-apply). */\nexport interface TxStore extends Queryable {\n\ttransaction<T>(fn: (tx: Queryable) => Promise<T>): Promise<T>;\n}\n\nexport interface BlockRow {\n\tnetwork: string;\n\tgeonameId: number | null;\n\tlatitude: number | null;\n\tlongitude: number | null;\n\taccuracyRadius: number | null;\n}\n\nexport interface NameRow {\n\tgeonameId: number;\n\tcontinentCode: string | null;\n\tcountryIso: string | null;\n\tcountryName: string | null;\n\tsubdivisionIso: string | null;\n\tsubdivisionName: string | null;\n\tcityName: string | null;\n\ttimeZone: string | null;\n}\n\n/** RFC4180-ish single-line parser: quoted fields, embedded commas, \"\" escapes. */\nexport function parseCsvLine(line: string): string[] {\n\tconst out: string[] = [];\n\tlet field = '';\n\tlet inQuotes = false;\n\tfor (let i = 0; i < line.length; i++) {\n\t\tconst c = line[i];\n\t\tif (inQuotes) {\n\t\t\tif (c === '\"') {\n\t\t\t\tif (line[i + 1] === '\"') { field += '\"'; i++; } else inQuotes = false;\n\t\t\t} else field += c;\n\t\t} else if (c === '\"') inQuotes = true;\n\t\telse if (c === ',') { out.push(field); field = ''; }\n\t\telse field += c;\n\t}\n\tout.push(field);\n\treturn out;\n}\n\nconst num = (s: string | undefined): number | null => {\n\tif (s == null || s === '') return null;\n\tconst n = Number(s);\n\treturn Number.isFinite(n) ? n : null;\n};\nconst str = (s: string | undefined): string | null => (s == null || s === '' ? null : s);\n\n// Blocks columns: network, geoname_id, registered_country_geoname_id,\n// represented_country_geoname_id, is_anonymous_proxy, is_satellite_provider,\n// postal_code, latitude, longitude, accuracy_radius.\nexport function blockRowFromLine(line: string): BlockRow | null {\n\tconst c = parseCsvLine(line);\n\tif (!c[0]) return null;\n\treturn {\n\t\tnetwork: c[0],\n\t\t// Fall back to the registered-country geoname when the block has no\n\t\t// city-level geoname (MaxMind's documented behavior) — otherwise a large\n\t\t// slice of the address space loses all country resolution.\n\t\tgeonameId: num(c[1]) ?? num(c[2]),\n\t\tlatitude: num(c[7]),\n\t\tlongitude: num(c[8]),\n\t\taccuracyRadius: num(c[9]),\n\t};\n}\n\n// Locations columns: geoname_id, locale_code, continent_code, continent_name,\n// country_iso_code, country_name, subdivision_1_iso_code, subdivision_1_name,\n// subdivision_2_iso_code, subdivision_2_name, city_name, metro_code, time_zone,\n// is_in_european_union.\nexport function nameRowFromLine(line: string): NameRow | null {\n\tconst c = parseCsvLine(line);\n\tconst id = num(c[0]);\n\tif (id == null) return null;\n\treturn {\n\t\tgeonameId: id,\n\t\tcontinentCode: str(c[2]),\n\t\tcountryIso: str(c[4]),\n\t\tcountryName: str(c[5]),\n\t\tsubdivisionIso: str(c[6]),\n\t\tsubdivisionName: str(c[7]),\n\t\tcityName: str(c[10]),\n\t\ttimeZone: str(c[12]),\n\t};\n}\n\n/** Parse a whole Blocks CSV string (header skipped). For tests / small inputs;\n * loadMaxMindCity streams the real multi-hundred-MB files instead. */\nexport function parseBlocksCsv(text: string): BlockRow[] {\n\treturn text.split(/\\r?\\n/).slice(1).map(blockRowFromLine).filter((r): r is BlockRow => r !== null);\n}\n\nexport function parseLocationsCsv(text: string): NameRow[] {\n\treturn text.split(/\\r?\\n/).slice(1).map(nameRowFromLine).filter((r): r is NameRow => r !== null);\n}\n\nconst CHUNK = 500;\n\nasync function insertChunked<T>(\n\tstore: Queryable,\n\trows: T[],\n\tcols: number,\n\tsqlHead: string,\n\tonConflict: string,\n\ttoParams: (r: T) => unknown[],\n): Promise<number> {\n\tlet n = 0;\n\tfor (let i = 0; i < rows.length; i += CHUNK) {\n\t\tconst batch = rows.slice(i, i + CHUNK);\n\t\tconst values = batch\n\t\t\t.map((_, b) => `(${Array.from({ length: cols }, (_, k) => `$${b * cols + k + 1}`).join(', ')})`)\n\t\t\t.join(', ');\n\t\tawait store.query(`${sqlHead} VALUES ${values} ${onConflict}`, batch.flatMap(toParams));\n\t\tn += batch.length;\n\t}\n\treturn n;\n}\n\nconst NAMES_HEAD =\n\t'INSERT INTO geo_names (geoname_id, continent_code, country_iso, country_name, subdivision_iso, subdivision_name, city_name, time_zone)';\nconst NAMES_CONFLICT =\n\t'ON CONFLICT (geoname_id) DO UPDATE SET continent_code = EXCLUDED.continent_code, country_iso = EXCLUDED.country_iso, country_name = EXCLUDED.country_name, subdivision_iso = EXCLUDED.subdivision_iso, subdivision_name = EXCLUDED.subdivision_name, city_name = EXCLUDED.city_name, time_zone = EXCLUDED.time_zone';\nconst nameParams = (r: NameRow) => [r.geonameId, r.continentCode, r.countryIso, r.countryName, r.subdivisionIso, r.subdivisionName, r.cityName, r.timeZone];\n\nconst BLOCKS_HEAD = 'INSERT INTO geo_blocks (network, geoname_id, latitude, longitude, accuracy_radius)';\nconst BLOCKS_CONFLICT =\n\t'ON CONFLICT (network) DO UPDATE SET geoname_id = EXCLUDED.geoname_id, latitude = EXCLUDED.latitude, longitude = EXCLUDED.longitude, accuracy_radius = EXCLUDED.accuracy_radius';\nconst blockParams = (r: BlockRow) => [r.network, r.geonameId, r.latitude, r.longitude, r.accuracyRadius];\n\n/** Upsert name rows (idempotent — safe to call without a prior truncate). */\nexport function ingestNames(store: Queryable, rows: NameRow[]): Promise<number> {\n\treturn insertChunked(store, rows, 8, NAMES_HEAD, NAMES_CONFLICT, nameParams);\n}\n\n/** Upsert block rows (idempotent on `network`). */\nexport function ingestBlocks(store: Queryable, rows: BlockRow[]): Promise<number> {\n\treturn insertChunked(store, rows, 5, BLOCKS_HEAD, BLOCKS_CONFLICT, blockParams);\n}\n\n/** Stream a CSV file line-by-line (header skipped), batching mapped rows — so a\n * multi-hundred-MB MaxMind file never lands in memory as one string. */\nasync function streamInto<T>(path: string, map: (line: string) => T | null, sink: (batch: T[]) => Promise<unknown>): Promise<number> {\n\tconst rl = createInterface({ input: createReadStream(path, { encoding: 'utf8' }), crlfDelay: Infinity });\n\tlet total = 0;\n\tlet batch: T[] = [];\n\tlet first = true;\n\tfor await (const line of rl) {\n\t\tif (first) { first = false; continue; } // header\n\t\tif (!line) continue;\n\t\tconst row = map(line);\n\t\tif (!row) continue;\n\t\tbatch.push(row);\n\t\tif (batch.length >= CHUNK) { await sink(batch); total += batch.length; batch = []; }\n\t}\n\tif (batch.length) { await sink(batch); total += batch.length; }\n\treturn total;\n}\n\n/**\n * Full reload from MaxMind City CSV files — ATOMIC (truncate + all inserts in\n * one transaction, so a mid-load failure rolls back instead of leaving the geo\n * tables empty/partial) and STREAMED (bounded memory on the large Blocks file).\n */\nexport async function loadMaxMindCity(\n\tstore: TxStore,\n\tfiles: { locationsPath: string; blocksV4Path?: string; blocksV6Path?: string },\n): Promise<{ names: number; blocks: number }> {\n\treturn store.transaction(async (tx) => {\n\t\tawait tx.query('TRUNCATE geo_blocks');\n\t\tawait tx.query('TRUNCATE geo_names');\n\t\tconst names = await streamInto(files.locationsPath, nameRowFromLine, (b) => ingestNames(tx, b));\n\t\tlet blocks = 0;\n\t\tfor (const p of [files.blocksV4Path, files.blocksV6Path]) {\n\t\t\tif (p) blocks += await streamInto(p, blockRowFromLine, (b) => ingestBlocks(tx, b));\n\t\t}\n\t\treturn { names, blocks };\n\t});\n}\n"],"mappings":"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","names":["_"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -39,6 +39,11 @@ declare class PostgresGeoProvider implements IGeoProvider {
|
|
|
39
39
|
lookup(ip: string): Promise<GeoLocation | null>;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/** A store that can open a single-connection transaction — required for an
|
|
43
|
+
* atomic reload (truncate + insert must not half-apply). */
|
|
44
|
+
interface TxStore extends Queryable {
|
|
45
|
+
transaction<T>(fn: (tx: Queryable) => Promise<T>): Promise<T>;
|
|
46
|
+
}
|
|
42
47
|
interface BlockRow {
|
|
43
48
|
network: string;
|
|
44
49
|
geonameId: number | null;
|
|
@@ -56,23 +61,24 @@ interface NameRow {
|
|
|
56
61
|
cityName: string | null;
|
|
57
62
|
timeZone: string | null;
|
|
58
63
|
}
|
|
59
|
-
/** RFC4180-ish single-line parser:
|
|
60
|
-
* "" escaped quotes (MaxMind city names like "Washington, D.C." need this). */
|
|
64
|
+
/** RFC4180-ish single-line parser: quoted fields, embedded commas, "" escapes. */
|
|
61
65
|
declare function parseCsvLine(line: string): string[];
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
66
|
+
declare function blockRowFromLine(line: string): BlockRow | null;
|
|
67
|
+
declare function nameRowFromLine(line: string): NameRow | null;
|
|
68
|
+
/** Parse a whole Blocks CSV string (header skipped). For tests / small inputs;
|
|
69
|
+
* loadMaxMindCity streams the real multi-hundred-MB files instead. */
|
|
65
70
|
declare function parseBlocksCsv(text: string): BlockRow[];
|
|
66
|
-
/** Parse a GeoLite2-City-Locations-<locale> CSV (header row skipped). Columns:
|
|
67
|
-
* geoname_id, locale_code, continent_code, continent_name, country_iso_code,
|
|
68
|
-
* country_name, subdivision_1_iso_code, subdivision_1_name, subdivision_2_iso_code,
|
|
69
|
-
* subdivision_2_name, city_name, metro_code, time_zone, is_in_european_union. */
|
|
70
71
|
declare function parseLocationsCsv(text: string): NameRow[];
|
|
72
|
+
/** Upsert name rows (idempotent — safe to call without a prior truncate). */
|
|
71
73
|
declare function ingestNames(store: Queryable, rows: NameRow[]): Promise<number>;
|
|
74
|
+
/** Upsert block rows (idempotent on `network`). */
|
|
72
75
|
declare function ingestBlocks(store: Queryable, rows: BlockRow[]): Promise<number>;
|
|
73
|
-
/**
|
|
74
|
-
*
|
|
75
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Full reload from MaxMind City CSV files — ATOMIC (truncate + all inserts in
|
|
78
|
+
* one transaction, so a mid-load failure rolls back instead of leaving the geo
|
|
79
|
+
* tables empty/partial) and STREAMED (bounded memory on the large Blocks file).
|
|
80
|
+
*/
|
|
81
|
+
declare function loadMaxMindCity(store: TxStore, files: {
|
|
76
82
|
locationsPath: string;
|
|
77
83
|
blocksV4Path?: string;
|
|
78
84
|
blocksV6Path?: string;
|
|
@@ -81,4 +87,4 @@ declare function loadMaxMindCity(store: Queryable, files: {
|
|
|
81
87
|
blocks: number;
|
|
82
88
|
}>;
|
|
83
89
|
|
|
84
|
-
export { type BlockRow, type GeoLocation, type IGeoProvider, type NameRow, PostgresGeoProvider, type Queryable, ingestBlocks, ingestNames, loadMaxMindCity, parseBlocksCsv, parseCsvLine, parseLocationsCsv };
|
|
90
|
+
export { type BlockRow, type GeoLocation, type IGeoProvider, type NameRow, PostgresGeoProvider, type Queryable, type TxStore, blockRowFromLine, ingestBlocks, ingestNames, loadMaxMindCity, nameRowFromLine, parseBlocksCsv, parseCsvLine, parseLocationsCsv };
|
package/dist/index.d.ts
CHANGED
|
@@ -39,6 +39,11 @@ declare class PostgresGeoProvider implements IGeoProvider {
|
|
|
39
39
|
lookup(ip: string): Promise<GeoLocation | null>;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/** A store that can open a single-connection transaction — required for an
|
|
43
|
+
* atomic reload (truncate + insert must not half-apply). */
|
|
44
|
+
interface TxStore extends Queryable {
|
|
45
|
+
transaction<T>(fn: (tx: Queryable) => Promise<T>): Promise<T>;
|
|
46
|
+
}
|
|
42
47
|
interface BlockRow {
|
|
43
48
|
network: string;
|
|
44
49
|
geonameId: number | null;
|
|
@@ -56,23 +61,24 @@ interface NameRow {
|
|
|
56
61
|
cityName: string | null;
|
|
57
62
|
timeZone: string | null;
|
|
58
63
|
}
|
|
59
|
-
/** RFC4180-ish single-line parser:
|
|
60
|
-
* "" escaped quotes (MaxMind city names like "Washington, D.C." need this). */
|
|
64
|
+
/** RFC4180-ish single-line parser: quoted fields, embedded commas, "" escapes. */
|
|
61
65
|
declare function parseCsvLine(line: string): string[];
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
66
|
+
declare function blockRowFromLine(line: string): BlockRow | null;
|
|
67
|
+
declare function nameRowFromLine(line: string): NameRow | null;
|
|
68
|
+
/** Parse a whole Blocks CSV string (header skipped). For tests / small inputs;
|
|
69
|
+
* loadMaxMindCity streams the real multi-hundred-MB files instead. */
|
|
65
70
|
declare function parseBlocksCsv(text: string): BlockRow[];
|
|
66
|
-
/** Parse a GeoLite2-City-Locations-<locale> CSV (header row skipped). Columns:
|
|
67
|
-
* geoname_id, locale_code, continent_code, continent_name, country_iso_code,
|
|
68
|
-
* country_name, subdivision_1_iso_code, subdivision_1_name, subdivision_2_iso_code,
|
|
69
|
-
* subdivision_2_name, city_name, metro_code, time_zone, is_in_european_union. */
|
|
70
71
|
declare function parseLocationsCsv(text: string): NameRow[];
|
|
72
|
+
/** Upsert name rows (idempotent — safe to call without a prior truncate). */
|
|
71
73
|
declare function ingestNames(store: Queryable, rows: NameRow[]): Promise<number>;
|
|
74
|
+
/** Upsert block rows (idempotent on `network`). */
|
|
72
75
|
declare function ingestBlocks(store: Queryable, rows: BlockRow[]): Promise<number>;
|
|
73
|
-
/**
|
|
74
|
-
*
|
|
75
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Full reload from MaxMind City CSV files — ATOMIC (truncate + all inserts in
|
|
78
|
+
* one transaction, so a mid-load failure rolls back instead of leaving the geo
|
|
79
|
+
* tables empty/partial) and STREAMED (bounded memory on the large Blocks file).
|
|
80
|
+
*/
|
|
81
|
+
declare function loadMaxMindCity(store: TxStore, files: {
|
|
76
82
|
locationsPath: string;
|
|
77
83
|
blocksV4Path?: string;
|
|
78
84
|
blocksV6Path?: string;
|
|
@@ -81,4 +87,4 @@ declare function loadMaxMindCity(store: Queryable, files: {
|
|
|
81
87
|
blocks: number;
|
|
82
88
|
}>;
|
|
83
89
|
|
|
84
|
-
export { type BlockRow, type GeoLocation, type IGeoProvider, type NameRow, PostgresGeoProvider, type Queryable, ingestBlocks, ingestNames, loadMaxMindCity, parseBlocksCsv, parseCsvLine, parseLocationsCsv };
|
|
90
|
+
export { type BlockRow, type GeoLocation, type IGeoProvider, type NameRow, PostgresGeoProvider, type Queryable, type TxStore, blockRowFromLine, ingestBlocks, ingestNames, loadMaxMindCity, nameRowFromLine, parseBlocksCsv, parseCsvLine, parseLocationsCsv };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
// src/provider.ts
|
|
2
2
|
var LOOKS_LIKE_IP = /^[0-9a-fA-F:.]+$/;
|
|
3
|
+
var V4_MAPPED = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i;
|
|
4
|
+
var PG_INVALID_TEXT = "22P02";
|
|
5
|
+
function normalizeIp(ip) {
|
|
6
|
+
const m = V4_MAPPED.exec(ip);
|
|
7
|
+
return m ? m[1] : ip;
|
|
8
|
+
}
|
|
3
9
|
var PostgresGeoProvider = class {
|
|
4
10
|
constructor(store) {
|
|
5
11
|
this.store = store;
|
|
@@ -7,8 +13,9 @@ var PostgresGeoProvider = class {
|
|
|
7
13
|
store;
|
|
8
14
|
name = "postgres";
|
|
9
15
|
async lookup(ip) {
|
|
10
|
-
const
|
|
11
|
-
if (!
|
|
16
|
+
const raw = (ip ?? "").trim();
|
|
17
|
+
if (!raw || raw.length > 45 || !LOOKS_LIKE_IP.test(raw)) return null;
|
|
18
|
+
const addr = normalizeIp(raw);
|
|
12
19
|
try {
|
|
13
20
|
const rows = await this.store.query(
|
|
14
21
|
`SELECT n.country_iso, n.country_name, n.subdivision_iso, n.subdivision_name,
|
|
@@ -35,14 +42,18 @@ var PostgresGeoProvider = class {
|
|
|
35
42
|
longitude: r.longitude != null ? Number(r.longitude) : null,
|
|
36
43
|
accuracyRadius: r.accuracy_radius != null ? Number(r.accuracy_radius) : null
|
|
37
44
|
};
|
|
38
|
-
} catch {
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (err?.code !== PG_INVALID_TEXT) {
|
|
47
|
+
console.error("@fonderie/geo: lookup failed (returning null):", err);
|
|
48
|
+
}
|
|
39
49
|
return null;
|
|
40
50
|
}
|
|
41
51
|
}
|
|
42
52
|
};
|
|
43
53
|
|
|
44
54
|
// src/ingest.ts
|
|
45
|
-
import {
|
|
55
|
+
import { createReadStream } from "fs";
|
|
56
|
+
import { createInterface } from "readline";
|
|
46
57
|
function parseCsvLine(line) {
|
|
47
58
|
const out = [];
|
|
48
59
|
let field = "";
|
|
@@ -71,93 +82,109 @@ var num = (s) => {
|
|
|
71
82
|
return Number.isFinite(n) ? n : null;
|
|
72
83
|
};
|
|
73
84
|
var str = (s) => s == null || s === "" ? null : s;
|
|
85
|
+
function blockRowFromLine(line) {
|
|
86
|
+
const c = parseCsvLine(line);
|
|
87
|
+
if (!c[0]) return null;
|
|
88
|
+
return {
|
|
89
|
+
network: c[0],
|
|
90
|
+
// Fall back to the registered-country geoname when the block has no
|
|
91
|
+
// city-level geoname (MaxMind's documented behavior) — otherwise a large
|
|
92
|
+
// slice of the address space loses all country resolution.
|
|
93
|
+
geonameId: num(c[1]) ?? num(c[2]),
|
|
94
|
+
latitude: num(c[7]),
|
|
95
|
+
longitude: num(c[8]),
|
|
96
|
+
accuracyRadius: num(c[9])
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function nameRowFromLine(line) {
|
|
100
|
+
const c = parseCsvLine(line);
|
|
101
|
+
const id = num(c[0]);
|
|
102
|
+
if (id == null) return null;
|
|
103
|
+
return {
|
|
104
|
+
geonameId: id,
|
|
105
|
+
continentCode: str(c[2]),
|
|
106
|
+
countryIso: str(c[4]),
|
|
107
|
+
countryName: str(c[5]),
|
|
108
|
+
subdivisionIso: str(c[6]),
|
|
109
|
+
subdivisionName: str(c[7]),
|
|
110
|
+
cityName: str(c[10]),
|
|
111
|
+
timeZone: str(c[12])
|
|
112
|
+
};
|
|
113
|
+
}
|
|
74
114
|
function parseBlocksCsv(text) {
|
|
75
|
-
|
|
76
|
-
const lines = text.split(/\r?\n/);
|
|
77
|
-
for (let i = 1; i < lines.length; i++) {
|
|
78
|
-
const line = lines[i];
|
|
79
|
-
if (!line) continue;
|
|
80
|
-
const c = parseCsvLine(line);
|
|
81
|
-
if (!c[0]) continue;
|
|
82
|
-
rows.push({
|
|
83
|
-
network: c[0],
|
|
84
|
-
geonameId: num(c[1]),
|
|
85
|
-
latitude: num(c[7]),
|
|
86
|
-
longitude: num(c[8]),
|
|
87
|
-
accuracyRadius: num(c[9])
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
return rows;
|
|
115
|
+
return text.split(/\r?\n/).slice(1).map(blockRowFromLine).filter((r) => r !== null);
|
|
91
116
|
}
|
|
92
117
|
function parseLocationsCsv(text) {
|
|
93
|
-
|
|
94
|
-
const lines = text.split(/\r?\n/);
|
|
95
|
-
for (let i = 1; i < lines.length; i++) {
|
|
96
|
-
const line = lines[i];
|
|
97
|
-
if (!line) continue;
|
|
98
|
-
const c = parseCsvLine(line);
|
|
99
|
-
const id = num(c[0]);
|
|
100
|
-
if (id == null) continue;
|
|
101
|
-
rows.push({
|
|
102
|
-
geonameId: id,
|
|
103
|
-
continentCode: str(c[2]),
|
|
104
|
-
countryIso: str(c[4]),
|
|
105
|
-
countryName: str(c[5]),
|
|
106
|
-
subdivisionIso: str(c[6]),
|
|
107
|
-
subdivisionName: str(c[7]),
|
|
108
|
-
cityName: str(c[10]),
|
|
109
|
-
timeZone: str(c[12])
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
return rows;
|
|
118
|
+
return text.split(/\r?\n/).slice(1).map(nameRowFromLine).filter((r) => r !== null);
|
|
113
119
|
}
|
|
114
120
|
var CHUNK = 500;
|
|
115
|
-
async function insertChunked(store, rows, cols, sqlHead, toParams) {
|
|
121
|
+
async function insertChunked(store, rows, cols, sqlHead, onConflict, toParams) {
|
|
116
122
|
let n = 0;
|
|
117
123
|
for (let i = 0; i < rows.length; i += CHUNK) {
|
|
118
124
|
const batch = rows.slice(i, i + CHUNK);
|
|
119
125
|
const values = batch.map((_, b) => `(${Array.from({ length: cols }, (_2, k) => `$${b * cols + k + 1}`).join(", ")})`).join(", ");
|
|
120
|
-
|
|
121
|
-
await store.query(`${sqlHead} VALUES ${values}`, params);
|
|
126
|
+
await store.query(`${sqlHead} VALUES ${values} ${onConflict}`, batch.flatMap(toParams));
|
|
122
127
|
n += batch.length;
|
|
123
128
|
}
|
|
124
129
|
return n;
|
|
125
130
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
return c;
|
|
135
|
-
});
|
|
131
|
+
var NAMES_HEAD = "INSERT INTO geo_names (geoname_id, continent_code, country_iso, country_name, subdivision_iso, subdivision_name, city_name, time_zone)";
|
|
132
|
+
var NAMES_CONFLICT = "ON CONFLICT (geoname_id) DO UPDATE SET continent_code = EXCLUDED.continent_code, country_iso = EXCLUDED.country_iso, country_name = EXCLUDED.country_name, subdivision_iso = EXCLUDED.subdivision_iso, subdivision_name = EXCLUDED.subdivision_name, city_name = EXCLUDED.city_name, time_zone = EXCLUDED.time_zone";
|
|
133
|
+
var nameParams = (r) => [r.geonameId, r.continentCode, r.countryIso, r.countryName, r.subdivisionIso, r.subdivisionName, r.cityName, r.timeZone];
|
|
134
|
+
var BLOCKS_HEAD = "INSERT INTO geo_blocks (network, geoname_id, latitude, longitude, accuracy_radius)";
|
|
135
|
+
var BLOCKS_CONFLICT = "ON CONFLICT (network) DO UPDATE SET geoname_id = EXCLUDED.geoname_id, latitude = EXCLUDED.latitude, longitude = EXCLUDED.longitude, accuracy_radius = EXCLUDED.accuracy_radius";
|
|
136
|
+
var blockParams = (r) => [r.network, r.geonameId, r.latitude, r.longitude, r.accuracyRadius];
|
|
137
|
+
function ingestNames(store, rows) {
|
|
138
|
+
return insertChunked(store, rows, 8, NAMES_HEAD, NAMES_CONFLICT, nameParams);
|
|
136
139
|
}
|
|
137
|
-
|
|
138
|
-
return insertChunked(
|
|
139
|
-
store,
|
|
140
|
-
rows,
|
|
141
|
-
5,
|
|
142
|
-
`INSERT INTO geo_blocks (network, geoname_id, latitude, longitude, accuracy_radius)`,
|
|
143
|
-
(r) => [r.network, r.geonameId, r.latitude, r.longitude, r.accuracyRadius]
|
|
144
|
-
);
|
|
140
|
+
function ingestBlocks(store, rows) {
|
|
141
|
+
return insertChunked(store, rows, 5, BLOCKS_HEAD, BLOCKS_CONFLICT, blockParams);
|
|
145
142
|
}
|
|
146
|
-
async function
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
let
|
|
151
|
-
for (const
|
|
152
|
-
if (
|
|
143
|
+
async function streamInto(path, map, sink) {
|
|
144
|
+
const rl = createInterface({ input: createReadStream(path, { encoding: "utf8" }), crlfDelay: Infinity });
|
|
145
|
+
let total = 0;
|
|
146
|
+
let batch = [];
|
|
147
|
+
let first = true;
|
|
148
|
+
for await (const line of rl) {
|
|
149
|
+
if (first) {
|
|
150
|
+
first = false;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (!line) continue;
|
|
154
|
+
const row = map(line);
|
|
155
|
+
if (!row) continue;
|
|
156
|
+
batch.push(row);
|
|
157
|
+
if (batch.length >= CHUNK) {
|
|
158
|
+
await sink(batch);
|
|
159
|
+
total += batch.length;
|
|
160
|
+
batch = [];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (batch.length) {
|
|
164
|
+
await sink(batch);
|
|
165
|
+
total += batch.length;
|
|
153
166
|
}
|
|
154
|
-
return
|
|
167
|
+
return total;
|
|
168
|
+
}
|
|
169
|
+
async function loadMaxMindCity(store, files) {
|
|
170
|
+
return store.transaction(async (tx) => {
|
|
171
|
+
await tx.query("TRUNCATE geo_blocks");
|
|
172
|
+
await tx.query("TRUNCATE geo_names");
|
|
173
|
+
const names = await streamInto(files.locationsPath, nameRowFromLine, (b) => ingestNames(tx, b));
|
|
174
|
+
let blocks = 0;
|
|
175
|
+
for (const p of [files.blocksV4Path, files.blocksV6Path]) {
|
|
176
|
+
if (p) blocks += await streamInto(p, blockRowFromLine, (b) => ingestBlocks(tx, b));
|
|
177
|
+
}
|
|
178
|
+
return { names, blocks };
|
|
179
|
+
});
|
|
155
180
|
}
|
|
156
181
|
export {
|
|
157
182
|
PostgresGeoProvider,
|
|
183
|
+
blockRowFromLine,
|
|
158
184
|
ingestBlocks,
|
|
159
185
|
ingestNames,
|
|
160
186
|
loadMaxMindCity,
|
|
187
|
+
nameRowFromLine,
|
|
161
188
|
parseBlocksCsv,
|
|
162
189
|
parseCsvLine,
|
|
163
190
|
parseLocationsCsv
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/provider.ts","../src/ingest.ts"],"sourcesContent":["import type { GeoLocation, IGeoProvider, Queryable } from './types.js';\n\n// Cheap guard so obvious garbage never reaches the ::inet cast (which would\n// throw). Not a full validator — the cast is the real gate; this just avoids a\n// round-trip (and an error log) for empty / clearly-non-IP input.\nconst LOOKS_LIKE_IP = /^[0-9a-fA-F:.]+$/;\n\n/**\n * The default, self-hosted provider: resolves an IP against the geo_blocks /\n * geo_names tables loaded from MaxMind/HE CSVs. The lookup is a CIDR\n * containment — the most-specific block that contains the address wins —\n * handling IPv4 and IPv6 uniformly via Postgres's native `cidr`/`inet`.\n */\nexport class PostgresGeoProvider implements IGeoProvider {\n\treadonly name = 'postgres';\n\n\tconstructor(private readonly store: Queryable) {}\n\n\tasync lookup(ip: string): Promise<GeoLocation | null> {\n\t\tconst addr = (ip ?? '').trim();\n\t\tif (!addr || addr.length > 45 || !LOOKS_LIKE_IP.test(addr)) return null;\n\t\ttry {\n\t\t\tconst rows = await this.store.query<{\n\t\t\t\tcountry_iso: string | null;\n\t\t\t\tcountry_name: string | null;\n\t\t\t\tsubdivision_iso: string | null;\n\t\t\t\tsubdivision_name: string | null;\n\t\t\t\tcity_name: string | null;\n\t\t\t\tcontinent_code: string | null;\n\t\t\t\ttime_zone: string | null;\n\t\t\t\tlatitude: number | null;\n\t\t\t\tlongitude: number | null;\n\t\t\t\taccuracy_radius: number | null;\n\t\t\t}>(\n\t\t\t\t`SELECT n.country_iso, n.country_name, n.subdivision_iso, n.subdivision_name,\n\t\t\t\t n.city_name, n.continent_code, n.time_zone,\n\t\t\t\t b.latitude, b.longitude, b.accuracy_radius\n\t\t\t\t FROM geo_blocks b\n\t\t\t\t LEFT JOIN geo_names n ON n.geoname_id = b.geoname_id\n\t\t\t\t WHERE b.network >>= $1::inet\n\t\t\t\t ORDER BY masklen(b.network) DESC\n\t\t\t\t LIMIT 1`,\n\t\t\t\t[addr],\n\t\t\t);\n\t\t\tconst r = rows[0];\n\t\t\tif (!r) return null;\n\t\t\treturn {\n\t\t\t\tcountry: r.country_iso ?? null,\n\t\t\t\tcountryName: r.country_name ?? null,\n\t\t\t\tsubdivision: r.subdivision_iso ?? null,\n\t\t\t\tsubdivisionName: r.subdivision_name ?? null,\n\t\t\t\tcity: r.city_name ?? null,\n\t\t\t\tcontinent: r.continent_code ?? null,\n\t\t\t\ttimeZone: r.time_zone ?? null,\n\t\t\t\tlatitude: r.latitude != null ? Number(r.latitude) : null,\n\t\t\t\tlongitude: r.longitude != null ? Number(r.longitude) : null,\n\t\t\t\taccuracyRadius: r.accuracy_radius != null ? Number(r.accuracy_radius) : null,\n\t\t\t};\n\t\t} catch {\n\t\t\t// Invalid inet (bad cast) or a transient store error → unknown, not a throw.\n\t\t\treturn null;\n\t\t}\n\t}\n}\n","// Load MaxMind GeoLite2 City CSVs (the same files the prior arbinuity importer\n// used) into geo_blocks + geo_names. Hurricane Electric / other sources work\n// too as long as rows map to {network, geoname_id, lat, lng, accuracy} and\n// {geoname_id, country, subdivision, city}.\nimport { readFileSync } from 'node:fs';\nimport type { Queryable } from './types.js';\n\nexport interface BlockRow {\n\tnetwork: string;\n\tgeonameId: number | null;\n\tlatitude: number | null;\n\tlongitude: number | null;\n\taccuracyRadius: number | null;\n}\n\nexport interface NameRow {\n\tgeonameId: number;\n\tcontinentCode: string | null;\n\tcountryIso: string | null;\n\tcountryName: string | null;\n\tsubdivisionIso: string | null;\n\tsubdivisionName: string | null;\n\tcityName: string | null;\n\ttimeZone: string | null;\n}\n\n/** RFC4180-ish single-line parser: handles quoted fields, embedded commas, and\n * \"\" escaped quotes (MaxMind city names like \"Washington, D.C.\" need this). */\nexport function parseCsvLine(line: string): string[] {\n\tconst out: string[] = [];\n\tlet field = '';\n\tlet inQuotes = false;\n\tfor (let i = 0; i < line.length; i++) {\n\t\tconst c = line[i];\n\t\tif (inQuotes) {\n\t\t\tif (c === '\"') {\n\t\t\t\tif (line[i + 1] === '\"') { field += '\"'; i++; } else inQuotes = false;\n\t\t\t} else field += c;\n\t\t} else if (c === '\"') inQuotes = true;\n\t\telse if (c === ',') { out.push(field); field = ''; }\n\t\telse field += c;\n\t}\n\tout.push(field);\n\treturn out;\n}\n\nconst num = (s: string | undefined): number | null => {\n\tif (s == null || s === '') return null;\n\tconst n = Number(s);\n\treturn Number.isFinite(n) ? n : null;\n};\nconst str = (s: string | undefined): string | null => (s == null || s === '' ? null : s);\n\n/** Parse a GeoLite2-City-Blocks-IPv4/IPv6 CSV (header row skipped). Columns:\n * network, geoname_id, registered_country_geoname_id, represented_country_geoname_id,\n * is_anonymous_proxy, is_satellite_provider, postal_code, latitude, longitude, accuracy_radius. */\nexport function parseBlocksCsv(text: string): BlockRow[] {\n\tconst rows: BlockRow[] = [];\n\tconst lines = text.split(/\\r?\\n/);\n\tfor (let i = 1; i < lines.length; i++) {\n\t\tconst line = lines[i];\n\t\tif (!line) continue;\n\t\tconst c = parseCsvLine(line);\n\t\tif (!c[0]) continue;\n\t\trows.push({\n\t\t\tnetwork: c[0],\n\t\t\tgeonameId: num(c[1]),\n\t\t\tlatitude: num(c[7]),\n\t\t\tlongitude: num(c[8]),\n\t\t\taccuracyRadius: num(c[9]),\n\t\t});\n\t}\n\treturn rows;\n}\n\n/** Parse a GeoLite2-City-Locations-<locale> CSV (header row skipped). Columns:\n * geoname_id, locale_code, continent_code, continent_name, country_iso_code,\n * country_name, subdivision_1_iso_code, subdivision_1_name, subdivision_2_iso_code,\n * subdivision_2_name, city_name, metro_code, time_zone, is_in_european_union. */\nexport function parseLocationsCsv(text: string): NameRow[] {\n\tconst rows: NameRow[] = [];\n\tconst lines = text.split(/\\r?\\n/);\n\tfor (let i = 1; i < lines.length; i++) {\n\t\tconst line = lines[i];\n\t\tif (!line) continue;\n\t\tconst c = parseCsvLine(line);\n\t\tconst id = num(c[0]);\n\t\tif (id == null) continue;\n\t\trows.push({\n\t\t\tgeonameId: id,\n\t\t\tcontinentCode: str(c[2]),\n\t\t\tcountryIso: str(c[4]),\n\t\t\tcountryName: str(c[5]),\n\t\t\tsubdivisionIso: str(c[6]),\n\t\t\tsubdivisionName: str(c[7]),\n\t\t\tcityName: str(c[10]),\n\t\t\ttimeZone: str(c[12]),\n\t\t});\n\t}\n\treturn rows;\n}\n\nconst CHUNK = 500;\n\nasync function insertChunked<T>(\n\tstore: Queryable,\n\trows: T[],\n\tcols: number,\n\tsqlHead: string,\n\ttoParams: (r: T) => unknown[],\n): Promise<number> {\n\tlet n = 0;\n\tfor (let i = 0; i < rows.length; i += CHUNK) {\n\t\tconst batch = rows.slice(i, i + CHUNK);\n\t\tconst values = batch\n\t\t\t.map((_, b) => `(${Array.from({ length: cols }, (_, k) => `$${b * cols + k + 1}`).join(', ')})`)\n\t\t\t.join(', ');\n\t\tconst params = batch.flatMap(toParams);\n\t\tawait store.query(`${sqlHead} VALUES ${values}`, params);\n\t\tn += batch.length;\n\t}\n\treturn n;\n}\n\nexport async function ingestNames(store: Queryable, rows: NameRow[]): Promise<number> {\n\treturn insertChunked(\n\t\tstore,\n\t\trows,\n\t\t8,\n\t\t`INSERT INTO geo_names (geoname_id, continent_code, country_iso, country_name, subdivision_iso, subdivision_name, city_name, time_zone)`,\n\t\t(r) => [r.geonameId, r.continentCode, r.countryIso, r.countryName, r.subdivisionIso, r.subdivisionName, r.cityName, r.timeZone],\n\t).then(async (c) => {\n\t\t// geo_names is a PK table; a re-run would conflict. Caller truncates first\n\t\t// for a full reload; this keeps ingest itself simple and idempotent-free.\n\t\treturn c;\n\t});\n}\n\nexport async function ingestBlocks(store: Queryable, rows: BlockRow[]): Promise<number> {\n\treturn insertChunked(\n\t\tstore,\n\t\trows,\n\t\t5,\n\t\t`INSERT INTO geo_blocks (network, geoname_id, latitude, longitude, accuracy_radius)`,\n\t\t(r) => [r.network, r.geonameId, r.latitude, r.longitude, r.accuracyRadius],\n\t);\n}\n\n/** Full load from MaxMind City CSV files. Truncates first so a reload is a\n * clean replace (the dataset is a full snapshot, not a delta). */\nexport async function loadMaxMindCity(\n\tstore: Queryable,\n\tfiles: { locationsPath: string; blocksV4Path?: string; blocksV6Path?: string },\n): Promise<{ names: number; blocks: number }> {\n\tawait store.query('TRUNCATE geo_blocks');\n\tawait store.query('TRUNCATE geo_names');\n\tconst names = await ingestNames(store, parseLocationsCsv(readFileSync(files.locationsPath, 'utf8')));\n\tlet blocks = 0;\n\tfor (const p of [files.blocksV4Path, files.blocksV6Path]) {\n\t\tif (p) blocks += await ingestBlocks(store, parseBlocksCsv(readFileSync(p, 'utf8')));\n\t}\n\treturn { names, blocks };\n}\n"],"mappings":";AAKA,IAAM,gBAAgB;AAQf,IAAM,sBAAN,MAAkD;AAAA,EAGxD,YAA6B,OAAkB;AAAlB;AAAA,EAAmB;AAAA,EAAnB;AAAA,EAFpB,OAAO;AAAA,EAIhB,MAAM,OAAO,IAAyC;AACrD,UAAM,QAAQ,MAAM,IAAI,KAAK;AAC7B,QAAI,CAAC,QAAQ,KAAK,SAAS,MAAM,CAAC,cAAc,KAAK,IAAI,EAAG,QAAO;AACnE,QAAI;AACH,YAAM,OAAO,MAAM,KAAK,MAAM;AAAA,QAY7B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAQA,CAAC,IAAI;AAAA,MACN;AACA,YAAM,IAAI,KAAK,CAAC;AAChB,UAAI,CAAC,EAAG,QAAO;AACf,aAAO;AAAA,QACN,SAAS,EAAE,eAAe;AAAA,QAC1B,aAAa,EAAE,gBAAgB;AAAA,QAC/B,aAAa,EAAE,mBAAmB;AAAA,QAClC,iBAAiB,EAAE,oBAAoB;AAAA,QACvC,MAAM,EAAE,aAAa;AAAA,QACrB,WAAW,EAAE,kBAAkB;AAAA,QAC/B,UAAU,EAAE,aAAa;AAAA,QACzB,UAAU,EAAE,YAAY,OAAO,OAAO,EAAE,QAAQ,IAAI;AAAA,QACpD,WAAW,EAAE,aAAa,OAAO,OAAO,EAAE,SAAS,IAAI;AAAA,QACvD,gBAAgB,EAAE,mBAAmB,OAAO,OAAO,EAAE,eAAe,IAAI;AAAA,MACzE;AAAA,IACD,QAAQ;AAEP,aAAO;AAAA,IACR;AAAA,EACD;AACD;;;AC3DA,SAAS,oBAAoB;AAwBtB,SAAS,aAAa,MAAwB;AACpD,QAAM,MAAgB,CAAC;AACvB,MAAI,QAAQ;AACZ,MAAI,WAAW;AACf,WAAS,IAAI,GAAG,IAAI,KAAK,QAAQ,KAAK;AACrC,UAAM,IAAI,KAAK,CAAC;AAChB,QAAI,UAAU;AACb,UAAI,MAAM,KAAK;AACd,YAAI,KAAK,IAAI,CAAC,MAAM,KAAK;AAAE,mBAAS;AAAK;AAAA,QAAK,MAAO,YAAW;AAAA,MACjE,MAAO,UAAS;AAAA,IACjB,WAAW,MAAM,IAAK,YAAW;AAAA,aACxB,MAAM,KAAK;AAAE,UAAI,KAAK,KAAK;AAAG,cAAQ;AAAA,IAAI,MAC9C,UAAS;AAAA,EACf;AACA,MAAI,KAAK,KAAK;AACd,SAAO;AACR;AAEA,IAAM,MAAM,CAAC,MAAyC;AACrD,MAAI,KAAK,QAAQ,MAAM,GAAI,QAAO;AAClC,QAAM,IAAI,OAAO,CAAC;AAClB,SAAO,OAAO,SAAS,CAAC,IAAI,IAAI;AACjC;AACA,IAAM,MAAM,CAAC,MAA0C,KAAK,QAAQ,MAAM,KAAK,OAAO;AAK/E,SAAS,eAAe,MAA0B;AACxD,QAAM,OAAmB,CAAC;AAC1B,QAAM,QAAQ,KAAK,MAAM,OAAO;AAChC,WAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK;AACtC,UAAM,OAAO,MAAM,CAAC;AACpB,QAAI,CAAC,KAAM;AACX,UAAM,IAAI,aAAa,IAAI;AAC3B,QAAI,CAAC,EAAE,CAAC,EAAG;AACX,SAAK,KAAK;AAAA,MACT,SAAS,EAAE,CAAC;AAAA,MACZ,WAAW,IAAI,EAAE,CAAC,CAAC;AAAA,MACnB,UAAU,IAAI,EAAE,CAAC,CAAC;AAAA,MAClB,WAAW,IAAI,EAAE,CAAC,CAAC;AAAA,MACnB,gBAAgB,IAAI,EAAE,CAAC,CAAC;AAAA,IACzB,CAAC;AAAA,EACF;AACA,SAAO;AACR;AAMO,SAAS,kBAAkB,MAAyB;AAC1D,QAAM,OAAkB,CAAC;AACzB,QAAM,QAAQ,KAAK,MAAM,OAAO;AAChC,WAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK;AACtC,UAAM,OAAO,MAAM,CAAC;AACpB,QAAI,CAAC,KAAM;AACX,UAAM,IAAI,aAAa,IAAI;AAC3B,UAAM,KAAK,IAAI,EAAE,CAAC,CAAC;AACnB,QAAI,MAAM,KAAM;AAChB,SAAK,KAAK;AAAA,MACT,WAAW;AAAA,MACX,eAAe,IAAI,EAAE,CAAC,CAAC;AAAA,MACvB,YAAY,IAAI,EAAE,CAAC,CAAC;AAAA,MACpB,aAAa,IAAI,EAAE,CAAC,CAAC;AAAA,MACrB,gBAAgB,IAAI,EAAE,CAAC,CAAC;AAAA,MACxB,iBAAiB,IAAI,EAAE,CAAC,CAAC;AAAA,MACzB,UAAU,IAAI,EAAE,EAAE,CAAC;AAAA,MACnB,UAAU,IAAI,EAAE,EAAE,CAAC;AAAA,IACpB,CAAC;AAAA,EACF;AACA,SAAO;AACR;AAEA,IAAM,QAAQ;AAEd,eAAe,cACd,OACA,MACA,MACA,SACA,UACkB;AAClB,MAAI,IAAI;AACR,WAAS,IAAI,GAAG,IAAI,KAAK,QAAQ,KAAK,OAAO;AAC5C,UAAM,QAAQ,KAAK,MAAM,GAAG,IAAI,KAAK;AACrC,UAAM,SAAS,MACb,IAAI,CAAC,GAAG,MAAM,IAAI,MAAM,KAAK,EAAE,QAAQ,KAAK,GAAG,CAACA,IAAG,MAAM,IAAI,IAAI,OAAO,IAAI,CAAC,EAAE,EAAE,KAAK,IAAI,CAAC,GAAG,EAC9F,KAAK,IAAI;AACX,UAAM,SAAS,MAAM,QAAQ,QAAQ;AACrC,UAAM,MAAM,MAAM,GAAG,OAAO,WAAW,MAAM,IAAI,MAAM;AACvD,SAAK,MAAM;AAAA,EACZ;AACA,SAAO;AACR;AAEA,eAAsB,YAAY,OAAkB,MAAkC;AACrF,SAAO;AAAA,IACN;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,CAAC,MAAM,CAAC,EAAE,WAAW,EAAE,eAAe,EAAE,YAAY,EAAE,aAAa,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,UAAU,EAAE,QAAQ;AAAA,EAC/H,EAAE,KAAK,OAAO,MAAM;AAGnB,WAAO;AAAA,EACR,CAAC;AACF;AAEA,eAAsB,aAAa,OAAkB,MAAmC;AACvF,SAAO;AAAA,IACN;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,CAAC,MAAM,CAAC,EAAE,SAAS,EAAE,WAAW,EAAE,UAAU,EAAE,WAAW,EAAE,cAAc;AAAA,EAC1E;AACD;AAIA,eAAsB,gBACrB,OACA,OAC6C;AAC7C,QAAM,MAAM,MAAM,qBAAqB;AACvC,QAAM,MAAM,MAAM,oBAAoB;AACtC,QAAM,QAAQ,MAAM,YAAY,OAAO,kBAAkB,aAAa,MAAM,eAAe,MAAM,CAAC,CAAC;AACnG,MAAI,SAAS;AACb,aAAW,KAAK,CAAC,MAAM,cAAc,MAAM,YAAY,GAAG;AACzD,QAAI,EAAG,WAAU,MAAM,aAAa,OAAO,eAAe,aAAa,GAAG,MAAM,CAAC,CAAC;AAAA,EACnF;AACA,SAAO,EAAE,OAAO,OAAO;AACxB;","names":["_"]}
|
|
1
|
+
{"version":3,"sources":["../src/provider.ts","../src/ingest.ts"],"sourcesContent":["import type { GeoLocation, IGeoProvider, Queryable } from './types.js';\n\n// Cheap guard so obvious garbage never reaches the ::inet cast (which would\n// throw). Not a full validator — the cast is the real gate; this just avoids a\n// round-trip for empty / clearly-non-IP input.\nconst LOOKS_LIKE_IP = /^[0-9a-fA-F:.]+$/;\n// IPv4-mapped IPv6 (::ffff:a.b.c.d) is an IPv4 address wearing a v6 hat. Node's\n// socket.remoteAddress returns this on dual-stack servers, and it casts to an\n// AF_INET6 inet that never matches IPv4 `cidr` blocks — so without this the\n// lookup silently returns null for the bulk of real traffic. Mirrors\n// @fonderie/core's resolveClientIp, which strips the same prefix upstream.\nconst V4_MAPPED = /^::ffff:(\\d{1,3}(?:\\.\\d{1,3}){3})$/i;\n// Postgres 22P02 = invalid_text_representation — i.e. a bad ::inet cast. That\n// is an \"unknown/invalid IP\" (expected → quiet null); anything else is a real\n// fault we must NOT hide behind null.\nconst PG_INVALID_TEXT = '22P02';\n\nfunction normalizeIp(ip: string): string {\n\tconst m = V4_MAPPED.exec(ip);\n\treturn m ? (m[1] as string) : ip;\n}\n\n/**\n * The default, self-hosted provider: resolves an IP against the geo_blocks /\n * geo_names tables loaded from MaxMind/HE CSVs. The lookup is a CIDR\n * containment — the most-specific block that contains the address wins —\n * handling IPv4 and IPv6 uniformly via Postgres's native `cidr`/`inet`.\n */\nexport class PostgresGeoProvider implements IGeoProvider {\n\treadonly name = 'postgres';\n\n\tconstructor(private readonly store: Queryable) {}\n\n\tasync lookup(ip: string): Promise<GeoLocation | null> {\n\t\tconst raw = (ip ?? '').trim();\n\t\tif (!raw || raw.length > 45 || !LOOKS_LIKE_IP.test(raw)) return null;\n\t\tconst addr = normalizeIp(raw); // ::ffff:1.2.3.4 → 1.2.3.4, so it matches IPv4 blocks\n\t\ttry {\n\t\t\tconst rows = await this.store.query<{\n\t\t\t\tcountry_iso: string | null;\n\t\t\t\tcountry_name: string | null;\n\t\t\t\tsubdivision_iso: string | null;\n\t\t\t\tsubdivision_name: string | null;\n\t\t\t\tcity_name: string | null;\n\t\t\t\tcontinent_code: string | null;\n\t\t\t\ttime_zone: string | null;\n\t\t\t\tlatitude: number | null;\n\t\t\t\tlongitude: number | null;\n\t\t\t\taccuracy_radius: number | null;\n\t\t\t}>(\n\t\t\t\t`SELECT n.country_iso, n.country_name, n.subdivision_iso, n.subdivision_name,\n\t\t\t\t n.city_name, n.continent_code, n.time_zone,\n\t\t\t\t b.latitude, b.longitude, b.accuracy_radius\n\t\t\t\t FROM geo_blocks b\n\t\t\t\t LEFT JOIN geo_names n ON n.geoname_id = b.geoname_id\n\t\t\t\t WHERE b.network >>= $1::inet\n\t\t\t\t ORDER BY masklen(b.network) DESC\n\t\t\t\t LIMIT 1`,\n\t\t\t\t[addr],\n\t\t\t);\n\t\t\tconst r = rows[0];\n\t\t\tif (!r) return null;\n\t\t\treturn {\n\t\t\t\tcountry: r.country_iso ?? null,\n\t\t\t\tcountryName: r.country_name ?? null,\n\t\t\t\tsubdivision: r.subdivision_iso ?? null,\n\t\t\t\tsubdivisionName: r.subdivision_name ?? null,\n\t\t\t\tcity: r.city_name ?? null,\n\t\t\t\tcontinent: r.continent_code ?? null,\n\t\t\t\ttimeZone: r.time_zone ?? null,\n\t\t\t\tlatitude: r.latitude != null ? Number(r.latitude) : null,\n\t\t\t\tlongitude: r.longitude != null ? Number(r.longitude) : null,\n\t\t\t\taccuracyRadius: r.accuracy_radius != null ? Number(r.accuracy_radius) : null,\n\t\t\t};\n\t\t} catch (err) {\n\t\t\t// A bad ::inet cast means the input wasn't a real IP → quiet null (expected).\n\t\t\t// Anything else (connection drop, a latent query bug) is a real fault: we\n\t\t\t// still return null so a caller isn't crashed, but we LOG it — silently\n\t\t\t// swallowing it would let a risk gate fail open during an outage with no\n\t\t\t// trace, and would hide query regressions the fake-store unit tests can't.\n\t\t\tif ((err as { code?: string })?.code !== PG_INVALID_TEXT) {\n\t\t\t\tconsole.error('@fonderie/geo: lookup failed (returning null):', err);\n\t\t\t}\n\t\t\treturn null;\n\t\t}\n\t}\n}\n","// Load MaxMind GeoLite2 City CSVs (the same files the prior arbinuity importer\n// used) into geo_blocks + geo_names. Hurricane Electric / other sources work\n// too as long as rows map to {network, geoname_id, lat, lng, accuracy} and\n// {geoname_id, country, subdivision, city}.\n//\n// NOTE: parsing is line-oriented (split on newlines, then parse quotes per\n// line). MaxMind City fields never contain newlines, so this is safe for that\n// data; a source with RFC4180 newlines *inside* quoted fields is not supported.\nimport { createReadStream } from 'node:fs';\nimport { createInterface } from 'node:readline';\nimport type { Queryable } from './types.js';\n\n/** A store that can open a single-connection transaction — required for an\n * atomic reload (truncate + insert must not half-apply). */\nexport interface TxStore extends Queryable {\n\ttransaction<T>(fn: (tx: Queryable) => Promise<T>): Promise<T>;\n}\n\nexport interface BlockRow {\n\tnetwork: string;\n\tgeonameId: number | null;\n\tlatitude: number | null;\n\tlongitude: number | null;\n\taccuracyRadius: number | null;\n}\n\nexport interface NameRow {\n\tgeonameId: number;\n\tcontinentCode: string | null;\n\tcountryIso: string | null;\n\tcountryName: string | null;\n\tsubdivisionIso: string | null;\n\tsubdivisionName: string | null;\n\tcityName: string | null;\n\ttimeZone: string | null;\n}\n\n/** RFC4180-ish single-line parser: quoted fields, embedded commas, \"\" escapes. */\nexport function parseCsvLine(line: string): string[] {\n\tconst out: string[] = [];\n\tlet field = '';\n\tlet inQuotes = false;\n\tfor (let i = 0; i < line.length; i++) {\n\t\tconst c = line[i];\n\t\tif (inQuotes) {\n\t\t\tif (c === '\"') {\n\t\t\t\tif (line[i + 1] === '\"') { field += '\"'; i++; } else inQuotes = false;\n\t\t\t} else field += c;\n\t\t} else if (c === '\"') inQuotes = true;\n\t\telse if (c === ',') { out.push(field); field = ''; }\n\t\telse field += c;\n\t}\n\tout.push(field);\n\treturn out;\n}\n\nconst num = (s: string | undefined): number | null => {\n\tif (s == null || s === '') return null;\n\tconst n = Number(s);\n\treturn Number.isFinite(n) ? n : null;\n};\nconst str = (s: string | undefined): string | null => (s == null || s === '' ? null : s);\n\n// Blocks columns: network, geoname_id, registered_country_geoname_id,\n// represented_country_geoname_id, is_anonymous_proxy, is_satellite_provider,\n// postal_code, latitude, longitude, accuracy_radius.\nexport function blockRowFromLine(line: string): BlockRow | null {\n\tconst c = parseCsvLine(line);\n\tif (!c[0]) return null;\n\treturn {\n\t\tnetwork: c[0],\n\t\t// Fall back to the registered-country geoname when the block has no\n\t\t// city-level geoname (MaxMind's documented behavior) — otherwise a large\n\t\t// slice of the address space loses all country resolution.\n\t\tgeonameId: num(c[1]) ?? num(c[2]),\n\t\tlatitude: num(c[7]),\n\t\tlongitude: num(c[8]),\n\t\taccuracyRadius: num(c[9]),\n\t};\n}\n\n// Locations columns: geoname_id, locale_code, continent_code, continent_name,\n// country_iso_code, country_name, subdivision_1_iso_code, subdivision_1_name,\n// subdivision_2_iso_code, subdivision_2_name, city_name, metro_code, time_zone,\n// is_in_european_union.\nexport function nameRowFromLine(line: string): NameRow | null {\n\tconst c = parseCsvLine(line);\n\tconst id = num(c[0]);\n\tif (id == null) return null;\n\treturn {\n\t\tgeonameId: id,\n\t\tcontinentCode: str(c[2]),\n\t\tcountryIso: str(c[4]),\n\t\tcountryName: str(c[5]),\n\t\tsubdivisionIso: str(c[6]),\n\t\tsubdivisionName: str(c[7]),\n\t\tcityName: str(c[10]),\n\t\ttimeZone: str(c[12]),\n\t};\n}\n\n/** Parse a whole Blocks CSV string (header skipped). For tests / small inputs;\n * loadMaxMindCity streams the real multi-hundred-MB files instead. */\nexport function parseBlocksCsv(text: string): BlockRow[] {\n\treturn text.split(/\\r?\\n/).slice(1).map(blockRowFromLine).filter((r): r is BlockRow => r !== null);\n}\n\nexport function parseLocationsCsv(text: string): NameRow[] {\n\treturn text.split(/\\r?\\n/).slice(1).map(nameRowFromLine).filter((r): r is NameRow => r !== null);\n}\n\nconst CHUNK = 500;\n\nasync function insertChunked<T>(\n\tstore: Queryable,\n\trows: T[],\n\tcols: number,\n\tsqlHead: string,\n\tonConflict: string,\n\ttoParams: (r: T) => unknown[],\n): Promise<number> {\n\tlet n = 0;\n\tfor (let i = 0; i < rows.length; i += CHUNK) {\n\t\tconst batch = rows.slice(i, i + CHUNK);\n\t\tconst values = batch\n\t\t\t.map((_, b) => `(${Array.from({ length: cols }, (_, k) => `$${b * cols + k + 1}`).join(', ')})`)\n\t\t\t.join(', ');\n\t\tawait store.query(`${sqlHead} VALUES ${values} ${onConflict}`, batch.flatMap(toParams));\n\t\tn += batch.length;\n\t}\n\treturn n;\n}\n\nconst NAMES_HEAD =\n\t'INSERT INTO geo_names (geoname_id, continent_code, country_iso, country_name, subdivision_iso, subdivision_name, city_name, time_zone)';\nconst NAMES_CONFLICT =\n\t'ON CONFLICT (geoname_id) DO UPDATE SET continent_code = EXCLUDED.continent_code, country_iso = EXCLUDED.country_iso, country_name = EXCLUDED.country_name, subdivision_iso = EXCLUDED.subdivision_iso, subdivision_name = EXCLUDED.subdivision_name, city_name = EXCLUDED.city_name, time_zone = EXCLUDED.time_zone';\nconst nameParams = (r: NameRow) => [r.geonameId, r.continentCode, r.countryIso, r.countryName, r.subdivisionIso, r.subdivisionName, r.cityName, r.timeZone];\n\nconst BLOCKS_HEAD = 'INSERT INTO geo_blocks (network, geoname_id, latitude, longitude, accuracy_radius)';\nconst BLOCKS_CONFLICT =\n\t'ON CONFLICT (network) DO UPDATE SET geoname_id = EXCLUDED.geoname_id, latitude = EXCLUDED.latitude, longitude = EXCLUDED.longitude, accuracy_radius = EXCLUDED.accuracy_radius';\nconst blockParams = (r: BlockRow) => [r.network, r.geonameId, r.latitude, r.longitude, r.accuracyRadius];\n\n/** Upsert name rows (idempotent — safe to call without a prior truncate). */\nexport function ingestNames(store: Queryable, rows: NameRow[]): Promise<number> {\n\treturn insertChunked(store, rows, 8, NAMES_HEAD, NAMES_CONFLICT, nameParams);\n}\n\n/** Upsert block rows (idempotent on `network`). */\nexport function ingestBlocks(store: Queryable, rows: BlockRow[]): Promise<number> {\n\treturn insertChunked(store, rows, 5, BLOCKS_HEAD, BLOCKS_CONFLICT, blockParams);\n}\n\n/** Stream a CSV file line-by-line (header skipped), batching mapped rows — so a\n * multi-hundred-MB MaxMind file never lands in memory as one string. */\nasync function streamInto<T>(path: string, map: (line: string) => T | null, sink: (batch: T[]) => Promise<unknown>): Promise<number> {\n\tconst rl = createInterface({ input: createReadStream(path, { encoding: 'utf8' }), crlfDelay: Infinity });\n\tlet total = 0;\n\tlet batch: T[] = [];\n\tlet first = true;\n\tfor await (const line of rl) {\n\t\tif (first) { first = false; continue; } // header\n\t\tif (!line) continue;\n\t\tconst row = map(line);\n\t\tif (!row) continue;\n\t\tbatch.push(row);\n\t\tif (batch.length >= CHUNK) { await sink(batch); total += batch.length; batch = []; }\n\t}\n\tif (batch.length) { await sink(batch); total += batch.length; }\n\treturn total;\n}\n\n/**\n * Full reload from MaxMind City CSV files — ATOMIC (truncate + all inserts in\n * one transaction, so a mid-load failure rolls back instead of leaving the geo\n * tables empty/partial) and STREAMED (bounded memory on the large Blocks file).\n */\nexport async function loadMaxMindCity(\n\tstore: TxStore,\n\tfiles: { locationsPath: string; blocksV4Path?: string; blocksV6Path?: string },\n): Promise<{ names: number; blocks: number }> {\n\treturn store.transaction(async (tx) => {\n\t\tawait tx.query('TRUNCATE geo_blocks');\n\t\tawait tx.query('TRUNCATE geo_names');\n\t\tconst names = await streamInto(files.locationsPath, nameRowFromLine, (b) => ingestNames(tx, b));\n\t\tlet blocks = 0;\n\t\tfor (const p of [files.blocksV4Path, files.blocksV6Path]) {\n\t\t\tif (p) blocks += await streamInto(p, blockRowFromLine, (b) => ingestBlocks(tx, b));\n\t\t}\n\t\treturn { names, blocks };\n\t});\n}\n"],"mappings":"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","names":["_"]}
|
|
@@ -38,3 +38,6 @@ CREATE TABLE IF NOT EXISTS geo_blocks (
|
|
|
38
38
|
-- supports the >>= operator; masklen() then picks the most specific.
|
|
39
39
|
CREATE INDEX IF NOT EXISTS idx_geo_blocks_network ON geo_blocks USING gist (network inet_ops);
|
|
40
40
|
CREATE INDEX IF NOT EXISTS idx_geo_blocks_geoname ON geo_blocks (geoname_id);
|
|
41
|
+
-- `network` is unique per snapshot — this both dedups on reload and is the
|
|
42
|
+
-- ON CONFLICT target the idempotent upsert ingest relies on.
|
|
43
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uq_geo_blocks_network ON geo_blocks (network);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fonderie/geo",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"fonderie": { "stability": "experimental" },
|
|
5
5
|
"description": "Self-hosted IP → location. Provider-abstracted (IGeoProvider); the default resolves against a Postgres table of MaxMind/Hurricane-Electric CIDR blocks using native inet/GiST — IPv4 and IPv6, no external API, no key shipped. A signal source for @fonderie/risk and a day-one 'where did this request come from' for any Fonderie app.",
|
|
6
6
|
"keywords": [
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"check": "biome check --write src"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
|
-
"@fonderie/core": "^0.
|
|
45
|
+
"@fonderie/core": "^0.12.0",
|
|
46
46
|
"@fonderie/store": "^0.3.0"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|