@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,1536 @@
1
+ /**
2
+ * @sylphx/lens-server - Lens Server
3
+ *
4
+ * Core server implementation:
5
+ * - Free Operations (query/mutation definitions)
6
+ * - GraphStateManager (per-client state tracking, minimal diffs)
7
+ * - Field-level subscriptions
8
+ * - Entity Resolvers with DataLoader batching
9
+ */
10
+
11
+ import {
12
+ type ContextValue,
13
+ type EntityDef,
14
+ type EntityDefinition,
15
+ type EntityResolvers,
16
+ type EntityResolversDefinition,
17
+ type FieldType,
18
+ type MutationDef,
19
+ type QueryDef,
20
+ type RelationDef,
21
+ type RelationTypeWithForeignKey,
22
+ type RouterDef,
23
+ type Update,
24
+ createContext,
25
+ createUpdate,
26
+ flattenRouter,
27
+ isBatchResolver,
28
+ isMutationDef,
29
+ isQueryDef,
30
+ runWithContext,
31
+ } from "@sylphx/lens-core";
32
+
33
+ /** Selection object type for nested field selection */
34
+ export interface SelectionObject {
35
+ [key: string]: boolean | SelectionObject | { select: SelectionObject };
36
+ }
37
+
38
+ import { GraphStateManager } from "../state/graph-state-manager";
39
+
40
+ // =============================================================================
41
+ // Types
42
+ // =============================================================================
43
+
44
+ /** Entity map type */
45
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
46
+ export type EntitiesMap = Record<string, EntityDef<string, any>>;
47
+
48
+ /** Queries map type */
49
+ export type QueriesMap = Record<string, QueryDef<unknown, unknown>>;
50
+
51
+ /** Mutations map type */
52
+ export type MutationsMap = Record<string, MutationDef<unknown, unknown>>;
53
+
54
+ /** Relations array type */
55
+ export type RelationsArray = RelationDef<
56
+ EntityDef<string, EntityDefinition>,
57
+ Record<string, RelationTypeWithForeignKey>
58
+ >[];
59
+
60
+ /** Operation metadata for handshake */
61
+ export interface OperationMeta {
62
+ type: "query" | "mutation";
63
+ optimistic?: unknown; // OptimisticDSL - sent as JSON
64
+ }
65
+
66
+ /** Nested operations structure for handshake */
67
+ export type OperationsMap = {
68
+ [key: string]: OperationMeta | OperationsMap;
69
+ };
70
+
71
+ /** Server configuration */
72
+ export interface LensServerConfig<TContext extends ContextValue = ContextValue> {
73
+ /** Entity definitions */
74
+ entities?: EntitiesMap;
75
+ /** Relation definitions */
76
+ relations?: RelationsArray;
77
+ /** Router definition (namespaced operations) */
78
+ router?: RouterDef;
79
+ /** Query definitions (flat, legacy) */
80
+ queries?: QueriesMap;
81
+ /** Mutation definitions (flat, legacy) */
82
+ mutations?: MutationsMap;
83
+ /** Entity resolvers */
84
+ resolvers?: EntityResolvers<EntityResolversDefinition>;
85
+ /** Context factory */
86
+ context?: (req?: unknown) => TContext | Promise<TContext>;
87
+ /** Server version */
88
+ version?: string;
89
+ }
90
+
91
+ /** Server metadata for transport handshake */
92
+ export interface ServerMetadata {
93
+ /** Server version */
94
+ version: string;
95
+ /** Operations metadata map */
96
+ operations: OperationsMap;
97
+ }
98
+
99
+ /** Operation for in-process transport */
100
+ export interface LensOperation {
101
+ /** Operation path (e.g., 'user.get', 'session.create') */
102
+ path: string;
103
+ /** Operation input */
104
+ input?: unknown;
105
+ }
106
+
107
+ /** Result from operation execution */
108
+ export interface LensResult<T = unknown> {
109
+ /** Success data */
110
+ data?: T;
111
+ /** Error if operation failed */
112
+ error?: Error;
113
+ }
114
+
115
+ /** Lens server interface */
116
+ export interface LensServer {
117
+ /** Get server metadata for transport handshake */
118
+ getMetadata(): ServerMetadata;
119
+ /** Execute operation - auto-detects query vs mutation from registered operations */
120
+ execute(op: LensOperation): Promise<LensResult>;
121
+ /** Execute a query (one-time) */
122
+ executeQuery<TInput, TOutput>(name: string, input?: TInput): Promise<TOutput>;
123
+ /** Execute a mutation */
124
+ executeMutation<TInput, TOutput>(name: string, input: TInput): Promise<TOutput>;
125
+ /** Handle WebSocket connection */
126
+ handleWebSocket(ws: WebSocketLike): void;
127
+ /** Handle HTTP request */
128
+ handleRequest(req: Request): Promise<Response>;
129
+ /** Get GraphStateManager for external access */
130
+ getStateManager(): GraphStateManager;
131
+ /** Start server */
132
+ listen(port: number): Promise<void>;
133
+ /** Close server */
134
+ close(): Promise<void>;
135
+ }
136
+
137
+ /** WebSocket interface */
138
+ export interface WebSocketLike {
139
+ send(data: string): void;
140
+ close(): void;
141
+ onmessage?: ((event: { data: string }) => void) | null;
142
+ onclose?: (() => void) | null;
143
+ onerror?: ((error: unknown) => void) | null;
144
+ }
145
+
146
+ /** Emit context for streaming resolvers */
147
+ interface EmitContext<T> {
148
+ emit: (data: T) => void;
149
+ onCleanup: (fn: () => void) => () => void;
150
+ }
151
+
152
+ // =============================================================================
153
+ // Protocol Messages
154
+ // =============================================================================
155
+
156
+ /** Subscribe to operation with field selection */
157
+ interface SubscribeMessage {
158
+ type: "subscribe";
159
+ id: string;
160
+ operation: string;
161
+ input?: unknown;
162
+ fields: string[] | "*";
163
+ /** SelectionObject for nested field selection */
164
+ select?: SelectionObject;
165
+ }
166
+
167
+ /** Update subscription fields */
168
+ interface UpdateFieldsMessage {
169
+ type: "updateFields";
170
+ id: string;
171
+ addFields?: string[];
172
+ removeFields?: string[];
173
+ /** Replace all fields with these (for 最大原則 downgrade from "*" to specific fields) */
174
+ setFields?: string[];
175
+ }
176
+
177
+ /** Unsubscribe */
178
+ interface UnsubscribeMessage {
179
+ type: "unsubscribe";
180
+ id: string;
181
+ }
182
+
183
+ /** One-time query */
184
+ interface QueryMessage {
185
+ type: "query";
186
+ id: string;
187
+ operation: string;
188
+ input?: unknown;
189
+ fields?: string[] | "*";
190
+ /** SelectionObject for nested field selection */
191
+ select?: SelectionObject;
192
+ }
193
+
194
+ /** Mutation */
195
+ interface MutationMessage {
196
+ type: "mutation";
197
+ id: string;
198
+ operation: string;
199
+ input: unknown;
200
+ }
201
+
202
+ /** Handshake */
203
+ interface HandshakeMessage {
204
+ type: "handshake";
205
+ id: string;
206
+ clientVersion?: string;
207
+ }
208
+
209
+ type ClientMessage =
210
+ | SubscribeMessage
211
+ | UpdateFieldsMessage
212
+ | UnsubscribeMessage
213
+ | QueryMessage
214
+ | MutationMessage
215
+ | HandshakeMessage;
216
+
217
+ // =============================================================================
218
+ // Client Connection
219
+ // =============================================================================
220
+
221
+ interface ClientConnection {
222
+ id: string;
223
+ ws: WebSocketLike;
224
+ subscriptions: Map<string, ClientSubscription>;
225
+ }
226
+
227
+ interface ClientSubscription {
228
+ id: string;
229
+ operation: string;
230
+ input: unknown;
231
+ fields: string[] | "*";
232
+ /** Entity keys this subscription is tracking */
233
+ entityKeys: Set<string>;
234
+ /** Cleanup functions */
235
+ cleanups: (() => void)[];
236
+ /** Last emitted data for diff computation */
237
+ lastData: unknown;
238
+ }
239
+
240
+ // =============================================================================
241
+ // DataLoader
242
+ // =============================================================================
243
+
244
+ class DataLoader<K, V> {
245
+ private batch: Map<K, { resolve: (v: V | null) => void; reject: (e: Error) => void }[]> =
246
+ new Map();
247
+ private scheduled = false;
248
+
249
+ constructor(private batchFn: (keys: K[]) => Promise<(V | null)[]>) {}
250
+
251
+ async load(key: K): Promise<V | null> {
252
+ return new Promise((resolve, reject) => {
253
+ const existing = this.batch.get(key);
254
+ if (existing) {
255
+ existing.push({ resolve, reject });
256
+ } else {
257
+ this.batch.set(key, [{ resolve, reject }]);
258
+ }
259
+ this.scheduleDispatch();
260
+ });
261
+ }
262
+
263
+ private scheduleDispatch(): void {
264
+ if (this.scheduled) return;
265
+ this.scheduled = true;
266
+ queueMicrotask(() => this.dispatch());
267
+ }
268
+
269
+ private async dispatch(): Promise<void> {
270
+ this.scheduled = false;
271
+ const batch = this.batch;
272
+ this.batch = new Map();
273
+
274
+ const keys = Array.from(batch.keys());
275
+ if (keys.length === 0) return;
276
+
277
+ try {
278
+ const results = await this.batchFn(keys);
279
+ keys.forEach((key, index) => {
280
+ const callbacks = batch.get(key)!;
281
+ const result = results[index] ?? null;
282
+ callbacks.forEach(({ resolve }) => resolve(result));
283
+ });
284
+ } catch (error) {
285
+ for (const callbacks of batch.values()) {
286
+ callbacks.forEach(({ reject }) => reject(error as Error));
287
+ }
288
+ }
289
+ }
290
+
291
+ clear(): void {
292
+ this.batch.clear();
293
+ }
294
+ }
295
+
296
+ // =============================================================================
297
+ // Lens Server Implementation
298
+ // =============================================================================
299
+
300
+ class LensServerImpl<
301
+ Q extends QueriesMap = QueriesMap,
302
+ M extends MutationsMap = MutationsMap,
303
+ TContext extends ContextValue = ContextValue,
304
+ > implements LensServer
305
+ {
306
+ private queries: Q;
307
+ private mutations: M;
308
+ private entities: EntitiesMap;
309
+ private resolvers?: EntityResolvers<EntityResolversDefinition>;
310
+ private contextFactory: (req?: unknown) => TContext | Promise<TContext>;
311
+ private version: string;
312
+ private ctx = createContext<TContext>();
313
+
314
+ /** GraphStateManager for per-client state tracking */
315
+ private stateManager: GraphStateManager;
316
+
317
+ /** DataLoaders for N+1 batching (per-request) */
318
+ private loaders = new Map<string, DataLoader<unknown, unknown>>();
319
+
320
+ /** Client connections */
321
+ private connections = new Map<string, ClientConnection>();
322
+ private connectionCounter = 0;
323
+
324
+ /** Server instance */
325
+ private server: unknown = null;
326
+
327
+ constructor(config: LensServerConfig<TContext> & { queries?: Q; mutations?: M }) {
328
+ // Start with flat queries/mutations (legacy)
329
+ const queries: QueriesMap = { ...(config.queries ?? {}) };
330
+ const mutations: MutationsMap = { ...(config.mutations ?? {}) };
331
+
332
+ // Flatten router into queries/mutations (if provided)
333
+ if (config.router) {
334
+ const flattened = flattenRouter(config.router);
335
+ for (const [path, procedure] of flattened) {
336
+ if (isQueryDef(procedure)) {
337
+ queries[path] = procedure;
338
+ } else if (isMutationDef(procedure)) {
339
+ mutations[path] = procedure;
340
+ }
341
+ }
342
+ }
343
+
344
+ this.queries = queries as Q;
345
+ this.mutations = mutations as M;
346
+ this.entities = config.entities ?? {};
347
+ this.resolvers = config.resolvers;
348
+ this.contextFactory = config.context ?? (() => ({}) as TContext);
349
+ this.version = config.version ?? "1.0.0";
350
+
351
+ // Inject entity names from keys (if not already set)
352
+ for (const [name, def] of Object.entries(this.entities)) {
353
+ if (def && typeof def === "object" && !def._name) {
354
+ (def as { _name?: string })._name = name;
355
+ }
356
+ }
357
+
358
+ // Inject mutation names and auto-derive optimistic from naming convention
359
+ for (const [name, def] of Object.entries(this.mutations)) {
360
+ if (def && typeof def === "object") {
361
+ // Inject name
362
+ (def as { _name?: string })._name = name;
363
+
364
+ // Auto-derive optimistic from naming convention if not explicitly set
365
+ // For namespaced routes (e.g., "user.create"), check the last segment
366
+ const lastSegment = name.includes(".") ? name.split(".").pop()! : name;
367
+ if (!def._optimistic) {
368
+ if (lastSegment.startsWith("update")) {
369
+ (def as { _optimistic?: string })._optimistic = "merge";
370
+ } else if (lastSegment.startsWith("create") || lastSegment.startsWith("add")) {
371
+ (def as { _optimistic?: string })._optimistic = "create";
372
+ } else if (lastSegment.startsWith("delete") || lastSegment.startsWith("remove")) {
373
+ (def as { _optimistic?: string })._optimistic = "delete";
374
+ }
375
+ }
376
+ }
377
+ }
378
+
379
+ // Inject query names
380
+ for (const [name, def] of Object.entries(this.queries)) {
381
+ if (def && typeof def === "object") {
382
+ (def as { _name?: string })._name = name;
383
+ }
384
+ }
385
+
386
+ // Initialize GraphStateManager
387
+ this.stateManager = new GraphStateManager({
388
+ onEntityUnsubscribed: (_entity, _id) => {
389
+ // Optional: cleanup when entity has no subscribers
390
+ },
391
+ });
392
+
393
+ // Validate queries and mutations
394
+ for (const [name, def] of Object.entries(this.queries)) {
395
+ if (!isQueryDef(def)) {
396
+ throw new Error(`Invalid query definition: ${name}`);
397
+ }
398
+ }
399
+ for (const [name, def] of Object.entries(this.mutations)) {
400
+ if (!isMutationDef(def)) {
401
+ throw new Error(`Invalid mutation definition: ${name}`);
402
+ }
403
+ }
404
+ }
405
+
406
+ getStateManager(): GraphStateManager {
407
+ return this.stateManager;
408
+ }
409
+
410
+ /**
411
+ * Get server metadata for transport handshake.
412
+ * Used by inProcess transport for direct access.
413
+ */
414
+ getMetadata(): ServerMetadata {
415
+ return {
416
+ version: this.version,
417
+ operations: this.buildOperationsMap(),
418
+ };
419
+ }
420
+
421
+ /**
422
+ * Execute operation - auto-detects query vs mutation from registered operations.
423
+ * Used by inProcess transport for direct server calls.
424
+ */
425
+ async execute(op: LensOperation): Promise<LensResult> {
426
+ const { path, input } = op;
427
+
428
+ try {
429
+ // Check if it's a query
430
+ if (this.queries[path]) {
431
+ const data = await this.executeQuery(path, input);
432
+ return { data };
433
+ }
434
+
435
+ // Check if it's a mutation
436
+ if (this.mutations[path]) {
437
+ const data = await this.executeMutation(path, input);
438
+ return { data };
439
+ }
440
+
441
+ // Operation not found
442
+ return { error: new Error(`Operation not found: ${path}`) };
443
+ } catch (error) {
444
+ return { error: error instanceof Error ? error : new Error(String(error)) };
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Build nested operations map for handshake response
450
+ * Converts flat "user.get", "user.create" into nested { user: { get: {...}, create: {...} } }
451
+ */
452
+ private buildOperationsMap(): OperationsMap {
453
+ const result: OperationsMap = {};
454
+
455
+ // Helper to set nested value
456
+ const setNested = (path: string, meta: OperationMeta) => {
457
+ const parts = path.split(".");
458
+ let current: OperationsMap = result;
459
+
460
+ for (let i = 0; i < parts.length - 1; i++) {
461
+ const part = parts[i];
462
+ if (!current[part] || "type" in current[part]) {
463
+ current[part] = {};
464
+ }
465
+ current = current[part] as OperationsMap;
466
+ }
467
+
468
+ current[parts[parts.length - 1]] = meta;
469
+ };
470
+
471
+ // Add queries
472
+ for (const [name, def] of Object.entries(this.queries)) {
473
+ setNested(name, { type: "query" });
474
+ }
475
+
476
+ // Add mutations with optimistic config
477
+ for (const [name, def] of Object.entries(this.mutations)) {
478
+ const meta: OperationMeta = { type: "mutation" };
479
+ if (def._optimistic) {
480
+ meta.optimistic = def._optimistic;
481
+ }
482
+ setNested(name, meta);
483
+ }
484
+
485
+ return result;
486
+ }
487
+
488
+ // ===========================================================================
489
+ // WebSocket Handling
490
+ // ===========================================================================
491
+
492
+ handleWebSocket(ws: WebSocketLike): void {
493
+ const clientId = `client_${++this.connectionCounter}`;
494
+
495
+ const conn: ClientConnection = {
496
+ id: clientId,
497
+ ws,
498
+ subscriptions: new Map(),
499
+ };
500
+
501
+ this.connections.set(clientId, conn);
502
+
503
+ // Register with GraphStateManager
504
+ this.stateManager.addClient({
505
+ id: clientId,
506
+ send: (msg) => {
507
+ ws.send(JSON.stringify(msg));
508
+ },
509
+ });
510
+
511
+ ws.onmessage = (event) => {
512
+ this.handleMessage(conn, event.data as string);
513
+ };
514
+
515
+ ws.onclose = () => {
516
+ this.handleDisconnect(conn);
517
+ };
518
+ }
519
+
520
+ private handleMessage(conn: ClientConnection, data: string): void {
521
+ try {
522
+ const message = JSON.parse(data) as ClientMessage;
523
+
524
+ switch (message.type) {
525
+ case "handshake":
526
+ this.handleHandshake(conn, message);
527
+ break;
528
+ case "subscribe":
529
+ this.handleSubscribe(conn, message);
530
+ break;
531
+ case "updateFields":
532
+ this.handleUpdateFields(conn, message);
533
+ break;
534
+ case "unsubscribe":
535
+ this.handleUnsubscribe(conn, message);
536
+ break;
537
+ case "query":
538
+ this.handleQuery(conn, message);
539
+ break;
540
+ case "mutation":
541
+ this.handleMutation(conn, message);
542
+ break;
543
+ }
544
+ } catch (error) {
545
+ conn.ws.send(
546
+ JSON.stringify({
547
+ type: "error",
548
+ error: { code: "PARSE_ERROR", message: String(error) },
549
+ }),
550
+ );
551
+ }
552
+ }
553
+
554
+ private handleHandshake(conn: ClientConnection, message: HandshakeMessage): void {
555
+ conn.ws.send(
556
+ JSON.stringify({
557
+ type: "handshake",
558
+ id: message.id,
559
+ version: this.version,
560
+ operations: this.buildOperationsMap(),
561
+ }),
562
+ );
563
+ }
564
+
565
+ private async handleSubscribe(conn: ClientConnection, message: SubscribeMessage): Promise<void> {
566
+ const { id, operation, input, fields } = message;
567
+
568
+ // Create subscription
569
+ const sub: ClientSubscription = {
570
+ id,
571
+ operation,
572
+ input,
573
+ fields,
574
+ entityKeys: new Set(),
575
+ cleanups: [],
576
+ lastData: null,
577
+ };
578
+
579
+ conn.subscriptions.set(id, sub);
580
+
581
+ // Execute query and start streaming
582
+ try {
583
+ await this.executeSubscription(conn, sub);
584
+ } catch (error) {
585
+ conn.ws.send(
586
+ JSON.stringify({
587
+ type: "error",
588
+ id,
589
+ error: { code: "EXECUTION_ERROR", message: String(error) },
590
+ }),
591
+ );
592
+ }
593
+ }
594
+
595
+ private async executeSubscription(
596
+ conn: ClientConnection,
597
+ sub: ClientSubscription,
598
+ ): Promise<void> {
599
+ const queryDef = this.queries[sub.operation];
600
+ if (!queryDef) {
601
+ throw new Error(`Query not found: ${sub.operation}`);
602
+ }
603
+
604
+ // Validate input
605
+ if (queryDef._input && sub.input !== undefined) {
606
+ const result = queryDef._input.safeParse(sub.input);
607
+ if (!result.success) {
608
+ throw new Error(`Invalid input: ${JSON.stringify(result.error)}`);
609
+ }
610
+ }
611
+
612
+ const context = await this.contextFactory();
613
+ let isFirstUpdate = true;
614
+
615
+ // Create emit function that integrates with GraphStateManager
616
+ const emitData = (data: unknown) => {
617
+ if (!data) return;
618
+
619
+ // Extract entity info from data
620
+ const entityName = this.getEntityNameFromOutput(queryDef._output);
621
+ const entities = this.extractEntities(entityName, data);
622
+
623
+ // Register entities with GraphStateManager and track in subscription
624
+ for (const { entity, id, entityData } of entities) {
625
+ const entityKey = `${entity}:${id}`;
626
+ sub.entityKeys.add(entityKey);
627
+
628
+ // Subscribe client to this entity in GraphStateManager
629
+ this.stateManager.subscribe(conn.id, entity, id, sub.fields);
630
+
631
+ // Emit to GraphStateManager (it will compute diffs and send to client)
632
+ this.stateManager.emit(entity, id, entityData);
633
+ }
634
+
635
+ // Also send operation-level response for first data
636
+ if (isFirstUpdate) {
637
+ conn.ws.send(
638
+ JSON.stringify({
639
+ type: "data",
640
+ id: sub.id,
641
+ data,
642
+ }),
643
+ );
644
+ isFirstUpdate = false;
645
+ sub.lastData = data;
646
+ } else {
647
+ // Compute operation-level diff for subsequent updates
648
+ const updates = this.computeUpdates(sub.lastData, data);
649
+ if (updates && Object.keys(updates).length > 0) {
650
+ conn.ws.send(
651
+ JSON.stringify({
652
+ type: "update",
653
+ id: sub.id,
654
+ updates,
655
+ }),
656
+ );
657
+ }
658
+ sub.lastData = data;
659
+ }
660
+ };
661
+
662
+ // Execute resolver
663
+ await runWithContext(this.ctx, context, async () => {
664
+ const resolver = queryDef._resolve;
665
+ if (!resolver) {
666
+ throw new Error(`Query ${sub.operation} has no resolver`);
667
+ }
668
+
669
+ // Add emit and onCleanup to context for subscriptions
670
+ const contextWithHelpers = {
671
+ ...context,
672
+ emit: emitData,
673
+ onCleanup: (fn: () => void) => {
674
+ sub.cleanups.push(fn);
675
+ return () => {
676
+ const idx = sub.cleanups.indexOf(fn);
677
+ if (idx >= 0) sub.cleanups.splice(idx, 1);
678
+ };
679
+ },
680
+ };
681
+
682
+ const result = resolver({
683
+ input: sub.input,
684
+ ctx: contextWithHelpers,
685
+ });
686
+
687
+ if (isAsyncIterable(result)) {
688
+ // Async generator - stream all values
689
+ for await (const value of result) {
690
+ emitData(value);
691
+ }
692
+ } else {
693
+ // Single value
694
+ const value = await result;
695
+ emitData(value);
696
+ }
697
+ });
698
+ }
699
+
700
+ private handleUpdateFields(conn: ClientConnection, message: UpdateFieldsMessage): void {
701
+ const sub = conn.subscriptions.get(message.id);
702
+ if (!sub) return;
703
+
704
+ // Handle 最大原則 (Maximum Principle) transitions:
705
+
706
+ // 1. Upgrade to full subscription ("*")
707
+ if (message.addFields?.includes("*")) {
708
+ sub.fields = "*";
709
+ // Update GraphStateManager subscriptions for all tracked entities
710
+ for (const entityKey of sub.entityKeys) {
711
+ const [entity, id] = entityKey.split(":");
712
+ this.stateManager.updateSubscription(conn.id, entity, id, "*");
713
+ }
714
+ return;
715
+ }
716
+
717
+ // 2. Downgrade from "*" to specific fields (setFields)
718
+ if (message.setFields !== undefined) {
719
+ sub.fields = message.setFields;
720
+ // Update GraphStateManager subscriptions for all tracked entities
721
+ for (const entityKey of sub.entityKeys) {
722
+ const [entity, id] = entityKey.split(":");
723
+ this.stateManager.updateSubscription(conn.id, entity, id, sub.fields);
724
+ }
725
+ return;
726
+ }
727
+
728
+ // 3. Already subscribing to all fields - no-op for regular add/remove
729
+ if (sub.fields === "*") {
730
+ return;
731
+ }
732
+
733
+ // 4. Normal field add/remove
734
+ const fields = new Set(sub.fields);
735
+
736
+ if (message.addFields) {
737
+ for (const field of message.addFields) {
738
+ fields.add(field);
739
+ }
740
+ }
741
+
742
+ if (message.removeFields) {
743
+ for (const field of message.removeFields) {
744
+ fields.delete(field);
745
+ }
746
+ }
747
+
748
+ sub.fields = Array.from(fields);
749
+
750
+ // Update GraphStateManager subscriptions for all tracked entities
751
+ for (const entityKey of sub.entityKeys) {
752
+ const [entity, id] = entityKey.split(":");
753
+ this.stateManager.updateSubscription(conn.id, entity, id, sub.fields);
754
+ }
755
+ }
756
+
757
+ private handleUnsubscribe(conn: ClientConnection, message: UnsubscribeMessage): void {
758
+ const sub = conn.subscriptions.get(message.id);
759
+ if (!sub) return;
760
+
761
+ // Cleanup
762
+ for (const cleanup of sub.cleanups) {
763
+ try {
764
+ cleanup();
765
+ } catch (e) {
766
+ console.error("Cleanup error:", e);
767
+ }
768
+ }
769
+
770
+ // Unsubscribe from all tracked entities in GraphStateManager
771
+ for (const entityKey of sub.entityKeys) {
772
+ const [entity, id] = entityKey.split(":");
773
+ this.stateManager.unsubscribe(conn.id, entity, id);
774
+ }
775
+
776
+ conn.subscriptions.delete(message.id);
777
+ }
778
+
779
+ private async handleQuery(conn: ClientConnection, message: QueryMessage): Promise<void> {
780
+ try {
781
+ // If select is provided, inject it into input for executeQuery to process
782
+ let input = message.input;
783
+ if (message.select) {
784
+ input = { ...((message.input as object) || {}), $select: message.select };
785
+ }
786
+
787
+ const result = await this.executeQuery(message.operation, input);
788
+
789
+ // Apply field selection if specified (for backward compatibility with simple field lists)
790
+ const selected =
791
+ message.fields && !message.select ? this.applySelection(result, message.fields) : result;
792
+
793
+ conn.ws.send(
794
+ JSON.stringify({
795
+ type: "result",
796
+ id: message.id,
797
+ data: selected,
798
+ }),
799
+ );
800
+ } catch (error) {
801
+ conn.ws.send(
802
+ JSON.stringify({
803
+ type: "error",
804
+ id: message.id,
805
+ error: { code: "EXECUTION_ERROR", message: String(error) },
806
+ }),
807
+ );
808
+ }
809
+ }
810
+
811
+ private async handleMutation(conn: ClientConnection, message: MutationMessage): Promise<void> {
812
+ try {
813
+ const result = await this.executeMutation(message.operation, message.input);
814
+
815
+ // After mutation, emit to GraphStateManager to notify all subscribers
816
+ const entityName = this.getEntityNameFromMutation(message.operation);
817
+ const entities = this.extractEntities(entityName, result);
818
+
819
+ for (const { entity, id, entityData } of entities) {
820
+ this.stateManager.emit(entity, id, entityData);
821
+ }
822
+
823
+ conn.ws.send(
824
+ JSON.stringify({
825
+ type: "result",
826
+ id: message.id,
827
+ data: result,
828
+ }),
829
+ );
830
+ } catch (error) {
831
+ conn.ws.send(
832
+ JSON.stringify({
833
+ type: "error",
834
+ id: message.id,
835
+ error: { code: "EXECUTION_ERROR", message: String(error) },
836
+ }),
837
+ );
838
+ }
839
+ }
840
+
841
+ private handleDisconnect(conn: ClientConnection): void {
842
+ // Cleanup all subscriptions
843
+ for (const sub of conn.subscriptions.values()) {
844
+ for (const cleanup of sub.cleanups) {
845
+ try {
846
+ cleanup();
847
+ } catch (e) {
848
+ console.error("Cleanup error:", e);
849
+ }
850
+ }
851
+ }
852
+
853
+ // Remove from GraphStateManager
854
+ this.stateManager.removeClient(conn.id);
855
+
856
+ // Remove connection
857
+ this.connections.delete(conn.id);
858
+ }
859
+
860
+ // ===========================================================================
861
+ // Query/Mutation Execution
862
+ // ===========================================================================
863
+
864
+ async executeQuery<TInput, TOutput>(name: string, input?: TInput): Promise<TOutput> {
865
+ const queryDef = this.queries[name];
866
+ if (!queryDef) {
867
+ throw new Error(`Query not found: ${name}`);
868
+ }
869
+
870
+ // Extract $select from input if present
871
+ let select: SelectionObject | undefined;
872
+ let cleanInput = input;
873
+ if (input && typeof input === "object" && "$select" in input) {
874
+ const { $select, ...rest } = input as Record<string, unknown>;
875
+ select = $select as SelectionObject;
876
+ cleanInput = (Object.keys(rest).length > 0 ? rest : undefined) as TInput;
877
+ }
878
+
879
+ if (queryDef._input && cleanInput !== undefined) {
880
+ const result = queryDef._input.safeParse(cleanInput);
881
+ if (!result.success) {
882
+ throw new Error(`Invalid input: ${JSON.stringify(result.error)}`);
883
+ }
884
+ }
885
+
886
+ const context = await this.contextFactory();
887
+
888
+ try {
889
+ return await runWithContext(this.ctx, context, async () => {
890
+ const resolver = queryDef._resolve;
891
+ if (!resolver) {
892
+ throw new Error(`Query ${name} has no resolver`);
893
+ }
894
+
895
+ const resolverCtx = {
896
+ input: cleanInput as TInput,
897
+ ctx: context, // Pass context directly to resolver (tRPC style)
898
+ emit: () => {},
899
+ onCleanup: () => () => {},
900
+ };
901
+
902
+ const result = resolver(resolverCtx);
903
+
904
+ let data: TOutput;
905
+ if (isAsyncIterable(result)) {
906
+ for await (const value of result) {
907
+ data = value as TOutput;
908
+ break;
909
+ }
910
+ if (data! === undefined) {
911
+ throw new Error(`Query ${name} returned empty stream`);
912
+ }
913
+ } else {
914
+ data = (await result) as TOutput;
915
+ }
916
+
917
+ // Process with entity resolvers and selection
918
+ return this.processQueryResult(name, data, select);
919
+ });
920
+ } finally {
921
+ this.clearLoaders();
922
+ }
923
+ }
924
+
925
+ async executeMutation<TInput, TOutput>(name: string, input: TInput): Promise<TOutput> {
926
+ const mutationDef = this.mutations[name];
927
+ if (!mutationDef) {
928
+ throw new Error(`Mutation not found: ${name}`);
929
+ }
930
+
931
+ if (mutationDef._input) {
932
+ const result = mutationDef._input.safeParse(input);
933
+ if (!result.success) {
934
+ throw new Error(`Invalid input: ${JSON.stringify(result.error)}`);
935
+ }
936
+ }
937
+
938
+ const context = await this.contextFactory();
939
+
940
+ try {
941
+ return await runWithContext(this.ctx, context, async () => {
942
+ const resolver = mutationDef._resolve;
943
+ if (!resolver) {
944
+ throw new Error(`Mutation ${name} has no resolver`);
945
+ }
946
+
947
+ const result = await resolver({
948
+ input: input as TInput,
949
+ ctx: context, // Pass context directly to resolver (tRPC style)
950
+ });
951
+
952
+ // Emit to GraphStateManager
953
+ const entityName = this.getEntityNameFromMutation(name);
954
+ const entities = this.extractEntities(entityName, result);
955
+
956
+ for (const { entity, id, entityData } of entities) {
957
+ this.stateManager.emit(entity, id, entityData);
958
+ }
959
+
960
+ return result as TOutput;
961
+ });
962
+ } finally {
963
+ this.clearLoaders();
964
+ }
965
+ }
966
+
967
+ // ===========================================================================
968
+ // HTTP Handler
969
+ // ===========================================================================
970
+
971
+ async handleRequest(req: Request): Promise<Response> {
972
+ const url = new URL(req.url);
973
+
974
+ // GET /__lens/metadata - Return operations metadata for client transport handshake
975
+ if (req.method === "GET" && url.pathname.endsWith("/__lens/metadata")) {
976
+ return new Response(JSON.stringify(this.getMetadata()), {
977
+ headers: { "Content-Type": "application/json" },
978
+ });
979
+ }
980
+
981
+ if (req.method === "POST") {
982
+ try {
983
+ const body = (await req.json()) as { operation: string; input?: unknown };
984
+
985
+ // Auto-detect operation type from server's registered operations
986
+ // Client doesn't need to know if it's a query or mutation
987
+ if (this.queries[body.operation]) {
988
+ const result = await this.executeQuery(body.operation, body.input);
989
+ return new Response(JSON.stringify({ data: result }), {
990
+ headers: { "Content-Type": "application/json" },
991
+ });
992
+ }
993
+
994
+ if (this.mutations[body.operation]) {
995
+ const result = await this.executeMutation(body.operation, body.input);
996
+ return new Response(JSON.stringify({ data: result }), {
997
+ headers: { "Content-Type": "application/json" },
998
+ });
999
+ }
1000
+
1001
+ return new Response(JSON.stringify({ error: `Operation not found: ${body.operation}` }), {
1002
+ status: 404,
1003
+ headers: { "Content-Type": "application/json" },
1004
+ });
1005
+ } catch (error) {
1006
+ return new Response(JSON.stringify({ error: String(error) }), {
1007
+ status: 500,
1008
+ headers: { "Content-Type": "application/json" },
1009
+ });
1010
+ }
1011
+ }
1012
+
1013
+ return new Response("Method not allowed", { status: 405 });
1014
+ }
1015
+
1016
+ // ===========================================================================
1017
+ // Server Lifecycle
1018
+ // ===========================================================================
1019
+
1020
+ async listen(port: number): Promise<void> {
1021
+ this.server = Bun.serve({
1022
+ port,
1023
+ fetch: (req, server) => {
1024
+ if (server.upgrade(req)) {
1025
+ return;
1026
+ }
1027
+ return this.handleRequest(req);
1028
+ },
1029
+ websocket: {
1030
+ message: (ws, message) => {
1031
+ const conn = this.findConnectionByWs(ws);
1032
+ if (conn) {
1033
+ this.handleMessage(conn, String(message));
1034
+ }
1035
+ },
1036
+ close: (ws) => {
1037
+ const conn = this.findConnectionByWs(ws);
1038
+ if (conn) {
1039
+ this.handleDisconnect(conn);
1040
+ }
1041
+ },
1042
+ },
1043
+ });
1044
+
1045
+ console.log(`Lens server listening on port ${port}`);
1046
+ }
1047
+
1048
+ async close(): Promise<void> {
1049
+ if (this.server && typeof (this.server as { stop?: () => void }).stop === "function") {
1050
+ (this.server as { stop: () => void }).stop();
1051
+ }
1052
+ this.server = null;
1053
+ }
1054
+
1055
+ private findConnectionByWs(ws: unknown): ClientConnection | undefined {
1056
+ for (const conn of this.connections.values()) {
1057
+ if (conn.ws === ws) {
1058
+ return conn;
1059
+ }
1060
+ }
1061
+ return undefined;
1062
+ }
1063
+
1064
+ // ===========================================================================
1065
+ // Helper Methods
1066
+ // ===========================================================================
1067
+
1068
+ private getEntityNameFromOutput(output: unknown): string {
1069
+ if (!output) return "unknown";
1070
+ if (typeof output === "object" && output !== null) {
1071
+ // Check for _name (new API) or name (backward compat)
1072
+ if ("_name" in output) {
1073
+ return (output as { _name: string })._name;
1074
+ }
1075
+ if ("name" in output) {
1076
+ return (output as { name: string }).name;
1077
+ }
1078
+ }
1079
+ if (Array.isArray(output) && output.length > 0) {
1080
+ const first = output[0];
1081
+ if (typeof first === "object" && first !== null) {
1082
+ if ("_name" in first) {
1083
+ return (first as { _name: string })._name;
1084
+ }
1085
+ if ("name" in first) {
1086
+ return (first as { name: string }).name;
1087
+ }
1088
+ }
1089
+ }
1090
+ return "unknown";
1091
+ }
1092
+
1093
+ private getEntityNameFromMutation(name: string): string {
1094
+ const mutationDef = this.mutations[name];
1095
+ if (!mutationDef) return "unknown";
1096
+ return this.getEntityNameFromOutput(mutationDef._output);
1097
+ }
1098
+
1099
+ private extractEntities(
1100
+ entityName: string,
1101
+ data: unknown,
1102
+ ): Array<{ entity: string; id: string; entityData: Record<string, unknown> }> {
1103
+ const results: Array<{ entity: string; id: string; entityData: Record<string, unknown> }> = [];
1104
+
1105
+ if (!data) return results;
1106
+
1107
+ if (Array.isArray(data)) {
1108
+ for (const item of data) {
1109
+ if (item && typeof item === "object" && "id" in item) {
1110
+ results.push({
1111
+ entity: entityName,
1112
+ id: String((item as { id: unknown }).id),
1113
+ entityData: item as Record<string, unknown>,
1114
+ });
1115
+ }
1116
+ }
1117
+ } else if (typeof data === "object" && "id" in data) {
1118
+ results.push({
1119
+ entity: entityName,
1120
+ id: String((data as { id: unknown }).id),
1121
+ entityData: data as Record<string, unknown>,
1122
+ });
1123
+ }
1124
+
1125
+ return results;
1126
+ }
1127
+
1128
+ private applySelection(data: unknown, fields: string[] | "*" | SelectionObject): unknown {
1129
+ if (fields === "*" || !data) return data;
1130
+
1131
+ if (Array.isArray(data)) {
1132
+ return data.map((item) => this.applySelectionToObject(item, fields));
1133
+ }
1134
+
1135
+ return this.applySelectionToObject(data, fields);
1136
+ }
1137
+
1138
+ private applySelectionToObject(
1139
+ data: unknown,
1140
+ fields: string[] | SelectionObject,
1141
+ ): Record<string, unknown> | null {
1142
+ if (!data || typeof data !== "object") return null;
1143
+
1144
+ const result: Record<string, unknown> = {};
1145
+ const obj = data as Record<string, unknown>;
1146
+
1147
+ // Always include id
1148
+ if ("id" in obj) {
1149
+ result.id = obj.id;
1150
+ }
1151
+
1152
+ // Handle string array (simple field list)
1153
+ if (Array.isArray(fields)) {
1154
+ for (const field of fields) {
1155
+ if (field in obj) {
1156
+ result[field] = obj[field];
1157
+ }
1158
+ }
1159
+ return result;
1160
+ }
1161
+
1162
+ // Handle SelectionObject (nested selection)
1163
+ for (const [key, value] of Object.entries(fields)) {
1164
+ if (value === false) continue;
1165
+
1166
+ const dataValue = obj[key];
1167
+
1168
+ if (value === true) {
1169
+ // Simple field selection
1170
+ result[key] = dataValue;
1171
+ } else if (typeof value === "object" && value !== null) {
1172
+ // Nested selection (relations or nested select)
1173
+ const nestedSelect = (value as { select?: SelectionObject }).select ?? value;
1174
+
1175
+ if (Array.isArray(dataValue)) {
1176
+ // HasMany relation
1177
+ result[key] = dataValue.map((item) =>
1178
+ this.applySelectionToObject(item, nestedSelect as SelectionObject),
1179
+ );
1180
+ } else if (dataValue !== null && typeof dataValue === "object") {
1181
+ // HasOne/BelongsTo relation
1182
+ result[key] = this.applySelectionToObject(dataValue, nestedSelect as SelectionObject);
1183
+ } else {
1184
+ result[key] = dataValue;
1185
+ }
1186
+ }
1187
+ }
1188
+
1189
+ return result;
1190
+ }
1191
+
1192
+ // ===========================================================================
1193
+ // Entity Resolver Execution
1194
+ // ===========================================================================
1195
+
1196
+ /**
1197
+ * Execute entity resolvers for nested data.
1198
+ * Processes the selection object and resolves relation fields.
1199
+ */
1200
+ private async executeEntityResolvers<T>(
1201
+ entityName: string,
1202
+ data: T,
1203
+ select?: SelectionObject,
1204
+ ): Promise<T> {
1205
+ if (!data || !select || !this.resolvers) return data;
1206
+
1207
+ const result = { ...(data as Record<string, unknown>) };
1208
+
1209
+ for (const [fieldName, fieldSelect] of Object.entries(select)) {
1210
+ if (fieldSelect === false || fieldSelect === true) continue;
1211
+
1212
+ // Check if this field has an entity resolver
1213
+ const resolver = this.resolvers.getResolver(entityName, fieldName);
1214
+ if (!resolver) continue;
1215
+
1216
+ // Execute resolver (with batching if available)
1217
+ if (isBatchResolver(resolver)) {
1218
+ // Use DataLoader for batching
1219
+ const loaderKey = `${entityName}.${fieldName}`;
1220
+ if (!this.loaders.has(loaderKey)) {
1221
+ this.loaders.set(
1222
+ loaderKey,
1223
+ new DataLoader(async (parents: unknown[]) => {
1224
+ return resolver.batch(parents);
1225
+ }),
1226
+ );
1227
+ }
1228
+ const loader = this.loaders.get(loaderKey)!;
1229
+ result[fieldName] = await loader.load(data);
1230
+ } else {
1231
+ // Simple resolver
1232
+ result[fieldName] = await resolver(data);
1233
+ }
1234
+
1235
+ // Recursively resolve nested selections
1236
+ const nestedSelect = (fieldSelect as { select?: SelectionObject }).select;
1237
+ if (nestedSelect && result[fieldName]) {
1238
+ const relationData = result[fieldName];
1239
+ // Get target entity name from the entity definition if available
1240
+ const targetEntity = this.getRelationTargetEntity(entityName, fieldName);
1241
+
1242
+ if (Array.isArray(relationData)) {
1243
+ result[fieldName] = await Promise.all(
1244
+ relationData.map((item) =>
1245
+ this.executeEntityResolvers(targetEntity, item, nestedSelect),
1246
+ ),
1247
+ );
1248
+ } else {
1249
+ result[fieldName] = await this.executeEntityResolvers(
1250
+ targetEntity,
1251
+ relationData,
1252
+ nestedSelect,
1253
+ );
1254
+ }
1255
+ }
1256
+ }
1257
+
1258
+ return result as T;
1259
+ }
1260
+
1261
+ /**
1262
+ * Get target entity name for a relation field.
1263
+ */
1264
+ private getRelationTargetEntity(entityName: string, fieldName: string): string {
1265
+ const entityDef = this.entities[entityName];
1266
+ if (!entityDef) return fieldName; // Fallback to field name
1267
+
1268
+ // EntityDef has 'fields' property
1269
+ const fields = (entityDef as { fields?: Record<string, FieldType> }).fields;
1270
+ if (!fields) return fieldName;
1271
+
1272
+ const fieldDef = fields[fieldName];
1273
+ if (!fieldDef) return fieldName;
1274
+
1275
+ // Check if it's a relation type
1276
+ if (
1277
+ fieldDef._type === "hasMany" ||
1278
+ fieldDef._type === "hasOne" ||
1279
+ fieldDef._type === "belongsTo"
1280
+ ) {
1281
+ return (fieldDef as unknown as { _target: string })._target ?? fieldName;
1282
+ }
1283
+
1284
+ return fieldName;
1285
+ }
1286
+
1287
+ /**
1288
+ * Serialize entity data for transport.
1289
+ * Auto-calls serialize() on field types (Date → ISO string, etc.)
1290
+ */
1291
+ private serializeEntity(
1292
+ entityName: string,
1293
+ data: Record<string, unknown> | null,
1294
+ ): Record<string, unknown> | null {
1295
+ if (data === null) return null;
1296
+
1297
+ const entityDef = this.entities[entityName];
1298
+ if (!entityDef) return data;
1299
+
1300
+ // EntityDef has 'fields' property
1301
+ const fields = (entityDef as { fields?: Record<string, FieldType> }).fields;
1302
+ if (!fields) return data;
1303
+
1304
+ const result: Record<string, unknown> = {};
1305
+
1306
+ for (const [fieldName, value] of Object.entries(data)) {
1307
+ const fieldType = fields[fieldName];
1308
+
1309
+ if (!fieldType) {
1310
+ // Field not in schema (extra data from resolver)
1311
+ result[fieldName] = value;
1312
+ continue;
1313
+ }
1314
+
1315
+ // Handle null values
1316
+ if (value === null || value === undefined) {
1317
+ result[fieldName] = value;
1318
+ continue;
1319
+ }
1320
+
1321
+ // Relations: recursively serialize
1322
+ if (
1323
+ fieldType._type === "hasMany" ||
1324
+ fieldType._type === "belongsTo" ||
1325
+ fieldType._type === "hasOne"
1326
+ ) {
1327
+ const targetEntity = (fieldType as { _target?: string })._target;
1328
+ if (targetEntity && Array.isArray(value)) {
1329
+ result[fieldName] = value.map((item) =>
1330
+ this.serializeEntity(targetEntity, item as Record<string, unknown>),
1331
+ );
1332
+ } else if (targetEntity && typeof value === "object") {
1333
+ result[fieldName] = this.serializeEntity(targetEntity, value as Record<string, unknown>);
1334
+ } else {
1335
+ result[fieldName] = value;
1336
+ }
1337
+ continue;
1338
+ }
1339
+
1340
+ // Scalar field - call serialize() if method exists
1341
+ if (typeof (fieldType as { serialize?: (v: unknown) => unknown }).serialize === "function") {
1342
+ try {
1343
+ result[fieldName] = (fieldType as { serialize: (v: unknown) => unknown }).serialize(
1344
+ value,
1345
+ );
1346
+ } catch (error) {
1347
+ console.warn(`Failed to serialize field ${entityName}.${fieldName}:`, error);
1348
+ result[fieldName] = value;
1349
+ }
1350
+ } else {
1351
+ result[fieldName] = value;
1352
+ }
1353
+ }
1354
+
1355
+ return result;
1356
+ }
1357
+
1358
+ /**
1359
+ * Process query result: execute entity resolvers, apply selection, serialize
1360
+ */
1361
+ private async processQueryResult<T>(
1362
+ queryName: string,
1363
+ data: T,
1364
+ select?: SelectionObject,
1365
+ ): Promise<T> {
1366
+ if (data === null || data === undefined) return data;
1367
+
1368
+ // Determine entity name from query definition's _output
1369
+ const queryDef = this.queries[queryName];
1370
+ const entityName = this.getEntityNameFromOutput(queryDef?._output);
1371
+
1372
+ // Handle array results - process each item
1373
+ if (Array.isArray(data)) {
1374
+ const processedItems = await Promise.all(
1375
+ data.map(async (item) => {
1376
+ let result = item;
1377
+
1378
+ // Execute entity resolvers for nested data
1379
+ if (select && this.resolvers) {
1380
+ result = await this.executeEntityResolvers(entityName, item, select);
1381
+ }
1382
+
1383
+ // Apply field selection
1384
+ if (select) {
1385
+ result = this.applySelection(result, select);
1386
+ }
1387
+
1388
+ // Serialize for transport
1389
+ if (entityName) {
1390
+ return this.serializeEntity(entityName, result as Record<string, unknown>);
1391
+ }
1392
+
1393
+ return result;
1394
+ }),
1395
+ );
1396
+ return processedItems as T;
1397
+ }
1398
+
1399
+ // Single object result
1400
+ let result: T = data;
1401
+
1402
+ // Execute entity resolvers for nested data
1403
+ if (select && this.resolvers) {
1404
+ result = (await this.executeEntityResolvers(entityName, data, select)) as T;
1405
+ }
1406
+
1407
+ // Apply field selection
1408
+ if (select) {
1409
+ result = this.applySelection(result, select) as T;
1410
+ }
1411
+
1412
+ // Serialize for transport
1413
+ if (entityName && typeof result === "object" && result !== null) {
1414
+ return this.serializeEntity(entityName, result as Record<string, unknown>) as T;
1415
+ }
1416
+
1417
+ return result;
1418
+ }
1419
+
1420
+ private computeUpdates(oldData: unknown, newData: unknown): Record<string, Update> | null {
1421
+ if (!oldData || !newData) return null;
1422
+ if (typeof oldData !== "object" || typeof newData !== "object") return null;
1423
+
1424
+ const updates: Record<string, Update> = {};
1425
+ const oldObj = oldData as Record<string, unknown>;
1426
+ const newObj = newData as Record<string, unknown>;
1427
+
1428
+ for (const key of Object.keys(newObj)) {
1429
+ const oldValue = oldObj[key];
1430
+ const newValue = newObj[key];
1431
+
1432
+ if (!this.deepEqual(oldValue, newValue)) {
1433
+ updates[key] = createUpdate(oldValue, newValue);
1434
+ }
1435
+ }
1436
+
1437
+ return Object.keys(updates).length > 0 ? updates : null;
1438
+ }
1439
+
1440
+ private deepEqual(a: unknown, b: unknown): boolean {
1441
+ if (a === b) return true;
1442
+ if (typeof a !== typeof b) return false;
1443
+ if (typeof a !== "object" || a === null || b === null) return false;
1444
+
1445
+ const aObj = a as Record<string, unknown>;
1446
+ const bObj = b as Record<string, unknown>;
1447
+
1448
+ const aKeys = Object.keys(aObj);
1449
+ const bKeys = Object.keys(bObj);
1450
+
1451
+ if (aKeys.length !== bKeys.length) return false;
1452
+
1453
+ for (const key of aKeys) {
1454
+ if (!this.deepEqual(aObj[key], bObj[key])) return false;
1455
+ }
1456
+
1457
+ return true;
1458
+ }
1459
+
1460
+ private clearLoaders(): void {
1461
+ for (const loader of this.loaders.values()) {
1462
+ loader.clear();
1463
+ }
1464
+ this.loaders.clear();
1465
+ }
1466
+ }
1467
+
1468
+ // =============================================================================
1469
+ // Utility
1470
+ // =============================================================================
1471
+
1472
+ function isAsyncIterable<T>(value: unknown): value is AsyncIterable<T> {
1473
+ return value !== null && typeof value === "object" && Symbol.asyncIterator in value;
1474
+ }
1475
+
1476
+ // =============================================================================
1477
+ // Type Inference Utilities (tRPC-style)
1478
+ // =============================================================================
1479
+
1480
+ /**
1481
+ * Infer input type from a query/mutation definition
1482
+ */
1483
+ export type InferInput<T> = T extends QueryDef<infer I, unknown>
1484
+ ? I extends void
1485
+ ? void
1486
+ : I
1487
+ : T extends MutationDef<infer I, unknown>
1488
+ ? I
1489
+ : never;
1490
+
1491
+ /**
1492
+ * Infer output type from a query/mutation definition
1493
+ */
1494
+ export type InferOutput<T> = T extends QueryDef<unknown, infer O>
1495
+ ? O
1496
+ : T extends MutationDef<unknown, infer O>
1497
+ ? O
1498
+ : never;
1499
+
1500
+ /**
1501
+ * API type for client inference
1502
+ * Export this type for client-side type safety
1503
+ *
1504
+ * @example
1505
+ * ```typescript
1506
+ * // Server
1507
+ * const server = createLensServer({ queries, mutations });
1508
+ * export type Api = InferApi<typeof server>;
1509
+ *
1510
+ * // Client (only imports TYPE)
1511
+ * import type { Api } from './server';
1512
+ * const client = createClient<Api>({ links: [...] });
1513
+ * ```
1514
+ */
1515
+ export type InferApi<T extends LensServer> = T extends LensServerImpl<infer Q, infer M>
1516
+ ? { queries: Q; mutations: M }
1517
+ : never;
1518
+
1519
+ // =============================================================================
1520
+ // Factory
1521
+ // =============================================================================
1522
+
1523
+ /**
1524
+ * Create Lens server with Operations API + Optimization Layer
1525
+ */
1526
+ export function createServer<
1527
+ TContext extends ContextValue = ContextValue,
1528
+ Q extends QueriesMap = QueriesMap,
1529
+ M extends MutationsMap = MutationsMap,
1530
+ >(
1531
+ config: LensServerConfig<TContext> & { queries?: Q; mutations?: M },
1532
+ ): LensServer & { _types: { queries: Q; mutations: M } } {
1533
+ const server = new LensServerImpl(config) as LensServerImpl<Q, M>;
1534
+ // Attach type marker for inference (stripped at runtime)
1535
+ return server as unknown as LensServer & { _types: { queries: Q; mutations: M } };
1536
+ }