@zaisma/cli 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/zaisma.mjs +314 -13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zaisma/cli",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Upload and publish browser games on Zaisma from the command line.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://zaisma.com",
package/zaisma.mjs CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
- // The Zaisma upload command. No dependencies; Node 18 or newer.
2
+ // The Zaisma upload command. No dependencies; Node 18 or newer (22.13 or newer for .sqlite files).
3
3
  // Docs: https://github.com/zaisma/zaisma/blob/main/docs/DEVELOPER-API.md#the-upload-command
4
4
 
5
5
  import { createHash } from "node:crypto";
6
6
  import { createReadStream } from "node:fs";
7
- import { open, readdir, readFile, stat } from "node:fs/promises";
8
- import { join, relative, sep } from "node:path";
7
+ import { open, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
8
+ import { extname, join, relative, sep } from "node:path";
9
9
 
10
10
  const API = (process.env.ZAISMA_API || "https://zaisma.com").replace(/\/+$/, "");
11
11
  const KEY = process.env.ZAISMA_KEY || "";
@@ -15,6 +15,10 @@ const JUNK = new Set([".DS_Store", "Thumbs.db", "desktop.ini", "__MACOSX", ".git
15
15
  /** How long upload waits for the scan (200 checks, 3 seconds apart) before it says it's still scanning. ZAISMA_SCAN_CHECK_INTERVAL_MS is for tests. */
16
16
  const SCAN_CHECK_INTERVAL_MS = Number(process.env.ZAISMA_SCAN_CHECK_INTERVAL_MS) || 3000;
17
17
  const SCAN_CHECK_COUNT = 200;
18
+ /** The most SQL database import sends at once (a single bigger statement goes on its own; the API takes up to 5 MB). ZAISMA_DUMP_PART_SIZE_BYTES is for tests. */
19
+ const DUMP_PART_SIZE_BYTES = Number(process.env.ZAISMA_DUMP_PART_SIZE_BYTES) || 4 * 1024 * 1024;
20
+ /** Files database export and import read and write as SQLite databases; any other name is SQL text. */
21
+ const SQLITE_EXTENSIONS = [".sqlite", ".sqlite3", ".db"];
18
22
 
19
23
  /** The choices the API takes, checked before anything is sent (test/node/cli.test.ts holds them to the API's lists). */
20
24
  const VISIBILITIES = ["listed", "unlisted", "private", "draft"];
@@ -41,6 +45,10 @@ const COMMAND_ERROR_CODES = {
41
45
  missing_value: "A flag that takes a value was given none.",
42
46
  takes_no_value: "A flag that takes no value was given one.",
43
47
  not_a_folder: "The folder to upload isn't a folder.",
48
+ not_a_file: "The file of statements, or the dump to import, can't be read.",
49
+ file_exists: "database export won't write over a file that's already there.",
50
+ needs_newer_node: "Reading or writing a .sqlite file needs Node 22.13 or newer.",
51
+ not_a_database: "The .sqlite file to import isn't a SQLite database.",
44
52
  server_unreachable: "Zaisma couldn't be reached: no connection, or a wrong ZAISMA_API.",
45
53
  unexpected_answer: "Zaisma answered without JSON, like a page from a network in between.",
46
54
  };
@@ -106,8 +114,31 @@ const COMMANDS = {
106
114
  arguments: [{ name: "topic_id" }],
107
115
  flags: { text: TEXT, reopen: { hasValue: false, about: "Reopen your topic: something fixed is back" }, "by-me": BY_ME },
108
116
  },
117
+ database: { about: "A game's own database: its tables and indexes, and this month's use against the free limits", arguments: [{ name: "slug" }], flags: {} },
118
+ "database query": {
119
+ about: "Run one SQL statement on a game's database, live; the SQL as an argument or piped in",
120
+ arguments: [{ name: "slug" }, { name: "sql", isOptional: true }],
121
+ flags: { values: { hasValue: true, placeholder: "'[…]'", about: 'The values for the ?s, as a JSON list: text, numbers, null or { "blob_base64": "…" }, like \'["player_8f3k2m", 1200]\'' } },
122
+ },
123
+ "database batch": {
124
+ about: "Run SQL statements on a game's database in one transaction: all of them happen, or none",
125
+ arguments: [{ name: "slug" }, { name: "file", isOptional: true }],
126
+ flags: {},
127
+ },
128
+ "database export": {
129
+ about: "Save a game's whole database: as SQLite's SQL dump (a .sql file, or printed), or as a ready .sqlite file, like the one the Node runner uses",
130
+ arguments: [{ name: "slug" }, { name: "file", isOptional: true }],
131
+ flags: {},
132
+ },
133
+ "database import": {
134
+ about: "Add a SQL dump (a .sql file, or piped in) or a .sqlite file to a game's database, live, in parts of up to 4 MB: each part all or nothing",
135
+ arguments: [{ name: "slug" }, { name: "file", isOptional: true }],
136
+ flags: {},
137
+ },
109
138
  help: { about: "This help; with --json, every command, flag, exit code and error code", arguments: [], flags: {} },
110
139
  };
140
+ /** Commands with a second word, like zaisma forum post. */
141
+ const SUBCOMMANDS = { forum: ["post", "message", "reply"], database: ["query", "batch", "export", "import"] };
111
142
  /** Flags every command takes. */
112
143
  const GLOBAL_FLAGS = {
113
144
  json: { hasValue: false, about: "Print the API's answer as JSON, as-is (on failure its { error }), and nothing else" },
@@ -178,9 +209,9 @@ function parseCommandLine(argv) {
178
209
  }
179
210
  isJson = globalFlags.json;
180
211
  if (!words.length || words[0] === "help") return { command: "help", values: {}, flags: {}, globalFlags, rest: words.slice(1) };
181
- const isForumSubcommand = words[0] === "forum" && ["post", "message", "reply"].includes(words[1]);
182
- const command = isForumSubcommand ? `forum ${words[1]}` : words[0];
183
- const rest = words.slice(isForumSubcommand ? 2 : 1);
212
+ const isSubcommand = Boolean(SUBCOMMANDS[words[0]]?.includes(words[1]));
213
+ const command = isSubcommand ? `${words[0]} ${words[1]}` : words[0];
214
+ const rest = words.slice(isSubcommand ? 2 : 1);
184
215
  if (RENAMED_COMMANDS[command]) {
185
216
  const renamed = RENAMED_COMMANDS[command];
186
217
  refuse("renamed_command", { message: `zaisma ${command} is now zaisma ${renamed.command}.`, fix: renamed.fix(rest) });
@@ -449,12 +480,7 @@ function printTopic(topic) {
449
480
 
450
481
  /** Text from --text, or piped in. */
451
482
  async function readText(command, flags) {
452
- let text = flags.text ?? "";
453
- if (!text && !process.stdin.isTTY) {
454
- const chunks = [];
455
- for await (const chunk of process.stdin) chunks.push(chunk);
456
- text = Buffer.concat(chunks).toString("utf8").trim();
457
- }
483
+ const text = flags.text ?? (await readInput());
458
484
  if (!text) refuse("missing_field", { message: `zaisma ${command} needs its text.`, field: "--text", fix: `Give --text "…", or pipe the text in. Run it as: ${usage(command)}` });
459
485
  return text;
460
486
  }
@@ -530,6 +556,280 @@ async function forum(command, values, flags) {
530
556
  }
531
557
  }
532
558
 
559
+ /** Everything piped in, as text; "" when nothing is. */
560
+ async function readInput() {
561
+ if (process.stdin.isTTY) return "";
562
+ const chunks = [];
563
+ for await (const chunk of process.stdin) chunks.push(chunk);
564
+ return Buffer.concat(chunks).toString("utf8").trim();
565
+ }
566
+
567
+ /** A game's own database (GET and POST /games/<slug>/database…), as the MCP's get_database, query_database, batch_database, export_database and import_database. */
568
+ async function database(command, values, flags) {
569
+ if (command === "database export") return exportDatabase(values);
570
+ if (command === "database import") return importDatabase(values);
571
+ if (command === "database") {
572
+ const answer = await api("GET", `/games/${values.slug}/database`);
573
+ if (isJson) return printJson(answer);
574
+ const use = answer.database_use_this_month;
575
+ if (!answer.tables.length) console.log("No tables yet. Make one from your game server, or with: zaisma database query <slug> \"CREATE TABLE …\"");
576
+ for (const table of answer.tables) console.log(`${table.sql};`);
577
+ for (const index of answer.indexes) console.log(`${index.sql};`);
578
+ const percent = (used, free) => `${Math.floor((used / free) * 100)}%`;
579
+ console.log(
580
+ `\nThis month: ${use.read_row_count.toLocaleString("en-US")} rows read (${percent(use.read_row_count, use.free_read_row_count)} of the free ${use.free_read_row_count.toLocaleString("en-US")}), ` +
581
+ `${use.written_row_count.toLocaleString("en-US")} written (${percent(use.written_row_count, use.free_written_row_count)} of ${use.free_written_row_count.toLocaleString("en-US")}), ` +
582
+ `${(use.storage_size_bytes / 1024 / 1024).toFixed(1)} MB stored (${percent(use.storage_size_bytes, use.free_storage_size_bytes)} of ${use.free_storage_size_bytes / 1024 / 1024} MB). Resets ${use.reset_date}.`,
583
+ );
584
+ return;
585
+ }
586
+ if (command === "database query") {
587
+ const sql = values.sql ?? (await readInput());
588
+ if (!sql) refuse("missing_field", { message: "zaisma database query needs the SQL.", field: "sql", fix: `Give it in quotes, or pipe it in. Run it as: ${usage(command)}` });
589
+ let statementValues;
590
+ if (flags.values !== undefined) {
591
+ try {
592
+ statementValues = JSON.parse(flags.values);
593
+ } catch {}
594
+ if (!Array.isArray(statementValues)) refuse("bad_values", { message: "--values isn't a JSON list.", field: "--values", fix: `Give the values as a JSON list in single quotes, like --values '["player_8f3k2m", 1200]'.` });
595
+ }
596
+ const answer = await api("POST", `/games/${values.slug}/database/query`, { sql, ...(statementValues ? { values: statementValues } : {}) });
597
+ if (isJson) return printJson(answer);
598
+ printRows(answer);
599
+ return;
600
+ }
601
+ const text = values.file ? await readFile(values.file, "utf8").catch(() => refuse("not_a_file", { message: `Couldn't read ${values.file}.`, field: "file", fix: "Give the path of a JSON file of statements, or pipe them in." })) : await readInput();
602
+ let statements;
603
+ try {
604
+ statements = JSON.parse(text);
605
+ } catch {}
606
+ if (!Array.isArray(statements)) refuse("bad_sql", { message: `${values.file ?? "What was piped in"} isn't a JSON list of statements.`, field: "file", fix: 'Give a JSON list like [{ "sql": "UPDATE scores SET best_score = 0 WHERE player_id = ?", "values": ["player_8f3k2m"] }, { "sql": "DELETE FROM matches" }].' });
607
+ const answer = await api("POST", `/games/${values.slug}/database/batch`, { statements });
608
+ if (isJson) return printJson(answer);
609
+ answer.statement_results.forEach((statementResult, i) => {
610
+ if (i) console.log("");
611
+ console.log(`Statement ${i + 1}:`);
612
+ printRows(statementResult);
613
+ });
614
+ }
615
+
616
+ const isSqliteFile = (file) => SQLITE_EXTENSIONS.includes(extname(file).toLowerCase());
617
+
618
+ /** Node's own SQLite (node:sqlite), loaded only for .sqlite files. */
619
+ async function nodeSqlite(file) {
620
+ try {
621
+ return await import("node:sqlite");
622
+ } catch {
623
+ refuse("needs_newer_node", { message: `Reading or writing ${file} needs Node's SQLite, which this Node (${process.version}) doesn't have.`, field: "file", fix: "Use Node 22.13 or newer, or give a .sql file to save or load SQL text instead." }, EXIT_CODES.failed);
624
+ }
625
+ }
626
+
627
+ /** database export: every part of the dump (GET …/database/export), into a .sqlite file, a .sql file or stdout. */
628
+ async function exportDatabase({ slug, file }) {
629
+ if (file && (await stat(file).catch(() => null))) refuse("file_exists", { message: `${file} is already there.`, field: "file", fix: "Give a new file name, or delete that file first." });
630
+ const temporaryFile = file ? `${file}.${process.pid}.partial` : null;
631
+ let sqlite = null;
632
+ if (file && isSqliteFile(file)) {
633
+ const { DatabaseSync } = await nodeSqlite(file);
634
+ sqlite = new DatabaseSync(temporaryFile);
635
+ }
636
+ const dumpParts = [];
637
+ let partCount = 0;
638
+ let readRowCount = 0;
639
+ try {
640
+ let cursor = null;
641
+ do {
642
+ const part = await api("GET", `/games/${slug}/database/export${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`);
643
+ partCount++;
644
+ readRowCount += part.read_row_count;
645
+ // A .sqlite file loads each part as it comes: the dump's own BEGIN and COMMIT make it one transaction.
646
+ if (sqlite) sqlite.exec(part.dump_sql);
647
+ else dumpParts.push(part.dump_sql);
648
+ cursor = part.next_cursor;
649
+ if (cursor && file) showProgress(`${partCount} parts saved`);
650
+ } while (cursor);
651
+ if (sqlite) sqlite.close();
652
+ else if (file) await writeFile(temporaryFile, dumpParts.join(""));
653
+ if (file) await rename(temporaryFile, file);
654
+ } catch (error) {
655
+ try {
656
+ sqlite?.close();
657
+ } catch {}
658
+ if (temporaryFile) await rm(temporaryFile, { force: true });
659
+ throw error;
660
+ }
661
+ if (partCount > 1 && file && !isJson) process.stdout.write("\n");
662
+ if (isJson) return printJson({ dump_sql: file ? null : dumpParts.join(""), file: file ?? null, part_count: partCount, read_row_count: readRowCount });
663
+ if (!file) return process.stdout.write(dumpParts.join(""));
664
+ console.log(`Saved ${slug}'s database to ${file} (${partCount} part${partCount === 1 ? "" : "s"}, ${readRowCount.toLocaleString("en-US")} rows read).${sqlite ? ` Run your game server on it with: node bin/node.js <server> --game ${slug} --database ${file}` : ""}`);
665
+ }
666
+
667
+ /** database import: a dump, from a .sql file, stdin or a .sqlite file, sent in parts (POST …/database/import). */
668
+ async function importDatabase({ slug, file }) {
669
+ let statements;
670
+ if (file && isSqliteFile(file)) {
671
+ if (!(await stat(file).catch(() => null))?.isFile()) refuse("not_a_file", { message: `Couldn't read ${file}.`, field: "file", fix: "Give the path of a .sqlite file, a .sql dump, or pipe the dump in." });
672
+ const { DatabaseSync } = await nodeSqlite(file);
673
+ let sqlite;
674
+ try {
675
+ sqlite = new DatabaseSync(file, { readOnly: true });
676
+ sqlite.prepare("SELECT count(*) FROM sqlite_schema").get();
677
+ } catch (error) {
678
+ refuse("not_a_database", { message: `${file} isn't a SQLite database: ${error.message}.`, field: "file", fix: "Give a SQLite database file, like the Node runner's zaisma-database.sqlite, or a .sql dump." }, EXIT_CODES.failed);
679
+ }
680
+ statements = sqliteDumpStatements(sqlite);
681
+ } else {
682
+ const text = file ? await readFile(file, "utf8").catch(() => refuse("not_a_file", { message: `Couldn't read ${file}.`, field: "file", fix: "Give the path of a .sql dump or a .sqlite file, or pipe the dump in." })) : await readInput();
683
+ if (!text.trim()) refuse("missing_field", { message: "zaisma database import needs a dump.", field: "file", fix: `Give a .sql or .sqlite file, or pipe the dump in. Run it as: ${usage("database import")}` });
684
+ statements = splitDump(text);
685
+ }
686
+ const totals = { part_count: 0, statement_count: 0, skipped_statement_count: 0, read_row_count: 0, written_row_count: 0 };
687
+ const send = async (dumpSql) => {
688
+ let answer;
689
+ try {
690
+ answer = await api("POST", `/games/${slug}/database/import`, { dump_sql: dumpSql });
691
+ } catch (error) {
692
+ if (error instanceof CommandError && totals.part_count) {
693
+ error.error.message = `${error.error.message} (Part ${totals.part_count + 1} was undone; the ${totals.part_count} before it, ${totals.statement_count} statements, went in.)`;
694
+ }
695
+ throw error;
696
+ }
697
+ totals.part_count++;
698
+ for (const key of ["statement_count", "skipped_statement_count", "read_row_count", "written_row_count"]) totals[key] += answer[key];
699
+ showProgress(`${totals.part_count} part${totals.part_count === 1 ? "" : "s"} in`);
700
+ };
701
+ let part = "";
702
+ let partSizeBytes = 0;
703
+ for (const statement of statements) {
704
+ const statementSizeBytes = Buffer.byteLength(statement);
705
+ if (part && partSizeBytes + statementSizeBytes > DUMP_PART_SIZE_BYTES) {
706
+ await send(part);
707
+ part = "";
708
+ partSizeBytes = 0;
709
+ }
710
+ part += statement;
711
+ partSizeBytes += statementSizeBytes;
712
+ }
713
+ if (part.trim()) await send(part);
714
+ if (!isJson) process.stdout.write(totals.part_count ? "\n" : "");
715
+ if (isJson) return printJson(totals);
716
+ console.log(`Imported into ${slug}'s database: ${totals.statement_count - totals.skipped_statement_count} statements in ${totals.part_count} part${totals.part_count === 1 ? "" : "s"}, ${totals.written_row_count.toLocaleString("en-US")} rows written.`);
717
+ }
718
+
719
+ /**
720
+ * A SQL dump cut into its statements, each with the ; that ends it, reading past quotes and comments and
721
+ * through a trigger's BEGIN … END, the same way Zaisma reads one (scanStatements in packages/game-server/src/database.ts).
722
+ */
723
+ function splitDump(text) {
724
+ const pieces = [];
725
+ let pieceStartIndex = 0;
726
+ let leadingWords = [];
727
+ let hasOnlyWords = true;
728
+ let hasTokens = false;
729
+ let lastWord = "";
730
+ let sinceSemicolonTokenCount = 0;
731
+ // A ; ends a trigger only after an END that comes straight after a ;.
732
+ const isTriggerBody = () => leadingWords[0] === "CREATE" && (leadingWords[1] === "TRIGGER" || ((leadingWords[1] === "TEMP" || leadingWords[1] === "TEMPORARY") && leadingWords[2] === "TRIGGER")) && !(lastWord === "END" && sinceSemicolonTokenCount === 1);
733
+ const token = (word) => {
734
+ hasTokens = true;
735
+ if (word === null) hasOnlyWords = false;
736
+ else if (hasOnlyWords && leadingWords.length < 6) leadingWords.push(word);
737
+ lastWord = word ?? "";
738
+ sinceSemicolonTokenCount++;
739
+ };
740
+ let i = 0;
741
+ while (i < text.length) {
742
+ const character = text[i];
743
+ if (character === "-" && text[i + 1] === "-") {
744
+ const endIndex = text.indexOf("\n", i);
745
+ i = endIndex === -1 ? text.length : endIndex + 1;
746
+ } else if (character === "/" && text[i + 1] === "*") {
747
+ const endIndex = text.indexOf("*/", i + 2);
748
+ i = endIndex === -1 ? text.length : endIndex + 2;
749
+ } else if (character === "'" || character === '"' || character === "`" || character === "[") {
750
+ const close = character === "[" ? "]" : character;
751
+ let endIndex = i + 1;
752
+ while (endIndex < text.length && !(text[endIndex] === close && text[endIndex + 1] !== close)) endIndex += text[endIndex] === close ? 2 : 1;
753
+ token(null);
754
+ i = endIndex + 1;
755
+ } else if (character === ";") {
756
+ if (hasTokens && !isTriggerBody()) {
757
+ pieces.push(text.slice(pieceStartIndex, i + 1));
758
+ pieceStartIndex = i + 1;
759
+ leadingWords = [];
760
+ hasOnlyWords = true;
761
+ hasTokens = false;
762
+ } else if (hasTokens) token(null);
763
+ lastWord = "";
764
+ sinceSemicolonTokenCount = 0;
765
+ i++;
766
+ } else if (/\s/.test(character)) {
767
+ i++;
768
+ } else if (/[A-Za-z0-9_$]/.test(character)) {
769
+ let endIndex = i + 1;
770
+ while (endIndex < text.length && /[A-Za-z0-9_$]/.test(text[endIndex])) endIndex++;
771
+ token(text.slice(i, endIndex).toUpperCase());
772
+ i = endIndex;
773
+ } else {
774
+ token(null);
775
+ i++;
776
+ }
777
+ }
778
+ // What's after the last ; goes with it: a last statement without one, or the line break that ends the file.
779
+ const rest = text.slice(pieceStartIndex);
780
+ if (rest.trim() || !pieces.length) pieces.push(rest);
781
+ else pieces[pieces.length - 1] += rest;
782
+ return pieces;
783
+ }
784
+
785
+ const quoteName = (name) => `"${name.replaceAll('"', '""')}"`;
786
+
787
+ /**
788
+ * A .sqlite file's dump, statement by statement: the same SQL Zaisma's export writes (dumpPart in
789
+ * packages/game-server/src/dump.ts), read in one go. test/node/dump.test.ts holds the two to each other.
790
+ */
791
+ function* sqliteDumpStatements(sqlite) {
792
+ const entries = sqlite
793
+ .prepare(
794
+ "SELECT entry.type AS type, entry.name AS name, entry.tbl_name AS table_name, entry.sql AS sql, listed.type AS table_kind, listed.wr AS is_without_rowid " +
795
+ "FROM sqlite_schema AS entry LEFT JOIN pragma_table_list AS listed ON listed.schema = 'main' AND listed.name = entry.name " +
796
+ "WHERE entry.sql IS NOT NULL AND substr(entry.tbl_name, 1, 4) <> '_cf_' ORDER BY entry.rowid",
797
+ )
798
+ .all();
799
+ yield "PRAGMA foreign_keys=OFF;\nBEGIN TRANSACTION;\n";
800
+ for (const table of entries.filter((entry) => entry.type === "table" && (entry.table_kind === "table" || entry.table_kind === "virtual") && !entry.name.startsWith("sqlite_"))) {
801
+ yield `${table.sql};\n`;
802
+ const columns = sqlite.prepare("SELECT name, hidden = 0 AS is_insertable, pk AS primary_key_number FROM pragma_table_xinfo(?) ORDER BY cid").all(table.name);
803
+ const insertedColumns = columns.filter((column) => column.is_insertable === 1);
804
+ const insertInto = `INSERT INTO ${quoteName(table.name)}${insertedColumns.length < columns.length ? `(${insertedColumns.map((column) => quoteName(column.name)).join(",")})` : ""} VALUES(`;
805
+ const valuesSql = insertedColumns.map((column, i) => `quote(${quoteName(column.name)}) AS value_${i}`).join(", ");
806
+ const primaryKey = columns.filter((column) => column.primary_key_number > 0).sort((first, second) => first.primary_key_number - second.primary_key_number);
807
+ const order = table.is_without_rowid ? primaryKey.map((column) => quoteName(column.name)).join(", ") : "rowid";
808
+ for (const row of sqlite.prepare(`SELECT ${valuesSql} FROM ${quoteName(table.name)} ORDER BY ${order}`).iterate()) yield `${insertInto}${insertedColumns.map((_, i) => row[`value_${i}`]).join(",")});\n`;
809
+ }
810
+ if (entries.some((entry) => entry.name === "sqlite_sequence")) {
811
+ yield "DELETE FROM sqlite_sequence;\n";
812
+ for (const counter of sqlite.prepare("SELECT quote(name) AS name_sql, quote(seq) AS counter_sql FROM sqlite_sequence").all()) yield `INSERT INTO sqlite_sequence VALUES(${counter.name_sql},${counter.counter_sql});\n`;
813
+ }
814
+ for (const entry of entries) {
815
+ if ((entry.type === "view" || entry.type === "index" || entry.type === "trigger") && !entry.table_name.startsWith("sqlite_")) yield `${entry.sql};\n`;
816
+ }
817
+ yield "COMMIT;\n";
818
+ }
819
+
820
+ /** A statement's rows as a table, then what it read and wrote. Blobs show their size. */
821
+ function printRows({ rows, read_row_count: readRowCount, written_row_count: writtenRowCount }) {
822
+ const shown = (cell) => (cell === null ? "NULL" : typeof cell === "object" ? `<blob, ${Math.floor((cell.blob_base64.replace(/=+$/, "").length * 3) / 4)} bytes>` : String(cell));
823
+ if (rows.length) {
824
+ const columns = Object.keys(rows[0]);
825
+ const widths = columns.map((column) => Math.min(40, Math.max(column.length, ...rows.map((row) => shown(row[column]).length))));
826
+ const line = (cells) => cells.map((cell, i) => (cell.length > widths[i] ? `${cell.slice(0, widths[i] - 1)}…` : cell.padEnd(widths[i]))).join(" ").trimEnd();
827
+ console.log(line(columns));
828
+ for (const row of rows) console.log(line(columns.map((column) => shown(row[column]))));
829
+ }
830
+ console.log(`${rows.length} row${rows.length === 1 ? "" : "s"}. Read ${readRowCount}, wrote ${writtenRowCount}.`);
831
+ }
832
+
533
833
  /** Runs the command and answers with how it ended (EXIT_CODES); a failure throws a CommandError. */
534
834
  async function run({ command, values, flags, globalFlags, rest }) {
535
835
  if (command === "help" || globalFlags.help) {
@@ -591,7 +891,8 @@ async function run({ command, values, flags, globalFlags, rest }) {
591
891
  console.log(`${answer.game.title} is now ${answer.game.visibility}.`);
592
892
  return EXIT_CODES.done;
593
893
  }
594
- await forum(command, values, flags);
894
+ if (command === "database" || command.startsWith("database ")) await database(command, values, flags);
895
+ else await forum(command, values, flags);
595
896
  return EXIT_CODES.done;
596
897
  }
597
898