@sylphx/lens-server 1.11.3 → 2.1.0

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