Merge pull request #10 from tiennm99/feat/httpvfs-range-queries

feat: read the databases over HTTP range requests
This commit is contained in:
2026-08-14 14:08:10 +07:00
committed by GitHub
47 changed files with 1179 additions and 1090 deletions
+2 -2
View File
@@ -66,8 +66,8 @@ jobs:
- name: Test assembler
run: go -C assembler test ./...
# Lint covers ESLint and svelte-check; the tests cover the framework-free
# modules, including the ASCII fold that has to match the Go parser.
# The tests cover the framework-free modules, including the ASCII fold
# that has to match the Go parser.
- name: Test web
working-directory: web
run: npm test
+13 -3
View File
@@ -24,7 +24,8 @@ Add the web checks when frontend files changed:
(cd web && npm test && npm run lint && npm run build)
```
`npm run lint` is ESLint plus `svelte-check`, so it is the type check too.
`npm run lint` is ESLint. The web app is plain JavaScript — no TypeScript, no
type-check step.
**Do not run the crawler suite as part of routine verification.** The crawler is
not part of the build — `data/<id>/` is committed and a crawl only refreshes it
@@ -44,8 +45,8 @@ hashes every real input file. That is the point of it; do not skip it.
regenerated.** A mismatch is a reader bug until proven otherwise, never a cue
to refresh the file. `parser/cmd/dumpcells` narrows a failure to the cell.
- **`ToAscii` filters the literal range U+0300..U+036F, not `unicode.Mn`.** It
must match `toAscii` in `web/src/lib/to-ascii.ts`, or accent-insensitive search
silently misses rows. `to-ascii.test.ts` pins the pairs both sides must agree
must match `toAscii` in `web/src/lib/to-ascii.js`, or accent-insensitive search
silently misses rows. `to-ascii.test.js` pins the pairs both sides must agree
on.
- **The 2016 files use four different layouts, and detection is per sheet.**
Two of them publish scores in one column per subject instead of a `DIEM_THI`
@@ -58,6 +59,15 @@ hashes every real input file. That is the point of it; do not skip it.
used — a fallback would break the `?q=` deep links.
- **`dbSizeMb` in `datasets.json` is a build guard, not just a label.** The
assembler refuses to publish an artifact that falls below a ratio of it.
- **The databases ship uncompressed, as `<id>.sqlite3`.** The browser reads
byte ranges of them, and a range of a gzip stream is not a range of the
database. The host must not apply `Content-Encoding` either — check with
`curl -sI` after a deploy.
- **Every query the site runs must be index-driven.** Over range requests an
unindexed query fetches the whole table. Hence no index on `ho_ten` (nothing
can use one), `name_word` for name search, partial indexes for the score
presets, and the footer count read from `datasets.json` instead of
`COUNT(*)`.
## Conventions
+5 -4
View File
@@ -1,7 +1,8 @@
# thptqg
Tra cứu điểm thi THPT Quốc gia — exam-score lookup for Vietnam's national high
school graduation exam. Client-side SQL (sql.js) over a SQLite database built
school graduation exam. Client-side SQL over a SQLite database read in place by
HTTP range request, built
from the published `.xls`/`.xlsx` score files by the Go `parser` module. Where
those files come from: [data pipeline](./docs/data-pipeline.md#sources).
@@ -36,12 +37,12 @@ Each stage runs on its own and hands its output to the next through the stores.
`datasets.json` is the contract between them. It is JSON because Go and the web
app both read it and neither needs a dependency to do so; presentation stays in
`web/src/lib/datasets.ts`, keyed by id, which fails loudly if the two disagree.
`web/src/lib/datasets.js`, keyed by id, which fails loudly if the two disagree.
The dataset id is one identifier end to end:
```
data/2017/ → parser/configs/2017.yml → db/2017.db.gz → /thptqg/2017/
data/2017/ → parser/configs/2017.yml → db/2017.sqlite3 → /thptqg/2017/
```
## Build
@@ -85,7 +86,7 @@ Pushing to `main` runs the same steps in
2. Add `parser/configs/<id>.yml` — sheet mode, column indices, validation
guards. No SQL; the schema is canonical.
3. Add an entry to `datasets.json` with its expected row count and size
4. Add the matching presentation to `CONTENT` in `web/src/lib/datasets.ts`
4. Add the matching presentation to `CONTENT` in `web/src/lib/datasets.js`
Everything else follows: the assembler, the router and the hub all read the
registry, and the UI adapts to whichever columns the dataset fills. Steps 3 and 4
+19 -68
View File
@@ -1,5 +1,4 @@
// Package databases builds, verifies and compresses one SQLite file per
// dataset.
// Package databases builds and verifies one SQLite file per dataset.
//
// VERIFICATION IS THE POINT OF THIS PACKAGE, not an extra.
//
@@ -11,13 +10,15 @@
//
// The guards below close that: a build whose row count does not match the
// registry, or whose artifact is implausibly small, fails the pipeline.
//
// The databases ship uncompressed. The browser reads them a page at a time over
// HTTP range requests, and a range of a gzip stream is not a range of the
// database.
package databases
import (
"compress/gzip"
"database/sql"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
@@ -30,10 +31,15 @@ import (
// driverName is modernc.org/sqlite's registered name.
const driverName = "sqlite"
// minSizeRatio: a gzipped database far below its usual size means a truncated
// build, even if the row count somehow passed.
// minSizeRatio: a database far below its usual size means a truncated build,
// even if the row count somehow passed.
const minSizeRatio = 0.9
// Extension is the published suffix. Not ".db": the sql.js-httpvfs ecosystem
// uses ".sqlite3", and keeping ".db" free lets the site assembly treat any
// stray .db or SQLite journal in the output as the leftover it is.
const Extension = ".sqlite3"
// Paths locates the pieces this package needs.
type Paths struct {
// Root is the repository root.
@@ -75,7 +81,7 @@ func Build(p Paths, bin string, d registry.Dataset) error {
if err := os.MkdirAll(p.OutDir, 0o755); err != nil {
return err
}
db := filepath.Join(p.OutDir, d.ID+".db")
db := filepath.Join(p.OutDir, d.ID+Extension)
cmd := exec.Command(bin,
"build",
@@ -99,12 +105,11 @@ func Build(p Paths, bin string, d registry.Dataset) error {
}
fmt.Printf(" ✓ %s: %d rows (matches expected)\n", d.ID, rows)
gz, size, err := compress(db)
st, err := os.Stat(db)
if err != nil {
return fmt.Errorf("%s: %w", d.ID, err)
}
sizeMb := float64(size) / 1024 / 1024
sizeMb := float64(st.Size()) / 1024 / 1024
if min := d.DbSizeMb * minSizeRatio; sizeMb < min {
return fmt.Errorf(
"%s: %.1f MB is below %.1f MB (%.0f%% of the expected %.0f MB)\n"+
@@ -112,7 +117,7 @@ func Build(p Paths, bin string, d registry.Dataset) error {
d.ID, sizeMb, min, minSizeRatio*100, d.DbSizeMb)
}
fmt.Printf(" → %s (%.1f MB)\n\n", filepath.Base(gz), sizeMb)
fmt.Printf(" → %s (%.1f MB)\n\n", filepath.Base(db), sizeMb)
return nil
}
@@ -131,61 +136,8 @@ func countRows(path string) (int64, error) {
return n, nil
}
// compress gzips path to path+".gz" and removes the original, returning the
// compressed path and its size.
//
// The source is deleted only after the compressed file is closed successfully,
// so a failure part-way through leaves the database rather than losing it.
func compress(path string) (string, int64, error) {
in, err := os.Open(path)
if err != nil {
return "", 0, err
}
defer in.Close()
gzPath := path + ".gz"
out, err := os.Create(gzPath)
if err != nil {
return "", 0, err
}
zw, err := gzip.NewWriterLevel(out, gzip.BestCompression)
if err != nil {
out.Close()
return "", 0, err
}
if _, err := io.Copy(zw, in); err != nil {
zw.Close()
out.Close()
os.Remove(gzPath)
return "", 0, err
}
if err := zw.Close(); err != nil {
out.Close()
os.Remove(gzPath)
return "", 0, err
}
if err := out.Close(); err != nil {
os.Remove(gzPath)
return "", 0, err
}
if err := in.Close(); err != nil {
return "", 0, err
}
if err := os.Remove(path); err != nil {
return "", 0, fmt.Errorf("removing the uncompressed database: %w", err)
}
st, err := os.Stat(gzPath)
if err != nil {
return "", 0, err
}
return gzPath, st.Size(), nil
}
// Clean removes staged artifacts for datasets that are no longer in the
// registry. Without this a removed dataset's .db.gz lingers in the staging
// registry. Without this a removed dataset's file lingers in the staging
// directory, and the site assembly copies that directory wholesale — so the
// dead database would be published again.
func Clean(p Paths, keep []registry.Dataset) error {
@@ -197,10 +149,9 @@ func Clean(p Paths, keep []registry.Dataset) error {
return err
}
wanted := make(map[string]bool, len(keep)*2)
wanted := make(map[string]bool, len(keep))
for _, d := range keep {
wanted[d.ID+".db"] = true
wanted[d.ID+".db.gz"] = true
wanted[d.ID+Extension] = true
}
for _, e := range entries {
+4 -46
View File
@@ -1,8 +1,6 @@
package databases
import (
"compress/gzip"
"io"
"os"
"path/filepath"
"slices"
@@ -11,52 +9,12 @@ import (
"github.com/tiennm99/thptqg/assembler/internal/registry"
)
// TestCompressRoundTripsAndRemovesTheSource: only the .gz may survive, so that
// shipping a 100+ MB uncompressed database is structurally impossible rather
// than left to a cleanup step.
func TestCompressRoundTripsAndRemovesTheSource(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "2016.db")
body := []byte("pretend this is a SQLite file")
if err := os.WriteFile(src, body, 0o644); err != nil {
t.Fatal(err)
}
gzPath, size, err := compress(src)
if err != nil {
t.Fatal(err)
}
if gzPath != src+".gz" || size <= 0 {
t.Fatalf("gzPath=%q size=%d", gzPath, size)
}
if _, err := os.Stat(src); !os.IsNotExist(err) {
t.Error("the uncompressed database must not survive")
}
f, err := os.Open(gzPath)
if err != nil {
t.Fatal(err)
}
defer f.Close()
zr, err := gzip.NewReader(f)
if err != nil {
t.Fatal(err)
}
got, err := io.ReadAll(zr)
if err != nil {
t.Fatal(err)
}
if string(got) != string(body) {
t.Errorf("round-trip gave %q", got)
}
}
func TestCleanRemovesOnlyDroppedDatasets(t *testing.T) {
dir := t.TempDir()
for _, name := range []string{
"2016.db.gz", "2017.db.gz",
"2017-old.db.gz", // dropped from the registry
"2017-old2.db.gz", // dropped from the registry
"2016.sqlite3", "2017.sqlite3",
"2017-old.sqlite3", // dropped from the registry
"2017-old2.sqlite3", // dropped from the registry
"2016.db-journal", // interrupted run
} {
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil {
@@ -80,7 +38,7 @@ func TestCleanRemovesOnlyDroppedDatasets(t *testing.T) {
}
slices.Sort(left)
want := []string{"2016.db.gz", "2017.db.gz"}
want := []string{"2016.sqlite3", "2017.sqlite3"}
if !slices.Equal(left, want) {
t.Errorf("left %v, want %v", left, want)
}
+16 -14
View File
@@ -20,6 +20,7 @@ import (
"regexp"
"strings"
"github.com/tiennm99/thptqg/assembler/internal/databases"
"github.com/tiennm99/thptqg/assembler/internal/registry"
)
@@ -89,7 +90,7 @@ func Assemble(p Paths, datasets []registry.Dataset) error {
if err := checkDatabasesPresent(p.Site, datasets); err != nil {
return err
}
if err := checkNoRawDatabases(p.Site); err != nil {
if err := checkNoStrayArtifacts(p.Site); err != nil {
return err
}
@@ -131,10 +132,10 @@ func checkDatasetPages(siteDir string, datasets []registry.Dataset) error {
func checkDatabasesPresent(siteDir string, datasets []registry.Dataset) error {
var missing []string
for _, d := range datasets {
gz := filepath.Join(siteDir, "db", d.ID+".db.gz")
st, err := os.Stat(gz)
file := filepath.Join(siteDir, "db", d.ID+databases.Extension)
st, err := os.Stat(file)
if err != nil || st.Size() == 0 {
missing = append(missing, d.ID+".db.gz")
missing = append(missing, d.ID+databases.Extension)
}
}
if len(missing) > 0 {
@@ -146,22 +147,23 @@ func checkDatabasesPresent(siteDir string, datasets []registry.Dataset) error {
return nil
}
// rawDatabase matches an uncompressed SQLite artifact, including the temporary
// files SQLite leaves mid-build.
var rawDatabase = regexp.MustCompile(`\.db(-journal|-wal|-shm)?$`)
// strayArtifact matches what must never reach the output: a SQLite journal from
// an interrupted run, a database under the old .db name, or a gzipped database
// from before the switch to range requests.
var strayArtifact = regexp.MustCompile(`(\.db|\.sqlite3)(-journal|-wal|-shm)$|\.db$|\.gz$`)
// checkNoRawDatabases rejects an uncompressed database that reached the output.
// checkNoStrayArtifacts rejects leftovers that would be published.
//
// The build gzips without keeping the source, so none should exist — but the
// staging directory is copied wholesale, and a leftover from an interrupted run
// would go straight through. A raw database is 100+ MB.
func checkNoRawDatabases(siteDir string) error {
// The staging directory is copied wholesale, so anything an interrupted run left
// behind goes straight through — and each of these is 100+ MB. A gzipped
// database would also be unreadable to the site, which reads byte ranges.
func checkNoStrayArtifacts(siteDir string) error {
var stray []string
err := filepath.WalkDir(siteDir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && rawDatabase.MatchString(d.Name()) {
if !d.IsDir() && strayArtifact.MatchString(d.Name()) {
stray = append(stray, path)
}
return nil
@@ -171,7 +173,7 @@ func checkNoRawDatabases(siteDir string) error {
}
if len(stray) > 0 {
var b strings.Builder
b.WriteString("uncompressed database artefact(s) found in the site output:\n")
b.WriteString("stray database artefact(s) found in the site output:\n")
for _, f := range stray {
st, _ := os.Stat(f)
fmt.Fprintf(&b, " %s (%.1f MB)\n", f, float64(st.Size())/1048576)
+28 -23
View File
@@ -23,7 +23,7 @@ func fakeBuild(t *testing.T, dbs ...string) Paths {
}
write(t, filepath.Join(dist, "_app", "immutable", "entry.js"), "console.log(1)")
for _, name := range dbs {
write(t, filepath.Join(dist, "db", name), "gzipped-bytes")
write(t, filepath.Join(dist, "db", name), "sqlite-bytes")
}
return Paths{Web: filepath.Join(root, "web"), Dist: dist, Site: filepath.Join(root, "_site")}
}
@@ -39,7 +39,7 @@ func write(t *testing.T, path, body string) {
}
func TestAssembleProducesAPageForEveryDataset(t *testing.T) {
p := fakeBuild(t, "2016.db.gz", "2017.db.gz")
p := fakeBuild(t, "2016.sqlite3", "2017.sqlite3")
if err := Assemble(p, datasets); err != nil {
t.Fatal(err)
}
@@ -49,7 +49,7 @@ func TestAssembleProducesAPageForEveryDataset(t *testing.T) {
filepath.Join("2016", "index.html"),
filepath.Join("2017", "index.html"),
filepath.Join("_app", "immutable", "entry.js"),
filepath.Join("db", "2016.db.gz"),
filepath.Join("db", "2016.sqlite3"),
} {
if _, err := os.Stat(filepath.Join(p.Site, want)); err != nil {
t.Errorf("missing from the artifact: %s", want)
@@ -61,7 +61,7 @@ func TestAssembleProducesAPageForEveryDataset(t *testing.T) {
// entry generator, which reads the same registry this does. If the two fall out
// of step, that dataset's URL 404s — so the build stops instead.
func TestMissingDatasetPageFailsTheBuild(t *testing.T) {
p := fakeBuild(t, "2016.db.gz", "2017.db.gz")
p := fakeBuild(t, "2016.sqlite3", "2017.sqlite3")
if err := os.RemoveAll(filepath.Join(p.Dist, "2017")); err != nil {
t.Fatal(err)
}
@@ -80,12 +80,12 @@ func TestMissingDatasetPageFailsTheBuild(t *testing.T) {
// renders, every query 404s, and CI stays green. The row-count and size guards
// cannot catch this — they only run when a database was built at all.
func TestMissingDatabaseFailsTheBuild(t *testing.T) {
p := fakeBuild(t, "2016.db.gz") // 2017 never built
p := fakeBuild(t, "2016.sqlite3") // 2017 never built
err := Assemble(p, datasets)
if err == nil {
t.Fatal("expected an error when a database is missing")
}
if !strings.Contains(err.Error(), "2017.db.gz") {
if !strings.Contains(err.Error(), "2017.sqlite3") {
t.Errorf("the error should name the missing database, got: %v", err)
}
}
@@ -93,40 +93,45 @@ func TestMissingDatabaseFailsTheBuild(t *testing.T) {
// TestEmptyDatabaseFailsTheBuild: a zero-byte file satisfies "exists" but is
// not a database.
func TestEmptyDatabaseFailsTheBuild(t *testing.T) {
p := fakeBuild(t, "2016.db.gz", "2017.db.gz")
write(t, filepath.Join(p.Dist, "db", "2017.db.gz"), "")
p := fakeBuild(t, "2016.sqlite3", "2017.sqlite3")
write(t, filepath.Join(p.Dist, "db", "2017.sqlite3"), "")
if err := Assemble(p, datasets); err == nil {
t.Fatal("expected an error for a zero-byte database")
}
}
// TestRawDatabaseFailsTheBuild: the compression step deletes its source, so a
// raw .db here means an interrupted run left one behind — and it is 100+ MB.
func TestRawDatabaseFailsTheBuild(t *testing.T) {
for _, name := range []string{"2016.db", "2016.db-journal", "2016.db-wal", "2016.db-shm"} {
// TestStrayArtifactFailsTheBuild: a journal means an interrupted run, a .db
// means the old naming, a .gz means a database the site could not read a range
// of — and each is 100+ MB.
func TestStrayArtifactFailsTheBuild(t *testing.T) {
for _, name := range []string{
"2016.db", "2016.sqlite3-journal", "2016.sqlite3-wal", "2016.sqlite3-shm", "2016.sqlite3.gz",
} {
t.Run(name, func(t *testing.T) {
p := fakeBuild(t, "2016.db.gz", "2017.db.gz")
p := fakeBuild(t, "2016.sqlite3", "2017.sqlite3")
write(t, filepath.Join(p.Dist, "db", name), "raw sqlite")
err := Assemble(p, datasets)
if err == nil {
t.Fatalf("expected an error for %s", name)
}
if !strings.Contains(err.Error(), "uncompressed") {
if !strings.Contains(err.Error(), "stray") {
t.Errorf("unexpected error: %v", err)
}
})
}
}
// TestGzipIsNotMistakenForRaw: the reject pattern is anchored, so a .db.gz must
// pass. Getting this wrong would fail every build.
func TestGzipIsNotMistakenForRaw(t *testing.T) {
if rawDatabase.MatchString("2016.db.gz") {
t.Error("a .db.gz must not be treated as an uncompressed database")
// TestPublishedDatabaseIsNotMistakenForStray: the pattern must pass the one
// file the site is built to serve. Getting this wrong would fail every build.
func TestPublishedDatabaseIsNotMistakenForStray(t *testing.T) {
if strayArtifact.MatchString("2016.sqlite3") {
t.Error("the published database must not be treated as a stray artifact")
}
for _, name := range []string{"2016.db", "x.db-journal", "x.db-wal", "x.db-shm"} {
if !rawDatabase.MatchString(name) {
t.Errorf("%s should be treated as an uncompressed artifact", name)
for _, name := range []string{
"2016.db", "x.sqlite3-journal", "x.sqlite3-wal", "x.sqlite3-shm", "x.sqlite3.gz", "x.db.gz",
} {
if !strayArtifact.MatchString(name) {
t.Errorf("%s should be rejected", name)
}
}
}
@@ -142,7 +147,7 @@ func TestAssembleRejectsAMissingBuild(t *testing.T) {
// TestAssembleIsIdempotent: the site directory is rebuilt from scratch, so a
// previous run's leftovers cannot survive into the artifact.
func TestAssembleIsIdempotent(t *testing.T) {
p := fakeBuild(t, "2016.db.gz", "2017.db.gz")
p := fakeBuild(t, "2016.sqlite3", "2017.sqlite3")
if err := Assemble(p, datasets); err != nil {
t.Fatal(err)
}
+14 -9
View File
@@ -44,8 +44,8 @@ type Result struct {
// OK reports whether the two databases are logically identical.
func (r Result) OK() bool { return len(r.Problems) == 0 }
// Compare checks every dataset in the registry, reading <dir>/<id>.db.gz (or
// <id>.db) from each side.
// Compare checks every dataset in the registry, reading <dir>/<id>.sqlite3
// from each side.
func Compare(datasets []registry.Dataset, dirA, dirB string) ([]Result, error) {
out := make([]Result, 0, len(datasets))
for _, d := range datasets {
@@ -135,21 +135,26 @@ func compareOne(id, dirA, dirB string) (Result, error) {
return res, nil
}
// open finds <id>.db.gz or <id>.db in dir and returns a handle. A compressed
// database is expanded to a temporary file, since SQLite needs to seek.
// open finds <id>.sqlite3 in dir and returns a read-only handle.
//
// A gzipped database is still expanded to a temporary file rather than
// rejected: the two sides of a comparison are often a build from before the
// switch to range requests and one from after.
func open(dir, id string) (*sql.DB, func(), error) {
noop := func() {}
plain := filepath.Join(dir, id+".db")
if _, err := os.Stat(plain); err == nil {
db, err := sql.Open(driverName, "file:"+plain+"?mode=ro")
return db, noop, err
for _, name := range []string{id + ".sqlite3", id + ".db"} {
plain := filepath.Join(dir, name)
if _, err := os.Stat(plain); err == nil {
db, err := sql.Open(driverName, "file:"+plain+"?mode=ro")
return db, noop, err
}
}
gzPath := filepath.Join(dir, id+".db.gz")
f, err := os.Open(gzPath)
if err != nil {
return nil, noop, fmt.Errorf("no %s.db or %s.db.gz in %s", id, id, dir)
return nil, noop, fmt.Errorf("no %s.sqlite3, %s.db or %s.db.gz in %s", id, id, id, dir)
}
defer f.Close()
+6 -5
View File
@@ -8,9 +8,10 @@
" inputs are frozen exam results, so a deviation of even one row",
" means something changed unintentionally and the assembler",
" refuses to publish.",
" dbSizeMb usual size of the gzipped database. Required and non-zero:",
" the assembler rejects a build that comes out far smaller, and",
" the web app shows it while the download runs.",
" dbSizeMb usual size of the published database. Required and non-zero:",
" the assembler rejects a build that comes out far smaller. The",
" file is served uncompressed and read a page at a time over",
" HTTP range requests, so nothing downloads it whole.",
"",
"Presentation (titles, labels, SQL presets) lives in web/src/datasets.js keyed",
"by id; that file throws at load if the two lists disagree."
@@ -19,12 +20,12 @@
{
"id": "2016",
"expectedRows": 877460,
"dbSizeMb": 45
"dbSizeMb": 302
},
{
"id": "2017",
"expectedRows": 861068,
"dbSizeMb": 48
"dbSizeMb": 247
}
]
}
+7 -3
View File
@@ -181,6 +181,10 @@ silently drops 13,720 students** (Hanoi +7,275, HCM +6,445). That is what
## Expected row counts
The databases are written with 1 KiB pages (`PRAGMA page_size` in
`parser/internal/writer/writer.go`) because the browser fetches them a page per
HTTP request. `CHUNK_BYTES` in `web/src/lib/sqlite.svelte.js` must match.
| id | Source rows | Skipped | DB rows |
| --- | --- | --- | --- |
| `2016` | 877,460 | 0 | **877,460** |
@@ -189,7 +193,7 @@ silently drops 13,720 students** (Hanoi +7,275, HCM +6,445). That is what
## Verifying a rebuild
The assembler verifies itself: each database's row count must match the
figure in the table above, and each `.db.gz` must be at least 90% of its usual
figure in the table above, and each `.sqlite3` must be at least 90% of its usual
size, or the build fails rather than publishing. That guard is the reason a
truncated dataset cannot reach the site with a green pipeline.
@@ -203,8 +207,8 @@ go -C assembler run ./cmd/assemble db # rebuild
go -C assembler run ./cmd/assemble verify /tmp/before .build/public/db
```
Each side is a directory of `<id>.db.gz` (or `<id>.db`); compressed databases are
expanded to a temporary file automatically. It exits non-zero on any mismatch,
Each side is a directory of `<id>.sqlite3`; a gzipped database from before the
switch to range requests is still expanded to a temporary file automatically. It exits non-zero on any mismatch,
names the first differing rows and columns, and fails rather than skipping when a
dataset is absent from either side — silently comparing one of two datasets is
how a gate passes without proving anything.
+18 -12
View File
@@ -75,17 +75,22 @@ artifact — one missing line away from publishing it.
## Notes
- **The gzipped database is not cacheable across deploys.** Every rebuild
produces a different `.db.gz`, because SQLite does not lay pages out
deterministically. First-visit users pay the full download; later visits hit
browser cache until the next deploy.
- **GitHub Pages caps individual files at 100 MB.** The largest gzipped database
is about 48 MB. Uncompressed they run 135234 MB and would not fit — which is
why the browser decompresses via `DecompressionStream`.
- **No server-side compression is assumed.** The app fetches the `.gz` bytes
directly rather than relying on `Content-Encoding: gzip`; Pages does not
reliably compress arbitrary paths on the fly.
- **Total artifact is about 93 MB**, well inside the 1 GB site limit.
- **The database is not cacheable across deploys.** Every rebuild lays SQLite
pages out differently, so the file changes even when the data does not. Only
the pages a query touches are fetched, so this costs far less than it used
to, but a deploy does invalidate what a returning visitor had cached.
- **The 100 MB file limit is a Git limit, not a Pages one.** It applies to
files committed to a repository; the databases are built in CI and uploaded
as a Pages artifact, and the documented Pages limits are a 1 GB published
site and 100 GB/month of bandwidth, with no per-file figure. The two
databases are 302 MB and 247 MB.
- **Total artifact is about 552 MB**, inside the 1 GB site limit but with less
headroom than before: a third dataset of this size would not fit. The fallback
is `sql.js-httpvfs`'s chunked mode, which splits a database into parts.
- **The server must not compress the databases.** Ranges of a compressed body
address the wrong bytes, and the library refuses to open a file whose HEAD
carries a `Content-Encoding`. `.sqlite3` is an unknown type to Pages, so it is
served as `application/octet-stream` and left alone — verify after a deploy.
## Rollback
@@ -99,6 +104,7 @@ run rebuilds the older state. There is no data to migrate.
| Blank page, 404 on assets | `paths.base` in `svelte.config.js` does not match the repo name |
| `Failed to fetch database: 404` | Dataset id in `datasets.json` does not match the file in `db/` |
| A route 404s | The site step did not run, or the id is missing from `datasets.json` |
| WASM fails to load | `sql.js.org` unreachable — self-host `sql-wasm.wasm` and update `SQL_WASM_URL` in `lib/sqlite.svelte.ts` |
| Database fails to open | The host compressed it. `curl -sI …/db/<id>.sqlite3` must show no `content-encoding`; ranges of a compressed body are unusable |
| Every query is slow or huge | It is not using an index. `EXPLAIN QUERY PLAN` it: a `SCAN` means the browser is fetching the whole table |
| Deploy fails on assembly | An uncompressed database artefact reached the output; the error names the files |
| Missing rows after a data update | Unknown Excel header — check the per-file row counts the parser prints |
+3 -3
View File
@@ -21,10 +21,10 @@ running entirely in the browser and hosted for free on GitHub Pages. Covers the
## Constraints
- **Zero backend.** The full database (4448 MB gzipped per dataset) is
downloaded to the browser and queried in-process.
- **Zero backend.** The database (238289 MB per dataset) stays on the server
and the browser reads the pages a query touches over HTTP range requests.
- **Read-only.** `INSERT`/`UPDATE`/`DELETE` are rejected, so nobody is misled
into thinking edits persist. `sql.js` is in-memory anyway.
into thinking edits persist. The file is fetched, never written.
- **Row caps.** 100 rows for lookups, 1000 for custom SQL, to prevent browser
hangs.
- **Vietnamese-first UI.** App labels and data are Vietnamese; documentation is
+31 -23
View File
@@ -1,7 +1,9 @@
# System Architecture
Static site, no backend. The browser downloads a compressed SQLite file at boot
and every query runs locally via `sql.js` (SQLite compiled to WebAssembly).
Static site, no backend. The SQLite file stays on the server and the browser
reads the pages a query touches over HTTP range requests, via `sql.js-httpvfs`
(SQLite compiled to WebAssembly behind a virtual file system). A lookup costs a
few hundred KB; nothing downloads the database.
One frontend, one parser, one schema, two datasets.
@@ -15,11 +17,11 @@ through. `assembler/` sequences everything from the parser onwards.
data/<id>/*.xls(x)
▼ parser/ (Go, one binary, one config per dataset)
.build/public/db/<id>.db
▼ assembler/ — row count must match datasets.json, then gzip
.build/public/db/<id>.db.gz (the raw .db does not survive)
.build/public/db/<id>.sqlite3
▼ assembler/ — row count and size must match datasets.json
.build/public/db/<id>.sqlite3 (uncompressed: ranges of a gzip stream
│ are not ranges of the database)
▼ assembler/ → npm run build (SvelteKit static, assets = .build/public)
web/dist/
@@ -27,7 +29,7 @@ data/<id>/*.xls(x)
_site/ → GitHub Pages
▼ browser
sql.js (WASM) opens the .db → queries run client-side
sql.js-httpvfs asks for pages → HTTP range requests → results client-side
```
## The dataset id
@@ -35,7 +37,7 @@ data/<id>/*.xls(x)
One identifier ties the whole pipeline together:
```
data/2017/ → parser/configs/2017.yml → db/2017.db.gz → /thptqg/2017/
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
@@ -44,7 +46,7 @@ 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/datasets.js`, keyed by id. That file cross-checks the two: a registry
`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.
@@ -123,13 +125,13 @@ No component contains a per-dataset conditional. Two mechanisms do the work:
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.ts`.
SQL presets — lives in `web/src/lib/datasets.js`.
## Exam ID formats
`web/src/lib/query-mode.ts` decides whether a query is an exam ID or a name, and
`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.ts`
separate copies and had drifted apart on exactly this rule). `query-mode.test.js`
covers every form in the table below.
| Form | Example | Where |
@@ -168,10 +170,12 @@ total descending.
| Concern | Choice | Rationale |
| --- | --- | --- |
| Storage | Static SQLite file | No backend; the datasets are frozen |
| Compression | gzip in CI, `DecompressionStream` in the browser | Native API, no extra library |
| WASM hosting | `sql.js.org` CDN | Smaller self-hosted artifact |
| Diacritics search | Pre-computed `ho_ten_ascii` | `LOWER(REPLACE(...))` at query time defeats the index |
| Storage | Static SQLite file, read by range request | No backend; the datasets are frozen, and a lookup needs a few pages of them |
| Compression | None | A byte range of a gzip stream is not a byte range of the database |
| WASM hosting | Bundled with the app | `sql.js-httpvfs` ships its own build; one less third-party runtime dependency |
| Diacritics search | Pre-computed `ho_ten_ascii`, indexed word by word | `LOWER(REPLACE(...))` at query time defeats the index, and `LIKE '%x%'` reads the whole table |
| Row count in the footer | Read from `datasets.json` | `COUNT(*)` scans an index — 20 MB over range requests |
| Page size | 1 KiB, matched by `requestChunkSize` | One HTTP request is one page; a row fetched by seek costs 1 KB rather than 4 KB, for about 5% more file |
| SQL safety | Leading-keyword allowlist | `sql.js` is in-memory so writes cannot persist; the allowlist prevents confusion |
| 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 |
@@ -179,12 +183,16 @@ total descending.
## Risks and limitations
- **Database size.** 4548 MB gzipped per dataset; slow links wait, mitigated by
a progress bar.
- **Browser memory.** The full database lives in RAM; older mobile devices may
run out.
- **`sql.js.org` dependency.** If that CDN is unreachable, the WASM fails to
load. Self-hosting `sql-wasm.wasm` and updating `SQL_WASM_URL` in
`web/src/lib/sqlite.svelte.ts` is the fix.
- **Unindexed queries are expensive.** The SQL tab can express a query that
walks the table, which over range requests means fetching 100+ MB. A byte
budget stops one before it gets that far, and the tab warns before it opens.
- **`Content-Encoding` breaks everything.** If the host ever compresses
`<id>.sqlite3` on the wire, ranges address compressed bytes and
`sql.js-httpvfs` refuses to open the file. Verify after a deploy:
`curl -sI …/db/2016.sqlite3` must show no `content-encoding`.
- **`sql.js-httpvfs` is unmaintained** (0.8.12, September 2022) and ships its
own SQLite WASM. `sqlite-wasm-http`, on the official build, is the fallback.
- **Hosted size.** 552 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.go` or a new config.
+1 -1
View File
@@ -41,7 +41,7 @@ reader independently verifiable against a hash oracle.
- `ToAscii` strips combining marks in the literal range U+0300U+036F rather
than by Unicode category. That covers every Vietnamese diacritic and must
stay identical to `toAscii` in `web/src/lib/to-ascii.ts`, or accent-insensitive
stay identical to `toAscii` in `web/src/lib/to-ascii.js`, or accent-insensitive
search misses rows.
- Gender is normalised to `Nam`/`Nữ`; the Cần Thơ files write `0`/`1` instead
and are translated. Anything else becomes NULL.
+52 -5
View File
@@ -18,9 +18,17 @@ import "regexp"
// DDL is executed verbatim after the output database is (re)created.
//
// idx_ten_cum_thi is partial, so it holds zero entries on the 2017 dataset —
// where the column is always NULL — while staying useful for the 2016
// cluster-grouping queries. Partial indexes are SQLite-specific.
// Every index here is chosen for a database read over HTTP range requests,
// where an unindexed query downloads the table. The rules that follow from
// that:
//
// - No index on ho_ten or ho_ten_ascii. Neither substring nor prefix LIKE can
// use one (SQLite's LIKE optimisation needs a NOCASE index or
// case_sensitive_like), so both scanned the whole table. name_word replaces
// them.
// - idx_ten_cum_thi is partial, so it holds zero entries on the 2017 dataset
// — where the column is always NULL — while serving the 2016 cluster
// grouping. Partial indexes are SQLite-specific.
//
// This text is frozen: it decides the shape of every database the parser
// produces. TestDDLIsFrozen holds an independent copy so any edit has to be
@@ -50,9 +58,48 @@ CREATE TABLE student (
tieng_nhat REAL,
tieng_trung REAL
);
CREATE INDEX idx_ho_ten ON student(ho_ten);
CREATE INDEX idx_ho_ten_ascii ON student(ho_ten_ascii);
CREATE INDEX idx_ten_cum_thi ON student(ten_cum_thi) WHERE ten_cum_thi IS NOT NULL;
CREATE TABLE name_word (
word TEXT NOT NULL,
so_bao_danh TEXT NOT NULL,
ho_ten_ascii TEXT NOT NULL,
PRIMARY KEY (word, so_bao_danh)
) WITHOUT ROWID;
CREATE TABLE name_word_freq (
word TEXT PRIMARY KEY,
n INTEGER NOT NULL
) WITHOUT ROWID;
`
// PostLoadSQL runs once the student rows are in, before VACUUM.
//
// The frequency table is what lets the site pick which word of a query to seek
// on: the vocabulary is about 4,400 words and the rarest word of a real query
// matches a few hundred rows, so seeking on it and filtering the rest inside
// name_word keeps a search to a few hundred kilobytes.
//
// The three score indexes are partial for the same reason idx_ten_cum_thi is:
// each covers only the exam year that has the column, and each costs about
// 4 MB. They exist so the SQL presets that rank by these columns seek instead
// of scanning 127 MB.
const PostLoadSQL = `
INSERT INTO name_word_freq (word, n)
SELECT word, COUNT(*) FROM name_word GROUP BY word;
CREATE INDEX idx_toan ON student(toan) WHERE toan IS NOT NULL;
CREATE INDEX idx_khtn ON student(khtn) WHERE khtn IS NOT NULL;
CREATE INDEX idx_khxh ON student(khxh) WHERE khxh IS NOT NULL;
`
// NameWordInsertSQL adds one row per distinct word of a candidate's ASCII name.
//
// ho_ten_ascii is carried along deliberately: a query with several words seeks
// on the rarest one and filters the others against this copy, so the whole
// match happens inside one b-tree and only the surviving rows are read from
// student.
const NameWordInsertSQL = `
INSERT OR IGNORE INTO name_word (word, so_bao_danh, ho_ten_ascii) VALUES (?, ?, ?)
`
// IdentityFields are the identity columns, in INSERT parameter order.
+40 -2
View File
@@ -109,15 +109,53 @@ CREATE TABLE student (
tieng_nhat REAL,
tieng_trung REAL
);
CREATE INDEX idx_ho_ten ON student(ho_ten);
CREATE INDEX idx_ho_ten_ascii ON student(ho_ten_ascii);
CREATE INDEX idx_ten_cum_thi ON student(ten_cum_thi) WHERE ten_cum_thi IS NOT NULL;
CREATE TABLE name_word (
word TEXT NOT NULL,
so_bao_danh TEXT NOT NULL,
ho_ten_ascii TEXT NOT NULL,
PRIMARY KEY (word, so_bao_danh)
) WITHOUT ROWID;
CREATE TABLE name_word_freq (
word TEXT PRIMARY KEY,
n INTEGER NOT NULL
) WITHOUT ROWID;
`
if DDL != want {
t.Errorf("DDL changed\n--- got ---\n%s\n--- want ---\n%s", DDL, want)
}
}
// TestNoIndexOnNameColumns: the databases are read over HTTP range requests, so
// an index that no query can use is dead weight in a file the browser pages
// through. Neither substring nor prefix LIKE can use one on these columns —
// name_word is what serves name search.
func TestNoIndexOnNameColumns(t *testing.T) {
for _, dead := range []string{"idx_ho_ten ", "idx_ho_ten_ascii"} {
if strings.Contains(DDL, dead) {
t.Errorf("DDL creates %q, which no query plan can use", dead)
}
}
}
// TestPostLoadBuildsTheSearchTables: the frequency table is what lets a search
// pick which word to seek on, and the score indexes are what keep the SQL
// presets off a full scan.
func TestPostLoadBuildsTheSearchTables(t *testing.T) {
for _, want := range []string{
"INSERT INTO name_word_freq",
"CREATE INDEX idx_toan",
"CREATE INDEX idx_khtn",
"CREATE INDEX idx_khxh",
} {
if !strings.Contains(PostLoadSQL, want) {
t.Errorf("PostLoadSQL is missing %q", want)
}
}
}
// TestScorePatternsMatchScores exercises each pattern against the shape the
// DIEM_THI cell actually carries, including the wide runs of spaces seen in the
// real corpus.
+83 -2
View File
@@ -14,6 +14,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/tiennm99/thptqg/parser/internal/schema"
"github.com/tiennm99/thptqg/parser/internal/sqlitedb"
@@ -42,6 +43,19 @@ func OpenDB(dbPath string) (*sql.DB, error) {
if err != nil {
return nil, fmt.Errorf("open db %s: %w", dbPath, err)
}
// Before the DDL, because a page size cannot change once a table exists —
// only the VACUUM in Finish could rewrite it, and only to this same value.
//
// 1 KiB rather than SQLite's 4 KiB default because the browser reads this
// file a page at a time over HTTP: a row fetched by index seek costs one
// page, so a search that returns 100 scattered rows transfers 100 KB
// instead of 400 KB. It costs about 5% file size, and both sql.js-httpvfs
// and sqlite-wasm-http recommend it. web/src/lib/sqlite.svelte.ts must
// request the same size.
if _, err := db.Exec("PRAGMA page_size = 1024"); err != nil {
db.Close()
return nil, fmt.Errorf("set page size: %w", err)
}
if _, err := db.Exec(schema.DDL); err != nil {
db.Close()
return nil, fmt.Errorf("execute DDL: %w", err)
@@ -110,10 +124,77 @@ type Stats struct {
Errors uint64
}
// Finish runs VACUUM and prints the stats block.
// BuildNameIndex fills name_word from the student rows, one entry per distinct
// word of each ASCII name.
//
// VACUUM must run AFTER the transaction commits — SQLite refuses it inside one.
// A second pass rather than a write alongside each insert: a repeated exam
// number replaces its earlier row, and the words of the row it replaced would
// otherwise stay behind pointing at a name that is no longer there.
func BuildNameIndex(db *sql.DB) error {
rows, err := db.Query("SELECT so_bao_danh, ho_ten_ascii FROM student")
if err != nil {
return fmt.Errorf("read names: %w", err)
}
defer rows.Close()
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("begin name index: %w", err)
}
stmt, err := tx.Prepare(schema.NameWordInsertSQL)
if err != nil {
tx.Rollback()
return fmt.Errorf("prepare name index: %w", err)
}
var words uint64
seen := make(map[string]struct{}, 8)
for rows.Next() {
var sbd, ascii string
if err := rows.Scan(&sbd, &ascii); err != nil {
tx.Rollback()
return fmt.Errorf("scan name: %w", err)
}
clear(seen)
for _, w := range strings.Fields(ascii) {
if _, dup := seen[w]; dup {
continue
}
seen[w] = struct{}{}
if _, err := stmt.Exec(w, sbd, ascii); err != nil {
tx.Rollback()
return fmt.Errorf("insert name word: %w", err)
}
words++
}
}
if err := rows.Err(); err != nil {
tx.Rollback()
return fmt.Errorf("read names: %w", err)
}
if err := stmt.Close(); err != nil {
tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit name index: %w", err)
}
if _, err := db.Exec(schema.PostLoadSQL); err != nil {
return fmt.Errorf("post-load statements: %w", err)
}
fmt.Printf("Name index: %d words\n", words)
return nil
}
// Finish builds the derived tables, runs VACUUM and prints the stats block.
//
// VACUUM must run AFTER the transaction commits — SQLite refuses it inside one
// — and after the name index, so the file is laid out in one pass.
func Finish(db *sql.DB, dbPath string, st Stats) error {
if err := BuildNameIndex(db); err != nil {
return err
}
if _, err := db.Exec("VACUUM"); err != nil {
return fmt.Errorf("vacuum: %w", err)
}
@@ -0,0 +1,75 @@
# Serve the databases over HTTP range requests
Status: implemented, unverified in a browser
The whole-database download is gone. `sql.js-httpvfs` reads the pages a query
touches, so the databases ship raw as `<id>.sqlite3` — a byte range of a gzip
stream is not a byte range of a database.
## Why the schema had to change first
Measured on the real 2016 database (223.5 MB before, 4 KB pages, 27 rows/page):
| Query | Plan before | Would have fetched |
| --- | --- | --- |
| `so_bao_danh = ?` | SEARCH via PK | ~20 KB |
| `ho_ten_ascii LIKE '%x%'` | SCAN | 127 MB |
| `ho_ten_ascii LIKE 'x%'` | SCAN — the LIKE optimisation needs a NOCASE index | 127 MB |
| `COUNT(*)` | covering scan of idx_ho_ten_ascii | 20 MB |
| preset `ORDER BY toan DESC LIMIT 10` | SCAN + temp b-tree | 127 MB |
So substring search was impossible, prefix search was no better, and the footer
count alone cost 20 MB per page load.
## What shipped
**Parser.** `name_word(word, so_bao_danh, ho_ten_ascii)` WITHOUT ROWID — the
table is the index — plus `name_word_freq(word, n)` and partial indexes on
`toan`, `khtn`, `khxh`. Dropped `idx_ho_ten` and `idx_ho_ten_ascii`: no query
plan could use either.
877,460 names hold 2.87M word entries over a vocabulary of 4,397. A search asks
the frequency table which word is rarest, seeks on that one, and filters the
rest against the `ho_ten_ascii` copy inside the same b-tree — so "buu loc" still
finds "Nguyễn Bửu Lộc", in a few hundred KB.
| Segment | 2016 |
| --- | --- |
| `student` | 137.5 MB |
| `name_word` | 98.7 MB |
| `idx_ten_cum_thi` | 38.1 MB |
| PK autoindex | 15.3 MB |
| `idx_toan` | 12.6 MB |
| **total** | **302.4 MB** (2017: 247.3 MB) |
Written with 1 KiB pages, so a row reached by an index seek costs one 1 KB
request instead of 4 KB: 6.3 rows share a page rather than 27, which is what
turns a 100-row search from ~400 KB of row fetches into ~100 KB.
**Assembler.** Publishes uncompressed; the size guard reads the raw size; the
stray-artifact check now rejects journals, `.db` and `.gz`.
**Web.** `RemoteDatabase` wraps `createDbWorker`. The search tab runs with a
25 MB byte budget, the SQL tab asks for consent and then gets 250 MB, and the
bytes fetched are shown next to the query time. The footer count comes from
`datasets.json`.
## Verified
- Row counts unchanged: 877,460 and 861,068, both through the assembler guards.
- Every query the app issues is index-driven, checked with `EXPLAIN QUERY PLAN`:
`SEARCH w USING PRIMARY KEY (word>? AND word<?)` then a PK seek per row.
- Real queries seek on 28717,000 entries: `nguyen buu l` → 287, `tran thi
phuoc an` → 3,684, `nguyen minh tien` → 16,996.
- GitHub Pages serves byte ranges: `206` with a correct `Content-Range`.
## Not verified
- **Nothing has run in a browser.** No browser exists on the build machine.
- **Per-query bytes are a structural estimate**, not a measurement — the byte
counter in the SQL tab is the real check, once deployed.
- **`Content-Encoding` on the deployed file.** The library throws if the host
compresses it. `curl -sI …/db/2016.sqlite3` must show none.
- **A 289 MB file on Pages.** No per-file limit is documented and the site is
528 MB against a 1 GB limit, but this is the first deploy at that size. The
fallback is the library's chunked mode.
@@ -0,0 +1,78 @@
# Brainstorm: which httpvfs best practices to adopt
2026-08-14 13:17. Follows
[the research report](./web-sqlite-range-hosting-research-260814-1317-httpvfs-best-practices-report.md).
Branch `feat/httpvfs-range-queries`.
## Problem
Research surfaced three upstream practices we do not follow. Decide which are
worth the change, knowing nothing can be verified in a browser on this machine.
## Codebase context (scout)
| Concern | Touch points |
| --- | --- |
| Page size | `parser/internal/writer/writer.go:41-47` (PRAGMA must precede the DDL — page size is fixed once a table exists), `:185` (VACUUM already applies it), `web/src/lib/sqlite.svelte.ts:18,53` |
| Chunked mode | `databases.go:41` Extension, size guard, **`Clean()` deletes anything not `<id>.sqlite3` — would eat every chunk**, `site.go:135-138,153`, `datasets.ts:91-97`, `datasets.json`, ~15 tests |
| Library swap | `sqlite.svelte.ts:1-3,50-58,76,82`, `package.json:15`; all consumers go through `RemoteDatabase`, so the blast radius is one file |
## Options evaluated
### A. page_size 1024 + requestChunkSize 1024 — ADOPTED
- Upstream consensus: phiresky and mmomtchev both recommend 1024.
- Honest sizing for *our* pattern: row fetches 400 KB → 100 KB per search;
the index walk is sequential so bytes are unchanged and only the request
count rises, which prefetch read-heads collapse. Net ≈ 300 KB saved per
search — bandwidth, not latency.
- Cost: ~10 lines; file size +5% (528 → ~555 MB total, still under the 1 GB
Pages limit); both databases rebuilt.
### B. serverMode chunked — REJECTED
- Only benefit is CDN cache efficiency. GitHub Pages serves everything with
`Cache-Control: max-age=600`, and each deploy relays out SQLite pages anyway,
so cross-deploy caching is zero either way.
- Cost: split step, config JSON, `Clean()`/guards/`dbOf()`/`datasets.json`
rework, ~15 tests.
- Complexity buying a benefit the host cancels. Revisit only behind a CDN with
long TTLs.
### C. swap to sqlite-wasm-http — DEFERRED
- For: maintained (Dec 2025), official SQLite WASM instead of a 2022 fork;
matches this repo's posture on stale dependencies. Swap is one file.
- Against: its differentiator (shared cache) needs COOP/COEP headers GitHub
Pages cannot send, so we would get the synchronous fallback and the
maintenance benefit only. And the current integration has never run in a
browser — swapping now means two unverified variables and no way to tell
which broke.
- Revisit after the current build is verified live.
## Decision
Adopt **A only**.
## Implementation notes
1. `PRAGMA page_size = 1024` in `writer.OpenDB`, between `sql.Open` and
`db.Exec(schema.DDL)`. The existing VACUUM in `Finish` applies it.
2. `CHUNK_BYTES = 1024` in `web/src/lib/sqlite.svelte.ts`; it feeds
`requestChunkSize` and must equal the page size.
3. Rebuild both databases, update `dbSizeMb` in `datasets.json` to the new
sizes, re-run the assembler guards.
## Risks
- The benefit is arithmetic plus upstream authority, not measurement. The byte
counter in the SQL tab is the check, once deployed.
- Prefetch deliberately overfetches ahead of the cursor, so the 25 MB search
budget may trip earlier than a strict page count suggests. Tune after a real
measurement, not before.
## Unresolved questions
1. Does 1024 actually beat 4096 for our queries in a browser?
2. Is Fastly's caching of ranges over a 300 MB object good enough that chunked
mode stays unnecessary?
@@ -0,0 +1,165 @@
# Research Report: sql.js-httpvfs best practices
Conducted 2026-08-14 13:17 (Asia/Saigon). Context: two static SQLite files
(2016 = 288.6 MB, 2017 = 237.7 MB) on GitHub Pages, branch
`feat/httpvfs-range-queries`.
## Executive summary
Our implementation matches upstream guidance on the thing that matters most —
index design — and diverges on one measurable parameter: **page size**. Both
phiresky (sql.js-httpvfs) and mmomtchev (sqlite-wasm-http) recommend
`page_size = 1024`; we shipped 4096. For our access pattern (~100 scattered
single-row reads per search) that is a real 4× overfetch on the row-fetch half
of a query.
Two findings reduce risk rather than add work. Prefetching with three virtual
read heads makes a sequential scan cost a *logarithmic* number of requests, so
our byte estimates hold but latency is better than assumed. And the canonical
demo hosts a **670 MiB** database on GitHub Pages, so 289 MB is precedented.
One finding is new and worth a decision: **chunked mode** (split file + JSON
config) exists specifically to make CDN caching effective for large databases,
which matters because GitHub Pages serves everything with `Cache-Control:
max-age=600`.
## Methodology
- Sources: 5 (1 primary blog, 2 project READMEs, 1 recent practitioner
writeup, 1 search on Pages/Fastly caching), plus direct reading of the
installed `sql.js-httpvfs@0.8.12` bundle in a previous session.
- Date range: 2021 (canonical post) → March 2026 (practitioner writeup).
- Gemini CLI absent → WebSearch/WebFetch.
## Key findings
### 1. Page size: recommended 1024, we use 4096
Both projects say the same thing. phiresky set 1 KiB pages "to balance request
overhead against bandwidth efficiency"; sqlite-wasm-http says "it is highly
recommended to decrease your SQLite page size to 1024 bytes for maximum
performance" (`PRAGMA page_size=1024; VACUUM`).
`requestChunkSize` must match the page size.
Measured on our 2016 file *before* the schema change:
| page_size | file size |
| --- | --- |
| 1024 | 235.8 MB |
| 4096 | 223.5 MB |
| 8192 | 221.8 MB |
So 1024 costs ~5.5% file size. What it buys: a scattered row read fetches 1 KB
instead of 4 KB. Our search does ~100 of those, so the row-fetch half of a
search drops from ~400 KB to ~100 KB. The index-walk half is sequential and
benefits from prefetch either way.
**Verdict: switch to 1024 + `requestChunkSize: 1024`.** Our workload is
dominated by scattered single-row reads, which is exactly the case small pages
serve.
### 2. Prefetch changes request count, not bytes
"Three separate virtual read heads" detect sequential access and grow request
sizes exponentially, so "index scans or table scans reading more than a few KiB
of data will only cause a number of requests that is logarithmic in the total
byte length."
Consequence for our analysis: a full table scan still transfers ~127 MB (bytes
are bytes), but in tens of requests rather than tens of thousands. Our byte
budget is the right guardrail; a request-count budget would not be.
### 3. Index design — we already comply
- Covering indexes: put every column the query needs *in* the index, else
SQLite does "another random access (unpredictable) read and thus HTTP request
to retrieve the actual value for every data point". This is exactly why
`name_word` carries `ho_ten_ascii`.
- Column order decides which lookups are cheap.
- Verify with `EXPLAIN QUERY PLAN`; a `SCAN` means the whole table crosses the
network. We did this for every query the app issues.
### 4. Chunked mode exists for CDN caching
`serverMode: "chunked"` splits the database into parts (10 MB is the commonly
cited size) with a JSON config. Stated benefit: "CDN caching much more
effective" for large databases.
Relevant because **GitHub Pages sets `Cache-Control: max-age=600`** — ten
minutes — on everything. Range responses are cached by Fastly per object; with
one 289 MB object the practical caching story is weaker than with 29 chunks
that a CDN edge can hold whole. Chunked mode also sidesteps any future per-file
concern.
Cost: a build step to split files + emit config, and every deploy invalidates
all chunks anyway (SQLite page layout is not deterministic across rebuilds).
### 5. Hosting facts confirmed
- Range requests work on Pages "out of the box" — matches our own probe (206 +
correct `Content-Range`).
- CORS headers (`Access-Control-Allow-Origin`, `Access-Control-Allow-Headers:
Range`) only matter cross-origin. Ours is same-origin — non-issue.
- 670 MiB database on Pages is the canonical demo. 289 MB is not exotic.
- `Content-Encoding` remains the one fatal case: the library discards
`Content-Length` and throws when a HEAD carries a non-identity encoding.
Unknown extensions like `.sqlite3` are served `application/octet-stream` and
left alone.
### 6. Alternatives
| | sql.js-httpvfs | sqlite-wasm-http |
| --- | --- | --- |
| WASM base | own sql.js fork (~3.36 era) | official `@sqlite.org/sqlite-wasm` |
| Last release | 0.8.12, Sept 2022 | 1.2.0, Dec 2023; activity into Dec 2025 |
| Self-description | "demo-level code… not for high stability" | "experimental" |
| Concurrency | one worker | multiple connections, shared cache |
| Shared cache needs | — | `SharedArrayBuffer` → COOP/COEP headers |
| On GitHub Pages | works | works, but **Pages cannot set COOP/COEP**, so it falls back to the synchronous backend without shared cache |
| Module format | CJS+ESM | **ES6 only** |
Both are self-declared experimental. sqlite-wasm-http's advantage (maintained,
official WASM) is real; its headline feature (shared cache) is unavailable on
Pages precisely because Pages cannot send cross-origin isolation headers.
## Implementation recommendations
1. **Change page size to 1024 and `requestChunkSize` to 1024.** Parser sets
`PRAGMA page_size=1024` before DDL; VACUUM already runs. Cost ~+5% file
size; benefit ~4× less overfetch per row read.
2. **Keep `serverMode: "full"` for now.** Chunked mode's benefit is CDN cache
efficiency, which Pages' 10-minute TTL blunts. Revisit if measured repeat-
visit cost is bad.
3. **Keep sql.js-httpvfs.** Switching to sqlite-wasm-http buys a maintained
dependency but loses nothing we use, and its differentiator does not work on
Pages. Note it as the escape hatch.
4. **Verify `Content-Encoding` after deploy** — the single fatal hosting case.
5. Keep the byte budget; drop any idea of a request-count budget.
## Common pitfalls
- Unindexed query → whole table over the network. `EXPLAIN QUERY PLAN` is the
check.
- Page size mismatched with `requestChunkSize` → every logical page read spans
two requests.
- Serving the database compressed → library refuses to open it.
- Assuming CDN caching helps: on Pages, `max-age=600`.
## References
- [Hosting SQLite databases on GitHub Pages — phiresky](https://phiresky.github.io/blog/2021/hosting-sqlite-databases-on-github-pages/)
- [phiresky/sql.js-httpvfs](https://github.com/phiresky/sql.js-httpvfs)
- [mmomtchev/sqlite-wasm-http](https://github.com/mmomtchev/sqlite-wasm-http)
- [Query SQLite on GitHub Pages with sql.js-httpvfs (Mar 2026)](https://recca0120.github.io/en/2026/03/07/sql-js-httpvfs-static-hosting/)
- [sqlite3 WebAssembly documentation](https://sqlite.org/wasm)
- [GitHub Pages asset caching discussion](https://github.com/orgs/community/discussions/11884)
## Unresolved questions
1. Does 1024 measurably beat 4096 *for our queries*? Only a browser with the
byte counter can answer; the estimate says yes for row fetches.
2. Does Fastly cache 206 responses for a 289 MB object well enough that chunked
mode is unnecessary? Needs a deployed measurement.
3. Does the read-head prefetch overfetch on our index-range walks (fetching
ahead beyond `LIMIT 100`)? Unknown without instrumentation.
+5 -12
View File
@@ -1,12 +1,10 @@
import js from "@eslint/js";
import svelte from "eslint-plugin-svelte";
import globals from "globals";
import ts from "typescript-eslint";
import svelteConfig from "./svelte.config.js";
export default ts.config(
export default [
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
{
languageOptions: {
@@ -15,16 +13,11 @@ export default ts.config(
},
{
// Svelte files and rune modules are parsed by svelte-eslint-parser, which
// needs the TypeScript parser handed to it for `lang="ts"` blocks.
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
// needs the project's svelte.config.js to resolve aliases and runes.
files: ["**/*.svelte", "**/*.svelte.js"],
languageOptions: {
parserOptions: {
parser: ts.parser,
projectService: true,
extraFileExtensions: [".svelte"],
svelteConfig,
},
parserOptions: { svelteConfig },
},
},
{ ignores: ["dist/", ".svelte-kit/", "node_modules/"] },
);
];
+3 -6
View File
@@ -1,14 +1,11 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
"sourceMap": true
}
}
+15 -477
View File
@@ -8,7 +8,7 @@
"name": "thptqg-web",
"version": "1.0.0",
"dependencies": {
"sql.js": "^1.14.1"
"sql.js-httpvfs": "^0.8.12"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
@@ -16,15 +16,11 @@
"@sveltejs/kit": "^2.70.2",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.3.3",
"@types/sql.js": "^1.4.9",
"eslint": "^9.39.4",
"eslint-plugin-svelte": "^3.14.0",
"globals": "^17.4.0",
"svelte": "^5.56.9",
"svelte-check": "^4.4.3",
"tailwindcss": "^4.3.3",
"typescript": "^5.9.3",
"typescript-eslint": "^8.48.2",
"vite": "^7.1.14",
"vitest": "^4.1.10"
}
@@ -1187,16 +1183,6 @@
}
}
},
"node_modules/@sveltejs/load-config": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz",
"integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 18.0.0"
}
},
"node_modules/@sveltejs/vite-plugin-svelte": {
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz",
@@ -1818,13 +1804,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/emscripten": {
"version": "1.41.5",
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
"integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"dev": true,
@@ -1835,27 +1814,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/sql.js": {
"version": "1.4.11",
"resolved": "https://registry.npmjs.org/@types/sql.js/-/sql.js-1.4.11.tgz",
"integrity": "sha512-QXIx38p2ZThJaK9vP5ZdqdlRe1FG9I8SmCZOS7FHfB/2qPAjZwkL7/vlfPg6N/oWHuuOaGg/P/IRwfP2W0kWVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/emscripten": "*",
"@types/node": "*"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -1863,301 +1821,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz",
"integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.67.0",
"@typescript-eslint/type-utils": "8.67.0",
"@typescript-eslint/utils": "8.67.0",
"@typescript-eslint/visitor-keys": "8.67.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.67.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz",
"integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz",
"integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.67.0",
"@typescript-eslint/types": "8.67.0",
"@typescript-eslint/typescript-estree": "8.67.0",
"@typescript-eslint/visitor-keys": "8.67.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz",
"integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.67.0",
"@typescript-eslint/types": "^8.67.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz",
"integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.67.0",
"@typescript-eslint/visitor-keys": "8.67.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz",
"integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz",
"integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.67.0",
"@typescript-eslint/typescript-estree": "8.67.0",
"@typescript-eslint/utils": "8.67.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz",
"integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz",
"integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.67.0",
"@typescript-eslint/tsconfig-utils": "8.67.0",
"@typescript-eslint/types": "8.67.0",
"@typescript-eslint/visitor-keys": "8.67.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.8"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz",
"integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.67.0",
"@typescript-eslint/types": "8.67.0",
"@typescript-eslint/typescript-estree": "8.67.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz",
"integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.67.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@vitest/expect": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
@@ -2401,22 +2064,6 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -2443,6 +2090,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/comlink": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/comlink/-/comlink-4.4.2.tgz",
"integrity": "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==",
"license": "Apache-2.0"
},
"node_modules/concat-map": {
"version": "0.0.1",
"dev": true,
@@ -3183,16 +2836,6 @@
"node": "*"
}
},
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
@@ -3489,20 +3132,6 @@
"node": ">=6"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/resolve-from": {
"version": "4.0.0",
"dev": true,
@@ -3557,19 +3186,6 @@
"fsevents": "~2.3.2"
}
},
"node_modules/sade": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
"integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
"dev": true,
"license": "MIT",
"dependencies": {
"mri": "^1.1.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/set-cookie-parser": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz",
@@ -3626,9 +3242,14 @@
"node": ">=0.10.0"
}
},
"node_modules/sql.js": {
"version": "1.14.1",
"license": "MIT"
"node_modules/sql.js-httpvfs": {
"version": "0.8.12",
"resolved": "https://registry.npmjs.org/sql.js-httpvfs/-/sql.js-httpvfs-0.8.12.tgz",
"integrity": "sha512-lcEBc2q0psFRfdCx8Di22oUIkkv5MUIaVO/fGCj/Jjx6YQDKVylQEcjd7NSSbmINHTRwVkm/vWP8uuevT7Rkkw==",
"license": "Apache-2.0",
"dependencies": {
"comlink": "^4.3.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
@@ -3694,31 +3315,6 @@
"node": ">=18"
}
},
"node_modules/svelte-check": {
"version": "4.7.6",
"resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.6.tgz",
"integrity": "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.25",
"@sveltejs/load-config": "^0.2.3",
"chokidar": "^4.0.1",
"fdir": "^6.2.0",
"picocolors": "^1.0.0",
"sade": "^1.7.4"
},
"bin": {
"svelte-check": "bin/svelte-check"
},
"engines": {
"node": ">= 18.0.0"
},
"peerDependencies": {
"svelte": "^4.0.0 || ^5.0.0-next.0",
"typescript": "^5.0.0 || ^6.0.0"
}
},
"node_modules/svelte-eslint-parser": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.8.0.tgz",
@@ -3836,19 +3432,6 @@
"node": ">=6"
}
},
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.12"
},
"peerDependencies": {
"typescript": ">=4.8.4"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"dev": true,
@@ -3860,51 +3443,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/typescript-eslint": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz",
"integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.67.0",
"@typescript-eslint/parser": "8.67.0",
"@typescript-eslint/typescript-estree": "8.67.0",
"@typescript-eslint/utils": "8.67.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"dev": true,
"license": "MIT"
},
"node_modules/uri-js": {
"version": "4.4.1",
"dev": true,
+2 -6
View File
@@ -9,10 +9,10 @@
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"lint": "eslint . && svelte-check --tsconfig ./tsconfig.json"
"lint": "eslint ."
},
"dependencies": {
"sql.js": "^1.14.1"
"sql.js-httpvfs": "^0.8.12"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
@@ -20,15 +20,11 @@
"@sveltejs/kit": "^2.70.2",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.3.3",
"@types/sql.js": "^1.4.9",
"eslint": "^9.39.4",
"eslint-plugin-svelte": "^3.14.0",
"globals": "^17.4.0",
"svelte": "^5.56.9",
"svelte-check": "^4.4.3",
"tailwindcss": "^4.3.3",
"typescript": "^5.9.3",
"typescript-eslint": "^8.48.2",
"vite": "^7.1.14",
"vitest": "^4.1.10"
}
@@ -1,15 +1,3 @@
import type { Student, SubjectKey } from "./types";
export type AdmissionBlock = { code: string; subjects: SubjectKey[]; label: string };
export type ComputedBlock = {
code: string;
label: string;
total: number;
parts: { key: SubjectKey; score: number }[];
};
export type TierKey = "common" | "uncommon" | "rare" | "epic" | "legendary" | "prismatic";
export type Tier = { key: TierKey; symbol: string; label: string };
// Vietnamese university admission-block ("khối thi") definitions, per Circular
// 03/2017/TT-BGDĐT. Each block is the sum of exactly 3 subject scores.
//
@@ -17,7 +5,7 @@ export type Tier = { key: TierKey; symbol: string; label: string };
// skips any block with a missing subject score, so blocks needing GDCD
// self-exclude on 2016 rows, and blocks needing a language the candidate did
// not sit self-exclude row by row.
export const ADMISSION_BLOCKS: AdmissionBlock[] = [
export const ADMISSION_BLOCKS = [
{ code: "A00", subjects: ["toan", "vat_ly", "hoa_hoc"], label: "Toán + Lý + Hóa" },
{ code: "A01", subjects: ["toan", "vat_ly", "tieng_anh"], label: "Toán + Lý + Anh" },
{ code: "A02", subjects: ["toan", "vat_ly", "sinh_hoc"], label: "Toán + Lý + Sinh" },
@@ -76,14 +64,13 @@ export const ADMISSION_BLOCKS: AdmissionBlock[] = [
// Returns { code, label, total, parts:[{key,score}] } for every block where
// the student has scores for all three subjects, sorted by total desc.
export function computeBlocks(student: Student): ComputedBlock[] {
const out: ComputedBlock[] = [];
export function computeBlocks(student) {
const out = [];
for (const b of ADMISSION_BLOCKS) {
const parts = b.subjects.map((k) => ({ key: k, score: student[k] }));
if (parts.some((p) => p.score === null || p.score === undefined)) continue;
const whole = parts as { key: SubjectKey; score: number }[];
const total = whole.reduce((s, p) => s + p.score, 0);
out.push({ code: b.code, label: b.label, total, parts: whole });
const total = parts.reduce((s, p) => s + p.score, 0);
out.push({ code: b.code, label: b.label, total, parts });
}
return out.sort((a, b) => b.total - a.total);
}
@@ -93,7 +80,7 @@ export function computeBlocks(student: Student): ComputedBlock[] {
// whatever the other scores are, so that boundary is inclusive while the rest
// are exclusive upper bounds. Every tier carries a unicode symbol as well as a
// color, so the meaning is never conveyed by color alone.
export function scoreTier(score: number | null | undefined): Tier | null {
export function scoreTier(score) {
if (score === null || score === undefined) return null;
if (score <= 1) return { key: "common", symbol: "·", label: "Điểm liệt" };
if (score < 5) return { key: "uncommon", symbol: "○", label: "Chưa đạt" };
@@ -1,9 +1,8 @@
import { describe, expect, it } from "vitest";
import { ADMISSION_BLOCKS, computeBlocks, scoreTier } from "./admission-blocks";
import type { Student } from "./types";
/** A row with every column NULL, so a test only states the scores it needs. */
function student(scores: Partial<Student>): Student {
function student(scores) {
return {
so_bao_danh: "49008235",
ho_ten: "Nguyễn Văn A",
+45 -42
View File
@@ -1,29 +1,26 @@
<script lang="ts">
import type { Database } from "sql.js";
import type { PresetGroup } from "$lib/types";
<script>
import { formatBytes, isBudgetError } from "$lib/sqlite.svelte";
const MAX_ROWS = 1000;
const PLACEHOLDER = "Nhập truy vấn SQL...\nVí dụ: SELECT * FROM student WHERE toan >= 9 LIMIT 10";
let {
db,
disabled = false,
presets = [],
}: { db: Database | null; disabled?: boolean; presets?: PresetGroup[] } = $props();
let { db, disabled = false, presets = [] } = $props();
// By convention every preset list ends with a "Hệ thống" group whose first
// query dumps the table schema. See lib/sql-presets.ts.
const schemaPreset = $derived(presets[presets.length - 1]?.queries[0]);
let sql = $state("");
let columns = $state<string[]>([]);
let rows = $state<unknown[][]>([]);
let queryError = $state<string | null>(null);
let execTime = $state<string | null>(null);
let columns = $state([]);
let rows = $state([]);
let queryError = $state(null);
let execTime = $state(null);
let running = $state(false);
let autoRan = false;
function execute(queryStr: string) {
if (!db) return;
async function execute(queryStr) {
const source = db;
if (!source?.ready) return;
queryError = null;
columns = [];
rows = [];
@@ -32,8 +29,8 @@
const trimmed = queryStr.trim();
if (!trimmed) return;
// Read-only statements only. The database is a per-browser copy, so this
// guards the user's own session against a typo, not the server.
// Read-only statements only. The database is remote and read-only anyway,
// so this guards the user's own session against a typo.
const upper = trimmed.toUpperCase();
if (!["SELECT", "PRAGMA", "EXPLAIN", "WITH"].some((kw) => upper.startsWith(kw))) {
queryError = "Chỉ hỗ trợ truy vấn đọc (SELECT, PRAGMA, EXPLAIN, WITH).";
@@ -47,39 +44,38 @@
finalSql = `${trimmed.replace(/;$/, "")} LIMIT ${MAX_ROWS}`;
}
running = true;
const start = performance.now();
try {
const start = performance.now();
const stmt = db.prepare(finalSql);
const colNames = stmt.getColumnNames();
const resultRows: unknown[][] = [];
let count = 0;
while (stmt.step() && count < MAX_ROWS) {
resultRows.push(stmt.get());
count++;
}
stmt.free();
const result = await source.query(finalSql, [], "SQL tab");
execTime = (performance.now() - start).toFixed(1);
columns = colNames;
rows = resultRows;
rows = result.slice(0, MAX_ROWS);
columns = rows.length > 0 ? Object.keys(rows[0]) : [];
} catch (err) {
queryError = err instanceof Error ? err.message : String(err);
queryError = isBudgetError(err)
? "Truy vấn này phải đọc quá nhiều dữ liệu và đã bị dừng. Hãy thêm điều kiện lọc, " +
"hoặc dùng cột đã có chỉ mục (so_bao_danh, toan, khtn, khxh, ten_cum_thi)."
: err instanceof Error
? err.message
: String(err);
} finally {
running = false;
}
}
function runPreset(presetSql: string) {
function runPreset(presetSql) {
sql = presetSql;
execute(presetSql);
void execute(presetSql);
}
// Show the student columns the first time the tab opens, rather than a blank
// textarea. Once only, however the database changes underneath.
$effect(() => {
if (!db || autoRan || !schemaPreset) return;
if (!db?.ready || autoRan || !schemaPreset) return;
autoRan = true;
runPreset(schemaPreset.sql);
});
</script>
<div class="mx-auto max-w-[900px]">
@@ -97,7 +93,7 @@
type="button"
class="btn-chip rounded-md"
onclick={() => runPreset(preset.sql)}
{disabled}
disabled={disabled || running}
>
{preset.label}
</button>
@@ -111,7 +107,7 @@
class="query-form mb-4"
onsubmit={(e) => {
e.preventDefault();
execute(sql);
void execute(sql);
}}
>
<textarea
@@ -122,13 +118,20 @@
rows={5}
spellcheck="false"
></textarea>
<div class="mt-2 flex items-center gap-4">
<button type="submit" class="btn-primary" disabled={disabled || !sql.trim()}>
Thực thi (Ctrl+Enter)
<div class="mt-2 flex flex-wrap items-center gap-4">
<button type="submit" class="btn-primary" disabled={disabled || running || !sql.trim()}>
{running ? "Đang chạy…" : "Thực thi (Ctrl+Enter)"}
</button>
{#if execTime !== null}
<span class="text-sm text-ink-muted">{rows.length} kết quả · {execTime}ms</span>
{/if}
{#if db?.lastCost}
<!-- What this query cost, then what the session has cost so far. -->
<span class="text-sm text-ink-subtle">
Truy vấn này: {db.lastCost.requests} yêu cầu · {formatBytes(db.lastCost.bytes)}
· Phiên: {db.requests} yêu cầu · {formatBytes(db.bytesRead)}
</span>
{/if}
</div>
</form>
@@ -145,7 +148,7 @@
[&>th]:bg-surface-alt [&>th]:px-2 [&>th]:py-2.5 [&>th]:text-left
[&>th]:whitespace-nowrap"
>
{#each columns as col, i (i)}
{#each columns as col (col)}
<th>{col}</th>
{/each}
</tr>
@@ -155,9 +158,9 @@
<tr
class="hover:bg-surface-alt [&>td]:border-b [&>td]:border-line [&>td]:px-2 [&>td]:py-2"
>
{#each row as cell, ci (ci)}
{#each columns as col (col)}
<td class="text-center font-medium tabular-nums">
{cell === null ? "NULL" : String(cell)}
{row[col] === null ? "NULL" : String(row[col])}
</td>
{/each}
</tr>
+3 -4
View File
@@ -1,11 +1,10 @@
<script lang="ts">
<script>
import { scoreTier } from "$lib/admission-blocks";
import { IDENTITY_COLUMNS, SUBJECTS, hasAnyValue } from "$lib/subjects";
import type { Student } from "$lib/types";
let { results }: { results: Student[] | null } = $props();
let { results } = $props();
function formatScore(val: number | null): string {
function formatScore(val) {
return val === null || val === undefined ? "—" : Number(val).toFixed(2);
}
+5 -17
View File
@@ -1,28 +1,16 @@
<script lang="ts">
<script>
import { detectMode } from "$lib/query-mode";
const DEBOUNCE_MS = 300;
let {
value = "",
onSearch,
onClear,
disabled = false,
examples = [],
}: {
value?: string;
onSearch: (q: string) => void;
onClear?: () => void;
disabled?: boolean;
examples?: string[];
} = $props();
let { value = "", onSearch, onClear, disabled = false, examples = [] } = $props();
// A writable derived: it follows the owner's value — which is bound to the
// URL, so a deep link or the clear button flows in — and typing overrides it
// until the next external change.
let query = $derived(value);
let input = $state<HTMLInputElement | null>(null);
let timer: ReturnType<typeof setTimeout> | undefined;
let input = $state(null);
let timer;
const detected = $derived(detectMode(query));
const canSearch = $derived(detected.mode === "sbd" || detected.mode === "name");
@@ -45,7 +33,7 @@
return () => clearTimeout(timer);
});
function submit(event: SubmitEvent) {
function submit(event) {
event.preventDefault();
if (!canSearch) return;
clearTimeout(timer);
+7 -8
View File
@@ -1,9 +1,8 @@
<script lang="ts">
<script>
import { computeBlocks, scoreTier } from "$lib/admission-blocks";
import { SUBJECTS } from "$lib/subjects";
import type { Student, SubjectKey } from "$lib/types";
let { student }: { student: Student } = $props();
let { student } = $props();
// Visible legend, so a user does not have to hover tiles to decode the
// colours. The ranges must stay in step with scoreTier() in
@@ -17,22 +16,22 @@
{ key: "prismatic", symbol: "❖", range: "9-10", label: "Xuất sắc" },
];
let copied = $state<null | "sbd" | "share">(null);
let copied = $state(null);
const blocks = $derived(computeBlocks(student));
const subjects = $derived(
SUBJECTS.filter((s) => student[s.key] !== null && student[s.key] !== undefined).map((s) => ({
key: s.key as SubjectKey,
key: s.key,
label: s.label,
score: student[s.key] as number,
score: student[s.key],
})),
);
function fmt(n: number | null | undefined): string {
function fmt(n) {
return n === null || n === undefined ? "—" : Number(n).toFixed(2);
}
function flash(kind: "sbd" | "share") {
function flash(kind) {
copied = kind;
setTimeout(() => (copied = null), 1500);
}
@@ -13,21 +13,23 @@
import registry from "../../../datasets.json";
import { PRESETS_2016, PRESETS_2017 } from "./sql-presets";
import type { Dataset, PresetGroup } from "./types";
const SUBTITLE = "Dữ liệu thí sinh toàn quốc · Hỗ trợ truy vấn SQL tùy chỉnh";
/**
* Presentation, keyed by the ids declared in datasets.json.
*
* `source` is the full article URL the dataset's spreadsheets come from, shown
* in the footer as a link. The canonical copy is the crawler's `Article` field
* `source` is the full article URL the dataset's spreadsheets come from. The
* canonical copy is the crawler's `Article` field
* (crawler/internal/sources/source_<id>.go); it is duplicated here because a Go
* module and a Vite app cannot share a constant. Keep the two in step.
* module and the web app cannot share a constant. Keep the two in step.
*
* `sourceName` is who published that article, and is what the footer shows
* the URLs run to 120 characters and used to wrap across three lines on a
* phone. The link still points at the article, and its title attribute still
* carries the URL for anyone who wants to see where it goes.
*/
type Content = Omit<Dataset, "id" | "dbSizeMb" | "blurb"> & { presets: PresetGroup[] };
const CONTENT: Record<string, Content> = {
const CONTENT = {
2016: {
label: "Kỳ thi 2016",
title: "Tra cứu điểm thi THPT Quốc gia 2016",
@@ -36,6 +38,7 @@ const CONTENT: Record<string, Content> = {
// ministry — hence the unexpected domain.
source:
"https://dtnt.bacninh.edu.vn/tin-tuc/tin-tuc-su-kien/cong-bo-diem-thi-thptqg-2016-toan-bo-120-cum-thi-da-co-diem.html",
sourceName: "Trường Phổ thông DTNT tỉnh Bắc Ninh",
examples: ["TKG002747", "Nguyễn Bửu Lộc"],
presets: PRESETS_2016,
},
@@ -45,6 +48,7 @@ const CONTENT: Record<string, Content> = {
subtitle: SUBTITLE,
source:
"https://baotintuc.vn/tuyen-sinh/tra-cuu-diem-thi-thpt-2017-cua-63-tinh-thanh-pho-tren-baotintucvn-20170706073512672.htm",
sourceName: "Báo Tin tức và Dân tộc - TTXVN",
examples: ["49008235", "Nguyễn Minh Tiến"],
presets: PRESETS_2017,
},
@@ -57,6 +61,13 @@ for (const { id } of registry.datasets) {
if (!CONTENT[id]) {
throw new Error(`datasets.json declares "${id}" but web/src/datasets.js has no content for it`);
}
// The footer renders sourceName as the link text, so a missing one is an
// empty link rather than a visible mistake.
for (const field of ["title", "label", "source", "sourceName", "examples", "presets"]) {
if (!CONTENT[id][field]) {
throw new Error(`web/src/lib/datasets.js: dataset "${id}" is missing ${field}`);
}
}
}
for (const id of Object.keys(CONTENT)) {
if (!registry.datasets.some((d) => d.id === id)) {
@@ -64,9 +75,10 @@ for (const id of Object.keys(CONTENT)) {
}
}
export const DATASETS: Dataset[] = registry.datasets.map((d) => ({
export const DATASETS = registry.datasets.map((d) => ({
id: d.id,
dbSizeMb: d.dbSizeMb,
rows: d.expectedRows,
// Derived from expectedRows so the count the hub shows is the same number the
// assembler enforces.
blurb: `${d.expectedRows.toLocaleString("vi-VN")} thí sinh`,
@@ -74,7 +86,7 @@ export const DATASETS: Dataset[] = registry.datasets.map((d) => ({
}));
/** Look up a dataset by the route segment, or undefined for an unknown id. */
export function datasetById(id: string): Dataset | undefined {
export function datasetById(id) {
return DATASETS.find((d) => d.id === id);
}
@@ -82,11 +94,16 @@ export function datasetById(id: string): Dataset | undefined {
* Site path for a dataset. `base` is SvelteKit's, which carries no trailing
* slash: pathOf(d, "/thptqg") "/thptqg/2017/".
*/
export function pathOf(dataset: Dataset, base: string): string {
export function pathOf(dataset, base) {
return `${base}/${dataset.id}/`;
}
/** Gzipped database URL, e.g. dbOf(d, "/thptqg") → "/thptqg/db/2017.db.gz". */
export function dbOf(dataset: Dataset, base: string): string {
return `${base}/db/${dataset.id}.db.gz`;
/**
* Database URL, e.g. dbOf(d, "/thptqg") "/thptqg/db/2017.sqlite3".
*
* Uncompressed on purpose: the browser reads byte ranges of it, and a range of
* a gzip stream is not a range of the database.
*/
export function dbOf(dataset, base) {
return `${base}/db/${dataset.id}.sqlite3`;
}
@@ -18,11 +18,8 @@ const SBD_PATTERN = /^[A-Za-z]{0,4}\d+$/;
export const MIN_SBD_DIGITS = 3;
export const MIN_NAME_CHARS = 2;
/** How a query was classified, and the hint shown beneath the field. */
export type QueryMode = "empty" | "sbd" | "sbd-short" | "name" | "name-short";
/** True when the query looks like an exam ID rather than a name. */
export function isExamId(query: string): boolean {
export function isExamId(query) {
return SBD_PATTERN.test(query.trim());
}
@@ -30,19 +27,20 @@ export function isExamId(query: string): boolean {
* Normalise an exam ID for lookup. Letter prefixes are stored upper-case, so a
* user typing "bal000001" still matches. No-op for all-digit IDs.
*/
export function normaliseExamId(query: string): string {
export function normaliseExamId(query) {
return query.trim().toUpperCase();
}
function digitCount(str: string): number {
function digitCount(str) {
return (str.match(/\d/g) ?? []).length;
}
/**
* Decide what the user is searching for, and what hint to show beneath the
* field.
* field. Returns `{ mode, hint }` where mode is one of:
* empty | sbd | sbd-short | name | name-short.
*/
export function detectMode(raw: string): { mode: QueryMode; hint: string } {
export function detectMode(raw) {
const q = raw.trim();
if (!q) return { mode: "empty", hint: "Gõ SBD (số báo danh) hoặc họ tên để tìm" };
+96
View File
@@ -0,0 +1,96 @@
import { normaliseExamId } from "./query-mode";
import { toAscii } from "./to-ascii";
export const MAX_RESULTS = 100;
/**
* Name search over the name_word table.
*
* A query is matched word by word, each word as a prefix, in any order so
* "buu loc" finds "Nguyễn Bửu Lộc". The work is arranged so that only one word
* is ever seeked on and the rest are filtered inside the same b-tree:
*
* 1. ask name_word_freq how many entries each word prefix covers (the whole
* vocabulary is ~4,400 rows, so this is a couple of pages);
* 2. seek on the rarest one for a real name that is a few hundred to a few
* thousand entries rather than the 300,000 a word like "thi" would walk;
* 3. filter the other words against the ho_ten_ascii copy carried in
* name_word, so nothing is read from student until a row has matched;
* 4. join to student for the rows that survive, at most MAX_RESULTS of them.
*
* Every step is an index seek. A search costs a few hundred KB.
*/
export async function searchByName(db, query) {
const words = tokenise(query);
if (words.length === 0) return [];
const seek = await rarest(db, words);
const others = words.filter((w) => w !== seek);
// A word matches at a word boundary: the leading space makes the first word
// reachable by the same pattern as the rest.
const filters = others.map(() => `(' ' || w.ho_ten_ascii) LIKE ? ESCAPE '\\'`);
const sql = `
SELECT s.* FROM name_word w
JOIN student s ON s.so_bao_danh = w.so_bao_danh
WHERE w.word >= ? AND w.word < ?${filters.length ? " AND " + filters.join(" AND ") : ""}
LIMIT ${MAX_RESULTS}`;
return db.query(
sql,
[seek, upperBound(seek), ...others.map((w) => `% ${escapeLike(w)}%`)],
`name ${JSON.stringify(words.join(" "))} seeking ${JSON.stringify(seek)}`,
);
}
/** Exact lookup by exam number: a primary-key seek, a few pages. */
export async function lookupExamId(db, id) {
return db.query(
"SELECT * FROM student WHERE so_bao_danh = ? LIMIT ?",
[normaliseExamId(id), MAX_RESULTS],
`exam id ${JSON.stringify(normaliseExamId(id))}`,
);
}
/** Fold to ASCII and split into words, the same shape name_word was built in. */
export function tokenise(query) {
return toAscii(query).split(/\s+/).filter(Boolean);
}
/**
* The word whose prefix covers the fewest entries, which is the one worth
* seeking on. One round trip for all of them.
*/
async function rarest(db, words) {
if (words.length === 1) return words[0];
const sql = words
.map(() => "SELECT ? AS word, COALESCE(SUM(n), 0) AS n FROM name_word_freq WHERE word >= ? AND word < ?")
.join(" UNION ALL ");
const params = words.flatMap((w) => [w, w, upperBound(w)]);
const counts = await db.query(sql, params, "word frequencies");
let best = words[0];
let bestN = Infinity;
for (const { word, n } of counts) {
if (n < bestN) {
best = word;
bestN = n;
}
}
return best;
}
/**
* The exclusive upper bound of a prefix range. U+FFFF sorts above any character
* that can follow the prefix, which is what turns "starts with" into a range
* the index can seek.
*/
function upperBound(prefix) {
return prefix + "￿";
}
/** Escape the LIKE wildcards so a user typing % or _ searches for them. */
function escapeLike(s) {
return s.replace(/[\\%_]/g, (c) => `\\${c}`);
}
@@ -1,5 +1,3 @@
import type { PresetGroup } from "./types";
/**
* SQL presets shown in the "Truy vấn SQL" tab, per dataset.
*
@@ -13,7 +11,7 @@ import type { PresetGroup } from "./types";
* before the user writes anything.
*/
export const PRESETS_2017: PresetGroup[] = [
export const PRESETS_2017 = [
{
category: "Xếp hạng môn",
queries: [
@@ -181,7 +179,7 @@ FROM student`,
],
},
];
export const PRESETS_2016: PresetGroup[] = [
export const PRESETS_2016 = [
{
category: "Xếp hạng môn",
queries: [
+161
View File
@@ -0,0 +1,161 @@
import { createDbWorker } from "sql.js-httpvfs";
import workerUrl from "sql.js-httpvfs/dist/sqlite.worker.js?url";
import wasmUrl from "sql.js-httpvfs/dist/sql-wasm.wasm?url";
/**
* The database is read where it lies. SQLite asks for pages, the virtual file
* system turns each into an HTTP range request, and only the pages a query
* touches ever cross the network a few hundred KB for a lookup, against the
* 45 MB the whole file used to cost before the first query.
*
* That only holds while every query is index-driven. The schema exists for it:
* name_word serves name search, and the score indexes serve the SQL presets.
* An unindexed query walks the table and pulls all 100+ MB of it, which is what
* the byte budget below is for.
*/
// Must equal the page size the parser writes (PRAGMA page_size in
// parser/internal/writer/writer.go), so one request is exactly one page. A
// mismatch makes every logical page read span two requests.
const CHUNK_BYTES = 1024;
/** Generous for indexed work: a name search costs well under 1 MB. */
export const SEARCH_BUDGET_BYTES = 25 * 1024 * 1024;
/** What the SQL tab gets once the user has accepted the cost of a scan. */
export const PLAYGROUND_BUDGET_BYTES = 250 * 1024 * 1024;
/**
* What one query cost over the network: `{ requests, bytes, ms }`.
*/
/**
* One remotely-paged database, with the load state the UI needs.
*
* `budgetBytes` is a hard ceiling for the worker's lifetime: past it a query
* fails instead of quietly downloading the file. Raising it means a new worker,
* which costs only the header pages.
*/
export class RemoteDatabase {
ready = $state(false);
error = $state(null);
/** Bytes fetched by this database so far, prefetch included. */
bytesRead = $state(0);
/** HTTP range requests issued so far. */
requests = $state(0);
/** What the most recent query cost on its own. */
lastCost = $state(null);
#worker = null;
#opening;
#closed = false;
// Previous cumulative reading, so a query's own cost is a subtraction.
#seen = { requests: 0, bytes: 0 };
constructor(url, budgetBytes = SEARCH_BUDGET_BYTES) {
this.url = url;
this.budgetBytes = budgetBytes;
this.#opening = this.#open();
}
async #open() {
const opened = performance.now();
try {
const worker = await createDbWorker(
[{ from: "inline", config: { serverMode: "full", url: this.url, requestChunkSize: CHUNK_BYTES } }],
workerUrl,
wasmUrl,
this.budgetBytes,
);
if (this.#closed) throw new Error("closed");
this.#worker = worker;
this.ready = true;
// Opening is not free either: the header and schema pages are read before
// any query runs, and that shows up in every later session total.
await this.#account(worker, `open ${this.url}`, performance.now() - opened);
return worker;
} catch (err) {
if (!this.#closed) {
this.error = message(err);
this.ready = false;
}
throw err;
}
}
/**
* Run a query and return its rows as objects.
*
* `label` names the query in the console trace the only way to see what a
* search actually costs, since the byte count depends on how much the read
* heads prefetched, not just on the pages the plan needed.
*/
async query(sql, params = [], label) {
const worker = this.#worker ?? (await this.#opening);
const started = performance.now();
try {
return await worker.db.query(sql, ...params);
} finally {
await this.#account(worker, label ?? firstLine(sql), performance.now() - started);
}
}
/**
* Read the cumulative counters and report the delta.
*
* getStats() rather than the worker's `bytesRead`: that one is the budget
* counter and resets itself to zero when a query trips the ceiling, so it
* would under-report exactly when the number matters most.
*/
async #account(worker, label, ms) {
const stats = await worker.worker.getStats().catch(() => null);
if (!stats) return;
const cost = {
requests: stats.totalRequests - this.#seen.requests,
bytes: stats.totalFetchedBytes - this.#seen.bytes,
ms,
};
this.#seen = { requests: stats.totalRequests, bytes: stats.totalFetchedBytes };
this.requests = stats.totalRequests;
this.bytesRead = stats.totalFetchedBytes;
this.lastCost = cost;
console.info(
`[httpvfs] ${label}${cost.requests} request(s), ${formatBytes(cost.bytes)}, ${ms.toFixed(0)} ms` +
` · session ${stats.totalRequests} request(s), ${formatBytes(stats.totalFetchedBytes)}` +
` of ${formatBytes(stats.totalBytes)}`,
);
}
/**
* Drop this database. createDbWorker owns the Worker and exposes no handle to
* it, so the thread outlives this call; a page creates at most one per
* dataset and one more if the SQL budget is raised, which is why that is
* tolerable rather than a leak worth working around.
*/
close() {
this.#closed = true;
this.#worker = null;
this.ready = false;
}
}
/** True when a query failed because it would have exceeded the byte budget. */
export function isBudgetError(err) {
return /maxBytesToRead|too much data|exceeded/i.test(message(err));
}
function message(err) {
return err instanceof Error ? err.message : String(err);
}
/** Enough of a query to recognise it in the console. */
function firstLine(sql) {
const line = sql.trim().split("\n")[0];
return line.length > 70 ? `${line.slice(0, 70)}` : line;
}
export function formatBytes(n) {
return n < 1024 * 1024 ? `${Math.round(n / 1024)} KB` : `${(n / 1048576).toFixed(1)} MB`;
}
-84
View File
@@ -1,84 +0,0 @@
import initSqlJs, { type Database } from "sql.js";
// The sql.js engine itself, fetched from the upstream CDN rather than bundled.
// It is a hard runtime dependency: if this URL is unreachable, initSqlJs()
// rejects and no dataset can be opened at all, whatever the database fetch does.
const SQL_WASM_URL = "https://sql.js.org/dist/sql-wasm.wasm";
/**
* A SQLite database loaded from a URL into sql.js, with reactive load state.
*
* The whole file is downloaded and decompressed before the first query: a `.gz`
* URL is inflated in the browser. Nothing streams sql.js needs the complete
* image in memory.
*/
export class SqliteSource {
db = $state<Database | null>(null);
loading = $state(true);
error = $state<string | null>(null);
progress = $state(0);
#cancelled = false;
constructor(url: string) {
void this.#load(url);
}
async #load(url: string) {
try {
const SQL = await initSqlJs({ locateFile: () => SQL_WASM_URL });
const response = await fetch(url);
if (!response.ok) throw new Error(`Failed to fetch database: ${response.status}`);
const contentLength = Number(response.headers.get("Content-Length")) || 0;
const reader = response.body!.getReader();
const chunks: Uint8Array[] = [];
let received = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
if (contentLength > 0) {
this.progress = Math.round((received / contentLength) * 100);
}
}
if (this.#cancelled) return;
const blob = new Blob(chunks as BlobPart[]);
const bytes = url.endsWith(".gz")
? await new Response(blob.stream().pipeThrough(new DecompressionStream("gzip"))).arrayBuffer()
: await blob.arrayBuffer();
if (this.#cancelled) return;
this.db = new SQL.Database(new Uint8Array(bytes));
this.loading = false;
} catch (err) {
if (this.#cancelled) return;
this.error = err instanceof Error ? err.message : String(err);
this.loading = false;
}
}
/** Release the database. Call from the owning component's teardown. */
close() {
this.#cancelled = true;
this.db?.close();
this.db = null;
}
}
/** Run a statement and return its rows as objects. */
export function queryRows<T>(db: Database, sql: string, params?: Record<string, unknown>): T[] {
const stmt = db.prepare(sql);
try {
if (params) stmt.bind(params as never);
const rows: T[] = [];
while (stmt.step()) rows.push(stmt.getAsObject() as T);
return rows;
} finally {
stmt.free();
}
}
@@ -1,5 +1,3 @@
import type { IdentityKey, Student, SubjectKey } from "./types";
/**
* The 16 subject columns of the canonical schema, in display order. Mirrors
* `ScoreFields` in parser/internal/schema/schema.go, and is the single list both
@@ -10,7 +8,7 @@ import type { IdentityKey, Student, SubjectKey } from "./types";
* years without branching.
*/
export const SUBJECTS: { key: SubjectKey; label: string }[] = [
export const SUBJECTS = [
{ key: "toan", label: "Toán" },
{ key: "ngu_van", label: "Ngữ văn" },
{ key: "vat_ly", label: "Vật lí" },
@@ -34,12 +32,12 @@ export const SUBJECTS: { key: SubjectKey; label: string }[] = [
* populates these; the same all-NULL filter that hides unused subjects hides
* them elsewhere, so no per-dataset conditional is needed.
*/
export const IDENTITY_COLUMNS: { key: IdentityKey; label: string }[] = [
export const IDENTITY_COLUMNS = [
{ key: "ten_cum_thi", label: "Cụm thi" },
{ key: "gioi_tinh", label: "GT" },
];
/** True when at least one row carries a value for `key`. */
export function hasAnyValue(rows: Student[], key: SubjectKey | IdentityKey): boolean {
export function hasAnyValue(rows, key) {
return rows.some((row) => row[key] !== null && row[key] !== undefined);
}
@@ -12,7 +12,7 @@
*
* to-ascii.test.ts pins the pairs both sides have to agree on.
*/
export function toAscii(str: string): string {
export function toAscii(str) {
return str
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
@@ -21,7 +21,7 @@ export function toAscii(str: string): string {
}
/** True when every character is ASCII, i.e. the query carries no diacritics. */
export function isAsciiOnly(str: string): boolean {
export function isAsciiOnly(str) {
for (let i = 0; i < str.length; i++) {
if (str.charCodeAt(i) > 127) return false;
}
@@ -8,7 +8,7 @@ import { isAsciiOnly, toAscii } from "./to-ascii";
* search silently misses rows. The cases below are the ones
* parser/internal/transform/transform_test.go pins on the Go side.
*/
const GO_PARITY: [input: string, want: string][] = [
const GO_PARITY = [
["Nguyễn Bửu Lộc", "nguyen buu loc"],
["NGUYỄN THỊ HOA", "nguyen thi hoa"],
["Trần Thị Phước An", "tran thi phuoc an"],
-51
View File
@@ -1,51 +0,0 @@
/**
* The shapes the app shares. `Student` mirrors the 22-column table in
* parser/internal/schema/schema.go every column is nullable except the three
* the schema declares NOT NULL, so a typo in a column name fails the build
* instead of rendering blank.
*/
export type SubjectKey =
| "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";
export type IdentityKey = "ten_cum_thi" | "gioi_tinh";
export type Student = {
so_bao_danh: string;
ho_ten: string;
ho_ten_ascii: string;
ngay_sinh: string | null;
} & { [K in IdentityKey]: string | null } & { [K in SubjectKey]: number | null };
/** A dataset as the interface needs it: registry facts plus presentation. */
export type Dataset = {
id: string;
dbSizeMb: number;
blurb: string;
label: string;
title: string;
subtitle: string;
source: string;
examples: string[];
presets: PresetGroup[];
};
export type PresetGroup = {
category: string;
queries: { label: string; sql: string }[];
};
+1 -1
View File
@@ -1,4 +1,4 @@
<script lang="ts">
<script>
import "../app.css";
let { children } = $props();
+2 -3
View File
@@ -1,12 +1,11 @@
<script lang="ts">
<script>
import { resolve } from "$app/paths";
import { DATASETS } from "$lib/datasets";
import type { Dataset } from "$lib/types";
// Trailing slash on purpose: each page is prerendered as <id>/index.html, and
// hitting it without the slash costs a GitHub Pages directory redirect.
// resolve() still supplies the base path.
function hrefOf(dataset: Dataset): string {
function hrefOf(dataset) {
return `${resolve("/[dataset]", { dataset: dataset.id })}/`;
}
</script>
@@ -1,15 +1,14 @@
import { error } from "@sveltejs/kit";
import { DATASETS, datasetById } from "$lib/datasets";
import type { EntryGenerator, PageLoad } from "./$types";
/**
* One prerendered page per registered dataset. The registry is the repository
* root's datasets.json, so a dataset the assembler builds is a page that exists,
* and one it does not is a 404 at build time rather than a blank page.
*/
export const entries: EntryGenerator = () => DATASETS.map((d) => ({ dataset: d.id }));
export const entries = () => DATASETS.map((d) => ({ dataset: d.id }));
export const load: PageLoad = ({ params }) => {
export const load = ({ params }) => {
const dataset = datasetById(params.dataset);
if (!dataset) throw error(404, `Unknown dataset: ${params.dataset}`);
return { dataset };
+120 -96
View File
@@ -1,4 +1,4 @@
<script lang="ts">
<script>
import { browser } from "$app/environment";
import { replaceState } from "$app/navigation";
import { base, resolve } from "$app/paths";
@@ -8,64 +8,51 @@
import SearchForm from "$lib/components/search-form.svelte";
import StudentDetail from "$lib/components/student-detail.svelte";
import { dbOf } from "$lib/datasets";
import { isExamId, normaliseExamId } from "$lib/query-mode";
import { SqliteSource, queryRows } from "$lib/sqlite.svelte";
import { isAsciiOnly, toAscii } from "$lib/to-ascii";
import type { Student } from "$lib/types";
const MAX_RESULTS = 100;
import { isExamId } from "$lib/query-mode";
import { MAX_RESULTS, lookupExamId, searchByName } from "$lib/search";
import { PLAYGROUND_BUDGET_BYTES, RemoteDatabase, SEARCH_BUDGET_BYTES } from "$lib/sqlite.svelte";
let { data } = $props();
const dataset = $derived(data.dataset);
let source = $state<SqliteSource | null>(null);
let results = $state<Student[] | null>(null);
let searchError = $state<string | null>(null);
let activeTab = $state<"search" | "sql">("search");
let totalCount = $state<number | null>(null);
let db = $state(null);
let results = $state(null);
let searchError = $state(null);
let activeTab = $state("search");
let sqlWarningOpen = $state(false);
// Raised once the user has accepted that a hand-written query may fetch a lot.
let budget = $state(SEARCH_BUDGET_BYTES);
// Owned here, not in SearchForm, so it can be bound to the URL both ways.
// The query string is unreadable while prerendering — there is no request —
// so a deep link is picked up on the client only.
let query = $state(browser ? (page.url.searchParams.get("q") ?? "") : "");
const db = $derived(source?.db ?? null);
const loading = $derived(source?.loading ?? true);
const loadError = $derived(source?.error ?? null);
const progress = $derived(source?.progress ?? 0);
const busy = $derived(loading || !!loadError);
const opening = $derived(db !== null && !db.ready && db.error === null);
const loadError = $derived(db?.error ?? null);
const busy = $derived(!db?.ready);
// The database is per dataset, and only ever fetched in the browser: $effect
// does not run while prerendering.
// Opened in the browser only: $effect does not run while prerendering. A new
// budget means a new worker, which costs only the header pages.
$effect(() => {
const opened = new SqliteSource(dbOf(dataset, base));
source = opened;
const opened = new RemoteDatabase(dbOf(dataset, base), budget);
db = opened;
return () => {
opened.close();
source = null;
results = null;
totalCount = null;
db = null;
};
});
// Candidate count for the footer. One-shot per database: it cannot change
// without a new one.
$effect(() => {
if (!db) return;
const [row] = queryRows<{ c: number }>(db, "SELECT COUNT(*) AS c FROM student");
totalCount = row?.c ?? null;
});
// Hydrate a ?q= deep link as soon as the database is ready.
// Hydrate a ?q= deep link as soon as the database is open.
let hydrated = false;
$effect(() => {
if (!db || hydrated) return;
if (!db?.ready || hydrated) return;
hydrated = true;
if (query) search(query);
if (query) void search(query);
});
// Sync the query to ?q= without adding a history entry, so back still leaves
// the page and a copied URL still reproduces the search.
function writeUrlQuery(q: string) {
function writeUrlQuery(q) {
const route = resolve("/[dataset]", { dataset: dataset.id });
// The target IS resolve()'d; the lint rule cannot see through the template
// literal that appends the query string.
@@ -73,35 +60,15 @@
replaceState(q ? `${route}?q=${encodeURIComponent(q)}` : route, page.state);
}
function search(q: string) {
if (!db) return;
async function search(q) {
const source = db;
if (!source?.ready) return;
searchError = null;
query = q;
writeUrlQuery(q);
try {
if (isExamId(q)) {
// Letter-prefixed 2016 IDs are stored upper-case; digits are unaffected.
results = queryRows<Student>(
db,
"SELECT * FROM student WHERE so_bao_danh = $q LIMIT $limit",
{ $q: normaliseExamId(q), $limit: MAX_RESULTS },
);
} else if (isAsciiOnly(q)) {
results = queryRows<Student>(
db,
"SELECT * FROM student WHERE ho_ten_ascii LIKE $q LIMIT $limit",
{ $q: `%${toAscii(q)}%`, $limit: MAX_RESULTS },
);
} else {
results = queryRows<Student>(
db,
`SELECT * FROM student
WHERE ho_ten LIKE $q OR ho_ten_ascii LIKE $qn
LIMIT $limit`,
{ $q: `%${q}%`, $qn: `%${toAscii(q)}%`, $limit: MAX_RESULTS },
);
}
results = isExamId(q) ? await lookupExamId(source, q) : await searchByName(source, q);
} catch (err) {
searchError = err instanceof Error ? err.message : String(err);
}
@@ -113,11 +80,36 @@
writeUrlQuery("");
}
function openSqlTab() {
if (budget >= PLAYGROUND_BUDGET_BYTES) {
activeTab = "sql";
return;
}
sqlWarningOpen = true;
}
function acceptSqlWarning() {
sqlWarningOpen = false;
// Reopening with the larger budget throws away the current worker, and with
// it the pages it had cached — a few hundred KB, refetched on demand.
budget = PLAYGROUND_BUDGET_BYTES;
activeTab = "sql";
}
function declineSqlWarning() {
sqlWarningOpen = false;
activeTab = "search";
}
// Global shortcuts: Ctrl+Enter submits the SQL query, "/" focuses the search
// box unless the user is already typing somewhere.
function onKeydown(event: KeyboardEvent) {
function onKeydown(event) {
if (event.key === "Escape" && sqlWarningOpen) {
declineSqlWarning();
return;
}
if (event.ctrlKey && event.key === "Enter" && activeTab === "sql") {
document.querySelector<HTMLFormElement>(".query-form")?.requestSubmit();
document.querySelector(".query-form")?.requestSubmit();
return;
}
if (
@@ -151,18 +143,8 @@
</header>
<main>
{#if loading}
<div class="my-4 text-center text-ink-muted">
<p>
Đang tải cơ sở dữ liệu ~{dataset.dbSizeMb} MB{progress > 0 ? ` · ${progress}%` : ""}
</p>
<div class="mx-auto my-2 h-2 w-[300px] max-w-full overflow-hidden rounded bg-surface-alt">
<div class="h-full rounded bg-primary transition-[width]" style="width: {progress}%"></div>
</div>
<p class="mx-auto mt-2 max-w-[480px] text-sm text-ink-subtle">
Lần đầu có thể mất 10-30 giây. Sau đó trình duyệt sẽ lưu cache và mở nhanh hơn.
</p>
</div>
{#if opening}
<p class="my-4 text-center text-ink-muted">Đang mở cơ sở dữ liệu…</p>
{/if}
{#if loadError}
@@ -170,20 +152,30 @@
{/if}
<div class="mx-auto mb-6 flex max-w-[600px] border-b-2 border-line">
{#each [{ id: "search", label: "Tra cứu" }, { id: "sql", label: "Truy vấn SQL" }] as const as tab (tab.id)}
<button
class="-mb-0.5 flex-1 cursor-pointer border-0 border-b-2 bg-transparent px-4 py-3
transition-colors hover:text-primary"
class:border-transparent={activeTab !== tab.id}
class:text-ink-muted={activeTab !== tab.id}
class:border-primary={activeTab === tab.id}
class:text-primary={activeTab === tab.id}
class:font-semibold={activeTab === tab.id}
onclick={() => (activeTab = tab.id)}
>
{tab.label}
</button>
{/each}
<button
class="-mb-0.5 flex-1 cursor-pointer border-0 border-b-2 bg-transparent px-4 py-3
transition-colors hover:text-primary"
class:border-transparent={activeTab !== "search"}
class:text-ink-muted={activeTab !== "search"}
class:border-primary={activeTab === "search"}
class:text-primary={activeTab === "search"}
class:font-semibold={activeTab === "search"}
onclick={() => (activeTab = "search")}
>
Tra cứu
</button>
<button
class="-mb-0.5 flex-1 cursor-pointer border-0 border-b-2 bg-transparent px-4 py-3
transition-colors hover:text-primary"
class:border-transparent={activeTab !== "sql"}
class:text-ink-muted={activeTab !== "sql"}
class:border-primary={activeTab === "sql"}
class:text-primary={activeTab === "sql"}
class:font-semibold={activeTab === "sql"}
onclick={openSqlTab}
>
Truy vấn SQL
</button>
</div>
{#if activeTab === "search"}
@@ -215,16 +207,48 @@
{/if}
</main>
<footer
class="mt-12 border-t border-line pt-4 text-center text-sm break-words text-ink-subtle"
>
<footer class="mt-12 border-t border-line pt-4 text-center text-sm text-ink-subtle">
<p>
Nguồn:
<a href={dataset.source} target="_blank" rel="noopener noreferrer">{dataset.source}</a>
{#if totalCount !== null}
· {totalCount.toLocaleString("vi-VN")} thí sinh
{/if}
· Dữ liệu chỉ mang tính tham khảo
<!-- An off-site article URL, so there is no route for resolve() to take. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={dataset.source} title={dataset.source} target="_blank" rel="noopener noreferrer"
>{dataset.sourceName}</a
>
· {dataset.rows.toLocaleString("vi-VN")} thí sinh · Dữ liệu chỉ mang tính tham khảo
</p>
</footer>
</div>
{#if sqlWarningOpen}
<!--
The SQL tab is the one place a user can write a query that reads the whole
database. Everything else here is a seek; this is not, so it is opt-in.
-->
<div
class="fixed inset-0 z-10 flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="sql-warning-title"
>
<div class="max-w-[520px] rounded-xl border border-line bg-surface p-6 shadow-card">
<h2 id="sql-warning-title" class="mb-3 text-lg font-semibold">Truy vấn SQL tốn dữ liệu mạng</h2>
<p class="mb-3 text-sm text-ink-muted">
Tra cứu thường chỉ tải vài trăm KB. Truy vấn SQL tự viết có thể quét toàn bộ bảng và tải tới
hàng trăm MB — tốn dữ liệu di động và có thể rất chậm.
</p>
<p class="mb-5 text-sm text-ink-muted">
Cơ sở dữ liệu này nặng {dataset.dbSizeMb} MB. Số byte đã tải sẽ hiển thị bên cạnh thời gian
chạy để bạn theo dõi.
</p>
<div class="flex flex-wrap justify-end gap-3">
<button type="button" class="btn-chip" onclick={declineSqlWarning}>
Quay lại tra cứu
</button>
<button type="button" class="btn-primary" onclick={acceptSqlWarning}>
Tôi hiểu, tiếp tục
</button>
</div>
</div>
</div>
{/if}
+1 -1
View File
@@ -7,6 +7,6 @@ export default defineConfig({
test: {
// Only the framework-free modules are unit-tested: score tiers, query-mode
// classification and the ASCII fold that has to match the Go parser.
include: ["src/lib/**/*.test.ts"],
include: ["src/lib/**/*.test.js"],
},
});