@fonderie/geo 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/brain/outcomes.md CHANGED
@@ -18,6 +18,7 @@ latitude DOUBLE PRECISION
18
18
  longitude DOUBLE PRECISION
19
19
  accuracy_radius INTEGER
20
20
  -- INDEX idx_geo_blocks_geoname (geoname_id)
21
+ -- UNIQUE INDEX uq_geo_blocks_network (network)
21
22
  ```
22
23
 
23
24
  ### `geo_names`
@@ -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: Queryable, files: { locationsPath: string; blocksV4Path?: string; blocksV6Path?: string; }): Promise<{ names: number; blocks: number; }>
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 addr = (ip ?? "").trim();
43
- if (!addr || addr.length > 45 || !LOOKS_LIKE_IP.test(addr)) return null;
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
- const rows = [];
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
- const rows = [];
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
- const params = batch.flatMap(toParams);
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
- async function ingestNames(store, rows) {
159
- return insertChunked(
160
- store,
161
- rows,
162
- 8,
163
- `INSERT INTO geo_names (geoname_id, continent_code, country_iso, country_name, subdivision_iso, subdivision_name, city_name, time_zone)`,
164
- (r) => [r.geonameId, r.continentCode, r.countryIso, r.countryName, r.subdivisionIso, r.subdivisionName, r.cityName, r.timeZone]
165
- ).then(async (c) => {
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
- async function ingestBlocks(store, rows) {
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 loadMaxMindCity(store, files) {
179
- await store.query("TRUNCATE geo_blocks");
180
- await store.query("TRUNCATE geo_names");
181
- const names = await ingestNames(store, parseLocationsCsv((0, import_node_fs.readFileSync)(files.locationsPath, "utf8")));
182
- let blocks = 0;
183
- for (const p of [files.blocksV4Path, files.blocksV6Path]) {
184
- if (p) blocks += await ingestBlocks(store, parseBlocksCsv((0, import_node_fs.readFileSync)(p, "utf8")));
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 { names, blocks };
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
@@ -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: handles quoted fields, embedded commas, and
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
- /** Parse a GeoLite2-City-Blocks-IPv4/IPv6 CSV (header row skipped). Columns:
63
- * network, geoname_id, registered_country_geoname_id, represented_country_geoname_id,
64
- * is_anonymous_proxy, is_satellite_provider, postal_code, latitude, longitude, accuracy_radius. */
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
- /** Full load from MaxMind City CSV files. Truncates first so a reload is a
74
- * clean replace (the dataset is a full snapshot, not a delta). */
75
- declare function loadMaxMindCity(store: Queryable, files: {
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: handles quoted fields, embedded commas, and
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
- /** Parse a GeoLite2-City-Blocks-IPv4/IPv6 CSV (header row skipped). Columns:
63
- * network, geoname_id, registered_country_geoname_id, represented_country_geoname_id,
64
- * is_anonymous_proxy, is_satellite_provider, postal_code, latitude, longitude, accuracy_radius. */
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
- /** Full load from MaxMind City CSV files. Truncates first so a reload is a
74
- * clean replace (the dataset is a full snapshot, not a delta). */
75
- declare function loadMaxMindCity(store: Queryable, files: {
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 addr = (ip ?? "").trim();
11
- if (!addr || addr.length > 45 || !LOOKS_LIKE_IP.test(addr)) return null;
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 { readFileSync } from "fs";
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
- const rows = [];
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
- const rows = [];
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
- const params = batch.flatMap(toParams);
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
- async function ingestNames(store, rows) {
127
- return insertChunked(
128
- store,
129
- rows,
130
- 8,
131
- `INSERT INTO geo_names (geoname_id, continent_code, country_iso, country_name, subdivision_iso, subdivision_name, city_name, time_zone)`,
132
- (r) => [r.geonameId, r.continentCode, r.countryIso, r.countryName, r.subdivisionIso, r.subdivisionName, r.cityName, r.timeZone]
133
- ).then(async (c) => {
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
- async function ingestBlocks(store, rows) {
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 loadMaxMindCity(store, files) {
147
- await store.query("TRUNCATE geo_blocks");
148
- await store.query("TRUNCATE geo_names");
149
- const names = await ingestNames(store, parseLocationsCsv(readFileSync(files.locationsPath, "utf8")));
150
- let blocks = 0;
151
- for (const p of [files.blocksV4Path, files.blocksV6Path]) {
152
- if (p) blocks += await ingestBlocks(store, parseBlocksCsv(readFileSync(p, "utf8")));
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 { names, blocks };
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":"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","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.0",
3
+ "version": "0.2.0",
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.10.0",
45
+ "@fonderie/core": "^0.11.0",
46
46
  "@fonderie/store": "^0.3.0"
47
47
  },
48
48
  "devDependencies": {