@zaisma/game-server 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/node-parts.js CHANGED
@@ -96,7 +96,8 @@ export function localDatabase(file) {
96
96
  statement.setReadBigInts(true);
97
97
  for (const row of statement.iterate(...values)) {
98
98
  if (rows.length >= DATABASE_LIMITS.maximumRowCount) throw tooManyRows();
99
- rows.push(checkedRow(numbersFrom(row)));
99
+ // node:sqlite gives an INTEGER as a BigInt and a REAL as a number: a REAL as big as 1e20 is no whole number to refuse.
100
+ rows.push(checkedRow(numbersFrom(row), new Set(Object.entries(row).filter(([, columnValue]) => typeof columnValue === "number").map(([column]) => column))));
100
101
  }
101
102
  return rows;
102
103
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zaisma/game-server",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Run your Zaisma game server: the same rooms on Zaisma or on your own server.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://zaisma.com",
package/sdk/saves.js CHANGED
@@ -33,7 +33,7 @@
33
33
  // first, doubling up to 1,024 while a batch's text stays under 1 MB (games keep whole files as records).
34
34
  var FIRST_BATCH_RECORD_COUNT = 16;
35
35
  var MAXIMUM_BATCH_RECORD_COUNT = 1024;
36
- var SMALL_BATCH_CHARACTER_COUNT = 1024 * 1024;
36
+ var SMALL_BATCH_BYTE_COUNT = 1024 * 1024;
37
37
 
38
38
  // Tests check the save format through this (test/node/saves.test.ts).
39
39
  if (__ZAISMA_SAVES__.test) return __ZAISMA_SAVES__.test({ encode: encode, decode: decode, saveText: saveText, putSave: putSave, readDatabases: readDatabases, databasesReader: databasesReader });
@@ -67,6 +67,17 @@
67
67
  return textOf(canonical(local), idbText);
68
68
  }
69
69
  // The same text as canonicalSave on the server: all three keys, always in this order.
70
+ /** Text's size in UTF-8 bytes, as the server counts a save against the limit, without encoding it. */
71
+ function byteLength(text) {
72
+ var byteCount = text.length;
73
+ for (var i = 0; i < text.length; i++) {
74
+ var code = text.charCodeAt(i);
75
+ if (code < 0x80) continue;
76
+ // Two bytes below U+0800, and each half of a surrogate pair (four bytes the pair); three for the rest.
77
+ byteCount += code < 0x800 || (code >= 0xd800 && code < 0xe000) ? 1 : 2;
78
+ }
79
+ return byteCount;
80
+ }
70
81
  function textOf(localText, idbText) {
71
82
  return '{"save_format_version_number":1,"local_storage_items":' + localText + ',"indexeddb_databases":' + (idbText || "[]") + "}";
72
83
  }
@@ -319,21 +330,23 @@
319
330
 
320
331
  /**
321
332
  * readNumberbText while the game plays: each store's text is kept, and a read only reads again the stores
322
- * the game changed (changed: { isAll, databases: [name], stores: ["<database>\n<store>"] }), a batch of
323
- * records at a time, stopping once the save passes maximumCharacterCount characters. The text is exactly
333
+ * the game changed (changed: { isAll, isCountCheck, databases: [name], stores: ["<database>\n<store>"] }), a batch
334
+ * of records at a time, stopping once the save passes maximumByteCount bytes (UTF-8, as the server counts).
335
+ * isCountCheck also reads again any database whose version, stores or record counts changed, which catches most
336
+ * writes the script can't watch (the game's own workers) without reading what didn't change. The text is exactly
324
337
  * readNumberbText's, put together from the kept parts. Answers { text } or { isOverLimit: true }.
325
338
  */
