@sylphx/lens-server 1.11.2 → 2.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.
@@ -0,0 +1,551 @@
1
+ /**
2
+ * @sylphx/lens-server - Plugin System Types
3
+ *
4
+ * Server-side plugin system with lifecycle hooks.
5
+ * Plugins can intercept, modify, or extend server behavior.
6
+ */
7
+
8
+ // =============================================================================
9
+ // Hook Context Types
10
+ // =============================================================================
11
+
12
+ /**
13
+ * Context passed to onSubscribe hook.
14
+ */
15
+ export interface SubscribeContext {
16
+ /** Client ID */
17
+ clientId: string;
18
+ /** Subscription ID (unique per client) */
19
+ subscriptionId: string;
20
+ /** Operation path (e.g., 'user.get') */
21
+ operation: string;
22
+ /** Operation input */
23
+ input: unknown;
24
+ /** Fields being subscribed to */
25
+ fields: string[] | "*";
26
+ /** Entity type (if determined) */
27
+ entity?: string;
28
+ /** Entity ID (if determined) */
29
+ entityId?: string;
30
+ }
31
+
32
+ /**
33
+ * Context passed to onUnsubscribe hook.
34
+ */
35
+ export interface UnsubscribeContext {
36
+ /** Client ID */
37
+ clientId: string;
38
+ /** Subscription ID */
39
+ subscriptionId: string;
40
+ /** Operation path */
41
+ operation: string;
42
+ /** Entity keys that were being tracked */
43
+ entityKeys: string[];
44
+ }
45
+
46
+ /**
47
+ * Context passed to beforeSend hook.
48
+ *
49
+ * The beforeSend hook is the key integration point for optimization plugins.
50
+ * Plugins can intercept the data and return an optimized payload (e.g., diff).
51
+ */
52
+ export interface BeforeSendContext {
53
+ /** Client ID */
54
+ clientId: string;
55
+ /** Subscription ID (unique per client subscription) */
56
+ subscriptionId: string;
57
+ /** Entity type */
58
+ entity: string;
59
+ /** Entity ID */
60
+ entityId: string;
61
+ /** Data to be sent (full entity data) */
62
+ data: Record<string, unknown>;
63
+ /** Whether this is the first send (initial subscription data) */
64
+ isInitial: boolean;
65
+ /** Fields the client is subscribed to */
66
+ fields: string[] | "*";
67
+ }
68
+
69
+ /**
70
+ * Context passed to afterSend hook.
71
+ */
72
+ export interface AfterSendContext {
73
+ /** Client ID */
74
+ clientId: string;
75
+ /** Subscription ID */
76
+ subscriptionId: string;
77
+ /** Entity type */
78
+ entity: string;
79
+ /** Entity ID */
80
+ entityId: string;
81
+ /** Data that was sent (may be optimized/transformed by beforeSend) */
82
+ data: Record<string, unknown>;
83
+ /** Whether this was the first send */
84
+ isInitial: boolean;
85
+ /** Fields the client is subscribed to */
86
+ fields: string[] | "*";
87
+ /** Timestamp of send */
88
+ timestamp: number;
89
+ }
90
+
91
+ /**
92
+ * Context passed to onConnect hook.
93
+ */
94
+ export interface ConnectContext {
95
+ /** Client ID */
96
+ clientId: string;
97
+ /** Request object (if available) */
98
+ request?: Request;
99
+ /** Function to send messages to this client */
100
+ send?: (message: unknown) => void;
101
+ }
102
+
103
+ /**
104
+ * Context passed to onBroadcast hook.
105
+ */
106
+ export interface BroadcastContext {
107
+ /** Entity type name */
108
+ entity: string;
109
+ /** Entity ID */
110
+ entityId: string;
111
+ /** Entity data */
112
+ data: Record<string, unknown>;
113
+ }
114
+
115
+ /**
116
+ * Context passed to onDisconnect hook.
117
+ */
118
+ export interface DisconnectContext {
119
+ /** Client ID */
120
+ clientId: string;
121
+ /** Number of active subscriptions at disconnect */
122
+ subscriptionCount: number;
123
+ }
124
+
125
+ /**
126
+ * Context passed to beforeMutation hook.
127
+ */
128
+ export interface BeforeMutationContext {
129
+ /** Mutation name */
130
+ name: string;
131
+ /** Mutation input */
132
+ input: unknown;
133
+ /** Client ID (if from WebSocket) */
134
+ clientId?: string;
135
+ }
136
+
137
+ /**
138
+ * Context passed to afterMutation hook.
139
+ */
140
+ export interface AfterMutationContext {
141
+ /** Mutation name */
142
+ name: string;
143
+ /** Mutation input */
144
+ input: unknown;
145
+ /** Mutation result */
146
+ result: unknown;
147
+ /** Client ID (if from WebSocket) */
148
+ clientId?: string;
149
+ /** Duration in milliseconds */
150
+ duration: number;
151
+ }
152
+
153
+ /**
154
+ * Context passed to onReconnect hook.
155
+ */
156
+ export interface ReconnectContext {
157
+ /** Client ID */
158
+ clientId: string;
159
+ /** Subscriptions to restore */
160
+ subscriptions: Array<{
161
+ id: string;
162
+ entity: string;
163
+ entityId: string;
164
+ fields: string[] | "*";
165
+ version: number;
166
+ dataHash?: string;
167
+ input?: unknown;
168
+ }>;
169
+ /** Client-generated reconnect ID */
170
+ reconnectId: string;
171
+ }
172
+
173
+ /**
174
+ * Result from onReconnect hook.
175
+ */
176
+ export interface ReconnectHookResult {
177
+ /** Subscription ID */
178
+ id: string;
179
+ /** Entity type */
180
+ entity: string;
181
+ /** Entity ID */
182
+ entityId: string;
183
+ /** Sync status */
184
+ status: "current" | "patched" | "snapshot" | "deleted" | "error";
185
+ /** Current server version */
186
+ version: number;
187
+ /** For "patched": ordered patches to apply */
188
+ patches?: Array<Array<{ op: string; path: string; value?: unknown }>>;
189
+ /** For "snapshot": full current state */
190
+ data?: Record<string, unknown>;
191
+ /** Error message if status is "error" */
192
+ error?: string;
193
+ }
194
+
195
+ /**
196
+ * Context passed to onUpdateFields hook.
197
+ */
198
+ export interface UpdateFieldsContext {
199
+ /** Client ID */
200
+ clientId: string;
201
+ /** Subscription ID */
202
+ subscriptionId: string;
203
+ /** Entity type */
204
+ entity: string;
205
+ /** Entity ID */
206
+ entityId: string;
207
+ /** New fields after update */
208
+ fields: string[] | "*";
209
+ /** Previous fields */
210
+ previousFields: string[] | "*";
211
+ }
212
+
213
+ /**
214
+ * Context passed to enhanceOperationMeta hook.
215
+ * Called for each operation when building handshake metadata.
216
+ */
217
+ export interface EnhanceOperationMetaContext {
218
+ /** Operation path (e.g., 'user.create') */
219
+ path: string;
220
+ /** Operation type */
221
+ type: "query" | "mutation";
222
+ /** Current metadata (can be modified) */
223
+ meta: Record<string, unknown>;
224
+ /** Operation definition (MutationDef or QueryDef) */
225
+ definition: unknown;
226
+ }
227
+
228
+ // =============================================================================
229
+ // Plugin Interface
230
+ // =============================================================================
231
+
232
+ /**
233
+ * Server plugin interface.
234
+ *
235
+ * Plugins receive lifecycle hooks to extend server behavior.
236
+ * All hooks are optional - implement only what you need.
237
+ *
238
+ * @example
239
+ * ```typescript
240
+ * const loggingPlugin: ServerPlugin = {
241
+ * name: 'logging',
242
+ * onSubscribe: (ctx) => {
243
+ * console.log(`Client ${ctx.clientId} subscribed to ${ctx.operation}`);
244
+ * },
245
+ * beforeSend: (ctx) => {
246
+ * console.log(`Sending ${Object.keys(ctx.data).length} fields to ${ctx.clientId}`);
247
+ * return ctx.data; // Can modify data
248
+ * },
249
+ * };
250
+ * ```
251
+ */
252
+ export interface ServerPlugin {
253
+ /** Plugin name (for debugging) */
254
+ name: string;
255
+
256
+ /**
257
+ * Called when a client connects.
258
+ * Can return false to reject the connection.
259
+ */
260
+ onConnect?: (ctx: ConnectContext) => void | boolean | Promise<void | boolean>;
261
+
262
+ /**
263
+ * Called when a client disconnects.
264
+ */
265
+ onDisconnect?: (ctx: DisconnectContext) => void | Promise<void>;
266
+
267
+ /**
268
+ * Called when a client subscribes to an operation.
269
+ * Can modify the context or return false to reject.
270
+ */
271
+ onSubscribe?: (ctx: SubscribeContext) => void | boolean | Promise<void | boolean>;
272
+
273
+ /**
274
+ * Called when a client unsubscribes.
275
+ */
276
+ onUnsubscribe?: (ctx: UnsubscribeContext) => void | Promise<void>;
277
+
278
+ /**
279
+ * Called before sending data to a client.
280
+ * Can modify the data to be sent.
281
+ *
282
+ * @returns Modified data, or undefined to use original
283
+ */
284
+ beforeSend?: (
285
+ ctx: BeforeSendContext,
286
+ ) => Record<string, unknown> | void | Promise<Record<string, unknown> | void>;
287
+
288
+ /**
289
+ * Called after data is sent to a client.
290
+ */
291
+ afterSend?: (ctx: AfterSendContext) => void | Promise<void>;
292
+
293
+ /**
294
+ * Called before a mutation is executed.
295
+ * Can modify the input or return false to reject.
296
+ */
297
+ beforeMutation?: (ctx: BeforeMutationContext) => void | boolean | Promise<void | boolean>;
298
+
299
+ /**
300
+ * Called after a mutation is executed.
301
+ */
302
+ afterMutation?: (ctx: AfterMutationContext) => void | Promise<void>;
303
+
304
+ /**
305
+ * Called when a client reconnects with subscription state.
306
+ * Plugin can return sync results for each subscription.
307
+ *
308
+ * @returns Array of sync results, or null to let other plugins handle
309
+ */
310
+ onReconnect?: (
311
+ ctx: ReconnectContext,
312
+ ) => ReconnectHookResult[] | null | Promise<ReconnectHookResult[] | null>;
313
+
314
+ /**
315
+ * Called when a client updates subscribed fields for an entity.
316
+ */
317
+ onUpdateFields?: (ctx: UpdateFieldsContext) => void | Promise<void>;
318
+
319
+ /**
320
+ * Called for each operation when building handshake metadata.
321
+ * Plugin can add fields to meta (e.g., optimistic config).
322
+ *
323
+ * @example
324
+ * ```typescript
325
+ * enhanceOperationMeta: (ctx) => {
326
+ * if (ctx.type === 'mutation' && ctx.definition._optimistic) {
327
+ * ctx.meta.optimistic = convertToExecutable(ctx.definition._optimistic);
328
+ * }
329
+ * }
330
+ * ```
331
+ */
332
+ enhanceOperationMeta?: (ctx: EnhanceOperationMetaContext) => void;
333
+
334
+ /**
335
+ * Called when broadcasting data to subscribers of an entity.
336
+ * Plugin updates canonical state and returns patch info.
337
+ * Handler is responsible for routing to subscribers.
338
+ *
339
+ * @returns BroadcastResult with version, patch, and data
340
+ */
341
+ onBroadcast?: (
342
+ ctx: BroadcastContext,
343
+ ) =>
344
+ | { version: number; patch: unknown[] | null; data: Record<string, unknown> }
345
+ | boolean
346
+ | void
347
+ | Promise<
348
+ { version: number; patch: unknown[] | null; data: Record<string, unknown> } | boolean | void
349
+ >;
350
+ }
351
+
352
+ // =============================================================================
353
+ // Plugin Manager
354
+ // =============================================================================
355
+
356
+ /**
357
+ * Plugin manager handles plugin lifecycle and hook execution.
358
+ */
359
+ export class PluginManager {
360
+ private plugins: ServerPlugin[] = [];
361
+
362
+ /**
363
+ * Register a plugin.
364
+ */
365
+ register(plugin: ServerPlugin): void {
366
+ this.plugins.push(plugin);
367
+ }
368
+
369
+ /**
370
+ * Get all registered plugins.
371
+ */
372
+ getPlugins(): readonly ServerPlugin[] {
373
+ return this.plugins;
374
+ }
375
+
376
+ /**
377
+ * Run onConnect hooks.
378
+ * Returns false if any plugin rejects the connection.
379
+ */
380
+ async runOnConnect(ctx: ConnectContext): Promise<boolean> {
381
+ for (const plugin of this.plugins) {
382
+ if (plugin.onConnect) {
383
+ const result = await plugin.onConnect(ctx);
384
+ if (result === false) return false;
385
+ }
386
+ }
387
+ return true;
388
+ }
389
+
390
+ /**
391
+ * Run onDisconnect hooks.
392
+ */
393
+ async runOnDisconnect(ctx: DisconnectContext): Promise<void> {
394
+ for (const plugin of this.plugins) {
395
+ if (plugin.onDisconnect) {
396
+ await plugin.onDisconnect(ctx);
397
+ }
398
+ }
399
+ }
400
+
401
+ /**
402
+ * Run onSubscribe hooks.
403
+ * Returns false if any plugin rejects the subscription.
404
+ */
405
+ async runOnSubscribe(ctx: SubscribeContext): Promise<boolean> {
406
+ for (const plugin of this.plugins) {
407
+ if (plugin.onSubscribe) {
408
+ const result = await plugin.onSubscribe(ctx);
409
+ if (result === false) return false;
410
+ }
411
+ }
412
+ return true;
413
+ }
414
+
415
+ /**
416
+ * Run onUnsubscribe hooks.
417
+ */
418
+ async runOnUnsubscribe(ctx: UnsubscribeContext): Promise<void> {
419
+ for (const plugin of this.plugins) {
420
+ if (plugin.onUnsubscribe) {
421
+ await plugin.onUnsubscribe(ctx);
422
+ }
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Run beforeSend hooks.
428
+ * Each plugin can modify the data.
429
+ */
430
+ async runBeforeSend(ctx: BeforeSendContext): Promise<Record<string, unknown>> {
431
+ let data = ctx.data;
432
+ for (const plugin of this.plugins) {
433
+ if (plugin.beforeSend) {
434
+ const result = await plugin.beforeSend({ ...ctx, data });
435
+ if (result !== undefined) {
436
+ data = result;
437
+ }
438
+ }
439
+ }
440
+ return data;
441
+ }
442
+
443
+ /**
444
+ * Run afterSend hooks.
445
+ */
446
+ async runAfterSend(ctx: AfterSendContext): Promise<void> {
447
+ for (const plugin of this.plugins) {
448
+ if (plugin.afterSend) {
449
+ await plugin.afterSend(ctx);
450
+ }
451
+ }
452
+ }
453
+
454
+ /**
455
+ * Run beforeMutation hooks.
456
+ * Returns false if any plugin rejects the mutation.
457
+ */
458
+ async runBeforeMutation(ctx: BeforeMutationContext): Promise<boolean> {
459
+ for (const plugin of this.plugins) {
460
+ if (plugin.beforeMutation) {
461
+ const result = await plugin.beforeMutation(ctx);
462
+ if (result === false) return false;
463
+ }
464
+ }
465
+ return true;
466
+ }
467
+
468
+ /**
469
+ * Run afterMutation hooks.
470
+ */
471
+ async runAfterMutation(ctx: AfterMutationContext): Promise<void> {
472
+ for (const plugin of this.plugins) {
473
+ if (plugin.afterMutation) {
474
+ await plugin.afterMutation(ctx);
475
+ }
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Run onReconnect hooks.
481
+ * Returns the first non-null result from a plugin.
482
+ */
483
+ async runOnReconnect(ctx: ReconnectContext): Promise<ReconnectHookResult[] | null> {
484
+ for (const plugin of this.plugins) {
485
+ if (plugin.onReconnect) {
486
+ const result = await plugin.onReconnect(ctx);
487
+ if (result !== null) {
488
+ return result;
489
+ }
490
+ }
491
+ }
492
+ return null;
493
+ }
494
+
495
+ /**
496
+ * Run onUpdateFields hooks.
497
+ */
498
+ async runOnUpdateFields(ctx: UpdateFieldsContext): Promise<void> {
499
+ for (const plugin of this.plugins) {
500
+ if (plugin.onUpdateFields) {
501
+ await plugin.onUpdateFields(ctx);
502
+ }
503
+ }
504
+ }
505
+
506
+ /**
507
+ * Run enhanceOperationMeta hooks.
508
+ * Each plugin can add fields to the operation metadata.
509
+ */
510
+ runEnhanceOperationMeta(ctx: EnhanceOperationMetaContext): void {
511
+ for (const plugin of this.plugins) {
512
+ if (plugin.enhanceOperationMeta) {
513
+ plugin.enhanceOperationMeta(ctx);
514
+ }
515
+ }
516
+ }
517
+
518
+ /**
519
+ * Run onBroadcast hooks.
520
+ * Returns BroadcastResult if a plugin handled it, null otherwise.
521
+ */
522
+ async runOnBroadcast(
523
+ ctx: BroadcastContext,
524
+ ): Promise<{ version: number; patch: unknown[] | null; data: Record<string, unknown> } | null> {
525
+ for (const plugin of this.plugins) {
526
+ if (plugin.onBroadcast) {
527
+ const result = await plugin.onBroadcast(ctx);
528
+ // If result is an object with version, it's a BroadcastResult
529
+ if (result && typeof result === "object" && "version" in result) {
530
+ return result as {
531
+ version: number;
532
+ patch: unknown[] | null;
533
+ data: Record<string, unknown>;
534
+ };
535
+ }
536
+ // Legacy: if true, means handled but no result
537
+ if (result === true) {
538
+ return { version: 0, patch: null, data: ctx.data };
539
+ }
540
+ }
541
+ }
542
+ return null;
543
+ }
544
+ }
545
+
546
+ /**
547
+ * Create a new plugin manager.
548
+ */
549
+ export function createPluginManager(): PluginManager {
550
+ return new PluginManager();
551
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @sylphx/lens-server - Reconnection Module
3
+ *
4
+ * Server-side reconnection support:
5
+ * - OperationLog for tracking state changes
6
+ * - Patch coalescing and size estimation
7
+ */
8
+
9
+ export { coalescePatches, estimatePatchSize, OperationLog } from "./operation-log.js";