@sylphx/lens-server 1.0.3 → 1.2.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.
@@ -306,14 +306,14 @@ describe("E2E - Subscriptions", () => {
306
306
  expect(received[0]).toMatchObject({ name: "Alice" });
307
307
  });
308
308
 
309
- it("subscribe receives updates via ctx.emit", async () => {
309
+ it("subscribe receives updates via emit", async () => {
310
310
  let emitFn: ((data: unknown) => void) | null = null;
311
311
 
312
312
  const watchUser = query()
313
313
  .input(z.object({ id: z.string() }))
314
314
  .returns(User)
315
- .resolve(({ input, ctx }) => {
316
- emitFn = ctx.emit;
315
+ .resolve(({ input, emit }) => {
316
+ emitFn = emit;
317
317
  return mockUsers.find((u) => u.id === input.id) ?? null;
318
318
  });
319
319
 
@@ -355,8 +355,8 @@ describe("E2E - Subscriptions", () => {
355
355
  const watchUser = query()
356
356
  .input(z.object({ id: z.string() }))
357
357
  .returns(User)
358
- .resolve(({ input, ctx }) => {
359
- emitFn = ctx.emit;
358
+ .resolve(({ input, emit }) => {
359
+ emitFn = emit;
360
360
  return mockUsers.find((u) => u.id === input.id) ?? null;
361
361
  });
362
362
 
@@ -447,7 +447,7 @@ describe("E2E - Server API", () => {
447
447
  });
448
448
 
449
449
  // =============================================================================
450
- // Test: Cleanup (ctx.onCleanup)
450
+ // Test: Cleanup (onCleanup)
451
451
  // =============================================================================
452
452
 
453
453
  describe("E2E - Cleanup", () => {
@@ -457,8 +457,8 @@ describe("E2E - Cleanup", () => {
457
457
  const watchUser = query()
458
458
  .input(z.object({ id: z.string() }))
459
459
  .returns(User)
460
- .resolve(({ input, ctx }) => {
461
- ctx.onCleanup(() => {
460
+ .resolve(({ input, onCleanup }) => {
461
+ onCleanup(() => {
462
462
  cleanedUp = true;
463
463
  });
464
464
  return mockUsers.find((u) => u.id === input.id) ?? null;
@@ -497,8 +497,8 @@ describe("E2E - GraphStateManager", () => {
497
497
  const getUser = query()
498
498
  .input(z.object({ id: z.string() }))
499
499
  .returns(User)
500
- .resolve(({ input, ctx }) => {
501
- emitFn = ctx.emit;
500
+ .resolve(({ input, emit }) => {
501
+ emitFn = emit;
502
502
  return mockUsers.find((u) => u.id === input.id) ?? null;
503
503
  });
504
504
 
@@ -644,8 +644,8 @@ describe("Minimum Transfer", () => {
644
644
 
645
645
  const liveQuery = query()
646
646
  .returns(User)
647
- .resolve(({ ctx }) => {
648
- emitFn = ctx.emit;
647
+ .resolve(({ emit }) => {
648
+ emitFn = emit;
649
649
  return { id: "1", name: "Alice", email: "alice@example.com" };
650
650
  });
651
651
 
@@ -681,13 +681,13 @@ describe("Minimum Transfer", () => {
681
681
  }
682
682
  });
683
683
 
684
- it("sends updates via ctx.emit", async () => {
684
+ it("sends updates via emit", async () => {
685
685
  let emitFn: ((data: unknown) => void) | null = null;
686
686
 
687
687
  const liveQuery = query()
688
688
  .returns(User)
689
- .resolve(({ ctx }) => {
690
- emitFn = ctx.emit;
689
+ .resolve(({ emit }) => {
690
+ emitFn = emit;
691
691
  return { id: "1", name: "Alice", email: "alice@example.com" };
692
692
  });
693
693
 
@@ -723,17 +723,17 @@ describe("Minimum Transfer", () => {
723
723
  });
724
724
 
725
725
  // =============================================================================
726
- // Test: ctx.onCleanup
726
+ // Test: onCleanup
727
727
  // =============================================================================
728
728
 
729
- describe("ctx.onCleanup", () => {
729
+ describe("onCleanup", () => {
730
730
  it("calls cleanup function on unsubscribe", async () => {
731
731
  let cleanedUp = false;
732
732
 
733
733
  const liveQuery = query()
734
734
  .returns(User)
735
- .resolve(({ ctx }) => {
736
- ctx.onCleanup(() => {
735
+ .resolve(({ onCleanup }) => {
736
+ onCleanup(() => {
737
737
  cleanedUp = true;
738
738
  });
739
739
  return mockUsers[0];
@@ -772,8 +772,8 @@ describe("ctx.onCleanup", () => {
772
772
 
773
773
  const liveQuery = query()
774
774
  .returns(User)
775
- .resolve(({ ctx }) => {
776
- ctx.onCleanup(() => {
775
+ .resolve(({ onCleanup }) => {
776
+ onCleanup(() => {
777
777
  cleanedUp = true;
778
778
  });
779
779
  return mockUsers[0];
@@ -10,6 +10,8 @@
10
10
 
11
11
  import {
12
12
  type ContextValue,
13
+ type Emit,
14
+ type EmitCommand,
13
15
  type EntityDef,
14
16
  type EntityDefinition,
15
17
  type EntityResolvers,
@@ -22,6 +24,7 @@ import {
22
24
  type RouterDef,
23
25
  type Update,
24
26
  createContext,
27
+ createEmit,
25
28
  createUpdate,
26
29
  flattenRouter,
27
30
  isBatchResolver,
@@ -666,22 +669,37 @@ class LensServerImpl<
666
669
  throw new Error(`Query ${sub.operation} has no resolver`);
667
670
  }
668
671
 
669
- // Add emit and onCleanup to context for subscriptions
670
- const contextWithHelpers = {
671
- ...context,
672
- emit: emitData,
673
- onCleanup: (fn: () => void) => {
674
- sub.cleanups.push(fn);
675
- return () => {
676
- const idx = sub.cleanups.indexOf(fn);
677
- if (idx >= 0) sub.cleanups.splice(idx, 1);
678
- };
679
- },
672
+ // Create emit API for this subscription
673
+ const emit = createEmit((command: EmitCommand) => {
674
+ // Route emit commands to appropriate handler
675
+ const entityName = this.getEntityNameFromOutput(queryDef._output);
676
+ if (entityName) {
677
+ // For entity-typed outputs, use GraphStateManager
678
+ const entities = this.extractEntities(entityName, command.type === "full" ? command.data : {});
679
+ for (const { entity, id } of entities) {
680
+ this.stateManager.processCommand(entity, id, command);
681
+ }
682
+ }
683
+ // Also emit the raw data for operation-level updates
684
+ if (command.type === "full") {
685
+ emitData(command.data);
686
+ }
687
+ });
688
+
689
+ // Create onCleanup function
690
+ const onCleanup = (fn: () => void) => {
691
+ sub.cleanups.push(fn);
692
+ return () => {
693
+ const idx = sub.cleanups.indexOf(fn);
694
+ if (idx >= 0) sub.cleanups.splice(idx, 1);
695
+ };
680
696
  };
681
697
 
682
698
  const result = resolver({
683
699
  input: sub.input,
684
- ctx: contextWithHelpers,
700
+ ctx: context,
701
+ emit,
702
+ onCleanup,
685
703
  });
686
704
 
687
705
  if (isAsyncIterable(result)) {
@@ -892,14 +910,16 @@ class LensServerImpl<
892
910
  throw new Error(`Query ${name} has no resolver`);
893
911
  }
894
912
 
895
- const resolverCtx = {
896
- input: cleanInput as TInput,
897
- ctx: context, // Pass context directly to resolver (tRPC style)
898
- emit: () => {},
899
- onCleanup: () => () => {},
900
- };
913
+ // Create no-op emit for one-shot queries (emit is only meaningful in subscriptions)
914
+ const emit = createEmit(() => {});
915
+ const onCleanup = () => () => {};
901
916
 
902
- const result = resolver(resolverCtx);
917
+ const result = resolver({
918
+ input: cleanInput as TInput,
919
+ ctx: context,
920
+ emit,
921
+ onCleanup,
922
+ });
903
923
 
904
924
  let data: TOutput;
905
925
  if (isAsyncIterable(result)) {
@@ -944,9 +964,15 @@ class LensServerImpl<
944
964
  throw new Error(`Mutation ${name} has no resolver`);
945
965
  }
946
966
 
967
+ // Create no-op emit for mutations (emit is primarily for subscriptions)
968
+ const emit = createEmit(() => {});
969
+ const onCleanup = () => () => {};
970
+
947
971
  const result = await resolver({
948
972
  input: input as TInput,
949
- ctx: context, // Pass context directly to resolver (tRPC style)
973
+ ctx: context,
974
+ emit,
975
+ onCleanup,
950
976
  });
951
977
 
952
978
  // Emit to GraphStateManager
@@ -331,4 +331,219 @@ describe("GraphStateManager", () => {
331
331
  expect(stats.totalSubscriptions).toBe(3);
332
332
  });
333
333
  });
334
+
335
+ describe("array operations", () => {
336
+ interface User {
337
+ id: string;
338
+ name: string;
339
+ }
340
+
341
+ it("emits array data", () => {
342
+ manager.subscribe("client-1", "Users", "list", "*");
343
+ mockClient.messages = [];
344
+
345
+ manager.emitArray("Users", "list", [
346
+ { id: "1", name: "Alice" },
347
+ { id: "2", name: "Bob" },
348
+ ]);
349
+
350
+ expect(mockClient.messages.length).toBe(1);
351
+ expect(mockClient.messages[0]).toMatchObject({
352
+ type: "update",
353
+ entity: "Users",
354
+ id: "list",
355
+ });
356
+ expect(mockClient.messages[0].updates._items.data).toEqual([
357
+ { id: "1", name: "Alice" },
358
+ { id: "2", name: "Bob" },
359
+ ]);
360
+ });
361
+
362
+ it("gets array state", () => {
363
+ manager.emitArray("Users", "list", [{ id: "1", name: "Alice" }]);
364
+
365
+ expect(manager.getArrayState("Users", "list")).toEqual([{ id: "1", name: "Alice" }]);
366
+ });
367
+
368
+ it("applies push operation", () => {
369
+ manager.emitArray("Users", "list", [{ id: "1", name: "Alice" }]);
370
+ manager.emitArrayOperation("Users", "list", {
371
+ op: "push",
372
+ item: { id: "2", name: "Bob" },
373
+ });
374
+
375
+ expect(manager.getArrayState("Users", "list")).toEqual([
376
+ { id: "1", name: "Alice" },
377
+ { id: "2", name: "Bob" },
378
+ ]);
379
+ });
380
+
381
+ it("applies unshift operation", () => {
382
+ manager.emitArray("Users", "list", [{ id: "1", name: "Alice" }]);
383
+ manager.emitArrayOperation("Users", "list", {
384
+ op: "unshift",
385
+ item: { id: "0", name: "Zero" },
386
+ });
387
+
388
+ expect(manager.getArrayState("Users", "list")).toEqual([
389
+ { id: "0", name: "Zero" },
390
+ { id: "1", name: "Alice" },
391
+ ]);
392
+ });
393
+
394
+ it("applies insert operation", () => {
395
+ manager.emitArray("Users", "list", [
396
+ { id: "1", name: "Alice" },
397
+ { id: "3", name: "Charlie" },
398
+ ]);
399
+ manager.emitArrayOperation("Users", "list", {
400
+ op: "insert",
401
+ index: 1,
402
+ item: { id: "2", name: "Bob" },
403
+ });
404
+
405
+ expect(manager.getArrayState("Users", "list")).toEqual([
406
+ { id: "1", name: "Alice" },
407
+ { id: "2", name: "Bob" },
408
+ { id: "3", name: "Charlie" },
409
+ ]);
410
+ });
411
+
412
+ it("applies remove operation", () => {
413
+ manager.emitArray("Users", "list", [
414
+ { id: "1", name: "Alice" },
415
+ { id: "2", name: "Bob" },
416
+ ]);
417
+ manager.emitArrayOperation("Users", "list", { op: "remove", index: 0 });
418
+
419
+ expect(manager.getArrayState("Users", "list")).toEqual([{ id: "2", name: "Bob" }]);
420
+ });
421
+
422
+ it("applies removeById operation", () => {
423
+ manager.emitArray("Users", "list", [
424
+ { id: "1", name: "Alice" },
425
+ { id: "2", name: "Bob" },
426
+ ]);
427
+ manager.emitArrayOperation("Users", "list", { op: "removeById", id: "1" });
428
+
429
+ expect(manager.getArrayState("Users", "list")).toEqual([{ id: "2", name: "Bob" }]);
430
+ });
431
+
432
+ it("handles removeById for non-existent id", () => {
433
+ manager.emitArray("Users", "list", [{ id: "1", name: "Alice" }]);
434
+ manager.emitArrayOperation("Users", "list", { op: "removeById", id: "999" });
435
+
436
+ expect(manager.getArrayState("Users", "list")).toEqual([{ id: "1", name: "Alice" }]);
437
+ });
438
+
439
+ it("applies update operation", () => {
440
+ manager.emitArray("Users", "list", [
441
+ { id: "1", name: "Alice" },
442
+ { id: "2", name: "Bob" },
443
+ ]);
444
+ manager.emitArrayOperation("Users", "list", {
445
+ op: "update",
446
+ index: 1,
447
+ item: { id: "2", name: "Robert" },
448
+ });
449
+
450
+ expect(manager.getArrayState("Users", "list")).toEqual([
451
+ { id: "1", name: "Alice" },
452
+ { id: "2", name: "Robert" },
453
+ ]);
454
+ });
455
+
456
+ it("applies updateById operation", () => {
457
+ manager.emitArray("Users", "list", [
458
+ { id: "1", name: "Alice" },
459
+ { id: "2", name: "Bob" },
460
+ ]);
461
+ manager.emitArrayOperation("Users", "list", {
462
+ op: "updateById",
463
+ id: "2",
464
+ item: { id: "2", name: "Robert" },
465
+ });
466
+
467
+ expect(manager.getArrayState("Users", "list")).toEqual([
468
+ { id: "1", name: "Alice" },
469
+ { id: "2", name: "Robert" },
470
+ ]);
471
+ });
472
+
473
+ it("applies merge operation", () => {
474
+ manager.emitArray("Users", "list", [
475
+ { id: "1", name: "Alice" },
476
+ { id: "2", name: "Bob" },
477
+ ]);
478
+ manager.emitArrayOperation("Users", "list", {
479
+ op: "merge",
480
+ index: 0,
481
+ partial: { name: "Alicia" },
482
+ });
483
+
484
+ expect(manager.getArrayState("Users", "list")).toEqual([
485
+ { id: "1", name: "Alicia" },
486
+ { id: "2", name: "Bob" },
487
+ ]);
488
+ });
489
+
490
+ it("applies mergeById operation", () => {
491
+ manager.emitArray("Users", "list", [
492
+ { id: "1", name: "Alice" },
493
+ { id: "2", name: "Bob" },
494
+ ]);
495
+ manager.emitArrayOperation("Users", "list", {
496
+ op: "mergeById",
497
+ id: "2",
498
+ partial: { name: "Bobby" },
499
+ });
500
+
501
+ expect(manager.getArrayState("Users", "list")).toEqual([
502
+ { id: "1", name: "Alice" },
503
+ { id: "2", name: "Bobby" },
504
+ ]);
505
+ });
506
+
507
+ it("processCommand handles array operations", () => {
508
+ manager.emitArray("Users", "list", [{ id: "1", name: "Alice" }]);
509
+
510
+ manager.processCommand("Users", "list", {
511
+ type: "array",
512
+ operation: { op: "push", item: { id: "2", name: "Bob" } },
513
+ });
514
+
515
+ expect(manager.getArrayState("Users", "list")).toEqual([
516
+ { id: "1", name: "Alice" },
517
+ { id: "2", name: "Bob" },
518
+ ]);
519
+ });
520
+
521
+ it("sends array updates to subscribed clients", () => {
522
+ manager.subscribe("client-1", "Users", "list", "*");
523
+ mockClient.messages = [];
524
+
525
+ manager.emitArray("Users", "list", [{ id: "1", name: "Alice" }]);
526
+ manager.emitArrayOperation("Users", "list", {
527
+ op: "push",
528
+ item: { id: "2", name: "Bob" },
529
+ });
530
+
531
+ expect(mockClient.messages.length).toBe(2);
532
+ expect(mockClient.messages[1].updates._items.data).toEqual([
533
+ { id: "1", name: "Alice" },
534
+ { id: "2", name: "Bob" },
535
+ ]);
536
+ });
537
+
538
+ it("does not send update if array unchanged", () => {
539
+ manager.subscribe("client-1", "Users", "list", "*");
540
+ manager.emitArray("Users", "list", [{ id: "1", name: "Alice" }]);
541
+ mockClient.messages = [];
542
+
543
+ // Remove by non-existent id (no change)
544
+ manager.emitArrayOperation("Users", "list", { op: "removeById", id: "999" });
545
+
546
+ expect(mockClient.messages.length).toBe(0);
547
+ });
548
+ });
334
549
  });