326
339
  function databasesReader() {
327
340
  // Database name -> { version, stores: { name: { text, hasRecords } } }
328
341
  var kept = {};
329
- return function read(changed, maximumCharacterCount) {
342
+ return function read(changed, maximumByteCount) {
330
343
  var changedStores = {};
331
344
  changed.stores.forEach(function (storeKey) {
332
345
  changedStores[storeKey] = true;
333
346
  });
334
347
  return names().then(function (list) {
335
348
  var keptNow = {};
336
- var characterCount = 0;
349
+ var byteCount = 0;
337
350
  var index = 0;
338
351
  function next() {
339
352
  if (index === list.length) {
@@ -355,26 +368,73 @@
355
368
  var isAnyStore = isWhole || Object.keys(changedStores).some(function (storeKey) {
356
369
  return storeKey.indexOf(name + "\n") === 0;
357
370
  });
358
- if (!isAnyStore) {
371
+ var keep = function () {
359
372
  keptNow[name] = before;
360
- characterCount += before.characterCount;
361
- return characterCount > maximumCharacterCount ? { isOverLimit: true } : next();
362
- }
363
- return readChanged(name, before, isWhole, changedStores, maximumCharacterCount - characterCount).then(function (database) {
364
- if (database && database.isOverLimit) return { isOverLimit: true };
365
- if (database) {
366
- keptNow[name] = database;
367
- characterCount += database.characterCount;
368
- }
369
- return next();
373
+ byteCount += before.byteCount;
374
+ return byteCount > maximumByteCount ? { isOverLimit: true } : next();
375
+ };
376
+ var readAgain = function (isWholeRead) {
377
+ return readChanged(name, before, isWholeRead, changedStores, maximumByteCount - byteCount).then(function (database) {
378
+ if (database && database.isOverLimit) return { isOverLimit: true };
379
+ if (database) {
380
+ keptNow[name] = database;
381
+ byteCount += database.byteCount;
382
+ }
383
+ return next();
384
+ });
385
+ };
386
+ if (isAnyStore) return readAgain(isWhole);
387
+ if (!changed.isCountCheck) return keep();
388
+ return countsChanged(name, before).then(function (isChanged) {
389
+ return isChanged ? readAgain(true) : keep();
370
390
  });
371
391
  }
372
392
  return next();
373
393
  });
374
394
  };
375
395
  }
396
+ /** Whether a database kept from an earlier read looks changed: its version, stores or a store's record count. Only counts are read, never records. */
397
+ function countsChanged(name, before) {
398
+ var created = false;
399
+ return open(name, 0, function () {
400
+ created = true;
401
+ }).then(function (database) {
402
+ var stores = storeNames(database);
403
+ if (created || database.version !== before.version || stores.join("\n") !== before.storeNames.join("\n")) {
404
+ database.close();
405
+ if (created) indexedDB.deleteDatabase(name);
406
+ return true;
407
+ }
408
+ if (!stores.length) {
409
+ database.close();
410
+ return false;
411
+ }
412
+ return new Promise(function (ok) {
413
+ var transaction = database.transaction(stores, "readonly");
414
+ var recordCounts = {};
415
+ stores.forEach(function (storeName) {
416
+ var counting = transaction.objectStore(storeName).count();
417
+ counting.onsuccess = function () {
418
+ recordCounts[storeName] = counting.result;
419
+ };
420
+ });
421
+ transaction.oncomplete = function () {
422
+ database.close();
423
+ ok(
424
+ stores.some(function (storeName) {
425
+ return recordCounts[storeName] !== before.stores[storeName].recordCount;
426
+ }),
427
+ );
428
+ };
429
+ transaction.onerror = transaction.onabort = function () {
430
+ database.close();
431
+ ok(true);
432
+ };
433
+ });
434
+ });
435
+ }
376
436
  /** One database for databasesReader: its stores read again where changed, the rest as kept. null when it's gone or has no stores. */
377
- function readChanged(name, before, isWhole, changedStores, maximumCharacterCount) {
437
+ function readChanged(name, before, isWhole, changedStores, maximumByteCount) {
378
438
  var created = false;
379
439
  return open(name, 0, function () {
380
440
  created = true;
@@ -390,12 +450,12 @@
390
450
  var reading = stores.filter(function (storeName) {
391
451
  return isWhole || !before.stores[storeName] || changedStores[name + "\n" + storeName];
392
452
  });
393
- var out = { version: database.version, storeNames: stores, stores: {}, characterCount: 0, hasRecords: false };
453
+ var out = { version: database.version, storeNames: stores, stores: {}, byteCount: 0, hasRecords: false };
394
454
  stores.forEach(function (storeName) {
395
455
  if (reading.indexOf(storeName) === -1) out.stores[storeName] = before.stores[storeName];
396
456
  });
397
457
  stores.forEach(function (storeName) {
398
- if (out.stores[storeName]) out.characterCount += out.stores[storeName].text.length;
458
+ if (out.stores[storeName]) out.byteCount += out.stores[storeName].byteCount;
399
459
  });
400
460
  if (!reading.length) {
401
461
  database.close();
@@ -421,20 +481,21 @@
421
481
  var keys = store.getAllKeys(range, batchRecordCount);
422
482
  var values = store.getAll(range, batchRecordCount);
423
483
  values.onsuccess = function () {
424
- var previousCharacterCount = out.characterCount;
484
+ var previousByteCount = out.byteCount;
425
485
  var blobCharacterCount = pending.blobCharacterCount || 0;
426
486
  values.result.forEach(function (record, i) {
427
487
  var encoded = encode([keys.result[i], record], pending, []);
428
488
  copy.records.push(encoded);
429
- out.characterCount += JSON.stringify(encoded).length + 1;
489
+ out.byteCount += byteLength(JSON.stringify(encoded)) + 1;
430
490
  });
431
- out.characterCount += (pending.blobCharacterCount || 0) - blobCharacterCount;
432
- if (out.characterCount > maximumCharacterCount) {
491
+ // A file's base64 text is all ASCII: a character a byte.
492
+ out.byteCount += (pending.blobCharacterCount || 0) - blobCharacterCount;
493
+ if (out.byteCount > maximumByteCount) {
433
494
  isOverLimit = true;
434
495
  transaction.abort();
435
496
  return;
436
497
  }
437
- var isSmall = out.characterCount - previousCharacterCount < SMALL_BATCH_CHARACTER_COUNT;
498
+ var isSmall = out.byteCount - previousByteCount < SMALL_BATCH_BYTE_COUNT;
438
499
  if (values.result.length === batchRecordCount) nextBatch(keys.result[keys.result.length - 1], isSmall ? Math.min(batchRecordCount * 2, MAXIMUM_BATCH_RECORD_COUNT) : batchRecordCount);
439
500
  else nextStore();
440
501
  };
@@ -448,11 +509,12 @@
448
509
  var recordTexts = stored.records.map(function (record) {
449
510
  return JSON.stringify(record);
450
511
  });
451
- out.stores[stored.shape.name] = { text: shapeText.slice(0, -1) + ',"records":[' + recordTexts.join(",") + "]}", hasRecords: stored.records.length > 0 };
512
+ var storeText = shapeText.slice(0, -1) + ',"records":[' + recordTexts.join(",") + "]}";
513
+ out.stores[stored.shape.name] = { text: storeText, byteCount: byteLength(storeText), recordCount: stored.records.length, hasRecords: stored.records.length > 0 };
452
514
  });
453
- out.characterCount = 0;
515
+ out.byteCount = 0;
454
516
  stores.forEach(function (storeName) {
455
- out.characterCount += out.stores[storeName].text.length;
517
+ out.byteCount += out.stores[storeName].byteCount;
456
518
  });
457
519
  ok(finish(out));
458
520
  }, fail);
@@ -477,7 +539,7 @@
477
539
  var read = databasesReader();
478
540
  addEventListener("message", function (event) {
479
541
  var asked = event.data || {};
480
- read(asked.changed, asked.maximumCharacterCount).then(
542
+ read(asked.changed, asked.maximumByteCount).then(
481
543
  function (answer) {
482
544
  answer.id = asked.id;
483
545
  postMessage(answer);
@@ -596,7 +658,7 @@
596
658
  // The page always hands over a v1 save.
597
659
  var local = save.local_storage_items;
598
660
  var databases = decode(save.indexeddb_databases);
599
- // IndexedDB first: it's the part that can fail, and then localStorage is still as it was.
661
+ // IndexedDB first, so a database that can't be set up leaves localStorage as it was.
600
662
  var writing =
601
663
  window.indexedDB && indexedDB.databases
602
664
  ? writeDatabases(databases, onBlocked)
@@ -604,10 +666,20 @@
604
666
  ? Promise.reject(new Error("No IndexedDB here"))
605
667
  : Promise.resolve();
606
668
  return writing.then(function () {
607
- localStorage.clear();
608
- Object.keys(local).forEach(function (key) {
609
- localStorage.setItem(key, String(local[key]));
610
- });
669
+ // A write that throws (the browser's storage is full) puts this device's localStorage back as it was.
670
+ var before = snapshot();
671
+ try {
672
+ localStorage.clear();
673
+ Object.keys(local).forEach(function (key) {
674
+ localStorage.setItem(key, String(local[key]));
675
+ });
676
+ } catch (error) {
677
+ localStorage.clear();
678
+ Object.keys(before).forEach(function (key) {
679
+ localStorage.setItem(key, before[key]);
680
+ });
681
+ throw error;
682
+ }
611
683
  });
612
684
  }
613
685
 
@@ -681,7 +753,8 @@
681
753
 
682
754
  // What the game changed since the last check, so a check reads only that again: a store, a whole
683
755
  // database (set up anew or deleted) or localStorage. Writes the game's own workers make, and
684
- // localStorage set as a property, aren't seen, so every 10th check (5 minutes) reads everything.
756
+ // localStorage set as a property, aren't seen: every 10th check (5 minutes) reads localStorage and any
757
+ // database whose version or record counts changed, and leaving the page reads every database whole.
685
758
  var changedStores = {};
686
759
  var changedDatabases = {};
687
760
  var isLocalChanged = true;
@@ -735,7 +808,7 @@
735
808
  var hereReader = null;
736
809
  var waiting = null;
737
810
  var readNumber = 0;
738
- function readDatabasesText(changed, maximumCharacterCount) {
811
+ function readDatabasesText(changed, maximumByteCount) {
739
812
  if (!isWorkerTried) {
740
813
  isWorkerTried = true;
741
814
  try {
@@ -755,7 +828,7 @@
755
828
  }
756
829
  if (!worker) {
757
830
  hereReader = hereReader || databasesReader();
758
- return hereReader(changed, maximumCharacterCount);
831
+ return hereReader(changed, maximumByteCount);
759
832
  }
760
833
  var id = ++readNumber;
761
834
  return new Promise(function (ok) {
@@ -766,14 +839,14 @@
766
839
  ok(answer);
767
840
  },
768
841
  };
769
- worker.postMessage({ id: id, changed: changed, maximumCharacterCount: maximumCharacterCount });
842
+ worker.postMessage({ id: id, changed: changed, maximumByteCount: maximumByteCount });
770
843
  }).then(function (answer) {
771
844
  if (!answer.isFailed) return answer;
772
845
  // The worker can't read here (or stopped): read on the page from now on, from the start.
773
846
  if (worker) worker.terminate();
774
847
  worker = null;
775
848
  hereReader = databasesReader();
776
- return hereReader({ isAll: true, databases: [], stores: [] }, maximumCharacterCount);
849
+ return hereReader({ isAll: true, databases: [], stores: [] }, maximumByteCount);
777
850
  });
778
851
  }
779
852
 
@@ -781,35 +854,44 @@
781
854
  var last = null;
782
855
  var timer = null;
783
856
  var localText = null;
857
+ var localByteCount = 0;
784
858
  var idbText = null;
785
859
  var checkCount = 0;
786
860
  var reading = false;
787
861
  var again = false;
788
862
  // localStorage this small is read at every check anyway, which also catches it set as a property.
789
863
  var SMALL_LOCAL_CHARACTER_COUNT = 64 * 1024;
790
- /** isLeaving: the player is leaving the tab, so localStorage is read whatever was seen. */
791
- function check(isLeaving) {
864
+ /**
865
+ * isLeaving: the player is leaving the tab, so localStorage is read whatever was seen. isWhole: the page is going
866
+ * away, so every database is read whole, catching any write the script couldn't see.
867
+ */
868
+ function check(isLeaving, isWhole) {
792
869
  if (!syncing) return;
793
870
  if (reading) {
794
871
  again = true;
795
872
  return;
796
873
  }
797
- var isAll = isAllChanged || idbText === null || ++checkCount % 10 === 0;
798
- var changed = { isAll: isAll, databases: Object.keys(changedDatabases), stores: Object.keys(changedStores) };
799
- var readsIdb = isAll || changed.databases.length > 0 || changed.stores.length > 0;
800
- var readsLocal = isAll || isLocalChanged || localText === null || isLeaving === true || localText.length < SMALL_LOCAL_CHARACTER_COUNT;
874
+ var isAll = isAllChanged || idbText === null || isWhole === true;
875
+ // Every 10th check (5 minutes), also what the script can't watch: localStorage, and databases whose version or record counts changed.
876
+ var isCountCheck = !isAll && ++checkCount % 10 === 0;
877
+ var changed = { isAll: isAll, isCountCheck: isCountCheck, databases: Object.keys(changedDatabases), stores: Object.keys(changedStores) };
878
+ var readsIdb = isAll || isCountCheck || changed.databases.length > 0 || changed.stores.length > 0;
879
+ var readsLocal = isAll || isCountCheck || isLocalChanged || localText === null || isLeaving === true || localText.length < SMALL_LOCAL_CHARACTER_COUNT;
801
880
  if (!readsIdb && !readsLocal) return;
802
881
  changedStores = {};
803
882
  changedDatabases = {};
804
883
  isAllChanged = false;
805
884
  isLocalChanged = false;
806
885
  reading = true;
807
- if (readsLocal) localText = canonical(snapshot());
808
- var maximumCharacterCount = saveLimitBytes - localText.length;
809
- (readsIdb ? readDatabasesText(changed, maximumCharacterCount) : Promise.resolve({ text: idbText }))
886
+ if (readsLocal) {
887
+ localText = canonical(snapshot());
888
+ localByteCount = byteLength(localText);
889
+ }
890
+ var maximumByteCount = saveLimitBytes - localByteCount;
891
+ (readsIdb ? readDatabasesText(changed, maximumByteCount) : Promise.resolve({ text: idbText }))
810
892
  .then(function (answer) {
811
893
  if (answer.isFailed) throw new Error("The save couldn't be read");
812
- if (answer.isOverLimit || localText.length > saveLimitBytes) {
894
+ if (answer.isOverLimit || localByteCount > saveLimitBytes) {
813
895
  // Read again in full next time: what's kept stopped partway.
814
896
  idbText = null;
815
897
  post({ step: "over_limit" });
@@ -856,17 +938,15 @@
856
938
  fail(new Error("The game holds its database open"));
857
939
  }).then(ok, fail);
858
940
  })
859
- .then(function () {
860
- // What's in storage now is the save the page sent: the next check has nothing new to send.
861
- return readNumberbText();
862
- })
863
941
  .then(
864
- function (freshIdbText) {
865
- idbText = freshIdbText;
942
+ function () {
943
+ // What's in storage now is the save the page sent, already encoded: nothing is read again on the game's thread.
944
+ idbText = save.indexeddb_databases.length ? JSON.stringify(save.indexeddb_databases) : "";
866
945
  localText = canonical(snapshot());
867
- // The worker's kept parts are from before: the next check reads everything (and finds nothing new).
946
+ localByteCount = byteLength(localText);
947
+ // The worker's kept parts are from before: the next check reads everything there (and finds nothing new).
868
948
  isAllChanged = true;
869
- last = textOf(localText, freshIdbText);
949
+ last = textOf(localText, idbText);
870
950
  post({ step: "applied", ok: true });
871
951
  },
872
952
  function () {
@@ -878,7 +958,7 @@
878
958
  if (document.visibilityState === "hidden") check(true);
879
959
  });
880
960
  addEventListener("pagehide", function () {
881
- check(true);
961
+ check(true, true);
882
962
  });
883
963
  post({ step: "hello", phase: "game", versionId: versionId });
884
964
  })();
package/sdk/zaisma.d.ts CHANGED
@@ -1005,7 +1005,8 @@ declare namespace Zaisma {
1005
1005
  readonly canChat: boolean;
1006
1006
  /**
1007
1007
  * When Zaisma will end this match, an ISO time, or null: set once the game's hosting pauses for the
1008
- * month, an hour after the pause (the "ending" event). Never set in a room on your own server.
1008
+ * month, an hour after the pause (the "ending" event); null again if Zaisma calls the end off (the
1009
+ * "endingCancelled" event). Never set in a room on your own server.
1009
1010
  */
1010
1011
  readonly endsAt: string | null;
1011
1012
  /**
@@ -1222,6 +1223,11 @@ declare namespace Zaisma {
1222
1223
  * "ended" follows then.
1223
1224
  */
1224
1225
  ending: (ending: Ending) => void;
1226
+ /**
1227
+ * Zaisma called off the end the "ending" event announced, because the game's hosting started again:
1228
+ * room.endsAt is null, and the match goes on. Optional, as events added after v1 are, so v1's types still fit.
1229
+ */
1230
+ endingCancelled?: () => void;
1225
1231
  /** The game server or the host removed the player from the room. The connection closes. */
1226
1232
  kicked: (kicked: Kicked) => void;
1227
1233
  /** The room's listing, Join switch, name or labels changed, or how many watch: room.isListed, room.isOpen, room.friendJoinsEnabled, room.name, room.labels and room.spectatorCount have the new ones. */
package/sdk/zaisma.js CHANGED
@@ -29,7 +29,7 @@
29
29
  var SMOOTHING_OPTIONS = ["delayMs"];
30
30
  var DELIVERIES = ["reliable", "latest_only"];
31
31
  var ROOM_INFO_FIELDS = ["name", "labels"];
32
- var ROOM_EVENTS = ["message", "bytes", "state", "players", "connectionStatus", "ended", "ending", "kicked", "settings", "move", "moved", "idle", "away", "back", "muted", "error", "versionUpdate", "chat"];
32
+ var ROOM_EVENTS = ["message", "bytes", "state", "players", "connectionStatus", "ended", "ending", "endingCancelled", "kicked", "settings", "move", "moved", "idle", "away", "back", "muted", "error", "versionUpdate", "chat"];
33
33
  /** Chat (room.chat): the longest message, the lines a room keeps in memory for reports, and how many of them a report carries. */
34
34
  var MAXIMUM_CHAT_CHARACTER_COUNT = 2000;
35
35
  var KEPT_CHAT_LINE_COUNT = 50;
@@ -640,6 +640,9 @@
640
640
  } else if (message.type === "ending") {
641
641
  room.endsAt = message.endsAt;
642
642
  room._emit("ending", { endsAt: message.endsAt });
643
+ } else if (message.type === "ending_cancelled") {
644
+ room.endsAt = null;
645
+ room._emit("endingCancelled");
643
646
  } else if (message.type === "version_update") {
644
647
  var versionUpdate = { versionId: message.versionId, appliesAt: message.appliesAt, isReloading: message.isReloading === true };
645
648
  if (message.step === "now") room._isReloadingForVersion = versionUpdate.isReloading;
package/src/database.d.ts CHANGED
@@ -76,18 +76,22 @@ export declare function checkPlayerId(playerId: unknown): string;
76
76
  /** What erasePlayer says when a table's foreign key refuses the delete. */
77
77
  export declare function eraseRefused(tableName: string, error: unknown): DatabaseError;
78
78
  export declare const DATABASE_LIMITS: {
79
- /** One statement's SQL. */
79
+ /** One statement's SQL, in characters. */
80
80
  readonly maximumSqlCharacterCount: 100000;
81
+ /** One statement's SQL, in UTF-8 bytes: Cloudflare's SQLite takes 100 KB, so text in other scripts is held to it too. */
82
+ readonly maximumSqlSizeBytes: 100000;
81
83
  /** Values for one statement's ?s. */
82
84
  readonly maximumValueCount: 100;
83
- /** One text or blob value, in a query and in the database. */
85
+ /** One text or blob value a game server or query binds. */
84
86
  readonly maximumValueSizeBytes: number;
87
+ /** One text or blob value in an import, which brings back every row an export wrote: what the database holds in a value. */
88
+ readonly maximumImportValueSizeBytes: number;
85
89
  /** Statements in one batch. */
86
90
  readonly maximumStatementCount: 100;
87
91
  /** Rows one statement may answer; past it, page with LIMIT and OFFSET or a WHERE. */
88
92
  readonly maximumRowCount: 10000;
89
93
  };
90
- export type DatabaseErrorCode = "bad_sql" | "bad_values" | "bad_player_id" | "too_many_rows" | "hosting_paused" | "database_full" | "database_unavailable";
94
+ export type DatabaseErrorCode = "bad_sql" | "bad_values" | "bad_player_id" | "too_many_rows" | "hosting_paused" | "database_full" | "database_unavailable" | "integer_too_large";
91
95
  /** A call the database refused, with a code, what happened and what to do (like the developer API's errors, docs/NAMING.md). */
92
96
  export declare class DatabaseError extends Error {
93
97
  readonly code: DatabaseErrorCode;
@@ -110,12 +114,18 @@ export type DatabaseAnswer<T> = {
110
114
  export declare function readDatabaseAnswer<T>(answer: DatabaseAnswer<T>): T;
111
115
  /** Run work, answering its result or the DatabaseError it threw; any other error is SQLite's (bad_sql). */
112
116
  export declare function answerDatabase<T>(work: () => T): DatabaseAnswer<T>;
117
+ /**
118
+ * The PRAGMAs Cloudflare's SQLite runs; it refuses the rest (user_version, journal_mode…) with SQLITE_AUTH, so both
119
+ * runtimes refuse them. defer_foreign_keys is what an import's part runs first, in its transaction.
120
+ */
121
+ export declare const ALLOWED_PRAGMAS: readonly string[];
113
122
  /**
114
123
  * Check one statement and its values before it runs: one statement only, no transaction control,
115
- * values of the kinds SQLite takes. Throws a DatabaseError; answers the statement to run (without a
116
- * trailing ; or comment, which SQLite in a Durable Object takes as an empty statement) and its values.
124
+ * nothing Cloudflare's SQLite refuses, values of the kinds SQLite takes. Throws a DatabaseError; answers the
125
+ * statement to run (without a trailing ; or comment, which SQLite in a Durable Object takes as an empty
126
+ * statement) and its values. A text or blob value may be up to maximumValueSizeBytes (an import's: more).
117
127
  */
118
- export declare function checkStatement(sql: unknown, values: unknown): {
128
+ export declare function checkStatement(sql: unknown, values: unknown, maximumValueSizeBytes?: number): {
119
129
  sql: string;
120
130
  values: DatabaseValue[];
121
131
  };
@@ -128,9 +138,12 @@ export declare function checkBatch(statements: unknown): {
128
138
  export declare function plainRow(row: Record<string, unknown>): DatabaseRow;
129
139
  /**
130
140
  * A row as a game's query gets it: plainRow, refusing a whole number past 2^53, which JavaScript would
131
- * round (a game would read a different number than the one stored). Dumps and previews keep plainRow.
141
+ * round (a game would read a different number than the one stored): integer_too_large. Dumps and previews keep
142
+ * plainRow. realColumns names the columns that hold REALs, which can be as big (1e20) and are not refused.
132
143
  */
133
- export declare function checkedRow(row: Record<string, unknown>): DatabaseRow;
144
+ export declare function checkedRow(row: Record<string, unknown>, realColumns?: ReadonlySet<string>): DatabaseRow;
145
+ /** A whole number past 2^53 in a result, which JavaScript can't hold exactly. */
146
+ export declare function integerTooLarge(column: string): DatabaseError;
134
147
  export declare function tooManyRows(): DatabaseError;
135
148
  /** One statement in a piece of SQL, as scanStatements finds it. */
136
149
  export interface ScannedStatement {
package/src/database.js CHANGED
@@ -39,18 +39,22 @@ export function eraseRefused(tableName, error) {
39
39
  return new DatabaseError("bad_sql", `Erasing the player from ${tableName} failed, so nothing was erased: SQLite: ${sqliteMessage}`, "Give the foreign key ON DELETE CASCADE, or turn deleted_account_erase_enabled off and erase in your own order.");
40
40
  }
41
41
  export const DATABASE_LIMITS = {
42
- /** One statement's SQL. */
42
+ /** One statement's SQL, in characters. */
43
43
  maximumSqlCharacterCount: 100_000,
44
+ /** One statement's SQL, in UTF-8 bytes: Cloudflare's SQLite takes 100 KB, so text in other scripts is held to it too. */
45
+ maximumSqlSizeBytes: 100_000,
44
46
  /** Values for one statement's ?s. */
45
47
  maximumValueCount: 100,
46
- /** One text or blob value, in a query and in the database. */
48
+ /** One text or blob value a game server or query binds. */
47
49
  maximumValueSizeBytes: 1024 * 1024,
50
+ /** One text or blob value in an import, which brings back every row an export wrote: what the database holds in a value. */
51
+ maximumImportValueSizeBytes: 2 * 1024 * 1024,
48
52
  /** Statements in one batch. */
49
53
  maximumStatementCount: 100,
50
54
  /** Rows one statement may answer; past it, page with LIMIT and OFFSET or a WHERE. */
51
55
  maximumRowCount: 10_000,
52
56
  };
53
- const DATABASE_ERROR_CODES = ["bad_sql", "bad_values", "bad_player_id", "too_many_rows", "hosting_paused", "database_full", "database_unavailable"];
57
+ const DATABASE_ERROR_CODES = ["bad_sql", "bad_values", "bad_player_id", "too_many_rows", "hosting_paused", "database_full", "database_unavailable", "integer_too_large"];
54
58
  /** A call the database refused, with a code, what happened and what to do (like the developer API's errors, docs/NAMING.md). */
55
59
  export class DatabaseError extends Error {
56
60
  code;
@@ -86,16 +90,49 @@ export function answerDatabase(work) {
86
90
  }
87
91
  // Statements that run their own transactions or reach other files. batch() is the transaction.
88
92
  const REFUSED_FIRST_WORDS = new Set(["BEGIN", "COMMIT", "END", "ROLLBACK", "SAVEPOINT", "RELEASE", "ATTACH", "DETACH"]);
93
+ /**
94
+ * The PRAGMAs Cloudflare's SQLite runs; it refuses the rest (user_version, journal_mode…) with SQLITE_AUTH, so both
95
+ * runtimes refuse them. defer_foreign_keys is what an import's part runs first, in its transaction.
96
+ */
97
+ export const ALLOWED_PRAGMAS = ["table_info", "table_xinfo", "table_list", "index_list", "index_info", "index_xinfo", "foreign_key_list", "foreign_key_check", "defer_foreign_keys", "quick_check", "integrity_check", "optimize"];
98
+ /** Functions Cloudflare's SQLite refuses, which would answer in the Node runner. */
99
+ const REFUSED_FUNCTIONS = ["sqlite_version", "sqlite_source_id", "load_extension"];
100
+ /** SQL with its quotes, names and comments blanked, so a word is only found where it's SQL. */
101
+ function sqlWithoutQuotes(sql) {
102
+ return sql.replace(/'(?:[^']|'')*'|"(?:[^"]|"")*"|`[^`]*`|\[[^\]]*\]|--[^\n]*|\/\*[\s\S]*?\*\//g, " ");
103
+ }
104
+ /** What Cloudflare's SQLite refuses and Node's would run: PRAGMAs outside ALLOWED_PRAGMAS, TEMP objects and unauthorized functions. */
105
+ function checkRefusedByCloudflare(sql, leadingWords) {
106
+ const [firstWord = "", secondWord = ""] = leadingWords;
107
+ const bare = sqlWithoutQuotes(sql);
108
+ if (firstWord === "PRAGMA") {
109
+ const pragmaName = /^\s*PRAGMA\s+(?:\w+\s*\.\s*)?(\w+)/i.exec(bare)?.[1]?.toLowerCase() ?? "";
110
+ if (!ALLOWED_PRAGMAS.includes(pragmaName)) {
111
+ throw new DatabaseError("bad_sql", `PRAGMA ${pragmaName || "…"} isn't allowed: Zaisma's database doesn't take it, and neither does your own server's runner, so a query runs the same in both.`, `Allowed: ${ALLOWED_PRAGMAS.map((name) => `PRAGMA ${name}`).join(", ")}. To keep a schema version, use a table of your own, like CREATE TABLE schema_version (version_number INTEGER NOT NULL).`);
112
+ }
113
+ }
114
+ if (firstWord === "CREATE" && (secondWord === "TEMP" || secondWord === "TEMPORARY")) {
115
+ throw new DatabaseError("bad_sql", `CREATE ${secondWord} isn't allowed: the database keeps no temporary tables or objects.`, "Make an ordinary table and drop it when done, or use a WITH query for a result you only need inside one statement.");
116
+ }
117
+ const refusedFunction = REFUSED_FUNCTIONS.find((name) => new RegExp(`\\b${name}\\s*\\(`, "i").test(bare));
118
+ if (refusedFunction)
119
+ throw new DatabaseError("bad_sql", `${refusedFunction}() isn't allowed: Zaisma's database doesn't take it, and neither does your own server's runner.`, "Keep what you need to know about the database (a schema version, say) in a table of your own.");
120
+ }
89
121
  /**
90
122
  * Check one statement and its values before it runs: one statement only, no transaction control,
91
- * values of the kinds SQLite takes. Throws a DatabaseError; answers the statement to run (without a
92
- * trailing ; or comment, which SQLite in a Durable Object takes as an empty statement) and its values.
123
+ * nothing Cloudflare's SQLite refuses, values of the kinds SQLite takes. Throws a DatabaseError; answers the
124
+ * statement to run (without a trailing ; or comment, which SQLite in a Durable Object takes as an empty
125
+ * statement) and its values. A text or blob value may be up to maximumValueSizeBytes (an import's: more).
93
126
  */
94
- export function checkStatement(sql, values) {
127
+ export function checkStatement(sql, values, maximumValueSizeBytes = DATABASE_LIMITS.maximumValueSizeBytes) {
95
128
  if (typeof sql !== "string" || !sql.trim())
96
129
  throw new DatabaseError("bad_sql", "The SQL isn't text.", 'Give one SQL statement as text, like "SELECT * FROM scores WHERE player_id = ?".');
97
130
  if (sql.length > DATABASE_LIMITS.maximumSqlCharacterCount)
98
131
  throw new DatabaseError("bad_sql", `The SQL is ${sql.length} characters.`, `Keep one statement under ${DATABASE_LIMITS.maximumSqlCharacterCount} characters, and pass data as values instead of writing it into the SQL.`);
132
+ // Cloudflare's limit is in bytes: three bytes a character at most, so short statements need no count.
133
+ const sqlSizeBytes = sql.length * 3 <= DATABASE_LIMITS.maximumSqlSizeBytes ? 0 : new TextEncoder().encode(sql).length;
134
+ if (sqlSizeBytes > DATABASE_LIMITS.maximumSqlSizeBytes)
135
+ throw new DatabaseError("bad_sql", `The SQL is ${sqlSizeBytes} bytes.`, `Keep one statement under ${DATABASE_LIMITS.maximumSqlSizeBytes} bytes (UTF-8), and pass data as values instead of writing it into the SQL.`);
99
136
  const statements = scanStatements(sql);
100
137
  if (statements.length === 0)
101
138
  throw new DatabaseError("bad_sql", "The SQL has only comments.", 'Give one SQL statement, like "SELECT * FROM scores WHERE player_id = ?".');
@@ -104,21 +141,22 @@ export function checkStatement(sql, values) {
104
141
  const firstWord = statements[0].leadingWords[0] ?? "";
105
142
  if (REFUSED_FIRST_WORDS.has(firstWord))
106
143
  throw new DatabaseError("bad_sql", `${firstWord} isn't allowed.`, "For a transaction, use a batch (room.database.batch([{ sql, values }, ...]) in a game server, batch_database through the API): its statements all happen or none do.");
144
+ checkRefusedByCloudflare(sql.slice(statements[0].startIndex, statements[0].endIndex), statements[0].leadingWords);
107
145
  const given = values === undefined || values === null ? [] : values;
108
146
  if (!Array.isArray(given))
109
147
  throw new DatabaseError("bad_values", "values isn't a list.", 'Give the values for the ?s as a list, in order, like ["player_8f3k2m", 1200].');
110
148
  if (given.length > DATABASE_LIMITS.maximumValueCount)
111
149
  throw new DatabaseError("bad_values", `There are ${given.length} values.`, `Give up to ${DATABASE_LIMITS.maximumValueCount} values per statement; split big inserts into several statements in one batch.`);
112
- return { sql: sql.slice(0, statements[0].endIndex), values: given.map(checkValue) };
150
+ return { sql: sql.slice(0, statements[0].endIndex), values: given.map((givenValue, i) => checkValue(givenValue, i, maximumValueSizeBytes)) };
113
151
  }
114
152
  /** One value for a ?, the i-th (from 0). */
115
- function checkValue(givenValue, i) {
153
+ function checkValue(givenValue, i, maximumValueSizeBytes) {
116
154
  if (typeof givenValue === "number" && Number.isInteger(givenValue) && !Number.isSafeInteger(givenValue))
117
155
  throw new DatabaseError("bad_values", `Value ${i + 1} is a whole number past 2^53, which JavaScript can't hold exactly.`, "Store it as text, like String(value), and read it back as text.");
118
156
  if (givenValue === null || typeof givenValue === "string" || givenValue instanceof Uint8Array || (typeof givenValue === "number" && Number.isFinite(givenValue))) {
119
157
  const sizeBytes = typeof givenValue === "string" ? new TextEncoder().encode(givenValue).length : givenValue instanceof Uint8Array ? givenValue.byteLength : 0;
120
- if (sizeBytes > DATABASE_LIMITS.maximumValueSizeBytes)
121
- throw new DatabaseError("bad_values", `Value ${i + 1} is ${sizeBytes} bytes.`, `Keep each text or blob under ${DATABASE_LIMITS.maximumValueSizeBytes / 1024 / 1024} MB; store big files somewhere else and keep a link here.`);
158
+ if (sizeBytes > maximumValueSizeBytes)
159
+ throw new DatabaseError("bad_values", `Value ${i + 1} is ${sizeBytes} bytes.`, `Keep each text or blob under ${maximumValueSizeBytes / 1024 / 1024} MB; store big files somewhere else and keep a link here.`);
122
160
  return givenValue;
123
161
  }
124
162
  const kind = typeof givenValue === "boolean" ? "true or false" : typeof givenValue === "number" ? String(givenValue) : Array.isArray(givenValue) ? "a list" : typeof givenValue;
@@ -150,17 +188,21 @@ export function plainRow(row) {
150
188
  }
151
189
  /**
152
190
  * A row as a game's query gets it: plainRow, refusing a whole number past 2^53, which JavaScript would
153
- * round (a game would read a different number than the one stored). Dumps and previews keep plainRow.
191
+ * round (a game would read a different number than the one stored): integer_too_large. Dumps and previews keep
192
+ * plainRow. realColumns names the columns that hold REALs, which can be as big (1e20) and are not refused.
154
193
  */
155
- export function checkedRow(row) {
194
+ export function checkedRow(row, realColumns) {
156
195
  const plain = plainRow(row);
157
196
  for (const [column, columnValue] of Object.entries(plain)) {
158
- if (typeof columnValue === "number" && Number.isInteger(columnValue) && !Number.isSafeInteger(columnValue)) {
159
- throw new DatabaseError("bad_sql", `Column ${column} holds a whole number past 2^53, which JavaScript can't hold exactly.`, `Read it as text with CAST(${column} AS TEXT).`);
160
- }
197
+ if (typeof columnValue === "number" && Number.isInteger(columnValue) && !Number.isSafeInteger(columnValue) && !realColumns?.has(column))
198
+ throw integerTooLarge(column);
161
199
  }
162
200
  return plain;
163
201
  }
202
+ /** A whole number past 2^53 in a result, which JavaScript can't hold exactly. */
203
+ export function integerTooLarge(column) {
204
+ return new DatabaseError("integer_too_large", `Column ${column} holds a whole number past 2^53 (9,007,199,254,740,991), which JavaScript can't hold exactly.`, `Read it as text with CAST(${column} AS TEXT), or store 64-bit IDs as TEXT.`);
205
+ }
164
206
  export function tooManyRows() {
165
207
  return new DatabaseError("too_many_rows", `The statement answered more than ${DATABASE_LIMITS.maximumRowCount} rows.`, "Ask for fewer with LIMIT (and OFFSET or a WHERE on the last row for the next page), or count with SELECT count(*).");
166
208
  }