@sylphx/lens-server 1.11.3 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,60 @@
1
+ /**
2
+ * @sylphx/lens-server - DataLoader
3
+ *
4
+ * Simple batching utility for N+1 prevention in field resolution.
5
+ * Batches multiple load calls within a single microtask.
6
+ */
7
+
8
+ /**
9
+ * DataLoader batches multiple load calls within a single microtask.
10
+ * Used internally for efficient field resolution.
11
+ */
12
+ export class DataLoader<K, V> {
13
+ private batch: Map<K, { resolve: (v: V | null) => void; reject: (e: Error) => void }[]> =
14
+ new Map();
15
+ private scheduled = false;
16
+
17
+ constructor(private batchFn: (keys: K[]) => Promise<(V | null)[]>) {}
18
+
19
+ async load(key: K): Promise<V | null> {
20
+ return new Promise((resolve, reject) => {
21
+ const existing = this.batch.get(key);
22
+ if (existing) {
23
+ existing.push({ resolve, reject });
24
+ } else {
25
+ this.batch.set(key, [{ resolve, reject }]);
26
+ }
27
+
28
+ if (!this.scheduled) {
29
+ this.scheduled = true;
30
+ queueMicrotask(() => this.flush());
31
+ }
32
+ });
33
+ }
34
+
35
+ private async flush(): Promise<void> {
36
+ this.scheduled = false;
37
+ const batch = this.batch;
38
+ this.batch = new Map();
39
+
40
+ const keys = Array.from(batch.keys());
41
+ if (keys.length === 0) return;
42
+
43
+ try {
44
+ const results = await this.batchFn(keys);
45
+ let i = 0;
46
+ for (const [_key, callbacks] of batch) {
47
+ const result = results[i++];
48
+ for (const { resolve } of callbacks) {
49
+ resolve(result);
50
+ }
51
+ }
52
+ } catch (error) {
53
+ for (const [, callbacks] of batch) {
54
+ for (const { reject } of callbacks) {
55
+ reject(error instanceof Error ? error : new Error(String(error)));
56
+ }
57
+ }
58
+ }
59
+ }
60
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * @sylphx/lens-server - Selection
3
+ *
4
+ * Field selection logic for query results.
5
+ */
6
+
7
+ import type { NestedSelection, SelectionObject } from "./types.js";
8
+
9
+ /**
10
+ * Check if a value is a NestedSelection with input.
11
+ */
12
+ function isNestedSelection(value: unknown): value is NestedSelection {
13
+ return (
14
+ typeof value === "object" &&
15
+ value !== null &&
16
+ ("input" in value || "select" in value) &&
17
+ !Array.isArray(value)
18
+ );
19
+ }
20
+
21
+ /**
22
+ * Extract the select object from a selection value.
23
+ * Handles: true, SelectionObject, { select: ... }, { input: ..., select: ... }
24
+ */
25
+ function extractSelect(value: unknown): SelectionObject | null {
26
+ if (value === true) return null;
27
+ if (typeof value !== "object" || value === null) return null;
28
+
29
+ const obj = value as Record<string, unknown>;
30
+
31
+ // { input?: ..., select?: ... } pattern
32
+ if ("input" in obj || ("select" in obj && typeof obj.select === "object")) {
33
+ return (obj.select as SelectionObject) ?? null;
34
+ }
35
+
36
+ // { select: ... } pattern (without input)
37
+ if ("select" in obj && typeof obj.select === "object") {
38
+ return obj.select as SelectionObject;
39
+ }
40
+
41
+ // Direct SelectionObject
42
+ return value as SelectionObject;
43
+ }
44
+
45
+ /**
46
+ * Apply field selection to data.
47
+ * Recursively filters data to only include selected fields.
48
+ *
49
+ * Supports:
50
+ * - `field: true` - Include field as-is
51
+ * - `field: { select: {...} }` - Nested selection
52
+ * - `field: { input: {...}, select: {...} }` - Nested with input (input passed to resolvers)
53
+ *
54
+ * @param data - The data to filter
55
+ * @param select - Selection object specifying which fields to include
56
+ * @returns Filtered data with only selected fields
57
+ */
58
+ export function applySelection(data: unknown, select: SelectionObject): unknown {
59
+ if (!data) return data;
60
+
61
+ if (Array.isArray(data)) {
62
+ return data.map((item) => applySelection(item, select));
63
+ }
64
+
65
+ if (typeof data !== "object") return data;
66
+
67
+ const obj = data as Record<string, unknown>;
68
+ const result: Record<string, unknown> = {};
69
+
70
+ // Always include id
71
+ if ("id" in obj) result.id = obj.id;
72
+
73
+ for (const [key, value] of Object.entries(select)) {
74
+ if (!(key in obj)) continue;
75
+
76
+ if (value === true) {
77
+ result[key] = obj[key];
78
+ } else if (typeof value === "object" && value !== null) {
79
+ const nestedSelect = extractSelect(value);
80
+ if (nestedSelect) {
81
+ result[key] = applySelection(obj[key], nestedSelect);
82
+ } else {
83
+ // No nested select means include the whole field
84
+ result[key] = obj[key];
85
+ }
86
+ }
87
+ }
88
+
89
+ return result;
90
+ }
91
+
92
+ /**
93
+ * Extract nested inputs from a selection object.
94
+ * Returns a map of field paths to their input params.
95
+ * Used by resolvers to fetch nested data with the right params.
96
+ */
97
+ export function extractNestedInputs(
98
+ select: SelectionObject,
99
+ prefix = "",
100
+ ): Map<string, Record<string, unknown>> {
101
+ const inputs = new Map<string, Record<string, unknown>>();
102
+
103
+ for (const [key, value] of Object.entries(select)) {
104
+ const path = prefix ? `${prefix}.${key}` : key;
105
+
106
+ if (isNestedSelection(value) && value.input) {
107
+ inputs.set(path, value.input);
108
+ }
109
+
110
+ // Recurse into nested selections
111
+ if (typeof value === "object" && value !== null) {
112
+ const nestedSelect = extractSelect(value);
113
+ if (nestedSelect) {
114
+ const nestedInputs = extractNestedInputs(nestedSelect, path);
115
+ for (const [nestedPath, nestedInput] of nestedInputs) {
116
+ inputs.set(nestedPath, nestedInput);
117
+ }
118
+ }
119
+ }
120
+ }
121
+
122
+ return inputs;
123
+ }
@@ -0,0 +1,306 @@
1
+ /**
2
+ * @sylphx/lens-server - Server Types
3
+ *
4
+ * Type definitions for Lens server configuration and operations.
5
+ */
6
+
7
+ import type {
8
+ ContextValue,
9
+ EntityDef,
10
+ InferRouterContext,
11
+ MutationDef,
12
+ OptimisticDSL,
13
+ QueryDef,
14
+ Resolvers,
15
+ RouterDef,
16
+ } from "@sylphx/lens-core";
17
+ import type { PluginManager, ServerPlugin } from "../plugin/types.js";
18
+
19
+ // =============================================================================
20
+ // Selection Types
21
+ // =============================================================================
22
+
23
+ /**
24
+ * Nested selection object with optional input.
25
+ * Used for relations that need their own params.
26
+ */
27
+ export interface NestedSelection {
28
+ /** Input/params for this nested query */
29
+ input?: Record<string, unknown>;
30
+ /** Field selection for this nested query */
31
+ select?: SelectionObject;
32
+ }
33
+
34
+ /**
35
+ * Selection object for field selection.
36
+ * Supports:
37
+ * - `true` - Select this field
38
+ * - `{ select: {...} }` - Nested selection only
39
+ * - `{ input: {...}, select?: {...} }` - Nested with input params
40
+ */
41
+ export interface SelectionObject {
42
+ [key: string]: boolean | SelectionObject | { select: SelectionObject } | NestedSelection;
43
+ }
44
+
45
+ // =============================================================================
46
+ // Map Types
47
+ // =============================================================================
48
+
49
+ /** Entity map type */
50
+ export type EntitiesMap = Record<string, EntityDef<string, any>>;
51
+
52
+ /** Queries map type */
53
+ export type QueriesMap = Record<string, QueryDef<unknown, unknown>>;
54
+
55
+ /** Mutations map type */
56
+ export type MutationsMap = Record<string, MutationDef<unknown, unknown>>;
57
+
58
+ // =============================================================================
59
+ // Operation Metadata
60
+ // =============================================================================
61
+
62
+ /** Operation metadata for handshake */
63
+ export interface OperationMeta {
64
+ type: "query" | "mutation" | "subscription";
65
+ optimistic?: OptimisticDSL;
66
+ }
67
+
68
+ /** Nested operations structure for handshake */
69
+ export type OperationsMap = {
70
+ [key: string]: OperationMeta | OperationsMap;
71
+ };
72
+
73
+ // =============================================================================
74
+ // Logger
75
+ // =============================================================================
76
+
77
+ /** Logger interface */
78
+ export interface LensLogger {
79
+ info?: (message: string, ...args: unknown[]) => void;
80
+ warn?: (message: string, ...args: unknown[]) => void;
81
+ error?: (message: string, ...args: unknown[]) => void;
82
+ }
83
+
84
+ // =============================================================================
85
+ // Server Configuration
86
+ // =============================================================================
87
+
88
+ /** Server configuration */
89
+ export interface LensServerConfig<
90
+ TContext extends ContextValue = ContextValue,
91
+ TRouter extends RouterDef = RouterDef,
92
+ > {
93
+ /** Entity definitions */
94
+ entities?: EntitiesMap | undefined;
95
+ /** Router definition (namespaced operations) */
96
+ router?: TRouter | undefined;
97
+ /** Query definitions (flat) */
98
+ queries?: QueriesMap | undefined;
99
+ /** Mutation definitions (flat) */
100
+ mutations?: MutationsMap | undefined;
101
+ /** Field resolvers array */
102
+ resolvers?: Resolvers | undefined;
103
+ /** Logger for server messages (default: silent) */
104
+ logger?: LensLogger | undefined;
105
+ /** Context factory */
106
+ context?: ((req?: unknown) => TContext | Promise<TContext>) | undefined;
107
+ /** Server version */
108
+ version?: string | undefined;
109
+ /**
110
+ * Server-level plugins for subscription lifecycle and state management.
111
+ * Plugins are processed at the server level, not adapter level.
112
+ */
113
+ plugins?: ServerPlugin[] | undefined;
114
+ }
115
+
116
+ // =============================================================================
117
+ // Server Metadata
118
+ // =============================================================================
119
+
120
+ /** Server metadata for transport handshake */
121
+ export interface ServerMetadata {
122
+ version: string;
123
+ operations: OperationsMap;
124
+ }
125
+
126
+ // =============================================================================
127
+ // Operations
128
+ // =============================================================================
129
+
130
+ /** Operation for execution */
131
+ export interface LensOperation {
132
+ path: string;
133
+ input?: unknown;
134
+ }
135
+
136
+ /** Result from operation execution */
137
+ export interface LensResult<T = unknown> {
138
+ data?: T;
139
+ error?: Error;
140
+ }
141
+
142
+ // =============================================================================
143
+ // Client Communication
144
+ // =============================================================================
145
+
146
+ /**
147
+ * Client send function type for subscription updates.
148
+ */
149
+ export type ClientSendFn = (message: unknown) => void;
150
+
151
+ /** WebSocket interface for adapters */
152
+ export interface WebSocketLike {
153
+ send(data: string): void;
154
+ close(): void;
155
+ onmessage?: ((event: { data: string }) => void) | null;
156
+ onclose?: (() => void) | null;
157
+ onerror?: ((error: unknown) => void) | null;
158
+ }
159
+
160
+ // =============================================================================
161
+ // Server Interface
162
+ // =============================================================================
163
+
164
+ /**
165
+ * Lens server interface
166
+ *
167
+ * Core methods:
168
+ * - getMetadata() - Server metadata for transport handshake
169
+ * - execute() - Execute any operation
170
+ *
171
+ * Subscription support (used by adapters):
172
+ * - addClient() / removeClient() - Client connection management
173
+ * - subscribe() / unsubscribe() - Subscription lifecycle
174
+ * - send() - Send data to client (runs through plugin hooks)
175
+ * - broadcast() - Broadcast to all entity subscribers
176
+ * - handleReconnect() - Handle client reconnection
177
+ *
178
+ * The server handles all business logic including state management (via plugins).
179
+ * Handlers are pure protocol translators that call these methods.
180
+ */
181
+ export interface LensServer {
182
+ /** Get server metadata for transport handshake */
183
+ getMetadata(): ServerMetadata;
184
+ /** Execute operation - auto-detects query vs mutation */
185
+ execute(op: LensOperation): Promise<LensResult>;
186
+
187
+ // =========================================================================
188
+ // Subscription Support (Optional - used by WS/SSE handlers)
189
+ // =========================================================================
190
+
191
+ /**
192
+ * Register a client connection.
193
+ * Call when a client connects via WebSocket/SSE.
194
+ */
195
+ addClient(clientId: string, send: ClientSendFn): Promise<boolean>;
196
+
197
+ /**
198
+ * Remove a client connection.
199
+ * Call when a client disconnects.
200
+ */
201
+ removeClient(clientId: string, subscriptionCount: number): void;
202
+
203
+ /**
204
+ * Subscribe a client to an entity.
205
+ * Runs plugin hooks and sets up state tracking (if clientState is enabled).
206
+ */
207
+ subscribe(ctx: import("../plugin/types.js").SubscribeContext): Promise<boolean>;
208
+
209
+ /**
210
+ * Unsubscribe a client from an entity.
211
+ * Runs plugin hooks and cleans up state tracking.
212
+ */
213
+ unsubscribe(ctx: import("../plugin/types.js").UnsubscribeContext): void;
214
+
215
+ /**
216
+ * Send data to a client for a specific subscription.
217
+ * Runs through plugin hooks (beforeSend/afterSend) for optimization.
218
+ */
219
+ send(
220
+ clientId: string,
221
+ subscriptionId: string,
222
+ entity: string,
223
+ entityId: string,
224
+ data: Record<string, unknown>,
225
+ isInitial: boolean,
226
+ ): Promise<void>;
227
+
228
+ /**
229
+ * Broadcast data to all subscribers of an entity.
230
+ * Runs through plugin hooks for each subscriber.
231
+ */
232
+ broadcast(entity: string, entityId: string, data: Record<string, unknown>): Promise<void>;
233
+
234
+ /**
235
+ * Handle a reconnection request from a client.
236
+ * Uses plugin hooks (onReconnect) for reconnection logic.
237
+ */
238
+ handleReconnect(
239
+ ctx: import("../plugin/types.js").ReconnectContext,
240
+ ): Promise<import("../plugin/types.js").ReconnectHookResult[] | null>;
241
+
242
+ /**
243
+ * Update subscribed fields for a client's subscription.
244
+ * Runs plugin hooks (onUpdateFields) to sync state.
245
+ */
246
+ updateFields(ctx: import("../plugin/types.js").UpdateFieldsContext): Promise<void>;
247
+
248
+ /**
249
+ * Get the plugin manager for direct hook access.
250
+ */
251
+ getPluginManager(): PluginManager;
252
+ }
253
+
254
+ // =============================================================================
255
+ // Type Inference
256
+ // =============================================================================
257
+
258
+ import type { FieldType } from "@sylphx/lens-core";
259
+
260
+ export type InferInput<T> =
261
+ T extends QueryDef<infer I, any> ? I : T extends MutationDef<infer I, any> ? I : never;
262
+
263
+ export type InferOutput<T> =
264
+ T extends QueryDef<any, infer O>
265
+ ? O
266
+ : T extends MutationDef<any, infer O>
267
+ ? O
268
+ : T extends FieldType<infer F>
269
+ ? F
270
+ : never;
271
+
272
+ export type InferApi<T> = T extends { _types: infer Types } ? Types : never;
273
+
274
+ export type ServerConfigWithInferredContext<
275
+ TRouter extends RouterDef,
276
+ Q extends QueriesMap = QueriesMap,
277
+ M extends MutationsMap = MutationsMap,
278
+ > = {
279
+ router: TRouter;
280
+ entities?: EntitiesMap;
281
+ queries?: Q;
282
+ mutations?: M;
283
+ resolvers?: Resolvers;
284
+ logger?: LensLogger;
285
+ context?: () => InferRouterContext<TRouter> | Promise<InferRouterContext<TRouter>>;
286
+ version?: string;
287
+ /** Server-level plugins (clientState, etc.) */
288
+ plugins?: ServerPlugin[];
289
+ };
290
+
291
+ export type ServerConfigLegacy<
292
+ TContext extends ContextValue,
293
+ Q extends QueriesMap = QueriesMap,
294
+ M extends MutationsMap = MutationsMap,
295
+ > = {
296
+ router?: RouterDef | undefined;
297
+ entities?: EntitiesMap;
298
+ queries?: Q;
299
+ mutations?: M;
300
+ resolvers?: Resolvers;
301
+ logger?: LensLogger;
302
+ context?: () => TContext | Promise<TContext>;
303
+ version?: string;
304
+ /** Server-level plugins (clientState, etc.) */
305
+ plugins?: ServerPlugin[];
306
+ };