@sylphx/lens-server 2.1.0 → 2.3.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.
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { describe, expect, it } from "bun:test";
9
- import { entity, mutation, query, router } from "@sylphx/lens-core";
9
+ import { entity, firstValueFrom, mutation, query, resolver, router, t } from "@sylphx/lens-core";
10
10
  import { z } from "zod";
11
11
  import { optimisticPlugin } from "../plugin/optimistic.js";
12
12
  import { createApp } from "./create.js";
@@ -190,10 +190,12 @@ describe("execute", () => {
190
190
  queries: { getUser },
191
191
  });
192
192
 
193
- const result = await server.execute({
194
- path: "getUser",
195
- input: { id: "123" },
196
- });
193
+ const result = await firstValueFrom(
194
+ server.execute({
195
+ path: "getUser",
196
+ input: { id: "123" },
197
+ }),
198
+ );
197
199
 
198
200
  expect(result.data).toEqual({
199
201
  id: "123",
@@ -208,10 +210,12 @@ describe("execute", () => {
208
210
  mutations: { createUser },
209
211
  });
210
212
 
211
- const result = await server.execute({
212
- path: "createUser",
213
- input: { name: "New User", email: "new@example.com" },
214
- });
213
+ const result = await firstValueFrom(
214
+ server.execute({
215
+ path: "createUser",
216
+ input: { name: "New User", email: "new@example.com" },
217
+ }),
218
+ );
215
219
 
216
220
  expect(result.data).toEqual({
217
221
  id: "new-id",
@@ -226,10 +230,12 @@ describe("execute", () => {
226
230
  queries: { getUser },
227
231
  });
228
232
 
229
- const result = await server.execute({
230
- path: "unknownOperation",
231
- input: {},
232
- });
233
+ const result = await firstValueFrom(
234
+ server.execute({
235
+ path: "unknownOperation",
236
+ input: {},
237
+ }),
238
+ );
233
239
 
234
240
  expect(result.data).toBeUndefined();
235
241
  expect(result.error).toBeInstanceOf(Error);
@@ -241,10 +247,12 @@ describe("execute", () => {
241
247
  queries: { getUser },
242
248
  });
243
249
 
244
- const result = await server.execute({
245
- path: "getUser",
246
- input: { invalid: true }, // Missing required 'id'
247
- });
250
+ const result = await firstValueFrom(
251
+ server.execute({
252
+ path: "getUser",
253
+ input: { invalid: true }, // Missing required 'id'
254
+ }),
255
+ );
248
256
 
249
257
  expect(result.data).toBeUndefined();
250
258
  expect(result.error).toBeInstanceOf(Error);
@@ -260,10 +268,12 @@ describe("execute", () => {
260
268
 
261
269
  const server = createApp({ router: appRouter });
262
270
 
263
- const queryResult = await server.execute({
264
- path: "user.get",
265
- input: { id: "456" },
266
- });
271
+ const queryResult = await firstValueFrom(
272
+ server.execute({
273
+ path: "user.get",
274
+ input: { id: "456" },
275
+ }),
276
+ );
267
277
 
268
278
  expect(queryResult.data).toEqual({
269
279
  id: "456",
@@ -271,10 +281,12 @@ describe("execute", () => {
271
281
  email: "test@example.com",
272
282
  });
273
283
 
274
- const mutationResult = await server.execute({
275
- path: "user.create",
276
- input: { name: "Router User" },
277
- });
284
+ const mutationResult = await firstValueFrom(
285
+ server.execute({
286
+ path: "user.create",
287
+ input: { name: "Router User" },
288
+ }),
289
+ );
278
290
 
279
291
  expect(mutationResult.data).toEqual({
280
292
  id: "new-id",
@@ -294,10 +306,12 @@ describe("execute", () => {
294
306
  queries: { errorQuery },
295
307
  });
296
308
 
297
- const result = await server.execute({
298
- path: "errorQuery",
299
- input: { id: "1" },
300
- });
309
+ const result = await firstValueFrom(
310
+ server.execute({
311
+ path: "errorQuery",
312
+ input: { id: "1" },
313
+ }),
314
+ );
301
315
 
302
316
  expect(result.data).toBeUndefined();
303
317
  expect(result.error).toBeInstanceOf(Error);
@@ -309,9 +323,11 @@ describe("execute", () => {
309
323
  queries: { getUsers },
310
324
  });
311
325
 
312
- const result = await server.execute({
313
- path: "getUsers",
314
- });
326
+ const result = await firstValueFrom(
327
+ server.execute({
328
+ path: "getUsers",
329
+ }),
330
+ );
315
331
 
316
332
  expect(result.data).toHaveLength(2);
317
333
  });
@@ -337,10 +353,12 @@ describe("context", () => {
337
353
  context: () => ({ userId: "user-123", role: "admin" }),
338
354
  });
339
355
 
340
- await server.execute({
341
- path: "contextQuery",
342
- input: { id: "1" },
343
- });
356
+ await firstValueFrom(
357
+ server.execute({
358
+ path: "contextQuery",
359
+ input: { id: "1" },
360
+ }),
361
+ );
344
362
 
345
363
  expect(capturedContext).toMatchObject({
346
364
  userId: "user-123",
@@ -366,10 +384,12 @@ describe("context", () => {
366
384
  },
367
385
  });
368
386
 
369
- await server.execute({
370
- path: "contextQuery",
371
- input: { id: "1" },
372
- });
387
+ await firstValueFrom(
388
+ server.execute({
389
+ path: "contextQuery",
390
+ input: { id: "1" },
391
+ }),
392
+ );
373
393
 
374
394
  expect(capturedContext).toMatchObject({
375
395
  userId: "async-user",
@@ -387,13 +407,15 @@ describe("selection", () => {
387
407
  queries: { getUser },
388
408
  });
389
409
 
390
- const result = await server.execute({
391
- path: "getUser",
392
- input: {
393
- id: "123",
394
- $select: { name: true },
395
- },
396
- });
410
+ const result = await firstValueFrom(
411
+ server.execute({
412
+ path: "getUser",
413
+ input: {
414
+ id: "123",
415
+ $select: { name: true },
416
+ },
417
+ }),
418
+ );
397
419
 
398
420
  expect(result.data).toEqual({
399
421
  id: "123", // id always included
@@ -422,3 +444,635 @@ describe("type inference", () => {
422
444
  expect((server as { _types?: unknown })._types).toBeUndefined(); // Runtime undefined, compile-time exists
423
445
  });
424
446
  });
447
+
448
+ // =============================================================================
449
+ // Field Resolver Tests (GraphQL-style per-field resolution)
450
+ // =============================================================================
451
+
452
+ describe("field resolvers", () => {
453
+ // Test entities for field resolver tests
454
+ const Author = entity("Author", {
455
+ id: t.id(),
456
+ name: t.string(),
457
+ });
458
+
459
+ const Post = entity("Post", {
460
+ id: t.id(),
461
+ title: t.string(),
462
+ content: t.string(),
463
+ published: t.boolean(),
464
+ authorId: t.string(),
465
+ });
466
+
467
+ // Mock data
468
+ const mockDb = {
469
+ authors: [
470
+ { id: "a1", name: "Alice" },
471
+ { id: "a2", name: "Bob" },
472
+ ],
473
+ posts: [
474
+ { id: "p1", title: "Post 1", content: "Content 1", published: true, authorId: "a1" },
475
+ { id: "p2", title: "Post 2", content: "Content 2", published: false, authorId: "a1" },
476
+ { id: "p3", title: "Post 3", content: "Content 3", published: true, authorId: "a1" },
477
+ { id: "p4", title: "Post 4", content: "Content 4", published: true, authorId: "a2" },
478
+ ],
479
+ };
480
+
481
+ type TestContext = { db: typeof mockDb };
482
+
483
+ it("resolves field with nested input args (like GraphQL)", async () => {
484
+ // Define field resolver with args (like GraphQL)
485
+ const authorResolver = resolver<TestContext>()(Author, (f) => ({
486
+ id: f.expose("id"),
487
+ name: f.expose("name"),
488
+ posts: f
489
+ .many(Post)
490
+ .args(
491
+ z.object({
492
+ limit: z.number().optional(),
493
+ published: z.boolean().optional(),
494
+ }),
495
+ )
496
+ .resolve(({ parent, args, ctx }) => {
497
+ let posts = ctx.db.posts.filter((p) => p.authorId === parent.id);
498
+ if (args.published !== undefined) {
499
+ posts = posts.filter((p) => p.published === args.published);
500
+ }
501
+ if (args.limit !== undefined) {
502
+ posts = posts.slice(0, args.limit);
503
+ }
504
+ return posts;
505
+ }),
506
+ }));
507
+
508
+ const getAuthor = query<TestContext>()
509
+ .input(z.object({ id: z.string() }))
510
+ .returns(Author)
511
+ .resolve(({ input, ctx }) => {
512
+ const author = ctx.db.authors.find((a) => a.id === input.id);
513
+ if (!author) throw new Error("Author not found");
514
+ return author;
515
+ });
516
+
517
+ const server = createApp({
518
+ entities: { Author, Post },
519
+ queries: { getAuthor },
520
+ resolvers: [authorResolver],
521
+ context: () => ({ db: mockDb }),
522
+ });
523
+
524
+ // Test with nested input: get author with only published posts, limit 2
525
+ const result = await firstValueFrom(
526
+ server.execute({
527
+ path: "getAuthor",
528
+ input: {
529
+ id: "a1",
530
+ $select: {
531
+ id: true,
532
+ name: true,
533
+ posts: {
534
+ input: { published: true, limit: 2 },
535
+ select: { id: true, title: true },
536
+ },
537
+ },
538
+ },
539
+ }),
540
+ );
541
+
542
+ expect(result.error).toBeUndefined();
543
+ expect(result.data).toBeDefined();
544
+
545
+ const data = result.data as { id: string; name: string; posts: { id: string; title: string }[] };
546
+ expect(data.id).toBe("a1");
547
+ expect(data.name).toBe("Alice");
548
+ expect(data.posts).toHaveLength(2); // limit: 2
549
+ expect(data.posts.every((p) => p.title)).toBe(true); // only selected fields
550
+ });
551
+
552
+ it("passes context to field resolvers", async () => {
553
+ let capturedContext: TestContext | null = null;
554
+
555
+ const authorResolver = resolver<TestContext>()(Author, (f) => ({
556
+ id: f.expose("id"),
557
+ name: f.expose("name"),
558
+ posts: f.many(Post).resolve(({ parent, ctx }) => {
559
+ capturedContext = ctx;
560
+ return ctx.db.posts.filter((p) => p.authorId === parent.id);
561
+ }),
562
+ }));
563
+
564
+ const getAuthor = query<TestContext>()
565
+ .input(z.object({ id: z.string() }))
566
+ .returns(Author)
567
+ .resolve(({ input, ctx }) => {
568
+ const author = ctx.db.authors.find((a) => a.id === input.id);
569
+ if (!author) throw new Error("Author not found");
570
+ return author;
571
+ });
572
+
573
+ const server = createApp({
574
+ entities: { Author, Post },
575
+ queries: { getAuthor },
576
+ resolvers: [authorResolver],
577
+ context: () => ({ db: mockDb }),
578
+ });
579
+
580
+ await firstValueFrom(
581
+ server.execute({
582
+ path: "getAuthor",
583
+ input: {
584
+ id: "a1",
585
+ $select: {
586
+ id: true,
587
+ posts: true,
588
+ },
589
+ },
590
+ }),
591
+ );
592
+
593
+ expect(capturedContext).toBeDefined();
594
+ expect(capturedContext?.db).toBe(mockDb);
595
+ });
596
+
597
+ it("supports nested input at multiple levels", async () => {
598
+ // Comment entity for deeper nesting
599
+ const Comment = entity("Comment", {
600
+ id: t.id(),
601
+ body: t.string(),
602
+ postId: t.string(),
603
+ });
604
+
605
+ const mockDbWithComments = {
606
+ ...mockDb,
607
+ comments: [
608
+ { id: "c1", body: "Comment 1", postId: "p1" },
609
+ { id: "c2", body: "Comment 2", postId: "p1" },
610
+ { id: "c3", body: "Comment 3", postId: "p2" },
611
+ ],
612
+ };
613
+
614
+ type CtxWithComments = { db: typeof mockDbWithComments };
615
+
616
+ const postResolver = resolver<CtxWithComments>()(Post, (f) => ({
617
+ id: f.expose("id"),
618
+ title: f.expose("title"),
619
+ comments: f
620
+ .many(Comment)
621
+ .args(z.object({ limit: z.number().optional() }))
622
+ .resolve(({ parent, args, ctx }) => {
623
+ let comments = ctx.db.comments.filter((c) => c.postId === parent.id);
624
+ if (args.limit !== undefined) {
625
+ comments = comments.slice(0, args.limit);
626
+ }
627
+ return comments;
628
+ }),
629
+ }));
630
+
631
+ const authorResolver = resolver<CtxWithComments>()(Author, (f) => ({
632
+ id: f.expose("id"),
633
+ name: f.expose("name"),
634
+ posts: f
635
+ .many(Post)
636
+ .args(z.object({ limit: z.number().optional() }))
637
+ .resolve(({ parent, args, ctx }) => {
638
+ let posts = ctx.db.posts.filter((p) => p.authorId === parent.id);
639
+ if (args.limit !== undefined) {
640
+ posts = posts.slice(0, args.limit);
641
+ }
642
+ return posts;
643
+ }),
644
+ }));
645
+
646
+ const getAuthor = query<CtxWithComments>()
647
+ .input(z.object({ id: z.string() }))
648
+ .returns(Author)
649
+ .resolve(({ input, ctx }) => {
650
+ const author = ctx.db.authors.find((a) => a.id === input.id);
651
+ if (!author) throw new Error("Author not found");
652
+ return author;
653
+ });
654
+
655
+ const server = createApp({
656
+ entities: { Author, Post, Comment },
657
+ queries: { getAuthor },
658
+ resolvers: [authorResolver, postResolver],
659
+ context: () => ({ db: mockDbWithComments }),
660
+ });
661
+
662
+ // Nested input at multiple levels:
663
+ // Author.posts(limit: 1) -> Post.comments(limit: 1)
664
+ const result = await firstValueFrom(
665
+ server.execute({
666
+ path: "getAuthor",
667
+ input: {
668
+ id: "a1",
669
+ $select: {
670
+ id: true,
671
+ posts: {
672
+ input: { limit: 1 },
673
+ select: {
674
+ id: true,
675
+ title: true,
676
+ comments: {
677
+ input: { limit: 1 },
678
+ select: { id: true, body: true },
679
+ },
680
+ },
681
+ },
682
+ },
683
+ },
684
+ }),
685
+ );
686
+
687
+ expect(result.error).toBeUndefined();
688
+ const data = result.data as any;
689
+ expect(data.posts).toHaveLength(1);
690
+ expect(data.posts[0].comments).toHaveLength(1);
691
+ });
692
+
693
+ it("works without nested input (default args)", async () => {
694
+ const authorResolver = resolver<TestContext>()(Author, (f) => ({
695
+ id: f.expose("id"),
696
+ name: f.expose("name"),
697
+ posts: f
698
+ .many(Post)
699
+ .args(z.object({ limit: z.number().default(10) }))
700
+ .resolve(({ parent, args, ctx }) => {
701
+ return ctx.db.posts.filter((p) => p.authorId === parent.id).slice(0, args.limit);
702
+ }),
703
+ }));
704
+
705
+ const getAuthor = query<TestContext>()
706
+ .input(z.object({ id: z.string() }))
707
+ .returns(Author)
708
+ .resolve(({ input, ctx }) => {
709
+ const author = ctx.db.authors.find((a) => a.id === input.id);
710
+ if (!author) throw new Error("Author not found");
711
+ return author;
712
+ });
713
+
714
+ const server = createApp({
715
+ entities: { Author, Post },
716
+ queries: { getAuthor },
717
+ resolvers: [authorResolver],
718
+ context: () => ({ db: mockDb }),
719
+ });
720
+
721
+ // Without nested input - should use default args
722
+ const result = await firstValueFrom(
723
+ server.execute({
724
+ path: "getAuthor",
725
+ input: {
726
+ id: "a1",
727
+ $select: {
728
+ id: true,
729
+ posts: { select: { id: true } },
730
+ },
731
+ },
732
+ }),
733
+ );
734
+
735
+ expect(result.error).toBeUndefined();
736
+ const data = result.data as any;
737
+ expect(data.posts).toHaveLength(3); // All of Alice's posts (default limit 10)
738
+ });
739
+
740
+ it("emit() goes through field resolvers", async () => {
741
+ // Track how many times posts resolver is called
742
+ let postsResolverCallCount = 0;
743
+
744
+ const Author = entity("Author", {
745
+ id: t.id(),
746
+ name: t.string(),
747
+ });
748
+
749
+ const Post = entity("Post", {
750
+ id: t.id(),
751
+ title: t.string(),
752
+ authorId: t.string(),
753
+ });
754
+
755
+ const mockDb = {
756
+ authors: [{ id: "a1", name: "Alice" }],
757
+ posts: [
758
+ { id: "p1", title: "Post 1", authorId: "a1" },
759
+ { id: "p2", title: "Post 2", authorId: "a1" },
760
+ ],
761
+ };
762
+
763
+ const authorResolver = resolver<{ db: typeof mockDb }>()(Author, (f) => ({
764
+ id: f.expose("id"),
765
+ name: f.expose("name"),
766
+ posts: f.many(Post).resolve(({ parent, ctx }) => {
767
+ postsResolverCallCount++;
768
+ return ctx.db.posts.filter((p) => p.authorId === parent.id);
769
+ }),
770
+ }));
771
+
772
+ type EmitFn = ((data: unknown) => void) & { merge: (partial: unknown) => void };
773
+ let capturedEmit: EmitFn | null = null;
774
+
775
+ const getAuthor = query<{ db: typeof mockDb; emit: EmitFn }>()
776
+ .input(z.object({ id: z.string() }))
777
+ .returns(Author)
778
+ .resolve(({ input, ctx }) => {
779
+ capturedEmit = ctx.emit as EmitFn;
780
+ const author = ctx.db.authors.find((a) => a.id === input.id);
781
+ if (!author) throw new Error("Author not found");
782
+ return author;
783
+ });
784
+
785
+ const server = createApp({
786
+ entities: { Author, Post },
787
+ queries: { getAuthor },
788
+ resolvers: [authorResolver],
789
+ context: () => ({ db: mockDb }),
790
+ });
791
+
792
+ // Subscribe to query with nested posts
793
+ const results: unknown[] = [];
794
+ const subscription = server
795
+ .execute({
796
+ path: "getAuthor",
797
+ input: {
798
+ id: "a1",
799
+ $select: {
800
+ id: true,
801
+ name: true,
802
+ posts: { select: { id: true, title: true } },
803
+ },
804
+ },
805
+ })
806
+ .subscribe({
807
+ next: (result) => {
808
+ results.push(result);
809
+ },
810
+ });
811
+
812
+ // Wait for initial result
813
+ await new Promise((resolve) => setTimeout(resolve, 50));
814
+
815
+ expect(results.length).toBe(1);
816
+ expect(postsResolverCallCount).toBe(1); // Called once for initial query
817
+
818
+ // Add a new post to the mock DB
819
+ mockDb.posts.push({ id: "p3", title: "Post 3", authorId: "a1" });
820
+
821
+ // Emit updated author (this should trigger field resolvers)
822
+ capturedEmit!({ id: "a1", name: "Alice Updated" });
823
+
824
+ // Wait for emit to process
825
+ await new Promise((resolve) => setTimeout(resolve, 50));
826
+
827
+ expect(results.length).toBe(2);
828
+ expect(postsResolverCallCount).toBe(2); // Called again after emit!
829
+
830
+ // Verify the emitted result includes the new post (from re-running posts resolver)
831
+ const latestResult = results[1] as { data: { posts: { id: string }[] } };
832
+ expect(latestResult.data.posts).toHaveLength(3); // Should have 3 posts now
833
+
834
+ subscription.unsubscribe();
835
+ });
836
+
837
+ it("field resolvers receive onCleanup for cleanup registration", async () => {
838
+ let cleanupCalled = false;
839
+ let resolverReceivedOnCleanup = false;
840
+
841
+ const Author = entity("Author", {
842
+ id: t.id(),
843
+ name: t.string(),
844
+ });
845
+
846
+ const Post = entity("Post", {
847
+ id: t.id(),
848
+ title: t.string(),
849
+ authorId: t.string(),
850
+ });
851
+
852
+ const mockDb = {
853
+ authors: [{ id: "a1", name: "Alice" }],
854
+ posts: [{ id: "p1", title: "Post 1", authorId: "a1" }],
855
+ };
856
+
857
+ const authorResolver = resolver<{ db: typeof mockDb }>()(Author, (f) => ({
858
+ id: f.expose("id"),
859
+ name: f.expose("name"),
860
+ posts: f.many(Post).resolve(({ parent, ctx }) => {
861
+ // Track if onCleanup was received (via ctx)
862
+ resolverReceivedOnCleanup = ctx.onCleanup !== undefined;
863
+
864
+ // Register a cleanup if available
865
+ if (ctx.onCleanup) {
866
+ ctx.onCleanup(() => {
867
+ cleanupCalled = true;
868
+ });
869
+ }
870
+
871
+ return ctx.db.posts.filter((p) => p.authorId === parent.id);
872
+ }),
873
+ }));
874
+
875
+ const getAuthor = query<{ db: typeof mockDb }>()
876
+ .input(z.object({ id: z.string() }))
877
+ .returns(Author)
878
+ .resolve(({ input, ctx }) => {
879
+ const author = ctx.db.authors.find((a) => a.id === input.id);
880
+ if (!author) throw new Error("Author not found");
881
+ return author;
882
+ });
883
+
884
+ const server = createApp({
885
+ entities: { Author, Post },
886
+ queries: { getAuthor },
887
+ resolvers: [authorResolver],
888
+ context: () => ({ db: mockDb }),
889
+ });
890
+
891
+ const subscription = server
892
+ .execute({
893
+ path: "getAuthor",
894
+ input: {
895
+ id: "a1",
896
+ $select: {
897
+ id: true,
898
+ posts: { select: { id: true } },
899
+ },
900
+ },
901
+ })
902
+ .subscribe({});
903
+
904
+ // Wait for query to execute
905
+ await new Promise((resolve) => setTimeout(resolve, 50));
906
+
907
+ // Verify field resolver received onCleanup
908
+ expect(resolverReceivedOnCleanup).toBe(true);
909
+ expect(cleanupCalled).toBe(false); // Not called yet
910
+
911
+ // Unsubscribe should trigger cleanup
912
+ subscription.unsubscribe();
913
+
914
+ // Cleanup should be called
915
+ expect(cleanupCalled).toBe(true);
916
+ });
917
+
918
+ it("field-level emit updates specific field and notifies observer", async () => {
919
+ const Author = entity("Author", {
920
+ id: t.id(),
921
+ name: t.string(),
922
+ });
923
+
924
+ const Post = entity("Post", {
925
+ id: t.id(),
926
+ title: t.string(),
927
+ authorId: t.string(),
928
+ });
929
+
930
+ const mockDb = {
931
+ authors: [{ id: "a1", name: "Alice" }],
932
+ posts: [
933
+ { id: "p1", title: "Post 1", authorId: "a1" },
934
+ { id: "p2", title: "Post 2", authorId: "a1" },
935
+ ],
936
+ };
937
+
938
+ // Track field emit
939
+ let capturedFieldEmit: ((value: unknown) => void) | undefined;
940
+
941
+ const authorResolver = resolver<{ db: typeof mockDb }>()(Author, (f) => ({
942
+ id: f.expose("id"),
943
+ name: f.expose("name"),
944
+ posts: f.many(Post).resolve(({ parent, ctx }) => {
945
+ // Capture the field emit for later use
946
+ capturedFieldEmit = ctx.emit;
947
+
948
+ // Set up a mock subscription that will use field emit
949
+ if (ctx.emit && ctx.onCleanup) {
950
+ // Simulate subscription setup
951
+ ctx.onCleanup(() => {
952
+ capturedFieldEmit = undefined;
953
+ });
954
+ }
955
+
956
+ return ctx.db.posts.filter((p) => p.authorId === parent.id);
957
+ }),
958
+ }));
959
+
960
+ const getAuthor = query<{ db: typeof mockDb }>()
961
+ .input(z.object({ id: z.string() }))
962
+ .returns(Author)
963
+ .resolve(({ input, ctx }) => {
964
+ const author = ctx.db.authors.find((a) => a.id === input.id);
965
+ if (!author) throw new Error("Author not found");
966
+ return author;
967
+ });
968
+
969
+ const server = createApp({
970
+ entities: { Author, Post },
971
+ queries: { getAuthor },
972
+ resolvers: [authorResolver],
973
+ context: () => ({ db: mockDb }),
974
+ });
975
+
976
+ const results: unknown[] = [];
977
+ const subscription = server
978
+ .execute({
979
+ path: "getAuthor",
980
+ input: {
981
+ id: "a1",
982
+ $select: {
983
+ id: true,
984
+ name: true,
985
+ posts: { select: { id: true, title: true } },
986
+ },
987
+ },
988
+ })
989
+ .subscribe({
990
+ next: (result) => {
991
+ results.push(result);
992
+ },
993
+ });
994
+
995
+ // Wait for initial result
996
+ await new Promise((resolve) => setTimeout(resolve, 50));
997
+
998
+ expect(results.length).toBe(1);
999
+ expect(capturedFieldEmit).toBeDefined();
1000
+
1001
+ const initialResult = results[0] as { data: { posts: { id: string }[] } };
1002
+ expect(initialResult.data.posts).toHaveLength(2);
1003
+
1004
+ // Use field-level emit to update just the posts field
1005
+ const newPosts = [
1006
+ { id: "p1", title: "Updated Post 1", authorId: "a1" },
1007
+ { id: "p2", title: "Updated Post 2", authorId: "a1" },
1008
+ { id: "p3", title: "New Post 3", authorId: "a1" },
1009
+ ];
1010
+ capturedFieldEmit!(newPosts);
1011
+
1012
+ // Wait for field emit to process
1013
+ await new Promise((resolve) => setTimeout(resolve, 50));
1014
+
1015
+ expect(results.length).toBe(2);
1016
+ const updatedResult = results[1] as { data: { posts: { id: string; title: string }[] } };
1017
+ expect(updatedResult.data.posts).toHaveLength(3);
1018
+ expect(updatedResult.data.posts[2].title).toBe("New Post 3");
1019
+
1020
+ subscription.unsubscribe();
1021
+ });
1022
+
1023
+ it("emit skips observer notification when value unchanged", async () => {
1024
+ type EmitFn = (data: { id: string; name: string }) => void;
1025
+ let capturedEmit: EmitFn | undefined;
1026
+
1027
+ // Query that captures emit for external use
1028
+ const liveQuery = query()
1029
+ .input(z.object({ id: z.string() }))
1030
+ .returns(User)
1031
+ .resolve(({ input, ctx }) => {
1032
+ capturedEmit = ctx.emit as EmitFn;
1033
+ return { id: input.id, name: "Initial" };
1034
+ });
1035
+
1036
+ const testRouter = router({ liveQuery });
1037
+ const app = createApp({ router: testRouter });
1038
+
1039
+ const results: unknown[] = [];
1040
+ const observable = app.execute({
1041
+ path: "liveQuery",
1042
+ input: { id: "1" },
1043
+ });
1044
+
1045
+ const subscription = observable.subscribe({
1046
+ next: (value) => results.push(value),
1047
+ });
1048
+
1049
+ // Wait for initial result
1050
+ await new Promise((resolve) => setTimeout(resolve, 50));
1051
+ expect(results.length).toBe(1);
1052
+ const firstResult = results[0] as { data: { id: string; name: string } };
1053
+ expect(firstResult.data.name).toBe("Initial");
1054
+
1055
+ // Emit same value - should be skipped (equality check)
1056
+ capturedEmit!({ id: "1", name: "Initial" });
1057
+ await new Promise((resolve) => setTimeout(resolve, 50));
1058
+ expect(results.length).toBe(1); // Still 1, not 2
1059
+
1060
+ // Emit same value again - should be skipped
1061
+ capturedEmit!({ id: "1", name: "Initial" });
1062
+ await new Promise((resolve) => setTimeout(resolve, 50));
1063
+ expect(results.length).toBe(1); // Still 1
1064
+
1065
+ // Emit different value - should emit
1066
+ capturedEmit!({ id: "1", name: "Changed" });
1067
+ await new Promise((resolve) => setTimeout(resolve, 50));
1068
+ expect(results.length).toBe(2); // Now 2
1069
+ expect((results[1] as { data: { name: string } }).data.name).toBe("Changed");
1070
+
1071
+ // Emit same "Changed" value again - should be skipped
1072
+ capturedEmit!({ id: "1", name: "Changed" });
1073
+ await new Promise((resolve) => setTimeout(resolve, 50));
1074
+ expect(results.length).toBe(2); // Still 2
1075
+
1076
+ subscription.unsubscribe();
1077
+ });
1078
+ });