Three reversals had landed without the documentation following them, so the docs described a pipeline that compresses its output, a schema with three secondary indexes, and a browser that re-downloads the file on every visit. None of those are true any more. - Compression: the assembler stopped producing .gz when the databases began shipping as .sqlite3. The deployment guide's "why no uncompressed database can ship" section explained a guard that now exists for the opposite reason — to keep .db, .gz and journals out, so .sqlite3 stays the only name. - Indexes: the architecture printed a DDL with three CREATE INDEX statements and a paragraph on the partial one. schema.go carries none. - Persistence: "the download is repeated every visit ... has not been done" was listed as an open risk after db-cache.js closed it. Replaced with the ETag flow, the offline fallback, and the risks that did replace it. Measured both transfers rather than scaling one from the other, which would have been wrong: 2016 is 142 MB stored and 31 MB delivered, 2017 is 119 MB and 36 MB. The smaller database is the larger download, so neither figure follows from the stored size. Also corrects a CHUNK_BYTES reference to a module that no longer exists, the 238-289 MB per-dataset figure, two paths to web/src/lib/datasets.js, and the CI step list, which omitted npm test and the post-deploy header check. The two code comments that said the same outdated things go with them.
13 KiB
System Architecture
Static site, no backend. The browser downloads the whole SQLite file once and
queries it in memory with sql.js (SQLite compiled to WebAssembly). 2016 costs
about 31 MB to fetch and 142 MB of memory to hold; 2017, 36 MB and 119 MB. Every
query after that is local — an exam-number lookup is immediate, and a name
search scans all 877,460 rows in a few hundred milliseconds.
The download is kept on the device, so the transfer is paid once per device rather than once per visit.
That is a reversal of the previous design, which read the file where it lay over HTTP range requests. See Considered and not taken.
One frontend, one parser, one schema, two datasets.
Data flow
Each stage is a directory; data/ and _site/ are the stores they hand work
through. assembler/ sequences everything from the parser onwards.
▲ crawler/ (Go — manual refresh only, never part of the build)
data/<id>/*.xls(x)
│
▼ parser/ (Go, one binary, one config per dataset)
.build/public/db/<id>.sqlite3
│
▼ assembler/ — row count and size must match datasets.json
.build/public/db/<id>.sqlite3 (uncompressed: the host gzips it on the
│ wire, so a .gz would decompress twice)
▼ assembler/ → npm run build (SvelteKit static, assets = .build/public)
web/dist/
│
▼ assembler/ — one index.html per dataset; every database must be present
_site/ → GitHub Pages
│
▼ browser
download gate → whole file → sql.js in memory → queries never leave the tab
↕
Cache Storage, keyed by ETag — a second visit skips the gate
The download, and keeping it
web/src/lib/database.svelte.js owns the transfer; web/src/lib/db-cache.js
owns what survives it. Opening a dataset page:
- A conditional request asks the server for the database's current ETag.
- If a copy of exactly that version is in Cache Storage, it opens without asking — consent was given once and reuse costs no network.
- Otherwise the gate states the transfer and the memory cost, and downloads
only when the visitor accepts. The response is stored under
<url>?v=<etag>, and every other version of that database is dropped, so a dataset never occupies the disk twice. - If the server cannot be reached at all, any stored version is used. Slightly old frozen exam results beat an error page, and this is what lets the site answer offline.
Cache Storage rather than IndexedDB: the thing being stored is an HTTP response, the browser can stream it to disk instead of holding it as one buffer, and eviction under storage pressure is the browser's business.
Progress is measured against dbSizeMb from the registry, not
Content-Length. The server sends the file gzipped, so Content-Length counts
compressed bytes while the stream yields decompressed ones; the compressed
figure is reported separately as the transfer size.
The dataset id
One identifier ties the whole pipeline together:
data/2017/ → parser/configs/2017.yml → db/2017.sqlite3 → /thptqg/2017/
datasets.json at the repository root declares the ids once, with the row count
and artifact size the assembler enforces. It is JSON rather than a module
because the assembler is a Go program and the web app is not, and JSON is the
only format both parse without a dependency.
Presentation — titles, labels, search examples, SQL presets — stays in
web/src/lib/datasets.js, keyed by id. That file cross-checks the two: a registry
entry with no content, or content for a dataset that was never built, throws at
module load rather than rendering a page with no title or a link to a database
that does not exist.
| id | Exam | Rows | Source |
|---|---|---|---|
2016 |
2016 | 877,460 | dtnt.bacninh.edu.vn |
2017 |
2017 | 861,068 | baotintuc.vn |
Full source URLs are in data-pipeline; the web footer links to them per dataset.
Canonical schema
Defined once in parser/internal/schema/schema.go — DDL, INSERT, column order and the 16
subject regexes. The two YAML configs carry no SQL at all, only per-dataset
parse rules. Config parsing sets KnownFields(true), so a leftover schema:
block fails loudly instead of looking effective while schema.go drives the
build.
CREATE TABLE student (
so_bao_danh TEXT PRIMARY KEY, -- 2017: 8 digits; 2016: 9 digits or a
-- 2-4 letter cluster code then digits
ho_ten TEXT NOT NULL,
ho_ten_ascii TEXT NOT NULL, -- NFD-stripped lowercase, for accent-insensitive search
ngay_sinh TEXT, -- dd/mm/yyyy
ten_cum_thi TEXT, -- 2016 only
gioi_tinh TEXT, -- 2016 only
toan, ngu_van, vat_ly, hoa_hoc, sinh_hoc, khtn,
lich_su, dia_ly, gdcd, khxh,
tieng_anh, tieng_phap, tieng_nga, tieng_duc, tieng_nhat, tieng_trung REAL
);
One table and no secondary indexes. The primary key is the only one, and it
comes free with the table. Everything else is a scan, which in memory costs a
few hundred milliseconds — an index would buy that back at the price of
megabytes on every visitor's network. TestDDLIsFrozen in parser holds an
independent copy of this DDL, so changing the shape has to be deliberate.
Every dataset gets all 22 columns; ones it has no data for are NULL, costing
about a byte per row. khtn, khxh and gdcd are empty on 2016;
ten_cum_thi and gioi_tinh are empty on 2017.
Routing
URLs are flat, one segment per dataset, and the segment is the id:
/thptqg/ hub
/thptqg/2016/
/thptqg/2017/
The route is web/src/routes/[dataset]/, and its entry generator reads the same
datasets.json the assembler does, so the set of pages and the set of databases
cannot drift apart. Unknown paths fall through to the hub.
SvelteKit prerenders one HTML file per route, each with its own <title>.
Asset URLs stay absolute (paths.relative: false), so the copy of the hub that
serves as 404.html resolves its assets from any depth. No SPA 404-fallback
redirect is used — the usual hack rewrites URLs and would interfere with the
deep links.
Serving both exam years without branching
No component contains a per-dataset conditional. Two mechanisms do the work:
- All-NULL columns are hidden.
score-table.sveltedrops any column where every row in the result set is NULL, so 2016 rows surface Cụm thi / GT / Đức / Nhật and 2017 rows surface KHTN / KHXH / GDCD / Nga. - Incomplete admission blocks are skipped.
computeBlocks()only returns a block when the student has all three subjects, so one block list covers both years: GDCD blocks self-exclude on 2016, German and Japanese blocks self-exclude wherever those languages were not sat.
Anything genuinely per-dataset — title, source, database size, search examples,
SQL presets — lives in web/src/lib/datasets.js.
Exam ID formats
web/src/lib/query-mode.js decides whether a query is an exam ID or a name, and
is shared by the dataset page and search-form.svelte (they previously held
separate copies and had drifted apart on exactly this rule). query-mode.test.js
covers every form in the table below.
| Form | Example | Where |
|---|---|---|
| 8 digits | 49008235 |
2017 — first two digits are the province |
| 9 digits with leading zero | 017006021 |
2016 |
| 2-4 letters then digits | BAL000001 |
2016 — exam cluster code |
The letter-prefixed form is the majority case for 2016: 624,424 of 877,460
candidates (71.2%); the remaining 253,036 are all 9-digit. Letter prefixes are
upper-cased before lookup, so bal000001 resolves.
Score tiers
Six-level ladder in scoreTier() (web/src/lib/admission-blocks.js), paired with a
symbol so meaning is never colour-only.
| Tier | Range | Vietnamese |
|---|---|---|
| common | ≤ 1 | Điểm liệt |
| uncommon | < 5 | Chưa đạt |
| rare | 5–6.5 | Trung bình |
| epic | 6.5–8 | Khá |
| legendary | 8–9 | Giỏi |
| prismatic | 9–10 | Xuất sắc |
Admission blocks
Vietnamese universities admit on three-subject combinations (khối thi).
web/src/lib/admission-blocks.js lists the blocks computable from this schema
(A00–A11, B00–B08, C00–C20, D01–D15, plus D05/D06 for German and Japanese).
computeBlocks(student) returns those where all three scores exist, sorted by
total descending.
Design decisions
| Concern | Choice | Rationale |
|---|---|---|
| Storage | Static SQLite file, downloaded whole | No backend; the datasets are frozen, and one large transfer is something browsers and CDNs are both good at |
| Download gate | Blocking, no dismiss | The page has no answers before the file arrives, and 31 MB of someone's mobile data should be asked for rather than spent silently |
| Keeping the download | Cache Storage, versioned by ETag | The transfer is paid once per device instead of once per visit; the ETag is what stops a redeploy being answered with last week's data |
| Searching | On submit, never while typing | A name search scans every row, so a keystroke-per-search would run hundreds of full scans to answer one question |
| Compression | None published | The host gzips on the wire, so a .gz artifact would only be decompressed twice |
| WASM hosting | Bundled with the app | sql.js ships its own build; one less third-party runtime dependency |
| Diacritics search | Pre-computed ho_ten_ascii |
LOWER(REPLACE(...)) at query time is far slower over 877,460 rows than a column computed once at build |
| Secondary indexes | None | In memory a full scan costs a few hundred milliseconds; an index costs every visitor megabytes of download. The name index alone was 146 MB |
| Row count in the footer | Read from datasets.json |
Costs nothing and is the same number the assembler enforces |
| Page size | 4 KiB | SQLite's default, and nothing on the client depends on it any more |
| SQL safety | Leading-keyword allowlist | The copy is the visitor's own, so this guards their session against a typo rather than protecting data |
| Row caps | 100 (lookup), 1000 (SQL) | Keeps DOM render sizes reasonable |
| Routing | SvelteKit file routes, prerendered | Each dataset gets a real HTML file with its own title |
| Styling | Tailwind, with tier colours as CSS variables | Tier classes are chosen at runtime, which no utility generator can see |
Considered and not taken
- Reading the file over HTTP range requests (
sql.js-httpvfs), which this site did until it was measured. Two costs killed it. Reads are serial — the worker uses synchronous XHR — so a name search that touched 390 pages waited 17 seconds to move 608 KB, and roughly one request per result row is a floor no page size removes. And the first visitor after each deploy waited ~26 s for the CDN to fill its cache with a 288 MB object. The download pays once, up front, visibly. sqlite-wasm-http. Built on the official SQLite WASM and maintained, but it sizes the file from a HEAD request'sContent-Lengthand exposes no option to override it, so on a host that gzips it would silently use the compressed size. Same class of problem, less recourse.- DuckDB-WASM over Parquet. Genuinely maintained, async, parallel range requests. Its binaries are 32–37 MB before the Parquet extension, which is more than the entire database download for a phone looking up one score.
- Static pre-generated shards, one file per exam-number bucket. The most
robust option and the fastest single lookup, but it cannot answer arbitrary
SQL, and a static file cannot stop early the way
LIMITdoes — a common Vietnamese surname would mean fetching a very large posting list.
Risks and limitations
- Memory is the binding constraint. The database lives in the tab's WebAssembly memory for as long as the page is open: 142 MB for 2016, 119 MB for 2017. A low-memory phone may have the tab killed, which is why the gate states the figure before the download starts.
- A deploy costs returning visitors the transfer again. Every rebuild lays SQLite pages out differently, so the file — and its ETag — changes even when the data does not. The stored copy is then a stale version and is replaced.
- The stored copy can be evicted. Cache Storage is subject to the browser's own storage pressure, so a device short on disk falls back to downloading.
- A visitor who will not download cannot use the site. That is the deliberate shape of the gate, and it makes the first impression a 31 MB ask.
- Hosted size. 526 MB for both datasets against the 1 GB GitHub Pages limit; a third dataset of this size would not fit.
- Excel format drift. A new source file with an unseen header layout needs a
new branch in
parser/internal/ingest/detect2016.goor a new config.