@sylphx/lens-server 1.0.2
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 +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1437 -0
- package/dist/server/create.d.ts +226 -0
- package/dist/server/create.d.ts.map +1 -0
- package/dist/sse/handler.d.ts +78 -0
- package/dist/sse/handler.d.ts.map +1 -0
- package/dist/state/graph-state-manager.d.ts +146 -0
- package/dist/state/graph-state-manager.d.ts.map +1 -0
- package/dist/state/index.d.ts +7 -0
- package/dist/state/index.d.ts.map +1 -0
- package/package.json +39 -0
- package/src/e2e/server.test.ts +666 -0
- package/src/index.ts +67 -0
- package/src/server/create.test.ts +807 -0
- package/src/server/create.ts +1536 -0
- package/src/sse/handler.ts +185 -0
- package/src/state/graph-state-manager.test.ts +334 -0
- package/src/state/graph-state-manager.ts +443 -0
- package/src/state/index.ts +16 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sylphx/lens-server - Graph State Manager
|
|
3
|
+
*
|
|
4
|
+
* Core orchestration layer that:
|
|
5
|
+
* - Maintains canonical state per entity (server truth)
|
|
6
|
+
* - Tracks per-client last known state
|
|
7
|
+
* - Computes minimal diffs when state changes
|
|
8
|
+
* - Auto-selects transfer strategy (value/delta/patch)
|
|
9
|
+
* - Pushes updates to subscribed clients
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { type EntityKey, type Update, createUpdate, makeEntityKey } from "@sylphx/lens-core";
|
|
13
|
+
|
|
14
|
+
// Re-export for convenience
|
|
15
|
+
export type { EntityKey };
|
|
16
|
+
|
|
17
|
+
/** Client connection interface */
|
|
18
|
+
export interface StateClient {
|
|
19
|
+
id: string;
|
|
20
|
+
send: (message: StateUpdateMessage) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Update message sent to clients */
|
|
24
|
+
export interface StateUpdateMessage {
|
|
25
|
+
type: "update";
|
|
26
|
+
entity: string;
|
|
27
|
+
id: string;
|
|
28
|
+
/** Field-level updates with strategy */
|
|
29
|
+
updates: Record<string, Update>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Full entity update message */
|
|
33
|
+
export interface StateFullMessage {
|
|
34
|
+
type: "data";
|
|
35
|
+
entity: string;
|
|
36
|
+
id: string;
|
|
37
|
+
data: Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Subscription info */
|
|
41
|
+
export interface Subscription {
|
|
42
|
+
clientId: string;
|
|
43
|
+
fields: Set<string> | "*";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Per-client state for an entity */
|
|
47
|
+
interface ClientEntityState {
|
|
48
|
+
/** Last state sent to this client */
|
|
49
|
+
lastState: Record<string, unknown>;
|
|
50
|
+
/** Fields this client is subscribed to */
|
|
51
|
+
fields: Set<string> | "*";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Configuration */
|
|
55
|
+
export interface GraphStateManagerConfig {
|
|
56
|
+
/** Called when an entity has no more subscribers */
|
|
57
|
+
onEntityUnsubscribed?: (entity: string, id: string) => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// =============================================================================
|
|
61
|
+
// GraphStateManager
|
|
62
|
+
// =============================================================================
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Manages server-side canonical state and syncs to clients.
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```typescript
|
|
69
|
+
* const manager = new GraphStateManager();
|
|
70
|
+
*
|
|
71
|
+
* // Add client
|
|
72
|
+
* manager.addClient({
|
|
73
|
+
* id: "client-1",
|
|
74
|
+
* send: (msg) => ws.send(JSON.stringify(msg)),
|
|
75
|
+
* });
|
|
76
|
+
*
|
|
77
|
+
* // Subscribe client to entity
|
|
78
|
+
* manager.subscribe("client-1", "Post", "123", ["title", "content"]);
|
|
79
|
+
*
|
|
80
|
+
* // Emit updates (from resolvers)
|
|
81
|
+
* manager.emit("Post", "123", { content: "Updated content" });
|
|
82
|
+
* // → Automatically computes diff and sends to subscribed clients
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
export class GraphStateManager {
|
|
86
|
+
/** Connected clients */
|
|
87
|
+
private clients = new Map<string, StateClient>();
|
|
88
|
+
|
|
89
|
+
/** Canonical state per entity (server truth) */
|
|
90
|
+
private canonical = new Map<EntityKey, Record<string, unknown>>();
|
|
91
|
+
|
|
92
|
+
/** Per-client state tracking */
|
|
93
|
+
private clientStates = new Map<string, Map<EntityKey, ClientEntityState>>();
|
|
94
|
+
|
|
95
|
+
/** Entity → subscribed client IDs */
|
|
96
|
+
private entitySubscribers = new Map<EntityKey, Set<string>>();
|
|
97
|
+
|
|
98
|
+
/** Configuration */
|
|
99
|
+
private config: GraphStateManagerConfig;
|
|
100
|
+
|
|
101
|
+
constructor(config: GraphStateManagerConfig = {}) {
|
|
102
|
+
this.config = config;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ===========================================================================
|
|
106
|
+
// Client Management
|
|
107
|
+
// ===========================================================================
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Add a client connection
|
|
111
|
+
*/
|
|
112
|
+
addClient(client: StateClient): void {
|
|
113
|
+
this.clients.set(client.id, client);
|
|
114
|
+
this.clientStates.set(client.id, new Map());
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Remove a client and cleanup all subscriptions
|
|
119
|
+
*/
|
|
120
|
+
removeClient(clientId: string): void {
|
|
121
|
+
// Remove from all entity subscribers
|
|
122
|
+
for (const [key, subscribers] of this.entitySubscribers) {
|
|
123
|
+
subscribers.delete(clientId);
|
|
124
|
+
if (subscribers.size === 0) {
|
|
125
|
+
this.cleanupEntity(key);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
this.clients.delete(clientId);
|
|
130
|
+
this.clientStates.delete(clientId);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ===========================================================================
|
|
134
|
+
// Subscription Management
|
|
135
|
+
// ===========================================================================
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Subscribe a client to an entity
|
|
139
|
+
*/
|
|
140
|
+
subscribe(clientId: string, entity: string, id: string, fields: string[] | "*" = "*"): void {
|
|
141
|
+
const key = this.makeKey(entity, id);
|
|
142
|
+
|
|
143
|
+
// Add to entity subscribers
|
|
144
|
+
let subscribers = this.entitySubscribers.get(key);
|
|
145
|
+
if (!subscribers) {
|
|
146
|
+
subscribers = new Set();
|
|
147
|
+
this.entitySubscribers.set(key, subscribers);
|
|
148
|
+
}
|
|
149
|
+
subscribers.add(clientId);
|
|
150
|
+
|
|
151
|
+
// Initialize client state for this entity
|
|
152
|
+
const clientStateMap = this.clientStates.get(clientId);
|
|
153
|
+
if (clientStateMap) {
|
|
154
|
+
const fieldSet = fields === "*" ? "*" : new Set(fields);
|
|
155
|
+
clientStateMap.set(key, {
|
|
156
|
+
lastState: {},
|
|
157
|
+
fields: fieldSet,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// If we have canonical state, send initial data
|
|
162
|
+
const canonicalState = this.canonical.get(key);
|
|
163
|
+
if (canonicalState) {
|
|
164
|
+
this.sendInitialData(clientId, entity, id, canonicalState, fields);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Unsubscribe a client from an entity
|
|
170
|
+
*/
|
|
171
|
+
unsubscribe(clientId: string, entity: string, id: string): void {
|
|
172
|
+
const key = this.makeKey(entity, id);
|
|
173
|
+
|
|
174
|
+
// Remove from entity subscribers
|
|
175
|
+
const subscribers = this.entitySubscribers.get(key);
|
|
176
|
+
if (subscribers) {
|
|
177
|
+
subscribers.delete(clientId);
|
|
178
|
+
if (subscribers.size === 0) {
|
|
179
|
+
this.cleanupEntity(key);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Remove client state
|
|
184
|
+
const clientStateMap = this.clientStates.get(clientId);
|
|
185
|
+
if (clientStateMap) {
|
|
186
|
+
clientStateMap.delete(key);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Update subscription fields for a client
|
|
192
|
+
*/
|
|
193
|
+
updateSubscription(clientId: string, entity: string, id: string, fields: string[] | "*"): void {
|
|
194
|
+
const key = this.makeKey(entity, id);
|
|
195
|
+
const clientStateMap = this.clientStates.get(clientId);
|
|
196
|
+
|
|
197
|
+
if (clientStateMap) {
|
|
198
|
+
const state = clientStateMap.get(key);
|
|
199
|
+
if (state) {
|
|
200
|
+
state.fields = fields === "*" ? "*" : new Set(fields);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ===========================================================================
|
|
206
|
+
// State Emission (Core)
|
|
207
|
+
// ===========================================================================
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Emit data for an entity.
|
|
211
|
+
* This is the core method called by resolvers.
|
|
212
|
+
*
|
|
213
|
+
* @param entity - Entity name
|
|
214
|
+
* @param id - Entity ID
|
|
215
|
+
* @param data - Full or partial entity data
|
|
216
|
+
* @param options - Emit options
|
|
217
|
+
*/
|
|
218
|
+
emit(
|
|
219
|
+
entity: string,
|
|
220
|
+
id: string,
|
|
221
|
+
data: Record<string, unknown>,
|
|
222
|
+
options: { replace?: boolean } = {},
|
|
223
|
+
): void {
|
|
224
|
+
const key = this.makeKey(entity, id);
|
|
225
|
+
|
|
226
|
+
// Get or create canonical state
|
|
227
|
+
let currentCanonical = this.canonical.get(key);
|
|
228
|
+
|
|
229
|
+
if (options.replace || !currentCanonical) {
|
|
230
|
+
// Replace mode or first emit
|
|
231
|
+
currentCanonical = { ...data };
|
|
232
|
+
} else {
|
|
233
|
+
// Merge mode (default)
|
|
234
|
+
currentCanonical = { ...currentCanonical, ...data };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
this.canonical.set(key, currentCanonical);
|
|
238
|
+
|
|
239
|
+
// Push updates to all subscribed clients
|
|
240
|
+
const subscribers = this.entitySubscribers.get(key);
|
|
241
|
+
if (!subscribers) return;
|
|
242
|
+
|
|
243
|
+
for (const clientId of subscribers) {
|
|
244
|
+
this.pushToClient(clientId, entity, id, key, currentCanonical);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Get current canonical state for an entity
|
|
250
|
+
*/
|
|
251
|
+
getState(entity: string, id: string): Record<string, unknown> | undefined {
|
|
252
|
+
return this.canonical.get(this.makeKey(entity, id));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Check if entity has any subscribers
|
|
257
|
+
*/
|
|
258
|
+
hasSubscribers(entity: string, id: string): boolean {
|
|
259
|
+
const subscribers = this.entitySubscribers.get(this.makeKey(entity, id));
|
|
260
|
+
return subscribers !== undefined && subscribers.size > 0;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ===========================================================================
|
|
264
|
+
// Internal Methods
|
|
265
|
+
// ===========================================================================
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Push update to a specific client
|
|
269
|
+
*/
|
|
270
|
+
private pushToClient(
|
|
271
|
+
clientId: string,
|
|
272
|
+
entity: string,
|
|
273
|
+
id: string,
|
|
274
|
+
key: EntityKey,
|
|
275
|
+
newState: Record<string, unknown>,
|
|
276
|
+
): void {
|
|
277
|
+
const client = this.clients.get(clientId);
|
|
278
|
+
if (!client) return;
|
|
279
|
+
|
|
280
|
+
const clientStateMap = this.clientStates.get(clientId);
|
|
281
|
+
if (!clientStateMap) return;
|
|
282
|
+
|
|
283
|
+
const clientEntityState = clientStateMap.get(key);
|
|
284
|
+
if (!clientEntityState) return;
|
|
285
|
+
|
|
286
|
+
const { lastState, fields } = clientEntityState;
|
|
287
|
+
|
|
288
|
+
// Determine which fields to send
|
|
289
|
+
const fieldsToCheck = fields === "*" ? Object.keys(newState) : Array.from(fields);
|
|
290
|
+
|
|
291
|
+
// Compute updates for changed fields
|
|
292
|
+
const updates: Record<string, Update> = {};
|
|
293
|
+
let hasChanges = false;
|
|
294
|
+
|
|
295
|
+
for (const field of fieldsToCheck) {
|
|
296
|
+
const oldValue = lastState[field];
|
|
297
|
+
const newValue = newState[field];
|
|
298
|
+
|
|
299
|
+
// Skip if unchanged
|
|
300
|
+
if (oldValue === newValue) continue;
|
|
301
|
+
if (
|
|
302
|
+
typeof oldValue === "object" &&
|
|
303
|
+
typeof newValue === "object" &&
|
|
304
|
+
JSON.stringify(oldValue) === JSON.stringify(newValue)
|
|
305
|
+
) {
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Compute optimal update
|
|
310
|
+
const update = createUpdate(oldValue, newValue);
|
|
311
|
+
updates[field] = update;
|
|
312
|
+
hasChanges = true;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (!hasChanges) return;
|
|
316
|
+
|
|
317
|
+
// Send update
|
|
318
|
+
client.send({
|
|
319
|
+
type: "update",
|
|
320
|
+
entity,
|
|
321
|
+
id,
|
|
322
|
+
updates,
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// Update client's last known state
|
|
326
|
+
for (const field of fieldsToCheck) {
|
|
327
|
+
if (newState[field] !== undefined) {
|
|
328
|
+
clientEntityState.lastState[field] = newState[field];
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Send initial data to a newly subscribed client
|
|
335
|
+
*/
|
|
336
|
+
private sendInitialData(
|
|
337
|
+
clientId: string,
|
|
338
|
+
entity: string,
|
|
339
|
+
id: string,
|
|
340
|
+
state: Record<string, unknown>,
|
|
341
|
+
fields: string[] | "*",
|
|
342
|
+
): void {
|
|
343
|
+
const client = this.clients.get(clientId);
|
|
344
|
+
if (!client) return;
|
|
345
|
+
|
|
346
|
+
const key = this.makeKey(entity, id);
|
|
347
|
+
const clientStateMap = this.clientStates.get(clientId);
|
|
348
|
+
if (!clientStateMap) return;
|
|
349
|
+
|
|
350
|
+
// Filter to requested fields
|
|
351
|
+
const fieldsToSend = fields === "*" ? Object.keys(state) : fields;
|
|
352
|
+
const dataToSend: Record<string, unknown> = {};
|
|
353
|
+
const updates: Record<string, Update> = {};
|
|
354
|
+
|
|
355
|
+
for (const field of fieldsToSend) {
|
|
356
|
+
if (state[field] !== undefined) {
|
|
357
|
+
dataToSend[field] = state[field];
|
|
358
|
+
updates[field] = { strategy: "value", data: state[field] };
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Send as update message with value strategy
|
|
363
|
+
client.send({
|
|
364
|
+
type: "update",
|
|
365
|
+
entity,
|
|
366
|
+
id,
|
|
367
|
+
updates,
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// Update client's last known state
|
|
371
|
+
const clientEntityState = clientStateMap.get(key);
|
|
372
|
+
if (clientEntityState) {
|
|
373
|
+
clientEntityState.lastState = { ...dataToSend };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Cleanup entity when no subscribers remain
|
|
379
|
+
*/
|
|
380
|
+
private cleanupEntity(key: EntityKey): void {
|
|
381
|
+
const [entity, id] = key.split(":") as [string, string];
|
|
382
|
+
|
|
383
|
+
// Optionally notify
|
|
384
|
+
if (this.config.onEntityUnsubscribed) {
|
|
385
|
+
this.config.onEntityUnsubscribed(entity, id);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Remove canonical state (optional - could keep for cache)
|
|
389
|
+
// this.canonical.delete(key);
|
|
390
|
+
|
|
391
|
+
// Remove from subscribers map
|
|
392
|
+
this.entitySubscribers.delete(key);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private makeKey(entity: string, id: string): EntityKey {
|
|
396
|
+
return makeEntityKey(entity, id);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ===========================================================================
|
|
400
|
+
// Stats & Debug
|
|
401
|
+
// ===========================================================================
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Get statistics
|
|
405
|
+
*/
|
|
406
|
+
getStats(): {
|
|
407
|
+
clients: number;
|
|
408
|
+
entities: number;
|
|
409
|
+
totalSubscriptions: number;
|
|
410
|
+
} {
|
|
411
|
+
let totalSubscriptions = 0;
|
|
412
|
+
for (const subscribers of this.entitySubscribers.values()) {
|
|
413
|
+
totalSubscriptions += subscribers.size;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return {
|
|
417
|
+
clients: this.clients.size,
|
|
418
|
+
entities: this.canonical.size,
|
|
419
|
+
totalSubscriptions,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Clear all state (for testing)
|
|
425
|
+
*/
|
|
426
|
+
clear(): void {
|
|
427
|
+
this.clients.clear();
|
|
428
|
+
this.canonical.clear();
|
|
429
|
+
this.clientStates.clear();
|
|
430
|
+
this.entitySubscribers.clear();
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// =============================================================================
|
|
435
|
+
// Factory
|
|
436
|
+
// =============================================================================
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Create a GraphStateManager instance
|
|
440
|
+
*/
|
|
441
|
+
export function createGraphStateManager(config?: GraphStateManagerConfig): GraphStateManager {
|
|
442
|
+
return new GraphStateManager(config);
|
|
443
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sylphx/lens-server - State Management
|
|
3
|
+
*
|
|
4
|
+
* Server-side state management for reactive data sync.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
GraphStateManager,
|
|
9
|
+
createGraphStateManager,
|
|
10
|
+
type EntityKey,
|
|
11
|
+
type StateClient,
|
|
12
|
+
type StateUpdateMessage,
|
|
13
|
+
type StateFullMessage,
|
|
14
|
+
type Subscription,
|
|
15
|
+
type GraphStateManagerConfig,
|
|
16
|
+
} from "./graph-state-manager";
|