@zaisma/game-server 1.6.0 → 1.8.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.
package/bin/node-parts.js CHANGED
@@ -96,7 +96,8 @@ export function localDatabase(file) {
96
96
  statement.setReadBigInts(true);
97
97
  for (const row of statement.iterate(...values)) {
98
98
  if (rows.length >= DATABASE_LIMITS.maximumRowCount) throw tooManyRows();
99
- rows.push(checkedRow(numbersFrom(row)));
99
+ // node:sqlite gives an INTEGER as a BigInt and a REAL as a number: a REAL as big as 1e20 is no whole number to refuse.
100
+ rows.push(checkedRow(numbersFrom(row), new Set(Object.entries(row).filter(([, columnValue]) => typeof columnValue === "number").map(([column]) => column))));
100
101
  }
101
102
  return rows;
102
103
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zaisma/game-server",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "Run your Zaisma game server: the same rooms on Zaisma or on your own server.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://zaisma.com",
package/src/database.d.ts CHANGED
@@ -76,18 +76,22 @@ export declare function checkPlayerId(playerId: unknown): string;
76
76
  /** What erasePlayer says when a table's foreign key refuses the delete. */
77
77
  export declare function eraseRefused(tableName: string, error: unknown): DatabaseError;
78
78
  export declare const DATABASE_LIMITS: {
79
- /** One statement's SQL. */
79
+ /** One statement's SQL, in characters. */
80
80
  readonly maximumSqlCharacterCount: 100000;
81
+ /** One statement's SQL, in UTF-8 bytes: Cloudflare's SQLite takes 100 KB, so text in other scripts is held to it too. */
82
+ readonly maximumSqlSizeBytes: 100000;
81
83
  /** Values for one statement's ?s. */
82
84
  readonly maximumValueCount: 100;
83
- /** One text or blob value, in a query and in the database. */
85
+ /** One text or blob value a game server or query binds. */
84
86
  readonly maximumValueSizeBytes: number;
87
+ /** One text or blob value in an import, which brings back every row an export wrote: what the database holds in a value. */
88
+ readonly maximumImportValueSizeBytes: number;
85
89
  /** Statements in one batch. */
86
90
  readonly maximumStatementCount: 100;
87
91
  /** Rows one statement may answer; past it, page with LIMIT and OFFSET or a WHERE. */
88
92
  readonly maximumRowCount: 10000;
89
93
  };
90
- export type DatabaseErrorCode = "bad_sql" | "bad_values" | "bad_player_id" | "too_many_rows" | "hosting_paused" | "database_full" | "database_unavailable";
94
+ export type DatabaseErrorCode = "bad_sql" | "bad_values" | "bad_player_id" | "too_many_rows" | "hosting_paused" | "database_full" | "database_unavailable" | "integer_too_large";
91
95
  /** A call the database refused, with a code, what happened and what to do (like the developer API's errors, docs/NAMING.md). */
92
96
  export declare class DatabaseError extends Error {
93
97
  readonly code: DatabaseErrorCode;
@@ -110,12 +114,18 @@ export type DatabaseAnswer<T> = {
110
114
  export declare function readDatabaseAnswer<T>(answer: DatabaseAnswer<T>): T;
111
115
  /** Run work, answering its result or the DatabaseError it threw; any other error is SQLite's (bad_sql). */
112
116
  export declare function answerDatabase<T>(work: () => T): DatabaseAnswer<T>;
117
+ /**
118
+ * The PRAGMAs Cloudflare's SQLite runs; it refuses the rest (user_version, journal_mode…) with SQLITE_AUTH, so both
119
+ * runtimes refuse them. defer_foreign_keys is what an import's part runs first, in its transaction.
120
+ */
121
+ export declare const ALLOWED_PRAGMAS: readonly string[];
113
122
  /**
114
123
  * Check one statement and its values before it runs: one statement only, no transaction control,
115
- * values of the kinds SQLite takes. Throws a DatabaseError; answers the statement to run (without a
116
- * trailing ; or comment, which SQLite in a Durable Object takes as an empty statement) and its values.
124
+ * nothing Cloudflare's SQLite refuses, values of the kinds SQLite takes. Throws a DatabaseError; answers the
125
+ * statement to run (without a trailing ; or comment, which SQLite in a Durable Object takes as an empty
126
+ * statement) and its values. A text or blob value may be up to maximumValueSizeBytes (an import's: more).
117
127
  */
118
- export declare function checkStatement(sql: unknown, values: unknown): {
128
+ export declare function checkStatement(sql: unknown, values: unknown, maximumValueSizeBytes?: number): {
119
129
  sql: string;
120
130
  values: DatabaseValue[];
121
131
  };
@@ -128,9 +138,12 @@ export declare function checkBatch(statements: unknown): {
128
138
  export declare function plainRow(row: Record<string, unknown>): DatabaseRow;
129
139
  /**
130
140
  * A row as a game's query gets it: plainRow, refusing a whole number past 2^53, which JavaScript would
131
- * round (a game would read a different number than the one stored). Dumps and previews keep plainRow.
141
+ * round (a game would read a different number than the one stored): integer_too_large. Dumps and previews keep
142
+ * plainRow. realColumns names the columns that hold REALs, which can be as big (1e20) and are not refused.
132
143
  */
133
- export declare function checkedRow(row: Record<string, unknown>): DatabaseRow;
144
+ export declare function checkedRow(row: Record<string, unknown>, realColumns?: ReadonlySet<string>): DatabaseRow;
145
+ /** A whole number past 2^53 in a result, which JavaScript can't hold exactly. */
146
+ export declare function integerTooLarge(column: string): DatabaseError;
134
147
  export declare function tooManyRows(): DatabaseError;
135
148
  /** One statement in a piece of SQL, as scanStatements finds it. */
136
149
  export interface ScannedStatement {
package/src/database.js CHANGED
@@ -39,18 +39,22 @@ export function eraseRefused(tableName, error) {
39
39
  return new DatabaseError("bad_sql", `Erasing the player from ${tableName} failed, so nothing was erased: SQLite: ${sqliteMessage}`, "Give the foreign key ON DELETE CASCADE, or turn deleted_account_erase_enabled off and erase in your own order.");
40
40
  }
41
41
  export const DATABASE_LIMITS = {
42
- /** One statement's SQL. */
42
+ /** One statement's SQL, in characters. */
43
43
  maximumSqlCharacterCount: 100_000,
44
+ /** One statement's SQL, in UTF-8 bytes: Cloudflare's SQLite takes 100 KB, so text in other scripts is held to it too. */
45
+ maximumSqlSizeBytes: 100_000,
44
46
  /** Values for one statement's ?s. */
45
47
  maximumValueCount: 100,
46
- /** One text or blob value, in a query and in the database. */
48
+ /** One text or blob value a game server or query binds. */
47
49
  maximumValueSizeBytes: 1024 * 1024,
50
+ /** One text or blob value in an import, which brings back every row an export wrote: what the database holds in a value. */
51
+ maximumImportValueSizeBytes: 2 * 1024 * 1024,
48
52
  /** Statements in one batch. */
49
53
  maximumStatementCount: 100,
50
54
  /** Rows one statement may answer; past it, page with LIMIT and OFFSET or a WHERE. */
51
55
  maximumRowCount: 10_000,
52
56
  };
53
- const DATABASE_ERROR_CODES = ["bad_sql", "bad_values", "bad_player_id", "too_many_rows", "hosting_paused", "database_full", "database_unavailable"];
57
+ const DATABASE_ERROR_CODES = ["bad_sql", "bad_values", "bad_player_id", "too_many_rows", "hosting_paused", "database_full", "database_unavailable", "integer_too_large"];
54
58
  /** A call the database refused, with a code, what happened and what to do (like the developer API's errors, docs/NAMING.md). */
55
59
  export class DatabaseError extends Error {
56
60
  code;
@@ -86,16 +90,49 @@ export function answerDatabase(work) {
86
90
  }
87
91
  // Statements that run their own transactions or reach other files. batch() is the transaction.
88
92
  const REFUSED_FIRST_WORDS = new Set(["BEGIN", "COMMIT", "END", "ROLLBACK", "SAVEPOINT", "RELEASE", "ATTACH", "DETACH"]);
93
+ /**
94
+ * The PRAGMAs Cloudflare's SQLite runs; it refuses the rest (user_version, journal_mode…) with SQLITE_AUTH, so both
95
+ * runtimes refuse them. defer_foreign_keys is what an import's part runs first, in its transaction.
96
+ */
97
+ export const ALLOWED_PRAGMAS = ["table_info", "table_xinfo", "table_list", "index_list", "index_info", "index_xinfo", "foreign_key_list", "foreign_key_check", "defer_foreign_keys", "quick_check", "integrity_check", "optimize"];
98
+ /** Functions Cloudflare's SQLite refuses, which would answer in the Node runner. */
99
+ const REFUSED_FUNCTIONS = ["sqlite_version", "sqlite_source_id", "load_extension"];
100
+ /** SQL with its quotes, names and comments blanked, so a word is only found where it's SQL. */
101
+ function sqlWithoutQuotes(sql) {
102
+ return sql.replace(/'(?:[^']|'')*'|"(?:[^"]|"")*"|`[^`]*`|\[[^\]]*\]|--[^\n]*|\/\*[\s\S]*?\*\//g, " ");
103
+ }
104
+ /** What Cloudflare's SQLite refuses and Node's would run: PRAGMAs outside ALLOWED_PRAGMAS, TEMP objects and unauthorized functions. */
105
+ function checkRefusedByCloudflare(sql, leadingWords) {
106
+ const [firstWord = "", secondWord = ""] = leadingWords;
107
+ const bare = sqlWithoutQuotes(sql);
108
+ if (firstWord === "PRAGMA") {
109
+ const pragmaName = /^\s*PRAGMA\s+(?:\w+\s*\.\s*)?(\w+)/i.exec(bare)?.[1]?.toLowerCase() ?? "";
110
+ if (!ALLOWED_PRAGMAS.includes(pragmaName)) {
111
+ throw new DatabaseError("bad_sql", `PRAGMA ${pragmaName || "…"} isn't allowed: Zaisma's database doesn't take it, and neither does your own server's runner, so a query runs the same in both.`, `Allowed: ${ALLOWED_PRAGMAS.map((name) => `PRAGMA ${name}`).join(", ")}. To keep a schema version, use a table of your own, like CREATE TABLE schema_version (version_number INTEGER NOT NULL).`);
112
+ }
113
+ }
114
+ if (firstWord === "CREATE" && (secondWord === "TEMP" || secondWord === "TEMPORARY")) {
115
+ throw new DatabaseError("bad_sql", `CREATE ${secondWord} isn't allowed: the database keeps no temporary tables or objects.`, "Make an ordinary table and drop it when done, or use a WITH query for a result you only need inside one statement.");
116
+ }
117
+ const refusedFunction = REFUSED_FUNCTIONS.find((name) => new RegExp(`\\b${name}\\s*\\(`, "i").test(bare));
118
+ if (refusedFunction)
119
+ throw new DatabaseError("bad_sql", `${refusedFunction}() isn't allowed: Zaisma's database doesn't take it, and neither does your own server's runner.`, "Keep what you need to know about the database (a schema version, say) in a table of your own.");
120
+ }
89
121
  /**
90
122
  * Check one statement and its values before it runs: one statement only, no transaction control,
91
- * values of the kinds SQLite takes. Throws a DatabaseError; answers the statement to run (without a
92
- * trailing ; or comment, which SQLite in a Durable Object takes as an empty statement) and its values.
123
+ * nothing Cloudflare's SQLite refuses, values of the kinds SQLite takes. Throws a DatabaseError; answers the
124
+ * statement to run (without a trailing ; or comment, which SQLite in a Durable Object takes as an empty
125
+ * statement) and its values. A text or blob value may be up to maximumValueSizeBytes (an import's: more).
93
126
  */
94
- export function checkStatement(sql, values) {
127
+ export function checkStatement(sql, values, maximumValueSizeBytes = DATABASE_LIMITS.maximumValueSizeBytes) {
95
128
  if (typeof sql !== "string" || !sql.trim())
96
129
  throw new DatabaseError("bad_sql", "The SQL isn't text.", 'Give one SQL statement as text, like "SELECT * FROM scores WHERE player_id = ?".');
97
130
  if (sql.length > DATABASE_LIMITS.maximumSqlCharacterCount)
98
131
  throw new DatabaseError("bad_sql", `The SQL is ${sql.length} characters.`, `Keep one statement under ${DATABASE_LIMITS.maximumSqlCharacterCount} characters, and pass data as values instead of writing it into the SQL.`);
132
+ // Cloudflare's limit is in bytes: three bytes a character at most, so short statements need no count.
133
+ const sqlSizeBytes = sql.length * 3 <= DATABASE_LIMITS.maximumSqlSizeBytes ? 0 : new TextEncoder().encode(sql).length;
134
+ if (sqlSizeBytes > DATABASE_LIMITS.maximumSqlSizeBytes)
135
+ throw new DatabaseError("bad_sql", `The SQL is ${sqlSizeBytes} bytes.`, `Keep one statement under ${DATABASE_LIMITS.maximumSqlSizeBytes} bytes (UTF-8), and pass data as values instead of writing it into the SQL.`);
99
136
  const statements = scanStatements(sql);
100
137
  if (statements.length === 0)
101
138
  throw new DatabaseError("bad_sql", "The SQL has only comments.", 'Give one SQL statement, like "SELECT * FROM scores WHERE player_id = ?".');
@@ -104,21 +141,22 @@ export function checkStatement(sql, values) {
104
141
  const firstWord = statements[0].leadingWords[0] ?? "";
105
142
  if (REFUSED_FIRST_WORDS.has(firstWord))
106
143
  throw new DatabaseError("bad_sql", `${firstWord} isn't allowed.`, "For a transaction, use a batch (room.database.batch([{ sql, values }, ...]) in a game server, batch_database through the API): its statements all happen or none do.");
144
+ checkRefusedByCloudflare(sql.slice(statements[0].startIndex, statements[0].endIndex), statements[0].leadingWords);
107
145
  const given = values === undefined || values === null ? [] : values;
108
146
  if (!Array.isArray(given))
109
147
  throw new DatabaseError("bad_values", "values isn't a list.", 'Give the values for the ?s as a list, in order, like ["player_8f3k2m", 1200].');
110
148
  if (given.length > DATABASE_LIMITS.maximumValueCount)
111
149
  throw new DatabaseError("bad_values", `There are ${given.length} values.`, `Give up to ${DATABASE_LIMITS.maximumValueCount} values per statement; split big inserts into several statements in one batch.`);
112
- return { sql: sql.slice(0, statements[0].endIndex), values: given.map(checkValue) };
150
+ return { sql: sql.slice(0, statements[0].endIndex), values: given.map((givenValue, i) => checkValue(givenValue, i, maximumValueSizeBytes)) };
113
151
  }
114
152
  /** One value for a ?, the i-th (from 0). */
115
- function checkValue(givenValue, i) {
153
+ function checkValue(givenValue, i, maximumValueSizeBytes) {
116
154
  if (typeof givenValue === "number" && Number.isInteger(givenValue) && !Number.isSafeInteger(givenValue))
117
155
  throw new DatabaseError("bad_values", `Value ${i + 1} is a whole number past 2^53, which JavaScript can't hold exactly.`, "Store it as text, like String(value), and read it back as text.");
118
156
  if (givenValue === null || typeof givenValue === "string" || givenValue instanceof Uint8Array || (typeof givenValue === "number" && Number.isFinite(givenValue))) {
119
157
  const sizeBytes = typeof givenValue === "string" ? new TextEncoder().encode(givenValue).length : givenValue instanceof Uint8Array ? givenValue.byteLength : 0;
120
- if (sizeBytes > DATABASE_LIMITS.maximumValueSizeBytes)
121
- throw new DatabaseError("bad_values", `Value ${i + 1} is ${sizeBytes} bytes.`, `Keep each text or blob under ${DATABASE_LIMITS.maximumValueSizeBytes / 1024 / 1024} MB; store big files somewhere else and keep a link here.`);
158
+ if (sizeBytes > maximumValueSizeBytes)
159
+ throw new DatabaseError("bad_values", `Value ${i + 1} is ${sizeBytes} bytes.`, `Keep each text or blob under ${maximumValueSizeBytes / 1024 / 1024} MB; store big files somewhere else and keep a link here.`);
122
160
  return givenValue;
123
161
  }
124
162
  const kind = typeof givenValue === "boolean" ? "true or false" : typeof givenValue === "number" ? String(givenValue) : Array.isArray(givenValue) ? "a list" : typeof givenValue;
@@ -150,17 +188,21 @@ export function plainRow(row) {
150
188
  }
151
189
  /**
152
190
  * A row as a game's query gets it: plainRow, refusing a whole number past 2^53, which JavaScript would
153
- * round (a game would read a different number than the one stored). Dumps and previews keep plainRow.
191
+ * round (a game would read a different number than the one stored): integer_too_large. Dumps and previews keep
192
+ * plainRow. realColumns names the columns that hold REALs, which can be as big (1e20) and are not refused.
154
193
  */
155
- export function checkedRow(row) {
194
+ export function checkedRow(row, realColumns) {
156
195
  const plain = plainRow(row);
157
196
  for (const [column, columnValue] of Object.entries(plain)) {
158
- if (typeof columnValue === "number" && Number.isInteger(columnValue) && !Number.isSafeInteger(columnValue)) {
159
- throw new DatabaseError("bad_sql", `Column ${column} holds a whole number past 2^53, which JavaScript can't hold exactly.`, `Read it as text with CAST(${column} AS TEXT).`);
160
- }
197
+ if (typeof columnValue === "number" && Number.isInteger(columnValue) && !Number.isSafeInteger(columnValue) && !realColumns?.has(column))
198
+ throw integerTooLarge(column);
161
199
  }
162
200
  return plain;
163
201
  }
202
+ /** A whole number past 2^53 in a result, which JavaScript can't hold exactly. */
203
+ export function integerTooLarge(column) {
204
+ return new DatabaseError("integer_too_large", `Column ${column} holds a whole number past 2^53 (9,007,199,254,740,991), which JavaScript can't hold exactly.`, `Read it as text with CAST(${column} AS TEXT), or store 64-bit IDs as TEXT.`);
205
+ }
164
206
  export function tooManyRows() {
165
207
  return new DatabaseError("too_many_rows", `The statement answered more than ${DATABASE_LIMITS.maximumRowCount} rows.`, "Ask for fewer with LIMIT (and OFFSET or a WHERE on the last row for the next page), or count with SELECT count(*).");
166
208
  }
package/src/dump.d.ts CHANGED
@@ -54,7 +54,10 @@ export interface ImportPlan {
54
54
  * statements, and leaves out what sqlite3 writes around them (the transaction, PRAGMAs, ANALYZE and its
55
55
  * sqlite_stat1 rows). The values in an INSERT become values for ?s, since Zaisma's database takes
56
56
  * statements up to 100 KB and a row of a dump can be bigger; unistr('…'), which sqlite3 writes for text with
57
- * line breaks, becomes the text it stands for. AUTOINCREMENT's counters only ever go up.
57
+ * line breaks, becomes the text it stands for. AUTOINCREMENT's counters only ever go up. The tables are made
58
+ * first, so a row can refer to a table made after it. A search table in sqlite3's own dump (its
59
+ * `INSERT INTO sqlite_schema` under writable_schema, and its shadow tables) becomes its CREATE VIRTUAL TABLE, filled again
60
+ * from its _content rows with their rowids.
58
61
  */
59
62
  export declare function importStatements(dumpSql: string): ImportPlan;
60
63
  /** The start of a statement, for an error. */
package/src/dump.js CHANGED
@@ -53,8 +53,12 @@ export function dumpPart(read, cursor, partSizeBytes = DUMP_LIMITS.exportPartSiz
53
53
  };
54
54
  let tableIndex = 0;
55
55
  let position = null;
56
- if (cursor === null)
56
+ if (cursor === null) {
57
57
  add(DUMP_START);
58
+ // Every table is made before any row goes in, so a row can refer to a table made after it.
59
+ for (const table of tables)
60
+ add(`${table.sql};\n`);
61
+ }
58
62
  else {
59
63
  position = readCursor(cursor);
60
64
  tableIndex = tables.findIndex((table) => table.name === position.table_name);
@@ -63,29 +67,39 @@ export function dumpPart(read, cursor, partSizeBytes = DUMP_LIMITS.exportPartSiz
63
67
  }
64
68
  for (; tableIndex < tables.length; tableIndex++) {
65
69
  const table = tables[tableIndex];
66
- if (!position)
67
- add(`${table.sql};\n`);
68
- position ??= { table_name: table.name, last_rowid_number: table.is_without_rowid ? null : 0, dumped_row_count: 0 };
70
+ position ??= { table_name: table.name, last_rowid_number: null, last_key: null };
69
71
  const columns = select("SELECT name, hidden = 0 AS is_insertable, pk AS primary_key_number FROM pragma_table_xinfo(?) ORDER BY cid", [table.name]);
70
72
  // Generated columns, and a virtual table's hidden ones, can't be inserted: the INSERTs name the rest.
71
73
  const insertedColumns = columns.filter((column) => column.is_insertable === 1);
72
- const insertInto = `INSERT INTO ${quoteName(table.name)}${insertedColumns.length < columns.length ? `(${insertedColumns.map((column) => quoteName(column.name)).join(",")})` : ""} VALUES(`;
74
+ // A search table's rows are known by their rowids, so they go in with them.
75
+ const isVirtual = table.table_kind === "virtual";
76
+ const insertInto = `INSERT INTO ${quoteName(table.name)}${insertedColumns.length < columns.length || isVirtual ? `(${[...(isVirtual ? ["rowid"] : []), ...insertedColumns.map((column) => quoteName(column.name))].join(",")})` : ""} VALUES(`;
73
77
  // quote() writes each value as SQL, the way sqlite3's .dump does: 'text' with '' for a quote, X'…' for bytes.
74
78
  const valuesSql = insertedColumns.map((column, i) => `quote(${quoteName(column.name)}) AS value_${i}`).join(", ");
79
+ // A table with a declared primary key is read in its key order (rowid last, where it has one), so a row the game deletes or
80
+ // replaces on the other side of the cursor can't move it; a table without one, in rowid order.
75
81
  const primaryKey = columns.filter((column) => column.primary_key_number > 0).sort((first, second) => first.primary_key_number - second.primary_key_number);
82
+ const tableName = quoteName(table.name);
83
+ const keyExpressions = primaryKey.length ? [...primaryKey.map((column) => `${tableName}.${quoteName(column.name)}`), ...(table.is_without_rowid ? [] : [`${tableName}.rowid`])] : [];
76
84
  while (true) {
77
- const rows = position.last_rowid_number === null
78
- ? select(`SELECT ${valuesSql} FROM ${quoteName(table.name)} ORDER BY ${primaryKey.map((column) => quoteName(column.name)).join(", ")} LIMIT ${PAGE_ROW_COUNT} OFFSET ?`, [position.dumped_row_count])
79
- : select(`SELECT rowid AS paging_rowid, ${valuesSql} FROM ${quoteName(table.name)} WHERE rowid > ? ORDER BY rowid LIMIT ${PAGE_ROW_COUNT}`, [position.last_rowid_number]);
85
+ let rows;
86
+ if (keyExpressions.length) {
87
+ const keySql = keyExpressions.map((expression, i) => `typeof(${expression}) AS key_type_${i}, CASE typeof(${expression}) WHEN 'text' THEN ${expression} WHEN 'blob' THEN hex(${expression}) WHEN 'null' THEN NULL ELSE quote(${expression}) END AS key_text_${i}`).join(", ");
88
+ const after = keyAfter(keyExpressions, position.last_key);
89
+ rows = select(`SELECT ${keySql}${valuesSql ? `, ${valuesSql}` : ""} FROM ${tableName}${after.sql ? ` WHERE ${after.sql}` : ""} ORDER BY ${keyExpressions.join(", ")} LIMIT ${PAGE_ROW_COUNT}`, after.values);
90
+ }
91
+ else {
92
+ rows = select(`SELECT rowid AS paging_rowid${valuesSql ? `, ${valuesSql}` : ""} FROM ${tableName} WHERE rowid > ? ORDER BY rowid LIMIT ${PAGE_ROW_COUNT}`, [position.last_rowid_number ?? 0]);
93
+ }
80
94
  for (const row of rows) {
81
- const insert = `${insertInto}${insertedColumns.map((_, i) => row[`value_${i}`]).join(",")});\n`;
95
+ const insert = `${insertInto}${[...(isVirtual ? [row.paging_rowid] : []), ...insertedColumns.map((_, i) => row[`value_${i}`])].join(",")});\n`;
82
96
  // The part ends before a row that would take it past partSizeBytes; the next starts with that row.
83
97
  if (hasRows && dumpSizeBytes + utf8SizeBytes(insert) > partSizeBytes)
84
98
  return { dumpSql, nextCursor: writeCursor(position), readRowCount };
85
99
  add(insert);
86
100
  hasRows = true;
87
- if (position.last_rowid_number === null)
88
- position.dumped_row_count++;
101
+ if (keyExpressions.length)
102
+ position.last_key = keyExpressions.map((_, i) => ({ key_type: row[`key_type_${i}`], key_text: row[`key_text_${i}`] }));
89
103
  else
90
104
  position.last_rowid_number = row.paging_rowid;
91
105
  }
@@ -131,26 +145,135 @@ function readCursor(cursor) {
131
145
  catch {
132
146
  throw new DumpCursorError("The cursor isn't one an export answered.");
133
147
  }
134
- const { table_name: tableName, last_rowid_number: lastRowidNumber, dumped_row_count: dumpedRowCount } = (position ?? {});
135
- if (typeof tableName !== "string" || !(lastRowidNumber === null || Number.isInteger(lastRowidNumber)) || !Number.isInteger(dumpedRowCount))
148
+ const { table_name: tableName, last_rowid_number: lastRowidNumber, last_key: lastKey } = (position ?? {});
149
+ const isKey = (key) => {
150
+ const { key_type: keyType, key_text: keyText } = (key ?? {});
151
+ return (keyType === "null" ? keyText === null : (keyType === "integer" || keyType === "real" || keyType === "text" || keyType === "blob") && typeof keyText === "string") && (keyType !== "integer" || /^-?\d+$/.test(keyText)) && (keyType !== "real" || /^-?[0-9.]+(e[+-]?\d+)?$/i.test(keyText)) && (keyType !== "blob" || /^([0-9A-Fa-f]{2})*$/.test(keyText));
152
+ };
153
+ if (typeof tableName !== "string" || !(lastRowidNumber === null || Number.isInteger(lastRowidNumber)) || !(lastKey === null || (Array.isArray(lastKey) && lastKey.length > 0 && lastKey.length <= 64 && lastKey.every(isKey))))
136
154
  throw new DumpCursorError("The cursor isn't one an export answered.");
137
- return { table_name: tableName, last_rowid_number: lastRowidNumber, dumped_row_count: dumpedRowCount };
155
+ return { table_name: tableName, last_rowid_number: lastRowidNumber, last_key: lastKey };
156
+ }
157
+ /**
158
+ * The WHERE for the rows after a key, for ORDER BY the key's columns: one OR per column, each holding the ones before it equal
159
+ * and itself greater (NULLs sort first). The key's values are bound, numbers as text SQLite casts back, so none loses digits.
160
+ */
161
+ function keyAfter(keyExpressions, lastKey) {
162
+ if (!lastKey)
163
+ return { sql: "", values: [] };
164
+ if (lastKey.length !== keyExpressions.length)
165
+ throw new DumpCursorError("The cursor isn't one an export answered.");
166
+ const values = [];
167
+ const operand = (key) => {
168
+ values.push(key.key_type === "blob" ? Uint8Array.from({ length: key.key_text.length / 2 }, (_, byteIndex) => parseInt(key.key_text.slice(byteIndex * 2, byteIndex * 2 + 2), 16)) : key.key_text);
169
+ return key.key_type === "integer" ? "CAST(? AS INTEGER)" : key.key_type === "real" ? "CAST(? AS REAL)" : "?";
170
+ };
171
+ const terms = keyExpressions.map((expression, i) => {
172
+ const conditions = keyExpressions.slice(0, i).map((earlier, earlierIndex) => (lastKey[earlierIndex].key_type === "null" ? `${earlier} IS NULL` : `${earlier} IS ${operand(lastKey[earlierIndex])}`));
173
+ conditions.push(lastKey[i].key_type === "null" ? `${expression} IS NOT NULL` : `${expression} > ${operand(lastKey[i])}`);
174
+ return `(${conditions.join(" AND ")})`;
175
+ });
176
+ return { sql: terms.join(" OR "), values };
138
177
  }
139
178
  const IMPORT_FIX = "Import takes a dump's CREATE and INSERT statements, as sqlite3's .dump or export_database writes them; it only adds. Run other changes with query_database or batch_database.";
179
+ /** The name a statement starts with after `CREATE TABLE [IF NOT EXISTS]` or `INSERT INTO`, however it's quoted. */
180
+ function tableNameAfter(text, introduction) {
181
+ const match = new RegExp(`^${introduction.source}\\s*(?:"((?:[^"]|"")*)"|'((?:[^']|'')*)'|\`([^\`]*)\`|\\[([^\\]]*)\\]|(\\w+))`, "i").exec(text);
182
+ if (!match)
183
+ return null;
184
+ const [, doubleQuoted, singleQuoted, backQuoted, bracketed, bare] = match;
185
+ return doubleQuoted?.replaceAll('""', '"') ?? singleQuoted?.replaceAll("''", "'") ?? backQuoted ?? bracketed ?? bare ?? null;
186
+ }
187
+ /** The shadow tables an FTS5 table makes for itself: sqlite3's .dump writes them as ordinary tables, and a search table makes them again. */
188
+ const FTS5_SHADOW_SUFFIXES = ["data", "idx", "content", "docsize", "config"];
189
+ const SEARCH_TABLE_FIX = "Import takes a search table made with fts5. For others, import the database file instead: npx @zaisma/cli database import <slug> game.sqlite.";
190
+ /** The columns of an fts5(…) table from its CREATE VIRTUAL TABLE: the arguments that aren't options (name=value). */
191
+ function searchColumnNames(createSql) {
192
+ const openIndex = createSql.indexOf("(", createSql.search(/fts5/i));
193
+ const closeIndex = createSql.lastIndexOf(")");
194
+ if (openIndex === -1 || closeIndex <= openIndex)
195
+ return [];
196
+ const argumentTexts = [];
197
+ let current = "";
198
+ let openParenthesisCount = 0;
199
+ let quote = null;
200
+ for (const character of createSql.slice(openIndex + 1, closeIndex)) {
201
+ if (quote) {
202
+ current += character;
203
+ if (character === quote)
204
+ quote = null;
205
+ }
206
+ else if (character === "'" || character === '"' || character === "`") {
207
+ quote = character;
208
+ current += character;
209
+ }
210
+ else if (character === "(") {
211
+ openParenthesisCount++;
212
+ current += character;
213
+ }
214
+ else if (character === ")") {
215
+ openParenthesisCount--;
216
+ current += character;
217
+ }
218
+ else if (character === "," && openParenthesisCount === 0) {
219
+ argumentTexts.push(current);
220
+ current = "";
221
+ }
222
+ else
223
+ current += character;
224
+ }
225
+ argumentTexts.push(current);
226
+ const names = [];
227
+ for (const argument of argumentTexts) {
228
+ const trimmed = argument.trim();
229
+ if (!trimmed || /^[\w.]+\s*=/.test(trimmed))
230
+ continue;
231
+ const first = /^(?:"((?:[^"]|"")*)"|'([^']*)'|`([^`]*)`|\[([^\]]*)\]|(\w+))/.exec(trimmed);
232
+ const name = first?.[1]?.replaceAll('""', '"') ?? first?.[2] ?? first?.[3] ?? first?.[4] ?? first?.[5];
233
+ if (name)
234
+ names.push(name);
235
+ }
236
+ return names;
237
+ }
140
238
  /**
141
239
  * Reads a part of a dump into the statements to run. Import only adds: it takes CREATE and INSERT
142
240
  * statements, and leaves out what sqlite3 writes around them (the transaction, PRAGMAs, ANALYZE and its
143
241
  * sqlite_stat1 rows). The values in an INSERT become values for ?s, since Zaisma's database takes
144
242
  * statements up to 100 KB and a row of a dump can be bigger; unistr('…'), which sqlite3 writes for text with
145
- * line breaks, becomes the text it stands for. AUTOINCREMENT's counters only ever go up.
243
+ * line breaks, becomes the text it stands for. AUTOINCREMENT's counters only ever go up. The tables are made
244
+ * first, so a row can refer to a table made after it. A search table in sqlite3's own dump (its
245
+ * `INSERT INTO sqlite_schema` under writable_schema, and its shadow tables) becomes its CREATE VIRTUAL TABLE, filled again
246
+ * from its _content rows with their rowids.
146
247
  */
147
248
  export function importStatements(dumpSql) {
148
249
  const statements = [];
149
250
  const scanned = scanStatements(dumpSql);
150
251
  let skippedStatementCount = 0;
252
+ const textOf = (scannedStatement) => dumpSql.slice(scannedStatement.startIndex, scannedStatement.endIndex);
253
+ const refuseAt = (statementNumber, text, message, fix = IMPORT_FIX) => new DatabaseError("bad_sql", `Statement ${statementNumber} of the dump (${preview(text)}): ${message}`, fix);
254
+ // sqlite3's .dump creates a virtual table by writing its row in the schema table: those come first, so the shadow tables are known.
255
+ const searchTables = new Map();
256
+ const shadowNames = new Map();
257
+ scanned.forEach((scannedStatement, i) => {
258
+ const [firstWord = "", secondWord = "", thirdWord = ""] = scannedStatement.leadingWords;
259
+ if (!(firstWord === "INSERT" && secondWord === "INTO" && (thirdWord === "SQLITE_SCHEMA" || thirdWord === "SQLITE_MASTER")))
260
+ return;
261
+ const text = textOf(scannedStatement);
262
+ const match = /^INSERT\s+INTO\s+sqlite_(?:schema|master)\s*\([^)]*\)\s*VALUES\s*\(\s*'table'\s*,\s*'((?:[^']|'')*)'\s*,\s*'(?:[^']|'')*'\s*,\s*\d+\s*,\s*'((?:[^']|'')*)'\s*\)$/i.exec(text);
263
+ const createSql = match?.[2]?.replaceAll("''", "'");
264
+ if (!match || !createSql || !/^CREATE\s+VIRTUAL\s+TABLE\s/i.test(createSql))
265
+ throw refuseAt(i + 1, text, "it writes to the schema table, and only a virtual table's can be read from there.");
266
+ if (!/\bUSING\s+fts5\b/i.test(createSql))
267
+ throw refuseAt(i + 1, text, "this virtual table isn't an fts5 search table.", SEARCH_TABLE_FIX);
268
+ const name = match[1].replaceAll("''", "'");
269
+ const table = { name, createSql, columnNames: searchColumnNames(createSql) };
270
+ searchTables.set(i, table);
271
+ for (const suffix of FTS5_SHADOW_SUFFIXES)
272
+ shadowNames.set(`${name}_${suffix}`.toLowerCase(), { ...table, suffix });
273
+ });
151
274
  scanned.forEach((scannedStatement, i) => {
152
275
  const statementNumber = i + 1;
153
- const text = dumpSql.slice(scannedStatement.startIndex, scannedStatement.endIndex);
276
+ const text = textOf(scannedStatement);
154
277
  const [firstWord = "", secondWord = "", thirdWord = ""] = scannedStatement.leadingWords;
155
278
  const words = scannedStatement.leadingWords.join(" ");
156
279
  const isSkipped = ["BEGIN", "COMMIT", "END", "ROLLBACK", "ANALYZE"].includes(firstWord) ||
@@ -162,7 +285,30 @@ export function importStatements(dumpSql) {
162
285
  skippedStatementCount++;
163
286
  return;
164
287
  }
165
- const refuse = (message) => new DatabaseError("bad_sql", `Statement ${statementNumber} of the dump (${preview(text)}): ${message}`, IMPORT_FIX);
288
+ const refuse = (message) => refuseAt(statementNumber, text, message);
289
+ if (firstWord === "INSERT" && secondWord === "INTO" && (thirdWord === "SQLITE_SCHEMA" || thirdWord === "SQLITE_MASTER")) {
290
+ statements.push({ sql: searchTables.get(i).createSql, values: [], statementNumber });
291
+ return;
292
+ }
293
+ if (shadowNames.size) {
294
+ const shadow = shadowNames.get(tableNameAfter(text, firstWord === "CREATE" ? /CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?/ : /INSERT\s+INTO/)?.toLowerCase() ?? "");
295
+ if (shadow && (firstWord === "CREATE" || firstWord === "INSERT")) {
296
+ if (firstWord === "CREATE" || shadow.suffix !== "content") {
297
+ skippedStatementCount++;
298
+ return;
299
+ }
300
+ // The rows of a search table are in its _content, by rowid; they go back in through the table itself.
301
+ const bound = bindLiterals(text, refuse);
302
+ const insertInto = `INSERT INTO ${quoteName(shadow.name)}(${["rowid", ...shadow.columnNames.map(quoteName)].join(",")}) VALUES(`;
303
+ const replaced = bound.sql.replace(/^INSERT\s+INTO\s+(?:"(?:[^"]|"")*"|\w+)\s+VALUES\s*\(/i, insertInto);
304
+ if (replaced === bound.sql)
305
+ throw refuse("a search table's content rows are INSERT INTO \"name_content\" VALUES(…).");
306
+ if (bound.values.length > DATABASE_LIMITS.maximumValueCount)
307
+ throw new DatabaseError("bad_values", `Statement ${statementNumber} of the dump (${preview(text)}) has ${bound.values.length} text and blob values.`, `Give each row its own INSERT, as sqlite3's .dump does: the database takes up to ${DATABASE_LIMITS.maximumValueCount} values a statement.`);
308
+ statements.push({ sql: replaced, values: bound.values, statementNumber });
309
+ return;
310
+ }
311
+ }
166
312
  if (firstWord === "CREATE") {
167
313
  statements.push({ sql: text, values: [], statementNumber });
168
314
  return;
@@ -188,7 +334,9 @@ export function importStatements(dumpSql) {
188
334
  throw new DatabaseError("bad_values", `Statement ${statementNumber} of the dump (${preview(text)}) has ${bound.values.length} text and blob values.`, `Give each row its own INSERT, as sqlite3's .dump does: the database takes up to ${DATABASE_LIMITS.maximumValueCount} values a statement.`);
189
335
  statements.push({ sql: bound.sql, values: bound.values, statementNumber });
190
336
  });
191
- return { statements, statementCount: scanned.length, skippedStatementCount };
337
+ // Tables first, in the order given, then the rest in theirs: a row may refer to a table made after it.
338
+ const isTable = (statement) => /^CREATE\s+(?:VIRTUAL\s+)?TABLE\s/i.test(statement.sql);
339
+ return { statements: [...statements.filter(isTable), ...statements.filter((statement) => !isTable(statement))], statementCount: scanned.length, skippedStatementCount };
192
340
  }
193
341
  /** The start of a statement, for an error. */
194
342
  export function preview(text) {