@sylphx/lens-server 1.11.3 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1244 -260
- package/dist/index.js +1700 -1158
- package/package.json +2 -2
- package/src/context/index.test.ts +425 -0
- package/src/context/index.ts +90 -0
- package/src/e2e/server.test.ts +215 -433
- package/src/handlers/framework.ts +294 -0
- package/src/handlers/http.test.ts +215 -0
- package/src/handlers/http.ts +189 -0
- package/src/handlers/index.ts +55 -0
- package/src/handlers/unified.ts +114 -0
- package/src/handlers/ws-types.ts +126 -0
- package/src/handlers/ws.ts +669 -0
- package/src/index.ts +127 -24
- package/src/plugin/index.ts +41 -0
- package/src/plugin/op-log.ts +286 -0
- package/src/plugin/optimistic.ts +375 -0
- package/src/plugin/types.ts +551 -0
- package/src/reconnect/index.ts +9 -0
- package/src/reconnect/operation-log.test.ts +480 -0
- package/src/reconnect/operation-log.ts +450 -0
- package/src/server/create.test.ts +256 -2193
- package/src/server/create.ts +285 -1481
- package/src/server/dataloader.ts +60 -0
- package/src/server/selection.ts +44 -0
- package/src/server/types.ts +289 -0
- package/src/sse/handler.ts +123 -56
- package/src/state/index.ts +9 -11
- package/src/storage/index.ts +26 -0
- package/src/storage/memory.ts +279 -0
- package/src/storage/types.ts +205 -0
- package/src/state/graph-state-manager.test.ts +0 -1105
- package/src/state/graph-state-manager.ts +0 -890
package/src/server/create.ts
CHANGED
|
@@ -1,424 +1,103 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @sylphx/lens-server - Lens Server
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* -
|
|
8
|
-
* -
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
export type
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
//
|
|
85
|
+
// Helper Functions
|
|
338
86
|
// =============================================================================
|
|
339
87
|
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
532
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1138
|
-
//
|
|
1139
|
-
|
|
1140
|
-
async handleRequest(req: Request): Promise<Response> {
|
|
1141
|
-
const url = new URL(req.url);
|
|
301
|
+
// =========================================================================
|
|
302
|
+
// Result Processing
|
|
303
|
+
// =========================================================================
|
|
1142
304
|
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
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
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1235
|
-
|
|
328
|
+
const obj = data as Record<string, unknown>;
|
|
329
|
+
const typeName = this.getTypeName(obj);
|
|
330
|
+
if (!typeName) return data;
|
|
1236
331
|
|
|
1237
|
-
|
|
1238
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
1269
|
-
|
|
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
|
-
|
|
340
|
+
// Skip exposed fields
|
|
341
|
+
if (resolverDef.isExposed(field)) continue;
|
|
1275
342
|
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
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
|
-
|
|
1301
|
-
|
|
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
|
|
357
|
+
return result as T;
|
|
1305
358
|
}
|
|
1306
359
|
|
|
1307
|
-
private
|
|
1308
|
-
|
|
1309
|
-
|
|
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
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
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
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1457
|
-
|
|
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
|
-
|
|
1478
|
-
|
|
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
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
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
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
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
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
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
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
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
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
return
|
|
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
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
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
|
-
|
|
1614
|
-
|
|
471
|
+
async subscribe(ctx: SubscribeContext): Promise<boolean> {
|
|
472
|
+
return this.pluginManager.runOnSubscribe(ctx);
|
|
473
|
+
}
|
|
1615
474
|
|
|
1616
|
-
|
|
1617
|
-
|
|
475
|
+
unsubscribe(ctx: UnsubscribeContext): void {
|
|
476
|
+
this.pluginManager.runOnUnsubscribe(ctx);
|
|
477
|
+
}
|
|
1618
478
|
|
|
1619
|
-
|
|
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
|
-
|
|
1622
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1629
|
-
|
|
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
|
-
|
|
1638
|
-
|
|
517
|
+
async updateFields(ctx: UpdateFieldsContext): Promise<void> {
|
|
518
|
+
await this.pluginManager.runOnUpdateFields(ctx);
|
|
519
|
+
}
|
|
1639
520
|
|
|
1640
|
-
|
|
1641
|
-
|
|
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
|
-
*
|
|
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
|
-
* //
|
|
1733
|
-
* const
|
|
1734
|
-
*
|
|
1735
|
-
*
|
|
1736
|
-
*
|
|
1737
|
-
*
|
|
1738
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
};
|