@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/bench/glass.js +276 -60
- package/bin/node-parts.js +2 -1
- package/package.json +1 -1
- package/sdk/saves.js +137 -57
- package/sdk/zaisma.d.ts +7 -1
- package/sdk/zaisma.js +4 -1
- package/src/database.d.ts +21 -8
- package/src/database.js +57 -15
- package/src/dump.d.ts +4 -1
- package/src/dump.js +166 -18
- package/src/room.d.ts +16 -2
- package/src/room.js +14 -0
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
|
-
|
|
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
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
|
|
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
|
|
323
|
-
* records at a time, stopping once the save passes
|
|
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,
|
|
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
|
|
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
|
-
|
|
371
|
+
var keep = function () {
|
|
359
372
|
keptNow[name] = before;
|
|
360
|
-
|
|
361
|
-
return
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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,
|
|
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: {},
|
|
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.
|
|
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
|
|
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.
|
|
489
|
+
out.byteCount += byteLength(JSON.stringify(encoded)) + 1;
|
|
430
490
|
});
|
|
431
|
-
|
|
432
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
515
|
+
out.byteCount = 0;
|
|
454
516
|
stores.forEach(function (storeName) {
|
|
455
|
-
out.
|
|
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.
|
|
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
|
|
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.
|
|
608
|
-
|
|
609
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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: [] },
|
|
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
|
-
/**
|
|
791
|
-
|
|
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 ||
|
|
798
|
-
|
|
799
|
-
var
|
|
800
|
-
var
|
|
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)
|
|
808
|
-
|
|
809
|
-
|
|
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 ||
|
|
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 (
|
|
865
|
-
|
|
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
|
-
|
|
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,
|
|
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)
|
|
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
|
|
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
|
|
116
|
-
* trailing ; or comment, which SQLite in a Durable Object takes as an empty
|
|
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
|
|
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
|
|
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
|
|
92
|
-
* trailing ; or comment, which SQLite in a Durable Object takes as an empty
|
|
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 >
|
|
121
|
-
throw new DatabaseError("bad_values", `Value ${i + 1} is ${sizeBytes} bytes.`, `Keep each text or blob under ${
|
|
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
|
|
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
|
|
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
|
}
|