Public: anyone can read this; the crew can change it.
Platform app move gap: A database import for an app #11713
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/11697-platform-app-move-gap-a-database-import → main 7 commits fork
18 files
changed +1102
−2
docs/MCP.md +1
| @@ -49,6 +49,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 49 | 49 | | `accounts` | — | Accounts, newest first: role, verified, suspended; `cursor` pages | 264 | |
| 50 | 50 | | `agent` | — | Admin: create\|update\|remove an agent | 371 | |
| 51 | 51 | | `agents` | — | Agents you may see, as GET /api/v1/agents | 160 | |
| 52 | | `app_data_import` | — | Load a SQL dump into an app's database and report per-table counts and checksums; admins only | 339 | | |
| 52 | 53 | | `app_deploy` | `redeploy`, `stop`, `pull`, `schedules`, `run_schedule`, `pause_schedule`, `resume_schedule`, `jobs`, `cancel_job`, `retry_job` | Redeploy or stop an env, pull, list and steer schedules and jobs | 408 | |
| 53 | 54 | | `app_domains` | — | App domains: list, add, check, remove | 201 | |
| 54 | 55 | | `app_env` | `list`, `set`, `unset` | An app's environment variables: list names, set or unset by scope; write-only | 393 | |
docs/features/platform-app-move.md +38 −1
| @@ -40,6 +40,43 @@ symlink is refused. | ||
| 40 | 40 | Secrets: the owner sets each by name |
| 41 | 41 | in the app's settings; they are never pasted into an issue, a pull or a run. |
| 42 | 42 | |
| 43 | ### The database import (built, #11697) | |
| 44 | `POST /api/v1/repos/<owner>/<name>/data-import` with an admin-scope token (or a same-origin | |
| 45 | admin session) and `{ "dump": "<sql text>", "env": "production", "file": "app.db", "replace": false }` | |
| 46 | (`importAppData`, `src/actions/migrate.ts`; engine `src/apps/dump_import.ts`). Only a repository's | |
| 47 | admins; the repository must be an app. The dump is SQL text up to 16 MB (413 past it, counted by | |
| 48 | the bytes read), the kind `sqlite3 .dump` writes. A Postgres dump is not run as it is: | |
| 49 | `pg_dump --inserts --no-owner` output that SQLite accepts works, nothing else (#11698). | |
| 50 | ||
| 51 | What runs is narrower than a statement's first words. `CREATE TABLE` (never `AS`), `CREATE [UNIQUE] | |
| 52 | INDEX`, `BEGIN`, `COMMIT`, `PRAGMA foreign_keys`, and `INSERT INTO <table> [(columns)] VALUES (...)[, (...)]` | |
| 53 | whose values are literals: numbers, strings, `X'..'`, NULL, TRUE, FALSE. `SELECT` and `WITH` outside | |
| 54 | quotes, a function call in a `VALUES` list, and the functions that make a big value from a small | |
| 55 | statement (`randomblob`, `zeroblob`, `printf`, `replace`, `hex`...) are refused with the statement's | |
| 56 | number before anything is written. At most 1,000,000 statements, and each database is capped at | |
| 57 | 16,384 pages (64 MiB). | |
| 58 | ||
| 59 | The dump is loaded into a scratch in-memory database (the dump side) and into a new file beside the | |
| 60 | target (the store side). The answer lists each table with its row count and a SHA-256 of its rows in | |
| 61 | rowid order, each value written as SQLite's `quote()` literal, for both sides, and `inserted`: the | |
| 62 | rows the dump's own text declares, counted from its `VALUES` tuples without SQLite. The file replaces | |
| 63 | the target only when the two loads agree on the checksum and each table's rows equal `inserted`; | |
| 64 | otherwise the answer is 409 `mismatch` and nothing changed. An existing file is refused (409 | |
| 65 | `exists`) unless `replace` is true, which keeps it as `<file>.before-import-<ms>` (a name no earlier | |
| 66 | backup has; a hard link, so the target is never absent) and renames the new file over it. Put the | |
| 67 | counts and checksums in the migration issue, beside the old node's `SELECT count(*)` per table. | |
| 68 | ||
| 69 | Doors: the terminal's palette entry "Database import" on a repository's admin (`src/tui/screens/dataimport.ts`; | |
| 70 | `i` imports, `r` imports and replaces) reads a `.sql` file already in the app's `imports/` directory | |
| 71 | under the environment's data directory, since a terminal cannot paste 16 MB and must not read a path | |
| 72 | it is given; MCP `app_data_import` takes the text as the API does. All three are the admin's alone. | |
| 73 | ||
| 74 | Not done: the load runs on the request, not in a worker. With only literal values and the caps above | |
| 75 | its time is linear in the dump's bytes (about a second near 16 MB), which is why a worker with a | |
| 76 | timeout was left out; the running app keeps its old file open until it restarts, so import with the app | |
| 77 | stopped or restart it after; the proof shows the load agrees with itself and with the dump's row count, | |
| 78 | not that the old node's dump was complete: compare its row counts too. No web page yet. | |
| 79 | ||
| 43 | 80 | ## 4. Rehearse on a preview (person) |
| 44 | 81 | Preview address, a copy of the data, the payment **sandbox** keys. The owner, |
| 45 | 82 | or the shop's owner, places an order and the webhook lands. No cut-over before |
| @@ -199,7 +236,7 @@ carry a provider id, not a hash, and the new app needs the same client | ||
| 199 | 236 | credentials set by name or those people sign in as new accounts. |
| 200 | 237 | |
| 201 | 238 | ## What the forge lacks (each is an issue) |
| 202 | - A database import for an app (`migrate` has no data path): upload a dump, load it, report counts and checksums. (#11697) | |
| 239 | - ~~A database import for an app.~~ Built: see "The database import" above. (#11697) | |
| 203 | 240 | - ~~A file and upload copy with a manifest compared on both ends.~~ Done as a script a person runs (#11699); no page or upload from a browser yet. |
| 204 | 241 | - ~~A Postgres app has no home here.~~ Decided, not built: no managed Postgres; port to `bun:sqlite` or keep an external one by `egress` (docs/APPS.md). (#11698) |
| 205 | 242 | - Per-app encrypted backup with a restore proof (`docs/features/backup-truth.md` covers the forge's own, not an app's). (#11701) |
src/actions/migrate.ts +95
| @@ -26,6 +26,10 @@ | ||
| 26 | 26 | * page can watch it. With a crew asked for, the same job then puts the |
| 27 | 27 | * agent on the crew, makes the repository an app, and tells the crew one |
| 28 | 28 | * thing: make it run here, adapting rather than rewriting. |
| 29 | * | |
| 30 | * The data is a second step (`importAppData`, below): a dump a person | |
| 31 | * uploads is loaded into the app's store and reported per table, counts and | |
| 32 | * checksums, on both sides (`apps/dump_import.ts`). | |
| 29 | 33 | */ |
| 30 | 34 | |
| 31 | 35 | import { NOT_A_MEMBER } from "../core/roles.ts"; |
| @@ -77,6 +81,10 @@ import { tell, toldWords } from "./tell.ts"; | ||
| 77 | 81 | import { putFollow, upstreamUrl } from "../core/upstream_follow.ts"; |
| 78 | 82 | import { pinFor, remoteGitEnv, type Pin } from "../git/remote-env.ts"; |
| 79 | 83 | import { AT_REPOSITORY_LIMIT, NO_SUCH_AGENT } from "./crew_limit_words.ts"; |
| 84 | import { envDataDir, isEnvName } from "../apps/data.ts"; | |
| 85 | import { IMPORT_FILE, MAX_STATEMENTS, importDump, type ImportError, type ImportReport } from "../apps/dump_import.ts"; | |
| 86 | import { getAppByRepo } from "../core/apps.ts"; | |
| 87 | import { canAdmin } from "../core/repos.ts"; | |
| 80 | 88 | |
| 81 | 89 | /** The whole clone — the HEAD probe and the fetch — gets this long. */ |
| 82 | 90 | export const MIGRATE_TIMEOUT_MS = 120_000; |
| @@ -900,3 +908,90 @@ Adapt, don't rewrite: | ||
| 900 | 908 | Done means the pull's preview starts, answers its \`health\` path, and does what the original did.`; |
| 901 | 909 | } |
| 902 | 910 | |
| 911 | ||
| 912 | export type ImportDataError = | |
| 913 | | "unauthorized" | |
| 914 | | "not_found" | |
| 915 | | "no_app" | |
| 916 | | "invalid_env" | |
| 917 | | "invalid_file" | |
| 918 | | ImportError; | |
| 919 | ||
| 920 | /** | |
| 921 | * Load a SQL dump into an app's store (#11697). Admins of the repository | |
| 922 | * only; anyone else is told `not_found`. `migrate` brings code and this | |
| 923 | * brings rows: the dump is vetted, loaded into a scratch database and a new | |
| 924 | * file, compared table by table, and only then put in place. A refusal | |
| 925 | * leaves the app's data as it was. | |
| 926 | */ | |
| 927 | export function importAppData( | |
| 928 | app: App, | |
| 929 | actor: User | undefined, | |
| 930 | repo: Repo, | |
| 931 | input: { | |
| 932 | readonly dump: string; | |
| 933 | readonly env?: string; | |
| 934 | readonly file?: string; | |
| 935 | readonly replace?: boolean; | |
| 936 | }, | |
| 937 | ): Result<ImportReport, ImportDataError> { | |
| 938 | const user = requireUser(actor); | |
| 939 | if (!user.ok) return user; | |
| 940 | if (!canAdmin(app.db, repo, user.value)) return err("not_found"); | |
| 941 | const record = getAppByRepo(app.db, repo.id); | |
| 942 | if (!record) return err("no_app"); | |
| 943 | const env = input.env ?? "production"; | |
| 944 | if (!isEnvName(env)) return err("invalid_env"); | |
| 945 | const file = input.file ?? "app.db"; | |
| 946 | if (!IMPORT_FILE.test(file)) return err("invalid_file"); | |
| 947 | const done = importDump( | |
| 948 | envDataDir(app.config.dataDir, record.id, env), | |
| 949 | file, | |
| 950 | input.dump, | |
| 951 | input.replace === true, | |
| 952 | ); | |
| 953 | if (!done.ok) return err(done.error); | |
| 954 | storageStale(app, repo.ownerId); | |
| 955 | app.bus.append({ | |
| 956 | kind: "app.data_imported", | |
| 957 | repoId: repo.id, | |
| 958 | actorId: user.value.id, | |
| 959 | payload: { | |
| 960 | env, | |
| 961 | file, | |
| 962 | tables: done.value.tables.map((t) => ({ table: t.table, rows: t.rows, sha256: t.sha256 })), | |
| 963 | }, | |
| 964 | }); | |
| 965 | return ok(done.value); | |
| 966 | } | |
| 967 | ||
| 968 | /** An import's refusal in words a person can act on. */ | |
| 969 | export function importDataMessage(error: ImportDataError): string { | |
| 970 | if (typeof error === "string") | |
| 971 | switch (error) { | |
| 972 | case "unauthorized": | |
| 973 | return "Sign in first."; | |
| 974 | case "not_found": | |
| 975 | return "No such repository, or you are not its admin."; | |
| 976 | case "no_app": | |
| 977 | return "This repository is not an app yet: turn it on under Settings first."; | |
| 978 | case "invalid_env": | |
| 979 | return "env is production, or pull-<number> for a preview."; | |
| 980 | case "invalid_file": | |
| 981 | return "file is a name ending in .db, with letters, digits, dots, dashes and underscores only."; | |
| 982 | } | |
| 983 | switch (error.error) { | |
| 984 | case "empty": | |
| 985 | return "The dump has no statements."; | |
| 986 | case "refused": | |
| 987 | return `Statement ${error.statement} (${error.kind}) is not allowed: a dump may only CREATE TABLE (not AS), CREATE INDEX, INSERT INTO ... VALUES of plain values, BEGIN, COMMIT and PRAGMA foreign_keys; no SELECT, WITH or function that builds a large value.`; | |
| 988 | case "invalid": | |
| 989 | return `Statement ${error.statement} failed: ${error.message}`; | |
| 990 | case "mismatch": | |
| 991 | return `The store does not match the dump in ${error.tables.filter((t) => !t.match).map((t) => t.table).join(", ")} (rows or checksum, or not the rows the dump's INSERTs count). Nothing was changed.`; | |
| 992 | case "too_large": | |
| 993 | return `The dump has ${error.statements} statements; at most ${MAX_STATEMENTS} are loaded.`; | |
| 994 | case "exists": | |
| 995 | return `${error.file} already exists in that environment. Send replace to keep it as ${error.file}.before-import-<time> and load the dump in its place.`; | |
| 996 | } | |
| 997 | } |
src/apps/dump_file.ts added +32
| @@ -0,0 +1,32 @@ | ||
| 1 | /** | |
| 2 | * A dump already in an app's data directory, for the terminal (#11697). A | |
| 3 | * terminal cannot paste 16 MB, and it must not read a path a person types, | |
| 4 | * so it reads one named file from `imports/` under the environment's data | |
| 5 | * directory: a plain name ending `.sql`, no separator, at most `DUMP_MAX` bytes. | |
| 6 | */ | |
| 7 | ||
| 8 | import { existsSync, readFileSync, statSync } from "node:fs"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { BODY_MAX } from "../http/limits.ts"; | |
| 11 | ||
| 12 | export const DUMP_MAX = BODY_MAX; | |
| 13 | export const DUMP_FILE = /^[\w][\w.-]*\.sql$/; | |
| 14 | export const IMPORTS_DIR = "imports"; | |
| 15 | ||
| 16 | export type DumpRead = | |
| 17 | | { readonly ok: true; readonly sql: string } | |
| 18 | | { readonly ok: false; readonly error: "invalid_name" | "missing" | "too_large" }; | |
| 19 | ||
| 20 | export function readStagedDump(envDir: string, name: string): DumpRead { | |
| 21 | if (!DUMP_FILE.test(name) || name.includes("..")) return { ok: false, error: "invalid_name" }; | |
| 22 | const path = join(envDir, IMPORTS_DIR, name); | |
| 23 | if (!existsSync(path) || !statSync(path).isFile()) return { ok: false, error: "missing" }; | |
| 24 | if (statSync(path).size > DUMP_MAX) return { ok: false, error: "too_large" }; | |
| 25 | return { ok: true, sql: readFileSync(path, "utf8") }; | |
| 26 | } | |
| 27 | ||
| 28 | export const DUMP_FILE_WORDS = { | |
| 29 | invalid_name: "A dump file is a name ending .sql: letters, digits, dots, dashes and underscores.", | |
| 30 | missing: `No such file in the app's ${IMPORTS_DIR}/ directory.`, | |
| 31 | too_large: "The dump is over 16 MB.", | |
| 32 | } as const; |
src/apps/dump_import.ts added +371
| @@ -0,0 +1,371 @@ | ||
| 1 | /** | |
| 2 | * Loading a SQL dump into an app's SQLite store, and proving it arrived. | |
| 3 | * | |
| 4 | * A dump is text a person uploaded, so it is never run as given. It is split | |
| 5 | * into statements (quotes and comments respected) and only a short list of | |
| 6 | * kinds may run: `CREATE TABLE`, `CREATE [UNIQUE] INDEX`, `INSERT INTO`, | |
| 7 | * `BEGIN`/`COMMIT`, and `PRAGMA foreign_keys`. `ATTACH`, other `PRAGMA`s, | |
| 8 | * triggers, views, `DROP`, `UPDATE` and `DELETE` are refused with the | |
| 9 | * statement's number, before anything is written. | |
| 10 | * | |
| 11 | * The kinds are narrower than their first words. An `INSERT` is only | |
| 12 | * `INSERT INTO <table> [(columns)] VALUES (...)[, (...)]`, its values | |
| 13 | * literals (numbers, strings, `X'..'` blobs, NULL, TRUE, FALSE): no function | |
| 14 | * call, no `SELECT`, no `WITH`. A `CREATE TABLE` is never `AS`. `SELECT` and | |
| 15 | * `WITH` are refused anywhere outside quotes, and so are the functions that | |
| 16 | * make a big value out of a small statement (`randomblob`, `zeroblob`, | |
| 17 | * `printf`, `replace`...). What is left costs time and disk in proportion to | |
| 18 | * the bytes of the dump: it is capped at 16 MB by the route, at | |
| 19 | * `MAX_STATEMENTS` statements here, and each database at `MAX_PAGES` pages. | |
| 20 | * | |
| 21 | * It is loaded twice, on purpose: into a scratch in-memory database (the | |
| 22 | * dump side) and into a new file beside the target (the store side). Each | |
| 23 | * side reports, per table, its row count and a SHA-256 of its rows in rowid | |
| 24 | * order, every value written as SQLite's own `quote()` literal so integers, | |
| 25 | * reals, text and blobs each hash by type. The two loads agree on the hash, | |
| 26 | * and each table's row count agrees with the number of `VALUES` tuples | |
| 27 | * counted in the dump's text, which SQLite did not produce. The file | |
| 28 | * replaces the target only when all of that holds. | |
| 29 | * | |
| 30 | * The input is the dump `sqlite3 .dump` writes, or `pg_dump --inserts | |
| 31 | * --no-owner` made SQLite-compatible; a Postgres app's home is #11698. | |
| 32 | */ | |
| 33 | ||
| 34 | import { Database } from "bun:sqlite"; | |
| 35 | import { existsSync, linkSync, mkdirSync, renameSync, rmSync } from "node:fs"; | |
| 36 | import { join } from "node:path"; | |
| 37 | ||
| 38 | /** A table's rows as one side holds them. */ | |
| 39 | export interface TableFingerprint { | |
| 40 | readonly table: string; | |
| 41 | readonly rows: number; | |
| 42 | /** SHA-256, hex, of the rows in rowid order. */ | |
| 43 | readonly sha256: string; | |
| 44 | } | |
| 45 | ||
| 46 | export interface TableCompare extends TableFingerprint { | |
| 47 | /** The same table as the store holds it. */ | |
| 48 | readonly store: TableFingerprint | undefined; | |
| 49 | /** Rows the dump's own text says were inserted, counted from its `VALUES` lists. */ | |
| 50 | readonly inserted: number; | |
| 51 | readonly match: boolean; | |
| 52 | } | |
| 53 | ||
| 54 | export type ImportError = | |
| 55 | | { readonly error: "empty" } | |
| 56 | | { readonly error: "refused"; readonly statement: number; readonly kind: string } | |
| 57 | | { readonly error: "invalid"; readonly statement: number; readonly message: string } | |
| 58 | | { readonly error: "mismatch"; readonly tables: readonly TableCompare[] } | |
| 59 | | { readonly error: "too_large"; readonly statements: number } | |
| 60 | | { readonly error: "exists"; readonly file: string }; | |
| 61 | ||
| 62 | export interface ImportReport { | |
| 63 | readonly file: string; | |
| 64 | readonly statements: number; | |
| 65 | /** One per table: the dump side, then what the store holds. */ | |
| 66 | readonly tables: readonly TableCompare[]; | |
| 67 | /** The file the replaced database was kept as, when there was one. */ | |
| 68 | readonly kept?: string; | |
| 69 | } | |
| 70 | ||
| 71 | /** | |
| 72 | * Split SQL at top-level semicolons. Single, double and backtick quotes | |
| 73 | * (doubled to escape), `--` and block comments are understood; comments are | |
| 74 | * dropped. Empty statements are not returned. | |
| 75 | */ | |
| 76 | export function splitSql(sql: string): string[] { | |
| 77 | const out: string[] = []; | |
| 78 | let cur = ""; | |
| 79 | let i = 0; | |
| 80 | const n = sql.length; | |
| 81 | while (i < n) { | |
| 82 | const c = sql[i]!; | |
| 83 | if (c === "'" || c === '"' || c === "`") { | |
| 84 | let j = i + 1; | |
| 85 | while (j < n) { | |
| 86 | if (sql[j] === c) { | |
| 87 | if (sql[j + 1] === c) j += 2; | |
| 88 | else break; | |
| 89 | } else j++; | |
| 90 | } | |
| 91 | cur += sql.slice(i, j + 1); | |
| 92 | i = j + 1; | |
| 93 | } else if (c === "-" && sql[i + 1] === "-") { | |
| 94 | const end = sql.indexOf("\n", i); | |
| 95 | i = end === -1 ? n : end; | |
| 96 | } else if (c === "/" && sql[i + 1] === "*") { | |
| 97 | const end = sql.indexOf("*/", i + 2); | |
| 98 | i = end === -1 ? n : end + 2; | |
| 99 | cur += " "; | |
| 100 | } else if (c === ";") { | |
| 101 | if (cur.trim() !== "") out.push(cur.trim()); | |
| 102 | cur = ""; | |
| 103 | i++; | |
| 104 | } else { | |
| 105 | cur += c; | |
| 106 | i++; | |
| 107 | } | |
| 108 | } | |
| 109 | if (cur.trim() !== "") out.push(cur.trim()); | |
| 110 | return out; | |
| 111 | } | |
| 112 | ||
| 113 | /** Statements one dump may hold: a 16 MB dump of `INSERT INTO t VALUES(1);` is about 700,000. */ | |
| 114 | export const MAX_STATEMENTS = 1_000_000; | |
| 115 | /** Pages either database may grow to: 16,384 x 4 KiB = 64 MiB. */ | |
| 116 | export const MAX_PAGES = 16_384; | |
| 117 | ||
| 118 | const NAME = String.raw`(?:"(?:[^"]|"")+"|` + "`(?:[^`]|``)+`" + String.raw`|\[[^\]]+\]|\w+)`; | |
| 119 | const ALLOWED: readonly RegExp[] = [ | |
| 120 | /^BEGIN(\s+(DEFERRED\s+|IMMEDIATE\s+|EXCLUSIVE\s+)?TRANSACTION)?$/i, | |
| 121 | /^(COMMIT|END)(\s+TRANSACTION)?$/i, | |
| 122 | /^PRAGMA\s+foreign_keys\s*=\s*(ON|OFF|0|1)$/i, | |
| 123 | /^CREATE\s+TABLE\s/i, | |
| 124 | /^CREATE\s+(UNIQUE\s+)?INDEX\s/i, | |
| 125 | ]; | |
| 126 | const INSERT = new RegExp(String.raw`^INSERT\s+INTO\s+(${NAME})\s*(\([^()]*\))?\s*VALUES\s*(\(.*)$`, "is"); | |
| 127 | const CREATE_TABLE_AS = new RegExp(String.raw`^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?${NAME}\s+AS\b`, "i"); | |
| 128 | /** Words that make a lot out of a little, or read the machine; none belongs in a dump. */ | |
| 129 | const HEAVY = /\b(select|with)\b|\b(randomblob|zeroblob|printf|format|replace|hex|unhex|load_extension|readfile|writefile|generate_series|json_each|json_tree|group_concat)\s*\(/i; | |
| 130 | const LITERAL_WORD = /^(null|true|false|x)$/i; | |
| 131 | ||
| 132 | /** `sql` with every quoted piece emptied, so what is left is syntax. */ | |
| 133 | function bare(sql: string): string { | |
| 134 | let out = ""; | |
| 135 | let i = 0; | |
| 136 | while (i < sql.length) { | |
| 137 | const c = sql[i]!; | |
| 138 | if (c === "'" || c === '"' || c === "`") { | |
| 139 | let j = i + 1; | |
| 140 | while (j < sql.length) { | |
| 141 | if (sql[j] === c) { | |
| 142 | if (sql[j + 1] === c) j += 2; | |
| 143 | else break; | |
| 144 | } else j++; | |
| 145 | } | |
| 146 | out += c + c; | |
| 147 | i = j + 1; | |
| 148 | } else if (c === "[") { | |
| 149 | const end = sql.indexOf("]", i); | |
| 150 | out += "[]"; | |
| 151 | i = end === -1 ? sql.length : end + 1; | |
| 152 | } else { | |
| 153 | out += c; | |
| 154 | i++; | |
| 155 | } | |
| 156 | } | |
| 157 | return out; | |
| 158 | } | |
| 159 | ||
| 160 | /** The tuples of a VALUES list: how many, or undefined when it is not only literals. */ | |
| 161 | function valueTuples(tail: string): number | undefined { | |
| 162 | const syntax = bare(tail); | |
| 163 | if (/[A-Za-z_]\w*\s*\(/.test(syntax.replace(/(?<![\w.])[xX](?=')/g, ""))) return undefined; // a call | |
| 164 | for (const w of syntax.match(/(?<![\w.])[A-Za-z_]\w*/g) ?? []) if (!LITERAL_WORD.test(w)) return undefined; | |
| 165 | if (!/^[\s\d.,()+\-'"xXeE\w]*$/.test(syntax)) return undefined; | |
| 166 | let depth = 0; | |
| 167 | let tuples = 0; | |
| 168 | let expectComma = false; | |
| 169 | for (const ch of syntax) { | |
| 170 | if (ch === "(") { | |
| 171 | if (depth === 0) { | |
| 172 | if (expectComma) return undefined; | |
| 173 | tuples++; | |
| 174 | } | |
| 175 | if (++depth > 1) return undefined; | |
| 176 | } else if (ch === ")") { | |
| 177 | if (--depth < 0) return undefined; | |
| 178 | if (depth === 0) expectComma = true; | |
| 179 | } else if (depth === 0 && ch === ",") expectComma = false; | |
| 180 | else if (depth === 0 && !/\s/.test(ch)) return undefined; | |
| 181 | } | |
| 182 | return depth === 0 && tuples > 0 ? tuples : undefined; | |
| 183 | } | |
| 184 | ||
| 185 | const tableKey = (raw: string): string => { | |
| 186 | const t = raw.trim(); | |
| 187 | const inner = /^"(.*)"$/s.test(t) ? t.slice(1, -1).replaceAll('""', '"') | |
| 188 | : /^`(.*)`$/s.test(t) ? t.slice(1, -1).replaceAll("``", "`") | |
| 189 | : /^\[(.*)\]$/s.test(t) ? t.slice(1, -1) | |
| 190 | : t; | |
| 191 | return inner.toLowerCase(); | |
| 192 | }; | |
| 193 | ||
| 194 | /** Rows each table should hold, counted from the dump's own text: the independent side of the proof. */ | |
| 195 | export function insertedRows(statements: readonly string[]): Map<string, number> { | |
| 196 | const counts = new Map<string, number>(); | |
| 197 | for (const s of statements) { | |
| 198 | const m = INSERT.exec(s); | |
| 199 | if (!m) continue; | |
| 200 | const n = valueTuples(m[3]!) ?? 0; | |
| 201 | const key = tableKey(m[1]!); | |
| 202 | counts.set(key, (counts.get(key) ?? 0) + n); | |
| 203 | } | |
| 204 | return counts; | |
| 205 | } | |
| 206 | ||
| 207 | const kindOf = (s: string): string => s.split(/\s+/).slice(0, 2).join(" ").slice(0, 40); | |
| 208 | ||
| 209 | /** The first statement that may not run, as its 1-based number and first words. */ | |
| 210 | export function firstRefused( | |
| 211 | statements: readonly string[], | |
| 212 | ): { statement: number; kind: string } | undefined { | |
| 213 | for (const [i, s] of statements.entries()) { | |
| 214 | const refuse = { statement: i + 1, kind: kindOf(s) }; | |
| 215 | if (/^INSERT\s/i.test(s)) { | |
| 216 | const m = INSERT.exec(s); | |
| 217 | if (!m || valueTuples(m[3]!) === undefined) return refuse; | |
| 218 | continue; | |
| 219 | } | |
| 220 | if (!ALLOWED.some((re) => re.test(s)) || CREATE_TABLE_AS.test(s) || HEAVY.test(bare(s))) return refuse; | |
| 221 | } | |
| 222 | return undefined; | |
| 223 | } | |
| 224 | ||
| 225 | const ident = (name: string): string => `"${name.replaceAll('"', '""')}"`; | |
| 226 | ||
| 227 | /** Row count and checksum of every user table, by name. */ | |
| 228 | export function fingerprint(db: Database): TableFingerprint[] { | |
| 229 | const names = db | |
| 230 | .query<{ name: string }, []>( | |
| 231 | "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", | |
| 232 | ) | |
| 233 | .all(); | |
| 234 | return names.map(({ name }) => { | |
| 235 | const columns = db | |
| 236 | .query<{ name: string }, []>(`PRAGMA table_info(${ident(name)})`) | |
| 237 | .all(); | |
| 238 | const select = columns.map((c) => `quote(${ident(c.name)})`).join(", "); | |
| 239 | const hasher = new Bun.CryptoHasher("sha256"); | |
| 240 | let rows = 0; | |
| 241 | const read = (order: string) => { | |
| 242 | const query = db.query(`SELECT ${select} FROM ${ident(name)}${order}`).values(); | |
| 243 | for (const row of query) { | |
| 244 | hasher.update(`${row.join("\x1f")}\n`); | |
| 245 | rows++; | |
| 246 | } | |
| 247 | }; | |
| 248 | try { | |
| 249 | read(" ORDER BY rowid"); | |
| 250 | } catch { | |
| 251 | // WITHOUT ROWID: a scan walks the primary key, which is its order. | |
| 252 | read(""); | |
| 253 | } | |
| 254 | return { table: name, rows, sha256: hasher.digest("hex") }; | |
| 255 | }); | |
| 256 | } | |
| 257 | ||
| 258 | function run( | |
| 259 | db: Database, | |
| 260 | statements: readonly string[], | |
| 261 | ): { statement: number; message: string } | undefined { | |
| 262 | for (const [i, s] of statements.entries()) { | |
| 263 | try { | |
| 264 | db.exec(s); | |
| 265 | } catch (e) { | |
| 266 | return { statement: i + 1, message: e instanceof Error ? e.message : String(e) }; | |
| 267 | } | |
| 268 | } | |
| 269 | return undefined; | |
| 270 | } | |
| 271 | ||
| 272 | export const IMPORT_FILE = /^[\w.-]+\.db$/; | |
| 273 | ||
| 274 | /** The row counts of a store against the dump's: pure, so the proof can be tried on its own. */ | |
| 275 | export function compareTables( | |
| 276 | dumpSide: readonly TableFingerprint[], | |
| 277 | storeSide: readonly TableFingerprint[], | |
| 278 | inserted: ReadonlyMap<string, number>, | |
| 279 | ): TableCompare[] { | |
| 280 | const store = new Map(storeSide.map((t) => [t.table, t])); | |
| 281 | return dumpSide.map((t): TableCompare => { | |
| 282 | const s = store.get(t.table); | |
| 283 | const counted = inserted.get(t.table.toLowerCase()) ?? 0; | |
| 284 | return { | |
| 285 | ...t, | |
| 286 | store: s, | |
| 287 | inserted: counted, | |
| 288 | match: s?.rows === t.rows && s.sha256 === t.sha256 && s.rows === counted, | |
| 289 | }; | |
| 290 | }); | |
| 291 | } | |
| 292 | ||
| 293 | /** A name for the replaced file that no earlier import's backup holds. */ | |
| 294 | function backupName(dir: string, file: string, now: number): string { | |
| 295 | for (let n = 0; ; n++) { | |
| 296 | const name = `${file}.before-import-${now}${n === 0 ? "" : `-${n}`}`; | |
| 297 | if (!existsSync(join(dir, name))) return name; | |
| 298 | } | |
| 299 | } | |
| 300 | ||
| 301 | /** | |
| 302 | * Load `sql` as `<dir>/<file>`. `dir` is an environment's data directory, | |
| 303 | * built by the caller from ids, never from input. An existing file is kept | |
| 304 | * (`<file>.before-import-<ms>`, a name no earlier backup has) only when | |
| 305 | * `replace` is set, else refused. The target is never absent: the old file | |
| 306 | * is hard-linked to its backup, then the new one is renamed over it. | |
| 307 | * `afterLoad` is a seam for tests: it may touch the store before the proof. | |
| 308 | */ | |
| 309 | export function importDump( | |
| 310 | dir: string, | |
| 311 | file: string, | |
| 312 | sql: string, | |
| 313 | replace: boolean, | |
| 314 | afterLoad?: (store: Database) => void, | |
| 315 | now: number = Date.now(), | |
| 316 | ): { ok: true; value: ImportReport } | { ok: false; error: ImportError } { | |
| 317 | const statements = splitSql(sql); | |
| 318 | if (statements.length === 0) return { ok: false, error: { error: "empty" } }; | |
| 319 | if (statements.length > MAX_STATEMENTS) | |
| 320 | return { ok: false, error: { error: "too_large", statements: statements.length } }; | |
| 321 | const refused = firstRefused(statements); | |
| 322 | if (refused) return { ok: false, error: { error: "refused", ...refused } }; | |
| 323 | const target = join(dir, file); | |
| 324 | if (existsSync(target) && !replace) | |
| 325 | return { ok: false, error: { error: "exists", file } }; | |
| 326 | ||
| 327 | const scratch = new Database(":memory:"); | |
| 328 | const staged = join(dir, `${file}.importing`); | |
| 329 | mkdirSync(dir, { recursive: true }); | |
| 330 | for (const p of [staged, `${staged}-wal`, `${staged}-shm`]) rmSync(p, { force: true }); | |
| 331 | const store = new Database(staged, { create: true }); | |
| 332 | try { | |
| 333 | for (const side of [scratch, store]) { | |
| 334 | side.exec(`PRAGMA max_page_count = ${MAX_PAGES}`); | |
| 335 | const failed = run(side, statements); | |
| 336 | if (failed) return { ok: false, error: { error: "invalid", ...failed } }; | |
| 337 | } | |
| 338 | afterLoad?.(store); | |
| 339 | const tables = compareTables(fingerprint(scratch), fingerprint(store), insertedRows(statements)); | |
| 340 | if (tables.some((t) => !t.match) || fingerprint(store).length !== tables.length) | |
| 341 | return { ok: false, error: { error: "mismatch", tables } }; | |
| 342 | store.exec("PRAGMA wal_checkpoint(TRUNCATE)"); | |
| 343 | store.close(); | |
| 344 | let kept: string | undefined; | |
| 345 | if (existsSync(target)) { | |
| 346 | try { | |
| 347 | const old = new Database(target, { readwrite: true }); | |
| 348 | old.exec("PRAGMA wal_checkpoint(TRUNCATE)"); | |
| 349 | old.close(); | |
| 350 | } catch { | |
| 351 | // Not SQLite after all: it is kept as it is. | |
| 352 | } | |
| 353 | kept = backupName(dir, file, now); | |
| 354 | linkSync(target, join(dir, kept)); | |
| 355 | } | |
| 356 | for (const suffix of ["-wal", "-shm"]) rmSync(`${target}${suffix}`, { force: true }); | |
| 357 | renameSync(staged, target); | |
| 358 | return { | |
| 359 | ok: true, | |
| 360 | value: { file, statements: statements.length, tables, ...(kept ? { kept } : {}) }, | |
| 361 | }; | |
| 362 | } finally { | |
| 363 | scratch.close(); | |
| 364 | try { | |
| 365 | store.close(); | |
| 366 | } catch { | |
| 367 | // Already closed. | |
| 368 | } | |
| 369 | for (const p of [staged, `${staged}-wal`, `${staged}-shm`]) rmSync(p, { force: true }); | |
| 370 | } | |
| 371 | } |
src/contract/openapi/routes/index.ts +1
| @@ -326,6 +326,7 @@ export { route as postApiV1ReposOwnerNameChecksRecheck } from "./postApiV1ReposO | ||
| 326 | 326 | export { route as postApiV1ReposOwnerNameContents } from "./postApiV1ReposOwnerNameContents.ts"; |
| 327 | 327 | export { route as postApiV1ReposOwnerNameContribute } from "./postApiV1ReposOwnerNameContribute.ts"; |
| 328 | 328 | export { route as postApiV1ReposOwnerNameDataEnvFileQuery } from "./postApiV1ReposOwnerNameDataEnvFileQuery.ts"; |
| 329 | export { route as postApiV1ReposOwnerNameDataImport } from "./postApiV1ReposOwnerNameDataImport.ts"; | |
| 329 | 330 | export { route as postApiV1ReposOwnerNameEdits } from "./postApiV1ReposOwnerNameEdits.ts"; |
| 330 | 331 | export { route as postApiV1ReposOwnerNameEgressGrants } from "./postApiV1ReposOwnerNameEgressGrants.ts"; |
| 331 | 332 | export { route as postApiV1ReposOwnerNameFollowingConverge } from "./postApiV1ReposOwnerNameFollowingConverge.ts"; |
src/contract/openapi/routes/postApiV1ReposOwnerNameDataImport.ts added +22
| @@ -0,0 +1,22 @@ | ||
| 1 | import { REPO, type Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "post", | |
| 5 | path: `${REPO}/data-import`, | |
| 6 | summary: | |
| 7 | "Load a SQL dump (CREATE TABLE, CREATE INDEX, INSERT INTO only; up to 16 MB) into the app's SQLite store and report each table's rows and SHA-256 on the dump side and the store side: { file, statements, tables: [{ table, rows, sha256, store, match }], kept? }; nothing changes unless every table matches; needs an admin token", | |
| 8 | auth: true, | |
| 9 | role: "admin", | |
| 10 | body: { dump: "string", env: "string?", file: "string?", replace: "boolean?" }, | |
| 11 | refusals: { | |
| 12 | invalid_dump: "dump is not text (422)", | |
| 13 | no_app: "the repository is not an app (422)", | |
| 14 | invalid_env: "env is production or pull-<number> (422)", | |
| 15 | invalid_file: "file is a name ending in .db (422)", | |
| 16 | empty: "the dump has no statements (422)", | |
| 17 | refused: "a statement is of a kind a dump may not run; its number is named (422)", | |
| 18 | invalid: "SQLite refused a statement; its number is named (422)", | |
| 19 | exists: "the file exists; send replace to keep it as <file>.before-import (409)", | |
| 20 | mismatch: "the store does not match the dump in some table; nothing was changed (409)", | |
| 21 | }, | |
| 22 | }; |
src/contract/tags.ts +1
| @@ -88,6 +88,7 @@ const ALIAS: Readonly<Record<string, string>> = { | ||
| 88 | 88 | orgs: "forges", |
| 89 | 89 | boards: "forges", |
| 90 | 90 | data: "apps", |
| 91 | "data-import": "apps", | |
| 91 | 92 | }; |
| 92 | 93 | |
| 93 | 94 | /** One line on each tag the document lists. */ |
src/crew/conversation_confirm.ts +1
| @@ -106,6 +106,7 @@ export const ASKING: Readonly<Record<string, Asking>> = { | ||
| 106 | 106 | parent_unlink: { does: "unlink parent-forge sign-in" }, |
| 107 | 107 | quotas: { does: "change an account's quotas" }, |
| 108 | 108 | spawn: { does: "spawn a child forge" }, |
| 109 | app_data_import: { does: "load a SQL dump into an app's database" }, | |
| 109 | 110 | app_deploy: { does: "redeploy or stop an app's production or preview" }, |
| 110 | 111 | stop: { does: "stop a crew run" }, |
| 111 | 112 | converge: { does: "have a fork's crew merge the parent's updates" }, |
src/design/activity.ts +1
| @@ -180,6 +180,7 @@ const WORDS: Record<string, string> = { | ||
| 180 | 180 | "repo.crew_outsiders": "changed who may trigger the crew", |
| 181 | 181 | "repo.mirrored": "mirrored a repository", |
| 182 | 182 | "repo.imported": "imported issues and pulls", |
| 183 | "app.data_imported": "loaded a database dump into an app", | |
| 183 | 184 | "repo.pinned": "pinned a repository", |
| 184 | 185 | "repo.unpinned": "unpinned a repository", |
| 185 | 186 | "board.pins": "changed their pinned panels", |
src/http/api/data.ts +23
| @@ -15,6 +15,7 @@ import { | ||
| 15 | 15 | sourceList, |
| 16 | 16 | tableList, |
| 17 | 17 | } from "../../data/repo_scope.ts"; |
| 18 | import { importAppData, importDataMessage } from "../../actions/migrate.ts"; | |
| 18 | 19 | import { loadRepo } from "../api.ts"; |
| 19 | 20 | import type { Ctx, Handler } from "../context.ts"; |
| 20 | 21 | import { QUERY_TIMEOUT_MS } from "../../data/query.ts"; |
| @@ -63,6 +64,28 @@ export function dataRoutes(router: Router<Handler>): void { | ||
| 63 | 64 | return result ? reply(result) : notFound(); |
| 64 | 65 | }); |
| 65 | 66 | |
| 67 | // A dump in, loaded and reported per table on both sides (#11697). Body: { dump, env?, file?, replace? }. | |
| 68 | router.add("POST", "/api/v1/repos/:owner/:name/data-import", async (ctx) => { | |
| 69 | const denied = requireScope(ctx, "admin"); | |
| 70 | if (denied) return denied; | |
| 71 | const repo = loadRepo(ctx); | |
| 72 | if (!repo) return notFound(); | |
| 73 | const input = await body(ctx.request); | |
| 74 | if (typeof input["dump"] !== "string") return apiError("invalid_dump", 422, "dump is the SQL text"); | |
| 75 | const done = importAppData(ctx.app, ctx.auth.user, repo, { | |
| 76 | dump: input["dump"], | |
| 77 | ...(typeof input["env"] === "string" ? { env: input["env"] } : {}), | |
| 78 | ...(typeof input["file"] === "string" ? { file: input["file"] } : {}), | |
| 79 | replace: input["replace"] === true, | |
| 80 | }); | |
| 81 | if (!done.ok) { | |
| 82 | if (done.error === "not_found") return notFound(); | |
| 83 | const code = typeof done.error === "string" ? done.error : done.error.error; | |
| 84 | return apiError(code, code === "exists" || code === "mismatch" ? 409 : code === "too_large" ? 413 : 422, importDataMessage(done.error)); | |
| 85 | } | |
| 86 | return json(done.value, 200, PRIVATE); | |
| 87 | }); | |
| 88 | ||
| 66 | 89 | router.add("POST", "/api/v1/repos/:owner/:name/data/:env/:file/query", async (ctx) => { |
| 67 | 90 | const denied = requireScope(ctx, "ro"); |
| 68 | 91 | if (denied) return denied; |
src/http/mcp/tools/app_data_import.ts added +39
| @@ -0,0 +1,39 @@ | ||
| 1 | import { importAppData, importDataMessage } from "../../../actions/migrate.ts"; | |
| 2 | import { bad, good, isAnswer, repoArg, str, type Tool } from "../shared.ts"; | |
| 3 | import { REPO_ARG } from "../shared.ts"; | |
| 4 | ||
| 5 | export const tool: Tool = { | |
| 6 | name: "app_data_import", | |
| 7 | doc: "`{ repo, dump, env?, file?, replace? }` as `POST …/data-import`: load SQL text (`sqlite3 .dump` kind, up to 16 MB: CREATE TABLE, CREATE INDEX, INSERT … VALUES, BEGIN, COMMIT) into an app's SQLite store; a repository admin's. Answers per table the dump's rows and checksum, the store's, and `match`; 409 `exists` unless `replace` (the old file is kept as `<file>.before-import-<time>`), 409 `mismatch` changes nothing", | |
| 8 | description: "Load a SQL dump into an app's database and report per-table counts and checksums; admins only.", | |
| 9 | inputSchema: { | |
| 10 | type: "object", | |
| 11 | properties: { | |
| 12 | repo: REPO_ARG, | |
| 13 | dump: { type: "string" }, | |
| 14 | env: { type: "string" }, | |
| 15 | file: { type: "string" }, | |
| 16 | replace: { type: "boolean" }, | |
| 17 | }, | |
| 18 | required: ["repo", "dump"], | |
| 19 | }, | |
| 20 | writes: true, | |
| 21 | async run(ctx, user, args) { | |
| 22 | const repo = repoArg(ctx, user, args); | |
| 23 | if (isAnswer(repo)) return repo; | |
| 24 | const dump = args["dump"]; | |
| 25 | if (typeof dump !== "string") return bad("invalid_dump", "dump is the SQL text"); | |
| 26 | if (Buffer.byteLength(dump) > 16 * 1024 * 1024) return bad("too_large", "the dump is over 16 MB"); | |
| 27 | const env = str(args["env"]); | |
| 28 | const file = str(args["file"]); | |
| 29 | const done = importAppData(ctx.app, user, repo, { | |
| 30 | dump, | |
| 31 | ...(env !== "" ? { env } : {}), | |
| 32 | ...(file !== "" ? { file } : {}), | |
| 33 | replace: args["replace"] === true, | |
| 34 | }); | |
| 35 | if (done.ok) return good(done.value); | |
| 36 | const code = typeof done.error === "string" ? done.error : done.error.error; | |
| 37 | return bad(code, importDataMessage(done.error)); | |
| 38 | }, | |
| 39 | }; |
src/http/mcp/tools/index.ts +1
| @@ -8,6 +8,7 @@ export { tool as account_verify } from "./account_verify.ts"; | ||
| 8 | 8 | export { tool as accounts } from "./accounts.ts"; |
| 9 | 9 | export { tool as agent } from "./agent.ts"; |
| 10 | 10 | export { tool as agents } from "./agents.ts"; |
| 11 | export { tool as app_data_import } from "./app_data_import.ts"; | |
| 11 | 12 | export { tool as app_deploy } from "./app_deploy.ts"; |
| 12 | 13 | export { tool as app_domains } from "./app_domains.ts"; |
| 13 | 14 | export { tool as app_env } from "./app_env.ts"; |
src/tui/screens/dataimport.ts added +115
| @@ -0,0 +1,115 @@ | ||
| 1 | /** | |
| 2 | * An app's database import in the terminal (#11697): the same | |
| 3 | * `importAppData` as `POST …/data-import` and MCP `app_data_import`, an | |
| 4 | * admin's only. A person who is not the repository's admin is offered | |
| 5 | * nothing. A terminal cannot paste a dump, so `i` asks for the name of a | |
| 6 | * `.sql` file already in the app's `imports/` directory (`apps/dump_file.ts`); | |
| 7 | * `r` does the same and replaces an existing store, keeping the old file as | |
| 8 | * `<file>.before-import-<time>`. The answer is the API's: per table, the | |
| 9 | * dump's rows and checksum, the store's, and whether they match. | |
| 10 | */ | |
| 11 | ||
| 12 | import { importAppData, importDataMessage } from "../../actions/migrate.ts"; | |
| 13 | import { envDataDir } from "../../apps/data.ts"; | |
| 14 | import { DUMP_FILE_WORDS, readStagedDump } from "../../apps/dump_file.ts"; | |
| 15 | import type { ImportReport } from "../../apps/dump_import.ts"; | |
| 16 | import { getAppByRepo } from "../../core/apps.ts"; | |
| 17 | import { canAdmin, type Repo } from "../../core/repos.ts"; | |
| 18 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 19 | import { binding } from "../app/keys.ts"; | |
| 20 | import type { Command } from "../app/palette.ts"; | |
| 21 | import type { Session } from "../session.ts"; | |
| 22 | import { style } from "../term/style.ts"; | |
| 23 | import { truncate } from "../term/width.ts"; | |
| 24 | import { fill, len, split } from "../ui/layout.ts"; | |
| 25 | import { ListState, drawList, promptScreen, type ListRow } from "./common.ts"; | |
| 26 | ||
| 27 | /** Whether this viewer may import: the repository's admin, and it is an app. */ | |
| 28 | export function mayImport(f: Session, repo: Repo | undefined): repo is Repo { | |
| 29 | return ( | |
| 30 | repo !== undefined && | |
| 31 | f.user !== undefined && | |
| 32 | getAppByRepo(f.app.db, repo.id) !== undefined && | |
| 33 | canAdmin(f.app.db, repo, f.user) | |
| 34 | ); | |
| 35 | } | |
| 36 | ||
| 37 | /** What an import's report says, a row per table. */ | |
| 38 | export function reportRows(report: ImportReport): ListRow[] { | |
| 39 | return report.tables.map((t) => ({ | |
| 40 | title: `${t.table}: ${t.rows} rows`, | |
| 41 | tone: t.match ? "success" : "danger", | |
| 42 | note: `dump ${t.sha256.slice(0, 16)} store ${t.store?.sha256.slice(0, 16) ?? "missing"} counted in dump ${t.inserted}`, | |
| 43 | ...(t.match ? {} : { meta: "mismatch" }), | |
| 44 | })); | |
| 45 | } | |
| 46 | ||
| 47 | /** Run an import from a staged file for this viewer: the report, or the words to say. */ | |
| 48 | export function importStaged( | |
| 49 | f: Session, | |
| 50 | repo: Repo, | |
| 51 | name: string, | |
| 52 | replace: boolean, | |
| 53 | ): { ok: true; report: ImportReport } | { ok: false; words: string } { | |
| 54 | const record = getAppByRepo(f.app.db, repo.id); | |
| 55 | if (record === undefined || !canAdmin(f.app.db, repo, f.user)) return { ok: false, words: "No such repository, or you are not its admin." }; | |
| 56 | const read = readStagedDump(envDataDir(f.app.config.dataDir, record.id, "production"), name); | |
| 57 | if (!read.ok) return { ok: false, words: DUMP_FILE_WORDS[read.error] }; | |
| 58 | const done = importAppData(f.app, f.user, repo, { dump: read.sql, replace }); | |
| 59 | return done.ok ? { ok: true, report: done.value } : { ok: false, words: importDataMessage(done.error) }; | |
| 60 | } | |
| 61 | ||
| 62 | export function dataImportScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | |
| 63 | const state = new ListState(); | |
| 64 | let rows: ListRow[] = []; | |
| 65 | let note = "i loads a .sql file from the app's imports/ directory; r loads it over an existing store."; | |
| 66 | const ask = (replace: boolean): Screen => | |
| 67 | promptScreen(ctx, screen, { | |
| 68 | title: replace ? "Import and replace" : "Import a dump", | |
| 69 | label: "file in imports/, like dump.sql", | |
| 70 | placeholder: "dump.sql", | |
| 71 | onSubmit: (name) => { | |
| 72 | if (name === "") return "Name a file."; | |
| 73 | const done = importStaged(f, repo, name, replace); | |
| 74 | if (!done.ok) return done.words; | |
| 75 | rows = reportRows(done.report); | |
| 76 | note = `Loaded ${done.report.statements} statements into ${done.report.file}${done.report.kept ? `; the old file is ${done.report.kept}` : ""}.`; | |
| 77 | ctx.invalidate(); | |
| 78 | return undefined; | |
| 79 | }, | |
| 80 | }); | |
| 81 | const screen: Screen = { | |
| 82 | title: `${repo.ownerName}/${repo.name} — Database import`, | |
| 83 | keys: [binding(["i"], "import"), binding(["r"], "import, replace"), binding(["j", "k"], "move cursor")], | |
| 84 | render(area, s) { | |
| 85 | const c = ctx.theme.c; | |
| 86 | const [head, list] = split(area, "v", [len(1), fill(1)]); | |
| 87 | s.text(head!.x, head!.y, truncate(mayImport(f, repo) ? note : "Only the app's admin imports a database.", head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 88 | drawList(s, list!, c, state, rows); | |
| 89 | }, | |
| 90 | key(e) { | |
| 91 | if (state.key(e, rows.length)) return true; | |
| 92 | if (e.ctrl || e.alt || (e.key !== "i" && e.key !== "r")) return false; | |
| 93 | if (!mayImport(f, repo)) { | |
| 94 | ctx.toast("Only the app's admin imports a database.", "danger"); | |
| 95 | return true; | |
| 96 | } | |
| 97 | ctx.push(ask(e.key === "r")); | |
| 98 | return true; | |
| 99 | }, | |
| 100 | }; | |
| 101 | return screen; | |
| 102 | } | |
| 103 | ||
| 104 | /** The palette's command: an admin of a repository that is an app. */ | |
| 105 | export function dataImportCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] { | |
| 106 | if (!mayImport(f, repo)) return []; | |
| 107 | return [ | |
| 108 | { | |
| 109 | id: "data-import", | |
| 110 | title: `${repo.ownerName}/${repo.name} — Database import`, | |
| 111 | hint: "load a SQL dump into the app's store, counts and checksums on both sides", | |
| 112 | run: () => ctx.push(dataImportScreen(f, ctx, repo)), | |
| 113 | }, | |
| 114 | ]; | |
| 115 | } |
src/tui/screens/workspace.ts +2
| @@ -171,6 +171,7 @@ import { autonomyCommands } from "./autonomy.ts"; | ||
| 171 | 171 | import { trafficCommands } from "./trafficview.ts"; |
| 172 | 172 | import { chaptersCommands } from "./chapters.ts"; |
| 173 | 173 | import { schedulesCommands } from "./schedules.ts"; |
| 174 | import { dataImportCommands } from "./dataimport.ts"; | |
| 174 | 175 | import { jobsCommands } from "./jobs.ts"; |
| 175 | 176 | import { queueCommands } from "./queuegrowth.ts"; |
| 176 | 177 | import { blockedCommands } from "./blocked.ts"; |
| @@ -1997,6 +1998,7 @@ export function workspaceScreen( | ||
| 1997 | 1998 | ...autonomyCommands(f, ctx, repo), |
| 1998 | 1999 | ...trafficCommands(f, ctx, repo), |
| 1999 | 2000 | ...schedulesCommands(f, ctx, repo), |
| 2001 | ...dataImportCommands(f, ctx, repo), | |
| 2000 | 2002 | ...jobsCommands(f, ctx, repo), |
| 2001 | 2003 | ...chaptersCommands(f, ctx, repo), |
| 2002 | 2004 | ...queueCommands(f, ctx, repo), |
test/app-dump-import.test.ts added +254
| @@ -0,0 +1,254 @@ | ||
| 1 | /** A dump in, loaded and proved per table on both sides (#11697). */ | |
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { Database } from "bun:sqlite"; | |
| 4 | import { existsSync, readdirSync, mkdirSync, writeFileSync } from "node:fs"; | |
| 5 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 6 | import { tmpdir } from "node:os"; | |
| 7 | import { join } from "node:path"; | |
| 8 | import { createHash } from "node:crypto"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { importAppData } from "../src/actions/migrate.ts"; | |
| 11 | import { envDataDir } from "../src/apps/data.ts"; | |
| 12 | import { compareTables, firstRefused, importDump, insertedRows, splitSql } from "../src/apps/dump_import.ts"; | |
| 13 | import { createApp as createAppRecord } from "../src/core/apps.ts"; | |
| 14 | import { createRepo, getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 15 | import { createUser, type User } from "../src/core/users.ts"; | |
| 16 | import { serve } from "../src/http/server.ts"; | |
| 17 | import { createToken } from "../src/core/tokens.ts"; | |
| 18 | import { quietLogs } from "./helpers/globals.ts"; | |
| 19 | ||
| 20 | quietLogs(); | |
| 21 | ||
| 22 | const DUMP = `-- a dump; with; semicolons in comments | |
| 23 | BEGIN TRANSACTION; | |
| 24 | CREATE TABLE orders (id INTEGER PRIMARY KEY, who TEXT, total REAL, note BLOB); | |
| 25 | INSERT INTO orders VALUES (1,'ann; "the first"',9.5,NULL); | |
| 26 | INSERT INTO orders VALUES (2,'it''s /* not a comment */',12,X'00ff'); | |
| 27 | CREATE TABLE empty_t (a TEXT); | |
| 28 | CREATE UNIQUE INDEX orders_who ON orders(who); | |
| 29 | COMMIT;`; | |
| 30 | ||
| 31 | describe("splitting and vetting", () => { | |
| 32 | test("quotes and comments hide semicolons", () => { | |
| 33 | expect(splitSql(DUMP)).toHaveLength(7); | |
| 34 | expect(splitSql("INSERT INTO t VALUES ('a;b'); /* ; */ ")).toEqual(["INSERT INTO t VALUES ('a;b')"]); | |
| 35 | }); | |
| 36 | test("only the allowed kinds pass, and the refusal names its statement", () => { | |
| 37 | expect(firstRefused(splitSql(DUMP))).toBeUndefined(); | |
| 38 | expect(firstRefused(splitSql("CREATE TABLE t(a); ATTACH DATABASE '/x' AS y"))).toEqual({ statement: 2, kind: "ATTACH DATABASE" }); | |
| 39 | expect(firstRefused(splitSql("PRAGMA writable_schema=1"))?.statement).toBe(1); | |
| 40 | expect(firstRefused(splitSql("DROP TABLE t"))?.statement).toBe(1); | |
| 41 | }); | |
| 42 | }); | |
| 43 | ||
| 44 | describe("what a dump may do (#11697 review)", () => { | |
| 45 | const refused = (sql: string) => firstRefused(splitSql(sql)); | |
| 46 | test("a recursive CTE, INSERT ... SELECT and CREATE TABLE AS are refused", () => { | |
| 47 | expect(refused("CREATE TABLE t(x); INSERT INTO t WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM c) SELECT x FROM c")?.statement).toBe(2); | |
| 48 | expect(refused("INSERT INTO t SELECT randomblob(1000000000)")?.statement).toBe(1); | |
| 49 | expect(refused("INSERT INTO t VALUES ((SELECT 1))")?.statement).toBe(1); | |
| 50 | expect(refused("INSERT INTO t VALUES (randomblob(1000000000))")?.statement).toBe(1); | |
| 51 | expect(refused("INSERT INTO t VALUES (1); INSERT INTO t VALUES (zeroblob(5))")?.statement).toBe(2); | |
| 52 | expect(refused("CREATE TABLE x AS SELECT 1")?.statement).toBe(1); | |
| 53 | expect(refused("CREATE TABLE x AS VALUES (1)")?.statement).toBe(1); | |
| 54 | expect(refused("CREATE TABLE x (a TEXT DEFAULT (hex(randomblob(500000000))))")?.statement).toBe(1); | |
| 55 | expect(refused("CREATE INDEX i ON t(a) WHERE a IN (SELECT 1)")?.statement).toBe(1); | |
| 56 | }); | |
| 57 | test("plain dumps still pass, quoted words and column names that look like functions included", () => { | |
| 58 | expect(refused(`CREATE TABLE "t" (format TEXT, "select" TEXT, price DECIMAL(10,2) DEFAULT (datetime('now'))); | |
| 59 | INSERT INTO "t" VALUES('select * from x; replace(1)', 'with', 1.5e3), ('b', NULL, -2); | |
| 60 | INSERT INTO "t"(format) VALUES (X'00ff'), (TRUE);`)).toBeUndefined(); | |
| 61 | }); | |
| 62 | test("an unbounded or mixed VALUES list is refused", () => { | |
| 63 | expect(refused("INSERT INTO t VALUES (1) (2)")?.statement).toBe(1); | |
| 64 | expect(refused("INSERT INTO t VALUES (1), (2) ON CONFLICT DO NOTHING")?.statement).toBe(1); | |
| 65 | expect(refused("INSERT INTO t VALUES (1); DELETE FROM t")?.statement).toBe(2); | |
| 66 | }); | |
| 67 | test("a recursive CTE loaded through importDump writes nothing", () => { | |
| 68 | const dir = join(tmpdir(), `forge-dump-cte-${process.pid}`); | |
| 69 | const done = importDump(dir, "x.db", "CREATE TABLE t(x INT); INSERT INTO t WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM c) SELECT x FROM c", false); | |
| 70 | expect(done).toMatchObject({ ok: false, error: { error: "refused", statement: 2 } }); | |
| 71 | expect(existsSync(join(dir, "x.db"))).toBe(false); | |
| 72 | }); | |
| 73 | test("rows are counted from the dump's text, per table, apart from SQLite", () => { | |
| 74 | const counts = insertedRows(splitSql(`CREATE TABLE a(x); INSERT INTO a VALUES (1),(2),('3);(4)'); INSERT INTO "A" VALUES (9); INSERT INTO b(x) VALUES (1)`)); | |
| 75 | expect([...counts]).toEqual([["a", 4], ["b", 1]]); | |
| 76 | }); | |
| 77 | test("mismatch: a store that lost or gained a row, or changed one, is not accepted and nothing is put in place", () => { | |
| 78 | const fp = (rows: number, sha: string) => ({ table: "t", rows, sha256: sha }); | |
| 79 | const counted = new Map([["t", 3]]); | |
| 80 | expect(compareTables([fp(3, "a")], [fp(3, "a")], counted)[0]!.match).toBe(true); | |
| 81 | expect(compareTables([fp(3, "a")], [fp(3, "b")], counted)[0]!.match).toBe(false); | |
| 82 | expect(compareTables([fp(3, "a")], [fp(2, "a")], counted)[0]!.match).toBe(false); | |
| 83 | // both loads agree with each other but not with the dump's own text | |
| 84 | expect(compareTables([fp(2, "a")], [fp(2, "a")], counted)[0]!.match).toBe(false); | |
| 85 | expect(compareTables([fp(3, "a")], [], counted)[0]!.match).toBe(false); | |
| 86 | const dir = join(tmpdir(), `forge-dump-mm-${process.pid}`); | |
| 87 | const done = importDump(dir, "m.db", "CREATE TABLE t(a); INSERT INTO t VALUES (1),(2);", false, (store) => store.exec("INSERT INTO t VALUES (3)")); | |
| 88 | expect(done).toMatchObject({ ok: false, error: { error: "mismatch", tables: [{ table: "t", rows: 2, inserted: 2, match: false, store: { rows: 3 } }] } }); | |
| 89 | expect(existsSync(join(dir, "m.db"))).toBe(false); | |
| 90 | expect(existsSync(join(dir, "m.db.importing"))).toBe(false); | |
| 91 | const kept = join(dir, "n.db"); | |
| 92 | mkdirSync(dir, { recursive: true }); | |
| 93 | writeFileSync(kept, "old"); | |
| 94 | importDump(dir, "n.db", "CREATE TABLE t(a); INSERT INTO t VALUES (1);", true, (store) => store.exec("DELETE FROM t")); | |
| 95 | expect(Bun.file(kept).size).toBe(3); | |
| 96 | }); | |
| 97 | test("a dump of more statements than the ceiling is refused before it runs", () => { | |
| 98 | const dir = join(tmpdir(), `forge-dump-big-${process.pid}`); | |
| 99 | const sql = "BEGIN;" + ";".repeat(0) + "INSERT INTO t VALUES (1);".repeat(1_000_001); | |
| 100 | expect(importDump(dir, "b.db", sql, false)).toMatchObject({ ok: false, error: { error: "too_large" } }); | |
| 101 | }); | |
| 102 | }); | |
| 103 | ||
| 104 | describe("importAppData", () => { | |
| 105 | let dataDir: string; | |
| 106 | let app: App; | |
| 107 | let owner: User; | |
| 108 | let stranger: User; | |
| 109 | let repo: Repo; | |
| 110 | let bare: Repo; | |
| 111 | const mk = async (name: string, admin = false): Promise<User> => { | |
| 112 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: name, email: `${name}@forge.local`, password: "a-good-passphrase", adminIfFirst: admin }); | |
| 113 | if (!made.ok) throw new Error(made.error); | |
| 114 | return made.value; | |
| 115 | }; | |
| 116 | beforeAll(async () => { | |
| 117 | dataDir = await mkdtemp(join(tmpdir(), "forge-dump-")); | |
| 118 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 119 | await mk("owner1", true); | |
| 120 | owner = await mk("alice"); | |
| 121 | stranger = await mk("bob"); | |
| 122 | const r = await createRepo(app.db, app.reposRoot, { ownerId: owner.id, name: "shop", visibility: "public" }); | |
| 123 | if (!r.ok) throw new Error(r.error); | |
| 124 | repo = getRepoById(app.db, r.value.id)!; | |
| 125 | const rec = createAppRecord(app.db, { repoId: repo.id, slug: "shop", access: "public" }); | |
| 126 | if (!rec.ok) throw new Error(rec.error); | |
| 127 | const b = await createRepo(app.db, app.reposRoot, { ownerId: owner.id, name: "plain", visibility: "public" }); | |
| 128 | if (!b.ok) throw new Error(b.error); | |
| 129 | bare = b.value; | |
| 130 | }); | |
| 131 | afterAll(async () => { | |
| 132 | app.close(); | |
| 133 | await rm(dataDir, { recursive: true, force: true }); | |
| 134 | }); | |
| 135 | const dirOf = () => envDataDir(dataDir, (app.db.query("SELECT id FROM app").get() as { id: string }).id, "production"); | |
| 136 | ||
| 137 | test("loads, and the counts and checksums match a hand calculation", () => { | |
| 138 | const done = importAppData(app, owner, repo, { dump: DUMP }); | |
| 139 | expect(done.ok).toBe(true); | |
| 140 | if (!done.ok) return; | |
| 141 | const orders = done.value.tables.find((t) => t.table === "orders")!; | |
| 142 | expect(orders.rows).toBe(2); | |
| 143 | // rows in rowid order, each value as SQLite's quote() writes it, joined by US, ended by newline | |
| 144 | const by = createHash("sha256") | |
| 145 | .update("1\x1f'ann; \"the first\"'\x1f9.5\x1fNULL\n2\x1f'it''s /* not a comment */'\x1f12.0\x1fX'00FF'\n") | |
| 146 | .digest("hex"); | |
| 147 | expect(orders.sha256).toBe(by); | |
| 148 | expect(orders.store).toEqual({ table: "orders", rows: 2, sha256: by }); | |
| 149 | expect(orders.inserted).toBe(2); | |
| 150 | expect(orders.match).toBe(true); | |
| 151 | expect(done.value.tables.find((t) => t.table === "empty_t")?.rows).toBe(0); | |
| 152 | const stored = new Database(join(dirOf(), "app.db"), { readonly: true }); | |
| 153 | expect(stored.query("SELECT count(*) AS n FROM orders").get()).toEqual({ n: 2 }); | |
| 154 | stored.close(); | |
| 155 | }); | |
| 156 | ||
| 157 | test("an existing file is refused unless replace is said, then kept", () => { | |
| 158 | const again = importAppData(app, owner, repo, { dump: DUMP }); | |
| 159 | expect(again).toEqual({ ok: false, error: { error: "exists", file: "app.db" } }); | |
| 160 | const replaced = importAppData(app, owner, repo, { dump: "CREATE TABLE t(a); INSERT INTO t VALUES (1);", replace: true }); | |
| 161 | const kept = replaced.ok ? replaced.value.kept : undefined; | |
| 162 | expect(kept).toMatch(/^app\.db\.before-import-\d+$/); | |
| 163 | expect(existsSync(join(dirOf(), kept!))).toBe(true); | |
| 164 | const d = new Database(join(dirOf(), "app.db"), { readonly: true }); | |
| 165 | expect(d.query("SELECT name FROM sqlite_master").all()).toEqual([{ name: "t" }]); | |
| 166 | d.close(); | |
| 167 | }); | |
| 168 | ||
| 169 | test("a refused or broken dump changes nothing", () => { | |
| 170 | const before = Bun.file(join(dirOf(), "app.db")).size; | |
| 171 | expect(importAppData(app, owner, repo, { dump: "ATTACH DATABASE '/etc/x' AS y", replace: true })).toMatchObject({ error: { error: "refused", statement: 1 } }); | |
| 172 | expect(importAppData(app, owner, repo, { dump: "CREATE TABLE u(a); INSERT INTO nope VALUES (1)", replace: true })).toMatchObject({ error: { error: "invalid", statement: 2 } }); | |
| 173 | expect(importAppData(app, owner, repo, { dump: " -- nothing ", replace: true })).toMatchObject({ error: { error: "empty" } }); | |
| 174 | expect(Bun.file(join(dirOf(), "app.db")).size).toBe(before); | |
| 175 | expect(existsSync(join(dirOf(), "app.db.importing"))).toBe(false); | |
| 176 | }); | |
| 177 | ||
| 178 | test("a second replace keeps the first backup too", () => { | |
| 179 | const backups = () => readdirSync(dirOf()).filter((n) => n.startsWith("app.db.before-import")); | |
| 180 | const before = backups().length; | |
| 181 | const again = importAppData(app, owner, repo, { dump: "CREATE TABLE t2(a); INSERT INTO t2 VALUES (7);", replace: true }); | |
| 182 | expect(again.ok).toBe(true); | |
| 183 | expect(backups().length).toBe(before + 1); | |
| 184 | // the same millisecond still gets its own name | |
| 185 | const dir = join(dataDir, "twice"); | |
| 186 | const one = importDump(dir, "a.db", "CREATE TABLE t(a);", false, undefined, 5); | |
| 187 | const two = importDump(dir, "a.db", "CREATE TABLE u(a);", true, undefined, 5); | |
| 188 | const three = importDump(dir, "a.db", "CREATE TABLE v(a);", true, undefined, 5); | |
| 189 | expect([one.ok, two.ok && two.value.kept, three.ok && three.value.kept]).toEqual([true, "a.db.before-import-5", "a.db.before-import-5-1"]); | |
| 190 | const old = new Database(join(dir, "a.db.before-import-5"), { readonly: true }); | |
| 191 | expect(old.query("SELECT name FROM sqlite_master").all()).toEqual([{ name: "t" }]); | |
| 192 | old.close(); | |
| 193 | }); | |
| 194 | ||
| 195 | test("who and where are checked at the boundary", () => { | |
| 196 | expect(importAppData(app, stranger, repo, { dump: DUMP, file: "x.db" })).toEqual({ ok: false, error: "not_found" }); | |
| 197 | expect(importAppData(app, undefined, repo, { dump: DUMP })).toEqual({ ok: false, error: "unauthorized" }); | |
| 198 | expect(importAppData(app, owner, bare, { dump: DUMP })).toEqual({ ok: false, error: "no_app" }); | |
| 199 | expect(importAppData(app, owner, repo, { dump: DUMP, env: "../x" })).toEqual({ ok: false, error: "invalid_env" }); | |
| 200 | expect(importAppData(app, owner, repo, { dump: DUMP, file: "../x.db" })).toEqual({ ok: false, error: "invalid_file" }); | |
| 201 | }); | |
| 202 | ||
| 203 | test("the API door: POST data-import, admin token, 404 for a stranger", async () => { | |
| 204 | const servers = await serve(app); | |
| 205 | try { | |
| 206 | const post = (who: User, payload: unknown) => | |
| 207 | fetch(`${servers.url}/api/v1/repos/alice/shop/data-import`, { | |
| 208 | method: "POST", | |
| 209 | headers: { authorization: `Bearer ${createToken(app.db, who.id, "t", "admin").token}`, "content-type": "application/json" }, | |
| 210 | body: JSON.stringify(payload), | |
| 211 | }); | |
| 212 | const ok = await post(owner, { dump: DUMP, replace: true, file: "api.db" }); | |
| 213 | expect(ok.status).toBe(200); | |
| 214 | const report = (await ok.json()) as { tables: { table: string; rows: number; match: boolean }[] }; | |
| 215 | expect(report.tables.map((t) => [t.table, t.rows, t.match])).toEqual([["empty_t", 0, true], ["orders", 2, true]]); | |
| 216 | expect((await post(owner, { dump: DUMP, file: "api.db" })).status).toBe(409); | |
| 217 | expect((await post(owner, { dump: "DROP TABLE t" })).status).toBe(422); | |
| 218 | expect((await post(stranger, { dump: DUMP })).status).toBe(404); | |
| 219 | // a recursive CTE is refused at the door, not run | |
| 220 | const cte = await post(owner, { dump: "CREATE TABLE t(x); INSERT INTO t WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM c) SELECT x FROM c", file: "cte.db" }); | |
| 221 | expect(cte.status).toBe(422); | |
| 222 | expect(((await cte.json()) as { error: string }).error).toBe("refused"); | |
| 223 | expect(existsSync(join(dirOf(), "cte.db"))).toBe(false); | |
| 224 | // a token that may only read cannot import, even for its own repository | |
| 225 | const ro = await fetch(`${servers.url}/api/v1/repos/alice/shop/data-import`, { | |
| 226 | method: "POST", | |
| 227 | headers: { authorization: `Bearer ${createToken(app.db, owner.id, "ro", "ro").token}`, "content-type": "application/json" }, | |
| 228 | body: JSON.stringify({ dump: DUMP, file: "ro.db" }), | |
| 229 | }); | |
| 230 | expect(ro.status).toBeGreaterThanOrEqual(400); | |
| 231 | expect(ro.status).toBeLessThan(500); | |
| 232 | expect(existsSync(join(dirOf(), "ro.db"))).toBe(false); | |
| 233 | // a body past 16 MB is stopped by the bytes read, with no length declared | |
| 234 | const chunk = new TextEncoder().encode("x".repeat(1024 * 1024)); | |
| 235 | let sent = 0; | |
| 236 | const stream = new ReadableStream<Uint8Array>({ | |
| 237 | pull(c) { | |
| 238 | if (sent++ < 18) c.enqueue(chunk); | |
| 239 | else c.close(); | |
| 240 | }, | |
| 241 | }); | |
| 242 | const huge = await fetch(`${servers.url}/api/v1/repos/alice/shop/data-import`, { | |
| 243 | method: "POST", | |
| 244 | headers: { authorization: `Bearer ${createToken(app.db, owner.id, "t", "admin").token}`, "content-type": "application/json" }, | |
| 245 | body: stream, | |
| 246 | duplex: "half", | |
| 247 | }); | |
| 248 | expect(huge.status).toBe(413); | |
| 249 | expect(existsSync(join(dirOf(), "app.db.importing"))).toBe(false); | |
| 250 | } finally { | |
| 251 | servers.stop(); | |
| 252 | } | |
| 253 | }); | |
| 254 | }); |
test/dataimport-doors.test.ts added +104
| @@ -0,0 +1,104 @@ | ||
| 1 | /** | |
| 2 | * The database import through the terminal and MCP doors (#11697): the same | |
| 3 | * gate as the API (the repository's admin; anyone else is offered nothing and | |
| 4 | * changes nothing), the same engine, the same words. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdirSync, writeFileSync, existsSync } from "node:fs"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { envDataDir } from "../src/apps/data.ts"; | |
| 14 | import { readStagedDump } from "../src/apps/dump_file.ts"; | |
| 15 | import { createApp as insertApp } from "../src/core/apps.ts"; | |
| 16 | import type { Repo } from "../src/core/repos.ts"; | |
| 17 | import { createUser, type User } from "../src/core/users.ts"; | |
| 18 | import { TOOLS } from "../src/http/mcp/tools.ts"; | |
| 19 | import { dataImportCommands, importStaged } from "../src/tui/screens/dataimport.ts"; | |
| 20 | import type { Ctx } from "../src/tui/app/app.ts"; | |
| 21 | import type { Session } from "../src/tui/session.ts"; | |
| 22 | import { quietLogs } from "./helpers/globals.ts"; | |
| 23 | import { promote } from "./helpers/members.ts"; | |
| 24 | ||
| 25 | quietLogs(); | |
| 26 | ||
| 27 | let dataDir: string; | |
| 28 | let app: App; | |
| 29 | let cy: User; // admin of the repository | |
| 30 | let dee: User; // a reader | |
| 31 | let repo: Repo; | |
| 32 | let appId: string; | |
| 33 | ||
| 34 | beforeAll(async () => { | |
| 35 | dataDir = await mkdtemp(join(tmpdir(), "forge-import-doors-")); | |
| 36 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 37 | const make = async (username: string, adminIfFirst = false) => { | |
| 38 | const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst }); | |
| 39 | if (!made.ok) throw new Error(made.error); | |
| 40 | return made.value; | |
| 41 | }; | |
| 42 | await make("ada", true); | |
| 43 | cy = await make("cy"); | |
| 44 | promote(app.db, cy); | |
| 45 | dee = await make("dee"); | |
| 46 | const made = await createRepo(app, cy, { name: "shop", visibility: "public", description: "" }); | |
| 47 | if (!made.ok) throw new Error(String(made.error)); | |
| 48 | repo = made.value; | |
| 49 | const row = insertApp(app.db, { repoId: repo.id, slug: "shop", access: "public" }); | |
| 50 | if (!row.ok) throw new Error(row.error); | |
| 51 | appId = row.value.id; | |
| 52 | const dir = join(envDataDir(dataDir, appId, "production"), "imports"); | |
| 53 | mkdirSync(dir, { recursive: true }); | |
| 54 | writeFileSync(join(dir, "dump.sql"), "CREATE TABLE t(a); INSERT INTO t VALUES (1),(2);"); | |
| 55 | }, 60_000); | |
| 56 | ||
| 57 | afterAll(async () => { | |
| 58 | app.db.close(); | |
| 59 | await rm(dataDir, { recursive: true, force: true }); | |
| 60 | }); | |
| 61 | ||
| 62 | const session = (user: User | undefined) => ({ user, db: app.db, app }) as unknown as Session; | |
| 63 | const ctx = { toast() {}, invalidate() {}, push() {} } as unknown as Ctx; | |
| 64 | ||
| 65 | describe("terminal", () => { | |
| 66 | test("only the repository's admin is offered the screen", () => { | |
| 67 | expect(dataImportCommands(session(cy), ctx, repo).map((c) => c.id)).toEqual(["data-import"]); | |
| 68 | expect(dataImportCommands(session(dee), ctx, repo)).toEqual([]); | |
| 69 | expect(dataImportCommands(session(undefined), ctx, repo)).toEqual([]); | |
| 70 | expect(dataImportCommands(session(cy), ctx, undefined)).toEqual([]); | |
| 71 | }); | |
| 72 | test("a non-admin's import is refused and writes nothing", () => { | |
| 73 | const refused = importStaged(session(dee), repo, "dump.sql", false); | |
| 74 | expect(refused.ok).toBe(false); | |
| 75 | expect(existsSync(join(envDataDir(dataDir, appId, "production"), "app.db"))).toBe(false); | |
| 76 | }); | |
| 77 | test("the admin's import loads a staged file and reports per table", () => { | |
| 78 | const done = importStaged(session(cy), repo, "dump.sql", false); | |
| 79 | expect(done.ok && done.report.tables.map((t) => [t.table, t.rows, t.inserted, t.match])).toEqual([["t", 2, 2, true]]); | |
| 80 | const again = importStaged(session(cy), repo, "dump.sql", false); | |
| 81 | expect(again).toMatchObject({ ok: false }); | |
| 82 | }); | |
| 83 | test("a file name is a plain .sql name that exists", () => { | |
| 84 | const dir = envDataDir(dataDir, appId, "production"); | |
| 85 | for (const name of ["../app.db", "a/b.sql", "dump.txt", ".sql", ""]) expect(readStagedDump(dir, name)).toEqual({ ok: false, error: "invalid_name" }); | |
| 86 | expect(readStagedDump(dir, "nope.sql")).toEqual({ ok: false, error: "missing" }); | |
| 87 | }); | |
| 88 | }); | |
| 89 | ||
| 90 | describe("MCP app_data_import", () => { | |
| 91 | const tool = TOOLS.find((t) => t.name === "app_data_import")!; | |
| 92 | const call = (user: User, args: Record<string, unknown>) => tool.run({ app } as never, user, args); | |
| 93 | test("is a write tool, registered", () => { | |
| 94 | expect(tool.writes).toBe(true); | |
| 95 | }); | |
| 96 | test("a non-admin is told not_found; an admin loads; a refused dump says why", async () => { | |
| 97 | const dump = "CREATE TABLE m(a); INSERT INTO m VALUES (1);"; | |
| 98 | expect(await call(dee, { repo: "cy/shop", dump, file: "m.db" })).toMatchObject({ ok: false, error: "not_found" }); | |
| 99 | const done = await call(cy, { repo: "cy/shop", dump, file: "m.db" }); | |
| 100 | expect(done).toMatchObject({ ok: true }); | |
| 101 | expect(await call(cy, { repo: "cy/shop", dump: "CREATE TABLE x AS SELECT 1", file: "x.db" })).toMatchObject({ ok: false, error: "refused" }); | |
| 102 | expect(await call(cy, { repo: "cy/shop", dump, file: "m.db" })).toMatchObject({ ok: false, error: "exists" }); | |
| 103 | }); | |
| 104 | }); |
test/mcp.test.ts +1 −1
| @@ -371,7 +371,7 @@ describe("the forge over MCP", () => { | ||
| 371 | 371 | "contribute", |
| 372 | 372 | "limits", |
| 373 | 373 | "app_env", |
| 374 | "agents", | |
| 374 | "agents", "app_data_import", | |
| 375 | 375 | "teams", |
| 376 | 376 | "cloud_accounts", |
| 377 | 377 | "cloud_account", |
new above
offline