@sylphx/lens-server 1.3.2 → 1.5.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/README.md +35 -0
- package/dist/index.d.ts +23 -109
- package/dist/index.js +58 -38
- package/package.json +37 -36
- package/src/e2e/server.test.ts +56 -45
- package/src/index.ts +26 -29
- package/src/server/create.test.ts +997 -20
- package/src/server/create.ts +82 -85
- package/src/sse/handler.ts +1 -1
- package/src/state/graph-state-manager.test.ts +566 -10
- package/src/state/graph-state-manager.ts +38 -13
- package/src/state/index.ts +3 -3
|
@@ -3,11 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
|
6
|
-
import {
|
|
7
|
-
GraphStateManager,
|
|
8
|
-
type StateClient,
|
|
9
|
-
type StateUpdateMessage,
|
|
10
|
-
} from "./graph-state-manager";
|
|
6
|
+
import { GraphStateManager, type StateClient, type StateUpdateMessage } from "./graph-state-manager";
|
|
11
7
|
|
|
12
8
|
describe("GraphStateManager", () => {
|
|
13
9
|
let manager: GraphStateManager;
|
|
@@ -332,8 +328,568 @@ describe("GraphStateManager", () => {
|
|
|
332
328
|
});
|
|
333
329
|
});
|
|
334
330
|
|
|
331
|
+
describe("updateSubscription", () => {
|
|
332
|
+
it("updates subscription fields for a client", () => {
|
|
333
|
+
manager.subscribe("client-1", "Post", "123", ["title"]);
|
|
334
|
+
manager.emit("Post", "123", { title: "Hello", content: "World" });
|
|
335
|
+
mockClient.messages = [];
|
|
336
|
+
|
|
337
|
+
// Update subscription to include content
|
|
338
|
+
manager.updateSubscription("client-1", "Post", "123", ["title", "content"]);
|
|
339
|
+
|
|
340
|
+
// Emit update with content change
|
|
341
|
+
manager.emit("Post", "123", { content: "Updated" });
|
|
342
|
+
|
|
343
|
+
expect(mockClient.messages.length).toBe(1);
|
|
344
|
+
expect(mockClient.messages[0].updates).toHaveProperty("content");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("updates subscription from specific fields to all fields (*)", () => {
|
|
348
|
+
manager.subscribe("client-1", "Post", "123", ["title"]);
|
|
349
|
+
manager.emit("Post", "123", { title: "Hello", content: "World", author: "Alice" });
|
|
350
|
+
mockClient.messages = [];
|
|
351
|
+
|
|
352
|
+
// Update subscription to all fields
|
|
353
|
+
manager.updateSubscription("client-1", "Post", "123", "*");
|
|
354
|
+
|
|
355
|
+
// Emit update
|
|
356
|
+
manager.emit("Post", "123", { content: "Updated", author: "Bob" });
|
|
357
|
+
|
|
358
|
+
expect(mockClient.messages.length).toBe(1);
|
|
359
|
+
expect(mockClient.messages[0].updates).toHaveProperty("content");
|
|
360
|
+
expect(mockClient.messages[0].updates).toHaveProperty("author");
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("updates subscription from all fields to specific fields", () => {
|
|
364
|
+
manager.subscribe("client-1", "Post", "123", "*");
|
|
365
|
+
manager.emit("Post", "123", { title: "Hello", content: "World", author: "Alice" });
|
|
366
|
+
mockClient.messages = [];
|
|
367
|
+
|
|
368
|
+
// Update subscription to only title
|
|
369
|
+
manager.updateSubscription("client-1", "Post", "123", ["title"]);
|
|
370
|
+
|
|
371
|
+
// Emit update
|
|
372
|
+
manager.emit("Post", "123", { title: "New", content: "Updated" });
|
|
373
|
+
|
|
374
|
+
expect(mockClient.messages.length).toBe(1);
|
|
375
|
+
expect(mockClient.messages[0].updates).toHaveProperty("title");
|
|
376
|
+
expect(mockClient.messages[0].updates).not.toHaveProperty("content");
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("handles updating subscription for non-subscribed entity", () => {
|
|
380
|
+
// Try to update subscription without subscribing first
|
|
381
|
+
expect(() => manager.updateSubscription("client-1", "Post", "999", ["title"])).not.toThrow();
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
describe("emitField", () => {
|
|
386
|
+
it("emits a field-level update with specific strategy", () => {
|
|
387
|
+
manager.subscribe("client-1", "Post", "123", "*");
|
|
388
|
+
mockClient.messages = [];
|
|
389
|
+
|
|
390
|
+
manager.emitField("Post", "123", "title", { strategy: "value", data: "Hello World" });
|
|
391
|
+
|
|
392
|
+
expect(mockClient.messages.length).toBe(1);
|
|
393
|
+
expect(mockClient.messages[0].updates.title).toEqual({
|
|
394
|
+
strategy: "value",
|
|
395
|
+
data: "Hello World",
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("applies field update to canonical state", () => {
|
|
400
|
+
manager.emitField("Post", "123", "title", { strategy: "value", data: "First" });
|
|
401
|
+
manager.emitField("Post", "123", "content", { strategy: "value", data: "Second" });
|
|
402
|
+
|
|
403
|
+
const state = manager.getState("Post", "123");
|
|
404
|
+
expect(state).toEqual({
|
|
405
|
+
title: "First",
|
|
406
|
+
content: "Second",
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("applies patch update to existing field", () => {
|
|
411
|
+
manager.emitField("Post", "123", "metadata", {
|
|
412
|
+
strategy: "value",
|
|
413
|
+
data: { views: 100, likes: 10 },
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
// Subscribe to see the patch
|
|
417
|
+
manager.subscribe("client-1", "Post", "123", "*");
|
|
418
|
+
mockClient.messages = [];
|
|
419
|
+
|
|
420
|
+
// Apply patch
|
|
421
|
+
manager.emitField("Post", "123", "metadata", {
|
|
422
|
+
strategy: "patch",
|
|
423
|
+
data: [{ op: "replace", path: "/views", value: 101 }],
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
const state = manager.getState("Post", "123");
|
|
427
|
+
expect(state?.metadata).toEqual({ views: 101, likes: 10 });
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("sends field update to subscribed clients only for subscribed fields", () => {
|
|
431
|
+
manager.subscribe("client-1", "Post", "123", ["title"]);
|
|
432
|
+
mockClient.messages = [];
|
|
433
|
+
|
|
434
|
+
manager.emitField("Post", "123", "title", { strategy: "value", data: "Hello" });
|
|
435
|
+
expect(mockClient.messages.length).toBe(1);
|
|
436
|
+
|
|
437
|
+
mockClient.messages = [];
|
|
438
|
+
manager.emitField("Post", "123", "content", { strategy: "value", data: "World" });
|
|
439
|
+
expect(mockClient.messages.length).toBe(0);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("does not send update if field value unchanged", () => {
|
|
443
|
+
manager.subscribe("client-1", "Post", "123", "*");
|
|
444
|
+
manager.emitField("Post", "123", "title", { strategy: "value", data: "Same" });
|
|
445
|
+
mockClient.messages = [];
|
|
446
|
+
|
|
447
|
+
manager.emitField("Post", "123", "title", { strategy: "value", data: "Same" });
|
|
448
|
+
expect(mockClient.messages.length).toBe(0);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("does not send update if object field unchanged", () => {
|
|
452
|
+
manager.subscribe("client-1", "Post", "123", "*");
|
|
453
|
+
manager.emitField("Post", "123", "metadata", {
|
|
454
|
+
strategy: "value",
|
|
455
|
+
data: { views: 100 },
|
|
456
|
+
});
|
|
457
|
+
mockClient.messages = [];
|
|
458
|
+
|
|
459
|
+
manager.emitField("Post", "123", "metadata", {
|
|
460
|
+
strategy: "value",
|
|
461
|
+
data: { views: 100 },
|
|
462
|
+
});
|
|
463
|
+
expect(mockClient.messages.length).toBe(0);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("handles emitField with no subscribers", () => {
|
|
467
|
+
expect(() => manager.emitField("Post", "999", "title", { strategy: "value", data: "Hello" })).not.toThrow();
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
describe("emitBatch", () => {
|
|
472
|
+
it("emits multiple field updates in a batch", () => {
|
|
473
|
+
manager.subscribe("client-1", "Post", "123", "*");
|
|
474
|
+
mockClient.messages = [];
|
|
475
|
+
|
|
476
|
+
manager.emitBatch("Post", "123", [
|
|
477
|
+
{ field: "title", update: { strategy: "value", data: "Hello" } },
|
|
478
|
+
{ field: "content", update: { strategy: "value", data: "World" } },
|
|
479
|
+
{ field: "author", update: { strategy: "value", data: "Alice" } },
|
|
480
|
+
]);
|
|
481
|
+
|
|
482
|
+
expect(mockClient.messages.length).toBe(1);
|
|
483
|
+
expect(mockClient.messages[0].updates.title.data).toBe("Hello");
|
|
484
|
+
expect(mockClient.messages[0].updates.content.data).toBe("World");
|
|
485
|
+
expect(mockClient.messages[0].updates.author.data).toBe("Alice");
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("applies batch updates to canonical state", () => {
|
|
489
|
+
manager.emitBatch("Post", "123", [
|
|
490
|
+
{ field: "title", update: { strategy: "value", data: "Title" } },
|
|
491
|
+
{ field: "content", update: { strategy: "value", data: "Content" } },
|
|
492
|
+
]);
|
|
493
|
+
|
|
494
|
+
const state = manager.getState("Post", "123");
|
|
495
|
+
expect(state).toEqual({
|
|
496
|
+
title: "Title",
|
|
497
|
+
content: "Content",
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("only sends batch updates for subscribed fields", () => {
|
|
502
|
+
manager.subscribe("client-1", "Post", "123", ["title", "content"]);
|
|
503
|
+
mockClient.messages = [];
|
|
504
|
+
|
|
505
|
+
manager.emitBatch("Post", "123", [
|
|
506
|
+
{ field: "title", update: { strategy: "value", data: "Hello" } },
|
|
507
|
+
{ field: "content", update: { strategy: "value", data: "World" } },
|
|
508
|
+
{ field: "author", update: { strategy: "value", data: "Alice" } },
|
|
509
|
+
]);
|
|
510
|
+
|
|
511
|
+
expect(mockClient.messages.length).toBe(1);
|
|
512
|
+
expect(mockClient.messages[0].updates).toHaveProperty("title");
|
|
513
|
+
expect(mockClient.messages[0].updates).toHaveProperty("content");
|
|
514
|
+
expect(mockClient.messages[0].updates).not.toHaveProperty("author");
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it("skips unchanged fields in batch", () => {
|
|
518
|
+
manager.subscribe("client-1", "Post", "123", "*");
|
|
519
|
+
manager.emitBatch("Post", "123", [
|
|
520
|
+
{ field: "title", update: { strategy: "value", data: "Same" } },
|
|
521
|
+
{ field: "content", update: { strategy: "value", data: "Same" } },
|
|
522
|
+
]);
|
|
523
|
+
mockClient.messages = [];
|
|
524
|
+
|
|
525
|
+
manager.emitBatch("Post", "123", [
|
|
526
|
+
{ field: "title", update: { strategy: "value", data: "Same" } },
|
|
527
|
+
{ field: "content", update: { strategy: "value", data: "Changed" } },
|
|
528
|
+
]);
|
|
529
|
+
|
|
530
|
+
expect(mockClient.messages.length).toBe(1);
|
|
531
|
+
expect(mockClient.messages[0].updates).not.toHaveProperty("title");
|
|
532
|
+
expect(mockClient.messages[0].updates).toHaveProperty("content");
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("skips unchanged object fields in batch", () => {
|
|
536
|
+
manager.subscribe("client-1", "Post", "123", "*");
|
|
537
|
+
manager.emitBatch("Post", "123", [{ field: "metadata", update: { strategy: "value", data: { views: 100 } } }]);
|
|
538
|
+
mockClient.messages = [];
|
|
539
|
+
|
|
540
|
+
manager.emitBatch("Post", "123", [{ field: "metadata", update: { strategy: "value", data: { views: 100 } } }]);
|
|
541
|
+
|
|
542
|
+
expect(mockClient.messages.length).toBe(0);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("does not send if no fields changed in batch", () => {
|
|
546
|
+
manager.subscribe("client-1", "Post", "123", "*");
|
|
547
|
+
manager.emitBatch("Post", "123", [{ field: "title", update: { strategy: "value", data: "Same" } }]);
|
|
548
|
+
mockClient.messages = [];
|
|
549
|
+
|
|
550
|
+
manager.emitBatch("Post", "123", [{ field: "title", update: { strategy: "value", data: "Same" } }]);
|
|
551
|
+
|
|
552
|
+
expect(mockClient.messages.length).toBe(0);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it("handles emitBatch with no subscribers", () => {
|
|
556
|
+
expect(() =>
|
|
557
|
+
manager.emitBatch("Post", "999", [{ field: "title", update: { strategy: "value", data: "Hello" } }]),
|
|
558
|
+
).not.toThrow();
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it("sends batch to multiple subscribed clients", () => {
|
|
562
|
+
const client2 = {
|
|
563
|
+
id: "client-2",
|
|
564
|
+
messages: [] as StateUpdateMessage[],
|
|
565
|
+
send: mock((msg: StateUpdateMessage) => {
|
|
566
|
+
client2.messages.push(msg);
|
|
567
|
+
}),
|
|
568
|
+
};
|
|
569
|
+
manager.addClient(client2);
|
|
570
|
+
|
|
571
|
+
manager.subscribe("client-1", "Post", "123", "*");
|
|
572
|
+
manager.subscribe("client-2", "Post", "123", "*");
|
|
573
|
+
mockClient.messages = [];
|
|
574
|
+
|
|
575
|
+
manager.emitBatch("Post", "123", [
|
|
576
|
+
{ field: "title", update: { strategy: "value", data: "Hello" } },
|
|
577
|
+
{ field: "content", update: { strategy: "value", data: "World" } },
|
|
578
|
+
]);
|
|
579
|
+
|
|
580
|
+
expect(mockClient.messages.length).toBe(1);
|
|
581
|
+
expect(client2.messages.length).toBe(1);
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
describe("processCommand", () => {
|
|
586
|
+
it("processes full command", () => {
|
|
587
|
+
manager.subscribe("client-1", "Post", "123", "*");
|
|
588
|
+
mockClient.messages = [];
|
|
589
|
+
|
|
590
|
+
manager.processCommand("Post", "123", {
|
|
591
|
+
type: "full",
|
|
592
|
+
data: { title: "Hello", content: "World" },
|
|
593
|
+
replace: true,
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
expect(mockClient.messages.length).toBe(1);
|
|
597
|
+
expect(manager.getState("Post", "123")).toEqual({
|
|
598
|
+
title: "Hello",
|
|
599
|
+
content: "World",
|
|
600
|
+
});
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it("processes full command with replace option", () => {
|
|
604
|
+
manager.emit("Post", "123", { title: "Old", content: "Old", author: "Alice" });
|
|
605
|
+
manager.subscribe("client-1", "Post", "123", "*");
|
|
606
|
+
mockClient.messages = [];
|
|
607
|
+
|
|
608
|
+
manager.processCommand("Post", "123", {
|
|
609
|
+
type: "full",
|
|
610
|
+
data: { title: "New" },
|
|
611
|
+
replace: true,
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
expect(manager.getState("Post", "123")).toEqual({ title: "New" });
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it("processes field command", () => {
|
|
618
|
+
manager.subscribe("client-1", "Post", "123", "*");
|
|
619
|
+
mockClient.messages = [];
|
|
620
|
+
|
|
621
|
+
manager.processCommand("Post", "123", {
|
|
622
|
+
type: "field",
|
|
623
|
+
field: "title",
|
|
624
|
+
update: { strategy: "value", data: "Hello" },
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
expect(mockClient.messages.length).toBe(1);
|
|
628
|
+
expect(manager.getState("Post", "123")).toEqual({ title: "Hello" });
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it("processes batch command", () => {
|
|
632
|
+
manager.subscribe("client-1", "Post", "123", "*");
|
|
633
|
+
mockClient.messages = [];
|
|
634
|
+
|
|
635
|
+
manager.processCommand("Post", "123", {
|
|
636
|
+
type: "batch",
|
|
637
|
+
updates: [
|
|
638
|
+
{ field: "title", update: { strategy: "value", data: "Hello" } },
|
|
639
|
+
{ field: "content", update: { strategy: "value", data: "World" } },
|
|
640
|
+
],
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
expect(mockClient.messages.length).toBe(1);
|
|
644
|
+
expect(manager.getState("Post", "123")).toEqual({
|
|
645
|
+
title: "Hello",
|
|
646
|
+
content: "World",
|
|
647
|
+
});
|
|
648
|
+
});
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
describe("edge cases", () => {
|
|
652
|
+
it("handles multiple clients with different field subscriptions", () => {
|
|
653
|
+
const client2 = {
|
|
654
|
+
id: "client-2",
|
|
655
|
+
messages: [] as StateUpdateMessage[],
|
|
656
|
+
send: mock((msg: StateUpdateMessage) => {
|
|
657
|
+
client2.messages.push(msg);
|
|
658
|
+
}),
|
|
659
|
+
};
|
|
660
|
+
const client3 = {
|
|
661
|
+
id: "client-3",
|
|
662
|
+
messages: [] as StateUpdateMessage[],
|
|
663
|
+
send: mock((msg: StateUpdateMessage) => {
|
|
664
|
+
client3.messages.push(msg);
|
|
665
|
+
}),
|
|
666
|
+
};
|
|
667
|
+
manager.addClient(client2);
|
|
668
|
+
manager.addClient(client3);
|
|
669
|
+
|
|
670
|
+
manager.subscribe("client-1", "Post", "123", ["title"]);
|
|
671
|
+
manager.subscribe("client-2", "Post", "123", ["content"]);
|
|
672
|
+
manager.subscribe("client-3", "Post", "123", "*");
|
|
673
|
+
mockClient.messages = [];
|
|
674
|
+
|
|
675
|
+
manager.emit("Post", "123", { title: "Hello", content: "World", author: "Alice" });
|
|
676
|
+
|
|
677
|
+
// client-1 should only get title
|
|
678
|
+
expect(mockClient.messages.length).toBe(1);
|
|
679
|
+
expect(mockClient.messages[0].updates).toHaveProperty("title");
|
|
680
|
+
expect(mockClient.messages[0].updates).not.toHaveProperty("content");
|
|
681
|
+
expect(mockClient.messages[0].updates).not.toHaveProperty("author");
|
|
682
|
+
|
|
683
|
+
// client-2 should only get content
|
|
684
|
+
expect(client2.messages.length).toBe(1);
|
|
685
|
+
expect(client2.messages[0].updates).not.toHaveProperty("title");
|
|
686
|
+
expect(client2.messages[0].updates).toHaveProperty("content");
|
|
687
|
+
expect(client2.messages[0].updates).not.toHaveProperty("author");
|
|
688
|
+
|
|
689
|
+
// client-3 should get all fields
|
|
690
|
+
expect(client3.messages.length).toBe(1);
|
|
691
|
+
expect(client3.messages[0].updates).toHaveProperty("title");
|
|
692
|
+
expect(client3.messages[0].updates).toHaveProperty("content");
|
|
693
|
+
expect(client3.messages[0].updates).toHaveProperty("author");
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it("handles deeply nested entity relationships", () => {
|
|
697
|
+
manager.subscribe("client-1", "Post", "123", "*");
|
|
698
|
+
mockClient.messages = [];
|
|
699
|
+
|
|
700
|
+
manager.emit("Post", "123", {
|
|
701
|
+
author: {
|
|
702
|
+
id: "1",
|
|
703
|
+
name: "Alice",
|
|
704
|
+
profile: {
|
|
705
|
+
bio: "Developer",
|
|
706
|
+
location: {
|
|
707
|
+
city: "SF",
|
|
708
|
+
country: "USA",
|
|
709
|
+
},
|
|
710
|
+
},
|
|
711
|
+
},
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
expect(mockClient.messages.length).toBe(1);
|
|
715
|
+
expect(mockClient.messages[0].updates.author.data).toEqual({
|
|
716
|
+
id: "1",
|
|
717
|
+
name: "Alice",
|
|
718
|
+
profile: {
|
|
719
|
+
bio: "Developer",
|
|
720
|
+
location: {
|
|
721
|
+
city: "SF",
|
|
722
|
+
country: "USA",
|
|
723
|
+
},
|
|
724
|
+
},
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
// Update nested object
|
|
728
|
+
mockClient.messages = [];
|
|
729
|
+
manager.emit("Post", "123", {
|
|
730
|
+
author: {
|
|
731
|
+
id: "1",
|
|
732
|
+
name: "Alice",
|
|
733
|
+
profile: {
|
|
734
|
+
bio: "Senior Developer",
|
|
735
|
+
location: {
|
|
736
|
+
city: "SF",
|
|
737
|
+
country: "USA",
|
|
738
|
+
},
|
|
739
|
+
},
|
|
740
|
+
},
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
expect(mockClient.messages.length).toBe(1);
|
|
744
|
+
expect(mockClient.messages[0].updates).toHaveProperty("author");
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
it("handles emitField creating entity from scratch", () => {
|
|
748
|
+
manager.subscribe("client-1", "Post", "new-123", "*");
|
|
749
|
+
mockClient.messages = [];
|
|
750
|
+
|
|
751
|
+
// First field on non-existent entity
|
|
752
|
+
manager.emitField("Post", "new-123", "title", { strategy: "value", data: "First" });
|
|
753
|
+
|
|
754
|
+
expect(mockClient.messages.length).toBe(1);
|
|
755
|
+
expect(manager.getState("Post", "new-123")).toEqual({ title: "First" });
|
|
756
|
+
|
|
757
|
+
// Add more fields
|
|
758
|
+
mockClient.messages = [];
|
|
759
|
+
manager.emitField("Post", "new-123", "content", { strategy: "value", data: "Second" });
|
|
760
|
+
|
|
761
|
+
expect(manager.getState("Post", "new-123")).toEqual({
|
|
762
|
+
title: "First",
|
|
763
|
+
content: "Second",
|
|
764
|
+
});
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
it("handles emitBatch creating entity from scratch", () => {
|
|
768
|
+
manager.subscribe("client-1", "Post", "new-456", "*");
|
|
769
|
+
mockClient.messages = [];
|
|
770
|
+
|
|
771
|
+
// Batch update on non-existent entity
|
|
772
|
+
manager.emitBatch("Post", "new-456", [
|
|
773
|
+
{ field: "title", update: { strategy: "value", data: "Title" } },
|
|
774
|
+
{ field: "content", update: { strategy: "value", data: "Content" } },
|
|
775
|
+
]);
|
|
776
|
+
|
|
777
|
+
expect(mockClient.messages.length).toBe(1);
|
|
778
|
+
expect(manager.getState("Post", "new-456")).toEqual({
|
|
779
|
+
title: "Title",
|
|
780
|
+
content: "Content",
|
|
781
|
+
});
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it("handles rapid succession of updates", () => {
|
|
785
|
+
manager.subscribe("client-1", "Post", "123", "*");
|
|
786
|
+
mockClient.messages = [];
|
|
787
|
+
|
|
788
|
+
// Rapid updates
|
|
789
|
+
for (let i = 0; i < 10; i++) {
|
|
790
|
+
manager.emit("Post", "123", { counter: i });
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// Should have 10 updates
|
|
794
|
+
expect(mockClient.messages.length).toBe(10);
|
|
795
|
+
expect(mockClient.messages[9].updates.counter.data).toBe(9);
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
it("handles large number of subscribers to same entity", () => {
|
|
799
|
+
const clients = [];
|
|
800
|
+
for (let i = 0; i < 100; i++) {
|
|
801
|
+
const client = {
|
|
802
|
+
id: `client-${i}`,
|
|
803
|
+
messages: [] as StateUpdateMessage[],
|
|
804
|
+
send: mock((msg: StateUpdateMessage) => {
|
|
805
|
+
client.messages.push(msg);
|
|
806
|
+
}),
|
|
807
|
+
};
|
|
808
|
+
manager.addClient(client);
|
|
809
|
+
manager.subscribe(`client-${i}`, "Post", "123", "*");
|
|
810
|
+
clients.push(client);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
manager.emit("Post", "123", { title: "Broadcast" });
|
|
814
|
+
|
|
815
|
+
// All clients should receive the update
|
|
816
|
+
for (const client of clients) {
|
|
817
|
+
expect(client.messages.length).toBe(1);
|
|
818
|
+
expect(client.messages[0].updates.title.data).toBe("Broadcast");
|
|
819
|
+
}
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
it("handles undefined field values", () => {
|
|
823
|
+
manager.subscribe("client-1", "Post", "123", "*");
|
|
824
|
+
manager.emit("Post", "123", { title: "Hello", content: undefined });
|
|
825
|
+
|
|
826
|
+
const state = manager.getState("Post", "123");
|
|
827
|
+
expect(state).toEqual({ title: "Hello", content: undefined });
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
it("handles null field values", () => {
|
|
831
|
+
manager.subscribe("client-1", "Post", "123", "*");
|
|
832
|
+
manager.emit("Post", "123", { title: "Hello", content: null });
|
|
833
|
+
|
|
834
|
+
const state = manager.getState("Post", "123");
|
|
835
|
+
expect(state).toEqual({ title: "Hello", content: null });
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
it("handles array field values", () => {
|
|
839
|
+
manager.subscribe("client-1", "Post", "123", "*");
|
|
840
|
+
mockClient.messages = [];
|
|
841
|
+
|
|
842
|
+
manager.emit("Post", "123", { tags: ["javascript", "typescript"] });
|
|
843
|
+
|
|
844
|
+
expect(mockClient.messages.length).toBe(1);
|
|
845
|
+
expect(mockClient.messages[0].updates.tags.data).toEqual(["javascript", "typescript"]);
|
|
846
|
+
|
|
847
|
+
// Update array
|
|
848
|
+
mockClient.messages = [];
|
|
849
|
+
manager.emit("Post", "123", { tags: ["javascript", "typescript", "react"] });
|
|
850
|
+
|
|
851
|
+
expect(mockClient.messages.length).toBe(1);
|
|
852
|
+
expect(mockClient.messages[0].updates.tags.data).toEqual(["javascript", "typescript", "react"]);
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
it("handles boolean field values", () => {
|
|
856
|
+
manager.subscribe("client-1", "Post", "123", "*");
|
|
857
|
+
mockClient.messages = [];
|
|
858
|
+
|
|
859
|
+
manager.emit("Post", "123", { published: true });
|
|
860
|
+
|
|
861
|
+
expect(mockClient.messages.length).toBe(1);
|
|
862
|
+
expect(mockClient.messages[0].updates.published.data).toBe(true);
|
|
863
|
+
|
|
864
|
+
// Toggle boolean
|
|
865
|
+
mockClient.messages = [];
|
|
866
|
+
manager.emit("Post", "123", { published: false });
|
|
867
|
+
|
|
868
|
+
expect(mockClient.messages.length).toBe(1);
|
|
869
|
+
expect(mockClient.messages[0].updates.published.data).toBe(false);
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
it("handles number field values including 0", () => {
|
|
873
|
+
manager.subscribe("client-1", "Post", "123", "*");
|
|
874
|
+
mockClient.messages = [];
|
|
875
|
+
|
|
876
|
+
manager.emit("Post", "123", { likes: 0 });
|
|
877
|
+
|
|
878
|
+
expect(mockClient.messages.length).toBe(1);
|
|
879
|
+
expect(mockClient.messages[0].updates.likes.data).toBe(0);
|
|
880
|
+
|
|
881
|
+
// Update to positive number
|
|
882
|
+
mockClient.messages = [];
|
|
883
|
+
manager.emit("Post", "123", { likes: 5 });
|
|
884
|
+
|
|
885
|
+
expect(mockClient.messages.length).toBe(1);
|
|
886
|
+
expect(mockClient.messages[0].updates.likes.data).toBe(5);
|
|
887
|
+
});
|
|
888
|
+
});
|
|
889
|
+
|
|
335
890
|
describe("array operations", () => {
|
|
336
|
-
|
|
891
|
+
// Interface kept for documentation - shows expected array shape
|
|
892
|
+
interface _User {
|
|
337
893
|
id: string;
|
|
338
894
|
name: string;
|
|
339
895
|
}
|
|
@@ -529,10 +1085,10 @@ describe("GraphStateManager", () => {
|
|
|
529
1085
|
});
|
|
530
1086
|
|
|
531
1087
|
expect(mockClient.messages.length).toBe(2);
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
]);
|
|
1088
|
+
// Second message should be incremental diff (push operation)
|
|
1089
|
+
const update = mockClient.messages[1].updates._items;
|
|
1090
|
+
expect(update.strategy).toBe("array");
|
|
1091
|
+
expect(update.data).toEqual([{ op: "push", item: { id: "2", name: "Bob" } }]);
|
|
536
1092
|
});
|
|
537
1093
|
|
|
538
1094
|
it("does not send update if array unchanged", () => {
|
|
@@ -11,13 +11,14 @@
|
|
|
11
11
|
|
|
12
12
|
import {
|
|
13
13
|
type ArrayOperation,
|
|
14
|
+
applyUpdate,
|
|
15
|
+
computeArrayDiff,
|
|
16
|
+
createUpdate,
|
|
14
17
|
type EmitCommand,
|
|
15
18
|
type EntityKey,
|
|
16
19
|
type InternalFieldUpdate,
|
|
17
|
-
type Update,
|
|
18
|
-
applyUpdate,
|
|
19
|
-
createUpdate,
|
|
20
20
|
makeEntityKey,
|
|
21
|
+
type Update,
|
|
21
22
|
} from "@sylphx/lens-core";
|
|
22
23
|
|
|
23
24
|
// Re-export for convenience
|
|
@@ -518,16 +519,40 @@ export class GraphStateManager {
|
|
|
518
519
|
return;
|
|
519
520
|
}
|
|
520
521
|
|
|
521
|
-
//
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
522
|
+
// Compute optimal array diff
|
|
523
|
+
const diff = computeArrayDiff(lastState, newArray);
|
|
524
|
+
|
|
525
|
+
if (diff === null || diff.length === 0) {
|
|
526
|
+
// Full replace is more efficient
|
|
527
|
+
client.send({
|
|
528
|
+
type: "update",
|
|
529
|
+
entity,
|
|
530
|
+
id,
|
|
531
|
+
updates: {
|
|
532
|
+
_items: { strategy: "value", data: newArray },
|
|
533
|
+
},
|
|
534
|
+
});
|
|
535
|
+
} else if (diff.length === 1 && diff[0].op === "replace") {
|
|
536
|
+
// Single replace op - send as value
|
|
537
|
+
client.send({
|
|
538
|
+
type: "update",
|
|
539
|
+
entity,
|
|
540
|
+
id,
|
|
541
|
+
updates: {
|
|
542
|
+
_items: { strategy: "value", data: newArray },
|
|
543
|
+
},
|
|
544
|
+
});
|
|
545
|
+
} else {
|
|
546
|
+
// Send incremental diff operations
|
|
547
|
+
client.send({
|
|
548
|
+
type: "update",
|
|
549
|
+
entity,
|
|
550
|
+
id,
|
|
551
|
+
updates: {
|
|
552
|
+
_items: { strategy: "array", data: diff },
|
|
553
|
+
},
|
|
554
|
+
});
|
|
555
|
+
}
|
|
531
556
|
|
|
532
557
|
// Update client's last known state
|
|
533
558
|
clientArrayState.lastState = [...newArray];
|
package/src/state/index.ts
CHANGED
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
export {
|
|
8
|
-
GraphStateManager,
|
|
9
8
|
createGraphStateManager,
|
|
10
9
|
type EntityKey,
|
|
10
|
+
GraphStateManager,
|
|
11
|
+
type GraphStateManagerConfig,
|
|
11
12
|
type StateClient,
|
|
12
|
-
type StateUpdateMessage,
|
|
13
13
|
type StateFullMessage,
|
|
14
|
+
type StateUpdateMessage,
|
|
14
15
|
type Subscription,
|
|
15
|
-
type GraphStateManagerConfig,
|
|
16
16
|
} from "./graph-state-manager";
|