Skip to content

Commit 07e2a1e

Browse files
authored
feat: on storage destroyed (#1220)
1 parent ad487ad commit 07e2a1e

File tree

14 files changed

+165
-42
lines changed

14 files changed

+165
-42
lines changed

.changeset/witty-bobcats-lay.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@pluv/platform-cloudflare": major
3+
"@pluv/platform-node": major
4+
"@pluv/platform-pluv": major
5+
"@pluv/io": major
6+
---
7+
8+
### Breaking Changes
9+
10+
#### Split `onDestroy` into `onRoomDestroyed` and `onStorageDestroyed`
11+
12+
The `onDestroy` event handler has been split into two separate handlers to provide better control over when storage is saved:
13+
14+
- **`onRoomDestroyed`**: Fires whenever a room is destroyed. This event no longer includes `encodedState` to prevent accidentally saving empty or uninitialized storage data.
15+
- **`onStorageDestroyed`**: Only fires when the storage document is about to be destroyed after it has been initialized via `initializeSession`. This event includes `encodedState` and should be used for saving storage to your database.
16+
17+
**Migration Guide:**
18+
19+
**Before:**
20+
```typescript
21+
io.server({
22+
onDestroy: async ({ encodedState, room }) => {
23+
// This could receive null/empty encodedState if storage wasn't initialized
24+
await saveToDatabase(room, encodedState);
25+
},
26+
});
27+
```
28+
29+
**After:**
30+
```typescript
31+
io.server({
32+
onRoomDestroyed: async ({ room }) => {
33+
// Room destroyed - use for cleanup if needed
34+
await cleanupRoom(room);
35+
},
36+
onStorageDestroyed: async ({ encodedState, room }) => {
37+
// Only fires when storage was initialized via initializeSession
38+
// Safe to save to database
39+
await saveToDatabase(room, encodedState);
40+
},
41+
});
42+
```
43+
44+
**Type Changes:**
45+
46+
- `IORoomListenerEvent` now includes `encodedState: string | null`
47+
- `IORoomDestroyedEvent` is a new type that does NOT include `encodedState`
48+
- `onRoomDeleted` now uses `IORoomDestroyedEvent` instead of `IORoomListenerEvent`

packages/io/src/IORoom.ts

Lines changed: 64 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import type {
3737
EventResolverContext,
3838
EventResolverKind,
3939
GetInitialStorageFn,
40+
IORoomDestroyedEvent,
4041
IORoomListenerEvent,
4142
IORoomMessageEvent,
4243
IOUserConnectedEvent,
@@ -64,7 +65,8 @@ export interface IORoomListeners<
6465
TContext extends Record<string, any>,
6566
TEvents extends PluvRouterEventConfig<TPlatform, TAuthorize, TContext>,
6667
> {
67-
onDestroy: (event: IORoomListenerEvent<TPlatform, TContext>) => void;
68+
onRoomDestroyed: (event: IORoomDestroyedEvent<TPlatform, TContext>) => void;
69+
onStorageDestroyed: (event: IORoomListenerEvent<TPlatform, TContext>) => void;
6870
onMessage: (event: IORoomMessageEvent<TPlatform, TAuthorize, TContext, TEvents>) => void;
6971
onUserConnected: (event: IOUserConnectedEvent<TPlatform, TAuthorize, TContext>) => void;
7072
onUserDisconnected: (event: IOUserDisconnectedEvent<TPlatform, TAuthorize, TContext>) => void;
@@ -81,11 +83,8 @@ export type BroadcastProxy<TIO extends IORoom<any, any, any, any, any>> = (<
8183

8284
export type IORoomConfig<
8385
TPlatform extends AbstractPlatform<any> = AbstractPlatform<any>,
84-
TAuthorize extends PluvIOAuthorize<
85-
TPlatform,
86+
TAuthorize extends PluvIOAuthorize<TPlatform, any, InferInitContextType<TPlatform>> | null =
8687
any,
87-
InferInitContextType<TPlatform>
88-
> | null = any,
8988
TContext extends Record<string, any> = {},
9089
TEvents extends PluvRouterEventConfig<TPlatform, TAuthorize, TContext> = {},
9190
> = Partial<IORoomListeners<TPlatform, TAuthorize, TContext, TEvents>> & {
@@ -118,16 +117,12 @@ export type WebSocketRegisterConfig<
118117

119118
export class IORoom<
120119
TPlatform extends AbstractPlatform<any> = AbstractPlatform<any>,
121-
TAuthorize extends PluvIOAuthorize<
122-
TPlatform,
123-
any,
124-
InferInitContextType<TPlatform>
125-
> | null = null,
120+
TAuthorize extends PluvIOAuthorize<TPlatform, any, InferInitContextType<TPlatform>> | null =
121+
null,
126122
TContext extends Record<string, any> = {},
127123
TCrdt extends CrdtLibraryType<any> = CrdtLibraryType<any>,
128124
TEvents extends PluvRouterEventConfig<TPlatform, TAuthorize, TContext> = {},
129-
> implements IOLike<TAuthorize, TCrdt, TEvents>
130-
{
125+
> implements IOLike<TAuthorize, TCrdt, TEvents> {
131126
public readonly id: string;
132127

133128
private _doc: Promise<CrdtDocLike<any, any>>;
@@ -150,6 +145,9 @@ export class IORoom<
150145
>();
151146
private readonly _userSessionss = new Map<[userId: string][0], Set<[sessionId: string][0]>>();
152147

148+
private _wasDocEmptyOnInit: boolean = true;
149+
private _storageInitializedViaSession: boolean = false;
150+
153151
/**
154152
* @ignore
155153
* @readonly
@@ -200,7 +198,8 @@ export class IORoom<
200198
crdt = noop,
201199
debug,
202200
getInitialStorage,
203-
onDestroy,
201+
onRoomDestroyed,
202+
onStorageDestroyed,
204203
onMessage,
205204
onUserConnected,
206205
onUserDisconnected,
@@ -221,7 +220,8 @@ export class IORoom<
221220
this._platform = platform.initialize({ ...(!!_meta ? { _meta } : {}), roomContext });
222221

223222
this._listeners = {
224-
onDestroy: (event) => onDestroy?.(event),
223+
onRoomDestroyed: (event) => onRoomDestroyed?.(event),
224+
onStorageDestroyed: (event) => onStorageDestroyed?.(event),
225225
onMessage: (event) => onMessage?.(event),
226226
onUserConnected: (event) => onUserConnected?.(event),
227227
onUserDisconnected: (event) => onUserDisconnected?.(event),
@@ -674,7 +674,16 @@ export class IORoom<
674674
}
675675
}
676676

677-
if (typeof encodedState === "string") doc.applyEncodedState({ update: encodedState });
677+
if (typeof encodedState === "string") {
678+
doc.applyEncodedState({ update: encodedState });
679+
680+
// If we loaded storage from persistence and it's non-empty, storage was previously initialized.
681+
// This handles hibernation wake-up: restore the initialization state so that onStorageDestroyed
682+
// will fire correctly when the room is destroyed.
683+
if (!doc.isEmpty()) {
684+
this._storageInitializedViaSession = true;
685+
}
686+
}
678687

679688
return doc;
680689
}
@@ -797,6 +806,10 @@ export class IORoom<
797806
);
798807

799808
const doc = await this._getInitialDoc();
809+
810+
// Track if doc was empty when room was first initialized
811+
this._wasDocEmptyOnInit = doc.isEmpty();
812+
800813
const uninitialize = async () => {
801814
this._platform.pubSub.unsubscribe(pubSubId);
802815

@@ -806,18 +819,35 @@ export class IORoom<
806819
doc.destroy();
807820
this._doc = Promise.resolve(this._docFactory.getEmpty());
808821

822+
// Always emit onRoomDestroyed
809823
await Promise.resolve(
810-
this._listeners.onDestroy({
824+
this._listeners.onRoomDestroyed({
811825
...("_meta" in this._platform && !!this._platform._meta
812826
? { _meta: this._platform._meta }
813827
: {}),
814828
context,
815-
encodedState,
816829
platform: this._platform,
817830
room: this.id,
818831
}),
819832
);
820833

834+
// Only emit onStorageDestroyed if storage was initialized via initializeSession
835+
if (this._storageInitializedViaSession) {
836+
await Promise.resolve(
837+
this._listeners.onStorageDestroyed({
838+
...("_meta" in this._platform && !!this._platform._meta
839+
? { _meta: this._platform._meta }
840+
: {}),
841+
context,
842+
encodedState,
843+
platform: this._platform,
844+
room: this.id,
845+
}),
846+
);
847+
848+
this._storageInitializedViaSession = false;
849+
}
850+
821851
this._uninitialize = null;
822852
};
823853

@@ -991,6 +1021,10 @@ export class IORoom<
9911021

9921022
await Promise.all([handleBroadcast(), handleSelf(), handleSync()]);
9931023
});
1024+
1025+
// After processing messages that might update storage (like $initializeSession),
1026+
// check if storage was initialized via initializeSession
1027+
await this._checkStorageInitializedViaSession();
9941028
};
9951029
}
9961030

@@ -1061,6 +1095,19 @@ export class IORoom<
10611095
return set;
10621096
}
10631097

1098+
private async _checkStorageInitializedViaSession(): Promise<void> {
1099+
if (this._storageInitializedViaSession) return;
1100+
1101+
// Only mark as initialized if:
1102+
// 1. Doc was empty when room was first initialized
1103+
// 2. Doc is now non-empty
1104+
// 3. At least one session has been registered
1105+
const doc = await this._doc;
1106+
if (this._wasDocEmptyOnInit && !doc.isEmpty() && this._sessions.size > 0) {
1107+
this._storageInitializedViaSession = true;
1108+
}
1109+
}
1110+
10641111
private async _sendMessage(
10651112
pluvWs: AbstractWebSocket<any, TAuthorize>,
10661113
message: IOEventMessage<any>,

packages/io/src/PluvServer.ts

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,8 @@ export type InferIORoom<TServer extends PluvServer<any, any, any, any, any>> =
4747

4848
export type PluvServerConfig<
4949
TPlatform extends AbstractPlatform<any, any> = AbstractPlatform<any, any>,
50-
TAuthorize extends PluvIOAuthorize<
51-
TPlatform,
50+
TAuthorize extends PluvIOAuthorize<TPlatform, any, InferInitContextType<TPlatform>> | null =
5251
any,
53-
InferInitContextType<TPlatform>
54-
> | null = any,
5552
TContext extends Record<string, any> = {},
5653
TCrdt extends CrdtLibraryType<any> = CrdtLibraryType<any>,
5754
TEvents extends PluvRouterEventConfig<TPlatform, TAuthorize, TContext> = {},
@@ -93,16 +90,12 @@ export type CreateRoomOptions<
9390

9491
export class PluvServer<
9592
TPlatform extends AbstractPlatform<any, any> = AbstractPlatform<any, any>,
96-
TAuthorize extends PluvIOAuthorize<
97-
TPlatform,
93+
TAuthorize extends PluvIOAuthorize<TPlatform, any, InferInitContextType<TPlatform>> | null =
9894
any,
99-
InferInitContextType<TPlatform>
100-
> | null = any,
10195
TContext extends Record<string, any> = {},
10296
TCrdt extends CrdtLibraryType<any> = CrdtLibraryType<any>,
10397
TEvents extends PluvRouterEventConfig<TPlatform, TAuthorize, TContext> = {},
104-
> implements IOLike<TAuthorize, TCrdt, TEvents>
105-
{
98+
> implements IOLike<TAuthorize, TCrdt, TEvents> {
10699
public readonly version: string = __PLUV_VERSION as any;
107100

108101
private readonly _config: NonNilProps<
@@ -379,6 +372,7 @@ export class PluvServer<
379372
const {
380373
onRoomDeleted,
381374
onRoomMessage,
375+
onStorageDestroyed,
382376
onStorageUpdated,
383377
onUserConnected,
384378
onUserDisconnected,
@@ -388,6 +382,7 @@ export class PluvServer<
388382
(this as any)._listeners = {
389383
onRoomDeleted: (event) => onRoomDeleted?.(event),
390384
onRoomMessage: (event) => onRoomMessage?.(event),
385+
onStorageDestroyed: (event) => onStorageDestroyed?.(event),
391386
onStorageUpdated: (event) => onStorageUpdated?.(event),
392387
onUserConnected: (event) => onUserConnected?.(event),
393388
onUserDisconnected: (event) => onUserDisconnected?.(event),
@@ -398,8 +393,18 @@ export class PluvServer<
398393
room: string,
399394
...options: CreateRoomOptions<TPlatform, TAuthorize, TContext, TEvents>
400395
): IORoom<TPlatform, TAuthorize, TContext, TCrdt, TEvents> {
401-
const { _meta, debug, onDestroy, onMessage, ...platformRoomContext } = (options[0] ??
402-
{}) as CreateRoomOptions<TPlatform, TAuthorize, TContext, TEvents>[0] & { _meta?: any };
396+
const {
397+
_meta,
398+
debug,
399+
onRoomDestroyed,
400+
onStorageDestroyed,
401+
onMessage,
402+
...platformRoomContext
403+
} = (options[0] ?? {}) as CreateRoomOptions<TPlatform, TAuthorize, TContext, TEvents>[0] & {
404+
_meta?: any;
405+
onRoomDestroyed?: (event: any) => void | Promise<void>;
406+
onStorageDestroyed?: (event: any) => void | Promise<void>;
407+
};
403408

404409
const platform = this._config.platform();
405410

@@ -423,15 +428,23 @@ export class PluvServer<
423428
crdt: this._config.crdt,
424429
debug: debug ?? this._config.debug,
425430
getInitialStorage: this._getInitialStorage,
426-
async onDestroy(event) {
431+
async onRoomDestroyed(event) {
427432
logDebug(`${colors.blue("Deleting empty room:")} ${room}`);
428433

429-
await Promise.resolve(onDestroy?.(event));
434+
await Promise.resolve(onRoomDestroyed?.(event));
430435
await Promise.resolve(listeners.onRoomDeleted(event));
431-
await event.platform.persistence.deleteStorageState(room);
432436

433437
logDebug(`${colors.blue("Deleted room:")} ${room}`);
434438
},
439+
async onStorageDestroyed(event) {
440+
logDebug(`${colors.blue("Destroying storage for room:")} ${room}`);
441+
442+
await Promise.resolve(onStorageDestroyed?.(event));
443+
await Promise.resolve(listeners.onStorageDestroyed(event));
444+
await event.platform.persistence.deleteStorageState(room);
445+
446+
logDebug(`${colors.blue("Destroyed storage for room:")} ${room}`);
447+
},
435448
async onMessage(event) {
436449
await Promise.resolve(onMessage?.(event));
437450
await Promise.resolve(listeners.onRoomMessage(event));

packages/io/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export type { CreateRoomOptions, InferIORoom, PluvServerConfig } from "./PluvSer
3838
export type {
3939
GetInitialStorageFn,
4040
HandleMode,
41+
IORoomDestroyedEvent,
4142
IORoomListenerEvent,
4243
IORoomMessageEvent,
4344
IOStorageUpdatedEvent,

packages/io/src/types.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export interface PlatformConfig {
122122
listeners: {
123123
onRoomDeleted?: boolean;
124124
onRoomMessage?: boolean;
125+
onStorageDestroyed?: boolean;
125126
onStorageUpdated?: boolean;
126127
onUserConnected?: boolean;
127128
onUserDisconnected?: boolean;
@@ -172,8 +173,9 @@ export type BasePluvIOListeners<
172173
TContext extends Record<string, any>,
173174
TEvents extends PluvRouterEventConfig<TPlatform, TAuthorize, TContext>,
174175
> = {
175-
onRoomDeleted: (event: IORoomListenerEvent<TPlatform, TContext>) => void;
176+
onRoomDeleted: (event: IORoomDestroyedEvent<TPlatform, TContext>) => void;
176177
onRoomMessage: (event: IORoomMessageEvent<TPlatform, TAuthorize, TContext, TEvents>) => void;
178+
onStorageDestroyed: (event: IORoomListenerEvent<TPlatform, TContext>) => void;
177179
onStorageUpdated: (event: IOStorageUpdatedEvent<TPlatform, TAuthorize, TContext>) => void;
178180
onUserConnected: (event: IOUserConnectedEvent<TPlatform, TAuthorize, TContext>) => void;
179181
onUserDisconnected: (event: IOUserDisconnectedEvent<TPlatform, TAuthorize, TContext>) => void;
@@ -236,6 +238,15 @@ export type IORoomListenerEvent<
236238
room: string;
237239
};
238240

241+
export type IORoomDestroyedEvent<
242+
TPlatform extends AbstractPlatform<any, any, any, any>,
243+
TContext extends Record<string, any>,
244+
> = {
245+
context: TContext;
246+
platform: TPlatform;
247+
room: string;
248+
};
249+
239250
export type IORoomMessageEvent<
240251
TPlatform extends AbstractPlatform<any>,
241252
TAuthorize extends PluvIOAuthorize<TPlatform, any, InferInitContextType<TPlatform>> | null,

packages/platform-cloudflare/src/CloudflarePlatform.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export class CloudflarePlatform<
4343
listeners: {
4444
onRoomDeleted: true;
4545
onRoomMessage: true;
46+
onStorageDestroyed: true;
4647
onStorageUpdated: true;
4748
onUserConnected: true;
4849
onUserDisconnected: true;
@@ -76,6 +77,7 @@ export class CloudflarePlatform<
7677
listeners: {
7778
onRoomDeleted: true as const,
7879
onRoomMessage: true as const,
80+
onStorageDestroyed: true as const,
7981
onStorageUpdated: true as const,
8082
onUserConnected: true as const,
8183
onUserDisconnected: true as const,

packages/platform-node/src/NodePlatform.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export class NodePlatform<
4242
listeners: {
4343
onRoomDeleted: true;
4444
onRoomMessage: true;
45+
onStorageDestroyed: true;
4546
onStorageUpdated: true;
4647
onUserConnected: true;
4748
onUserDisconnected: true;
@@ -71,6 +72,7 @@ export class NodePlatform<
7172
listeners: {
7273
onRoomDeleted: true as const,
7374
onRoomMessage: true as const,
75+
onStorageDestroyed: true as const,
7476
onStorageUpdated: true as const,
7577
onUserConnected: true as const,
7678
onUserDisconnected: true as const,

0 commit comments

Comments
 (0)