@sylphx/lens-server 2.3.2 → 2.4.1
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 +353 -19
- package/package.json +1 -1
- 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 +78 -9
- 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
|
// =============================================================================
|
|
@@ -247,13 +296,22 @@ class LensServerImpl<
|
|
|
247
296
|
let cancelled = false;
|
|
248
297
|
let currentState: unknown;
|
|
249
298
|
let lastEmittedResult: unknown;
|
|
299
|
+
let lastEmittedHash: string | undefined;
|
|
250
300
|
const cleanups: (() => void)[] = [];
|
|
251
301
|
|
|
252
302
|
// Helper to emit only if value changed
|
|
303
|
+
// Uses cached hash for O(1) comparison after first call
|
|
253
304
|
const emitIfChanged = (data: unknown) => {
|
|
254
305
|
if (cancelled) return;
|
|
255
|
-
|
|
306
|
+
const dataHash = hashValue(data);
|
|
307
|
+
if (
|
|
308
|
+
lastEmittedHash !== undefined &&
|
|
309
|
+
valuesEqual(data, lastEmittedResult, dataHash, lastEmittedHash)
|
|
310
|
+
) {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
256
313
|
lastEmittedResult = data;
|
|
314
|
+
lastEmittedHash = dataHash;
|
|
257
315
|
observer.next?.({ data });
|
|
258
316
|
};
|
|
259
317
|
|
|
@@ -302,14 +360,15 @@ class LensServerImpl<
|
|
|
302
360
|
// Emit commands are queued and processed through processQueryResult
|
|
303
361
|
// to ensure field resolvers run on every emit
|
|
304
362
|
let emitProcessing = false;
|
|
305
|
-
const
|
|
363
|
+
const MAX_EMIT_QUEUE_SIZE = 100; // Backpressure: prevent memory bloat
|
|
364
|
+
const emitQueue = new RingBuffer<EmitCommand>(MAX_EMIT_QUEUE_SIZE);
|
|
306
365
|
|
|
307
366
|
const processEmitQueue = async () => {
|
|
308
367
|
if (emitProcessing || cancelled) return;
|
|
309
368
|
emitProcessing = true;
|
|
310
369
|
|
|
311
|
-
|
|
312
|
-
|
|
370
|
+
let command = emitQueue.dequeue();
|
|
371
|
+
while (command !== null && !cancelled) {
|
|
313
372
|
currentState = this.applyEmitCommand(command, currentState);
|
|
314
373
|
|
|
315
374
|
// Process through field resolvers (unlike before where we bypassed this)
|
|
@@ -339,6 +398,7 @@ class LensServerImpl<
|
|
|
339
398
|
: currentState;
|
|
340
399
|
|
|
341
400
|
emitIfChanged(processed);
|
|
401
|
+
command = emitQueue.dequeue();
|
|
342
402
|
}
|
|
343
403
|
|
|
344
404
|
emitProcessing = false;
|
|
@@ -346,7 +406,10 @@ class LensServerImpl<
|
|
|
346
406
|
|
|
347
407
|
const emitHandler = (command: EmitCommand) => {
|
|
348
408
|
if (cancelled) return;
|
|
349
|
-
|
|
409
|
+
|
|
410
|
+
// Enqueue command - RingBuffer handles backpressure automatically
|
|
411
|
+
// (drops oldest if at capacity, which is correct for live queries)
|
|
412
|
+
emitQueue.enqueue(command);
|
|
350
413
|
// Fire async processing (don't await - emit should be sync from caller's perspective)
|
|
351
414
|
processEmitQueue().catch((err) => {
|
|
352
415
|
if (!cancelled) {
|
|
@@ -416,8 +479,12 @@ class LensServerImpl<
|
|
|
416
479
|
)
|
|
417
480
|
: value;
|
|
418
481
|
emitIfChanged(processed);
|
|
419
|
-
|
|
420
|
-
//
|
|
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
|
+
}
|
|
421
488
|
}
|
|
422
489
|
});
|
|
423
490
|
} catch (error) {
|
|
@@ -781,6 +848,8 @@ class LensServerImpl<
|
|
|
781
848
|
let loader = this.loaders.get(loaderKey);
|
|
782
849
|
if (!loader) {
|
|
783
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);
|
|
784
853
|
const results: unknown[] = [];
|
|
785
854
|
for (const parent of parents) {
|
|
786
855
|
try {
|
|
@@ -788,7 +857,7 @@ class LensServerImpl<
|
|
|
788
857
|
fieldName,
|
|
789
858
|
parent as Record<string, unknown>,
|
|
790
859
|
{},
|
|
791
|
-
|
|
860
|
+
context,
|
|
792
861
|
);
|
|
793
862
|
results.push(result);
|
|
794
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;
|