@arcadible/sdk 1.1.0 → 1.2.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.
@@ -1,4 +1,5 @@
1
1
  // [import]
2
+ import { ArcadibleError, Codes, log } from "../../bus/protocol.js";
2
3
  import { listenersByKey } from "./listeners.js";
3
4
  import { memoryBackend } from "./memory.js";
4
5
  // [declare]
@@ -12,9 +13,6 @@ const CHANNEL = "arcadible-saves";
12
13
  // every save is a guest's for One; a signed-in player's sit beside them
13
14
  // later, under their own id, with no migration.
14
15
  const GUEST = "guest";
15
- // how long opening may take before saves are kept in memory instead: some
16
- // browsers can leave an open hanging.
17
- const OPEN_TIMEOUT = 5000;
18
16
  // [function]
19
17
  function settle(request) {
20
18
  return new Promise((resolve, reject) => {
@@ -31,9 +29,10 @@ function finish(tx) {
31
29
  });
32
30
  }
33
31
  // [function]
34
- function open(factory, timeout) {
32
+ // Opens the database, however long it takes: a slow open is waited for,
33
+ // since the saves are late, not gone (the game's calls wait, or time out).
34
+ function open(factory) {
35
35
  return new Promise((resolve, reject) => {
36
- const timer = setTimeout(() => reject(new Error("Opening IndexedDB timed out.")), timeout);
37
36
  const request = factory.open(DB_NAME, DB_VERSION);
38
37
  request.onupgradeneeded = () => {
39
38
  const db = request.result;
@@ -45,14 +44,8 @@ function open(factory, timeout) {
45
44
  saves.createIndex(BY_SCOPE, ["player", "game", "channel"]);
46
45
  db.createObjectStore(GAMES, { keyPath: ["player", "game", "channel"] });
47
46
  };
48
- request.onsuccess = () => {
49
- clearTimeout(timer);
50
- resolve(request.result);
51
- };
52
- request.onerror = () => {
53
- clearTimeout(timer);
54
- reject(request.error);
55
- };
47
+ request.onsuccess = () => resolve(request.result);
48
+ request.onerror = () => reject(request.error);
56
49
  });
57
50
  }
58
51
  // [function]
@@ -69,8 +62,11 @@ function isClosed(err) {
69
62
  }
70
63
  // [function]
71
64
  // Saves kept in IndexedDB: the play page's, which every game shares, and a
72
- // game standing alone on its own origin. Where IndexedDB won't open, such as
73
- // some `file://` pages, they're kept in memory, and `status()` says so.
65
+ // game standing alone on its own origin. Where there's no IndexedDB, or it
66
+ // refuses to open (a browser blocking site data, some `file://` pages),
67
+ // they're kept in memory, and `status()` says so. Once it has opened, it
68
+ // never switches: a lost connection fails calls with `unavailable` until it
69
+ // opens again, rather than show an empty copy.
74
70
  export function indexedDBBackend(options = {}) {
75
71
  const factory = options.factory === undefined
76
72
  ? (globalThis.indexedDB ?? null)
@@ -78,35 +74,64 @@ export function indexedDBBackend(options = {}) {
78
74
  const player = options.player ?? GUEST;
79
75
  const fallback = memoryBackend();
80
76
  const listeners = listenersByKey();
81
- let status = "local";
77
+ // memory only when the first open fails; never once it has opened.
78
+ let status = "device";
79
+ let opened = false;
80
+ let current = null;
81
+ let opening = null;
82
82
  let asked = false;
83
83
  const keyOf = (scope) => `${player}\u0000${scope.game}\u0000${scope.channel}`;
84
84
  const rangeOf = (scope) => IDBKeyRange.only([player, scope.game, scope.channel]);
85
+ const lost = function (db) {
86
+ if (current === db)
87
+ current = null;
88
+ };
85
89
  const connect = async function () {
86
90
  try {
87
91
  if (!factory)
88
92
  throw new Error("IndexedDB isn't available here.");
89
- const db = await open(factory, options.openTimeout ?? OPEN_TIMEOUT);
93
+ const db = await open(factory);
90
94
  // a newer version of this page wants to upgrade: step aside.
91
- db.onversionchange = () => db.close();
95
+ db.onversionchange = () => {
96
+ db.close();
97
+ lost(db);
98
+ };
99
+ // the browser closed it under us (Safari can).
100
+ db.onclose = () => lost(db);
92
101
  if (options.persist && !asked) {
93
102
  asked = true;
94
103
  globalThis.navigator?.storage?.persist?.().catch(() => false);
95
104
  }
105
+ opened = true;
106
+ current = db;
96
107
  return db;
97
108
  }
98
109
  catch (err) {
99
- console.warn("arcadible: saves are kept in memory only:", err);
100
- status = "ephemeral";
110
+ if (opened) {
111
+ log("warn", "the saves' storage couldn't be opened again.", err);
112
+ throw new ArcadibleError(Codes.Unavailable, "Saves can't be reached right now. Try again.");
113
+ }
114
+ log("warn", "this browser won't keep saves here, so they're kept in memory until the page closes.", err);
115
+ status = "memory";
101
116
  return null;
102
117
  }
118
+ finally {
119
+ opening = null;
120
+ }
121
+ };
122
+ // the database, opening it once however many ask; null in memory.
123
+ const database = function () {
124
+ if (status === "memory")
125
+ return Promise.resolve(null);
126
+ if (current)
127
+ return Promise.resolve(current);
128
+ opening ??= connect();
129
+ return opening;
103
130
  };
104
- let opening = connect();
105
131
  // runs `op` on the database, opening it again once if it was closed, or
106
132
  // on the memory fallback when there's none.
107
133
  const run = async function (op, otherwise) {
108
- const current = opening;
109
- const db = await current;
134
+ const db = await database();
110
135
  if (!db)
111
136
  return otherwise();
112
137
  try {
@@ -115,13 +140,12 @@ export function indexedDBBackend(options = {}) {
115
140
  catch (err) {
116
141
  if (!isClosed(err))
117
142
  throw err;
118
- // once, however many operations found it closed.
119
- if (opening === current)
120
- opening = connect();
121
- const again = await opening;
143
+ lost(db);
144
+ const again = await database();
122
145
  return again ? op(again) : otherwise();
123
146
  }
124
147
  };
148
+ database().catch(() => undefined);
125
149
  const broadcast = typeof BroadcastChannel === "undefined"
126
150
  ? null
127
151
  : new BroadcastChannel(CHANNEL);
@@ -218,7 +242,8 @@ export function indexedDBBackend(options = {}) {
218
242
  },
219
243
  close() {
220
244
  broadcast?.close();
221
- opening.then((db) => db?.close()).catch(() => undefined);
245
+ current?.close();
246
+ opening?.then((db) => db?.close()).catch(() => undefined);
222
247
  },
223
248
  };
224
249
  }
@@ -1,4 +1,5 @@
1
1
  // [import]
2
+ import { log } from "../../bus/protocol.js";
2
3
  // [function]
3
4
  // Each game's subscribers, by key, for a backend to tell of changes. Each
4
5
  // gets its own copy, so one can't change what the next one sees, and one
@@ -25,7 +26,7 @@ export function listenersByKey() {
25
26
  listener(structuredClone(change), source);
26
27
  }
27
28
  catch (err) {
28
- console.error("arcadible: a change's listener failed:", err);
29
+ log("error", "a change's listener failed.", err);
29
30
  }
30
31
  }
31
32
  },
@@ -1,7 +1,7 @@
1
- import type { SaveStatus } from "../rules.js";
1
+ import type { BackendStatus } from "../rules.js";
2
2
  import type { Backend, Scope } from "./types.js";
3
3
  export declare function memoryBackend({ status, }?: {
4
- status?: SaveStatus;
4
+ status?: BackendStatus;
5
5
  }): Backend & {
6
6
  used(scope: Scope): string | undefined;
7
7
  };
@@ -5,10 +5,11 @@ function keyOf(scope) {
5
5
  return `${scope.game}\u0000${scope.channel}`;
6
6
  }
7
7
  // [function]
8
- // Saves kept in memory, for tests, `arc preview`, and wherever IndexedDB
8
+ // Saves kept in memory, for tests, and wherever IndexedDB
9
9
  // won't open. Hosts sharing one act like tabs sharing a device. Its saves
10
- // last as long as the page, so it says `ephemeral`.
11
- export function memoryBackend({ status = "ephemeral", } = {}) {
10
+ // last as long as the page, so it says `memory`; tests pretend it's a
11
+ // device's with `device`.
12
+ export function memoryBackend({ status = "memory", } = {}) {
12
13
  const games = new Map();
13
14
  const listeners = listenersByKey();
14
15
  const gameOf = function (scope) {
@@ -1,4 +1,4 @@
1
- import type { SaveStatus } from "../rules.js";
1
+ import type { BackendStatus } from "../rules.js";
2
2
  export type Scope = {
3
3
  game: string;
4
4
  channel: string;
@@ -18,7 +18,7 @@ export type Change = {
18
18
  revision: number;
19
19
  };
20
20
  export interface Backend {
21
- status(): SaveStatus;
21
+ status(): BackendStatus;
22
22
  list(scope: Scope): Promise<SaveRecord[]>;
23
23
  get(scope: Scope, name: string): Promise<SaveRecord | null>;
24
24
  change(scope: Scope, name: string, decide: (saves: SaveRecord[]) => NewRecord | null, source: string): Promise<Change | null>;
@@ -1,12 +1,30 @@
1
- import type { Bus } from "../bus/client.js";
2
- import { type SaveInfo } from "./rules.js";
1
+ import { type Bus } from "../bus/client.js";
2
+ import { type SaveInfo, type Where } from "./rules.js";
3
+ /** `arcadible.saves`: each save is a name holding one string. */
3
4
  export interface Saves {
5
+ /** Keeps `data` as the save `name`, replacing what it held. */
4
6
  write(name: string, data: string): Promise<SaveInfo>;
7
+ /** The save's string, or `null` when there's none. */
5
8
  read(name: string): Promise<string | null>;
9
+ /** The names of the game's saves, sorted. */
6
10
  list(): Promise<string[]>;
11
+ /** Which version wrote the save, and when, or `null` when there's none. */
7
12
  info(name: string): Promise<SaveInfo | null>;
13
+ /** Removes the save, if there is one. */
8
14
  remove(name: string): Promise<void>;
15
+ /** After `ready`, the save's string from memory; before it, throws `not_ready`. */
9
16
  readSync(name: string): string | null;
17
+ /** After `ready`, the names of the game's saves from memory, sorted. */
18
+ listSync(): string[];
19
+ /** After `ready`, the save's version and time from memory, or `null`. */
20
+ infoSync(name: string): SaveInfo | null;
21
+ /**
22
+ * Who can read the saves back, once `ready` has settled: `"game"` (every
23
+ * version of the game), `"build"` (only this build), or `"memory"` (only
24
+ * until the page closes). `undefined` until it's known. Other values may
25
+ * be added: treat one you don't know as `"game"`.
26
+ */
27
+ readonly where: Where | undefined;
10
28
  }
11
29
  export declare function createSaves(bus: Bus, { timeout }: {
12
30
  timeout: number;
@@ -1,11 +1,12 @@
1
1
  // Saves, the game's side: `arcadible.saves`, and its memory copy of the
2
2
  // game's saves, so reads after `ready` answer at once.
3
- import { ArcadibleError, Codes, utf8Size } from "../bus/protocol.js";
4
- import { checkName, checkWrite, } from "./rules.js";
3
+ // [import]
4
+ import { mayHaveLanded } from "../bus/client.js";
5
+ import { ArcadibleError, buildOf, Codes, isStringArray, isStringOrNull, log, utf8Size, } from "../bus/protocol.js";
6
+ import { checkName, checkWrite, isRevisionOrNull, isSaveInfoOrNull, isSaveMeta, isSavesChange, isSavesLoad, } from "./rules.js";
5
7
  // [function]
6
- // a call that failed this way may still have been kept by the page.
7
- function mayHaveLanded(err) {
8
- return err instanceof ArcadibleError && err.code === Codes.Timeout;
8
+ function isAnything(_value) {
9
+ return true;
9
10
  }
10
11
  // [function]
11
12
  function infoOf(meta) {
@@ -20,11 +21,17 @@ export function createSaves(bus, { timeout }) {
20
21
  let settled = false;
21
22
  // whether the memory copy holds the game's saves.
22
23
  let loaded = false;
24
+ let where;
23
25
  let version = null;
24
26
  const mirror = new Map();
25
27
  // the newest revision seen of each save, removals included.
26
28
  const revisions = new Map();
27
29
  const early = [];
30
+ // the newest write of each save, until the page answers it: an older
31
+ // write's answer, merged with it or ahead of it, mustn't replace it in
32
+ // the memory copy.
33
+ const writing = new Map();
34
+ let writes = 0;
28
35
  let open = () => undefined;
29
36
  const opened = new Promise((resolve) => {
30
37
  open = () => {
@@ -52,6 +59,14 @@ export function createSaves(bus, { timeout }) {
52
59
  kept(name, revision, save);
53
60
  };
54
61
  // [function]
62
+ // the answer to a write or remove that timed out, once it comes: the
63
+ // page did keep it, so the memory copy takes it, unless a newer write of
64
+ // the save is waiting, whose answer will be newer still.
65
+ const landed = function (name, revision, save) {
66
+ if (!writing.has(name))
67
+ kept(name, revision, save);
68
+ };
69
+ // [function]
55
70
  const restore = function (name, previous) {
56
71
  if (previous)
57
72
  mirror.set(name, previous);
@@ -72,131 +87,160 @@ export function createSaves(bus, { timeout }) {
72
87
  }
73
88
  };
74
89
  // [function]
75
- const request = async function (name, params) {
76
- await opened;
77
- return bus.request(name, params);
90
+ // runs `call` once `ready` has settled: at once when it has, so a write
91
+ // changes the memory copy in the same tick, and a `readSync` right after
92
+ // it sees the save.
93
+ const whenOpened = function (call) {
94
+ return settled ? call() : opened.then(call);
95
+ };
96
+ // [function]
97
+ const send = function (name, data) {
98
+ // before the saves have loaded, only the name and document can be
99
+ // checked here; the page checks the rest once they have.
100
+ const optimistic = loaded;
101
+ const refused = checkWrite(optimistic ? mirror : new Map(), name, data);
102
+ if (refused)
103
+ return Promise.reject(refused);
104
+ // the memory copy changes at once, so the next read sees it; the
105
+ // promise settles once the page has kept it.
106
+ const previous = mirror.get(name);
107
+ const ticket = ++writes;
108
+ writing.set(name, ticket);
109
+ if (optimistic) {
110
+ mirror.set(name, {
111
+ name,
112
+ data,
113
+ info: {
114
+ version,
115
+ savedAt: new Date().toISOString(),
116
+ size: utf8Size(data),
117
+ revision: previous?.info.revision ?? 0,
118
+ },
119
+ });
120
+ }
121
+ const save = (meta) => ({ name, data, info: meta });
122
+ return bus
123
+ .request("saves.write", { name, data }, isSaveMeta, {
124
+ late: (meta) => landed(name, meta.revision, save(meta)),
125
+ })
126
+ .then((meta) => {
127
+ if (writing.get(name) === ticket) {
128
+ writing.delete(name);
129
+ kept(name, meta.revision, save(meta));
130
+ }
131
+ return infoOf(meta);
132
+ }, (err) => {
133
+ if (writing.get(name) === ticket)
134
+ writing.delete(name);
135
+ // one the page refused is rolled back, unless something newer
136
+ // replaced it meanwhile; one it may have kept stays.
137
+ if (optimistic &&
138
+ !mayHaveLanded(err) &&
139
+ mirror.get(name)?.data === data) {
140
+ restore(name, previous);
141
+ }
142
+ throw err;
143
+ });
144
+ };
145
+ // [function]
146
+ const erase = function (name) {
147
+ const optimistic = loaded;
148
+ const previous = mirror.get(name);
149
+ if (optimistic)
150
+ mirror.delete(name);
151
+ return bus
152
+ .request("saves.remove", { name }, isRevisionOrNull, {
153
+ late: (revision) => {
154
+ if (revision !== null)
155
+ landed(name, revision, null);
156
+ },
157
+ })
158
+ .then((revision) => {
159
+ if (revision !== null)
160
+ kept(name, revision, null);
161
+ }, (err) => {
162
+ if (optimistic && !mayHaveLanded(err) && !mirror.has(name)) {
163
+ restore(name, previous);
164
+ }
165
+ throw err;
166
+ });
78
167
  };
79
168
  // [start]
80
169
  let timer;
81
- bus.on("saves.load", (data) => {
170
+ // the load is checked here, not by the bus, so even a malformed one
171
+ // settles `ready` at once: the saves didn't load, and the sync reads say so.
172
+ bus.on("saves.load", isAnything, (load) => {
82
173
  if (loaded)
83
174
  return;
84
175
  clearTimeout(timer);
85
- const load = data;
86
- try {
87
- if (!load?.error && Array.isArray(load?.items)) {
88
- for (const save of load.items) {
89
- mirror.set(save.name, save);
90
- revisions.set(save.name, save.info.revision);
91
- }
92
- loaded = true;
93
- for (const change of early.splice(0))
94
- apply(change);
95
- }
176
+ if (!isSavesLoad(load)) {
177
+ log("warn", "the play page sent saves the game couldn't read.");
178
+ open();
179
+ return;
96
180
  }
97
- finally {
98
- // even a malformed load settles `ready`.
181
+ if (load.error) {
99
182
  open();
183
+ return;
184
+ }
185
+ for (const save of load.items) {
186
+ mirror.set(save.name, save);
187
+ revisions.set(save.name, save.info.revision);
100
188
  }
189
+ loaded = true;
190
+ where = typeof load.where === "string" ? load.where : undefined;
191
+ for (const change of early.splice(0))
192
+ apply(change);
193
+ open();
101
194
  });
102
- bus.on("saves.change", (data) => {
195
+ bus.on("saves.change", isSavesChange, (change) => {
103
196
  // one that comes before the saves is kept until they do.
104
197
  if (loaded)
105
- apply(data);
198
+ apply(change);
106
199
  else
107
- early.push(data);
200
+ early.push(change);
108
201
  });
109
202
  bus.welcomed.then((welcome) => {
110
- version = welcome.build?.version ?? null;
203
+ version = buildOf(welcome.build)?.version ?? null;
111
204
  // the memory copy doesn't pretend to be empty while the saves are late:
112
205
  // a game that took that for "no save yet" could start over, and
113
206
  // overwrite the player's progress once they arrived.
114
207
  if (!settled) {
115
208
  timer = setTimeout(() => {
116
- console.warn("arcadible: the game's saves haven't arrived yet.");
209
+ if (!loaded) {
210
+ log("warn", "the game's saves haven't arrived yet, so the sync reads throw unavailable until they do.");
211
+ }
117
212
  open();
118
213
  }, timeout);
119
214
  }
120
215
  });
121
216
  const saves = {
122
- async write(name, data) {
123
- await opened;
124
- // before the saves have loaded, only the name and document can be
125
- // checked here; the page checks the rest once they have.
126
- const optimistic = loaded;
127
- const refused = checkWrite(optimistic ? mirror : new Map(), name, data);
128
- if (refused)
129
- throw refused;
130
- // the memory copy changes at once, so the next read sees it; the
131
- // promise settles once the page has kept it.
132
- const previous = mirror.get(name);
133
- if (optimistic) {
134
- mirror.set(name, {
135
- name,
136
- data,
137
- info: {
138
- version,
139
- savedAt: new Date().toISOString(),
140
- size: utf8Size(data),
141
- revision: previous?.info.revision ?? 0,
142
- },
143
- });
144
- }
145
- try {
146
- const meta = (await bus.request("saves.write", {
147
- name,
148
- data,
149
- }));
150
- kept(name, meta.revision, { name, data, info: meta });
151
- return infoOf(meta);
152
- }
153
- catch (err) {
154
- // one the page refused is rolled back, unless something newer
155
- // replaced it meanwhile.
156
- if (optimistic &&
157
- !mayHaveLanded(err) &&
158
- mirror.get(name)?.data === data) {
159
- restore(name, previous);
160
- }
161
- throw err;
162
- }
217
+ write(name, data) {
218
+ return whenOpened(() => send(name, data));
163
219
  },
164
220
  async read(name) {
165
221
  const refused = checkName(name);
166
222
  if (refused)
167
223
  throw refused;
168
- return (await request("saves.read", { name }));
224
+ await opened;
225
+ return bus.request("saves.read", { name }, isStringOrNull);
169
226
  },
170
227
  async list() {
171
- return (await request("saves.list"));
228
+ await opened;
229
+ return bus.request("saves.list", undefined, isStringArray);
172
230
  },
173
231
  async info(name) {
174
232
  const refused = checkName(name);
175
233
  if (refused)
176
234
  throw refused;
177
- const meta = (await request("saves.info", { name }));
235
+ await opened;
236
+ const meta = await bus.request("saves.info", { name }, isSaveInfoOrNull);
178
237
  return meta ? infoOf(meta) : null;
179
238
  },
180
239
  async remove(name) {
181
240
  const refused = checkName(name);
182
241
  if (refused)
183
242
  throw refused;
184
- await opened;
185
- const optimistic = loaded;
186
- const previous = mirror.get(name);
187
- if (optimistic)
188
- mirror.delete(name);
189
- try {
190
- const revision = (await bus.request("saves.remove", { name }));
191
- if (revision !== null)
192
- kept(name, revision, null);
193
- }
194
- catch (err) {
195
- if (optimistic && !mayHaveLanded(err) && !mirror.has(name)) {
196
- restore(name, previous);
197
- }
198
- throw err;
199
- }
243
+ return whenOpened(() => erase(name));
200
244
  },
201
245
  readSync(name) {
202
246
  assertLoaded();
@@ -205,6 +249,21 @@ export function createSaves(bus, { timeout }) {
205
249
  throw refused;
206
250
  return mirror.get(name)?.data ?? null;
207
251
  },
252
+ listSync() {
253
+ assertLoaded();
254
+ return [...mirror.keys()].sort();
255
+ },
256
+ infoSync(name) {
257
+ assertLoaded();
258
+ const refused = checkName(name);
259
+ if (refused)
260
+ throw refused;
261
+ const save = mirror.get(name);
262
+ return save ? infoOf(save.info) : null;
263
+ },
264
+ get where() {
265
+ return where;
266
+ },
208
267
  };
209
268
  return { saves, opened };
210
269
  }
@@ -1,9 +1,22 @@
1
1
  import { ArcadibleError } from "../bus/protocol.js";
2
2
  export declare const MAX_SAVES = 32;
3
3
  export declare const MAX_TOTAL: number;
4
- export type SaveStatus = "local" | "ephemeral";
4
+ /**
5
+ * Who can read the saves back: every version of the game (`"game"`), only
6
+ * this build (`"build"`), or only until the page closes (`"memory"`). Other
7
+ * values may be added, such as `"account"`: a game treats one it doesn't
8
+ * know as `"game"`.
9
+ */
10
+ export type Where = "game" | "build" | "memory" | (string & {});
11
+ export type BackendStatus = "device" | "memory";
12
+ /** What a game is told of a save. */
5
13
  export type SaveInfo = {
14
+ /**
15
+ * The game's version that wrote it, stamped by the page; `null` when it
16
+ * was written standing alone, where there's no version to stamp.
17
+ */
6
18
  version: string | null;
19
+ /** When it was written, as an ISO 8601 time. */
7
20
  savedAt: string;
8
21
  };
9
22
  export type SaveMeta = SaveInfo & {
@@ -16,7 +29,7 @@ export type Save = {
16
29
  info: SaveMeta;
17
30
  };
18
31
  export type SavesLoad = {
19
- status: SaveStatus;
32
+ where?: Where;
20
33
  items: Save[];
21
34
  error?: {
22
35
  code: string;
@@ -32,3 +45,9 @@ export declare function checkName(name: unknown): ArcadibleError | null;
32
45
  export declare function checkWrite(saves: ReadonlyMap<string, {
33
46
  info: SaveMeta;
34
47
  }>, name: unknown, data: unknown): ArcadibleError | null;
48
+ export declare function isSaveMeta(value: unknown): value is SaveMeta;
49
+ export declare function isSaveInfoOrNull(value: unknown): value is SaveInfo | null;
50
+ export declare function isSave(value: unknown): value is Save;
51
+ export declare function isSavesLoad(value: unknown): value is SavesLoad;
52
+ export declare function isSavesChange(value: unknown): value is SavesChange;
53
+ export declare function isRevisionOrNull(value: unknown): value is number | null;
@@ -1,7 +1,7 @@
1
1
  // Saves' rules, shared by the game's side and the page's so they can't drift
2
2
  // apart: the shapes, the limits, and their checks (DESIGN.md, section 6).
3
3
  // [import]
4
- import { ArcadibleError, Codes, utf8Size } from "../bus/protocol.js";
4
+ import { ArcadibleError, Codes, isNumber, isObject, isStringOrNull, utf8Size, } from "../bus/protocol.js";
5
5
  // [declare]
6
6
  // Saves' limits, the same everywhere (DESIGN.md, section 6, Limits).
7
7
  export const MAX_SAVES = 32;
@@ -47,3 +47,48 @@ export function checkWrite(saves, name, data) {
47
47
  }
48
48
  return null;
49
49
  }
50
+ // [function]
51
+ // What the page sends of a save, checked for only what a game relies on
52
+ // (DESIGN.md, The host is never trusted): extra fields pass.
53
+ export function isSaveMeta(value) {
54
+ return (isObject(value) &&
55
+ isStringOrNull(value["version"]) &&
56
+ typeof value["savedAt"] === "string" &&
57
+ isNumber(value["size"]) &&
58
+ isNumber(value["revision"]));
59
+ }
60
+ // [function]
61
+ export function isSaveInfoOrNull(value) {
62
+ return (value === null ||
63
+ (isObject(value) &&
64
+ isStringOrNull(value["version"]) &&
65
+ typeof value["savedAt"] === "string"));
66
+ }
67
+ // [function]
68
+ export function isSave(value) {
69
+ return (isObject(value) &&
70
+ checkName(value["name"]) === null &&
71
+ typeof value["data"] === "string" &&
72
+ isSaveMeta(value["info"]));
73
+ }
74
+ // [function]
75
+ // A load the memory copy can take whole: every save readable, or none.
76
+ export function isSavesLoad(value) {
77
+ return (isObject(value) &&
78
+ Array.isArray(value["items"]) &&
79
+ (value["error"] !== undefined || value["items"].every(isSave)));
80
+ }
81
+ // [function]
82
+ export function isSavesChange(value) {
83
+ if (!isObject(value) ||
84
+ checkName(value["name"]) !== null ||
85
+ !isNumber(value["revision"])) {
86
+ return false;
87
+ }
88
+ const save = value["save"];
89
+ return save === null || (isSave(save) && save.name === value["name"]);
90
+ }
91
+ // [function]
92
+ export function isRevisionOrNull(value) {
93
+ return value === null || isNumber(value);
94
+ }
@@ -1,8 +1,10 @@
1
1
  import type { Module, ModuleContext } from "../bus/server.js";
2
2
  import type { Backend, Scope } from "./backend/types.js";
3
+ import { type Where } from "./rules.js";
3
4
  type SavesServerOptions = {
4
5
  scope: Scope;
5
6
  version: string | null;
7
+ where: Where;
6
8
  backend: Backend;
7
9
  now: () => Date;
8
10
  };