@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.
@@ -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";