georeference 0.0.0-stage → 1.0.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/CHANGELOG.md +19 -0
- package/CONTRIBUTING.fr.md +44 -0
- package/CONTRIBUTING.md +44 -0
- package/LICENSE +21 -0
- package/README.fr.md +84 -0
- package/README.md +83 -2
- package/SECURITY.fr.md +9 -0
- package/SECURITY.md +9 -0
- package/dist/adapters/common.d.ts +5 -0
- package/dist/adapters/common.js +37 -0
- package/dist/adapters/common.js.map +1 -0
- package/dist/adapters/csv.d.ts +2 -0
- package/dist/adapters/csv.js +32 -0
- package/dist/adapters/csv.js.map +1 -0
- package/dist/adapters/geojson.d.ts +2 -0
- package/dist/adapters/geojson.js +36 -0
- package/dist/adapters/geojson.js.map +1 -0
- package/dist/adapters/index.d.ts +3 -0
- package/dist/adapters/index.js +10 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/json.d.ts +3 -0
- package/dist/adapters/json.js +48 -0
- package/dist/adapters/json.js.map +1 -0
- package/dist/adapters/postgres.d.ts +3 -0
- package/dist/adapters/postgres.js +59 -0
- package/dist/adapters/postgres.js.map +1 -0
- package/dist/adapters/sqlite.d.ts +2 -0
- package/dist/adapters/sqlite.js +49 -0
- package/dist/adapters/sqlite.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +219 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +9 -0
- package/dist/client.js +24 -0
- package/dist/client.js.map +1 -0
- package/dist/export.d.ts +6 -0
- package/dist/export.js +127 -0
- package/dist/export.js.map +1 -0
- package/dist/files.d.ts +6 -0
- package/dist/files.js +28 -0
- package/dist/files.js.map +1 -0
- package/dist/generate.d.ts +10 -0
- package/dist/generate.js +102 -0
- package/dist/generate.js.map +1 -0
- package/dist/graph.d.ts +3 -0
- package/dist/graph.js +31 -0
- package/dist/graph.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/package.d.ts +6 -0
- package/dist/package.js +133 -0
- package/dist/package.js.map +1 -0
- package/dist/seeding/index.d.ts +6 -0
- package/dist/seeding/index.js +80 -0
- package/dist/seeding/index.js.map +1 -0
- package/dist/seeding/ledger.d.ts +12 -0
- package/dist/seeding/ledger.js +54 -0
- package/dist/seeding/ledger.js.map +1 -0
- package/dist/seeding/mapping.d.ts +31 -0
- package/dist/seeding/mapping.js +54 -0
- package/dist/seeding/mapping.js.map +1 -0
- package/dist/seeding/preflight.d.ts +10 -0
- package/dist/seeding/preflight.js +149 -0
- package/dist/seeding/preflight.js.map +1 -0
- package/dist/seeding/retained.d.ts +5 -0
- package/dist/seeding/retained.js +32 -0
- package/dist/seeding/retained.js.map +1 -0
- package/dist/seeding/types.d.ts +41 -0
- package/dist/seeding/types.js +2 -0
- package/dist/seeding/types.js.map +1 -0
- package/dist/seeding/write.d.ts +4 -0
- package/dist/seeding/write.js +31 -0
- package/dist/seeding/write.js.map +1 -0
- package/dist/snapshot.d.ts +14 -0
- package/dist/snapshot.js +154 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/sources/geonames.d.ts +14 -0
- package/dist/sources/geonames.js +118 -0
- package/dist/sources/geonames.js.map +1 -0
- package/dist/types.d.ts +113 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/validation.d.ts +4 -0
- package/dist/validation.js +91 -0
- package/dist/validation.js.map +1 -0
- package/docs/architecture.fr.md +105 -0
- package/docs/architecture.md +105 -0
- package/docs/data-sources.fr.md +39 -0
- package/docs/data-sources.md +39 -0
- package/docs/format-decisions.fr.md +54 -0
- package/docs/format-decisions.md +54 -0
- package/docs/formats.fr.md +34 -0
- package/docs/formats.md +34 -0
- package/docs/generation.fr.md +29 -0
- package/docs/generation.md +29 -0
- package/docs/getting-started.fr.md +79 -0
- package/docs/getting-started.md +79 -0
- package/docs/seeding.fr.md +71 -0
- package/docs/seeding.md +71 -0
- package/examples/README.md +7 -0
- package/examples/metadata.json +13 -0
- package/examples/sample.ndjson +4 -0
- package/package.json +64 -4
package/dist/snapshot.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { access, mkdir, link, unlink } from 'node:fs/promises';
|
|
4
|
+
import { dirname } from 'node:path';
|
|
5
|
+
import { canonicalJson, validateEntity, validateMetadata } from "./validation.js";
|
|
6
|
+
function ensureGraph(db) {
|
|
7
|
+
if (db.prepare('PRAGMA foreign_key_check').all().length)
|
|
8
|
+
throw new Error('Unknown parent reference');
|
|
9
|
+
const invalid = db.prepare(`SELECT e.id FROM entities e LEFT JOIN entities c ON e.country_id=c.id LEFT JOIN entities p ON e.parent_id=p.id
|
|
10
|
+
WHERE c.entity_type IS NULL OR c.entity_type!='country' OR
|
|
11
|
+
(e.parent_id IS NOT NULL AND (p.country_id!=e.country_id OR p.entity_type='locality')) LIMIT 1`).get();
|
|
12
|
+
if (invalid)
|
|
13
|
+
throw new Error(`Invalid country or parent relationship for ${invalid.id}`);
|
|
14
|
+
const reached = db.prepare(`WITH RECURSIVE tree(id,depth) AS (
|
|
15
|
+
SELECT id,0 FROM entities WHERE parent_id IS NULL UNION ALL
|
|
16
|
+
SELECT e.id,t.depth+1 FROM entities e JOIN tree t ON e.parent_id=t.id)
|
|
17
|
+
SELECT count(*) AS count FROM tree`).get();
|
|
18
|
+
if (Number(reached.count) !== Number(db.prepare('SELECT count(*) AS count FROM entities').get().count))
|
|
19
|
+
throw new Error('Cycle or unreachable entity in parent graph');
|
|
20
|
+
db.exec(`WITH RECURSIVE tree(id,depth) AS (
|
|
21
|
+
SELECT id,0 FROM entities WHERE parent_id IS NULL UNION ALL
|
|
22
|
+
SELECT e.id,t.depth+1 FROM entities e JOIN tree t ON e.parent_id=t.id)
|
|
23
|
+
UPDATE entities SET depth=(SELECT depth FROM tree WHERE tree.id=entities.id)`);
|
|
24
|
+
}
|
|
25
|
+
export async function createSnapshot(path, metadata, entities) {
|
|
26
|
+
validateMetadata(metadata);
|
|
27
|
+
await mkdir(dirname(path), { recursive: true });
|
|
28
|
+
try {
|
|
29
|
+
await access(path);
|
|
30
|
+
throw new Error('Snapshot destination already exists');
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (error.code !== 'ENOENT')
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
const temporary = path + '.' + randomUUID() + '.tmp';
|
|
37
|
+
const db = new DatabaseSync(temporary);
|
|
38
|
+
let closed = false;
|
|
39
|
+
try {
|
|
40
|
+
db.exec(`PRAGMA foreign_keys=ON; BEGIN;
|
|
41
|
+
CREATE TABLE metadata (value TEXT NOT NULL);
|
|
42
|
+
CREATE TABLE entities (id TEXT PRIMARY KEY, entity_type TEXT NOT NULL, country_id TEXT NOT NULL,
|
|
43
|
+
parent_id TEXT REFERENCES entities(id) DEFERRABLE INITIALLY DEFERRED, depth INTEGER NOT NULL DEFAULT 0, data TEXT NOT NULL);
|
|
44
|
+
CREATE INDEX entities_parent ON entities(parent_id);
|
|
45
|
+
CREATE INDEX entities_country ON entities(country_id);`);
|
|
46
|
+
db.prepare('INSERT INTO metadata VALUES (?)').run(canonicalJson(metadata));
|
|
47
|
+
const insert = db.prepare('INSERT INTO entities (id,entity_type,country_id,parent_id,data) VALUES (?,?,?,?,?)');
|
|
48
|
+
for await (const entity of entities) {
|
|
49
|
+
validateEntity(entity);
|
|
50
|
+
insert.run(entity.id, entity.entityType, entity.countryId, entity.parentId, canonicalJson(entity));
|
|
51
|
+
}
|
|
52
|
+
ensureGraph(db);
|
|
53
|
+
db.exec('COMMIT; PRAGMA optimize;');
|
|
54
|
+
db.close();
|
|
55
|
+
closed = true;
|
|
56
|
+
await link(temporary, path);
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
if (!closed)
|
|
60
|
+
db.close();
|
|
61
|
+
await unlink(temporary).catch(() => { });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export class Snapshot {
|
|
65
|
+
db;
|
|
66
|
+
nextPlan = 0;
|
|
67
|
+
constructor(path) {
|
|
68
|
+
this.db = new DatabaseSync(path, { readOnly: true });
|
|
69
|
+
try {
|
|
70
|
+
validateMetadata(this.metadata);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
this.db.close();
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
static open(path) { return new Snapshot(path); }
|
|
78
|
+
get metadata() { return JSON.parse(this.db.prepare('SELECT value FROM metadata').get().value); }
|
|
79
|
+
get count() { return Number(this.db.prepare('SELECT count(*) AS count FROM entities').get().count); }
|
|
80
|
+
get(id) {
|
|
81
|
+
const row = this.db.prepare('SELECT data FROM entities WHERE id=?').get(id);
|
|
82
|
+
return row ? JSON.parse(row.data) : undefined;
|
|
83
|
+
}
|
|
84
|
+
*entities() {
|
|
85
|
+
for (const row of this.db.prepare('SELECT data FROM entities ORDER BY depth,id').iterate())
|
|
86
|
+
yield JSON.parse(row.data);
|
|
87
|
+
}
|
|
88
|
+
select(selection) {
|
|
89
|
+
selection = structuredClone(selection);
|
|
90
|
+
const request = { format: 'json', ...selection };
|
|
91
|
+
if (selection.entityTypes && (selection.entityTypes.length === 0 || selection.entityTypes.some(type => !['country', 'subdivision', 'locality'].includes(type))))
|
|
92
|
+
throw new Error('Invalid entityTypes selection');
|
|
93
|
+
const table = `selected_${this.nextPlan++}`;
|
|
94
|
+
this.db.exec(`CREATE TEMP TABLE ${table} (id TEXT PRIMARY KEY, context INTEGER NOT NULL DEFAULT 0)`);
|
|
95
|
+
try {
|
|
96
|
+
const insert = this.db.prepare(`INSERT OR IGNORE INTO ${table}(id) SELECT id FROM entities WHERE country_id=?`);
|
|
97
|
+
const scoped = Boolean(selection.countries?.length || selection.subdivisions?.length);
|
|
98
|
+
for (const country of selection.countries ?? []) {
|
|
99
|
+
const matches = this.db.prepare(`SELECT id FROM entities WHERE entity_type='country' AND
|
|
100
|
+
(id=? OR upper(json_extract(data,'$.codes.iso2'))=upper(?) OR upper(json_extract(data,'$.codes.iso3'))=upper(?))`).all(country, country, country);
|
|
101
|
+
if (matches.length !== 1)
|
|
102
|
+
throw new Error(`Unknown country or ambiguous code: ${country}`);
|
|
103
|
+
insert.run(matches[0].id);
|
|
104
|
+
}
|
|
105
|
+
for (const id of selection.subdivisions ?? []) {
|
|
106
|
+
const entity = this.get(id);
|
|
107
|
+
if (!entity || entity.entityType !== 'subdivision')
|
|
108
|
+
throw new Error(`Unknown subdivision: ${id}`);
|
|
109
|
+
this.db.prepare(`WITH RECURSIVE subtree(id) AS (SELECT ? UNION ALL SELECT e.id FROM entities e JOIN subtree s ON e.parent_id=s.id)
|
|
110
|
+
INSERT OR IGNORE INTO ${table}(id) SELECT id FROM subtree`).run(id);
|
|
111
|
+
}
|
|
112
|
+
if (!scoped)
|
|
113
|
+
this.db.exec(`INSERT INTO ${table}(id) SELECT id FROM entities`);
|
|
114
|
+
if (selection.entityTypes)
|
|
115
|
+
this.db.prepare(`DELETE FROM ${table} WHERE id IN (SELECT id FROM entities WHERE entity_type NOT IN (${selection.entityTypes.map(() => '?').join(',')}))`).run(...selection.entityTypes);
|
|
116
|
+
if (selection.includeAncestors !== false)
|
|
117
|
+
this.db.exec(`WITH RECURSIVE ancestors(id) AS (
|
|
118
|
+
SELECT e.parent_id FROM entities e JOIN ${table} s ON e.id=s.id WHERE e.parent_id IS NOT NULL UNION
|
|
119
|
+
SELECT e.parent_id FROM entities e JOIN ancestors a ON e.id=a.id WHERE e.parent_id IS NOT NULL)
|
|
120
|
+
INSERT OR IGNORE INTO ${table}(id,context) SELECT id,1 FROM ancestors`);
|
|
121
|
+
const contextIds = this.db.prepare(`SELECT s.id FROM ${table} s JOIN entities e ON s.id=e.id WHERE s.context=1 ORDER BY e.depth,e.id`).all().map(row => row.id);
|
|
122
|
+
const externalReferences = this.db.prepare(`SELECT DISTINCT ref FROM (
|
|
123
|
+
SELECT e.parent_id AS ref FROM entities e JOIN ${table} s ON s.id=e.id UNION
|
|
124
|
+
SELECT e.country_id AS ref FROM entities e JOIN ${table} s ON s.id=e.id)
|
|
125
|
+
WHERE ref IS NOT NULL AND ref NOT IN (SELECT id FROM ${table}) ORDER BY ref`).all().map(row => row.ref);
|
|
126
|
+
const db = this.db;
|
|
127
|
+
let disposed = false;
|
|
128
|
+
function* iterate(order) {
|
|
129
|
+
if (disposed)
|
|
130
|
+
throw new Error('Selection plan is disposed');
|
|
131
|
+
for (const row of db.prepare(`SELECT e.data FROM entities e JOIN ${table} s ON s.id=e.id ORDER BY ${order}`).iterate()) {
|
|
132
|
+
const entity = JSON.parse(row.data);
|
|
133
|
+
if (!selection.coordinates)
|
|
134
|
+
delete entity.coordinates;
|
|
135
|
+
if (!selection.boundaries)
|
|
136
|
+
delete entity.boundary;
|
|
137
|
+
yield entity;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { request, metadata: this.metadata, count: Number(db.prepare(`SELECT count(*) AS count FROM ${table}`).get().count), contextIds, externalReferences,
|
|
141
|
+
entities: () => iterate('e.depth,e.id'), identityEntities: () => iterate('e.id'),
|
|
142
|
+
dispose() { if (!disposed) {
|
|
143
|
+
db.exec(`DROP TABLE ${table}`);
|
|
144
|
+
disposed = true;
|
|
145
|
+
} } };
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
this.db.exec(`DROP TABLE IF EXISTS ${table}`);
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
close() { this.db.close(); }
|
|
153
|
+
}
|
|
154
|
+
//# sourceMappingURL=snapshot.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"snapshot.js","sourceRoot":"","sources":["../src/snapshot.ts"],"names":[],"mappings":"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","sourcesContent":["import { DatabaseSync } from 'node:sqlite';\nimport { randomUUID } from 'node:crypto';\nimport { access, mkdir, link, unlink } from 'node:fs/promises';\nimport { dirname } from 'node:path';\nimport { canonicalJson, validateEntity, validateMetadata } from './validation.ts';\nimport type { Entity, SnapshotMetadata, Selection, ExportPlan, ExportRequest } from './types.ts';\n\nfunction ensureGraph(db: DatabaseSync): void {\n if (db.prepare('PRAGMA foreign_key_check').all().length) throw new Error('Unknown parent reference');\n const invalid = db.prepare(`SELECT e.id FROM entities e LEFT JOIN entities c ON e.country_id=c.id LEFT JOIN entities p ON e.parent_id=p.id\n WHERE c.entity_type IS NULL OR c.entity_type!='country' OR\n (e.parent_id IS NOT NULL AND (p.country_id!=e.country_id OR p.entity_type='locality')) LIMIT 1`).get();\n if (invalid) throw new Error(`Invalid country or parent relationship for ${invalid.id}`);\n const reached = db.prepare(`WITH RECURSIVE tree(id,depth) AS (\n SELECT id,0 FROM entities WHERE parent_id IS NULL UNION ALL\n SELECT e.id,t.depth+1 FROM entities e JOIN tree t ON e.parent_id=t.id)\n SELECT count(*) AS count FROM tree`).get()!;\n if (Number(reached.count) !== Number(db.prepare('SELECT count(*) AS count FROM entities').get()!.count)) throw new Error('Cycle or unreachable entity in parent graph');\n db.exec(`WITH RECURSIVE tree(id,depth) AS (\n SELECT id,0 FROM entities WHERE parent_id IS NULL UNION ALL\n SELECT e.id,t.depth+1 FROM entities e JOIN tree t ON e.parent_id=t.id)\n UPDATE entities SET depth=(SELECT depth FROM tree WHERE tree.id=entities.id)`);\n}\n\nexport async function createSnapshot(path: string, metadata: SnapshotMetadata, entities: Iterable<Entity> | AsyncIterable<Entity>): Promise<void> {\n validateMetadata(metadata);\n await mkdir(dirname(path), { recursive: true });\n try { await access(path); throw new Error('Snapshot destination already exists'); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }\n const temporary = path + '.' + randomUUID() + '.tmp';\n const db = new DatabaseSync(temporary);\n let closed = false;\n try {\n db.exec(`PRAGMA foreign_keys=ON; BEGIN;\n CREATE TABLE metadata (value TEXT NOT NULL);\n CREATE TABLE entities (id TEXT PRIMARY KEY, entity_type TEXT NOT NULL, country_id TEXT NOT NULL,\n parent_id TEXT REFERENCES entities(id) DEFERRABLE INITIALLY DEFERRED, depth INTEGER NOT NULL DEFAULT 0, data TEXT NOT NULL);\n CREATE INDEX entities_parent ON entities(parent_id);\n CREATE INDEX entities_country ON entities(country_id);`);\n db.prepare('INSERT INTO metadata VALUES (?)').run(canonicalJson(metadata));\n const insert = db.prepare('INSERT INTO entities (id,entity_type,country_id,parent_id,data) VALUES (?,?,?,?,?)');\n for await (const entity of entities) {\n validateEntity(entity);\n insert.run(entity.id,entity.entityType,entity.countryId,entity.parentId,canonicalJson(entity));\n }\n ensureGraph(db);\n db.exec('COMMIT; PRAGMA optimize;');\n db.close(); closed = true;\n await link(temporary,path);\n } finally {\n if (!closed) db.close();\n await unlink(temporary).catch(()=>{});\n }\n}\n\nexport class Snapshot {\n private db: DatabaseSync;\n private nextPlan = 0;\n private constructor(path: string) {\n this.db = new DatabaseSync(path,{readOnly:true});\n try { validateMetadata(this.metadata); } catch (error) { this.db.close(); throw error; }\n }\n static open(path: string): Snapshot { return new Snapshot(path); }\n get metadata(): SnapshotMetadata { return JSON.parse(this.db.prepare('SELECT value FROM metadata').get()!.value as string) as SnapshotMetadata; }\n get count(): number { return Number(this.db.prepare('SELECT count(*) AS count FROM entities').get()!.count); }\n get(id: string): Entity | undefined {\n const row=this.db.prepare('SELECT data FROM entities WHERE id=?').get(id);\n return row ? JSON.parse(row.data as string) as Entity : undefined;\n }\n *entities(): IterableIterator<Entity> {\n for (const row of this.db.prepare('SELECT data FROM entities ORDER BY depth,id').iterate()) yield JSON.parse(row.data as string) as Entity;\n }\n select(selection: Selection): ExportPlan {\n selection=structuredClone(selection);\n const request = {format:'json',...selection} as ExportRequest;\n if (selection.entityTypes && (selection.entityTypes.length===0 || selection.entityTypes.some(type=>!['country','subdivision','locality'].includes(type)))) throw new Error('Invalid entityTypes selection');\n const table = `selected_${this.nextPlan++}`;\n this.db.exec(`CREATE TEMP TABLE ${table} (id TEXT PRIMARY KEY, context INTEGER NOT NULL DEFAULT 0)`);\n try {\n const insert = this.db.prepare(`INSERT OR IGNORE INTO ${table}(id) SELECT id FROM entities WHERE country_id=?`);\n const scoped = Boolean(selection.countries?.length || selection.subdivisions?.length);\n for (const country of selection.countries ?? []) {\n const matches=this.db.prepare(`SELECT id FROM entities WHERE entity_type='country' AND\n (id=? OR upper(json_extract(data,'$.codes.iso2'))=upper(?) OR upper(json_extract(data,'$.codes.iso3'))=upper(?))`).all(country,country,country);\n if (matches.length!==1) throw new Error(`Unknown country or ambiguous code: ${country}`);\n insert.run(matches[0]!.id as string);\n }\n for (const id of selection.subdivisions ?? []) {\n const entity=this.get(id);\n if (!entity || entity.entityType!=='subdivision') throw new Error(`Unknown subdivision: ${id}`);\n this.db.prepare(`WITH RECURSIVE subtree(id) AS (SELECT ? UNION ALL SELECT e.id FROM entities e JOIN subtree s ON e.parent_id=s.id)\n INSERT OR IGNORE INTO ${table}(id) SELECT id FROM subtree`).run(id);\n }\n if (!scoped) this.db.exec(`INSERT INTO ${table}(id) SELECT id FROM entities`);\n if (selection.entityTypes) this.db.prepare(`DELETE FROM ${table} WHERE id IN (SELECT id FROM entities WHERE entity_type NOT IN (${selection.entityTypes.map(()=>'?').join(',')}))`).run(...selection.entityTypes);\n if (selection.includeAncestors!==false) this.db.exec(`WITH RECURSIVE ancestors(id) AS (\n SELECT e.parent_id FROM entities e JOIN ${table} s ON e.id=s.id WHERE e.parent_id IS NOT NULL UNION\n SELECT e.parent_id FROM entities e JOIN ancestors a ON e.id=a.id WHERE e.parent_id IS NOT NULL)\n INSERT OR IGNORE INTO ${table}(id,context) SELECT id,1 FROM ancestors`);\n const contextIds=this.db.prepare(`SELECT s.id FROM ${table} s JOIN entities e ON s.id=e.id WHERE s.context=1 ORDER BY e.depth,e.id`).all().map(row=>row.id as string);\n const externalReferences=this.db.prepare(`SELECT DISTINCT ref FROM (\n SELECT e.parent_id AS ref FROM entities e JOIN ${table} s ON s.id=e.id UNION\n SELECT e.country_id AS ref FROM entities e JOIN ${table} s ON s.id=e.id)\n WHERE ref IS NOT NULL AND ref NOT IN (SELECT id FROM ${table}) ORDER BY ref`).all().map(row=>row.ref as string);\n const db=this.db;let disposed=false;\n function* iterate(order:string):IterableIterator<Entity> {\n if(disposed) throw new Error('Selection plan is disposed');\n for (const row of db.prepare(`SELECT e.data FROM entities e JOIN ${table} s ON s.id=e.id ORDER BY ${order}`).iterate()) {\n const entity=JSON.parse(row.data as string) as Entity;\n if (!selection.coordinates) delete entity.coordinates;\n if (!selection.boundaries) delete entity.boundary;\n yield entity;\n }\n }\n return {request, metadata:this.metadata, count:Number(db.prepare(`SELECT count(*) AS count FROM ${table}`).get()!.count),contextIds,externalReferences,\n entities:()=>iterate('e.depth,e.id'),identityEntities:()=>iterate('e.id'),\n dispose(){ if(!disposed) {db.exec(`DROP TABLE ${table}`);disposed=true;} }\n };\n } catch(error) {this.db.exec(`DROP TABLE IF EXISTS ${table}`);throw error;}\n }\n close(): void { this.db.close(); }\n}\n"]}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Entity, SnapshotMetadata } from '../types.ts';
|
|
2
|
+
export interface GeoNamesPaths {
|
|
3
|
+
countryInfo: string;
|
|
4
|
+
admin1?: string;
|
|
5
|
+
admin2?: string;
|
|
6
|
+
places?: string;
|
|
7
|
+
}
|
|
8
|
+
/** Ingest unzipped local GeoNames files. Records/maps are retained in memory. */
|
|
9
|
+
export declare function importGeoNames(paths: GeoNamesPaths, options: {
|
|
10
|
+
datasetVersion: string;
|
|
11
|
+
}): Promise<{
|
|
12
|
+
metadata: SnapshotMetadata;
|
|
13
|
+
entities: Entity[];
|
|
14
|
+
}>;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
/** Ingest unzipped local GeoNames files. Records/maps are retained in memory. */
|
|
5
|
+
export async function importGeoNames(paths, options) {
|
|
6
|
+
if (!options.datasetVersion.trim())
|
|
7
|
+
throw new Error('A nonempty dataset version is required');
|
|
8
|
+
const entities = [];
|
|
9
|
+
const identifiers = new Set();
|
|
10
|
+
const countries = new Map();
|
|
11
|
+
const subdivisions = new Map();
|
|
12
|
+
const sources = [];
|
|
13
|
+
function add(entity) {
|
|
14
|
+
if (identifiers.has(entity.id))
|
|
15
|
+
throw new Error(`Duplicate GeoNames ID: ${entity.id}`);
|
|
16
|
+
identifiers.add(entity.id);
|
|
17
|
+
entities.push(entity);
|
|
18
|
+
}
|
|
19
|
+
function countryId(code) {
|
|
20
|
+
const id = countries.get(code);
|
|
21
|
+
if (!id)
|
|
22
|
+
throw new Error(`Unknown country code ${code}; include its countryInfo record`);
|
|
23
|
+
return id;
|
|
24
|
+
}
|
|
25
|
+
async function consume(path, label, minimum, onRow) {
|
|
26
|
+
const stream = createReadStream(path);
|
|
27
|
+
const hash = createHash('sha256');
|
|
28
|
+
stream.on('data', chunk => hash.update(chunk));
|
|
29
|
+
const lines = createInterface({ input: stream, crlfDelay: Infinity });
|
|
30
|
+
let lineNumber = 0;
|
|
31
|
+
try {
|
|
32
|
+
for await (const line of lines) {
|
|
33
|
+
lineNumber++;
|
|
34
|
+
if (!line.trim() || line.startsWith('#'))
|
|
35
|
+
continue;
|
|
36
|
+
const row = line.split('\t');
|
|
37
|
+
if (row.length < minimum)
|
|
38
|
+
throw new Error(`Expected at least ${minimum} tab-separated columns`);
|
|
39
|
+
try {
|
|
40
|
+
onRow(row);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
throw new Error(`${label}:${lineNumber}: ${error.message}`, { cause: error });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
lines.close();
|
|
49
|
+
stream.destroy();
|
|
50
|
+
}
|
|
51
|
+
sources.push({
|
|
52
|
+
name: `GeoNames ${label}`,
|
|
53
|
+
url: label === 'populated places' ? 'https://download.geonames.org/export/dump/' : `https://download.geonames.org/export/dump/${label}`,
|
|
54
|
+
license: 'CC-BY-4.0',
|
|
55
|
+
attribution: 'GeoNames — https://www.geonames.org/ — Creative Commons Attribution 4.0',
|
|
56
|
+
revision: `sha256:${hash.digest('hex')}`,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
await consume(paths.countryInfo, 'countryInfo.txt', 17, row => {
|
|
60
|
+
const code = row[0];
|
|
61
|
+
if (!/^[A-Z]{2}$/.test(code))
|
|
62
|
+
throw new Error(`Invalid country code ${code}`);
|
|
63
|
+
if (countries.has(code))
|
|
64
|
+
throw new Error(`Duplicate country code ${code}`);
|
|
65
|
+
const id = sourceId(row[16]);
|
|
66
|
+
countries.set(code, id);
|
|
67
|
+
add({ id, entityType: 'country', name: requiredName(row[4]), countryId: id, parentId: null, codes: { iso2: code, iso3: row[1], geonames: row[16] } });
|
|
68
|
+
});
|
|
69
|
+
if (!countries.size)
|
|
70
|
+
throw new Error('countryInfo.txt contains no country records');
|
|
71
|
+
for (const [path, level, label] of [[paths.admin1, 1, 'admin1CodesASCII.txt'], [paths.admin2, 2, 'admin2Codes.txt']]) {
|
|
72
|
+
if (!path)
|
|
73
|
+
continue;
|
|
74
|
+
await consume(path, label, 4, row => {
|
|
75
|
+
const code = row[0];
|
|
76
|
+
const parts = code.split('.');
|
|
77
|
+
if (parts.length !== level + 1 || parts.some(part => !part))
|
|
78
|
+
throw new Error(`Invalid administrative code ${code}`);
|
|
79
|
+
if (subdivisions.has(code))
|
|
80
|
+
throw new Error(`Duplicate administrative code ${code}`);
|
|
81
|
+
const country = countryId(parts[0]);
|
|
82
|
+
const id = sourceId(row[3]);
|
|
83
|
+
const parentId = level === 2 ? subdivisions.get(parts.slice(0, 2).join('.')) ?? country : country;
|
|
84
|
+
add({ id, entityType: 'subdivision', name: requiredName(row[1]), countryId: country, parentId, level, codes: { geonames: row[3], geonamesAdmin: code } });
|
|
85
|
+
subdivisions.set(code, id);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
if (paths.places)
|
|
89
|
+
await consume(paths.places, 'populated places', 19, row => {
|
|
90
|
+
if (row[6] !== 'P')
|
|
91
|
+
return;
|
|
92
|
+
const countryCode = row[8];
|
|
93
|
+
const country = countryId(countryCode);
|
|
94
|
+
const id = sourceId(row[0]);
|
|
95
|
+
const admin1 = `${countryCode}.${row[10]}`;
|
|
96
|
+
const admin2 = `${admin1}.${row[11]}`;
|
|
97
|
+
const parentId = subdivisions.get(admin2) ?? subdivisions.get(admin1) ?? country;
|
|
98
|
+
const latitude = Number(row[4]);
|
|
99
|
+
const longitude = Number(row[5]);
|
|
100
|
+
if (!row[4]?.trim() || !row[5]?.trim() || !Number.isFinite(latitude) || !Number.isFinite(longitude) || Math.abs(latitude) > 90 || Math.abs(longitude) > 180) {
|
|
101
|
+
throw new Error(`Invalid coordinates for ${id}`);
|
|
102
|
+
}
|
|
103
|
+
const adminCode = row[11] ? admin2 : row[10] ? admin1 : countryCode;
|
|
104
|
+
add({ id, entityType: 'locality', name: requiredName(row[1]), countryId: country, parentId, codes: { geonames: row[0], geonamesAdmin: adminCode }, coordinates: { longitude, latitude } });
|
|
105
|
+
});
|
|
106
|
+
return { metadata: { contractVersion: '1.0', datasetVersion: options.datasetVersion, sources }, entities };
|
|
107
|
+
}
|
|
108
|
+
function sourceId(value) {
|
|
109
|
+
if (!/^[1-9]\d*$/.test(value))
|
|
110
|
+
throw new Error(`Invalid GeoNames geoname ID: ${value}`);
|
|
111
|
+
return `geonames:${value}`;
|
|
112
|
+
}
|
|
113
|
+
function requiredName(value) {
|
|
114
|
+
if (!value.trim())
|
|
115
|
+
throw new Error('An entity name must not be empty');
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
118
|
+
//# sourceMappingURL=geonames.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"geonames.js","sourceRoot":"","sources":["../../src/sources/geonames.ts"],"names":[],"mappings":"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","sourcesContent":["import { createReadStream } from 'node:fs';\nimport { createHash } from 'node:crypto';\nimport { createInterface } from 'node:readline';\nimport type { DataSource, Entity, SnapshotMetadata } from '../types.ts';\n\nexport interface GeoNamesPaths {\n countryInfo: string;\n admin1?: string;\n admin2?: string;\n places?: string;\n}\n\n/** Ingest unzipped local GeoNames files. Records/maps are retained in memory. */\nexport async function importGeoNames(\n paths: GeoNamesPaths,\n options: { datasetVersion: string },\n): Promise<{ metadata: SnapshotMetadata; entities: Entity[] }> {\n if (!options.datasetVersion.trim()) throw new Error('A nonempty dataset version is required');\n const entities: Entity[] = [];\n const identifiers = new Set<string>();\n const countries = new Map<string, string>();\n const subdivisions = new Map<string, string>();\n const sources: DataSource[] = [];\n\n function add(entity: Entity) {\n if (identifiers.has(entity.id)) throw new Error(`Duplicate GeoNames ID: ${entity.id}`);\n identifiers.add(entity.id);\n entities.push(entity);\n }\n\n function countryId(code: string) {\n const id = countries.get(code);\n if (!id) throw new Error(`Unknown country code ${code}; include its countryInfo record`);\n return id;\n }\n\n async function consume(path: string, label: string, minimum: number, onRow: (row: string[]) => void) {\n const stream = createReadStream(path);\n const hash = createHash('sha256');\n stream.on('data', chunk => hash.update(chunk));\n const lines = createInterface({input:stream,crlfDelay:Infinity});\n let lineNumber = 0;\n try {\n for await (const line of lines) {\n lineNumber++;\n if (!line.trim() || line.startsWith('#')) continue;\n const row = line.split('\\t');\n if (row.length < minimum) throw new Error(`Expected at least ${minimum} tab-separated columns`);\n try { onRow(row); }\n catch (error) { throw new Error(`${label}:${lineNumber}: ${(error as Error).message}`, {cause:error}); }\n }\n } finally { lines.close(); stream.destroy(); }\n sources.push({\n name:`GeoNames ${label}`,\n url:label === 'populated places' ? 'https://download.geonames.org/export/dump/' : `https://download.geonames.org/export/dump/${label}`,\n license:'CC-BY-4.0',\n attribution:'GeoNames — https://www.geonames.org/ — Creative Commons Attribution 4.0',\n revision:`sha256:${hash.digest('hex')}`,\n });\n }\n\n await consume(paths.countryInfo, 'countryInfo.txt', 17, row => {\n const code = row[0]!;\n if (!/^[A-Z]{2}$/.test(code)) throw new Error(`Invalid country code ${code}`);\n if (countries.has(code)) throw new Error(`Duplicate country code ${code}`);\n const id = sourceId(row[16]!);\n countries.set(code, id);\n add({id,entityType:'country',name:requiredName(row[4]!),countryId:id,parentId:null,codes:{iso2:code,iso3:row[1]!,geonames:row[16]!}});\n });\n if (!countries.size) throw new Error('countryInfo.txt contains no country records');\n\n for (const [path, level, label] of [[paths.admin1,1,'admin1CodesASCII.txt'],[paths.admin2,2,'admin2Codes.txt']] as const) {\n if (!path) continue;\n await consume(path,label,4,row => {\n const code = row[0]!;\n const parts = code.split('.');\n if (parts.length !== level + 1 || parts.some(part => !part)) throw new Error(`Invalid administrative code ${code}`);\n if (subdivisions.has(code)) throw new Error(`Duplicate administrative code ${code}`);\n const country = countryId(parts[0]!);\n const id = sourceId(row[3]!);\n const parentId = level === 2 ? subdivisions.get(parts.slice(0,2).join('.')) ?? country : country;\n add({id,entityType:'subdivision',name:requiredName(row[1]!),countryId:country,parentId,level,codes:{geonames:row[3]!,geonamesAdmin:code}});\n subdivisions.set(code,id);\n });\n }\n\n if (paths.places) await consume(paths.places,'populated places',19,row => {\n if (row[6] !== 'P') return;\n const countryCode = row[8]!;\n const country = countryId(countryCode);\n const id = sourceId(row[0]!);\n const admin1 = `${countryCode}.${row[10]!}`;\n const admin2 = `${admin1}.${row[11]!}`;\n const parentId = subdivisions.get(admin2) ?? subdivisions.get(admin1) ?? country;\n const latitude = Number(row[4]);\n const longitude = Number(row[5]);\n if (!row[4]?.trim() || !row[5]?.trim() || !Number.isFinite(latitude) || !Number.isFinite(longitude) || Math.abs(latitude) > 90 || Math.abs(longitude) > 180) {\n throw new Error(`Invalid coordinates for ${id}`);\n }\n const adminCode = row[11] ? admin2 : row[10] ? admin1 : countryCode;\n add({id,entityType:'locality',name:requiredName(row[1]!),countryId:country,parentId,codes:{geonames:row[0]!,geonamesAdmin:adminCode},coordinates:{longitude,latitude}});\n });\n\n return {metadata:{contractVersion:'1.0',datasetVersion:options.datasetVersion,sources},entities};\n}\n\nfunction sourceId(value: string): string {\n if (!/^[1-9]\\d*$/.test(value)) throw new Error(`Invalid GeoNames geoname ID: ${value}`);\n return `geonames:${value}`;\n}\n\nfunction requiredName(value: string): string {\n if (!value.trim()) throw new Error('An entity name must not be empty');\n return value;\n}\n"]}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/** Public v1 contract. IDs are opaque strings and never derived from display names. */
|
|
2
|
+
export type EntityType = 'country' | 'subdivision' | 'locality';
|
|
3
|
+
export interface Coordinates {
|
|
4
|
+
longitude: number;
|
|
5
|
+
latitude: number;
|
|
6
|
+
}
|
|
7
|
+
export interface Polygon {
|
|
8
|
+
type: 'Polygon';
|
|
9
|
+
coordinates: number[][][];
|
|
10
|
+
}
|
|
11
|
+
export interface MultiPolygon {
|
|
12
|
+
type: 'MultiPolygon';
|
|
13
|
+
coordinates: number[][][][];
|
|
14
|
+
}
|
|
15
|
+
export type Boundary = Polygon | MultiPolygon;
|
|
16
|
+
export interface Entity {
|
|
17
|
+
id: string;
|
|
18
|
+
entityType: EntityType;
|
|
19
|
+
name: string;
|
|
20
|
+
countryId: string;
|
|
21
|
+
parentId: string | null;
|
|
22
|
+
codes?: Record<string, string>;
|
|
23
|
+
names?: Record<string, string>;
|
|
24
|
+
level?: number;
|
|
25
|
+
coordinates?: Coordinates | null;
|
|
26
|
+
boundary?: Boundary | null;
|
|
27
|
+
}
|
|
28
|
+
export interface DataSource {
|
|
29
|
+
name: string;
|
|
30
|
+
url: string;
|
|
31
|
+
license: string;
|
|
32
|
+
attribution: string;
|
|
33
|
+
revision: string;
|
|
34
|
+
}
|
|
35
|
+
export interface SnapshotMetadata {
|
|
36
|
+
contractVersion: '1.0';
|
|
37
|
+
datasetVersion: string;
|
|
38
|
+
sources: DataSource[];
|
|
39
|
+
}
|
|
40
|
+
export interface Selection {
|
|
41
|
+
countries?: string[];
|
|
42
|
+
subdivisions?: string[];
|
|
43
|
+
entityTypes?: EntityType[];
|
|
44
|
+
includeAncestors?: boolean;
|
|
45
|
+
coordinates?: boolean;
|
|
46
|
+
boundaries?: boolean;
|
|
47
|
+
}
|
|
48
|
+
export type ExportFormat = 'json' | 'ndjson' | 'csv' | 'sql-postgres' | 'sqlite' | 'geojson' | (string & {});
|
|
49
|
+
export interface ExportRequest extends Selection {
|
|
50
|
+
format: ExportFormat;
|
|
51
|
+
layout?: 'flat' | 'hierarchical';
|
|
52
|
+
compression?: 'none' | 'gzip' | 'zip';
|
|
53
|
+
schemaMode?: 'schema-and-data' | 'data-only';
|
|
54
|
+
sqlMode?: 'upsert' | 'insert-only';
|
|
55
|
+
spreadsheetSafe?: boolean;
|
|
56
|
+
}
|
|
57
|
+
export interface ExportPlan {
|
|
58
|
+
request: ExportRequest;
|
|
59
|
+
metadata: SnapshotMetadata;
|
|
60
|
+
count: number;
|
|
61
|
+
contextIds: string[];
|
|
62
|
+
externalReferences: string[];
|
|
63
|
+
/** Repeatable iterator; each call starts at the beginning, in dependency order. */
|
|
64
|
+
entities(): Iterable<Entity>;
|
|
65
|
+
identityEntities?(): Iterable<Entity>;
|
|
66
|
+
dispose?(): void;
|
|
67
|
+
}
|
|
68
|
+
export interface AdapterContext {
|
|
69
|
+
plan: ExportPlan;
|
|
70
|
+
directory: string;
|
|
71
|
+
signal?: AbortSignal;
|
|
72
|
+
}
|
|
73
|
+
export interface ExportAdapter {
|
|
74
|
+
format: ExportFormat;
|
|
75
|
+
version: string;
|
|
76
|
+
validate(request: ExportRequest): void;
|
|
77
|
+
/** Return relative filenames, never absolute paths; close all file handles before resolving. */
|
|
78
|
+
write(context: AdapterContext): Promise<string[]>;
|
|
79
|
+
}
|
|
80
|
+
export interface ManifestFile {
|
|
81
|
+
path: string;
|
|
82
|
+
bytes: number;
|
|
83
|
+
sha256: string;
|
|
84
|
+
}
|
|
85
|
+
export interface ExportManifest {
|
|
86
|
+
contractVersion: '1.0';
|
|
87
|
+
generatorVersion: string;
|
|
88
|
+
adapterVersion: string;
|
|
89
|
+
datasetVersion: string;
|
|
90
|
+
contentId: string;
|
|
91
|
+
request: ExportRequest;
|
|
92
|
+
sources: DataSource[];
|
|
93
|
+
count: number;
|
|
94
|
+
counts: Record<EntityType, number>;
|
|
95
|
+
contextIds: string[];
|
|
96
|
+
externalReferences: string[];
|
|
97
|
+
geometry: {
|
|
98
|
+
coordinates: {
|
|
99
|
+
requested: boolean;
|
|
100
|
+
missing: number;
|
|
101
|
+
};
|
|
102
|
+
boundaries: {
|
|
103
|
+
requested: boolean;
|
|
104
|
+
missing: number;
|
|
105
|
+
};
|
|
106
|
+
crs: 'OGC:CRS84';
|
|
107
|
+
};
|
|
108
|
+
scope: {
|
|
109
|
+
kind: 'full';
|
|
110
|
+
complete: boolean;
|
|
111
|
+
};
|
|
112
|
+
files: ManifestFile[];
|
|
113
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"","sourcesContent":["/** Public v1 contract. IDs are opaque strings and never derived from display names. */\nexport type EntityType = 'country' | 'subdivision' | 'locality';\nexport interface Coordinates { longitude: number; latitude: number }\nexport interface Polygon { type: 'Polygon'; coordinates: number[][][] }\nexport interface MultiPolygon { type: 'MultiPolygon'; coordinates: number[][][][] }\nexport type Boundary = Polygon | MultiPolygon;\nexport interface Entity {\n id: string;\n entityType: EntityType;\n name: string;\n countryId: string;\n parentId: string | null;\n codes?: Record<string, string>;\n names?: Record<string, string>;\n level?: number;\n coordinates?: Coordinates | null;\n boundary?: Boundary | null;\n}\nexport interface DataSource {\n name: string;\n url: string;\n license: string;\n attribution: string;\n revision: string;\n}\nexport interface SnapshotMetadata {\n contractVersion: '1.0';\n datasetVersion: string;\n sources: DataSource[];\n}\nexport interface Selection {\n countries?: string[];\n subdivisions?: string[];\n entityTypes?: EntityType[];\n includeAncestors?: boolean;\n coordinates?: boolean;\n boundaries?: boolean;\n}\nexport type ExportFormat = 'json' | 'ndjson' | 'csv' | 'sql-postgres' | 'sqlite' | 'geojson' | (string & {});\nexport interface ExportRequest extends Selection {\n format: ExportFormat;\n layout?: 'flat' | 'hierarchical';\n compression?: 'none' | 'gzip' | 'zip';\n schemaMode?: 'schema-and-data' | 'data-only';\n sqlMode?: 'upsert' | 'insert-only';\n spreadsheetSafe?: boolean;\n}\nexport interface ExportPlan {\n request: ExportRequest;\n metadata: SnapshotMetadata;\n count: number;\n contextIds: string[];\n externalReferences: string[];\n /** Repeatable iterator; each call starts at the beginning, in dependency order. */\n entities(): Iterable<Entity>;\n identityEntities?(): Iterable<Entity>;\n dispose?(): void;\n}\nexport interface AdapterContext {\n plan: ExportPlan;\n directory: string;\n signal?: AbortSignal;\n}\nexport interface ExportAdapter {\n format: ExportFormat;\n version: string;\n validate(request: ExportRequest): void;\n /** Return relative filenames, never absolute paths; close all file handles before resolving. */\n write(context: AdapterContext): Promise<string[]>;\n}\nexport interface ManifestFile { path: string; bytes: number; sha256: string }\nexport interface ExportManifest {\n contractVersion: '1.0';\n generatorVersion: string;\n adapterVersion: string;\n datasetVersion: string;\n contentId: string;\n request: ExportRequest;\n sources: DataSource[];\n count: number;\n counts: Record<EntityType, number>;\n contextIds: string[];\n externalReferences: string[];\n geometry: { coordinates: { requested: boolean; missing: number }; boundaries: { requested: boolean; missing: number }; crs: 'OGC:CRS84' };\n scope: { kind: 'full'; complete: boolean };\n files: ManifestFile[];\n}\n"]}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Entity, SnapshotMetadata } from './types.ts';
|
|
2
|
+
export declare function canonicalJson(value: unknown): string;
|
|
3
|
+
export declare function validateMetadata(value: unknown): asserts value is SnapshotMetadata;
|
|
4
|
+
export declare function validateEntity(value: unknown): asserts value is Entity;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
export function canonicalJson(value) {
|
|
2
|
+
if (Array.isArray(value))
|
|
3
|
+
return '[' + value.map(canonicalJson).join(',') + ']';
|
|
4
|
+
if (value !== null && typeof value === 'object') {
|
|
5
|
+
const object = value;
|
|
6
|
+
return '{' + Object.keys(object).filter(key => object[key] !== undefined).sort().map(key => JSON.stringify(key) + ':' + canonicalJson(object[key])).join(',') + '}';
|
|
7
|
+
}
|
|
8
|
+
const result = JSON.stringify(value);
|
|
9
|
+
if (result === undefined)
|
|
10
|
+
throw new Error('Unsupported JSON value');
|
|
11
|
+
return result;
|
|
12
|
+
}
|
|
13
|
+
function object(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); }
|
|
14
|
+
function text(value, label) {
|
|
15
|
+
if (typeof value !== 'string' || !value.trim() || value.includes('\0'))
|
|
16
|
+
throw new Error(`Invalid ${label}: expected nonempty text without NUL`);
|
|
17
|
+
}
|
|
18
|
+
export function validateMetadata(value) {
|
|
19
|
+
if (!object(value) || value.contractVersion !== '1.0')
|
|
20
|
+
throw new Error('Unsupported snapshot contractVersion');
|
|
21
|
+
text(value.datasetVersion, 'datasetVersion');
|
|
22
|
+
if (!Array.isArray(value.sources) || value.sources.length === 0)
|
|
23
|
+
throw new Error('At least one attributed data source is required');
|
|
24
|
+
for (const source of value.sources) {
|
|
25
|
+
if (!object(source))
|
|
26
|
+
throw new Error('Invalid source');
|
|
27
|
+
for (const key of ['name', 'url', 'license', 'attribution', 'revision'])
|
|
28
|
+
text(source[key], `source.${key}`);
|
|
29
|
+
if (!['http:', 'https:'].includes(new URL(source.url).protocol))
|
|
30
|
+
throw new Error('Source URL must be HTTP(S)');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function validateBoundary(value) {
|
|
34
|
+
if (!object(value) || !['Polygon', 'MultiPolygon'].includes(value.type) || !Array.isArray(value.coordinates) || !value.coordinates.length)
|
|
35
|
+
throw new Error('Invalid boundary geometry');
|
|
36
|
+
const polygons = value.type === 'Polygon' ? [value.coordinates] : value.coordinates;
|
|
37
|
+
for (const polygon of polygons) {
|
|
38
|
+
if (!Array.isArray(polygon) || !polygon.length)
|
|
39
|
+
throw new Error('Boundary polygon needs rings');
|
|
40
|
+
for (const ring of polygon) {
|
|
41
|
+
if (!Array.isArray(ring) || ring.length < 4)
|
|
42
|
+
throw new Error('Boundary ring requires at least four positions');
|
|
43
|
+
for (const position of ring) {
|
|
44
|
+
if (!Array.isArray(position) || position.length !== 2 || !Number.isFinite(position[0]) || !Number.isFinite(position[1]) || Math.abs(position[0]) > 180 || Math.abs(position[1]) > 90)
|
|
45
|
+
throw new Error('Invalid boundary coordinate');
|
|
46
|
+
}
|
|
47
|
+
if (ring[0][0] !== ring[ring.length - 1][0] || ring[0][1] !== ring[ring.length - 1][1])
|
|
48
|
+
throw new Error('Boundary ring must be closed');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function validateEntity(value) {
|
|
53
|
+
if (!object(value))
|
|
54
|
+
throw new Error('Entity must be an object');
|
|
55
|
+
const fields = new Set(['id', 'entityType', 'name', 'countryId', 'parentId', 'codes', 'names', 'level', 'coordinates', 'boundary']);
|
|
56
|
+
for (const key of Object.keys(value))
|
|
57
|
+
if (!fields.has(key))
|
|
58
|
+
throw new Error(`Unknown entity field: ${key}`);
|
|
59
|
+
for (const field of ['id', 'name', 'countryId'])
|
|
60
|
+
text(value[field], field);
|
|
61
|
+
if (!['country', 'subdivision', 'locality'].includes(value.entityType))
|
|
62
|
+
throw new Error('Invalid entityType');
|
|
63
|
+
if (value.parentId !== null)
|
|
64
|
+
text(value.parentId, 'parentId');
|
|
65
|
+
if (value.entityType === 'country' && (value.countryId !== value.id || value.parentId !== null))
|
|
66
|
+
throw new Error('Country must reference itself and have no parent');
|
|
67
|
+
if (value.entityType !== 'country' && value.parentId === null)
|
|
68
|
+
throw new Error('Non-country entity requires a parent reference');
|
|
69
|
+
for (const field of ['codes', 'names']) {
|
|
70
|
+
if (value[field] === undefined)
|
|
71
|
+
continue;
|
|
72
|
+
if (!object(value[field]))
|
|
73
|
+
throw new Error(`Invalid ${field}`);
|
|
74
|
+
for (const [key, item] of Object.entries(value[field])) {
|
|
75
|
+
text(key, field + ' key');
|
|
76
|
+
text(item, field + ' value');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (value.level !== undefined && (!Number.isSafeInteger(value.level) || value.level < 1))
|
|
80
|
+
throw new Error('Invalid administrative level');
|
|
81
|
+
if (value.coordinates !== undefined && value.coordinates !== null) {
|
|
82
|
+
if (!object(value.coordinates))
|
|
83
|
+
throw new Error('Invalid coordinates');
|
|
84
|
+
const { longitude, latitude } = value.coordinates;
|
|
85
|
+
if (typeof longitude !== 'number' || typeof latitude !== 'number' || !Number.isFinite(longitude) || !Number.isFinite(latitude) || Math.abs(longitude) > 180 || Math.abs(latitude) > 90)
|
|
86
|
+
throw new Error('Invalid longitude/latitude coordinates');
|
|
87
|
+
}
|
|
88
|
+
if (value.boundary !== undefined && value.boundary !== null)
|
|
89
|
+
validateBoundary(value.boundary);
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=validation.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validation.js","sourceRoot":"","sources":["../src/validation.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Boundary, Entity, SnapshotMetadata } from './types.ts';\n\nexport function canonicalJson(value: unknown): string {\n if (Array.isArray(value)) return '[' + value.map(canonicalJson).join(',') + ']';\n if (value !== null && typeof value === 'object') {\n const object = value as Record<string, unknown>;\n return '{' + Object.keys(object).filter(key => object[key] !== undefined).sort().map(key => JSON.stringify(key) + ':' + canonicalJson(object[key])).join(',') + '}';\n }\n const result = JSON.stringify(value);\n if (result === undefined) throw new Error('Unsupported JSON value');\n return result;\n}\nfunction object(value: unknown): value is Record<string, unknown> { return typeof value === 'object' && value !== null && !Array.isArray(value); }\nfunction text(value: unknown, label: string): asserts value is string {\n if (typeof value !== 'string' || !value.trim() || value.includes('\\0')) throw new Error(`Invalid ${label}: expected nonempty text without NUL`);\n}\nexport function validateMetadata(value: unknown): asserts value is SnapshotMetadata {\n if (!object(value) || value.contractVersion !== '1.0') throw new Error('Unsupported snapshot contractVersion');\n text(value.datasetVersion, 'datasetVersion');\n if (!Array.isArray(value.sources) || value.sources.length === 0) throw new Error('At least one attributed data source is required');\n for (const source of value.sources) {\n if (!object(source)) throw new Error('Invalid source');\n for (const key of ['name','url','license','attribution','revision']) text(source[key], `source.${key}`);\n if (!['http:','https:'].includes(new URL(source.url as string).protocol)) throw new Error('Source URL must be HTTP(S)');\n }\n}\nfunction validateBoundary(value: unknown): asserts value is Boundary {\n if (!object(value) || !['Polygon','MultiPolygon'].includes(value.type as string) || !Array.isArray(value.coordinates) || !value.coordinates.length) throw new Error('Invalid boundary geometry');\n const polygons = value.type === 'Polygon' ? [value.coordinates] : value.coordinates;\n for (const polygon of polygons) {\n if (!Array.isArray(polygon) || !polygon.length) throw new Error('Boundary polygon needs rings');\n for (const ring of polygon) {\n if (!Array.isArray(ring) || ring.length < 4) throw new Error('Boundary ring requires at least four positions');\n for (const position of ring) {\n if (!Array.isArray(position) || position.length !== 2 || !Number.isFinite(position[0]) || !Number.isFinite(position[1]) || Math.abs(position[0]) > 180 || Math.abs(position[1]) > 90) throw new Error('Invalid boundary coordinate');\n }\n if (ring[0][0] !== ring[ring.length-1][0] || ring[0][1] !== ring[ring.length-1][1]) throw new Error('Boundary ring must be closed');\n }\n }\n}\nexport function validateEntity(value: unknown): asserts value is Entity {\n if (!object(value)) throw new Error('Entity must be an object');\n const fields = new Set(['id','entityType','name','countryId','parentId','codes','names','level','coordinates','boundary']);\n for (const key of Object.keys(value)) if (!fields.has(key)) throw new Error(`Unknown entity field: ${key}`);\n for (const field of ['id','name','countryId']) text(value[field], field);\n if (!['country','subdivision','locality'].includes(value.entityType as string)) throw new Error('Invalid entityType');\n if (value.parentId !== null) text(value.parentId, 'parentId');\n if (value.entityType === 'country' && (value.countryId !== value.id || value.parentId !== null)) throw new Error('Country must reference itself and have no parent');\n if (value.entityType !== 'country' && value.parentId === null) throw new Error('Non-country entity requires a parent reference');\n for (const field of ['codes','names']) {\n if (value[field] === undefined) continue;\n if (!object(value[field])) throw new Error(`Invalid ${field}`);\n for (const [key, item] of Object.entries(value[field])) { text(key, field+' key'); text(item, field+' value'); }\n }\n if (value.level !== undefined && (!Number.isSafeInteger(value.level) || (value.level as number) < 1)) throw new Error('Invalid administrative level');\n if (value.coordinates !== undefined && value.coordinates !== null) {\n if (!object(value.coordinates)) throw new Error('Invalid coordinates');\n const {longitude,latitude} = value.coordinates;\n if (typeof longitude !== 'number' || typeof latitude !== 'number' || !Number.isFinite(longitude) || !Number.isFinite(latitude) || Math.abs(longitude)>180 || Math.abs(latitude)>90) throw new Error('Invalid longitude/latitude coordinates');\n }\n if (value.boundary !== undefined && value.boundary !== null) validateBoundary(value.boundary);\n}\n"]}
|