@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.
@@ -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
+ });
@@ -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
- /** Execute operation - auto-detects query vs mutation */
185
- execute(op: LensOperation): Promise<LensResult>;
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)