@sylphx/lens-server 2.1.0 → 2.3.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.
- package/dist/index.d.ts +14 -3
- package/dist/index.js +299 -100
- package/package.json +2 -2
- package/src/e2e/server.test.ts +81 -61
- package/src/handlers/framework.ts +4 -3
- package/src/handlers/http.ts +7 -4
- package/src/handlers/ws.ts +18 -10
- package/src/server/create.test.ts +701 -47
- package/src/server/create.ts +492 -105
- package/src/server/selection.test.ts +253 -0
- package/src/server/types.ts +15 -2
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sylphx/lens-server - Selection Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for field selection and nested input extraction.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from "bun:test";
|
|
8
|
+
import { applySelection, extractNestedInputs } from "./selection.js";
|
|
9
|
+
|
|
10
|
+
// =============================================================================
|
|
11
|
+
// applySelection Tests
|
|
12
|
+
// =============================================================================
|
|
13
|
+
|
|
14
|
+
describe("applySelection", () => {
|
|
15
|
+
it("selects simple fields", () => {
|
|
16
|
+
const data = { id: "1", name: "Alice", email: "alice@example.com", age: 30 };
|
|
17
|
+
const select = { name: true, email: true };
|
|
18
|
+
|
|
19
|
+
const result = applySelection(data, select);
|
|
20
|
+
|
|
21
|
+
expect(result).toEqual({ id: "1", name: "Alice", email: "alice@example.com" });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("always includes id field", () => {
|
|
25
|
+
const data = { id: "1", name: "Alice" };
|
|
26
|
+
const select = { name: true };
|
|
27
|
+
|
|
28
|
+
const result = applySelection(data, select);
|
|
29
|
+
|
|
30
|
+
expect(result).toEqual({ id: "1", name: "Alice" });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("handles nested selection with { select: ... }", () => {
|
|
34
|
+
const data = {
|
|
35
|
+
id: "1",
|
|
36
|
+
name: "Alice",
|
|
37
|
+
profile: { avatar: "url", bio: "Hello" },
|
|
38
|
+
};
|
|
39
|
+
const select = {
|
|
40
|
+
name: true,
|
|
41
|
+
profile: { select: { avatar: true } },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const result = applySelection(data, select);
|
|
45
|
+
|
|
46
|
+
expect(result).toEqual({
|
|
47
|
+
id: "1",
|
|
48
|
+
name: "Alice",
|
|
49
|
+
profile: { avatar: "url" },
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("handles nested selection with { input: ..., select: ... }", () => {
|
|
54
|
+
const data = {
|
|
55
|
+
id: "1",
|
|
56
|
+
name: "Alice",
|
|
57
|
+
posts: [
|
|
58
|
+
{ id: "p1", title: "Post 1" },
|
|
59
|
+
{ id: "p2", title: "Post 2" },
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
const select = {
|
|
63
|
+
name: true,
|
|
64
|
+
posts: {
|
|
65
|
+
input: { limit: 5 }, // input is ignored for selection, used for resolver args
|
|
66
|
+
select: { title: true },
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const result = applySelection(data, select);
|
|
71
|
+
|
|
72
|
+
expect(result).toEqual({
|
|
73
|
+
id: "1",
|
|
74
|
+
name: "Alice",
|
|
75
|
+
posts: [
|
|
76
|
+
{ id: "p1", title: "Post 1" },
|
|
77
|
+
{ id: "p2", title: "Post 2" },
|
|
78
|
+
],
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("handles arrays", () => {
|
|
83
|
+
const data = [
|
|
84
|
+
{ id: "1", name: "Alice", email: "alice@example.com" },
|
|
85
|
+
{ id: "2", name: "Bob", email: "bob@example.com" },
|
|
86
|
+
];
|
|
87
|
+
const select = { name: true };
|
|
88
|
+
|
|
89
|
+
const result = applySelection(data, select);
|
|
90
|
+
|
|
91
|
+
expect(result).toEqual([
|
|
92
|
+
{ id: "1", name: "Alice" },
|
|
93
|
+
{ id: "2", name: "Bob" },
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("returns null/undefined as-is", () => {
|
|
98
|
+
expect(applySelection(null, { name: true })).toBeNull();
|
|
99
|
+
expect(applySelection(undefined, { name: true })).toBeUndefined();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("includes whole field when no nested select", () => {
|
|
103
|
+
const data = {
|
|
104
|
+
id: "1",
|
|
105
|
+
profile: { avatar: "url", bio: "Hello" },
|
|
106
|
+
};
|
|
107
|
+
const select = {
|
|
108
|
+
profile: { input: { size: "large" } }, // input only, no select
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const result = applySelection(data, select);
|
|
112
|
+
|
|
113
|
+
expect(result).toEqual({
|
|
114
|
+
id: "1",
|
|
115
|
+
profile: { avatar: "url", bio: "Hello" },
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// =============================================================================
|
|
121
|
+
// extractNestedInputs Tests
|
|
122
|
+
// =============================================================================
|
|
123
|
+
|
|
124
|
+
describe("extractNestedInputs", () => {
|
|
125
|
+
it("extracts input from nested selection", () => {
|
|
126
|
+
const select = {
|
|
127
|
+
name: true,
|
|
128
|
+
posts: {
|
|
129
|
+
input: { limit: 5, published: true },
|
|
130
|
+
select: { title: true },
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const inputs = extractNestedInputs(select);
|
|
135
|
+
|
|
136
|
+
expect(inputs.size).toBe(1);
|
|
137
|
+
expect(inputs.get("posts")).toEqual({ limit: 5, published: true });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("extracts inputs at multiple levels", () => {
|
|
141
|
+
const select = {
|
|
142
|
+
name: true,
|
|
143
|
+
posts: {
|
|
144
|
+
input: { limit: 5 },
|
|
145
|
+
select: {
|
|
146
|
+
title: true,
|
|
147
|
+
comments: {
|
|
148
|
+
input: { limit: 3 },
|
|
149
|
+
select: { body: true },
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const inputs = extractNestedInputs(select);
|
|
156
|
+
|
|
157
|
+
expect(inputs.size).toBe(2);
|
|
158
|
+
expect(inputs.get("posts")).toEqual({ limit: 5 });
|
|
159
|
+
expect(inputs.get("posts.comments")).toEqual({ limit: 3 });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("returns empty map when no nested inputs", () => {
|
|
163
|
+
const select = {
|
|
164
|
+
name: true,
|
|
165
|
+
posts: { select: { title: true } },
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const inputs = extractNestedInputs(select);
|
|
169
|
+
|
|
170
|
+
expect(inputs.size).toBe(0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("handles deeply nested inputs", () => {
|
|
174
|
+
const select = {
|
|
175
|
+
author: {
|
|
176
|
+
input: { includeDeleted: false },
|
|
177
|
+
select: {
|
|
178
|
+
posts: {
|
|
179
|
+
input: { status: "published" },
|
|
180
|
+
select: {
|
|
181
|
+
comments: {
|
|
182
|
+
input: { limit: 10 },
|
|
183
|
+
select: {
|
|
184
|
+
replies: {
|
|
185
|
+
input: { depth: 2 },
|
|
186
|
+
select: { body: true },
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const inputs = extractNestedInputs(select);
|
|
197
|
+
|
|
198
|
+
expect(inputs.size).toBe(4);
|
|
199
|
+
expect(inputs.get("author")).toEqual({ includeDeleted: false });
|
|
200
|
+
expect(inputs.get("author.posts")).toEqual({ status: "published" });
|
|
201
|
+
expect(inputs.get("author.posts.comments")).toEqual({ limit: 10 });
|
|
202
|
+
expect(inputs.get("author.posts.comments.replies")).toEqual({ depth: 2 });
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("handles mixed selection (some with input, some without)", () => {
|
|
206
|
+
const select = {
|
|
207
|
+
name: true,
|
|
208
|
+
posts: {
|
|
209
|
+
input: { limit: 5 },
|
|
210
|
+
select: { title: true },
|
|
211
|
+
},
|
|
212
|
+
followers: {
|
|
213
|
+
select: { name: true }, // no input
|
|
214
|
+
},
|
|
215
|
+
settings: true, // simple selection
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const inputs = extractNestedInputs(select);
|
|
219
|
+
|
|
220
|
+
expect(inputs.size).toBe(1);
|
|
221
|
+
expect(inputs.get("posts")).toEqual({ limit: 5 });
|
|
222
|
+
expect(inputs.has("followers")).toBe(false);
|
|
223
|
+
expect(inputs.has("settings")).toBe(false);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("handles input with empty object", () => {
|
|
227
|
+
const select = {
|
|
228
|
+
posts: {
|
|
229
|
+
input: {},
|
|
230
|
+
select: { title: true },
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const inputs = extractNestedInputs(select);
|
|
235
|
+
|
|
236
|
+
// Empty input object is still recorded
|
|
237
|
+
expect(inputs.size).toBe(1);
|
|
238
|
+
expect(inputs.get("posts")).toEqual({});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("handles selection with input but no select", () => {
|
|
242
|
+
const select = {
|
|
243
|
+
posts: {
|
|
244
|
+
input: { limit: 5 },
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const inputs = extractNestedInputs(select);
|
|
249
|
+
|
|
250
|
+
expect(inputs.size).toBe(1);
|
|
251
|
+
expect(inputs.get("posts")).toEqual({ limit: 5 });
|
|
252
|
+
});
|
|
253
|
+
});
|
package/src/server/types.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
EntityDef,
|
|
10
10
|
InferRouterContext,
|
|
11
11
|
MutationDef,
|
|
12
|
+
Observable,
|
|
12
13
|
OptimisticDSL,
|
|
13
14
|
QueryDef,
|
|
14
15
|
Resolvers,
|
|
@@ -181,8 +182,20 @@ export interface WebSocketLike {
|
|
|
181
182
|
export interface LensServer {
|
|
182
183
|
/** Get server metadata for transport handshake */
|
|
183
184
|
getMetadata(): ServerMetadata;
|
|
184
|
-
|
|
185
|
-
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Execute operation - auto-detects query vs mutation.
|
|
188
|
+
*
|
|
189
|
+
* Always returns Observable<LensResult>:
|
|
190
|
+
* - One-shot: emits once, then completes
|
|
191
|
+
* - Streaming: emits multiple times (AsyncIterable or emit-based)
|
|
192
|
+
*
|
|
193
|
+
* Usage:
|
|
194
|
+
* - HTTP: `await firstValueFrom(server.execute(op))`
|
|
195
|
+
* - WS/SSE: `server.execute(op).subscribe(...)`
|
|
196
|
+
* - direct: pass through Observable directly
|
|
197
|
+
*/
|
|
198
|
+
execute(op: LensOperation): Observable<LensResult>;
|
|
186
199
|
|
|
187
200
|
// =========================================================================
|
|
188
201
|
// Subscription Support (Optional - used by WS/SSE handlers)
|