@xgamesfun/xcore 0.1.0 → 0.2.1

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 (72) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +183 -84
  3. package/bin/xcore.mjs +13 -8
  4. package/commands/add.mjs +11 -8
  5. package/commands/adopt.mjs +9 -7
  6. package/commands/backend.mjs +2 -2
  7. package/commands/check.mjs +4 -4
  8. package/commands/db.mjs +218 -0
  9. package/commands/dev.mjs +8 -6
  10. package/commands/domain.mjs +8 -8
  11. package/commands/embed.mjs +3 -3
  12. package/commands/files.mjs +106 -0
  13. package/commands/login.mjs +9 -17
  14. package/commands/logout.mjs +15 -4
  15. package/commands/loot.mjs +2 -2
  16. package/commands/plan.mjs +22 -19
  17. package/commands/publish.mjs +54 -21
  18. package/commands/secret.mjs +6 -6
  19. package/docs/SERVER.md +218 -18
  20. package/lib/add.mjs +265 -40
  21. package/lib/browser.mjs +11 -2
  22. package/lib/check.mjs +10 -9
  23. package/lib/cli.mjs +24 -9
  24. package/lib/client/gamesfun.js +242 -122
  25. package/lib/client/godot/gamesfun.gd +17 -11
  26. package/lib/client/godot/gamesfun_peer.gd +2 -2
  27. package/lib/client/server/0001_init.sql +10 -0
  28. package/lib/client/server/database.js +271 -109
  29. package/lib/client/server/gf-db.js +55 -0
  30. package/lib/client/server/gf-docs.js +56 -0
  31. package/lib/client/server/rooms.js +16 -9
  32. package/lib/client/server/rpc.js +7 -7
  33. package/lib/client/server/stock.js +37 -0
  34. package/lib/client/server/vouchers.js +127 -46
  35. package/lib/client/unity/GamesFun.cs +15 -12
  36. package/lib/client/unity/GamesFun.jslib +1 -1
  37. package/lib/config-dir.mjs +1 -1
  38. package/lib/credentials.mjs +10 -5
  39. package/lib/db.mjs +355 -0
  40. package/lib/dev/d1.mjs +486 -0
  41. package/lib/dev/files.mjs +247 -0
  42. package/lib/dev/gf-local.js +69 -3
  43. package/lib/dev/index.mjs +68 -20
  44. package/lib/dev/limits.mjs +2 -2
  45. package/lib/dev/platform.mjs +111 -12
  46. package/lib/dev/server.mjs +54 -5
  47. package/lib/dev/tools.mjs +71 -18
  48. package/lib/files.mjs +208 -0
  49. package/lib/open.mjs +1 -1
  50. package/lib/play-policy.mjs +14 -5
  51. package/lib/publish/adopt.mjs +73 -33
  52. package/lib/publish/client.mjs +130 -20
  53. package/lib/publish/gate.mjs +4 -2
  54. package/lib/publish/hosting.mjs +5 -5
  55. package/lib/publish/index.mjs +162 -40
  56. package/lib/publish/oidc.mjs +26 -5
  57. package/lib/publish/page.mjs +1 -1
  58. package/lib/publish/plans.mjs +19 -12
  59. package/lib/publish/scan.mjs +78 -102
  60. package/lib/publish/secrets.mjs +4 -5
  61. package/lib/publish/site.mjs +9 -10
  62. package/lib/publish/tree.mjs +8 -8
  63. package/lib/secret-patterns.mjs +53 -16
  64. package/lib/secret-store.mjs +2 -2
  65. package/lib/server/bundle.mjs +113 -13
  66. package/lib/server/contract.mjs +440 -16
  67. package/lib/server/data-calls.mjs +136 -0
  68. package/lib/server/secrets.mjs +9 -7
  69. package/lib/solana-rpc.mjs +5 -5
  70. package/lib/state-folder.mjs +23 -0
  71. package/package.json +4 -4
  72. package/skills/bring-your-own-game/SKILL.md +10 -9
