@sylphx/lens-server 1.11.3 → 2.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/dist/index.d.ts +1262 -262
- package/dist/index.js +1714 -1154
- package/package.json +2 -2
- package/src/context/index.test.ts +425 -0
- package/src/context/index.ts +90 -0
- package/src/e2e/server.test.ts +215 -433
- package/src/handlers/framework.ts +294 -0
- package/src/handlers/http.test.ts +215 -0
- package/src/handlers/http.ts +189 -0
- package/src/handlers/index.ts +55 -0
- package/src/handlers/unified.ts +114 -0
- package/src/handlers/ws-types.ts +126 -0
- package/src/handlers/ws.ts +669 -0
- package/src/index.ts +127 -24
- package/src/plugin/index.ts +41 -0
- package/src/plugin/op-log.ts +286 -0
- package/src/plugin/optimistic.ts +375 -0
- package/src/plugin/types.ts +551 -0
- package/src/reconnect/index.ts +9 -0
- package/src/reconnect/operation-log.test.ts +480 -0
- package/src/reconnect/operation-log.ts +450 -0
- package/src/server/create.test.ts +256 -2193
- package/src/server/create.ts +285 -1481
- package/src/server/dataloader.ts +60 -0
- package/src/server/selection.ts +123 -0
- package/src/server/types.ts +306 -0
- package/src/sse/handler.ts +123 -56
- package/src/state/index.ts +9 -11
- package/src/storage/index.ts +26 -0
- package/src/storage/memory.ts +279 -0
- package/src/storage/types.ts +205 -0
- package/src/state/graph-state-manager.test.ts +0 -1105
- package/src/state/graph-state-manager.ts +0 -890
package/src/sse/handler.ts
CHANGED
|
@@ -1,73 +1,96 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @sylphx/lens-server - SSE
|
|
2
|
+
* @sylphx/lens-server - SSE Handler
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Pure transport handler for Server-Sent Events.
|
|
5
|
+
* No state management - just handles SSE connection lifecycle and message sending.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type { GraphStateManager, StateClient } from "../state/graph-state-manager.js";
|
|
9
|
-
|
|
10
8
|
// =============================================================================
|
|
11
9
|
// Types
|
|
12
10
|
// =============================================================================
|
|
13
11
|
|
|
14
12
|
/** SSE handler configuration */
|
|
15
13
|
export interface SSEHandlerConfig {
|
|
16
|
-
/** GraphStateManager instance (required) */
|
|
17
|
-
stateManager: GraphStateManager;
|
|
18
14
|
/** Heartbeat interval in ms (default: 30000) */
|
|
19
15
|
heartbeatInterval?: number;
|
|
16
|
+
/** Called when a client connects */
|
|
17
|
+
onConnect?: (client: SSEClient) => void;
|
|
18
|
+
/** Called when a client disconnects */
|
|
19
|
+
onDisconnect?: (clientId: string) => void;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
/** SSE client
|
|
23
|
-
export interface
|
|
22
|
+
/** SSE client handle for sending messages */
|
|
23
|
+
export interface SSEClient {
|
|
24
|
+
/** Unique client ID */
|
|
24
25
|
id: string;
|
|
25
|
-
|
|
26
|
+
/** Send a message to this client */
|
|
27
|
+
send: (message: unknown) => void;
|
|
28
|
+
/** Send a named event to this client */
|
|
29
|
+
sendEvent: (event: string, data: unknown) => void;
|
|
30
|
+
/** Close this client's connection */
|
|
31
|
+
close: () => void;
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
// =============================================================================
|
|
29
|
-
// SSE Handler
|
|
35
|
+
// SSE Handler
|
|
30
36
|
// =============================================================================
|
|
31
37
|
|
|
32
38
|
/**
|
|
33
|
-
* SSE transport
|
|
39
|
+
* Pure SSE transport handler.
|
|
34
40
|
*
|
|
35
|
-
* This
|
|
36
|
-
* -
|
|
37
|
-
* -
|
|
38
|
-
* -
|
|
41
|
+
* This handler ONLY manages:
|
|
42
|
+
* - SSE connection lifecycle
|
|
43
|
+
* - Message sending to clients
|
|
44
|
+
* - Heartbeat keepalive
|
|
39
45
|
*
|
|
40
|
-
*
|
|
46
|
+
* It does NOT know about:
|
|
47
|
+
* - State management
|
|
48
|
+
* - Subscriptions
|
|
49
|
+
* - Plugins
|
|
41
50
|
*
|
|
42
51
|
* @example
|
|
43
52
|
* ```typescript
|
|
44
|
-
* const
|
|
45
|
-
*
|
|
53
|
+
* const sse = new SSEHandler({
|
|
54
|
+
* onConnect: (client) => {
|
|
55
|
+
* console.log('Client connected:', client.id);
|
|
56
|
+
* // Register with your state management here
|
|
57
|
+
* },
|
|
58
|
+
* onDisconnect: (clientId) => {
|
|
59
|
+
* console.log('Client disconnected:', clientId);
|
|
60
|
+
* // Cleanup your state management here
|
|
61
|
+
* },
|
|
62
|
+
* });
|
|
46
63
|
*
|
|
47
64
|
* // Handle SSE connection
|
|
48
65
|
* app.get('/events', (req) => sse.handleConnection(req));
|
|
49
66
|
*
|
|
50
|
-
* //
|
|
51
|
-
*
|
|
67
|
+
* // Send message to specific client
|
|
68
|
+
* sse.send(clientId, { type: 'update', data: {...} });
|
|
52
69
|
* ```
|
|
53
70
|
*/
|
|
54
71
|
export class SSEHandler {
|
|
55
|
-
private stateManager: GraphStateManager;
|
|
56
72
|
private heartbeatInterval: number;
|
|
73
|
+
private onConnectCallback: ((client: SSEClient) => void) | undefined;
|
|
74
|
+
private onDisconnectCallback: ((clientId: string) => void) | undefined;
|
|
57
75
|
private clients = new Map<
|
|
58
76
|
string,
|
|
59
|
-
{
|
|
77
|
+
{
|
|
78
|
+
controller: ReadableStreamDefaultController;
|
|
79
|
+
heartbeat: ReturnType<typeof setInterval>;
|
|
80
|
+
encoder: TextEncoder;
|
|
81
|
+
}
|
|
60
82
|
>();
|
|
61
83
|
private clientCounter = 0;
|
|
62
84
|
|
|
63
|
-
constructor(config: SSEHandlerConfig) {
|
|
64
|
-
this.stateManager = config.stateManager;
|
|
85
|
+
constructor(config: SSEHandlerConfig = {}) {
|
|
65
86
|
this.heartbeatInterval = config.heartbeatInterval ?? 30000;
|
|
87
|
+
this.onConnectCallback = config.onConnect;
|
|
88
|
+
this.onDisconnectCallback = config.onDisconnect;
|
|
66
89
|
}
|
|
67
90
|
|
|
68
91
|
/**
|
|
69
|
-
* Handle new SSE connection
|
|
70
|
-
* Returns a Response with SSE stream
|
|
92
|
+
* Handle new SSE connection.
|
|
93
|
+
* Returns a Response with SSE stream.
|
|
71
94
|
*/
|
|
72
95
|
handleConnection(_req?: Request): Response {
|
|
73
96
|
const clientId = `sse_${++this.clientCounter}_${Date.now()}`;
|
|
@@ -75,26 +98,6 @@ export class SSEHandler {
|
|
|
75
98
|
|
|
76
99
|
const stream = new ReadableStream({
|
|
77
100
|
start: (controller) => {
|
|
78
|
-
// Register with GraphStateManager
|
|
79
|
-
const stateClient: StateClient = {
|
|
80
|
-
id: clientId,
|
|
81
|
-
send: (msg) => {
|
|
82
|
-
try {
|
|
83
|
-
const data = `data: ${JSON.stringify(msg)}\n\n`;
|
|
84
|
-
controller.enqueue(encoder.encode(data));
|
|
85
|
-
} catch {
|
|
86
|
-
// Connection closed
|
|
87
|
-
this.removeClient(clientId);
|
|
88
|
-
}
|
|
89
|
-
},
|
|
90
|
-
};
|
|
91
|
-
this.stateManager.addClient(stateClient);
|
|
92
|
-
|
|
93
|
-
// Send connected event
|
|
94
|
-
controller.enqueue(
|
|
95
|
-
encoder.encode(`event: connected\ndata: ${JSON.stringify({ clientId })}\n\n`),
|
|
96
|
-
);
|
|
97
|
-
|
|
98
101
|
// Setup heartbeat
|
|
99
102
|
const heartbeat = setInterval(() => {
|
|
100
103
|
try {
|
|
@@ -105,7 +108,21 @@ export class SSEHandler {
|
|
|
105
108
|
}, this.heartbeatInterval);
|
|
106
109
|
|
|
107
110
|
// Track client
|
|
108
|
-
this.clients.set(clientId, { controller, heartbeat });
|
|
111
|
+
this.clients.set(clientId, { controller, heartbeat, encoder });
|
|
112
|
+
|
|
113
|
+
// Send connected event
|
|
114
|
+
controller.enqueue(
|
|
115
|
+
encoder.encode(`event: connected\ndata: ${JSON.stringify({ clientId })}\n\n`),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// Create client handle and notify
|
|
119
|
+
const client: SSEClient = {
|
|
120
|
+
id: clientId,
|
|
121
|
+
send: (message: unknown) => this.send(clientId, message),
|
|
122
|
+
sendEvent: (event: string, data: unknown) => this.sendEvent(clientId, event, data),
|
|
123
|
+
close: () => this.closeClient(clientId),
|
|
124
|
+
};
|
|
125
|
+
this.onConnectCallback?.(client);
|
|
109
126
|
},
|
|
110
127
|
cancel: () => {
|
|
111
128
|
this.removeClient(clientId);
|
|
@@ -123,19 +140,62 @@ export class SSEHandler {
|
|
|
123
140
|
}
|
|
124
141
|
|
|
125
142
|
/**
|
|
126
|
-
*
|
|
143
|
+
* Send a message to a specific client.
|
|
144
|
+
*/
|
|
145
|
+
send(clientId: string, message: unknown): boolean {
|
|
146
|
+
const client = this.clients.get(clientId);
|
|
147
|
+
if (!client) return false;
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const data = `data: ${JSON.stringify(message)}\n\n`;
|
|
151
|
+
client.controller.enqueue(client.encoder.encode(data));
|
|
152
|
+
return true;
|
|
153
|
+
} catch {
|
|
154
|
+
this.removeClient(clientId);
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Send a named event to a specific client.
|
|
161
|
+
*/
|
|
162
|
+
sendEvent(clientId: string, event: string, data: unknown): boolean {
|
|
163
|
+
const client = this.clients.get(clientId);
|
|
164
|
+
if (!client) return false;
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
168
|
+
client.controller.enqueue(client.encoder.encode(message));
|
|
169
|
+
return true;
|
|
170
|
+
} catch {
|
|
171
|
+
this.removeClient(clientId);
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Broadcast a message to all connected clients.
|
|
178
|
+
*/
|
|
179
|
+
broadcast(message: unknown): void {
|
|
180
|
+
for (const clientId of this.clients.keys()) {
|
|
181
|
+
this.send(clientId, message);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Remove client and cleanup.
|
|
127
187
|
*/
|
|
128
188
|
private removeClient(clientId: string): void {
|
|
129
189
|
const client = this.clients.get(clientId);
|
|
130
190
|
if (client) {
|
|
131
191
|
clearInterval(client.heartbeat);
|
|
132
192
|
this.clients.delete(clientId);
|
|
193
|
+
this.onDisconnectCallback?.(clientId);
|
|
133
194
|
}
|
|
134
|
-
this.stateManager.removeClient(clientId);
|
|
135
195
|
}
|
|
136
196
|
|
|
137
197
|
/**
|
|
138
|
-
* Close specific client connection
|
|
198
|
+
* Close specific client connection.
|
|
139
199
|
*/
|
|
140
200
|
closeClient(clientId: string): void {
|
|
141
201
|
const client = this.clients.get(clientId);
|
|
@@ -150,21 +210,28 @@ export class SSEHandler {
|
|
|
150
210
|
}
|
|
151
211
|
|
|
152
212
|
/**
|
|
153
|
-
* Get connected client count
|
|
213
|
+
* Get connected client count.
|
|
154
214
|
*/
|
|
155
215
|
getClientCount(): number {
|
|
156
216
|
return this.clients.size;
|
|
157
217
|
}
|
|
158
218
|
|
|
159
219
|
/**
|
|
160
|
-
* Get connected client IDs
|
|
220
|
+
* Get connected client IDs.
|
|
161
221
|
*/
|
|
162
222
|
getClientIds(): string[] {
|
|
163
223
|
return Array.from(this.clients.keys());
|
|
164
224
|
}
|
|
165
225
|
|
|
166
226
|
/**
|
|
167
|
-
*
|
|
227
|
+
* Check if a client is connected.
|
|
228
|
+
*/
|
|
229
|
+
hasClient(clientId: string): boolean {
|
|
230
|
+
return this.clients.has(clientId);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Close all connections.
|
|
168
235
|
*/
|
|
169
236
|
closeAll(): void {
|
|
170
237
|
for (const clientId of this.clients.keys()) {
|
|
@@ -178,8 +245,8 @@ export class SSEHandler {
|
|
|
178
245
|
// =============================================================================
|
|
179
246
|
|
|
180
247
|
/**
|
|
181
|
-
* Create SSE handler (transport
|
|
248
|
+
* Create SSE handler (pure transport).
|
|
182
249
|
*/
|
|
183
|
-
export function createSSEHandler(config: SSEHandlerConfig): SSEHandler {
|
|
250
|
+
export function createSSEHandler(config: SSEHandlerConfig = {}): SSEHandler {
|
|
184
251
|
return new SSEHandler(config);
|
|
185
252
|
}
|
package/src/state/index.ts
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @sylphx/lens-server - State Management
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* State management is handled by the storage layer.
|
|
5
|
+
* See: packages/server/src/storage/
|
|
6
|
+
*
|
|
7
|
+
* Available storage adapters:
|
|
8
|
+
* - memoryStorage() - In-memory (default)
|
|
9
|
+
* - redisStorage() - Redis via ioredis
|
|
10
|
+
* - upstashStorage() - Upstash Redis HTTP
|
|
11
|
+
* - vercelKVStorage() - Vercel KV
|
|
5
12
|
*/
|
|
6
13
|
|
|
7
|
-
|
|
8
|
-
createGraphStateManager,
|
|
9
|
-
type EntityKey,
|
|
10
|
-
GraphStateManager,
|
|
11
|
-
type GraphStateManagerConfig,
|
|
12
|
-
type StateClient,
|
|
13
|
-
type StateFullMessage,
|
|
14
|
-
type StateUpdateMessage,
|
|
15
|
-
type Subscription,
|
|
16
|
-
} from "./graph-state-manager.js";
|
|
14
|
+
// No exports - state management moved to storage layer
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sylphx/lens-server - Storage
|
|
3
|
+
*
|
|
4
|
+
* Storage adapters for opLog plugin.
|
|
5
|
+
*
|
|
6
|
+
* Built-in:
|
|
7
|
+
* - `memoryStorage()` - In-memory (default, for long-running servers)
|
|
8
|
+
*
|
|
9
|
+
* External packages (install separately):
|
|
10
|
+
* - `@sylphx/lens-storage-redis` - Redis via ioredis
|
|
11
|
+
* - `@sylphx/lens-storage-upstash` - Upstash Redis HTTP (serverless/edge)
|
|
12
|
+
* - `@sylphx/lens-storage-vercel-kv` - Vercel KV (Next.js/Vercel)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// In-memory (default)
|
|
16
|
+
export { memoryStorage } from "./memory.js";
|
|
17
|
+
|
|
18
|
+
// Types (for implementing custom storage adapters)
|
|
19
|
+
export {
|
|
20
|
+
DEFAULT_STORAGE_CONFIG,
|
|
21
|
+
type EmitResult,
|
|
22
|
+
type OpLogStorage,
|
|
23
|
+
type OpLogStorageConfig,
|
|
24
|
+
type StoredEntityState,
|
|
25
|
+
type StoredPatchEntry,
|
|
26
|
+
} from "./types.js";
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sylphx/lens-server - Memory Storage
|
|
3
|
+
*
|
|
4
|
+
* In-memory storage adapter for opLog plugin.
|
|
5
|
+
* Default storage for long-running servers.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - O(1) state and version lookups
|
|
9
|
+
* - Bounded patch history per entity
|
|
10
|
+
* - Automatic cleanup of old patches
|
|
11
|
+
*
|
|
12
|
+
* Memory: O(entities × maxPatchesPerEntity)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { PatchOperation } from "@sylphx/lens-core";
|
|
16
|
+
import {
|
|
17
|
+
DEFAULT_STORAGE_CONFIG,
|
|
18
|
+
type EmitResult,
|
|
19
|
+
type OpLogStorage,
|
|
20
|
+
type OpLogStorageConfig,
|
|
21
|
+
type StoredPatchEntry,
|
|
22
|
+
} from "./types.js";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Entity key for internal storage.
|
|
26
|
+
*/
|
|
27
|
+
type EntityKey = string;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Internal entity state.
|
|
31
|
+
*/
|
|
32
|
+
interface EntityState {
|
|
33
|
+
data: Record<string, unknown>;
|
|
34
|
+
version: number;
|
|
35
|
+
patches: StoredPatchEntry[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Create entity key from entity type and ID.
|
|
40
|
+
*/
|
|
41
|
+
function makeKey(entity: string, entityId: string): EntityKey {
|
|
42
|
+
return `${entity}:${entityId}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Compute JSON Patch operations between two states.
|
|
47
|
+
*/
|
|
48
|
+
function computePatch(
|
|
49
|
+
oldState: Record<string, unknown>,
|
|
50
|
+
newState: Record<string, unknown>,
|
|
51
|
+
): PatchOperation[] {
|
|
52
|
+
const patch: PatchOperation[] = [];
|
|
53
|
+
const oldKeys = new Set(Object.keys(oldState));
|
|
54
|
+
const newKeys = new Set(Object.keys(newState));
|
|
55
|
+
|
|
56
|
+
// Additions and replacements
|
|
57
|
+
for (const key of newKeys) {
|
|
58
|
+
const oldValue = oldState[key];
|
|
59
|
+
const newValue = newState[key];
|
|
60
|
+
|
|
61
|
+
if (!oldKeys.has(key)) {
|
|
62
|
+
// New field
|
|
63
|
+
patch.push({ op: "add", path: `/${key}`, value: newValue });
|
|
64
|
+
} else if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
|
|
65
|
+
// Changed field
|
|
66
|
+
patch.push({ op: "replace", path: `/${key}`, value: newValue });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Deletions
|
|
71
|
+
for (const key of oldKeys) {
|
|
72
|
+
if (!newKeys.has(key)) {
|
|
73
|
+
patch.push({ op: "remove", path: `/${key}` });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return patch;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Hash entity state for change detection.
|
|
82
|
+
*/
|
|
83
|
+
function hashState(state: Record<string, unknown>): string {
|
|
84
|
+
return JSON.stringify(state);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Create an in-memory storage adapter.
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* ```typescript
|
|
92
|
+
* const storage = memoryStorage();
|
|
93
|
+
*
|
|
94
|
+
* // Or with custom config
|
|
95
|
+
* const storage = memoryStorage({
|
|
96
|
+
* maxPatchesPerEntity: 500,
|
|
97
|
+
* maxPatchAge: 60000,
|
|
98
|
+
* });
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
export function memoryStorage(config: OpLogStorageConfig = {}): OpLogStorage {
|
|
102
|
+
const cfg = { ...DEFAULT_STORAGE_CONFIG, ...config };
|
|
103
|
+
const entities = new Map<EntityKey, EntityState>();
|
|
104
|
+
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
105
|
+
|
|
106
|
+
// Start cleanup timer
|
|
107
|
+
if (cfg.cleanupInterval > 0) {
|
|
108
|
+
cleanupTimer = setInterval(() => cleanup(), cfg.cleanupInterval);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Cleanup old patches based on age.
|
|
113
|
+
*/
|
|
114
|
+
function cleanup(): void {
|
|
115
|
+
const now = Date.now();
|
|
116
|
+
const minTimestamp = now - cfg.maxPatchAge;
|
|
117
|
+
|
|
118
|
+
for (const state of entities.values()) {
|
|
119
|
+
// Remove patches older than maxPatchAge
|
|
120
|
+
state.patches = state.patches.filter((p) => p.timestamp >= minTimestamp);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Trim patches to maxPatchesPerEntity.
|
|
126
|
+
*/
|
|
127
|
+
function trimPatches(state: EntityState): void {
|
|
128
|
+
if (state.patches.length > cfg.maxPatchesPerEntity) {
|
|
129
|
+
// Remove oldest patches
|
|
130
|
+
state.patches = state.patches.slice(-cfg.maxPatchesPerEntity);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
async emit(entity, entityId, data): Promise<EmitResult> {
|
|
136
|
+
const key = makeKey(entity, entityId);
|
|
137
|
+
const existing = entities.get(key);
|
|
138
|
+
const now = Date.now();
|
|
139
|
+
|
|
140
|
+
if (!existing) {
|
|
141
|
+
// First emit - no previous state
|
|
142
|
+
const newState: EntityState = {
|
|
143
|
+
data: { ...data },
|
|
144
|
+
version: 1,
|
|
145
|
+
patches: [],
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// No patch for first emit (full state is sent instead)
|
|
149
|
+
entities.set(key, newState);
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
version: 1,
|
|
153
|
+
patch: null,
|
|
154
|
+
changed: true,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Check if state actually changed
|
|
159
|
+
const oldHash = hashState(existing.data);
|
|
160
|
+
const newHash = hashState(data);
|
|
161
|
+
|
|
162
|
+
if (oldHash === newHash) {
|
|
163
|
+
return {
|
|
164
|
+
version: existing.version,
|
|
165
|
+
patch: null,
|
|
166
|
+
changed: false,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Compute patch
|
|
171
|
+
const patch = computePatch(existing.data, data);
|
|
172
|
+
|
|
173
|
+
// Update state
|
|
174
|
+
const newVersion = existing.version + 1;
|
|
175
|
+
existing.data = { ...data };
|
|
176
|
+
existing.version = newVersion;
|
|
177
|
+
|
|
178
|
+
// Append patch to log
|
|
179
|
+
if (patch.length > 0) {
|
|
180
|
+
existing.patches.push({
|
|
181
|
+
version: newVersion,
|
|
182
|
+
patch,
|
|
183
|
+
timestamp: now,
|
|
184
|
+
});
|
|
185
|
+
trimPatches(existing);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
version: newVersion,
|
|
190
|
+
patch: patch.length > 0 ? patch : null,
|
|
191
|
+
changed: true,
|
|
192
|
+
};
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
async getState(entity, entityId): Promise<Record<string, unknown> | null> {
|
|
196
|
+
const key = makeKey(entity, entityId);
|
|
197
|
+
const state = entities.get(key);
|
|
198
|
+
return state ? { ...state.data } : null;
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
async getVersion(entity, entityId): Promise<number> {
|
|
202
|
+
const key = makeKey(entity, entityId);
|
|
203
|
+
const state = entities.get(key);
|
|
204
|
+
return state?.version ?? 0;
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
async getLatestPatch(entity, entityId): Promise<PatchOperation[] | null> {
|
|
208
|
+
const key = makeKey(entity, entityId);
|
|
209
|
+
const state = entities.get(key);
|
|
210
|
+
|
|
211
|
+
if (!state || state.patches.length === 0) {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return state.patches[state.patches.length - 1].patch;
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
async getPatchesSince(entity, entityId, sinceVersion): Promise<PatchOperation[][] | null> {
|
|
219
|
+
const key = makeKey(entity, entityId);
|
|
220
|
+
const state = entities.get(key);
|
|
221
|
+
|
|
222
|
+
if (!state) {
|
|
223
|
+
return sinceVersion === 0 ? [] : null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Already up to date
|
|
227
|
+
if (sinceVersion >= state.version) {
|
|
228
|
+
return [];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Find patches since the given version
|
|
232
|
+
const relevantPatches = state.patches.filter((p) => p.version > sinceVersion);
|
|
233
|
+
|
|
234
|
+
if (relevantPatches.length === 0) {
|
|
235
|
+
// No patches in log - version too old
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Verify continuity
|
|
240
|
+
relevantPatches.sort((a, b) => a.version - b.version);
|
|
241
|
+
|
|
242
|
+
// First patch must be sinceVersion + 1
|
|
243
|
+
if (relevantPatches[0].version !== sinceVersion + 1) {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Check for gaps
|
|
248
|
+
for (let i = 1; i < relevantPatches.length; i++) {
|
|
249
|
+
if (relevantPatches[i].version !== relevantPatches[i - 1].version + 1) {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return relevantPatches.map((p) => p.patch);
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
async has(entity, entityId): Promise<boolean> {
|
|
258
|
+
const key = makeKey(entity, entityId);
|
|
259
|
+
return entities.has(key);
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
async delete(entity, entityId): Promise<void> {
|
|
263
|
+
const key = makeKey(entity, entityId);
|
|
264
|
+
entities.delete(key);
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
async clear(): Promise<void> {
|
|
268
|
+
entities.clear();
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
async dispose(): Promise<void> {
|
|
272
|
+
if (cleanupTimer) {
|
|
273
|
+
clearInterval(cleanupTimer);
|
|
274
|
+
cleanupTimer = null;
|
|
275
|
+
}
|
|
276
|
+
entities.clear();
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|