the admin_levels table and everything around it: a country, a state, a city, a neighborhood, a
street — each one a named area with a shape, sitting somewhere on a scale. the ddl, the queries and
the upsert live in repository, the rtree in spatial_index, the three stages that produce the
rows behind admin_level::extract(level), and the connection lifecycle in src/database/mod.rs
calls into the first two; src/database/admin_levels.rs, src/index/coordinates.rs and
src/extract/admin_levels/ are gone.
levelthe scale every named area sits on. osm tags levels 1..11 on boundary relations; geolite extends it
with 12 for streets, which are mapped as ways rather than boundaries. a house number is not a
level: a query answers it beside the ladder, in the house_number of the match.
| level | name | level | name | |
|---|---|---|---|---|
| 1 | continent | 7 | municipality | |
| 2 | country | 8 | city | |
| 3 | region | 9 | locality | |
| 4 | state | 10 | neighborhood | |
| 5 | district | 12 | street | |
| 6 | county | 14 | address |
the set is closed on the way in. new accepts only the levels above, and everything that
writes a level goes through it: the entity carries a level, the presets list levels, and
--admin-level 11 is refused where it is parsed instead of travelling downstream as a number that
would quietly extract nothing. name() returns &'static str, not an Option — there is no
“unknown” to return, because an unnamed level cannot be constructed.
what comes back out is a level too: every row the repository returns carries
admin_level: level, read through level_of, which skips a row whose stored value is outside the
scale with a warning — a defence, since no release ever wrote one. u8 survives in exactly two
places, both of them edges: the admin_levels.admin_level column (and the tantivy term that mirrors
it) and the level field of the json response. a level outside the scale is refused where it is
read: --admin-level 11, --last-admin-levels 11 and ?last_admin_levels=11 all answer with an
error instead of extracting or filtering nothing; level::parse is the one reading of a level
from text.
ordering is by the level value — a higher level is more specific, so a street sorts after the city that contains it — and it is implemented explicitly rather than derived, so that moving a variant cannot silently change it.
admin_level_idan area’s id is the osm id of the way or relation it came from, shifted left one bit, with the
low bit set for relations: a way and a relation that share an osm id stay distinct, the id is a
pure function of the source and never an insert-order rowid, and osm_id() and kind() read it
back. the row carries it from the stage that builds it (from_way or from_relation), so a row
without an origin cannot be built; batch_upsert writes the relation_id/way_id columns back
from kind() and osm_id(). a street folded from several ways (optimize-merge-admin-levels, in
admin_level_hierarchy) keeps the id of the smallest of them and its way_id, and the rows of the
others are gone.
the ways stay named in merged_way_ids, a jsonb array aligned by position with the lines of the
geometry: element i is the osm way line i came from, the street’s own way first. it is NULL on
a row that was never folded, and there is no relation counterpart because only ways fold. it
describes the wkb, so the two are written by the same statement: batch_upsert writes NULL
beside the geometry and batch_upsert_folded, which only the street merge calls, writes the ways,
and a row a stage writes again loses the fold and its trace together. add_merged_way_ids gives the
column to a database built before it on the next writable open, and merged_way_ids_by_ids answers
nothing over a read-only database that never got it, so a query keeps answering there.
geometryadmin_geometry is the wkb column: spatialite’s blob layout on the way in, decoded with
SpatiaLiteWkb on the way out, because geozero’s writer omits the byte-order byte and uses its own
sub-geometry separator, which the ISO WKB reader rejects. a blob that cannot be read degrades to an
empty geometry with a warning instead of failing the query. mbr_center reads the centre of the
blob’s MBR header without decoding the geometry, the shortcut the house-number stage snaps with.
bounding_box is the envelope the rtree indexes; it moved here from query because the
persistence imported it, and a repository importing from the query layer is the wrong direction.
bounding_geometry is the region of --bounding-wkt and of the http bounding_wkt: the polygon
for the exact test, its bounding_box for the rtree and the wkt it was parsed from, the text
the etag of an http answer is derived from. parse_bounding_wkt is its one parse, the cli
value_parser and the http check alike, and it takes only a polygon or a multipolygon.
mbr_of reads the box of a blob without decoding it. nearby_pairs finds which lines touch or come
within a reach of each other, and fold_lines puts the lines of the members of a piece in one
multi-line — always a multi-line, each line as its way has it, never stitched and never dropped —
and answers the way of each line beside it: the two pieces of the street merge. a way the geometry
already holds is told by its id, not by its coordinates, and is not folded again. merged_way_ids
is that answer and the type of the column: json text on the way in, under JSONB(?n), and read back
through JSON(merged_way_ids).
spatial_indexadmin_levels_rtree is a second table subordinate to the first, the same arrangement
osm_pbf_blob_chunks has with osm_pbf_files: a box means nothing without the row it bounds. it
is a virtual table with no indexes of its own, dropped and recreated by every run, which is why it
has functions rather than an impl table. run pages through every row with a geometry and
inserts one box per row; on a file database up to eight readers scan disjoint id ranges in
parallel while the connection that owns the table writes, and an in-memory database is scanned on
the calling thread, because conn.path() is empty for it and a worker could not reopen it.
the rtree’s two reads live here too. nearest(conn, point, envelope) is what the coordinate
service asks: the rtree narrows the streets to a window of 0.1° around the point inside the
envelope, each candidate’s closest point is measured on the ground, and the answer comes most
specific level first, closest first within a level, and by id when two are at the same distance; a
street whose blob is missing, is not a line, or is empty is dropped, and the debug output counts
why. ids_in_bounding_box is the region filter of the text search. neither address nor
repository writes sql over the rtree any more.
extractadmin_level::extract(conn, level, opts, on_event) is the one way rows get into the table. the
domain decides what a level is made of; the caller only watches:
| level | stages | reads |
|---|---|---|
| any other | relations |
boundary relations tagged admin_level=N, assembled into areas |
| 10 | relations, then place_ways |
the same, then ways tagged place=neighbourhood or suburb |
| 12 | streets |
every named way the exclude rules let through |
stages_of(level) returns that list as stage { level, source, ordinal, of }, and extract runs
it in order, emitting an extract_event { stage, step } at each step: started; candidates,
from relations stages only, with how many relations the level has and how many are not extracted
yet; progress(progress_report) after every batch; finished. an event carries its stage, so a
consumer needs no state to know what it is looking at. the cli’s renderer keeps one bar per stage
and prints the lines it always printed. extract_opts carries the thread count of the relations
stage, the name priority and the rule overrides.
what the cli keeps is the run, not the level: the --recreate wipe before the loop, and after the
last level the index and the count on the ledger — the index is created once at the end because
maintaining it through the upserts is the expensive way round.
each stage reads its candidates through the element repositories (osm_relation::repository,
osm_way::repository) and writes through repository::batch_upsert. the relations stage assembles
the member ways of each relation into rings (geometry::assemble_rings), closes each ring that
comes back to its start into a polygon wound clockwise, the way spatialite’s st_buildarea does,
and falls back to a multi-line when nothing closes; a place way closes into one polygon by the same
rule; a street is a line, even when the way is a ring, and always a multi-line, one line per way,
once its ways are folded.
rulesextraction_rules { level, include, exclude } is what a preset can override per level
(admin_levels_rules); resolve_rules answers with the override when there is one and with the
defaults below otherwise. relations stages take no rules: they are selected by the admin_level
tag alone. every source drops elements without a name, because a nameless area cannot produce
useful data.
| level | include | because | exclude | because |
|---|---|---|---|---|
| any other, and 10 | relations tagged admin_level=N |
the simplest selection; neighbourhood boundaries are mapped as relations too | ||
| 10 | ways tagged place=neighbourhood |
the simplest selection | ||
| 10 | ways tagged place=suburb |
in some regions suburbs are the de-facto neighbourhood unit when place=neighbourhood is not mapped |
||
| 12 | every way | the simplest selection | ways tagged place=neighbourhood or place=suburb |
already captured at level 10 |
| 12 | ways tagged leisure=park, building or waterway |
noise in street-level data |
the filter vocabulary is osm_way::way_filter: rules selects with it, and only the way’s
repository knows what each variant means in sql. this table is the reference for those defaults:
a change to rules.rs changes it too (CLAUDE.md, rule 8).