@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,8 +1,8 @@
1
1
  // gf-server-v1: what a game's own server code is, as the kit reads it. This one module holds every name and number of
2
- // the contract between a game's `server/`, the kit (dev, publish) and games.fun's hosting (the play edge's /api/*, the
2
+ // the contract between a game's `server/`, the kit (dev, publish) and xgames.fun's hosting (the play edge's /api/*, the
3
3
  // dispatch namespace, the platform binding env.GF). docs/SERVER.md is the person's page for it.
4
4
  //
5
- // Coded to games.fun's hosting contract, version gf-server-v1, and the play edge's own checks of a server's manifest.
5
+ // Coded to xgames.fun's hosting contract, version gf-server-v1, and the play edge's own checks of a server's manifest.
6
6
  // Where that contract changes, change it here: nothing else in the kit names these values.
7
7
  //
8
8
  // A game folder:
@@ -12,22 +12,27 @@
12
12
  // "routes": "/api/*", "compatibilityFlags": [] } } (§2). Without it, server/index.js is the
13
13
  // server when it exists, with no objects.
14
14
  // server/ and gamesfun.json are never served; a game with a server has no files under api/ (§1).
15
+ // gamesfun.json { "database": { "engine": "d1", "binding": "DB", "migrations": "server/migrations" } }: a D1
16
+ // database of the game's own (xgames.fun's STORAGE.md §8.2, readDatabaseConfig below). A
17
+ // database and no server of its own: xcore's stock server is the server.
15
18
  // Requests: <the game's origin>/api/* → the server's fetch, the URL unchanged; every x-gf-* header removed (§4.1)
16
19
  // (in the kit's dev server: the page's /game/api/*, sent on as /api/*)
17
- // env: env.GF the platform (below); no other secret of games.fun's
20
+ // env: env.GF the platform (below); no other secret of xgames.fun's
18
21
  // env.<binding> a Durable Object namespace for each declared object, SQLite storage
19
22
  // env.GF_GAME_ID, env.GF_SLUG, env.GF_CONTRACT plain text
23
+ // env.<database.binding> the game's D1 database, when gamesfun.json has a database (env.DB)
20
24
  // Runtime: Workers, compatibility date 2025-06-01; nodejs_compat only when gamesfun.json asks for it
21
- // Outbound: fetch() reaches only backend.json's origins and games.fun's (hosted; not enforced on this machine)
25
+ // Outbound: fetch() reaches only backend.json's origins and xgames.fun's (hosted; not enforced on this machine)
26
+ import { createHash } from 'node:crypto';
22
27
  import { existsSync } from 'node:fs';
23
- import { readFile } from 'node:fs/promises';
28
+ import { readdir, readFile } from 'node:fs/promises';
24
29
  import path from 'node:path';
25
30
 
26
31
  export const SERVER_FORMAT = 'gf-server-v1';
27
32
  export const CONFIG_FILE = 'gamesfun.json';
28
33
  export const DEFAULT_ENTRY = 'server/index.js';
29
34
  export const SERVER_DIR = 'server';
30
- /** The runtime the hosted script runs on (games.fun's upload and its local parity use these). */
35
+ /** The runtime the hosted script runs on (xgames.fun's upload and its local parity use these). */
31
36
  export const COMPATIBILITY_DATE = '2025-06-01';
32
37
  /** The only compatibility flag a game may ask for (gamesfun.json server.compatibilityFlags). */
33
38
  export const ALLOWED_FLAGS = Object.freeze(['nodejs_compat']);
@@ -36,7 +41,7 @@ export const ROUTES = '/api/*';
36
41
  export const SERVER_KEYS = Object.freeze(['main', 'objects', 'routes', 'compatibilityFlags']);
37
42
  /** One module goes up: the bundle. */
38
43
  export const BUNDLE_NAME = 'index.js';
