mirror of
https://github.com/tiennm99/thptqg.git
synced 2026-08-14 11:22:46 +00:00
Merge pull request #10 from tiennm99/feat/httpvfs-range-queries
feat: read the databases over HTTP range requests
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 135–234 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 |
|
||||
|
||||
@@ -21,10 +21,10 @@ running entirely in the browser and hosted for free on GitHub Pages. Covers the
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Zero backend.** The full database (44–48 MB gzipped per dataset) is
|
||||
downloaded to the browser and queried in-process.
|
||||
- **Zero backend.** The database (238–289 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
@@ -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.** 45–48 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
@@ -41,7 +41,7 @@ reader independently verifiable against a hash oracle.
|
||||
|
||||
- `ToAscii` strips combining marks in the literal range U+0300–U+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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 287–17,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.
|
||||
+78
@@ -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?
|
||||
+165
@@ -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
@@ -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/"] },
|
||||
);
|
||||
];
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Generated
+15
-477
@@ -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
@@ -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",
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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: [
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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"],
|
||||
@@ -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,4 +1,4 @@
|
||||
<script lang="ts">
|
||||
<script>
|
||||
import "../app.css";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
@@ -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 };
|
||||
@@ -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}
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user