@sylphx/lens-server 2.3.1 → 2.4.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 +241 -23
- package/dist/index.js +373 -24
- package/package.json +1 -1
- package/src/handlers/framework.ts +17 -4
- package/src/handlers/http.test.ts +227 -2
- package/src/handlers/http.ts +223 -22
- package/src/handlers/index.ts +2 -0
- package/src/handlers/ws-types.ts +39 -0
- package/src/handlers/ws.test.ts +559 -0
- package/src/handlers/ws.ts +99 -0
- package/src/index.ts +21 -0
- package/src/logging/index.ts +20 -0
- package/src/logging/structured-logger.test.ts +367 -0
- package/src/logging/structured-logger.ts +335 -0
- package/src/server/create.test.ts +198 -0
- package/src/server/create.ts +90 -10
- package/src/server/types.ts +1 -1
|
@@ -1076,3 +1076,201 @@ describe("field resolvers", () => {
|
|
|
1076
1076
|
subscription.unsubscribe();
|
|
1077
1077
|
});
|
|
1078
1078
|
});
|
|
1079
|
+
|
|
1080
|
+
// =============================================================================
|
|
1081
|
+
// Observable Completion Tests
|
|
1082
|
+
// =============================================================================
|
|
1083
|
+
|
|
1084
|
+
describe("observable behavior", () => {
|
|
1085
|
+
it("delivers initial result immediately for queries", async () => {
|
|
1086
|
+
const simpleQuery = query()
|
|
1087
|
+
.input(z.object({ id: z.string() }))
|
|
1088
|
+
.resolve(({ input }) => ({ id: input.id, name: "Test" }));
|
|
1089
|
+
|
|
1090
|
+
const server = createApp({ queries: { simpleQuery } });
|
|
1091
|
+
|
|
1092
|
+
const result = await firstValueFrom(
|
|
1093
|
+
server.execute({
|
|
1094
|
+
path: "simpleQuery",
|
|
1095
|
+
input: { id: "1" },
|
|
1096
|
+
}),
|
|
1097
|
+
);
|
|
1098
|
+
|
|
1099
|
+
expect(result.data).toEqual({ id: "1", name: "Test" });
|
|
1100
|
+
expect(result.error).toBeUndefined();
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
it("keeps subscription open for potential emit", async () => {
|
|
1104
|
+
type EmitFn = (data: unknown) => void;
|
|
1105
|
+
let capturedEmit: EmitFn | undefined;
|
|
1106
|
+
|
|
1107
|
+
const liveQuery = query()
|
|
1108
|
+
.input(z.object({ id: z.string() }))
|
|
1109
|
+
.resolve(({ input, ctx }) => {
|
|
1110
|
+
capturedEmit = ctx.emit as EmitFn;
|
|
1111
|
+
return { id: input.id, name: "Initial" };
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
const server = createApp({ queries: { liveQuery } });
|
|
1115
|
+
|
|
1116
|
+
const results: unknown[] = [];
|
|
1117
|
+
const subscription = server
|
|
1118
|
+
.execute({
|
|
1119
|
+
path: "liveQuery",
|
|
1120
|
+
input: { id: "1" },
|
|
1121
|
+
})
|
|
1122
|
+
.subscribe({
|
|
1123
|
+
next: (value) => results.push(value),
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1127
|
+
expect(results.length).toBe(1);
|
|
1128
|
+
|
|
1129
|
+
// Emit should still work (subscription is open)
|
|
1130
|
+
capturedEmit!({ id: "1", name: "Updated" });
|
|
1131
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1132
|
+
|
|
1133
|
+
expect(results.length).toBe(2);
|
|
1134
|
+
subscription.unsubscribe();
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
it("delivers mutation result via observable", async () => {
|
|
1138
|
+
const testMutation = mutation()
|
|
1139
|
+
.input(z.object({ name: z.string() }))
|
|
1140
|
+
.resolve(({ input }) => ({ id: "new", name: input.name }));
|
|
1141
|
+
|
|
1142
|
+
const server = createApp({ mutations: { testMutation } });
|
|
1143
|
+
|
|
1144
|
+
const result = await firstValueFrom(
|
|
1145
|
+
server.execute({
|
|
1146
|
+
path: "testMutation",
|
|
1147
|
+
input: { name: "Test" },
|
|
1148
|
+
}),
|
|
1149
|
+
);
|
|
1150
|
+
|
|
1151
|
+
expect(result.data).toEqual({ id: "new", name: "Test" });
|
|
1152
|
+
});
|
|
1153
|
+
|
|
1154
|
+
it("can be unsubscribed", async () => {
|
|
1155
|
+
let resolverCalls = 0;
|
|
1156
|
+
|
|
1157
|
+
const simpleQuery = query()
|
|
1158
|
+
.input(z.object({ id: z.string() }))
|
|
1159
|
+
.resolve(({ input }) => {
|
|
1160
|
+
resolverCalls++;
|
|
1161
|
+
return { id: input.id };
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
const server = createApp({ queries: { simpleQuery } });
|
|
1165
|
+
|
|
1166
|
+
const subscription = server
|
|
1167
|
+
.execute({
|
|
1168
|
+
path: "simpleQuery",
|
|
1169
|
+
input: { id: "1" },
|
|
1170
|
+
})
|
|
1171
|
+
.subscribe({});
|
|
1172
|
+
|
|
1173
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1174
|
+
subscription.unsubscribe();
|
|
1175
|
+
|
|
1176
|
+
// Should have been called exactly once
|
|
1177
|
+
expect(resolverCalls).toBe(1);
|
|
1178
|
+
});
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
// =============================================================================
|
|
1182
|
+
// Backpressure Tests
|
|
1183
|
+
// =============================================================================
|
|
1184
|
+
|
|
1185
|
+
describe("emit backpressure", () => {
|
|
1186
|
+
it("handles rapid emit calls without losing data", async () => {
|
|
1187
|
+
type EmitFn = (data: unknown) => void;
|
|
1188
|
+
let capturedEmit: EmitFn | undefined;
|
|
1189
|
+
|
|
1190
|
+
const liveQuery = query()
|
|
1191
|
+
.input(z.object({ id: z.string() }))
|
|
1192
|
+
.resolve(({ input, ctx }) => {
|
|
1193
|
+
capturedEmit = ctx.emit as EmitFn;
|
|
1194
|
+
return { id: input.id, count: 0 };
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
const server = createApp({ queries: { liveQuery } });
|
|
1198
|
+
|
|
1199
|
+
const results: unknown[] = [];
|
|
1200
|
+
const subscription = server
|
|
1201
|
+
.execute({
|
|
1202
|
+
path: "liveQuery",
|
|
1203
|
+
input: { id: "1" },
|
|
1204
|
+
})
|
|
1205
|
+
.subscribe({
|
|
1206
|
+
next: (value) => results.push(value),
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1210
|
+
|
|
1211
|
+
// Rapidly emit many values
|
|
1212
|
+
for (let i = 1; i <= 10; i++) {
|
|
1213
|
+
capturedEmit!({ id: "1", count: i });
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// Wait for all emits to process
|
|
1217
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
1218
|
+
|
|
1219
|
+
// Should have received all unique values
|
|
1220
|
+
// Note: deduplication may reduce count if emit is too fast
|
|
1221
|
+
expect(results.length).toBeGreaterThan(1);
|
|
1222
|
+
|
|
1223
|
+
// The last result should have count = 10
|
|
1224
|
+
const lastResult = results[results.length - 1] as { data: { count: number } };
|
|
1225
|
+
expect(lastResult.data.count).toBe(10);
|
|
1226
|
+
|
|
1227
|
+
subscription.unsubscribe();
|
|
1228
|
+
});
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
// =============================================================================
|
|
1232
|
+
// Observable Error Handling Tests
|
|
1233
|
+
// =============================================================================
|
|
1234
|
+
|
|
1235
|
+
describe("observable error handling", () => {
|
|
1236
|
+
it("propagates resolver errors to observer", async () => {
|
|
1237
|
+
const errorQuery = query()
|
|
1238
|
+
.input(z.object({ id: z.string() }))
|
|
1239
|
+
.resolve(() => {
|
|
1240
|
+
throw new Error("Test error");
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
const server = createApp({ queries: { errorQuery } });
|
|
1244
|
+
|
|
1245
|
+
const result = await firstValueFrom(
|
|
1246
|
+
server.execute({
|
|
1247
|
+
path: "errorQuery",
|
|
1248
|
+
input: { id: "1" },
|
|
1249
|
+
}),
|
|
1250
|
+
);
|
|
1251
|
+
|
|
1252
|
+
expect(result.error).toBeDefined();
|
|
1253
|
+
expect(result.error?.message).toBe("Test error");
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
it("handles async resolver errors", async () => {
|
|
1257
|
+
const asyncErrorQuery = query()
|
|
1258
|
+
.input(z.object({ id: z.string() }))
|
|
1259
|
+
.resolve(async () => {
|
|
1260
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
1261
|
+
throw new Error("Async error");
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
const server = createApp({ queries: { asyncErrorQuery } });
|
|
1265
|
+
|
|
1266
|
+
const result = await firstValueFrom(
|
|
1267
|
+
server.execute({
|
|
1268
|
+
path: "asyncErrorQuery",
|
|
1269
|
+
input: { id: "1" },
|
|
1270
|
+
}),
|
|
1271
|
+
);
|
|
1272
|
+
|
|
1273
|
+
expect(result.error).toBeDefined();
|
|
1274
|
+
expect(result.error?.message).toBe("Async error");
|
|
1275
|
+
});
|
|
1276
|
+
});
|
package/src/server/create.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
type EmitCommand,
|
|
22
22
|
type EntityDef,
|
|
23
23
|
flattenRouter,
|
|
24
|
+
hashValue,
|
|
24
25
|
type InferRouterContext,
|
|
25
26
|
isEntityDef,
|
|
26
27
|
isMutationDef,
|
|
@@ -31,7 +32,7 @@ import {
|
|
|
31
32
|
toResolverMap,
|
|
32
33
|
valuesEqual,
|
|
33
34
|
} from "@sylphx/lens-core";
|
|
34
|
-
import { createContext, runWithContext } from "../context/index.js";
|
|
35
|
+
import { createContext, runWithContext, tryUseContext } from "../context/index.js";
|
|
35
36
|
import {
|
|
36
37
|
createPluginManager,
|
|
37
38
|
type PluginManager,
|
|
@@ -92,6 +93,54 @@ function isAsyncIterable(value: unknown): value is AsyncIterable<unknown> {
|
|
|
92
93
|
return value != null && typeof value === "object" && Symbol.asyncIterator in value;
|
|
93
94
|
}
|
|
94
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Ring buffer with O(1) enqueue/dequeue operations.
|
|
98
|
+
* Used for emit queue to avoid O(n) Array.shift() performance issues.
|
|
99
|
+
*/
|
|
100
|
+
class RingBuffer<T> {
|
|
101
|
+
private buffer: (T | null)[];
|
|
102
|
+
private head = 0; // Points to first element to dequeue
|
|
103
|
+
private tail = 0; // Points to where to enqueue next
|
|
104
|
+
private count = 0;
|
|
105
|
+
|
|
106
|
+
constructor(private capacity: number) {
|
|
107
|
+
this.buffer = new Array(capacity).fill(null);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
get size(): number {
|
|
111
|
+
return this.count;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
get isEmpty(): boolean {
|
|
115
|
+
return this.count === 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Enqueue item. If at capacity, drops oldest item (returns true if dropped). */
|
|
119
|
+
enqueue(item: T): boolean {
|
|
120
|
+
let dropped = false;
|
|
121
|
+
if (this.count >= this.capacity) {
|
|
122
|
+
// Drop oldest (backpressure)
|
|
123
|
+
this.head = (this.head + 1) % this.capacity;
|
|
124
|
+
this.count--;
|
|
125
|
+
dropped = true;
|
|
126
|
+
}
|
|
127
|
+
this.buffer[this.tail] = item;
|
|
128
|
+
this.tail = (this.tail + 1) % this.capacity;
|
|
129
|
+
this.count++;
|
|
130
|
+
return dropped;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Dequeue and return item, or null if empty. */
|
|
134
|
+
dequeue(): T | null {
|
|
135
|
+
if (this.count === 0) return null;
|
|
136
|
+
const item = this.buffer[this.head];
|
|
137
|
+
this.buffer[this.head] = null; // Allow GC
|
|
138
|
+
this.head = (this.head + 1) % this.capacity;
|
|
139
|
+
this.count--;
|
|
140
|
+
return item;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
95
144
|
// =============================================================================
|
|
96
145
|
// Server Implementation
|
|
97
146
|
// =============================================================================
|
|
@@ -136,8 +185,19 @@ class LensServerImpl<
|
|
|
136
185
|
|
|
137
186
|
this.queries = queries as Q;
|
|
138
187
|
this.mutations = mutations as M;
|
|
139
|
-
this.entities = config.entities ?? {};
|
|
140
188
|
this.resolverMap = config.resolvers ? toResolverMap(config.resolvers) : undefined;
|
|
189
|
+
|
|
190
|
+
// Build entities map: explicit config + auto-extracted from resolvers
|
|
191
|
+
const entities: EntitiesMap = { ...(config.entities ?? {}) };
|
|
192
|
+
if (config.resolvers) {
|
|
193
|
+
for (const resolver of config.resolvers) {
|
|
194
|
+
const entityName = resolver.entity._name;
|
|
195
|
+
if (entityName && !entities[entityName]) {
|
|
196
|
+
entities[entityName] = resolver.entity;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
this.entities = entities;
|
|
141
201
|
this.contextFactory = config.context ?? (() => ({}) as TContext);
|
|
142
202
|
this.version = config.version ?? "1.0.0";
|
|
143
203
|
this.logger = config.logger ?? noopLogger;
|
|
@@ -236,13 +296,22 @@ class LensServerImpl<
|
|
|
236
296
|
let cancelled = false;
|
|
237
297
|
let currentState: unknown;
|
|
238
298
|
let lastEmittedResult: unknown;
|
|
299
|
+
let lastEmittedHash: string | undefined;
|
|
239
300
|
const cleanups: (() => void)[] = [];
|
|
240
301
|
|
|
241
302
|
// Helper to emit only if value changed
|
|
303
|
+
// Uses cached hash for O(1) comparison after first call
|
|
242
304
|
const emitIfChanged = (data: unknown) => {
|
|
243
305
|
if (cancelled) return;
|
|
244
|
-
|
|
306
|
+
const dataHash = hashValue(data);
|
|
307
|
+
if (
|
|
308
|
+
lastEmittedHash !== undefined &&
|
|
309
|
+
valuesEqual(data, lastEmittedResult, dataHash, lastEmittedHash)
|
|
310
|
+
) {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
245
313
|
lastEmittedResult = data;
|
|
314
|
+
lastEmittedHash = dataHash;
|
|
246
315
|
observer.next?.({ data });
|
|
247
316
|
};
|
|
248
317
|
|
|
@@ -291,14 +360,15 @@ class LensServerImpl<
|
|
|
291
360
|
// Emit commands are queued and processed through processQueryResult
|
|
292
361
|
// to ensure field resolvers run on every emit
|
|
293
362
|
let emitProcessing = false;
|
|
294
|
-
const
|
|
363
|
+
const MAX_EMIT_QUEUE_SIZE = 100; // Backpressure: prevent memory bloat
|
|
364
|
+
const emitQueue = new RingBuffer<EmitCommand>(MAX_EMIT_QUEUE_SIZE);
|
|
295
365
|
|
|
296
366
|
const processEmitQueue = async () => {
|
|
297
367
|
if (emitProcessing || cancelled) return;
|
|
298
368
|
emitProcessing = true;
|
|
299
369
|
|
|
300
|
-
|
|
301
|
-
|
|
370
|
+
let command = emitQueue.dequeue();
|
|
371
|
+
while (command !== null && !cancelled) {
|
|
302
372
|
currentState = this.applyEmitCommand(command, currentState);
|
|
303
373
|
|
|
304
374
|
// Process through field resolvers (unlike before where we bypassed this)
|
|
@@ -328,6 +398,7 @@ class LensServerImpl<
|
|
|
328
398
|
: currentState;
|
|
329
399
|
|
|
330
400
|
emitIfChanged(processed);
|
|
401
|
+
command = emitQueue.dequeue();
|
|
331
402
|
}
|
|
332
403
|
|
|
333
404
|
emitProcessing = false;
|
|
@@ -335,7 +406,10 @@ class LensServerImpl<
|
|
|
335
406
|
|
|
336
407
|
const emitHandler = (command: EmitCommand) => {
|
|
337
408
|
if (cancelled) return;
|
|
338
|
-
|
|
409
|
+
|
|
410
|
+
// Enqueue command - RingBuffer handles backpressure automatically
|
|
411
|
+
// (drops oldest if at capacity, which is correct for live queries)
|
|
412
|
+
emitQueue.enqueue(command);
|
|
339
413
|
// Fire async processing (don't await - emit should be sync from caller's perspective)
|
|
340
414
|
processEmitQueue().catch((err) => {
|
|
341
415
|
if (!cancelled) {
|
|
@@ -405,8 +479,12 @@ class LensServerImpl<
|
|
|
405
479
|
)
|
|
406
480
|
: value;
|
|
407
481
|
emitIfChanged(processed);
|
|
408
|
-
|
|
409
|
-
//
|
|
482
|
+
|
|
483
|
+
// Mutations complete immediately - they're truly one-shot
|
|
484
|
+
// Queries stay open for potential emit calls from field resolvers
|
|
485
|
+
if (!isQuery && !cancelled) {
|
|
486
|
+
observer.complete?.();
|
|
487
|
+
}
|
|
410
488
|
}
|
|
411
489
|
});
|
|
412
490
|
} catch (error) {
|
|
@@ -770,6 +848,8 @@ class LensServerImpl<
|
|
|
770
848
|
let loader = this.loaders.get(loaderKey);
|
|
771
849
|
if (!loader) {
|
|
772
850
|
loader = new DataLoader(async (parents: unknown[]) => {
|
|
851
|
+
// Get context from AsyncLocalStorage - maintains request context in batched calls
|
|
852
|
+
const context = tryUseContext<TContext>() ?? ({} as TContext);
|
|
773
853
|
const results: unknown[] = [];
|
|
774
854
|
for (const parent of parents) {
|
|
775
855
|
try {
|
|
@@ -777,7 +857,7 @@ class LensServerImpl<
|
|
|
777
857
|
fieldName,
|
|
778
858
|
parent as Record<string, unknown>,
|
|
779
859
|
{},
|
|
780
|
-
|
|
860
|
+
context,
|
|
781
861
|
);
|
|
782
862
|
results.push(result);
|
|
783
863
|
} catch {
|
package/src/server/types.ts
CHANGED
|
@@ -152,7 +152,7 @@ export type ClientSendFn = (message: unknown) => void;
|
|
|
152
152
|
/** WebSocket interface for adapters */
|
|
153
153
|
export interface WebSocketLike {
|
|
154
154
|
send(data: string): void;
|
|
155
|
-
close(): void;
|
|
155
|
+
close(code?: number, reason?: string): void;
|
|
156
156
|
onmessage?: ((event: { data: string }) => void) | null;
|
|
157
157
|
onclose?: (() => void) | null;
|
|
158
158
|
onerror?: ((error: unknown) => void) | null;
|