Public: anyone can read this; the crew can change it.

A SQLite file committed to a repository (.db, .sqlite, .sqlite3) renders as its tables and their first rows, read-only #10332

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/10290-a-sqlite-file-committed-to-a-repository → main 8 commits fork 5 threads · 5 unresolved

  • Merged. scout/10290-a-sqlite-file-committed-to-a-repository was merged into main as 90f9ae6.

Review

Sign in to review this change.
8 files changed +333 −14
docs/design/attention-budget.json +8 −8
@@ -13,25 +13,25 @@
1313 },
1414 "repository": {
1515 "390": {
16 "words": 189,
17 "blocks": 28,
16 "words": 197,
17 "blocks": 29,
1818 "colours": 2
1919 },
2020 "1280": {
21 "words": 189,
22 "blocks": 28,
21 "words": 197,
22 "blocks": 29,
2323 "colours": 2
2424 }
2525 },
2626 "app": {
2727 "390": {
28 "words": 189,
29 "blocks": 28,
28 "words": 197,
29 "blocks": 29,
3030 "colours": 2
3131 },
3232 "1280": {
33 "words": 189,
34 "blocks": 28,
33 "words": 197,
34 "blocks": 29,
3535 "colours": 2
3636 }
3737 },
docs/features/sqlite-view.md added +13
@@ -0,0 +1,13 @@
1 # SQLite files as tables
2
3 A `.db`, `.sqlite` or `.sqlite3` file whose first bytes are the SQLite header opens on the blob page as its tables and views (`src/web/render/sqlite.ts`).
4 A file with one of those names but no header stays an ordinary binary file.
5 The list shows each name, its kind, its row count (tables only; a view is never counted) and its columns; a table that cannot be read says so in its row.
6 Click a name (`?table=<name>`) for its first 100 rows, columns capped at 100 and cells at 300 characters; NULL and a BLOB's size are written as words.
7 The database is opened read-only in memory from the committed bytes and closed again; no SQL comes from the reader, and a table name is used only if `sqlite_master` lists it.
8 A name the file does not hold goes back to the list with a note, and that page is not kept in the page cache.
9 Up to 5 MB is drawn; a larger file shows Download. A truncated or damaged file shows the source with a one-line reason. Raw and Download work as for any file.
10
11 ## In the terminal
12
13 The terminal's file screen (`src/tui/screens/workspace.ts`) already covers a SQLite file as it does any binary: it says `Binary file, <size>.` and never prints bytes. It does not list tables; for that, open the blob page in the browser, or download the file and run `sqlite3` on it.
src/web/pages/repo.ts +18 −6
@@ -86,6 +86,7 @@ import { flashTo, fragment, page, redirect } from "../layout.ts";
8686 import { renderMarkdown } from "../markdown.ts";
8787 import { overSize } from "../render/archive.ts";
8888 import { csvJs, isCsvPath } from "../render/csv.ts";
89 import { hasTable } from "../render/sqlite.ts";
8990 import { fileKind, HEAD_BYTES, RENDER_CAP } from "../render/kinds.ts";
9091 import { RENDERERS, rendererFor } from "../render/renderers.ts";
9192 import { mediaHref } from "./media.ts";
@@ -1129,6 +1130,8 @@ export function renderBlob(
11291130 highlighted?: (ms: number) => void;
11301131 /** Draw every line instead of the first 1,500. */
11311132 all?: boolean;
1133 /** `?table=` on a SQLite file. */
1134 table?: string;
11321135 /** Rows a worker already built for this file. */
11331136 rows?: CodeRows;
11341137 },
@@ -1147,6 +1150,7 @@ export function renderBlob(
11471150 media: mediaHref(base, o.ref, o.path),
11481151 pathHref: `${base}/blob/${encoded}/${o.path}`,
11491152 all: o.all === true,
1153 ...(o.table !== undefined ? { table: o.table } : {}),
11501154 })
11511155 : undefined;
11521156 const fellBack = drawn?.fallback;
@@ -1639,6 +1643,7 @@ export async function blobPage(ctx: Ctx): Promise<Response> {
16391643 ctx.auth.user === undefined && repo.visibility === "public" && FULL_OID.test(ref) && ctx.url.searchParams.get("fragment") !== "1";
16401644 const view = blobView(ctx.url.searchParams.get("view"));
outdated

Unvalidated table string in the cache key lets strangers fill the kept-page cache.

16411645 const all = ctx.url.searchParams.get("all") === "1";
1646 const table = ctx.url.searchParams.get("table")?.slice(0, 256) ?? undefined;
16421647 // The whole page as it last went out to a stranger, encoded (`kept_page.ts`).
16431648 const keptKey = kept ? keptPageKey(ctx, repo.id) : undefined;
16441649 const finished = keptKey === undefined ? undefined : keptPage(keptKey);
@@ -1646,9 +1651,11 @@ export async function blobPage(ctx: Ctx): Promise<Response> {
16461651 git.done();
16471652 return immutableWhenAddressed(ctx.request, true, repo, ref, finished);
16481653 }
1649 const keepFinished = (response: Response) => (keptKey === undefined ? response : keepPage(keptKey, response, true));
1654 // A `?table=` that names nothing in the file is a stranger's string: its page is never kept.
1655 let keepable = true;
1656 const keepFinished = (response: Response) => (keptKey === undefined || !keepable ? response : keepPage(keptKey, response, true));
16501657 if (kept) {
1651 const hit = cachedBlobPage(repo.id, ref, path, String(view), all);
1658 const hit = cachedBlobPage(repo.id, ref, path, `${view}\0${table ?? ""}`, all);
16521659 if (hit !== undefined) {
16531660 git.done();
16541661 return immutableWhenAddressed(
@@ -1674,7 +1681,8 @@ export async function blobPage(ctx: Ctx): Promise<Response> {
16741681 const refs = await git.read(() => (kept ? keptRefs(repo.id, () => refSetFrom(scope.source)) : refSetFrom(scope.source)));
16751682 let highlightMs: number | undefined;
16761683 const renderStart = performance.now();
1677 const shown = capped(blob.value);
1684 const shown = capped(blob.value, path);
1685 if (table !== undefined) keepable = shown.kind !== "too_large" && hasTable(shown.body, table);
16781686 // A cold page for a stranger highlights and lays out its rows on a worker (#9630).
16791687 let rows: CodeRows | undefined;
16801688 if (kept && shown.kind === "text" && (view === "source" || !blobRenderer(scope, path, shown))) {
@@ -1691,6 +1699,7 @@ export async function blobPage(ctx: Ctx): Promise<Response> {
16911699 view,
16921700 highlighted: (ms) => (highlightMs = (highlightMs ?? 0) + ms),
16931701 all,
1702 ...(table !== undefined ? { table } : {}),
16941703 });
16951704 if (highlightMs !== undefined) notePhase(ctx.request, "highlight", highlightMs);
16961705 const paneMs = performance.now() - renderStart - (highlightMs ?? 0);
@@ -1718,8 +1727,8 @@ export async function blobPage(ctx: Ctx): Promise<Response> {
17181727 ),
17191728 });
17201729 const objectSrc = drawsPdf(scope, path, shown, view);
1721 if (kept)
1722 keepBlobPage(repo.id, ref, path, String(view), all, {
1730 if (kept && keepable)
1731 keepBlobPage(repo.id, ref, path, `${view}\0${table ?? ""}`, all, {
17231732 title: `${filename} · ${repo.name}`,
17241733 explorer: explorerHtml,
17251734 content: pane,
@@ -1765,7 +1774,10 @@ export async function pathsJson(ctx: Ctx): Promise<Response> {
17651774 * The page's own render ceiling, under the source's fetch ceiling. A body
17661775 * over it is dropped and only its head kept, so a media kind is still drawn.
17671776 */
1768 export function capped(blob: Blob): Blob {
1777 export function capped(blob: Blob, path = ""): Blob {
1778 // A SQLite file is read whole, up to its own cap, because the database is opened from the bytes.
1779 if (blob.kind !== "too_large" && fileKind(path, blob.body.subarray(0, HEAD_BYTES)) === "sqlite" && blob.size <= RENDER_CAP.sqlite)
1780 return blob;
17691781 return blob.kind !== "too_large" && blob.size > MAX_RENDER_BYTES
17701782 ? { kind: "too_large", size: blob.size, head: blob.body.slice(0, HEAD_BYTES) }
17711783 : blob;
src/web/render/kinds.ts +6
@@ -19,6 +19,7 @@ export type FileKind =
1919 | "audio"
2020 | "video"
2121 | "csv"
22 | "sqlite"
2223 | "model3d"
2324 | "archive"
2425 | "config"
@@ -41,6 +42,8 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = {
4142 audio: 500 * MB,
4243 video: 4_000 * MB,
4344 csv: 10 * MB,
45 // Under the source's own 5 MB read ceiling: a larger file is never read.
46 sqlite: 5 * MB,
4447 model3d: 100 * MB,
4548 archive: 50 * MB,
4649 config: 1 * MB,
@@ -117,6 +120,9 @@ const MEDIA: Readonly<Record<string, Media>> = {
117120 m4v: { kind: "video", name: "MPEG-4", type: "video/mp4", magic: ftyp },
118121 webm: { kind: "video", name: "WebM", type: "video/webm", magic: bytes(0x1a, 0x45, 0xdf, 0xa3) },
119122 mov: { kind: "video", name: "QuickTime", type: "video/quicktime", magic: quicktime },
123 db: { kind: "sqlite", name: "SQLite", type: "application/vnd.sqlite3", magic: ascii("SQLite format 3\0") },
124 sqlite: { kind: "sqlite", name: "SQLite", type: "application/vnd.sqlite3", magic: ascii("SQLite format 3\0") },
125 sqlite3: { kind: "sqlite", name: "SQLite", type: "application/vnd.sqlite3", magic: ascii("SQLite format 3\0") },
120126 stl: { kind: "model3d", name: "STL", type: "model/stl" },
121127 glb: { kind: "model3d", name: "glTF binary", type: "model/gltf-binary", magic: ascii("glTF") },
122128 };
src/web/render/renderers.ts +4
@@ -15,6 +15,7 @@ import { image } from "./image.ts";
1515 import { claimedMedia, fileKind, HEAD_BYTES, NO_BYTES, RENDER_CAP, type FileKind } from "./kinds.ts";
1616 import { markdown } from "./markdown.ts";
1717 import { pdf } from "./pdf.ts";
18 import { sqlite } from "./sqlite.ts";
1819 import { subtitles } from "./subtitles.ts";
1920 import { video } from "./video.ts";
2021
@@ -33,6 +34,8 @@ export interface RenderInput {
3334 readonly pathHref?: string;
3435 /** `?all=1`: draw past the default window, up to the renderer's cap. */
3536 readonly all?: boolean;
37 /** `?table=<name>`: unchecked text; a renderer matches it against its own list. */
38 readonly table?: string;
3639 }
3740
3841 export interface Rendered {
@@ -53,6 +56,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = {
5356 markdown,
5457 pdf,
5558 csv,
59 sqlite,
5660 audio,
5761 video,
5862 config,
src/web/render/sqlite.ts added +125
@@ -0,0 +1,125 @@
1 /**
2 * A SQLite file committed to a repository, read-only: each table and view
3 * with its columns and row count, and `?table=<name>` for the first rows of
4 * one. The blob is opened in memory (`Database.deserialize`), never written
5 * anywhere, and closed before this returns. There is no SQL from the reader:
6 * a table name is only ever one read back from `sqlite_master`.
7 */
8
9 import { Database } from "bun:sqlite";
10 import { escapeHtml, html, raw } from "../html.ts";
11 import { fileSize } from "../ui.ts";
12 import type { Rendered, RenderInput } from "./renderers.ts";
13
14 /** Rows drawn for one table, tables listed, columns and characters per cell drawn. */
15 export const ROW_LIMIT = 100;
16 export const TABLE_CAP = 200;
17 export const COLUMN_CAP = 100;
18 export const CELL_CHARS = 300;
19
20 interface Entry {
21 readonly name: string;
22 readonly type: "table" | "view";
23 readonly rows: number | undefined;
24 readonly columns: readonly string[];
25 /** Why this table could not be read, shown in its place. */
26 readonly note?: string;
27 }
28
29 const plural = (n: number, one: string): string => `${n.toLocaleString()} ${one}${n === 1 ? "" : "s"}`;
30
31 /** True when `name` is a table or view of this database (cheap; used before a page is kept). */
32 export function hasTable(body: Uint8Array, name: string): boolean {
33 let db: Database | undefined;
34 try {
35 db = Database.deserialize(body, { readonly: true });
36 return db.query("SELECT 1 FROM sqlite_master WHERE type IN ('table','view') AND name = ? AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\'").get(name) !== null;
37 } catch {
38 return false;
39 } finally {
40 db?.close();
41 }
42 }
43
outdated

Empty catch hides a failing table; show a note instead.

44 const quote = (name: string): string => `"${name.replaceAll('"', '""')}"`;
45
46 /** One cell as text: NULL and a BLOB's size are words, a long value is cut. */
47 function cell(v: unknown): string {
48 if (v === null || v === undefined) return `<td class="muted">NULL</td>`;
49 if (v instanceof Uint8Array) return `<td class="muted num">BLOB · ${fileSize(v.length)}</td>`;
50 const s = String(v);
51 const cut = s.length > CELL_CHARS ? `${s.slice(0, CELL_CHARS)}…` : s;
52 return `<td${typeof v === "number" || typeof v === "bigint" ? ' class="num"' : ""}>${escapeHtml(cut)}</td>`;
53 }
54
55 function entries(db: Database): Entry[] {
56 const found = db
57 .query(
58 `SELECT name, type FROM sqlite_master
59 WHERE type IN ('table','view') AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\'
60 ORDER BY name LIMIT ${TABLE_CAP}`,
61 )
62 .all() as { name: string; type: "table" | "view" }[];
63 return found.map(({ name, type }) => {
64 let columns: string[] = [];
65 let rows: number | undefined;
66 try {
67 columns = (db.query("SELECT name FROM pragma_table_info(?)").all(name) as { name: string }[]).map((c) => c.name);
68 // A view is a stored query of unknown cost, so it is never counted.
69 if (type === "table") rows = (db.query(`SELECT count(*) AS n FROM ${quote(name)}`).get() as { n: number }).n;
70 } catch {
71 return { name, type, rows: undefined, columns, note: "could not be read" };
72 }
73 return { name, type, rows, columns };
74 });
75 }
76
77 function rowsOf(db: Database, entry: Entry): string {
78 const width = Math.min(entry.columns.length, COLUMN_CAP);
79 const data = db.query(`SELECT * FROM ${quote(entry.name)} LIMIT ${ROW_LIMIT}`).values() as unknown[][];
80 const head = entry.columns.slice(0, width).map((c) => `<th>${escapeHtml(c)}</th>`).join("");
81 const body = data.map((r) => `<tr>${r.slice(0, width).map(cell).join("")}</tr>`).join("");
82 return `<table class="table" data-dense aria-label="${escapeHtml(entry.name)} rows"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
83 }
84

Principle 8: "1 views" — pluralise correctly.

85 export function sqlite(input: RenderInput): Rendered {
86 let db: Database | undefined;
87 try {
88 db = Database.deserialize(input.body, { readonly: true });
89 const list = entries(db);
90 const href = input.pathHref ?? "";
91 const wanted = input.table;
92 const chosen = wanted === undefined ? undefined : list.find((e) => e.name === wanted);
93 const notice =
outdated

Listing table lacks a scroll wrapper; overflows at 360px. Wrap in csv-scroll.

94 wanted !== undefined && chosen === undefined
95 ? `<p class="muted small">This database has no table named “${escapeHtml(wanted.slice(0, 100))}”.</p>`
96 : "";
97 const tables = list.filter((e) => e.type === "table").length;
98 const summary = `${plural(tables, "table")} · ${plural(list.length - tables, "view")} · ${fileSize(input.size)}`;
99
100 const items = list
101 .map((e) => {
102 const cols = e.columns.slice(0, COLUMN_CAP).join(", ");
103 const count = e.note !== undefined ? e.note : e.rows === undefined ? "" : plural(e.rows, "row");
104 return `<tr><td><a href="${escapeHtml(href)}?table=${encodeURIComponent(e.name)}">${escapeHtml(e.name)}</a></td><td class="muted">${e.type}</td><td class="num${e.note !== undefined ? " muted" : ""}">${escapeHtml(count)}</td><td class="muted small">${escapeHtml(cols)}</td></tr>`;
outdated

Principle 6/7: a card around the table is a box around a thing. Drop the card class.

105 })
106 .join("");
107 const listing = `<div class="csv-scroll"><table class="table" data-dense aria-label="Tables and views"><thead><tr><th>Name</th><th>Kind</th><th class="num">Rows</th><th>Columns</th></tr></thead><tbody>${items}</tbody></table></div>`;
108 const empty = list.length === 0 ? `<p class="muted small">This database has no tables.</p>` : "";
109 const more = list.length >= TABLE_CAP ? `<p class="muted small">Showing the first ${TABLE_CAP} tables and views.</p>` : "";
110
111 let content: string;
112 if (chosen !== undefined) {
113 const total = chosen.rows === undefined ? "" : ` of ${chosen.rows.toLocaleString()}`;
114 if (chosen.note !== undefined) return { content: html`<div class="sqlite-view"><p class="small"><a href="${href}">← All tables</a> · <strong>${chosen.name}</strong> <span class="muted">${chosen.note}</span></p></div>`, lines: 0, summary };
115 content = `<p class="small"><a href="${escapeHtml(href)}">← All tables</a> · <strong>${escapeHtml(chosen.name)}</strong> <span class="muted">first ${Math.min(ROW_LIMIT, chosen.rows ?? ROW_LIMIT).toLocaleString()}${total} rows</span></p><div class="csv-scroll">${rowsOf(db, chosen)}</div>`;
116 } else {
117 content = `${notice}${listing}${empty}${more}`;
118 }
119 return { content: html`<div class="sqlite-view">${raw(content)}</div>`, lines: 0, summary };
120 } catch {
121 return { content: html``, lines: 0, fallback: "This SQLite file is cut short or damaged, so its tables cannot be read." };
122 } finally {
123 db?.close();
124 }
125 }
test/render-kinds.test.ts +3
@@ -37,6 +37,9 @@ const SAMPLES: Record<string, [Uint8Array, FileKind, string]> = {
3737 mov: [box("qt "), "video", "video/quicktime"],
3838 stl: [enc("solid cube\n"), "model3d", "model/stl"],
3939 glb: [enc("glTF\x02\0\0\0"), "model3d", "model/gltf-binary"],
40 db: [enc("SQLite format 3\0"), "sqlite", "application/vnd.sqlite3"],
41 sqlite: [enc("SQLite format 3\0"), "sqlite", "application/vnd.sqlite3"],
42 sqlite3: [enc("SQLite format 3\0"), "sqlite", "application/vnd.sqlite3"],
4043 csv: [enc("a,b\n1,2\n"), "csv", "application/octet-stream"],
4144 tsv: [enc("a\tb\n"), "csv", "application/octet-stream"],
4245 jsonl: [enc("{\"a\":1}\n"), "csv", "application/octet-stream"],
test/render-sqlite.test.ts added +156
@@ -0,0 +1,156 @@
1 /** A committed SQLite file as its tables and first rows, read-only. */
2 import { Database } from "bun:sqlite";
3 import { describe, expect, test } from "bun:test";
4 import { renderBlob, capped } from "../src/web/pages/repo.ts";
5 import { fileKind } from "../src/web/render/kinds.ts";
6 import { RENDERERS } from "../src/web/render/renderers.ts";
7 import { ROW_LIMIT, TABLE_CAP, hasTable, sqlite } from "../src/web/render/sqlite.ts";
8 import { expectWork } from "./helpers/work.ts";
9 import type { RepoScope } from "../src/web/scope.ts";
10
11 const make = (): Uint8Array => {
12 const db = new Database(":memory:");
13 db.run("CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT, pic BLOB)");
14 db.run("INSERT INTO notes (body, pic) VALUES ('<script>alert(1)</script>', x'00010203')");
15 db.run("INSERT INTO notes (body, pic) VALUES (NULL, NULL)");
16 db.run('CREATE TABLE "we""ird" (n INTEGER)');
17 for (let i = 0; i < 250; i++) db.run('INSERT INTO "we""ird" VALUES (?)', [i]);
18 db.run("CREATE VIEW recent AS SELECT id, body FROM notes");
19 const bytes = db.serialize();
20 db.close();
21 return bytes;
22 };
23 const DB = make();
24 const draw = (body: Uint8Array, table?: string) =>
25 sqlite({ path: "a.db", size: body.length, body, media: "/m", pathHref: "/o/r/blob/main/a.db", ...(table ? { table } : {}) });
26
27 describe("sqlite kind", () => {
28 test("the header confirms it; a .db without it is binary", () => {
29 expect(fileKind("a.db", DB.subarray(0, 4096))).toBe("sqlite");
30 expect(fileKind("a.sqlite3", DB.subarray(0, 4096))).toBe("sqlite");
31 expect(fileKind("a.db", new Uint8Array([1, 0, 2, 3]))).toBe("binary");
32 expect(RENDERERS.sqlite).toBe(sqlite);
33 });
34 });
35
36 describe("sqlite page", () => {
37 test("lists tables and views with counts and columns", () => {
38 const r = draw(DB);
39 const h = String(r.content);
40 expect(r.fallback).toBeUndefined();
41 expect(r.summary).toContain("2 tables · 1 view"); expect(r.summary).not.toContain("1 views");
42 expect(h).toContain('href="/o/r/blob/main/a.db?table=notes"');
43 expect(h).toContain("2 rows");
44 expect(h).toContain("250 rows");
45 expect(h).toContain("id, body, pic");
46 expect(h).toContain("recent");
47 });
48 test("?table shows rows: escaped, BLOB as size, NULL, capped at 100", () => {
49 const h = String(draw(DB, "notes").content);
50 expect(h).toContain("&lt;script&gt;alert(1)&lt;/script&gt;");
51 expect(h).not.toContain("<script>");
52 expect(h).toContain("BLOB · 4 B");
53 expect(h).toContain("NULL");
54 const big = String(draw(DB, 'we"ird').content);
55 expect(big.split("<tr>").length - 1).toBe(ROW_LIMIT + 1);
56 expect(String(draw(DB, "recent").content)).toContain("<th>body</th>");
57 });
58 test("an unknown table, or an injection attempt, goes back to the list", () => {
59 for (const t of ["nope", 'notes"; DROP TABLE notes; --', "sqlite_master"]) {
60 const h = String(draw(DB, t).content);
61 expect(h).toContain("has no table named");
62 expect(h).toContain("?table=notes");
63 expect(h).not.toContain("<th>pic</th>");
64 }
65 });
66 test("a truncated or corrupt file falls back with one sentence", () => {
67 expect(draw(DB.slice(0, 5000)).fallback).toBeDefined();
68 const junk = new Uint8Array(DB.length).fill(7);
69 junk.set(DB.subarray(0, 16));
70 expect(draw(junk).fallback).toBeDefined();
71 });
72 });
73
74 describe("sqlite on the blob page", () => {
75 const scope = { base: "/o/r", facts: { name: "r" }, caps: { write: false }, kind: "local" } as unknown as RepoScope;
76 const page = (name: string, body: Uint8Array, table?: string) =>
77 String(
78 renderBlob(scope, {
79 refs: { branches: ["main"], tags: [] },
80 ref: "main",
81 path: name,
82 blob: capped({ kind: "binary", body, size: body.length }, name),
83 ...(table ? { table } : {}),
84 }),
85 );
86 test("rendered, with the table", () => {
87 expect(page("a.db", DB)).toContain("Tables and views");
88 expect(page("a.db", DB, "notes")).toContain("&lt;script&gt;");
89 });
90 test("a non-SQLite .db stays binary; a corrupt one offers Download", () => {
91 expect(page("a.db", new Uint8Array([1, 0, 2]))).toContain("Binary file");
92 const h = page("a.db", DB.slice(0, 5000));
93 expect(h).toContain("Showing the source: This SQLite file is cut short or damaged");
94 expect(h).toContain("Download");
95 });
96 test("a file over the 1 MB page ceiling but under the sqlite cap is drawn, not binary", () => {
97 const db = new Database(":memory:");
98 db.run("CREATE TABLE big (v TEXT)");
99 db.run("INSERT INTO big SELECT hex(randomblob(500)) FROM (WITH RECURSIVE c(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM c WHERE n < 3000) SELECT n FROM c)");
100 const body = db.serialize();
101 db.close();
102 expect(body.length).toBeGreaterThan(1_000_000);
103 const h = page("big.db", body);
104 expect(h).toContain("Tables and views");
105 expect(h).toContain("3,000 rows");
106 expect(page("big.db", body, "big")).toContain("<th>v</th>");
107 });
108 test("a file over the 5 MB sqlite cap offers Download instead", () => {
109 const body = new Uint8Array(5_500_000);
110 body.set(DB);
111 const h = page("huge.db", body);
112 expect(h).not.toContain("Tables and views");
113 expect(h).toContain("Download");
114 });
115 });
116
117 describe("sqlite details", () => {
118 test("hasTable names only tables and views the file holds", () => {
119 expect(hasTable(DB, "notes")).toBe(true);
120 expect(hasTable(DB, "recent")).toBe(true);
121 expect(hasTable(DB, "nope")).toBe(false);
122 expect(hasTable(DB, "sqlite_master")).toBe(false);
123 expect(hasTable(new Uint8Array([1, 2, 3]), "notes")).toBe(false);
124 });
125 test("a view that fails shows a note, not nothing", () => {
126 const db = new Database(":memory:");
127 db.run("CREATE TABLE t (a)");
128 db.run("CREATE VIEW v AS SELECT a FROM t");
129 db.run("DROP TABLE t");
130 const body = db.serialize();
131 db.close();
132 const r = draw(body);
133 expect(String(r.content)).toContain("could not be read");
134 });
135 test("the listing scrolls on a phone and has no card", () => {
136 const h = String(draw(DB).content);
137 expect(h).toContain('<div class="csv-scroll"><table');
138 expect(h).not.toContain("card");
139 });
140 test("200 tables of a ~5 MB file list in bounded work", () => {
141 const db = new Database(":memory:");
142 for (let i = 0; i < 200; i++) {
143 db.run(`CREATE TABLE t${i} (a INTEGER, b TEXT)`);
144 db.run(`INSERT INTO t${i} SELECT n, hex(randomblob(60)) FROM (WITH RECURSIVE c(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM c WHERE n < 150) SELECT n FROM c)`);
145 }
146 const body = db.serialize();
147 db.close();
148 const r = draw(body);
149 const h = String(r.content);
150 // Work, not a clock: one listing row per table, two statements each at most.
151 expectWork(h.split("?table=").length - 1, { max: TABLE_CAP });
152 expect(h.split("?table=").length - 1).toBe(200);
153 expectWork(h.length, { max: 100_000 });
154 expect(h).toContain("150 rows");
155 });
156 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs