@zaisma/game-server 0.0.1 → 1.1.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/LICENSE +21 -0
- package/README.md +35 -1
- package/bin/node-parts.js +313 -0
- package/bin/node.d.ts +47 -0
- package/bin/node.js +701 -0
- package/package.json +42 -1
- package/src/chat.d.ts +65 -0
- package/src/chat.js +108 -0
- package/src/database.d.ts +150 -0
- package/src/database.js +244 -0
- package/src/dump.d.ts +61 -0
- package/src/dump.js +282 -0
- package/src/index.d.ts +10 -0
- package/src/index.js +12 -0
- package/src/internal.d.ts +13 -0
- package/src/internal.js +15 -0
- package/src/names.d.ts +4 -0
- package/src/names.js +18 -0
- package/src/outside-calls.d.ts +46 -0
- package/src/outside-calls.js +30 -0
- package/src/pass.d.ts +194 -0
- package/src/pass.js +231 -0
- package/src/relay.d.ts +7 -0
- package/src/relay.js +20 -0
- package/src/requests.d.ts +89 -0
- package/src/requests.js +127 -0
- package/src/room-calls.d.ts +92 -0
- package/src/room-calls.js +194 -0
- package/src/room.d.ts +1550 -0
- package/src/room.js +2315 -0
- package/src/schedules.d.ts +82 -0
- package/src/schedules.js +200 -0
- package/src/state-sync.d.ts +57 -0
- package/src/state-sync.js +175 -0
- package/src/webhooks.d.ts +114 -0
- package/src/webhooks.js +102 -0
package/package.json
CHANGED
|
@@ -1 +1,42 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"name": "@zaisma/game-server",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Run your Zaisma game server: the same rooms on Zaisma or on your own server.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://zaisma.com",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/zaisma/zaisma.git",
|
|
10
|
+
"directory": "packages/game-server"
|
|
11
|
+
},
|
|
12
|
+
"bugs": "https://github.com/zaisma/zaisma/issues",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"zaisma",
|
|
15
|
+
"games",
|
|
16
|
+
"browser-games",
|
|
17
|
+
"multiplayer",
|
|
18
|
+
"game-server",
|
|
19
|
+
"websocket"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./src/index.d.ts",
|
|
25
|
+
"default": "./src/index.js"
|
|
26
|
+
},
|
|
27
|
+
"./node": {
|
|
28
|
+
"types": "./bin/node.d.ts",
|
|
29
|
+
"default": "./bin/node.js"
|
|
30
|
+
},
|
|
31
|
+
"./package.json": "./package.json"
|
|
32
|
+
},
|
|
33
|
+
"bin": {
|
|
34
|
+
"zaisma-game-server": "bin/node.js"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=22.18"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"ws": "8.21.3"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/chat.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zaisma's chat filter for your own server (docs/DECISIONS.md, "The text filter catches what matters and
|
|
3
|
+
* leaves the rest alone"). Every chat message to or from an under-18 account is checked on Zaisma's side:
|
|
4
|
+
* contact details in any disguise, moving to another app or meeting up, personal details, slurs and
|
|
5
|
+
* sexual content are stopped. Swearing is only flagged (hasSwearing): blocking it is your call. The answer
|
|
6
|
+
* is one verdict for the whole message, never per receiver, so it never says who's under 18.
|
|
7
|
+
*
|
|
8
|
+
* checkChat sends the players' passes (as your server received them) and the sender's last few messages to
|
|
9
|
+
* Zaisma (POST <issuer>/v1/chat/check), so a phone number sent a few digits at a time is caught too.
|
|
10
|
+
*
|
|
11
|
+
* It's for your own server only. In Zaisma rooms the room filters the chat it delivers itself, so a game
|
|
12
|
+
* server running there never sees passes, and checkChat throws a ChatCheckError (own_server_only).
|
|
13
|
+
*/
|
|
14
|
+
/** Why a message was stopped. The same reasons as Zaisma.checkChat, plus chat_paused and not_allowed_to_chat. */
|
|
15
|
+
export type ChatCheckReason = "contact_details" | "personal_details" | "slur" | "sexual" | "chat_paused" | "unchecked" | "too_long" | "not_allowed_to_chat";
|
|
16
|
+
export interface ChatCheck {
|
|
17
|
+
/** Whether to deliver it. */
|
|
18
|
+
isAllowed: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Why not; null when it's allowed. chat_paused: the sender's chat is paused or removed. unchecked: Zaisma
|
|
21
|
+
* couldn't check it, so don't deliver it (try again in a moment). too_long: over 2000 characters.
|
|
22
|
+
* not_allowed_to_chat: canChat says the sender and a receiver may not chat (Zaisma checks it too).
|
|
23
|
+
*/
|
|
24
|
+
reason: ChatCheckReason | null;
|
|
25
|
+
/** The words to show the sender when it's stopped (never mentions age or the other player); null when it's allowed. */
|
|
26
|
+
message: string | null;
|
|
27
|
+
/** Whether it swears. Zaisma allows it; block it yourself if your game doesn't. */
|
|
28
|
+
hasSwearing: boolean;
|
|
29
|
+
}
|
|
30
|
+
export interface ChatCheckOptions {
|
|
31
|
+
/** The message. */
|
|
32
|
+
text: string;
|
|
33
|
+
/** The sender's player pass, the signed token as your server received it. */
|
|
34
|
+
senderPass: string;
|
|
35
|
+
/** The passes of everyone it would reach, up to 50. */
|
|
36
|
+
receiverPasses: string[];
|
|
37
|
+
}
|
|
38
|
+
export declare const CHAT_CHECK_LIMITS: {
|
|
39
|
+
/** The longest message checked, in characters: the whole text is checked, and a longer one is refused (too_long), never cut. */
|
|
40
|
+
readonly maximumMessageCharacterCount: 2000;
|
|
41
|
+
readonly maximumReceiverCount: 50;
|
|
42
|
+
/** The sender's last messages sent along with each one, from the last 2 minutes. */
|
|
43
|
+
readonly recentTextCount: 5;
|
|
44
|
+
readonly recentTextDurationMs: 120000;
|
|
45
|
+
/** Senders whose last messages this server keeps in memory. */
|
|
46
|
+
readonly maximumSenderCount: 10000;
|
|
47
|
+
/** How long checkChat waits for Zaisma before answering unchecked. */
|
|
48
|
+
readonly checkTimeoutMs: 5000;
|
|
49
|
+
};
|
|
50
|
+
/** checkChat called where it can't run, with what to do instead (like the developer API's errors, docs/NAMING.md). */
|
|
51
|
+
export declare class ChatCheckError extends Error {
|
|
52
|
+
readonly code = "own_server_only";
|
|
53
|
+
readonly fix: string;
|
|
54
|
+
constructor(message: string, fix: string);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Check one chat message with Zaisma's filter. Call it on every chat message your server delivers, after
|
|
58
|
+
* canChat says the players may chat, and deliver it only when isAllowed (Zaisma applies canChat's rule
|
|
59
|
+
* too, so a message to anyone who may not chat is stopped). It's free. When Zaisma can't be
|
|
60
|
+
* reached it answers unchecked: don't deliver the message.
|
|
61
|
+
*
|
|
62
|
+
* const check = await checkChat({ text, senderPass, receiverPasses: [receiverPass] });
|
|
63
|
+
* if (!check.isAllowed || check.hasSwearing) return reply(sender, check.message ?? "No swearing here.");
|
|
64
|
+
*/
|
|
65
|
+
export declare function checkChat({ text, senderPass, receiverPasses }: ChatCheckOptions): Promise<ChatCheck>;
|
package/src/chat.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zaisma's chat filter for your own server (docs/DECISIONS.md, "The text filter catches what matters and
|
|
3
|
+
* leaves the rest alone"). Every chat message to or from an under-18 account is checked on Zaisma's side:
|
|
4
|
+
* contact details in any disguise, moving to another app or meeting up, personal details, slurs and
|
|
5
|
+
* sexual content are stopped. Swearing is only flagged (hasSwearing): blocking it is your call. The answer
|
|
6
|
+
* is one verdict for the whole message, never per receiver, so it never says who's under 18.
|
|
7
|
+
*
|
|
8
|
+
* checkChat sends the players' passes (as your server received them) and the sender's last few messages to
|
|
9
|
+
* Zaisma (POST <issuer>/v1/chat/check), so a phone number sent a few digits at a time is caught too.
|
|
10
|
+
*
|
|
11
|
+
* It's for your own server only. In Zaisma rooms the room filters the chat it delivers itself, so a game
|
|
12
|
+
* server running there never sees passes, and checkChat throws a ChatCheckError (own_server_only).
|
|
13
|
+
*/
|
|
14
|
+
import { OutsideCallError } from "./outside-calls.js";
|
|
15
|
+
export const CHAT_CHECK_LIMITS = {
|
|
16
|
+
/** The longest message checked, in characters: the whole text is checked, and a longer one is refused (too_long), never cut. */
|
|
17
|
+
maximumMessageCharacterCount: 2000,
|
|
18
|
+
maximumReceiverCount: 50,
|
|
19
|
+
/** The sender's last messages sent along with each one, from the last 2 minutes. */
|
|
20
|
+
recentTextCount: 5,
|
|
21
|
+
recentTextDurationMs: 120_000,
|
|
22
|
+
/** Senders whose last messages this server keeps in memory. */
|
|
23
|
+
maximumSenderCount: 10_000,
|
|
24
|
+
/** How long checkChat waits for Zaisma before answering unchecked. */
|
|
25
|
+
checkTimeoutMs: 5_000,
|
|
26
|
+
};
|
|
27
|
+
/** checkChat called where it can't run, with what to do instead (like the developer API's errors, docs/NAMING.md). */
|
|
28
|
+
export class ChatCheckError extends Error {
|
|
29
|
+
code = "own_server_only";
|
|
30
|
+
fix;
|
|
31
|
+
constructor(message, fix) {
|
|
32
|
+
super(`${message} ${fix} (own_server_only)`);
|
|
33
|
+
this.name = "ChatCheckError";
|
|
34
|
+
this.fix = fix;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const UNCHECKED = { isAllowed: false, reason: "unchecked", message: "This message couldn't be checked. Try again in a moment.", hasSwearing: false };
|
|
38
|
+
const TOO_LONG = { isAllowed: false, reason: "too_long", message: "That message is too long to send.", hasSwearing: false };
|
|
39
|
+
const REASONS = ["contact_details", "personal_details", "slur", "sexual", "chat_paused", "unchecked", "too_long", "not_allowed_to_chat"];
|
|
40
|
+
/** Each sender's last messages, by the sender's pass's player ID: only in this process's memory. */
|
|
41
|
+
const recentBySender = new Map();
|
|
42
|
+
/** The claims of a pass, unchecked: Zaisma checks the signature. Only to find whose it is and where it's from. */
|
|
43
|
+
function readClaims(pass) {
|
|
44
|
+
try {
|
|
45
|
+
const payload = pass.split(".")[1] ?? "";
|
|
46
|
+
return JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(payload.length / 4) * 4, "=")));
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Zaisma's rooms, or a local preview of Zaisma: never anywhere a made-up pass could send players' messages. */
|
|
53
|
+
function isZaismaIssuer(issuer) {
|
|
54
|
+
if (issuer === "https://rooms.zaisma.com")
|
|
55
|
+
return true;
|
|
56
|
+
return typeof issuer === "string" && /^http:\/\/(localhost|127\.0\.0\.1):\d{1,5}$/.test(issuer);
|
|
57
|
+
}
|
|
58
|
+
function rememberText(senderPlayerId, text, nowMs) {
|
|
59
|
+
const recent = (recentBySender.get(senderPlayerId) ?? []).filter((sent) => nowMs - sent.sentAtMs < CHAT_CHECK_LIMITS.recentTextDurationMs);
|
|
60
|
+
recent.push({ text, sentAtMs: nowMs });
|
|
61
|
+
recentBySender.delete(senderPlayerId);
|
|
62
|
+
// The oldest sender goes first when the map is full.
|
|
63
|
+
if (recentBySender.size >= CHAT_CHECK_LIMITS.maximumSenderCount)
|
|
64
|
+
recentBySender.delete(recentBySender.keys().next().value);
|
|
65
|
+
recentBySender.set(senderPlayerId, recent.slice(-CHAT_CHECK_LIMITS.recentTextCount));
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Check one chat message with Zaisma's filter. Call it on every chat message your server delivers, after
|
|
69
|
+
* canChat says the players may chat, and deliver it only when isAllowed (Zaisma applies canChat's rule
|
|
70
|
+
* too, so a message to anyone who may not chat is stopped). It's free. When Zaisma can't be
|
|
71
|
+
* reached it answers unchecked: don't deliver the message.
|
|
72
|
+
*
|
|
73
|
+
* const check = await checkChat({ text, senderPass, receiverPasses: [receiverPass] });
|
|
74
|
+
* if (!check.isAllowed || check.hasSwearing) return reply(sender, check.message ?? "No swearing here.");
|
|
75
|
+
*/
|
|
76
|
+
export async function checkChat({ text, senderPass, receiverPasses }) {
|
|
77
|
+
const claims = readClaims(String(senderPass ?? ""));
|
|
78
|
+
if (typeof claims.sub !== "string" || !isZaismaIssuer(claims.iss) || !Array.isArray(receiverPasses))
|
|
79
|
+
return UNCHECKED;
|
|
80
|
+
const message = String(text ?? "");
|
|
81
|
+
if (message.length > CHAT_CHECK_LIMITS.maximumMessageCharacterCount)
|
|
82
|
+
return TOO_LONG;
|
|
83
|
+
const nowMs = Date.now();
|
|
84
|
+
const recentTexts = (recentBySender.get(claims.sub) ?? []).filter((sent) => nowMs - sent.sentAtMs < CHAT_CHECK_LIMITS.recentTextDurationMs).map((sent) => sent.text);
|
|
85
|
+
rememberText(claims.sub, message, nowMs);
|
|
86
|
+
try {
|
|
87
|
+
const response = await fetch(`${claims.iss}/v1/chat/check`, {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: { "Content-Type": "application/json" },
|
|
90
|
+
body: JSON.stringify({ gameSlug: claims.aud, senderPass, receiverPasses: receiverPasses.slice(0, CHAT_CHECK_LIMITS.maximumReceiverCount), text: message, recentTexts }),
|
|
91
|
+
signal: AbortSignal.timeout(CHAT_CHECK_LIMITS.checkTimeoutMs),
|
|
92
|
+
});
|
|
93
|
+
if (!response.ok)
|
|
94
|
+
return UNCHECKED;
|
|
95
|
+
const answer = (await response.json());
|
|
96
|
+
if (typeof answer.isAllowed !== "boolean")
|
|
97
|
+
return UNCHECKED;
|
|
98
|
+
const reason = typeof answer.reason === "string" && REASONS.includes(answer.reason) ? answer.reason : null;
|
|
99
|
+
return { isAllowed: answer.isAllowed, reason, message: typeof answer.message === "string" ? answer.message : null, hasSwearing: answer.hasSwearing === true };
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
// In Zaisma rooms the gateway refuses the call to Zaisma's own address: checkChat is for a game server on your own server.
|
|
103
|
+
if (error instanceof OutsideCallError && ["unreachable_host", "not_https", "host_not_allowed"].includes(error.code)) {
|
|
104
|
+
throw new ChatCheckError("checkChat is for a game server on your own server. In Zaisma rooms the room runs Zaisma's chat filter on the chat it delivers.", "Send chat through Zaisma's chat hosting in rooms once it's out; until then, check what a player types in the game with Zaisma.checkChat, or offer preset phrases and emotes.");
|
|
105
|
+
}
|
|
106
|
+
return UNCHECKED;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The game database: one SQLite database per game, which its game server reads and changes with
|
|
3
|
+
* plain SQL (room.database). In Zaisma rooms it's a Durable Object of its own (apps/rooms/src/database.ts);
|
|
4
|
+
* on the developer's own server the Node runner keeps it in a local file (@zaisma/game-server/node). Both check every
|
|
5
|
+
* call here first, so a query that works in one works the same in the other.
|
|
6
|
+
*/
|
|
7
|
+
/** A value a query takes for a ? and gives back in a column. Blobs come back as Uint8Array. */
|
|
8
|
+
export type DatabaseValue = string | number | null | Uint8Array;
|
|
9
|
+
/** One row, by column name. */
|
|
10
|
+
export type DatabaseRow = Record<string, DatabaseValue>;
|
|
11
|
+
/** One statement of a batch: its SQL, and the values for its ?s in order. */
|
|
12
|
+
export interface DatabaseStatement {
|
|
13
|
+
sql: string;
|
|
14
|
+
values?: DatabaseValue[];
|
|
15
|
+
}
|
|
16
|
+
export interface GameDatabase {
|
|
17
|
+
/**
|
|
18
|
+
* Run one SQL statement and answer its rows ([] for a statement that returns none). values fill
|
|
19
|
+
* its ?s in order: text, numbers, null or a Uint8Array (use 1 and 0 for true and false). To see
|
|
20
|
+
* what an INSERT, UPDATE or DELETE changed, add RETURNING.
|
|
21
|
+
*
|
|
22
|
+
* await room.database.query("CREATE TABLE IF NOT EXISTS scores (player_id TEXT PRIMARY KEY, best_score INTEGER NOT NULL)");
|
|
23
|
+
* await room.database.query("INSERT INTO scores VALUES (?, ?) ON CONFLICT (player_id) DO UPDATE SET best_score = max(best_score, excluded.best_score)", [player.id, 1200]);
|
|
24
|
+
* const top = await room.database.query("SELECT player_id, best_score FROM scores ORDER BY best_score DESC LIMIT 10");
|
|
25
|
+
*/
|
|
26
|
+
query(sql: string, values?: DatabaseValue[]): Promise<DatabaseRow[]>;
|
|
27
|
+
/**
|
|
28
|
+
* Run several statements in one transaction: all of them happen, or, if one fails, none do.
|
|
29
|
+
* Answers each statement's rows, in order. For moving an item between two inventories, say.
|
|
30
|
+
*/
|
|
31
|
+
batch(statements: DatabaseStatement[]): Promise<DatabaseRow[][]>;
|
|
32
|
+
/**
|
|
33
|
+
* Erase one player: every row of every table with a column named player_id that holds playerId, in
|
|
34
|
+
* one transaction. Other columns, like winner_player_id, are left alone. Answers each table it
|
|
35
|
+
* looked in with the rows it erased (0 included). Zaisma does this for you when a player deletes
|
|
36
|
+
* their account, unless the game's deleted_account_erase_enabled is off; then do it yourself:
|
|
37
|
+
*
|
|
38
|
+
* onWebhook(event, server) {
|
|
39
|
+
* if (event.kind === "account_deleted") return server.database.erasePlayer(event.account_deletion.player_id);
|
|
40
|
+
* }
|
|
41
|
+
*
|
|
42
|
+
* A foreign key that refuses the delete (no ON DELETE CASCADE) undoes the whole erase.
|
|
43
|
+
*/
|
|
44
|
+
erasePlayer(playerId: string): Promise<ErasedTable[]>;
|
|
45
|
+
}
|
|
46
|
+
/** One table erasePlayer looked in, and how many of its rows it erased. */
|
|
47
|
+
export interface ErasedTable {
|
|
48
|
+
tableName: string;
|
|
49
|
+
erasedRowCount: number;
|
|
50
|
+
}
|
|
51
|
+
/** Lists every table's columns, for eraseStatements, leaving out Cloudflare's (_cf_…, which refuse to be read) and SQLite's own; both runtimes read their tables with it. */
|
|
52
|
+
export declare const TABLE_COLUMNS_SQL = "SELECT schema.name AS table_name, info.name AS column_name FROM sqlite_schema AS schema, pragma_table_info(schema.name) AS info WHERE schema.type = 'table' AND substr(schema.name, 1, 4) <> '_cf_' AND substr(schema.name, 1, 7) <> 'sqlite_' ORDER BY schema.name, info.cid";
|
|
53
|
+
/** The rows of TABLE_COLUMNS_SQL as eraseStatements takes them. */
|
|
54
|
+
export declare function tablesFromColumns(rows: {
|
|
55
|
+
table_name: unknown;
|
|
56
|
+
column_name: unknown;
|
|
57
|
+
}[]): {
|
|
58
|
+
name: string;
|
|
59
|
+
columnNames: string[];
|
|
60
|
+
}[];
|
|
61
|
+
/**
|
|
62
|
+
* The deletes that erase a player: one per table with a column named player_id (compared the way SQLite
|
|
63
|
+
* compares names, ignoring case), leaving out SQLite's own tables (sqlite_…) and Cloudflare's (_cf_…).
|
|
64
|
+
* One rule in one place, so Zaisma rooms and the Node runner erase the same rows.
|
|
65
|
+
*/
|
|
66
|
+
export declare function eraseStatements(tables: {
|
|
67
|
+
name: string;
|
|
68
|
+
columnNames: string[];
|
|
69
|
+
}[], playerId: string): {
|
|
70
|
+
tableName: string;
|
|
71
|
+
sql: string;
|
|
72
|
+
values: [string];
|
|
73
|
+
}[];
|
|
74
|
+
/** A player ID as Zaisma makes them (player_ and lowercase letters and digits), or a bad_player_id DatabaseError. */
|
|
75
|
+
export declare function checkPlayerId(playerId: unknown): string;
|
|
76
|
+
/** What erasePlayer says when a table's foreign key refuses the delete. */
|
|
77
|
+
export declare function eraseRefused(tableName: string, error: unknown): DatabaseError;
|
|
78
|
+
export declare const DATABASE_LIMITS: {
|
|
79
|
+
/** One statement's SQL. */
|
|
80
|
+
readonly maximumSqlCharacterCount: 100000;
|
|
81
|
+
/** Values for one statement's ?s. */
|
|
82
|
+
readonly maximumValueCount: 100;
|
|
83
|
+
/** One text or blob value, in a query and in the database. */
|
|
84
|
+
readonly maximumValueSizeBytes: number;
|
|
85
|
+
/** Statements in one batch. */
|
|
86
|
+
readonly maximumStatementCount: 100;
|
|
87
|
+
/** Rows one statement may answer; past it, page with LIMIT and OFFSET or a WHERE. */
|
|
88
|
+
readonly maximumRowCount: 10000;
|
|
89
|
+
};
|
|
90
|
+
export type DatabaseErrorCode = "bad_sql" | "bad_values" | "bad_player_id" | "too_many_rows" | "hosting_paused" | "database_full" | "database_unavailable";
|
|
91
|
+
/** A call the database refused, with a code, what happened and what to do (like the developer API's errors, docs/NAMING.md). */
|
|
92
|
+
export declare class DatabaseError extends Error {
|
|
93
|
+
readonly code: DatabaseErrorCode;
|
|
94
|
+
readonly fix: string;
|
|
95
|
+
constructor(code: DatabaseErrorCode, message: string, fix: string);
|
|
96
|
+
/** True for any error named DatabaseError with a known code, so instanceof works for the copy a game's sandbox has too. */
|
|
97
|
+
static [Symbol.hasInstance](candidate: unknown): boolean;
|
|
98
|
+
}
|
|
99
|
+
/** What a database answers across a boundary (the sandbox, RPC): the result, or the error to throw. */
|
|
100
|
+
export type DatabaseAnswer<T> = {
|
|
101
|
+
queryResult: T;
|
|
102
|
+
} | {
|
|
103
|
+
error: {
|
|
104
|
+
code: DatabaseErrorCode;
|
|
105
|
+
message: string;
|
|
106
|
+
fix: string;
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
/** The answer's result, or its error thrown as a DatabaseError. */
|
|
110
|
+
export declare function readDatabaseAnswer<T>(answer: DatabaseAnswer<T>): T;
|
|
111
|
+
/** Run work, answering its result or the DatabaseError it threw; any other error is SQLite's (bad_sql). */
|
|
112
|
+
export declare function answerDatabase<T>(work: () => T): DatabaseAnswer<T>;
|
|
113
|
+
/**
|
|
114
|
+
* 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.
|
|
117
|
+
*/
|
|
118
|
+
export declare function checkStatement(sql: unknown, values: unknown): {
|
|
119
|
+
sql: string;
|
|
120
|
+
values: DatabaseValue[];
|
|
121
|
+
};
|
|
122
|
+
/** Check a batch's statements; answers each with its values to bind. */
|
|
123
|
+
export declare function checkBatch(statements: unknown): {
|
|
124
|
+
sql: string;
|
|
125
|
+
values: DatabaseValue[];
|
|
126
|
+
}[];
|
|
127
|
+
/** A row as the game gets it: a plain object, with blobs as Uint8Array. */
|
|
128
|
+
export declare function plainRow(row: Record<string, unknown>): DatabaseRow;
|
|
129
|
+
/**
|
|
130
|
+
* 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.
|
|
132
|
+
*/
|
|
133
|
+
export declare function checkedRow(row: Record<string, unknown>): DatabaseRow;
|
|
134
|
+
export declare function tooManyRows(): DatabaseError;
|
|
135
|
+
/** One statement in a piece of SQL, as scanStatements finds it. */
|
|
136
|
+
export interface ScannedStatement {
|
|
137
|
+
/** Where its first word or symbol starts (after any space and comments before it). */
|
|
138
|
+
startIndex: number;
|
|
139
|
+
/** Where it ends: after its last word or symbol, before its ; and any comment after it. */
|
|
140
|
+
endIndex: number;
|
|
141
|
+
/** Its first words in capitals, up to six, until the first symbol or quote: ["CREATE", "TABLE", "IF", "NOT", "EXISTS", "SCORES"]. */
|
|
142
|
+
leadingWords: string[];
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The statements a piece of SQL holds, reading past quotes and comments: a ; inside 'text' or a
|
|
146
|
+
* comment doesn't end a statement, and neither does one inside a trigger's BEGIN … END, the way
|
|
147
|
+
* SQLite's own sqlite3_complete reads it (a ; ends a CREATE TRIGGER only after an END that comes
|
|
148
|
+
* straight after a ;, so a CASE … END inside doesn't). An empty statement (;;) isn't one.
|
|
149
|
+
*/
|
|
150
|
+
export declare function scanStatements(sql: string): ScannedStatement[];
|
package/src/database.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The game database: one SQLite database per game, which its game server reads and changes with
|
|
3
|
+
* plain SQL (room.database). In Zaisma rooms it's a Durable Object of its own (apps/rooms/src/database.ts);
|
|
4
|
+
* on the developer's own server the Node runner keeps it in a local file (@zaisma/game-server/node). Both check every
|
|
5
|
+
* call here first, so a query that works in one works the same in the other.
|
|
6
|
+
*/
|
|
7
|
+
/** Lists every table's columns, for eraseStatements, leaving out Cloudflare's (_cf_…, which refuse to be read) and SQLite's own; both runtimes read their tables with it. */
|
|
8
|
+
export const TABLE_COLUMNS_SQL = "SELECT schema.name AS table_name, info.name AS column_name FROM sqlite_schema AS schema, pragma_table_info(schema.name) AS info WHERE schema.type = 'table' AND substr(schema.name, 1, 4) <> '_cf_' AND substr(schema.name, 1, 7) <> 'sqlite_' ORDER BY schema.name, info.cid";
|
|
9
|
+
/** The rows of TABLE_COLUMNS_SQL as eraseStatements takes them. */
|
|
10
|
+
export function tablesFromColumns(rows) {
|
|
11
|
+
const tables = new Map();
|
|
12
|
+
for (const row of rows) {
|
|
13
|
+
const name = String(row.table_name);
|
|
14
|
+
if (!tables.has(name))
|
|
15
|
+
tables.set(name, []);
|
|
16
|
+
tables.get(name).push(String(row.column_name));
|
|
17
|
+
}
|
|
18
|
+
return [...tables].map(([name, columnNames]) => ({ name, columnNames }));
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The deletes that erase a player: one per table with a column named player_id (compared the way SQLite
|
|
22
|
+
* compares names, ignoring case), leaving out SQLite's own tables (sqlite_…) and Cloudflare's (_cf_…).
|
|
23
|
+
* One rule in one place, so Zaisma rooms and the Node runner erase the same rows.
|
|
24
|
+
*/
|
|
25
|
+
export function eraseStatements(tables, playerId) {
|
|
26
|
+
return tables
|
|
27
|
+
.filter((table) => !table.name.toLowerCase().startsWith("sqlite_") && !table.name.toLowerCase().startsWith("_cf_") && table.columnNames.some((columnName) => columnName.toLowerCase() === "player_id"))
|
|
28
|
+
.map((table) => ({ tableName: table.name, sql: `DELETE FROM "${table.name.replaceAll('"', '""')}" WHERE "player_id" = ?`, values: [playerId] }));
|
|
29
|
+
}
|
|
30
|
+
/** A player ID as Zaisma makes them (player_ and lowercase letters and digits), or a bad_player_id DatabaseError. */
|
|
31
|
+
export function checkPlayerId(playerId) {
|
|
32
|
+
if (typeof playerId === "string" && /^player_[a-z0-9]+$/.test(playerId))
|
|
33
|
+
return playerId;
|
|
34
|
+
throw new DatabaseError("bad_player_id", `${JSON.stringify(playerId) ?? String(playerId)} isn't a player ID.`, "Give the player_… ID: event.account_deletion.player_id in an account_deleted event, player.id in a room, or a player pass's sub.");
|
|
35
|
+
}
|
|
36
|
+
/** What erasePlayer says when a table's foreign key refuses the delete. */
|
|
37
|
+
export function eraseRefused(tableName, error) {
|
|
38
|
+
const sqliteMessage = String(error instanceof Error ? error.message : error).replace(/^Error: /, "").slice(0, 300);
|
|
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
|
+
}
|
|
41
|
+
export const DATABASE_LIMITS = {
|
|
42
|
+
/** One statement's SQL. */
|
|
43
|
+
maximumSqlCharacterCount: 100_000,
|
|
44
|
+
/** Values for one statement's ?s. */
|
|
45
|
+
maximumValueCount: 100,
|
|
46
|
+
/** One text or blob value, in a query and in the database. */
|
|
47
|
+
maximumValueSizeBytes: 1024 * 1024,
|
|
48
|
+
/** Statements in one batch. */
|
|
49
|
+
maximumStatementCount: 100,
|
|
50
|
+
/** Rows one statement may answer; past it, page with LIMIT and OFFSET or a WHERE. */
|
|
51
|
+
maximumRowCount: 10_000,
|
|
52
|
+
};
|
|
53
|
+
const DATABASE_ERROR_CODES = ["bad_sql", "bad_values", "bad_player_id", "too_many_rows", "hosting_paused", "database_full", "database_unavailable"];
|
|
54
|
+
/** A call the database refused, with a code, what happened and what to do (like the developer API's errors, docs/NAMING.md). */
|
|
55
|
+
export class DatabaseError extends Error {
|
|
56
|
+
code;
|
|
57
|
+
fix;
|
|
58
|
+
constructor(code, message, fix) {
|
|
59
|
+
super(`${message} ${fix} (${code})`);
|
|
60
|
+
this.name = "DatabaseError";
|
|
61
|
+
this.code = code;
|
|
62
|
+
this.fix = fix;
|
|
63
|
+
}
|
|
64
|
+
/** True for any error named DatabaseError with a known code, so instanceof works for the copy a game's sandbox has too. */
|
|
65
|
+
static [Symbol.hasInstance](candidate) {
|
|
66
|
+
return candidate instanceof Error && candidate.name === "DatabaseError" && DATABASE_ERROR_CODES.includes(candidate.code);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** The answer's result, or its error thrown as a DatabaseError. */
|
|
70
|
+
export function readDatabaseAnswer(answer) {
|
|
71
|
+
if ("error" in answer)
|
|
72
|
+
throw new DatabaseError(answer.error.code, answer.error.message, answer.error.fix);
|
|
73
|
+
return answer.queryResult;
|
|
74
|
+
}
|
|
75
|
+
/** Run work, answering its result or the DatabaseError it threw; any other error is SQLite's (bad_sql). */
|
|
76
|
+
export function answerDatabase(work) {
|
|
77
|
+
try {
|
|
78
|
+
return { queryResult: work() };
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
// The error's own message ends with its fix and code, which the answer gives apart.
|
|
82
|
+
if (error instanceof DatabaseError)
|
|
83
|
+
return { error: { code: error.code, message: error.message.slice(0, -` ${error.fix} (${error.code})`.length), fix: error.fix } };
|
|
84
|
+
return { error: { code: "bad_sql", message: `SQLite: ${String(error instanceof Error ? error.message : error).replace(/^Error: /, "").slice(0, 500)}`, fix: "Check the statement against SQLite's syntax and your tables; list them with SELECT name, sql FROM sqlite_schema." } };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Statements that run their own transactions or reach other files. batch() is the transaction.
|
|
88
|
+
const REFUSED_FIRST_WORDS = new Set(["BEGIN", "COMMIT", "END", "ROLLBACK", "SAVEPOINT", "RELEASE", "ATTACH", "DETACH"]);
|
|
89
|
+
/**
|
|
90
|
+
* 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.
|
|
93
|
+
*/
|
|
94
|
+
export function checkStatement(sql, values) {
|
|
95
|
+
if (typeof sql !== "string" || !sql.trim())
|
|
96
|
+
throw new DatabaseError("bad_sql", "The SQL isn't text.", 'Give one SQL statement as text, like "SELECT * FROM scores WHERE player_id = ?".');
|
|
97
|
+
if (sql.length > DATABASE_LIMITS.maximumSqlCharacterCount)
|
|
98
|
+
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.`);
|
|
99
|
+
const statements = scanStatements(sql);
|
|
100
|
+
if (statements.length === 0)
|
|
101
|
+
throw new DatabaseError("bad_sql", "The SQL has only comments.", 'Give one SQL statement, like "SELECT * FROM scores WHERE player_id = ?".');
|
|
102
|
+
if (statements.length > 1)
|
|
103
|
+
throw new DatabaseError("bad_sql", "The SQL has more than one statement.", "Give one statement per query, or several in a batch (room.database.batch([{ sql, values }, ...]) in a game server, batch_database through the API), which also runs them in one transaction.");
|
|
104
|
+
const firstWord = statements[0].leadingWords[0] ?? "";
|
|
105
|
+
if (REFUSED_FIRST_WORDS.has(firstWord))
|
|
106
|
+
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.");
|
|
107
|
+
const given = values === undefined || values === null ? [] : values;
|
|
108
|
+
if (!Array.isArray(given))
|
|
109
|
+
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
|
+
if (given.length > DATABASE_LIMITS.maximumValueCount)
|
|
111
|
+
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) };
|
|
113
|
+
}
|
|
114
|
+
/** One value for a ?, the i-th (from 0). */
|
|
115
|
+
function checkValue(givenValue, i) {
|
|
116
|
+
if (typeof givenValue === "number" && Number.isInteger(givenValue) && !Number.isSafeInteger(givenValue))
|
|
117
|
+
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
|
+
if (givenValue === null || typeof givenValue === "string" || givenValue instanceof Uint8Array || (typeof givenValue === "number" && Number.isFinite(givenValue))) {
|
|
119
|
+
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.`);
|
|
122
|
+
return givenValue;
|
|
123
|
+
}
|
|
124
|
+
const kind = typeof givenValue === "boolean" ? "true or false" : typeof givenValue === "number" ? String(givenValue) : Array.isArray(givenValue) ? "a list" : typeof givenValue;
|
|
125
|
+
const fix = typeof givenValue === "boolean" ? "Give 1 for true and 0 for false." : typeof givenValue === "object" ? "Give text, a number, null or a Uint8Array; to store an object, give JSON.stringify(object) and read it back with JSON.parse or SQLite's JSON functions." : "Give text, a finite number, null or a Uint8Array.";
|
|
126
|
+
throw new DatabaseError("bad_values", `Value ${i + 1} is ${kind}.`, fix);
|
|
127
|
+
}
|
|
128
|
+
/** Check a batch's statements; answers each with its values to bind. */
|
|
129
|
+
export function checkBatch(statements) {
|
|
130
|
+
if (!Array.isArray(statements) || statements.length === 0)
|
|
131
|
+
throw new DatabaseError("bad_sql", "The batch isn't a list of statements.", 'Give a list like [{ sql: "UPDATE ...", values: [...] }, { sql: "INSERT ...", values: [...] }].');
|
|
132
|
+
if (statements.length > DATABASE_LIMITS.maximumStatementCount)
|
|
133
|
+
throw new DatabaseError("bad_sql", `The batch has ${statements.length} statements.`, `Give up to ${DATABASE_LIMITS.maximumStatementCount} statements per batch.`);
|
|
134
|
+
return statements.map((statement, i) => {
|
|
135
|
+
if (!statement || typeof statement !== "object" || Array.isArray(statement))
|
|
136
|
+
throw new DatabaseError("bad_sql", `Statement ${i + 1} isn't { sql, values }.`, 'Give each statement as { sql: "...", values: [...] }.');
|
|
137
|
+
for (const field of Object.keys(statement))
|
|
138
|
+
if (field !== "sql" && field !== "values")
|
|
139
|
+
throw new DatabaseError("bad_sql", `Statement ${i + 1} has ${field}.`, "A statement takes only sql and values.");
|
|
140
|
+
const { sql, values } = statement;
|
|
141
|
+
return checkStatement(sql, values);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
/** A row as the game gets it: a plain object, with blobs as Uint8Array. */
|
|
145
|
+
export function plainRow(row) {
|
|
146
|
+
const plain = {};
|
|
147
|
+
for (const [column, columnValue] of Object.entries(row))
|
|
148
|
+
plain[column] = columnValue instanceof ArrayBuffer ? new Uint8Array(columnValue) : columnValue;
|
|
149
|
+
return plain;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* 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.
|
|
154
|
+
*/
|
|
155
|
+
export function checkedRow(row) {
|
|
156
|
+
const plain = plainRow(row);
|
|
157
|
+
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
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return plain;
|
|
163
|
+
}
|
|
164
|
+
export function tooManyRows() {
|
|
165
|
+
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
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The statements a piece of SQL holds, reading past quotes and comments: a ; inside 'text' or a
|
|
169
|
+
* comment doesn't end a statement, and neither does one inside a trigger's BEGIN … END, the way
|
|
170
|
+
* SQLite's own sqlite3_complete reads it (a ; ends a CREATE TRIGGER only after an END that comes
|
|
171
|
+
* straight after a ;, so a CASE … END inside doesn't). An empty statement (;;) isn't one.
|
|
172
|
+
*/
|
|
173
|
+
export function scanStatements(sql) {
|
|
174
|
+
const statements = [];
|
|
175
|
+
// The statement being read, if one has started, the last word read (a quote or symbol clears it), and
|
|
176
|
+
// how many words, quotes and symbols came since the last ; inside it.
|
|
177
|
+
const reading = { statement: null, lastWord: "", sinceSemicolonTokenCount: 0 };
|
|
178
|
+
/** Adds the token from startIndex to endIndex to the statement, starting one if there's none. */
|
|
179
|
+
const addToken = (startIndex, endIndex, word) => {
|
|
180
|
+
const statement = (reading.statement ??= { startIndex, endIndex, leadingWords: [], hasOnlyWords: true });
|
|
181
|
+
statement.endIndex = endIndex;
|
|
182
|
+
if (word === null)
|
|
183
|
+
statement.hasOnlyWords = false;
|
|
184
|
+
else if (statement.hasOnlyWords && statement.leadingWords.length < 6)
|
|
185
|
+
statement.leadingWords.push(word);
|
|
186
|
+
reading.lastWord = word ?? "";
|
|
187
|
+
reading.sinceSemicolonTokenCount++;
|
|
188
|
+
};
|
|
189
|
+
const isInTriggerBody = (statement) => {
|
|
190
|
+
const words = statement.leadingWords;
|
|
191
|
+
return words[0] === "CREATE" && (words[1] === "TRIGGER" || ((words[1] === "TEMP" || words[1] === "TEMPORARY") && words[2] === "TRIGGER")) && !(reading.lastWord === "END" && reading.sinceSemicolonTokenCount === 1);
|
|
192
|
+
};
|
|
193
|
+
let i = 0;
|
|
194
|
+
while (i < sql.length) {
|
|
195
|
+
const character = sql[i];
|
|
196
|
+
if (character === "-" && sql[i + 1] === "-") {
|
|
197
|
+
const endIndex = sql.indexOf("\n", i);
|
|
198
|
+
i = endIndex === -1 ? sql.length : endIndex + 1;
|
|
199
|
+
}
|
|
200
|
+
else if (character === "/" && sql[i + 1] === "*") {
|
|
201
|
+
const endIndex = sql.indexOf("*/", i + 2);
|
|
202
|
+
i = endIndex === -1 ? sql.length : endIndex + 2;
|
|
203
|
+
}
|
|
204
|
+
else if (character === "'" || character === '"' || character === "`" || character === "[") {
|
|
205
|
+
const close = character === "[" ? "]" : character;
|
|
206
|
+
let endIndex = i + 1;
|
|
207
|
+
// A quote is escaped by doubling it.
|
|
208
|
+
while (endIndex < sql.length && !(sql[endIndex] === close && sql[endIndex + 1] !== close))
|
|
209
|
+
endIndex += sql[endIndex] === close ? 2 : 1;
|
|
210
|
+
addToken(i, Math.min(endIndex + 1, sql.length), null);
|
|
211
|
+
i = endIndex + 1;
|
|
212
|
+
}
|
|
213
|
+
else if (character === ";") {
|
|
214
|
+
const statement = reading.statement;
|
|
215
|
+
if (statement && !isInTriggerBody(statement)) {
|
|
216
|
+
statements.push({ startIndex: statement.startIndex, endIndex: statement.endIndex, leadingWords: statement.leadingWords });
|
|
217
|
+
reading.statement = null;
|
|
218
|
+
}
|
|
219
|
+
else if (statement)
|
|
220
|
+
addToken(i, i + 1, null);
|
|
221
|
+
reading.lastWord = "";
|
|
222
|
+
reading.sinceSemicolonTokenCount = 0;
|
|
223
|
+
i++;
|
|
224
|
+
}
|
|
225
|
+
else if (/\s/.test(character)) {
|
|
226
|
+
i++;
|
|
227
|
+
}
|
|
228
|
+
else if (/[A-Za-z0-9_$]/.test(character)) {
|
|
229
|
+
let endIndex = i + 1;
|
|
230
|
+
while (endIndex < sql.length && /[A-Za-z0-9_$]/.test(sql[endIndex]))
|
|
231
|
+
endIndex++;
|
|
232
|
+
addToken(i, endIndex, sql.slice(i, endIndex).toUpperCase());
|
|
233
|
+
i = endIndex;
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
addToken(i, i + 1, null);
|
|
237
|
+
i++;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const last = reading.statement;
|
|
241
|
+
if (last)
|
|
242
|
+
statements.push({ startIndex: last.startIndex, endIndex: last.endIndex, leadingWords: last.leadingWords });
|
|
243
|
+
return statements;
|
|
244
|
+
}
|