@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.
@@ -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
+ });
@@ -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
- if (valuesEqual(data, lastEmittedResult)) return;
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 emitQueue: EmitCommand[] = [];
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
- while (emitQueue.length > 0 && !cancelled) {
312
- const command = emitQueue.shift()!;
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
- emitQueue.push(command);
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
- // Don't complete immediately - stay open for potential emit calls
420
- // For true one-shot, client can unsubscribe after first value
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 {
@@ -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;