@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signe/room",
3
- "version": "3.0.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.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
- path,
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 (legacyRoot !== undefined) {
748
+ if (legacyDeleteKeys.length > 0) {
607
749
  await this.deleteStorageKeys(legacyDeleteKeys);
608
750
  }
609
- load(instance, legacyObject, true);
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
- load(instance, tmpObject, true);
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
- // Persist callback: Save changes to storage
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 = isClass(classType) ? new classType() : classType(conn, ctx);
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 = isClass(classType) ? new classType() : classType();
1918
+ const user = this.createUserFromClassType(classType);
1747
1919
 
1748
1920
  const hydratedSnapshot =
1749
1921
  (await awaitReturn(
@@ -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
+ });