@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
@@ -1,9 +1,9 @@
1
- // The games.fun sign-in (`xcore login` / `publish` / `logout`) on top of one secret store,
1
+ // The xgames.fun sign-in (`xcore login` / `publish` / `logout`) on top of one secret store,
2
2
  // lib/secret-store.mjs (macOS keychain, Linux Secret Service, Windows DPAPI, else a 0600 file; the kit's backend sign-ins
3
3
  // live there too). This module adds only what publishing needs beside the secret: a public index of who is signed in
4
4
  // where and until when, credentials.json in the person's config folder (0600; never a secret in it).
5
5
  //
6
- // Names. The secret store takes names of [a-z0-9-]; a sign-in's key is `games.fun <origin>`, so its secret is kept
6
+ // Names. The secret store takes names of [a-z0-9-]; a sign-in's key is `xgames.fun <origin>`, so its secret is kept
7
7
  // under `publish-<first 16 hex of sha256(key)>`. The backend's names are `backend-supabase`, `backend-supabase-app`,
8
8
  // `supabase-db-<ref>`, `railway-token` and `railway-client`: no `publish-` among them, so a login and a backend connect
9
9
  // never overwrite each other.
@@ -18,7 +18,7 @@ import { configDir, deleteSecret as storeDelete, getSecret, setSecret, storeInfo
18
18
  export { configDir };
19
19
  export const credentialsFile = opts => path.join(configDir(opts), 'credentials.json');
20
20
 
21
- /** The secret store's name for a sign-in key (`games.fun https://…`). */
21
+ /** The secret store's name for a sign-in key (`xgames.fun https://…`). */
22
22
  export const secretName = key => `publish-${createHash('sha256').update(String(key)).digest('hex').slice(0, 16)}`;
23
23
 
24
24
  // the old switch keeps working: GAMESFUN_CREDENTIALS=file → the store's file
@@ -62,7 +62,7 @@ export function storeName({ env = process.env, platform = process.platform } = {
62
62
  }
63
63
 
64
64
  /**
65
- * Keep `secret` under `key` (e.g. "games.fun https://xgames.fun"), with `meta` (public: email, expiry).
65
+ * Keep `secret` under `key` (e.g. "xgames.fun https://xgames.fun"), with `meta` (public: email, expiry).
66
66
  * → { store: 'keychain' | 'secret-service' | 'dpapi' | 'file', file }
67
67
  */
68
68
  export async function saveSecret(key, secret, meta = {}, { env = process.env, platform = process.platform } = {}) {
@@ -82,7 +82,12 @@ export async function loadSecret(key, { env = process.env, platform = process.pl
82
82
  if (!e) return null;
83
83
  const { secret: legacy, store, name, ...meta } = e;
84
84
  // an entry written by an older version kept a file-store secret inline
85
- const secret = legacy && store === 'file' ? legacy : getSecret(secretName(key), { env: storeEnv(env), platform });
85
+ if (legacy && store === 'file') return { secret: legacy, meta, store };
86
+ // the store the record names first: a sign-in saved to the file (over SSH, where the Secret Service cannot be
87
+ // reached) is never shadowed by an older token the keyring still holds under the same name
88
+ const fromFile =
89
+ store === 'file' ? getSecret(secretName(key), { env: { ...env, GAMESFUN_SECRET_STORE: 'file' }, platform }) : null;
90
+ const secret = fromFile ?? getSecret(secretName(key), { env: storeEnv(env), platform });
86
91
  return secret ? { secret, meta, store } : null;
87
92
  }
88
93
 
package/lib/db.mjs ADDED
@@ -0,0 +1,355 @@
1
+ // `xcore db info|query|export|import|restore`: the game's own database (gamesfun.json → database, a D1), on this
2
+ // machine by default (the local database `xcore dev` runs, ./dev/d1.mjs), on xgames.fun with `live: true`, through the
3
+ // owner's routes (xgames.fun's STORAGE.md §8.5; each call is kept in the game's audit log there):
4
+ // GET /api/kit/games/<id>/db → {ok, exists, name, sizeBytes, applied, cap}
5
+ // POST /api/kit/games/<id>/db/query {sql, params?, write?} → {ok, results, meta}
6
+ // POST /api/kit/games/<id>/db/export → {ok, url} (a signed address of the SQL dump)
7
+ // POST /api/kit/games/<id>/db/import {sql} (≤ 5 MB) → {ok, meta}
8
+ // POST /api/kit/games/<id>/db/restore {timestamp, confirm?} → {ok, preview: {bookmark, at}, economy} without
9
+ // confirm; {ok, restored: {bookmark, at}} with it
10
+ // A query is one statement, and reads only (SELECT, EXPLAIN, PRAGMA table_info / table_list) unless `write` is set:
11
+ // the same rule here as there (queryProblem, a copy of xgames.fun's), so what works on this machine works live. The game is the folder's own (its
12
+ // .gamesfun/publish.json for this xgames.fun); the credential is publish's.
13
+ import { existsSync } from 'node:fs';
14
+ import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
15
+ import path from 'node:path';
16
+ import { call, studioOrigin } from './publish/client.mjs';
17
+ import { OIDC_KIND, requireCredential } from './publish/oidc.mjs';
18
+ import { readLinks } from './publish/index.mjs';
19
+ import {
20
+ CONFIG_FILE,
21
+ DATABASE_LIMITS,
22
+ STUDIO_BODY_BYTES,
23
+ readServerConfig,
24
+ sqlStatements,
25
+ } from './server/contract.mjs';
26
+ import { d1Folder, dumpDatabase, importPlan, internalTable, openLocalDatabase, readApplied } from './dev/d1.mjs';
27
+ import { CMD } from './command.mjs';
28
+ import { stateFolder } from './state-folder.mjs';
29
+
30
+ /** The game's database settings; throws in words when it has none. */
31
+ export async function databaseOf(dir) {
32
+ const cfg = await readServerConfig(dir);
33
+ if (cfg.problems.length) throw new Error(`${CONFIG_FILE}: ${cfg.problems.join('; ')}`);
34
+ if (!cfg.database?.has)
35
+ throw new Error(`this game has no database yet: ${CMD} add database gives it one (${CONFIG_FILE} → database)`);
36
+ return cfg.database;
37
+ }
38
+
39
+ // ---- the query route's rule (§8.5), copied from xgames.fun's creator-studio/lib/game-database.mjs as it is, so a query
40
+ // is taken or refused the same here as there ------------------------------------------------------------------------
41
+ export const QUERY_LIMITS = Object.freeze({ sqlChars: 100_000, params: 100 });
42
+ // sqlStatements is in ../server/contract.mjs, which the migrations' check reads too
43
+ export { sqlStatements };
44
+ const READ = [
45
+ /^SELECT\b/i,
46
+ /^EXPLAIN\b/i,
47
+ /^PRAGMA\s+(?:main\s*\.\s*)?table_list\s*$/i,
48
+ /^PRAGMA\s+(?:main\s*\.\s*)?table_info\s*\(\s*(?:[A-Za-z_][A-Za-z0-9_]*|'[^']*'|"[^"]*"|`[^`]*`)\s*\)\s*$/i,
49
+ ];
50
+ /**
51
+ * Why `sql` may not run through the owner's query route, or null (§8.5): one statement; without `write`, only SELECT,
52
+ * EXPLAIN and PRAGMA table_info / table_list.
53
+ */
54
+ export function queryProblem(sql, { write = false } = {}) {
55
+ if (typeof sql !== 'string' || !sql.trim()) return 'Send the SQL as {"sql": "SELECT …"}.';
56
+ if (sql.length > QUERY_LIMITS.sqlChars)
57
+ return `A query is at most ${QUERY_LIMITS.sqlChars.toLocaleString('en-US')} characters (an import takes more: xcore db import).`;
58
+ const statements = sqlStatements(sql);
59
+ if (!statements) return 'The SQL has a string, a quoted name or a comment that never ends.';
60
+ if (statements.length !== 1) return `Send one statement at a time (this has ${statements.length}).`;
61
+ if (write) return null;
62
+ if (!READ.some(re => re.test(statements[0])))
63
+ return 'Without "write": true, a query may only read: SELECT, EXPLAIN, PRAGMA table_info(<table>) or PRAGMA table_list. Add --write to change data.';
64
+ return null;
65
+ }
66
+ /**
67
+ * A query's params as D1's query API takes them: a list of strings. A number goes as its decimal text and a boolean as
68
+ * "1" or "0" (SQLite has no booleans); SQLite's column affinity turns them back into numbers in a comparison with, or an
69
+ * insert into, a numeric column. null cannot be sent as a string, so it is refused: write NULL in the SQL itself. → the
70
+ * strings, or null
71
+ */
72
+ export function d1Params(params) {
73
+ if (!Array.isArray(params) || params.length > QUERY_LIMITS.params) return null;
74
+ const out = [];
75
+ for (const v of params) {
76
+ if (typeof v === 'string') out.push(v);
77
+ else if (typeof v === 'number' && Number.isFinite(v)) out.push(String(v));
78
+ else if (typeof v === 'boolean') out.push(v ? '1' : '0');
79
+ else return null;
80
+ }
81
+ return out;
82
+ }
83
+ // ---- end of the copy -----------------------------------------------------------------------------------------------
84
+
85
+ /** Is this SQL one statement that only reads (the rule above without `write`)? → null, or why not */
86
+ export const readOnlyProblem = sql => queryProblem(sql);
87
+
88
+ /** The game's id on xgames.fun and how to sign the call. Throws in plain words. */
89
+ async function target(dir, { origin, env, fetchImpl }) {
90
+ const known = (await readLinks(dir))[origin];
91
+ if (!known?.projectId)
92
+ throw new Error(`this folder's game is not on ${origin} yet: publish it with its database first (${CMD} publish)`);
93
+ const cred = await requireCredential(origin, { env, fetchImpl });
94
+ return { id: known.projectId, auth: await cred.auth(), ci: cred.kind === OIDC_KIND };
95
+ }
96
+
97
+ /** What a GitHub Actions run may not do on xgames.fun (403): the owner's own sign-in does it. */
98
+ export const notFromCi = what =>
99
+ `xgames.fun does not let a GitHub Actions run ${what}: any workflow of the linked branch could do it. Run it from your own machine, signed in as the game's owner (${CMD} login there), not from CI.`;
100
+ const route = (id, what = '') => `/api/kit/games/${encodeURIComponent(id)}/db${what ? `/${what}` : ''}`;
101
+ const mb = n => `${(n / 1_000_000).toFixed(1)} MB`;
102
+ const CI_REFUSED = {
103
+ export: 'export the database',
104
+ import: 'import into the database',
105
+ restore: 'restore the database',
106
+ query: 'change the database (query --write)',
107
+ };
108
+ const liveCall = async (dir, what, opts, { origin = studioOrigin(), env = process.env, fetchImpl = fetch } = {}) => {
109
+ const t = await target(dir, { origin, env, fetchImpl });
110
+ try {
111
+ return await call(origin, route(t.id, what), { ...opts, ...t.auth, fetchImpl });
112
+ } catch (e) {
113
+ if (e.status === 403 && t.ci && CI_REFUSED[what])
114
+ throw Object.assign(new Error(notFromCi(CI_REFUSED[what])), { status: 403, code: 'ci', cause: e });
115
+ // Studio's host refuses a request over 4.5 MB with a bare 413, before xgames.fun reads it
116
+ if (e.status === 413 && !e.code)
117
+ throw Object.assign(
118
+ new Error(
119
+ `xgames.fun answered 413: the request was too big for it to take (at most ${mb(STUDIO_BODY_BYTES)} through its Studio). Split the SQL into smaller files and import each.`,
120
+ ),
121
+ { status: 413, code: 'too-big', cause: e },
122
+ );
123
+ if (e.code === 'migration-busy')
124
+ throw Object.assign(
125
+ new Error(
126
+ 'another publish of this game is applying its database migrations on xgames.fun right now: try again in a minute',
127
+ ),
128
+ { status: e.status, code: e.code, cause: e },
129
+ );
130
+ throw e;
131
+ }
132
+ };
133
+
134
+ /** The local database, opened for one task and closed after it. */
135
+ async function withLocal(dir, fn, { env = process.env, log = () => {} } = {}) {
136
+ const database = await databaseOf(dir);
137
+ const local = await openLocalDatabase(dir, database, { env, log });
138
+ try {
139
+ return await fn(local.db, database, local);
140
+ } finally {
141
+ await local.close().catch(() => {});
142
+ }
143
+ }
144
+
145
+ /** The database: where it is, its size, the migrations applied, its tables. → {ok, live, …} */
146
+ export async function dbInfo(dir, { live = false, ...opts } = {}) {
147
+ if (live) return { live: true, ...(await liveCall(dir, '', { method: 'GET' }, opts)) };
148
+ return withLocal(
149
+ dir,
150
+ async (db, database) => {
151
+ const { results } = await db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name").all();
152
+ const tables = [];
153
+ for (const { name } of results.filter(r => !internalTable(r.name))) {
154
+ const n = (await db.prepare(`SELECT count(*) AS n FROM "${name.replace(/"/g, '""')}"`).first())?.n ?? 0;
155
+ tables.push({ name, rows: n });
156
+ }
157
+ return {
158
+ ok: true,
159
+ live: false,
160
+ exists: true,
161
+ binding: database.binding,
162
+ folder: path.relative(dir, d1Folder(dir)).split(path.sep).join('/'),
163
+ sizeBytes: await folderBytes(d1Folder(dir)),
164
+ applied: await readApplied(db),
165
+ tables,
166
+ };
167
+ },
168
+ opts,
169
+ );
170
+ }
171
+ async function folderBytes(dir) {
172
+ const { readdir } = await import('node:fs/promises');
173
+ let n = 0;
174
+ for (const e of await readdir(dir, { withFileTypes: true, recursive: true }).catch(() => []))
175
+ if (e.isFile() && /\.sqlite$/.test(e.name)) n += (await stat(path.join(e.parentPath ?? e.path, e.name))).size;
176
+ return n;
177
+ }
178
+
179
+ /** One statement. → {ok, live, results, meta} */
180
+ export async function dbQuery(dir, sql, { live = false, params = [], write = false, ...opts } = {}) {
181
+ // the route's own checks, so a query is refused here in the words xgames.fun would answer
182
+ const why = queryProblem(sql, { write: !!write });
183
+ if (why) throw Object.assign(new Error(why), { code: write ? 'bad-sql' : 'read-only' });
184
+ // the same here as live (D1's API takes each as text): a null is refused, and written as NULL in the SQL instead
185
+ if (!d1Params(params))
186
+ throw Object.assign(
187
+ new Error(
188
+ `params is a list (a JSON array) of at most ${QUERY_LIMITS.params} strings, numbers or booleans, such as ["moon", 3]. D1 takes each as text; for a null, write NULL in the SQL itself.`,
189
+ ),
190
+ { code: 'bad-sql' },
191
+ );
192
+ if (live)
193
+ return {
194
+ live: true,
195
+ ...(await liveCall(dir, 'query', { method: 'POST', body: { sql, params, write: !!write } }, opts)),
196
+ };
197
+ return withLocal(
198
+ dir,
199
+ async db => {
200
+ const r = await db
201
+ .prepare(sql)
202
+ .bind(...d1Params(params))
203
+ .all();
204
+ return { ok: true, live: false, results: r.results ?? [], meta: r.meta ?? {} };
205
+ },
206
+ opts,
207
+ );
208
+ }
209
+
210
+ /**
211
+ * The database as SQL text, into a file: `out`, else .gamesfun/exports/db-<when>.sql in the game folder (a hidden
212
+ * folder, so a dump of the players' data is never published). → {ok, live, file, bytes}
213
+ */
214
+ export async function dbExport(
215
+ dir,
216
+ { live = false, out = null, log = () => {}, tries = 5, wait = 3000, ...opts } = {},
217
+ ) {
218
+ const when = new Date().toISOString().replace(/[:.]/g, '-');
219
+ const file = path.resolve(out ?? path.join(dir, '.gamesfun', 'exports', `db-${live ? 'live' : 'local'}-${when}.sql`));
220
+ let text,
221
+ note = null;
222
+ if (live) {
223
+ // a large export answers 504 export-pending with its bookmark: asked again with it, D1 goes on where it got to
224
+ // (asked without it, it starts over; nobody asking, it stops). One this command gave up on is kept in
225
+ // .gamesfun/export-pending.json, so the next `db export --live` picks it up there
226
+ const pendingFile = path.join(dir, '.gamesfun', 'export-pending.json');
227
+ const origin = opts.origin ?? studioOrigin();
228
+ let a,
229
+ bookmark = null,
230
+ kept = null;
231
+ try {
232
+ const p = JSON.parse(await readFile(pendingFile, 'utf8'));
233
+ if (p?.origin === origin && typeof p.bookmark === 'string') bookmark = kept = p.bookmark;
234
+ } catch {
235
+ /* none kept */
236
+ }
237
+ for (let i = 0; ; i++) {
238
+ try {
239
+ a = await liveCall(dir, 'export', { method: 'POST', ...(bookmark ? { body: { bookmark } } : {}) }, opts);
240
+ break;
241
+ } catch (e) {
242
+ // a kept bookmark D1 no longer knows (its export stopped): this one starts over
243
+ if (kept && bookmark === kept && e.code !== 'export-pending' && e.status >= 400 && e.status < 500) {
244
+ bookmark = kept = null;
245
+ i--;
246
+ continue;
247
+ }
248
+ if (e.code !== 'export-pending' || i + 1 >= tries) {
249
+ if (e.code === 'export-pending') {
250
+ const left = e.answer?.bookmark ?? bookmark;
251
+ if (left) {
252
+ await stateFolder(dir);
253
+ await writeFile(pendingFile, JSON.stringify({ origin, bookmark: left, at: new Date().toISOString() }));
254
+ }
255
+ throw Object.assign(
256
+ new Error(
257
+ `the export is still running on D1${e.answer?.bookmark ? ` (bookmark ${e.answer.bookmark})` : ''}: run ${CMD} db export --live again soon to pick it up. Until it ends, the game's database answers nothing else`,
258
+ ),
259
+ { code: e.code, bookmark: left, cause: e },
260
+ );
261
+ }
262
+ throw e;
263
+ }
264
+ bookmark = e.answer?.bookmark ?? bookmark;
265
+ log(
266
+ `The export is still running on D1; asking again in ${wait / 1000} s. While it runs, the game's database answers nothing else (its players' calls wait or fail).`,
267
+ );
268
+ await new Promise(r => setTimeout(r, wait));
269
+ }
270
+ }
271
+ await rm(pendingFile, { force: true });
272
+ if (!a?.url) throw new Error('xgames.fun did not give an address for the dump');
273
+ note = a.note ?? null;
274
+ const res = await (opts.fetchImpl ?? fetch)(a.url);
275
+ if (!res.ok) throw new Error(`the dump could not be downloaded (${res.status}): run it again`);
276
+ text = Buffer.from(await res.arrayBuffer());
277
+ } else text = Buffer.from(await withLocal(dir, db => dumpDatabase(db), { log, ...opts }));
278
+ if (!out) await stateFolder(dir);
279
+ await mkdir(path.dirname(file), { recursive: true });
280
+ await writeFile(file, text, { mode: 0o600 });
281
+ return { ok: true, live, file, bytes: text.length, ...(note ? { note } : {}) };
282
+ }
283
+
284
+ /**
285
+ * The import a file makes (./dev/d1.mjs importPlan), held to the limits before anything is asked or sent: the file is
286
+ * there, at most 5 MB of SQL, and live at most 4.5 MB as sent (Studio's host). Throws in words. → the plan
287
+ */
288
+ export async function importOf(file, { live = false } = {}) {
289
+ if (!existsSync(file)) throw new Error(`${file} is not there`);
290
+ const text = await readFile(file, 'utf8');
291
+ if (Buffer.byteLength(text) > DATABASE_LIMITS.importBytes)
292
+ throw new Error(`an import is at most ${DATABASE_LIMITS.importBytes / 1048576} MB of SQL; split it into parts`);
293
+ const plan = importPlan(text);
294
+ // live, the SQL goes as JSON through Studio, whose host takes at most 4.5 MB a request
295
+ const sent = live ? Buffer.byteLength(JSON.stringify({ sql: plan.sql })) : 0;
296
+ if (sent > STUDIO_BODY_BYTES)
297
+ throw Object.assign(
298
+ new Error(
299
+ `this import is ${mb(sent)} as sent, and a request through xgames.fun's Studio is at most ${mb(STUDIO_BODY_BYTES)}: split it into smaller files and import each`,
300
+ ),
301
+ { code: 'too-big' },
302
+ );
303
+ return plan;
304
+ }
305
+
306
+ /**
307
+ * SQL statements from a file, run in one batch: a dump from `xcore db export` (here or live, and D1's own) goes in over
308
+ * the database as it is (or into an empty one), each row updated by its key where it is, none deleted, foreign keys
309
+ * checked at the end; statements of your own run as they are
310
+ * (./dev/d1.mjs importPlan). → {ok, live, meta | statements, dump, left}
311
+ */
312
+ export async function dbImport(dir, file, { live = false, ...opts } = {}) {
313
+ const plan = await importOf(file, { live });
314
+ if (live)
315
+ return {
316
+ live: true,
317
+ dump: plan.dump,
318
+ left: plan.left,
319
+ ...(await liveCall(dir, 'import', { method: 'POST', body: { sql: plan.sql } }, opts)),
320
+ };
321
+ return withLocal(
322
+ dir,
323
+ async db => {
324
+ if (plan.statements.length) await db.batch(plan.statements.map(s => db.prepare(s)));
325
+ return { ok: true, live: false, statements: plan.statements.length, dump: plan.dump, left: plan.left };
326
+ },
327
+ opts,
328
+ );
329
+ }
330
+
331
+ /**
332
+ * The live database as it was at `timestamp` (D1 Time Travel, the last 30 days). Without `confirm` it changes
333
+ * nothing and answers what it would go back to: {ok, preview: {bookmark, at}, economy}; with it, {ok, restored}.
334
+ */
335
+ export async function dbRestore(dir, timestamp, { live = false, confirm = false, ...opts } = {}) {
336
+ if (!live)
337
+ throw new Error(
338
+ `restore goes back in time on xgames.fun's copy (--live). The database on this machine has no history: to put back an earlier state here, ${CMD} db import a dump from ${CMD} db export`,
339
+ );
340
+ const text = String(timestamp ?? '').trim();
341
+ // xgames.fun takes an ISO 8601 time (a number there is milliseconds), so unix seconds and any date go as ISO text
342
+ const ms = /^\d{9,11}$/.test(text) ? Number(text) * 1000 : Date.parse(text);
343
+ if (!text || Number.isNaN(ms))
344
+ throw new Error('restore needs a time: a date such as 2026-11-08T12:00:00Z, or unix seconds');
345
+ const at = new Date(ms).toISOString();
346
+ return {
347
+ live: true,
348
+ ...(await liveCall(
349
+ dir,
350
+ 'restore',
351
+ { method: 'POST', body: { timestamp: at, ...(confirm ? { confirm: true } : {}) } },
352
+ opts,
353
+ )),
354
+ };
355
+ }