@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.
@@ -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
  // =============================================================================
@@ -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
- 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
+ }
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 emitQueue: EmitCommand[] = [];
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
- while (emitQueue.length > 0 && !cancelled) {
301
- const command = emitQueue.shift()!;
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
- 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);
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
- // Don't complete immediately - stay open for potential emit calls
409
- // 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
+ }
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 {
@@ -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;