39
- /** games.fun's caps for one game's server (§7: 16 modules and 5 MB in all, 8 Durable Object classes). */
44
+ /** xgames.fun's caps for one game's server (§7: 16 modules and 5 MB in all, 8 Durable Object classes). */
40
45
  export const LIMITS = Object.freeze({
41
46
  bytes: 5 * 1024 * 1024,
42
47
  warnBytes: 1024 * 1024,
@@ -92,10 +97,20 @@ export const NODE_REFUSED = Object.freeze([
92
97
  * scores.top(board, {limit?, offset?}) → {ok, order, writer, entries: [{rank, player, name, score, at}]}
93
98
  * scores.best(board, player) → {ok, entry: {rank, score, name, at} | null}
94
99
  * scores.remove(board, player) → {ok, removed}
95
- * loot.report({round, endedAt, winners: [{wallet, place, grant?}]}) → {ok, result, vouchers} (games.fun signs it
100
+ * loot.report({round, endedAt, winners: [{wallet, place, grant?}]}) → {ok, result, vouchers} (xgames.fun signs it
96
101
  * with the game's host key; the keeper decides) — locally logged only
97
102
  * loot.status() → {ok, host, certified, game}
98
103
  * info() → {ok, contract, gameId, slug}
104
+ * files.put(path, body, {type, public = false, length}) → {ok, path, bytes, url?} (the game's project files,
105
+ * STORAGE.md §8.6, §8.10: body a string, ArrayBuffer or byte ReadableStream, up
106
+ * to 25 MB; a stream held to exactly its length, or up to 1 MB without; url,
107
+ * https://<slug>.xgamesusercontent.com/<path>, when public; over the cap
108
+ * {ok: false, code: 'full'})
109
+ * files.get(path) → {ok, body, type, bytes, public} (body: a ReadableStream of the bytes)
110
+ * files.head(path) → {ok, type, bytes, public}
111
+ * files.list({prefix, after, limit = 100}) → {ok, items: [{path, bytes, type, public, updatedAt}], after}
112
+ * files.delete(path) → {ok, deleted}
113
+ * files.usage() → {ok, bytes, files, cap}
99
114
  */
100
115
  export const GF_METHODS = Object.freeze([
101
116
  'verifyPlayer',
@@ -112,8 +127,79 @@ export const GF_METHODS = Object.freeze([
112
127
  'loot.report',
113
128
  'loot.status',
114
129
  'info',
130
+ 'files.put',
131
+ 'files.get',
132
+ 'files.head',
133
+ 'files.list',
134
+ 'files.delete',
135
+ 'files.usage',
115
136
  ]);
116
- /** The platform's per-game limits (games.fun platform/limits.mjs), the same here so a game fits both. */
137
+
138
+ // ---- the game's project files: env.GF.files and the files domain (STORAGE.md §8.6–8.7) ---------------------------
139
+ // Files the developer or the game's server writes, never players (replays, generated levels, content packs). Live:
140
+ // the bucket xgames-files, key g/<gameId>/<path>; a public one served at https://<slug>.xgamesusercontent.com/<path>,
141
+ // another site to the browser. On this machine: <game>/.gamesfun/dev/files/, served on a port of its own (another
142
+ // origin, though the same site as the page's)
143
+ // (../dev/files.mjs).
144
+
145
+ /** The files' rules and caps (§8.6; the cap is Free's: Creator 10 GB, Scale 100 GB). */
146
+ export const FILES = Object.freeze({
147
+ path: /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/,
148
+ pathChars: 512,
149
+ fileBytes: 25 * 1024 * 1024,
150
+ capBytes: 100 * 1024 * 1024,
151
+ listMax: 1000,
152
+ domain: 'xgamesusercontent.com',
153
+ });
154
+
155
+ /** A file's path, checked: letters, digits, . _ - and / between them, no . or .. part, at most 512. → null or why */
156
+ export function filePathProblem(p) {
157
+ if (typeof p !== 'string' || !p) return 'A file needs a path, such as replays/2026-11-08.json.';
158
+ if (p.length > FILES.pathChars) return `A file's path is at most ${FILES.pathChars} characters.`;
159
+ if (!FILES.path.test(p)) return 'A path is letters, digits, ".", "_" and "-", with "/" between folders.';
160
+ if (p.split('/').some(part => part === '.' || part === '..')) return 'A path has no "." or ".." part.';
161
+ return null;
162
+ }
163
+
164
+ /** The types served as themselves (§8.7); anything else goes as application/octet-stream, to be downloaded. */
165
+ const INLINE =
166
+ /^(image\/(png|jpeg|webp|gif|avif)|audio\/[a-z0-9.+-]+|video\/[a-z0-9.+-]+|font\/[a-z0-9.+-]+|application\/json|text\/plain)$/;
167
+ /** How the files domain serves a file of this type. → {type, inline} */
168
+ export function servedType(type) {
169
+ const t = String(type ?? '')
170
+ .split(';')[0]
171
+ .trim()
172
+ .toLowerCase();
173
+ return INLINE.test(t)
174
+ ? { type: t === 'text/plain' ? 'text/plain; charset=utf-8' : t, inline: true }
175
+ : { type: 'application/octet-stream', inline: false };
176
+ }
177
+ const BY_EXTENSION = {
178
+ png: 'image/png',
179
+ jpg: 'image/jpeg',
180
+ jpeg: 'image/jpeg',
181
+ webp: 'image/webp',
182
+ gif: 'image/gif',
183
+ avif: 'image/avif',
184
+ mp3: 'audio/mpeg',
185
+ ogg: 'audio/ogg',
186
+ wav: 'audio/wav',
187
+ m4a: 'audio/mp4',
188
+ mp4: 'video/mp4',
189
+ webm: 'video/webm',
190
+ woff2: 'font/woff2',
191
+ woff: 'font/woff',
192
+ ttf: 'font/ttf',
193
+ otf: 'font/otf',
194
+ json: 'application/json',
195
+ txt: 'text/plain',
196
+ };
197
+ /** A file's type from its name, when the put names none. */
198
+ export const fileType = p => BY_EXTENSION[/\.([a-z0-9]+)$/i.exec(p)?.[1]?.toLowerCase()] ?? 'application/octet-stream';
199
+
200
+ /** Server code that calls env.GF.files (its sources, comments left out), which needs "files" in server.features. */
201
+ export const usesFiles = code => /\bGF\s*\.\s*files\b|\bfiles\b[^;{}]*\}\s*=\s*[\w.]*\bGF\b/.test(code);
202
+ /** The platform's per-game limits (xgames.fun platform/limits.mjs), the same here so a game fits both. */
117
203
  export const PLATFORM_LIMITS = Object.freeze({
118
204
  saves: Object.freeze({ playerChars: 128, valueBytes: 64 * 1024, keysPerPlayer: 16 }),
119
205
  scores: Object.freeze({ boards: 16, rules: 32, nameChars: 32, perPlayerEveryMs: 1000, topMax: 100 }),
@@ -123,7 +209,7 @@ export const PLATFORM_LIMITS = Object.freeze({
123
209
  export const SAVE_KEY = /^[A-Za-z0-9_.-]{1,32}$/;
124
210
  const ADDRESS = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
125
211
  const ROUND = /^[0-9a-f]{32}$/;
126
- /** A loot result's shape, as games.fun checks it (platform/loot.mjs checkResult, less the owner's grant list). → null or a refusal */
212
+ /** A loot result's shape, as xgames.fun checks it (platform/loot.mjs checkResult, less the owner's grant list). → null or a refusal */
127
213
  export function checkLootResult(r, now = Date.now()) {
128
214
  const no = (code, why) => ({ ok: false, code, why });
129
215
  if (!r || typeof r !== 'object') return no('shape', 'A result is {round, endedAt, winners}.');
@@ -143,7 +229,7 @@ export function checkLootResult(r, now = Date.now()) {
143
229
  }
144
230
 
145
231
  /**
146
- * Does this games.fun take server code? The contract (§6) does not say how a kit learns it, so the kit asks GET
232
+ * Does this xgames.fun take server code? The contract (§6) does not say how a kit learns it, so the kit asks GET
147
233
  * /api/kit/me for `server: { contracts: ['gf-server-v1'], maxBytes? }` (also read: `formats`, `contract`) and sends
148
234
  * server code only when it is there. → { ok, maxBytes } | { ok: false, why }
149
235
  */
@@ -155,17 +241,25 @@ export function studioAccepts(me) {
155
241
  if (!formats.includes(SERVER_FORMAT))
156
242
  return {
157
243
  ok: false,
158
- why: `this games.fun does not take server code yet (GET /api/kit/me does not list ${SERVER_FORMAT})`,
244
+ why: `this xgames.fun does not take server code yet (GET /api/kit/me does not list ${SERVER_FORMAT})`,
159
245
  };
160
246
  const maxBytes = Number.isFinite(s.maxBytes) && s.maxBytes > 0 ? Math.min(s.maxBytes, LIMITS.bytes) : LIMITS.bytes;
161
247
  return { ok: true, maxBytes };
162
248
  }
163
249
 
250
+ /**
251
+ * What else this xgames.fun runs for a game (STORAGE.md §8.1): GET /api/kit/me → server.features, a list that has
252
+ * "d1" once it makes each game a database, and "files" once env.GF.files and the files domain are live. → a Set
253
+ */
254
+ export const studioFeatures = me =>
255
+ new Set(Array.isArray(me?.server?.features) ? me.server.features.filter(f => typeof f === 'string') : []);
256
+
164
257
  /**
165
258
  * The publish body's `server` field (POST /api/kit/publish), the manifest of §6.1. The module's bytes go up through
166
- * the same blob route as the files (POST /api/kit/publish/<id>/blobs/<sha256>), named in the answer's `missing`.
259
+ * the same blob route as the files (POST /api/kit/publish/<id>/blobs/<sha256>), named in the answer's `missing`; so
260
+ * do the database's migration files (STORAGE.md §8.3: `database: {binding, migrations: [{name, sha256, bytes}]}`).
167
261
  */
168
- export function publishField({ sha256, bytes, objects, compatibilityFlags, wasm = [] }) {
262
+ export function publishField({ sha256, bytes, objects, compatibilityFlags, wasm = [], database = null }) {
169
263
  return {
170
264
  contract: SERVER_FORMAT,
171
265
  main: BUNDLE_NAME,
@@ -176,9 +270,317 @@ export function publishField({ sha256, bytes, objects, compatibilityFlags, wasm
176
270
  objects: objects.map(o => ({ class: o.class, binding: o.binding })),
177
271
  compatibilityDate: COMPATIBILITY_DATE,
178
272
  compatibilityFlags: [...compatibilityFlags],
273
+ ...(database?.has
274
+ ? {
275
+ database: {
276
+ binding: database.binding,
277
+ migrations: database.migrations.map(m => ({ name: m.name, sha256: m.sha256, bytes: m.bytes })),
278
+ },
279
+ }
280
+ : {}),
281
+ };
282
+ }
283
+
284
+ // ---- the game's database: gamesfun.json → database (STORAGE.md §8.2) ---------------------------------------------
285
+ // "database": { "engine": "d1", "binding": "DB", "migrations": "server/migrations",
286
+ // "collections": { "*": {"read": "owner", "write": "owner"} } }
287
+ // One D1 database a game, bound to its server as env.<binding>. Its tables come from the migration files, applied in
288
+ // name order, each once; once applied a migration never changes (append only). `collections` are the rules of the
289
+ // page's documents (gf.db, api/db/*): a game with a database and no server/ gets xcore's stock server
290
+ // (../client/server/stock.js), which serves them from the database; a game with a server of its own serves them with
291
+ // server/gf-docs.js (`xcore add` writes it), and publish refuses one whose server does not route api/db/.
292
+
293
+ /** The keys gamesfun.json → database takes. */
294
+ export const DATABASE_KEYS = Object.freeze(['engine', 'binding', 'migrations', 'collections']);
295
+ export const DATABASE_DEFAULTS = Object.freeze({ engine: 'd1', binding: 'DB', migrations: 'server/migrations' });
296
+ /** A migration's file name: four digits, then words (0001_init.sql). */
297
+ export const MIGRATION_NAME = /^\d{4}_[a-z0-9_-]+\.sql$/;
298
+ /**
299
+ * The most a request through xgames.fun's Studio may carry (§8.10): it runs on Vercel, which caps a request body at
300
+ * 4.5 MB. An owner's `db import --live` and `files put --live` are held to it; a bigger file goes up from the game's
301
+ * server (env.GF.files.put, up to 25 MB).
302
+ */
303
+ export const STUDIO_BODY_BYTES = 4_500_000;
304
+ /** xgames.fun's caps (§8.3, §8.5): 200 migrations of 1 MB each; an import of 5 MB. */
305
+ export const DATABASE_LIMITS = Object.freeze({
306
+ migrations: 200,
307
+ migrationBytes: 1024 * 1024,
308
+ importBytes: 5 * 1024 * 1024,
309
+ // D1's longest SQL statement (developers.cloudflare.com/d1/platform/limits); each statement of a migration is held
310
+ // to it on its own (§8.10), so a migration may still be 1 MB of many statements
311
+ statementBytes: 100_000,
312
+ });
313
+ /** In server/database.js since 0.2.0 (its table's key): every player a space of their own; 0.1.0's had (collection, id). */
314
+ export const OWNER_SPACES = 'PRIMARY KEY (collection, owner, id)';
315
+ /** RULES as xcore 0.1.0 wrote it into server/database.js, untouched: anyone read every document. */
316
+ export const OLD_DEFAULT_RULES = "export const RULES = {\n '*': { read: 'anyone', write: 'owner' },\n};";
317
+ /** The collections' rules when the stock server has none: each player reads and writes only their own. */
318
+ export const DEFAULT_COLLECTIONS = Object.freeze({ '*': Object.freeze({ read: 'owner', write: 'owner' }) });
319
+ /** What the stock server is called where a server's entry file would be named. */
320
+ export const STOCK_ENTRY = "xcore's stock server";
321
+ const COLLECTION = /^[A-Za-z0-9_-]{1,64}$/;
322
+
323
+ /**
324
+ * The database block, checked (§8.2). `objects`: the server's Durable Objects, whose bindings it must not take.
325
+ * → { has, engine, binding, migrationsDir, migrations: [{name, path, sha256, bytes, data, text}], collections, problems,
326
+ * warnings }. `has` is false without a block.
327
+ */
328
+ export async function readDatabaseConfig(dir, config, { objects = [], own = false } = {}) {
329
+ const problems = [],
330
+ warnings = [];
331
+ const d = config?.database;
332
+ const none = { has: false, binding: null, migrations: [], collections: null, problems, warnings };
333
+ if (d === undefined) return none;
334
+ if (d === null || typeof d !== 'object' || Array.isArray(d)) {
335
+ problems.push(`${CONFIG_FILE} "database" is an object: {"engine": "d1", "binding": "DB"}`);
336
+ return none;
337
+ }
338
+ const extra = Object.keys(d).filter(k => !DATABASE_KEYS.includes(k));
339
+ if (extra.length)
340
+ problems.push(
341
+ `${CONFIG_FILE} database has ${extra.map(k => JSON.stringify(k)).join(', ')}, which it does not know (it takes ${DATABASE_KEYS.join(', ')})`,
342
+ );
343
+ const engine = d.engine ?? DATABASE_DEFAULTS.engine;
344
+ if (engine !== 'd1') problems.push(`${CONFIG_FILE} database.engine is "d1" (the only engine xgames.fun runs)`);
345
+ const binding = d.binding ?? DATABASE_DEFAULTS.binding;
346
+ if (typeof binding !== 'string' || !BINDING_NAME.test(binding) || reservedBinding(binding))
347
+ problems.push(
348
+ `${CONFIG_FILE} database.binding ${JSON.stringify(binding)} must be A–Z, 0–9 and _, starting with a letter, at most 32 characters, and not GF or GF_* (it is env.<binding> in the server; the default is "DB")`,
349
+ );
350
+ else if (objects.some(o => o.binding === binding))
351
+ problems.push(
352
+ `${CONFIG_FILE} database.binding ${binding} is also a Durable Object's binding (server.objects): give one of them another name`,
353
+ );
354
+ // the migrations stay inside server/, which is never a public file
355
+ const folder =
356
+ typeof d.migrations === 'string' ? d.migrations.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '') : null;
357
+ let migrationsDir = DATABASE_DEFAULTS.migrations;
358
+ if (
359
+ d.migrations !== undefined &&
360
+ (folder === null || !/^server\/[A-Za-z0-9_./-]+$/.test(folder) || folder.split('/').includes('..'))
361
+ )
362
+ problems.push(
363
+ `${CONFIG_FILE} database.migrations is a folder inside server/, such as "server/migrations" (server/ is never a public file)`,
364
+ );
365
+ else if (folder !== null) migrationsDir = folder;
366
+ const migrations = [];
367
+ const abs = path.join(dir, ...migrationsDir.split('/'));
368
+ if (existsSync(abs)) {
369
+ const names = (await readdir(abs, { withFileTypes: true }))
370
+ .filter(e => e.isFile() && !e.name.startsWith('.'))
371
+ .map(e => e.name)
372
+ .sort();
373
+ const seen = new Map();
374
+ for (const name of names) {
375
+ if (!MIGRATION_NAME.test(name)) {
376
+ if (/\.sql$/i.test(name))
377
+ problems.push(
378
+ `${migrationsDir}/${name}: a migration's name is four digits, _ and lowercase words, such as 0002_add_guilds.sql`,
379
+ );
380
+ else warnings.push(`${migrationsDir}/${name} is not a migration (NNNN_name.sql): it is left out`);
381
+ continue;
382
+ }
383
+ const n = name.slice(0, 4);
384
+ if (seen.has(n)) {
385
+ problems.push(`${migrationsDir}: ${seen.get(n)} and ${name} have the same number; give each its own`);
386
+ continue;
387
+ }
388
+ seen.set(n, name);
389
+ const bytes = await readFile(path.join(abs, name));
390
+ const empty = emptyMigration({ name, path: `${migrationsDir}/${name}`, bytes: bytes.length });
391
+ if (empty) problems.push(empty);
392
+ else if (bytes.length > DATABASE_LIMITS.migrationBytes)
393
+ problems.push(`${migrationsDir}/${name} is ${(bytes.length / 1048576).toFixed(1)} MB; a migration may be 1 MB`);
394
+ else {
395
+ // xgames.fun refuses it at publish (422 migration-failed), with nothing of the publish's migrations applied
396
+ const why = migrationStatementsProblem({ name, text: bytes.toString('utf8') });
397
+ if (why) problems.push(`${migrationsDir}/${name}: ${why}. xgames.fun would refuse it (migration-failed)`);
398
+ }
399
+ migrations.push({
400
+ name,
401
+ path: `${migrationsDir}/${name}`,
402
+ sha256: createHash('sha256').update(bytes).digest('hex'),
403
+ bytes: bytes.length,
404
+ data: bytes,
405
+ text: bytes.toString('utf8'),
406
+ });
407
+ }
408
+ if (migrations.length > DATABASE_LIMITS.migrations)
409
+ problems.push(
410
+ `${migrationsDir} has ${migrations.length} migrations; a game may have ${DATABASE_LIMITS.migrations}`,
411
+ );
412
+ }
413
+ let collections = null;
414
+ if (d.collections !== undefined) {
415
+ if (!d.collections || typeof d.collections !== 'object' || Array.isArray(d.collections))
416
+ problems.push(
417
+ `${CONFIG_FILE} database.collections is an object: {"*": {"read": "owner", "write": "owner"}, "profiles": {"read": "anyone", "write": "owner"}}`,
418
+ );
419
+ else {
420
+ collections = {};
421
+ for (const [name, rule] of Object.entries(d.collections)) {
422
+ if (name !== '*' && !COLLECTION.test(name)) {
423
+ problems.push(
424
+ `${CONFIG_FILE} database.collections: ${JSON.stringify(name)} is not a collection's name (1 to 64 letters, digits, - or _; or "*")`,
425
+ );
426
+ continue;
427
+ }
428
+ const keys = rule && typeof rule === 'object' && !Array.isArray(rule) ? Object.keys(rule) : null;
429
+ if (
430
+ !keys ||
431
+ keys.some(k => k !== 'read' && k !== 'write') ||
432
+ (rule.read !== undefined && !['owner', 'anyone'].includes(rule.read)) ||
433
+ (rule.write !== undefined && !['owner', 'server'].includes(rule.write))
434
+ ) {
435
+ problems.push(
436
+ `${CONFIG_FILE} database.collections.${name} is {"read": "owner" | "anyone", "write": "owner" | "server"}`,
437
+ );
438
+ continue;
439
+ }
440
+ collections[name] = { ...(rule.read ? { read: rule.read } : {}), ...(rule.write ? { write: rule.write } : {}) };
441
+ }
442
+ }
443
+ }
444
+ return {
445
+ has: true,
446
+ engine,
447
+ binding: typeof binding === 'string' ? binding : DATABASE_DEFAULTS.binding,
448
+ migrationsDir,
449
+ migrations,
450
+ // the stock server's rules, or a server of its own's when it has them (server/gf-docs.js reads them)
451
+ collections: own ? collections : (collections ?? { ...DEFAULT_COLLECTIONS }),
452
+ problems,
453
+ warnings,
179
454
  };
180
455
  }
181
456
 
457
+ /**
458
+ * The SQL's statements, split on semicolons outside strings, quoted names and comments, with the comments removed.
459
+ * → [statement text, trimmed] (empty statements dropped). An unterminated string or comment → null. With `triggers`, a
460
+ * trigger's BEGIN … END is one statement. Copied from xgames.fun's creator-studio/lib/game-database.mjs as it is: the
461
+ * query route's rule (../db.mjs queryProblem) and a migration's statements as Studio sends them to D1.
462
+ */
463
+ export function sqlStatements(sql, { triggers = false } = {}) {
464
+ const out = [];
465
+ let cur = '',
466
+ i = 0,
467
+ head = [],
468
+ depth = 0;
469
+ const n = sql.length;
470
+ // A trigger's body (CREATE [TEMP] TRIGGER … BEGIN …; …; END) is one statement whose own statements end in `;`, read
471
+ // as SQLite reads it (and as ../dev/d1.mjs splitSql does): BEGIN, and a CASE inside the body, open what END closes.
472
+ const trigger = () =>
473
+ head[0] === 'CREATE' &&
474
+ (head[1] === 'TRIGGER' || ((head[1] === 'TEMP' || head[1] === 'TEMPORARY') && head[2] === 'TRIGGER'));
475
+ while (i < n) {
476
+ const c = sql[i],
477
+ d = sql[i + 1];
478
+ if (c === '-' && d === '-') {
479
+ const e = sql.indexOf('\n', i);
480
+ i = e < 0 ? n : e + 1;
481
+ cur += ' ';
482
+ continue;
483
+ }
484
+ if (c === '/' && d === '*') {
485
+ const e = sql.indexOf('*/', i + 2);
486
+ if (e < 0) return null;
487
+ i = e + 2;
488
+ cur += ' ';
489
+ continue;
490
+ }
491
+ if (c === "'" || c === '"' || c === '`' || c === '[') {
492
+ const close = c === '[' ? ']' : c;
493
+ let j = i + 1;
494
+ for (;;) {
495
+ if (j >= n) return null;
496
+ if (sql[j] === close) {
497
+ if (close !== ']' && sql[j + 1] === close) {
498
+ j += 2;
499
+ continue;
500
+ }
501
+ break;
502
+ }
503
+ j++;
504
+ }
505
+ cur += sql.slice(i, j + 1);
506
+ i = j + 1;
507
+ continue;
508
+ }
509
+ if (triggers && /[A-Za-z_]/.test(c) && !/[A-Za-z0-9_$]/.test(sql[i - 1] ?? '')) {
510
+ const w = /^[A-Za-z_][A-Za-z0-9_$]*/.exec(sql.slice(i, i + 64))[0],
511
+ W = w.toUpperCase();
512
+ if (head.length < 3) head.push(W);
513
+ if (trigger()) {
514
+ if (W === 'BEGIN' || (W === 'CASE' && depth > 0)) depth++;
515
+ else if (W === 'END' && depth > 0) depth--;
516
+ }
517
+ cur += w;
518
+ i += w.length;
519
+ continue;
520
+ }
521
+ if (c === ';' && depth === 0) {
522
+ if (cur.trim()) out.push(cur.trim());
523
+ cur = '';
524
+ head = [];
525
+ i++;
526
+ continue;
527
+ }
528
+ cur += c;
529
+ i++;
530
+ }
531
+ if (cur.trim()) out.push(cur.trim());
532
+ return out;
533
+ }
534
+
535
+ const clip = s => {
536
+ const t = s.replace(/\s+/g, ' ');
537
+ return t.length > 80 ? `${t.slice(0, 80)}…` : t;
538
+ };
539
+ /**
540
+ * A migration D1 cannot run as it is, in xgames.fun's words (§8.10): SQL that never ends, or a statement over D1's
541
+ * 100,000 bytes (UTF-8, as Studio sends it: comments left out). `m`: {name, text}. → null, or why (without an end mark)
542
+ */
543
+ export function migrationStatementsProblem(m) {
544
+ const statements = sqlStatements(m.text, { triggers: true });
545
+ if (!statements) return `${m.name} has a string, a quoted name or a comment that never ends`;
546
+ for (const [i, st] of statements.entries()) {
547
+ const size = Buffer.byteLength(st);
548
+ if (size > DATABASE_LIMITS.statementBytes)
549
+ return `Statement ${i + 1} of ${m.name} (${clip(st)}) is ${size.toLocaleString('en-US')} bytes, and D1 runs at most ${DATABASE_LIMITS.statementBytes.toLocaleString('en-US')} bytes in one statement. Split it (an INSERT of many rows into several INSERTs, each under the limit)`;
550
+ }
551
+ return null;
552
+ }
553
+
554
+ /** A migration file with nothing in it (0 bytes) is refused, here as on xgames.fun. → null, or why */
555
+ export const emptyMigration = m =>
556
+ m.bytes === 0
557
+ ? `${m.path ?? m.name} is empty (0 bytes): put the migration's statements in it, or delete the file`
558
+ : null;
559
+
560
+ /**
561
+ * Do the migrations still begin with the ones already applied, each the same bytes (append only, §8.4.2)? `applied`:
562
+ * [{name, sha256}] in the order they were applied. → null, or {code, name, why} in plain words.
563
+ */
564
+ export function migrationsChanged(migrations, applied = []) {
565
+ for (let i = 0; i < applied.length; i++) {
566
+ const was = applied[i],
567
+ now = migrations[i];
568
+ if (now?.name === was.name && now.sha256 === was.sha256) continue;
569
+ const there = migrations.find(m => m.name === was.name);
570
+ const why = !there
571
+ ? `${was.name} was applied to the database, and it is not there any more`
572
+ : there.sha256 !== was.sha256
573
+ ? `${was.name} changed after it was applied to the database`
574
+ : `${now?.name ?? 'a migration'} comes before ${was.name}, which was applied already`;
575
+ return {
576
+ code: 'migration-changed',
577
+ name: was.name,
578
+ why: `${why}. A migration is never changed once applied: put it back as it was, and make the change in a new migration after the last one`,
579
+ };
580
+ }
581
+ return null;
582
+ }
583
+
182
584
  /**
183
585
  * The game's server settings (§2). → { has, entry (relative, posix), objects: [{class, binding}], compatibilityFlags,
184
586
  * problems, config (gamesfun.json or null) }. `has` is false for a game without server code (no gamesfun.json
@@ -205,8 +607,25 @@ export async function readServerConfig(dir) {
205
607
  `${CONFIG_FILE} "server" is an object: {"main": "server/index.js", "objects": [{"class": "Room", "binding": "ROOM"}]}`,
206
608
  );
207
609
  const srv = s && typeof s === 'object' && !Array.isArray(s) ? s : {};
208
- const has = s !== undefined || existsSync(path.join(dir, DEFAULT_ENTRY));
209
- if (!has) return { has: false, entry: null, objects: [], compatibilityFlags: [], problems, config };
610
+ const own = s !== undefined || existsSync(path.join(dir, DEFAULT_ENTRY));
611
+ if (!own) {
612
+ // a database and no server of its own: xcore's stock server serves the page's api/db/ from it
613
+ const database = await readDatabaseConfig(dir, config);
614
+ problems.push(...database.problems);
615
+ if (!database.has)
616
+ return { has: false, entry: null, objects: [], compatibilityFlags: [], problems, warnings: [], database, config };
617
+ return {
618
+ has: true,
619
+ stock: true,
620
+ entry: STOCK_ENTRY,
621
+ objects: [],
622
+ compatibilityFlags: [],
623
+ problems,
624
+ warnings: [...database.warnings],
625
+ database,
626
+ config,
627
+ };
628
+ }
210
629
  // a misspelled key (object for objects) was silently ignored, and the server went up without its Durable Objects
211
630
  const extra = srv && typeof srv === 'object' ? Object.keys(srv).filter(k => !SERVER_KEYS.includes(k)) : [];
212
631
  if (extra.length)
@@ -256,12 +675,17 @@ export async function readServerConfig(dir) {
256
675
  problems.push(
257
676
  `${CONFIG_FILE} server.compatibilityFlags may hold only ${ALLOWED_FLAGS.map(f => JSON.stringify(f)).join(', ')}`,
258
677
  );
678
+ const database = await readDatabaseConfig(dir, config, { objects, own: true });
679
+ problems.push(...database.problems);
259
680
  return {
260
681
  has: true,
682
+ stock: false,
261
683
  entry,
262
684
  objects,
263
685
  compatibilityFlags: Array.isArray(flags) ? [...new Set(flags.filter(f => ALLOWED_FLAGS.includes(f)))] : [],
264
686
  problems,
687
+ warnings: [...database.warnings],
688
+ database,
265
689
  config,
266
690
  };
267
691
  }