package/lib/dev/d1.mjs ADDED
@@ -0,0 +1,486 @@
1
+ // The game's database on this machine (gamesfun.json → database; xgames.fun's STORAGE.md §8.2): a local D1, run by
2
+ // Miniflare as `xcore dev` runs the game's server, kept in <game>/.gamesfun/dev/d1/ (a hidden folder: never published),
3
+ // with the same migrations xgames.fun applies, in name order, each once. As on xgames.fun, the database itself records
4
+ // what it has applied, in its table _gf_migrations (name, sha256, applied_at in ms), written in the same batch as each
5
+ // migration's statements, as xgames.fun writes it: the row as started (applied_at = minus the time the batch began), the
6
+ // file's statements, the row finished (applied_at = that time). A negative applied_at is a file left partly applied:
7
+ // refused, with how to undo it, never taken as applied nor run again over itself. A migration that changed after
8
+ // it was applied here is refused, as xgames.fun refuses it (409 migration-changed): the database is left as it was and
9
+ // the server does not start until the migration is put back. `xcore db` (../db.mjs) reads and writes the same database.
10
+ import { existsSync } from 'node:fs';
11
+ import { mkdir, readFile, rm } from 'node:fs/promises';
12
+ import path from 'node:path';
13
+ import {
14
+ COMPATIBILITY_DATE,
15
+ emptyMigration,
16
+ migrationStatementsProblem,
17
+ migrationsChanged,
18
+ } from '../server/contract.mjs';
19
+ import { stateFolder } from '../state-folder.mjs';
20
+
21
+ /** Where the local database lives. */
22
+ export const devFolder = dir => path.join(dir, '.gamesfun', 'dev');
23
+ export const d1Folder = dir => path.join(devFolder(dir), 'd1');
24
+ /** The record an xcore before this one kept beside the database, read once into _gf_migrations and then removed. */
25
+ const oldAppliedFile = dir => path.join(d1Folder(dir), 'gf-migrations.json');
26
+ /** The local database's id in Miniflare (one a game folder). */
27
+ export const LOCAL_D1_ID = 'gf-local-database';
28
+
29
+ /** The table in the game's database that records the migrations applied to it (the same name and shape live). */
30
+ export const MIGRATIONS_TABLE = '_gf_migrations';
31
+ /** xgames.fun's progress of a migration batch (§8.10): there while one runs, or after one failed part-way. */
32
+ export const STEPS_TABLE = '_gf_migration_steps';
33
+ const MIGRATIONS_DDL = `CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (name TEXT PRIMARY KEY, sha256 TEXT NOT NULL, applied_at INTEGER NOT NULL)`;
34
+ const RECORD = `INSERT INTO ${MIGRATIONS_TABLE} (name, sha256, applied_at) VALUES (?, ?, ?)`;
35
+ const FINISHED = `UPDATE ${MIGRATIONS_TABLE} SET applied_at = ? WHERE name = ?`;
36
+
37
+ /** A migration whose batch began and never finished (its row's applied_at is negative): what happened, and the way out. */
38
+ export function partlyApplied(name, startedAt) {
39
+ const began = Number.isFinite(startedAt) && startedAt > 0 ? new Date(startedAt).toISOString() : null;
40
+ return `${name} was partly applied: it failed part-way${began ? ` (it began at ${began})` : ''}, and the database kept the statements before the one that failed. It is not run again over itself. Undo or finish it by hand (xcore db query "…" --write), then delete its row (xcore db query "DELETE FROM ${MIGRATIONS_TABLE} WHERE name = '${name}'" --write); or, on this machine, delete .gamesfun/dev/d1/ to start the local database afresh. Then fix ${name}`;
41
+ }
42
+
43
+ /**
44
+ * The tables that are the database's own machinery, not the game's: SQLite's, D1's, the migrations' record and
45
+ * xgames.fun's progress of a migration batch.
46
+ */
47
+ export const internalTable = name =>
48
+ /^(sqlite_|_cf_|d1_)/i.test(String(name)) || name === MIGRATIONS_TABLE || name === STEPS_TABLE;
49
+
50
+ /**
51
+ * The migrations recorded in `db` (a D1Database), in name order, the order they went in: [{name, sha256, at}] (`at` in
52
+ * milliseconds; negative: started at -at and never finished). A database with no record yet has applied none.
53
+ */
54
+ export async function readApplied(db) {
55
+ await db.prepare(MIGRATIONS_DDL).run();
56
+ const { results } = await db.prepare(`SELECT name, sha256, applied_at FROM ${MIGRATIONS_TABLE} ORDER BY name`).all();
57
+ return (results ?? []).map(r => ({ name: r.name, sha256: r.sha256, at: Number(r.applied_at) }));
58
+ }
59
+
60
+ /** An xcore before this one kept the record in a file beside the database: it goes into the table, once. */
61
+ async function adoptOldRecord(dir, db) {
62
+ const file = dir ? oldAppliedFile(dir) : null;
63
+ if (!file || !existsSync(file)) return;
64
+ let old = [];
65
+ try {
66
+ const v = JSON.parse(await readFile(file, 'utf8'));
67
+ old = Array.isArray(v) ? v.filter(m => typeof m?.name === 'string' && typeof m?.sha256 === 'string') : [];
68
+ } catch {
69
+ /* unreadable: nothing to carry over */
70
+ }
71
+ if (old.length && !(await readApplied(db)).length)
72
+ await db.batch(old.map(m => db.prepare(RECORD).bind(m.name, m.sha256, Date.parse(m.at) || Date.now())));
73
+ await rm(file, { force: true });
74
+ }
75
+
76
+ /**
77
+ * SQL text as its statements, each without its closing `;`. Quotes, comments and a trigger's BEGIN … END (whose
78
+ * statements end in `;` too) are read as SQLite reads them.
79
+ */
80
+ export function splitSql(text) {
81
+ const out = [];
82
+ let start = 0,
83
+ depth = 0,
84
+ i = 0;
85
+ const TRIGGER = /^(?:\s|--[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)*CREATE\s+(?:TEMP\s+|TEMPORARY\s+)?TRIGGER\b/i;
86
+ let trigger = TRIGGER.test(text.slice(0, 4096));
87
+ const word = () => /^[A-Za-z_]+/.exec(text.slice(i, i + 64))?.[0]?.toUpperCase() ?? '';
88
+ const push = end => {
89
+ const s = text
90
+ .slice(start, end)
91
+ .replace(/^(\s|--[^\n]*(\n|$)|\/\*[\s\S]*?\*\/)+/, '')
92
+ .trim();
93
+ if (s) out.push(s);
94
+ };
95
+ while (i < text.length) {
96
+ const c = text[i];
97
+ if (c === '-' && text[i + 1] === '-') {
98
+ const nl = text.indexOf('\n', i);
99
+ i = nl < 0 ? text.length : nl + 1;
100
+ } else if (c === '/' && text[i + 1] === '*') {
101
+ const end = text.indexOf('*/', i + 2);
102
+ i = end < 0 ? text.length : end + 2;
103
+ } else if (c === "'" || c === '"' || c === '`' || c === '[') {
104
+ const close = c === '[' ? ']' : c;
105
+ i++;
106
+ while (i < text.length) {
107
+ if (text[i] === close && text[i + 1] === close && c !== '[') i += 2;
108
+ else if (text[i] === close) break;
109
+ else i++;
110
+ }
111
+ i++;
112
+ } else if (/[A-Za-z_]/.test(c) && !/[A-Za-z0-9_$]/.test(text[i - 1] ?? '')) {
113
+ const w = word();
114
+ // inside a trigger, BEGIN and CASE open what END closes
115
+ if (trigger) {
116
+ if (w === 'BEGIN' || (w === 'CASE' && depth > 0)) depth++;
117
+ else if (w === 'END' && depth > 0) depth--;
118
+ }
119
+ i += w.length;
120
+ } else if (c === ';' && depth === 0) {
121
+ push(i);
122
+ start = ++i;
123
+ trigger = TRIGGER.test(text.slice(start, start + 4096));
124
+ } else i++;
125
+ }
126
+ push(text.length);
127
+ return out;
128
+ }
129
+
130
+ /**
131
+ * Apply the migrations not applied yet to `db` (a D1Database), in order, each in one batch with its record in
132
+ * _gf_migrations (all of it or none). `dir`: the game folder, for an older xcore's record file (null: none).
133
+ * → {ok: true, applied: [names applied now]} | {ok: false, code: 'migration-changed' | 'migration-empty' |
134
+ * 'migration-failed', name, why, applied, partly?}. A refusal leaves the database as it was before that migration;
135
+ * `partly`: a migration an earlier batch left partly applied (refused until it is undone).
136
+ */
137
+ export async function applyMigrations(dir, db, migrations, { now = () => Date.now() } = {}) {
138
+ await db.prepare(MIGRATIONS_DDL).run();
139
+ await adoptOldRecord(dir, db);
140
+ const applied = await readApplied(db);
141
+ const half = applied.find(a => !(a.at > 0));
142
+ if (half)
143
+ return {
144
+ ok: false,
145
+ code: 'migration-failed',
146
+ name: half.name,
147
+ why: partlyApplied(half.name, -half.at),
148
+ applied: [],
149
+ partly: true,
150
+ };
151
+ const changed = migrationsChanged(migrations, applied);
152
+ if (changed) return { ok: false, ...changed, applied: [] };
153
+ // every statement of every new migration measured before the first runs, as xgames.fun does (§8.10): one D1 cannot
154
+ // run (over 100,000 bytes, or SQL that never ends) is refused with nothing of them applied
155
+ for (const m of migrations.slice(applied.length)) {
156
+ const why = m.bytes === 0 ? null : migrationStatementsProblem(m);
157
+ if (why)
158
+ return {
159
+ ok: false,
160
+ code: 'migration-failed',
161
+ name: m.name,
162
+ why: `${why}. Nothing of the new migrations was applied; fix ${m.name}`,
163
+ applied: [],
164
+ };
165
+ }
166
+ const done = [];
167
+ for (const m of migrations.slice(applied.length)) {
168
+ const why = emptyMigration(m);
169
+ if (why) return { ok: false, code: 'migration-empty', name: m.name, why, applied: done };
170
+ const statements = splitSql(m.text);
171
+ const t = Math.max(1, Math.floor(now()));
172
+ try {
173
+ await db.batch([
174
+ db.prepare(MIGRATIONS_DDL),
175
+ db.prepare(RECORD).bind(m.name, m.sha256, -t),
176
+ ...statements.map(s => db.prepare(s)),
177
+ db.prepare(FINISHED).bind(t, m.name),
178
+ ]);
179
+ } catch (e) {
180
+ return {
181
+ ok: false,
182
+ code: 'migration-failed',
183
+ name: m.name,
184
+ why: `${m.name} failed: ${
185
+ String(e?.message ?? e)
186
+ .replace(/^D1_ERROR:\s*/, '')
187
+ .split('\n')[0]
188
+ }${done.length ? ` (${done.join(', ')} before it went in)` : ''}`,
189
+ applied: done,
190
+ };
191
+ }
192
+ done.push(m.name);
193
+ }
194
+ return { ok: true, applied: done };
195
+ }
196
+
197
+ /** Miniflare's options for the D1 binding of a game's server. */
198
+ export const d1Options = (dir, binding) => ({
199
+ d1Databases: { [binding]: LOCAL_D1_ID },
200
+ d1Persist: d1Folder(dir),
201
+ });
202
+
203
+ /**
204
+ * The local database on its own, for `xcore db` (no game server needed): a Miniflare with one empty Worker and the
205
+ * binding, its migrations applied first. → {db, applied, close()}. Throws in words when a migration is refused.
206
+ */
207
+ export async function openLocalDatabase(dir, database, { log = () => {}, env = process.env } = {}) {
208
+ const { loadMiniflare } = await import('./tools.mjs');
209
+ const { Miniflare } = await loadMiniflare({ log, env });
210
+ await stateFolder(dir);
211
+ await mkdir(d1Folder(dir), { recursive: true });
212
+ const mf = new Miniflare({
213
+ modules: true,
214
+ script: 'export default { fetch: () => new Response(null, { status: 404 }) };',
215
+ compatibilityDate: COMPATIBILITY_DATE,
216
+ ...d1Options(dir, database.binding),
217
+ });
218
+ try {
219
+ const db = await mf.getD1Database(database.binding);
220
+ const r = await applyMigrations(dir, db, database.migrations);
221
+ // a migration left partly applied: the database opens all the same (xcore db query --write is how it is mended),
222
+ // with no other migration applied, and says so
223
+ if (!r.ok && r.partly) log(`${r.why}.`);
224
+ else if (!r.ok) throw Object.assign(new Error(`${r.why}.`), { code: r.code, name: r.name });
225
+ return { db, applied: r.applied, partly: r.partly ? r.name : null, close: () => mf.dispose() };
226
+ } catch (e) {
227
+ await mf.dispose().catch(() => {});
228
+ if (e.code) throw e;
229
+ throw new Error(
230
+ `the local database did not open (${String(e?.message ?? e).split('\n')[0]}). If ${'`xcore dev`'} is running for this game, stop it first`,
231
+ { cause: e },
232
+ );
233
+ }
234
+ }
235
+
236
+ /** Has this game a local database yet? */
237
+ export const hasLocalDatabase = dir => existsSync(d1Folder(dir));
238
+
239
+ const quote = v =>
240
+ v === null || v === undefined
241
+ ? 'NULL'
242
+ : typeof v === 'number' || typeof v === 'bigint'
243
+ ? String(v)
244
+ : v instanceof ArrayBuffer || ArrayBuffer.isView(v) || Array.isArray(v)
245
+ ? `X'${Buffer.from(Array.isArray(v) ? v : v instanceof ArrayBuffer ? new Uint8Array(v) : v).toString('hex')}'`
246
+ : `'${String(v).replace(/'/g, "''")}'`;
247
+ const ident = name => `"${String(name).replace(/"/g, '""')}"`;
248
+ const same = (a, b) => String(a).toLowerCase() === String(b).toLowerCase();
249
+
250
+ /** A CREATE that stops a dump going into a database that has the object already: it gets IF NOT EXISTS. */
251
+ const CREATE =
252
+ /^CREATE\s+(?:TEMP\s+|TEMPORARY\s+)?(?:UNIQUE\s+INDEX|INDEX|TABLE|VIEW|TRIGGER|VIRTUAL\s+TABLE)\s+(?!IF\s+NOT\s+EXISTS\b)/i;
253
+ const keepWhatIsThere = s => s.replace(CREATE, m => `${m.trimEnd()} IF NOT EXISTS `);
254
+
255
+ /**
256
+ * What makes an INSERT of a dump go in over the database as it is: a row with the same key is updated where it is
257
+ * (an upsert), never deleted and written again as REPLACE does, which would fire the ON DELETE CASCADE of every row
258
+ * that points at it. `key`: the table's primary key, or rowid for a table without one; with no column but the key,
259
+ * the row is there already as it is.
260
+ */
261
+ const onConflict = (columns, key) => {
262
+ const rest = columns.filter(c => !key.some(k => same(k, c)));
263
+ return ` ON CONFLICT (${key.map(ident).join(', ')}) ${
264
+ rest.length ? `DO UPDATE SET ${rest.map(c => `${ident(c)} = excluded.${ident(c)}`).join(', ')}` : 'DO NOTHING'
265
+ }`;
266
+ };
267
+
268
+ /** SQL as its tokens, each {t: 'word' | 'name' (quoted) | 'text' | 'mark', v, start, end}, outside comments. */
269
+ function tokens(s) {
270
+ const out = [];
271
+ let i = 0;
272
+ while (i < s.length) {
273
+ const c = s[i];
274
+ if (/\s/.test(c)) i++;
275
+ else if (c === '-' && s[i + 1] === '-') i = s.indexOf('\n', i) < 0 ? s.length : s.indexOf('\n', i) + 1;
276
+ else if (c === '/' && s[i + 1] === '*') i = s.indexOf('*/', i + 2) < 0 ? s.length : s.indexOf('*/', i + 2) + 2;
277
+ else if (c === "'" || c === '"' || c === '`' || c === '[') {
278
+ const close = c === '[' ? ']' : c;
279
+ let j = i + 1,
280
+ v = '';
281
+ while (j < s.length) {
282
+ if (s[j] === close && s[j + 1] === close && c !== '[') ((v += close), (j += 2));
283
+ else if (s[j] === close) break;
284
+ else v += s[j++];
285
+ }
286
+ out.push({ t: c === "'" ? 'text' : 'name', v, start: i, end: j + 1 });
287
+ i = j + 1;
288
+ } else if (/[A-Za-z0-9_$]/.test(c)) {
289
+ const w = /^[A-Za-z0-9_$]+/.exec(s.slice(i))[0];
290
+ out.push({ t: 'word', v: w, start: i, end: i + w.length });
291
+ i += w.length;
292
+ } else out.push({ t: 'mark', v: c, start: i, end: ++i });
293
+ }
294
+ return out;
295
+ }
296
+ const isWord = (tk, ...words) => tk?.t === 'word' && words.some(w => same(tk.v, w));
297
+ const nameOf = tk => (tk && (tk.t === 'name' || tk.t === 'word') ? tk.v : null);
298
+ /** A table's name in its tokens from `i` (main.x, "x", [x]): → {name, next} */
299
+ function tableAt(tk, i) {
300
+ if (isWord(tk[i], 'main', 'temp') && tk[i + 1]?.v === '.' && tk[i + 1].t === 'mark') i += 2;
301
+ return { name: nameOf(tk[i]), next: i + 1 };
302
+ }
303
+ /** The tokens between the ( at `i` and its ), split at its own commas: → {items: [tokens], next} */
304
+ function inParens(tk, i) {
305
+ const items = [[]];
306
+ let depth = 0;
307
+ for (; i < tk.length; i++) {
308
+ const v = tk[i].t === 'mark' ? tk[i].v : null;
309
+ if (v === '(' && depth++ === 0) continue;
310
+ if (v === ')' && --depth === 0) return { items: items.filter(x => x.length), next: i + 1 };
311
+ if (v === ',' && depth === 1) items.push([]);
312
+ else items.at(-1).push(tk[i]);
313
+ }
314
+ return null;
315
+ }
316
+
317
+ /**
318
+ * The tables a dump makes, read from its CREATE TABLE statements: name (lower case) → {columns, key}, and `statements`,
319
+ * the CREATE TABLEs read. `key` is the primary key's columns, or [] for a table without one. A table made AS SELECT,
320
+ * or one that cannot be read, is not in it.
321
+ */
322
+ function tablesOf(statements) {
323
+ const out = Object.assign(new Map(), { statements: new Set() });
324
+ for (const st of statements) {
325
+ const tk = tokens(st);
326
+ let i = 0;
327
+ if (!isWord(tk[i++], 'CREATE')) continue;
328
+ if (isWord(tk[i], 'TEMP', 'TEMPORARY')) i++;
329
+ if (!isWord(tk[i++], 'TABLE')) continue;
330
+ if (isWord(tk[i], 'IF') && isWord(tk[i + 1], 'NOT') && isWord(tk[i + 2], 'EXISTS')) i += 3;
331
+ const { name, next } = tableAt(tk, i);
332
+ if (!name || tk[next]?.v !== '(') continue;
333
+ const body = inParens(tk, next);
334
+ if (!body) continue;
335
+ const columns = [];
336
+ let key = [];
337
+ for (const item of body.items) {
338
+ if (isWord(item[0], 'CONSTRAINT', 'PRIMARY', 'UNIQUE', 'CHECK', 'FOREIGN')) {
339
+ const at = item.findIndex((x, j) => isWord(x, 'PRIMARY') && isWord(item[j + 1], 'KEY'));
340
+ const list = at >= 0 && inParens(item, at + 2);
341
+ if (list) key = list.items.map(x => nameOf(x[0])).filter(Boolean);
342
+ continue;
343
+ }
344
+ const column = nameOf(item[0]);
345
+ if (!column) continue;
346
+ columns.push(column);
347
+ if (item.some((x, j) => j > 0 && isWord(x, 'PRIMARY') && isWord(item[j + 1], 'KEY'))) key = [column];
348
+ }
349
+ out.set(name.toLowerCase(), { columns, key });
350
+ out.statements.add(st);
351
+ }
352
+ return out;
353
+ }
354
+
355
+ /**
356
+ * An INSERT of a dump made to go in over what is there without deleting anything. A plain INSERT INTO, INSERT OR
357
+ * REPLACE INTO or REPLACE INTO (D1's export writes the first) becomes an upsert on the table's key: its columns are the
358
+ * statement's own list, or the table's in the dump's CREATE TABLE. One already with an ON CONFLICT (xcore's export),
359
+ * or any other statement, is left as it is. One into a table the dump does not make, or one without a key, is a plain
360
+ * INSERT INTO (it adds its rows, or fails on a key that is there, and never deletes one).
361
+ */
362
+ function upsertOf(st, tables) {
363
+ const tk = tokens(st);
364
+ let i;
365
+ if (isWord(tk[0], 'REPLACE')) i = 1;
366
+ else if (isWord(tk[0], 'INSERT') && isWord(tk[1], 'OR') && isWord(tk[2], 'REPLACE')) i = 3;
367
+ else if (isWord(tk[0], 'INSERT')) i = 1;
368
+ else return st;
369
+ if (!isWord(tk[i++], 'INTO')) return st;
370
+ const { name, next } = tableAt(tk, i);
371
+ const head = tk[i].start;
372
+ let columns = null,
373
+ j = next;
374
+ if (tk[j]?.v === '(') {
375
+ const list = inParens(tk, j);
376
+ if (!list) return st;
377
+ columns = list.items.map(x => nameOf(x[0]));
378
+ j = list.next;
379
+ }
380
+ if (!isWord(tk[j], 'VALUES')) return st;
381
+ // an ON CONFLICT or RETURNING of its own, outside the rows' parentheses
382
+ let depth = 0;
383
+ for (const x of tk.slice(j)) {
384
+ if (x.t === 'mark' && x.v === '(') depth++;
385
+ else if (x.t === 'mark' && x.v === ')') depth--;
386
+ else if (depth === 0 && isWord(x, 'ON', 'RETURNING')) return st;
387
+ }
388
+ const table = name && tables.get(name.toLowerCase());
389
+ const rest = `INSERT INTO ${st.slice(head)}`;
390
+ if (!table) return rest;
391
+ columns ??= table.columns;
392
+ const key = table.key.length ? table.key : columns.filter(c => /^(rowid|_rowid_|oid)$/i.test(c ?? '')).slice(0, 1);
393
+ if (!key.length || columns.some(c => !c) || !key.every(k => columns.some(c => same(c, k)))) return rest;
394
+ return rest + onConflict(columns, key);
395
+ }
396
+
397
+ /** A statement on the migrations' record (_gf_migrations) or xgames.fun's progress of their batch (_gf_migration_steps). */
398
+ const ON_RECORD = new RegExp(
399
+ String.raw`^(?:CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?|INSERT\s+(?:OR\s+[A-Z]+\s+)?INTO\s+|REPLACE\s+INTO\s+|DELETE\s+FROM\s+|UPDATE\s+(?:OR\s+[A-Z]+\s+)?|DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?|ALTER\s+TABLE\s+)(?:main\s*\.\s*)?["\x60[]?(?:${MIGRATIONS_TABLE}|${STEPS_TABLE})\b`,
400
+ 'i',
401
+ );
402
+ const DUMP_MARK = /^PRAGMA\s+defer_foreign_keys\b/i;
403
+
404
+ /**
405
+ * What an import runs (`xcore db import`, here and live). A dump (its first statement PRAGMA defer_foreign_keys, as
406
+ * xcore's and D1's exports begin) goes in over the database as it is, or into an empty one: each CREATE gets IF NOT
407
+ * EXISTS, so the tables the migrations made are kept; each INSERT updates the row with the same key in place (an
408
+ * upsert: nothing is deleted, so no ON DELETE CASCADE fires); and the PRAGMA stays first, here as live, so a row may
409
+ * point at one the dump writes after it. Its _gf_migrations (and any _gf_migration_steps) is the record of the database
410
+ * it came from, so it is left out (this database keeps its own). SQL of your own runs as it is, and may touch neither.
411
+ * → {sql (the text to send live), statements (to run here, the PRAGMA first), dump, left (statements left out)}
412
+ */
413
+ export function importPlan(text) {
414
+ const all = splitSql(text);
415
+ const dump = DUMP_MARK.test(all[0] ?? '');
416
+ if (!dump) {
417
+ const touch = all.find(st => ON_RECORD.test(st));
418
+ if (touch)
419
+ throw Object.assign(
420
+ new Error(
421
+ `${MIGRATIONS_TABLE} and ${STEPS_TABLE} are the database's record of its migrations, which xcore and xgames.fun keep: an import does not change them (${touch.slice(0, 60)}…)`,
422
+ ),
423
+ { code: 'bad-sql' },
424
+ );
425
+ return { sql: text, statements: all, dump: false, left: 0 };
426
+ }
427
+ const body = all.slice(1).filter(st => !ON_RECORD.test(st));
428
+ const tables = tablesOf(body);
429
+ // the tables first: an INSERT into one whose foreign key names a table not made yet fails, deferred or not
430
+ const made = st => tables.statements.has(st);
431
+ const statements = [all[0], ...body.filter(made), ...body.filter(st => !made(st))].map((st, i) =>
432
+ i ? upsertOf(keepWhatIsThere(st), tables) : st,
433
+ );
434
+ return {
435
+ sql: statements.map(st => `${st};`).join('\n') + '\n',
436
+ statements,
437
+ dump: true,
438
+ left: all.length - statements.length,
439
+ };
440
+ }
441
+
442
+ /**
443
+ * The database as SQL text: its tables (CREATE TABLE IF NOT EXISTS) and their rows (INSERT … ON CONFLICT DO UPDATE on
444
+ * the table's key, the rowid with them for a table without a primary key), then indexes, views and triggers, after
445
+ * PRAGMA defer_foreign_keys, so a row may come before the one it points at. It goes back in with `xcore db import`,
446
+ * here or live, over a database whose migrations made the same tables (each row updated where it is, none deleted,
447
+ * none twice) or into an empty one. _gf_migrations is left out: it is the record of this database's migrations, and
448
+ * the database it goes into keeps its own.
449
+ */
450
+ export async function dumpDatabase(db) {
451
+ const { results: all } = await db
452
+ .prepare(
453
+ "SELECT type, name, tbl_name, sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY CASE type WHEN 'table' THEN 0 ELSE 1 END, name",
454
+ )
455
+ .all();
456
+ const objects = all.filter(o => !internalTable(o.name) && !internalTable(o.tbl_name));
457
+ const lines = [
458
+ 'PRAGMA defer_foreign_keys=TRUE;',
459
+ `-- the game's database, from xcore db export: xcore db import puts it back (tables kept, each row updated where it is, none deleted). ${MIGRATIONS_TABLE} is left out: each database keeps its own.`,
460
+ ];
461
+ const tables = objects.filter(x => x.type === 'table');
462
+ // every table before any row: a row's foreign key may name a table that comes after its own
463
+ for (const o of tables) lines.push(`${keepWhatIsThere(o.sql)};`);
464
+ for (const o of tables) {
465
+ if (/^CREATE\s+VIRTUAL\b/i.test(o.sql)) continue; // its rows are in its own tables, dumped with the rest
466
+ // table_info leaves out generated columns, which take no value of their own
467
+ const { results: info } = await db.prepare(`PRAGMA table_info(${ident(o.name)})`).all();
468
+ const pk = info.filter(c => c.pk > 0).sort((a, b) => a.pk - b.pk);
469
+ // a table without a primary key is told its rows apart by rowid: kept, so an import updates and never doubles
470
+ const rowid = !pk.length && !info.some(c => /^(rowid|_rowid_|oid)$/i.test(c.name));
471
+ const columns = [...(rowid ? ['rowid'] : []), ...info.map(c => c.name)];
472
+ const key = pk.length ? pk.map(c => c.name) : rowid ? ['rowid'] : [];
473
+ const tail = key.length ? onConflict(columns, key) : '';
474
+ const { results: rows } = await db
475
+ .prepare(
476
+ `SELECT ${columns.map(c => (c === 'rowid' && rowid ? 'rowid AS "rowid"' : ident(c))).join(', ')} FROM ${ident(o.name)}`,
477
+ )
478
+ .all();
479
+ for (const r of rows)
480
+ lines.push(
481
+ `INSERT INTO ${ident(o.name)} (${columns.map(ident).join(', ')}) VALUES (${columns.map(c => quote(r[c])).join(', ')})${tail};`,
482
+ );
483
+ }
484
+ for (const o of objects.filter(x => x.type !== 'table')) lines.push(`${keepWhatIsThere(o.sql)};`);
485
+ return lines.join('\n') + '\n';
486
+ }