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