@signe/room 3.0.0 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +21 -1
- package/dist/index.js +136 -11
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/readme.md +47 -0
- package/src/index.ts +2 -2
- package/src/server.ts +184 -12
- package/src/types/party.ts +26 -0
- package/tests/storage-restore.spec.ts +122 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@signe/room",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.1",
|
|
4
4
|
"description": "PartyKit room primitives with synchronized state, sessions, guards, and HTTP handlers.",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"keywords": [
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"dset": "^3.1.3",
|
|
32
32
|
"partysocket": "^1.0.1",
|
|
33
33
|
"zod": "^3.23.8",
|
|
34
|
-
"@signe/sync": "3.0.
|
|
34
|
+
"@signe/sync": "3.0.1"
|
|
35
35
|
},
|
|
36
36
|
"publishConfig": {
|
|
37
37
|
"access": "public"
|
package/readme.md
CHANGED
|
@@ -299,6 +299,47 @@ const hydrated = { ...snapshot, items };
|
|
|
299
299
|
load(user, hydrated, true);
|
|
300
300
|
```
|
|
301
301
|
|
|
302
|
+
### Storage Restore Hydration
|
|
303
|
+
|
|
304
|
+
Room storage is loaded automatically when a room starts. If persisted snapshots
|
|
305
|
+
contain complex values that must become runtime instances again, implement
|
|
306
|
+
`onStorageRestore` or `onUserStorageRestore` on the room.
|
|
307
|
+
|
|
308
|
+
Use `onStorageRestore` to transform the full room snapshot before it is loaded:
|
|
309
|
+
|
|
310
|
+
```ts
|
|
311
|
+
class GameRoom {
|
|
312
|
+
async onStorageRestore({ snapshot, room, legacy }) {
|
|
313
|
+
return {
|
|
314
|
+
...snapshot,
|
|
315
|
+
status: snapshot.status ?? "waiting",
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Use `onUserStorageRestore` to transform each persisted entry in the room's
|
|
322
|
+
`@users()` collection. The hook receives a fresh user helper instance so you can
|
|
323
|
+
reuse instance methods to hydrate nested data before the snapshot is loaded.
|
|
324
|
+
|
|
325
|
+
```ts
|
|
326
|
+
class GameRoom {
|
|
327
|
+
@users(Player) players = signal({});
|
|
328
|
+
|
|
329
|
+
async onUserStorageRestore({ userSnapshot, user, publicId }) {
|
|
330
|
+
return {
|
|
331
|
+
...userSnapshot,
|
|
332
|
+
items: await user.resolveItems(userSnapshot.items),
|
|
333
|
+
skills: await user.resolveSkills(userSnapshot.skills),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
Returning `undefined` keeps the original snapshot unchanged. The `legacy` flag is
|
|
340
|
+
`true` only when loading data from the pre-`state:` storage layout during
|
|
341
|
+
automatic migration.
|
|
342
|
+
|
|
302
343
|
### Room Configuration
|
|
303
344
|
|
|
304
345
|
The `@Room` decorator accepts various configuration options:
|
|
@@ -955,6 +996,12 @@ The SQLite helper enables `PRAGMA busy_timeout = 5000` and `PRAGMA journal_mode
|
|
|
955
996
|
write contention. You can override those defaults with `busyTimeoutMs`,
|
|
956
997
|
`journalMode`, and `busyRetries`.
|
|
957
998
|
|
|
999
|
+
Room state is stored as incremental `state:` entries. When a persisted delete is
|
|
1000
|
+
encountered, the server compacts the room state by materializing the current
|
|
1001
|
+
snapshot and removing durable delete markers. This keeps long-running SQLite
|
|
1002
|
+
storage from accumulating `"$delete"` tombstones after objects or users are
|
|
1003
|
+
removed.
|
|
1004
|
+
|
|
958
1005
|
To create your own storage backend, implement the key-value methods used by
|
|
959
1006
|
`@signe/room`: `get`, `put`, `delete`, and `list`, then return it from a storage
|
|
960
1007
|
provider.
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
export * from './decorators';
|
|
2
2
|
export { ClientIo, MockConnection, ServerIo } from './mock';
|
|
3
|
-
export { Server } from './server';
|
|
3
|
+
export { Server, type StorageRestoreContext, type UserStorageRestoreContext } from './server';
|
|
4
4
|
export * from './testing';
|
|
5
5
|
export * from './shard';
|
|
6
6
|
export * from './world';
|
|
7
7
|
export * from './interfaces';
|
|
8
8
|
export * from './request/response';
|
|
9
|
-
export { requireSession, createRequireSessionGuard } from './session.guard';
|
|
9
|
+
export { requireSession, createRequireSessionGuard } from './session.guard';
|
package/src/server.ts
CHANGED
|
@@ -33,6 +33,23 @@ type CreateRoomOptions = {
|
|
|
33
33
|
throttleStorage?: number;
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
+
export type StorageRestoreContext<TSnapshot = any> = {
|
|
37
|
+
snapshot: TSnapshot;
|
|
38
|
+
room: Party.Room;
|
|
39
|
+
server: Server;
|
|
40
|
+
legacy: boolean;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type UserStorageRestoreContext<TUser = any, TSnapshot = any> = {
|
|
44
|
+
userSnapshot: TSnapshot;
|
|
45
|
+
user: TUser | undefined;
|
|
46
|
+
publicId: string;
|
|
47
|
+
usersPropName: string;
|
|
48
|
+
room: Party.Room;
|
|
49
|
+
server: Server;
|
|
50
|
+
legacy: boolean;
|
|
51
|
+
};
|
|
52
|
+
|
|
36
53
|
type SessionData = {
|
|
37
54
|
publicId: string;
|
|
38
55
|
state?: any;
|
|
@@ -52,6 +69,8 @@ type StorageMetrics = {
|
|
|
52
69
|
loadMs: number;
|
|
53
70
|
loadStateKeys: number;
|
|
54
71
|
loadLegacyKeys: number;
|
|
72
|
+
stateCompactions: number;
|
|
73
|
+
stateCompactionDeletes: number;
|
|
55
74
|
persistFlushes: number;
|
|
56
75
|
persistWrites: number;
|
|
57
76
|
persistDeletes: number;
|
|
@@ -195,16 +214,139 @@ export class Server implements Party.Server {
|
|
|
195
214
|
const descendantEntries = await this.listStorage(`${stateKey}.`);
|
|
196
215
|
await this.deleteStorageKeys([
|
|
197
216
|
...Array.from(descendantEntries.keys()),
|
|
198
|
-
|
|
217
|
+
stateKey,
|
|
199
218
|
]);
|
|
200
219
|
await this.saveStatePath(path, DELETE_TOKEN);
|
|
201
220
|
}
|
|
202
221
|
|
|
222
|
+
private containsDeleteToken(value: any): boolean {
|
|
223
|
+
if (value === DELETE_TOKEN) {
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
if (!value || typeof value !== "object") {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
if (Array.isArray(value)) {
|
|
230
|
+
return value.some((item) => this.containsDeleteToken(item));
|
|
231
|
+
}
|
|
232
|
+
return Object.values(value).some((item) => this.containsDeleteToken(item));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private async compactStateStorage(instance: any) {
|
|
236
|
+
const entries = await this.listStorage(STATE_PREFIX);
|
|
237
|
+
const keys = Array.from(entries.keys());
|
|
238
|
+
if (keys.length === 0) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const snapshot = createStatesSnapshotDeep(instance);
|
|
243
|
+
await this.deleteStorageKeys(keys);
|
|
244
|
+
await this.saveStatePath(".", snapshot);
|
|
245
|
+
|
|
246
|
+
const metrics = instance.$storageMetrics as StorageMetrics | undefined;
|
|
247
|
+
if (metrics) {
|
|
248
|
+
metrics.stateCompactions += 1;
|
|
249
|
+
metrics.stateCompactionDeletes += keys.length;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private createUserFromClassType(classType: any, ...args: any[]) {
|
|
254
|
+
if (!classType) {
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
return isClass(classType) ? new classType() : classType(...args);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private async restoreUsersStorageSnapshot(
|
|
261
|
+
instance: any,
|
|
262
|
+
snapshot: any,
|
|
263
|
+
options: { legacy: boolean }
|
|
264
|
+
) {
|
|
265
|
+
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) {
|
|
266
|
+
return snapshot;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const restoreUser = instance["onUserStorageRestore"];
|
|
270
|
+
if (typeof restoreUser !== "function") {
|
|
271
|
+
return snapshot;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const signal = this.getUsersProperty(instance);
|
|
275
|
+
const usersPropName = this.getUsersPropName(instance);
|
|
276
|
+
if (!signal || !usersPropName) {
|
|
277
|
+
return snapshot;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const usersSnapshot = snapshot[usersPropName];
|
|
281
|
+
if (!usersSnapshot || typeof usersSnapshot !== "object" || Array.isArray(usersSnapshot)) {
|
|
282
|
+
return snapshot;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const { classType } = signal.options || {};
|
|
286
|
+
let nextSnapshot = snapshot;
|
|
287
|
+
let nextUsersSnapshot = usersSnapshot;
|
|
288
|
+
|
|
289
|
+
for (const [publicId, userSnapshot] of Object.entries(usersSnapshot)) {
|
|
290
|
+
const user = this.createUserFromClassType(classType, publicId);
|
|
291
|
+
const restoredUserSnapshot = await awaitReturn(
|
|
292
|
+
restoreUser.call(instance, {
|
|
293
|
+
userSnapshot,
|
|
294
|
+
user,
|
|
295
|
+
publicId,
|
|
296
|
+
usersPropName,
|
|
297
|
+
room: this.room,
|
|
298
|
+
server: this,
|
|
299
|
+
legacy: options.legacy,
|
|
300
|
+
} satisfies UserStorageRestoreContext)
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
if (restoredUserSnapshot !== undefined && restoredUserSnapshot !== userSnapshot) {
|
|
304
|
+
if (nextSnapshot === snapshot) {
|
|
305
|
+
nextSnapshot = { ...snapshot };
|
|
306
|
+
}
|
|
307
|
+
if (nextUsersSnapshot === usersSnapshot) {
|
|
308
|
+
nextUsersSnapshot = { ...usersSnapshot };
|
|
309
|
+
nextSnapshot[usersPropName] = nextUsersSnapshot;
|
|
310
|
+
}
|
|
311
|
+
nextUsersSnapshot[publicId] = restoredUserSnapshot;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return nextSnapshot;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private async restoreStorageSnapshot(
|
|
319
|
+
instance: any,
|
|
320
|
+
snapshot: any,
|
|
321
|
+
options: { legacy: boolean }
|
|
322
|
+
) {
|
|
323
|
+
const restoreSnapshot = instance["onStorageRestore"];
|
|
324
|
+
let restoredSnapshot = snapshot;
|
|
325
|
+
|
|
326
|
+
if (typeof restoreSnapshot === "function") {
|
|
327
|
+
const result = await awaitReturn(
|
|
328
|
+
restoreSnapshot.call(instance, {
|
|
329
|
+
snapshot,
|
|
330
|
+
room: this.room,
|
|
331
|
+
server: this,
|
|
332
|
+
legacy: options.legacy,
|
|
333
|
+
} satisfies StorageRestoreContext)
|
|
334
|
+
);
|
|
335
|
+
if (result !== undefined) {
|
|
336
|
+
restoredSnapshot = result;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return this.restoreUsersStorageSnapshot(instance, restoredSnapshot, options);
|
|
341
|
+
}
|
|
342
|
+
|
|
203
343
|
private createStorageMetrics(): StorageMetrics {
|
|
204
344
|
return {
|
|
205
345
|
loadMs: 0,
|
|
206
346
|
loadStateKeys: 0,
|
|
207
347
|
loadLegacyKeys: 0,
|
|
348
|
+
stateCompactions: 0,
|
|
349
|
+
stateCompactionDeletes: 0,
|
|
208
350
|
persistFlushes: 0,
|
|
209
351
|
persistWrites: 0,
|
|
210
352
|
persistDeletes: 0,
|
|
@@ -603,15 +745,27 @@ export class Server implements Party.Server {
|
|
|
603
745
|
migratedEntries.map(([path, value]) => [this.stateKey(path), value])
|
|
604
746
|
)
|
|
605
747
|
);
|
|
606
|
-
if (
|
|
748
|
+
if (legacyDeleteKeys.length > 0) {
|
|
607
749
|
await this.deleteStorageKeys(legacyDeleteKeys);
|
|
608
750
|
}
|
|
609
|
-
|
|
751
|
+
const restoredLegacyObject = await this.restoreStorageSnapshot(instance, legacyObject, {
|
|
752
|
+
legacy: true,
|
|
753
|
+
});
|
|
754
|
+
load(instance, restoredLegacyObject, true);
|
|
755
|
+
if (this.containsDeleteToken(restoredLegacyObject)) {
|
|
756
|
+
await this.compactStateStorage(instance);
|
|
757
|
+
}
|
|
610
758
|
metrics.loadMs = Date.now() - startedAt;
|
|
611
759
|
return;
|
|
612
760
|
}
|
|
613
761
|
|
|
614
|
-
|
|
762
|
+
const restoredObject = await this.restoreStorageSnapshot(instance, tmpObject, {
|
|
763
|
+
legacy: false,
|
|
764
|
+
});
|
|
765
|
+
load(instance, restoredObject, true);
|
|
766
|
+
if (this.containsDeleteToken(restoredObject)) {
|
|
767
|
+
await this.compactStateStorage(instance);
|
|
768
|
+
}
|
|
615
769
|
metrics.loadMs = Date.now() - startedAt;
|
|
616
770
|
};
|
|
617
771
|
|
|
@@ -784,19 +938,16 @@ export class Server implements Party.Server {
|
|
|
784
938
|
values.clear();
|
|
785
939
|
}
|
|
786
940
|
|
|
787
|
-
|
|
788
|
-
const persistCb = async (values: Map<string, any>) => {
|
|
789
|
-
if (initPersist) {
|
|
790
|
-
values.clear();
|
|
791
|
-
return;
|
|
792
|
-
}
|
|
941
|
+
const flushPersist = async (values: Map<string, any>) => {
|
|
793
942
|
const startedAt = Date.now();
|
|
794
943
|
const stateWrites: Record<string, any> = {};
|
|
795
944
|
const deleteTasks: Promise<void>[] = [];
|
|
945
|
+
let hasDeletes = false;
|
|
796
946
|
for (let [path, value] of values) {
|
|
797
947
|
const _instance =
|
|
798
948
|
path == "." ? instance : getByPath(instance, path);
|
|
799
949
|
if (value == DELETE_TOKEN) {
|
|
950
|
+
hasDeletes = true;
|
|
800
951
|
deleteTasks.push(this.deleteStatePath(path));
|
|
801
952
|
} else {
|
|
802
953
|
const itemValue = _instance?.$snapshot
|
|
@@ -809,6 +960,9 @@ export class Server implements Party.Server {
|
|
|
809
960
|
this.putStorageEntries(stateWrites),
|
|
810
961
|
...deleteTasks,
|
|
811
962
|
]);
|
|
963
|
+
if (hasDeletes) {
|
|
964
|
+
await this.compactStateStorage(instance);
|
|
965
|
+
}
|
|
812
966
|
const metrics = instance.$storageMetrics as StorageMetrics | undefined;
|
|
813
967
|
if (metrics) {
|
|
814
968
|
metrics.persistFlushes += 1;
|
|
@@ -816,7 +970,25 @@ export class Server implements Party.Server {
|
|
|
816
970
|
metrics.persistDeletes += deleteTasks.length;
|
|
817
971
|
metrics.persistLastFlushMs = Date.now() - startedAt;
|
|
818
972
|
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
let persistQueue = Promise.resolve();
|
|
976
|
+
|
|
977
|
+
// Persist callback: Save changes to storage
|
|
978
|
+
const persistCb = async (values: Map<string, any>) => {
|
|
979
|
+
if (initPersist) {
|
|
980
|
+
values.clear();
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const valuesSnapshot = new Map(values);
|
|
819
985
|
values.clear();
|
|
986
|
+
|
|
987
|
+
persistQueue = persistQueue.then(
|
|
988
|
+
() => flushPersist(valuesSnapshot),
|
|
989
|
+
() => flushPersist(valuesSnapshot)
|
|
990
|
+
);
|
|
991
|
+
await persistQueue;
|
|
820
992
|
}
|
|
821
993
|
|
|
822
994
|
const debouncePersist = (wait: number) => {
|
|
@@ -1188,7 +1360,7 @@ export class Server implements Party.Server {
|
|
|
1188
1360
|
if (transferData?.restored && signal()[publicId]) {
|
|
1189
1361
|
user = signal()[publicId];
|
|
1190
1362
|
} else {
|
|
1191
|
-
user =
|
|
1363
|
+
user = this.createUserFromClassType(classType, conn, ctx);
|
|
1192
1364
|
signal()[publicId] = user;
|
|
1193
1365
|
const snapshot = createStatesSnapshotDeep(user);
|
|
1194
1366
|
await this.saveStatePath(`${usersPropName}.${publicId}`, snapshot);
|
|
@@ -1743,7 +1915,7 @@ export class Server implements Party.Server {
|
|
|
1743
1915
|
const { classType } = signal.options;
|
|
1744
1916
|
|
|
1745
1917
|
// Create new user instance
|
|
1746
|
-
const user =
|
|
1918
|
+
const user = this.createUserFromClassType(classType);
|
|
1747
1919
|
|
|
1748
1920
|
const hydratedSnapshot =
|
|
1749
1921
|
(await awaitReturn(
|
package/src/types/party.ts
CHANGED
|
@@ -253,6 +253,32 @@ import type {
|
|
|
253
253
|
* @param conn The connection object
|
|
254
254
|
*/
|
|
255
255
|
onLeave?(user: TUser, conn: Connection): void | Promise<void>;
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Called before persisted room state is loaded into the room instance.
|
|
259
|
+
* Return a replacement snapshot to hydrate serialized values before load.
|
|
260
|
+
*/
|
|
261
|
+
onStorageRestore?(context: {
|
|
262
|
+
snapshot: any;
|
|
263
|
+
room: Room;
|
|
264
|
+
server: Server;
|
|
265
|
+
legacy: boolean;
|
|
266
|
+
}): any | Promise<any>;
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Called for each restored entry in the room's @users() collection before
|
|
270
|
+
* persisted room state is loaded. Return a replacement user snapshot to
|
|
271
|
+
* hydrate serialized nested values before load.
|
|
272
|
+
*/
|
|
273
|
+
onUserStorageRestore?(context: {
|
|
274
|
+
userSnapshot: any;
|
|
275
|
+
user: TUser | undefined;
|
|
276
|
+
publicId: string;
|
|
277
|
+
usersPropName: string;
|
|
278
|
+
room: Room;
|
|
279
|
+
server: Server;
|
|
280
|
+
legacy: boolean;
|
|
281
|
+
}): any | Promise<any>;
|
|
256
282
|
}
|
|
257
283
|
|
|
258
284
|
/** @deprecated Use `Party.Room` instead */
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { signal } from "@signe/reactive";
|
|
3
|
+
import { sync, users } from "@signe/sync";
|
|
4
|
+
import { Room, Server, ServerIo } from "../src";
|
|
5
|
+
|
|
6
|
+
class Item {
|
|
7
|
+
id = signal("");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
class Player {
|
|
11
|
+
items = signal<any[]>([]);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("storage restore hooks", () => {
|
|
15
|
+
it("allows a room to hydrate the persisted root snapshot before load", async () => {
|
|
16
|
+
@Room({ path: "demo" })
|
|
17
|
+
class DemoRoom {
|
|
18
|
+
@sync()
|
|
19
|
+
title = signal("");
|
|
20
|
+
|
|
21
|
+
onStorageRestore({ snapshot }: { snapshot: any }) {
|
|
22
|
+
return {
|
|
23
|
+
...snapshot,
|
|
24
|
+
title: `${snapshot.title}:hydrated`,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class DemoServer extends Server {
|
|
30
|
+
rooms = [DemoRoom];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const io = new ServerIo("demo");
|
|
34
|
+
await io.storage.put("state:title", "saved");
|
|
35
|
+
|
|
36
|
+
const server = new DemoServer(io as any);
|
|
37
|
+
await server.onStart();
|
|
38
|
+
|
|
39
|
+
expect((server.subRoom as any).title()).toBe("saved:hydrated");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("allows a room to hydrate persisted user snapshots before load", async () => {
|
|
43
|
+
@Room({ path: "demo" })
|
|
44
|
+
class DemoRoom {
|
|
45
|
+
@users(Player)
|
|
46
|
+
players = signal<Record<string, Player>>({});
|
|
47
|
+
|
|
48
|
+
async onUserStorageRestore({ userSnapshot, user }: { userSnapshot: any; user?: Player }) {
|
|
49
|
+
return {
|
|
50
|
+
...userSnapshot,
|
|
51
|
+
items: userSnapshot.items.map((entry: any) => {
|
|
52
|
+
const item = new Item();
|
|
53
|
+
item.id.set(entry.id);
|
|
54
|
+
return item;
|
|
55
|
+
}),
|
|
56
|
+
usedHelperInstance: user instanceof Player,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
class DemoServer extends Server {
|
|
62
|
+
rooms = [DemoRoom];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const io = new ServerIo("demo");
|
|
66
|
+
await io.storage.put("state:players.public-1.items", [{ id: "potion" }]);
|
|
67
|
+
|
|
68
|
+
const server = new DemoServer(io as any);
|
|
69
|
+
await server.onStart();
|
|
70
|
+
|
|
71
|
+
const restoredPlayer = (server.subRoom as any).players()["public-1"];
|
|
72
|
+
expect(restoredPlayer).toBeInstanceOf(Player);
|
|
73
|
+
expect(restoredPlayer.items()[0]).toBeInstanceOf(Item);
|
|
74
|
+
expect(restoredPlayer.items()[0].id()).toBe("potion");
|
|
75
|
+
expect((restoredPlayer as any).usedHelperInstance).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("compacts persisted delete markers when loading room storage", async () => {
|
|
79
|
+
@Room({ path: "demo" })
|
|
80
|
+
class DemoRoom {
|
|
81
|
+
@sync()
|
|
82
|
+
items = signal<Record<string, number>>({});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
class DemoServer extends Server {
|
|
86
|
+
rooms = [DemoRoom];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const io = new ServerIo("demo");
|
|
90
|
+
await io.storage.put("state:items.a", "$delete");
|
|
91
|
+
|
|
92
|
+
const server = new DemoServer(io as any);
|
|
93
|
+
await server.onStart();
|
|
94
|
+
|
|
95
|
+
const storageEntries = await io.storage.list({ prefix: "state:" });
|
|
96
|
+
expect(storageEntries.get("state:items.a")).toBeUndefined();
|
|
97
|
+
expect(storageEntries.get("state:.")).toEqual({ items: {} });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("compacts runtime deletes instead of leaving delete markers in storage", async () => {
|
|
101
|
+
@Room({ path: "demo" })
|
|
102
|
+
class DemoRoom {
|
|
103
|
+
@sync()
|
|
104
|
+
items = signal<Record<string, number>>({ a: 1, b: 2 });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
class DemoServer extends Server {
|
|
108
|
+
rooms = [DemoRoom];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const io = new ServerIo("demo");
|
|
112
|
+
const server = new DemoServer(io as any);
|
|
113
|
+
await server.onStart();
|
|
114
|
+
|
|
115
|
+
delete (server.subRoom as any).items().a;
|
|
116
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
117
|
+
|
|
118
|
+
const storageEntries = await io.storage.list({ prefix: "state:" });
|
|
119
|
+
expect(storageEntries.get("state:items.a")).toBeUndefined();
|
|
120
|
+
expect(storageEntries.get("state:.")).toEqual({ items: { b: 2 } });
|
|
121
|
+
});
|
|
122
|
+
});
|