@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.
- package/LICENSE +1 -1
- package/README.md +183 -84
- package/bin/xcore.mjs +13 -8
- package/commands/add.mjs +11 -8
- package/commands/adopt.mjs +9 -7
- package/commands/backend.mjs +2 -2
- package/commands/check.mjs +4 -4
- package/commands/db.mjs +218 -0
- package/commands/dev.mjs +8 -6
- package/commands/domain.mjs +8 -8
- package/commands/embed.mjs +3 -3
- package/commands/files.mjs +106 -0
- package/commands/login.mjs +9 -17
- package/commands/logout.mjs +15 -4
- package/commands/loot.mjs +2 -2
- package/commands/plan.mjs +22 -19
- package/commands/publish.mjs +54 -21
- package/commands/secret.mjs +6 -6
- package/docs/SERVER.md +218 -18
- package/lib/add.mjs +265 -40
- package/lib/browser.mjs +11 -2
- package/lib/check.mjs +10 -9
- package/lib/cli.mjs +24 -9
- package/lib/client/gamesfun.js +242 -122
- package/lib/client/godot/gamesfun.gd +17 -11
- package/lib/client/godot/gamesfun_peer.gd +2 -2
- package/lib/client/server/0001_init.sql +10 -0
- package/lib/client/server/database.js +271 -109
- package/lib/client/server/gf-db.js +55 -0
- package/lib/client/server/gf-docs.js +56 -0
- package/lib/client/server/rooms.js +16 -9
- package/lib/client/server/rpc.js +7 -7
- package/lib/client/server/stock.js +37 -0
- package/lib/client/server/vouchers.js +127 -46
- package/lib/client/unity/GamesFun.cs +15 -12
- package/lib/client/unity/GamesFun.jslib +1 -1
- package/lib/config-dir.mjs +1 -1
- package/lib/credentials.mjs +10 -5
- package/lib/db.mjs +355 -0
- package/lib/dev/d1.mjs +486 -0
- package/lib/dev/files.mjs +247 -0
- package/lib/dev/gf-local.js +69 -3
- package/lib/dev/index.mjs +68 -20
- package/lib/dev/limits.mjs +2 -2
- package/lib/dev/platform.mjs +111 -12
- package/lib/dev/server.mjs +54 -5
- package/lib/dev/tools.mjs +71 -18
- package/lib/files.mjs +208 -0
- package/lib/open.mjs +1 -1
- package/lib/play-policy.mjs +14 -5
- package/lib/publish/adopt.mjs +73 -33
- package/lib/publish/client.mjs +130 -20
- package/lib/publish/gate.mjs +4 -2
- package/lib/publish/hosting.mjs +5 -5
- package/lib/publish/index.mjs +162 -40
- package/lib/publish/oidc.mjs +26 -5
- package/lib/publish/page.mjs +1 -1
- package/lib/publish/plans.mjs +19 -12
- package/lib/publish/scan.mjs +78 -102
- package/lib/publish/secrets.mjs +4 -5
- package/lib/publish/site.mjs +9 -10
- package/lib/publish/tree.mjs +8 -8
- package/lib/secret-patterns.mjs +53 -16
- package/lib/secret-store.mjs +2 -2
- package/lib/server/bundle.mjs +113 -13
- package/lib/server/contract.mjs +440 -16
- package/lib/server/data-calls.mjs +136 -0
- package/lib/server/secrets.mjs +9 -7
- package/lib/solana-rpc.mjs +5 -5
- package/lib/state-folder.mjs +23 -0
- package/package.json +4 -4
- 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
|
+
}
|