@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.
- package/dist/index.d.ts +509 -9
- package/dist/index.js +269 -295
- package/package.json +3 -4
- package/src/e2e/server.test.ts +10 -10
- package/src/server/create.test.ts +11 -11
- package/src/server/create.ts +46 -20
- package/src/state/graph-state-manager.test.ts +215 -0
- package/src/state/graph-state-manager.ts +423 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/server/create.d.ts +0 -226
- package/dist/server/create.d.ts.map +0 -1
- package/dist/sse/handler.d.ts +0 -78
- package/dist/sse/handler.d.ts.map +0 -1
- package/dist/state/graph-state-manager.d.ts +0 -146
- package/dist/state/graph-state-manager.d.ts.map +0 -1
- package/dist/state/index.d.ts +0 -7
- package/dist/state/index.d.ts.map +0 -1
package/src/e2e/server.test.ts
CHANGED
|
@@ -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
|
|
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,
|
|
316
|
-
emitFn =
|
|
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,
|
|
359
|
-
emitFn =
|
|
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 (
|
|
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,
|
|
461
|
-
|
|
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,
|
|
501
|
-
emitFn =
|
|
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(({
|
|
648
|
-
emitFn =
|
|
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
|
|
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(({
|
|
690
|
-
emitFn =
|
|
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:
|
|
726
|
+
// Test: onCleanup
|
|
727
727
|
// =============================================================================
|
|
728
728
|
|
|
729
|
-
describe("
|
|
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(({
|
|
736
|
-
|
|
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(({
|
|
776
|
-
|
|
775
|
+
.resolve(({ onCleanup }) => {
|
|
776
|
+
onCleanup(() => {
|
|
777
777
|
cleanedUp = true;
|
|
778
778
|
});
|
|
779
779
|
return mockUsers[0];
|
package/src/server/create.ts
CHANGED
|
@@ -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
|
-
//
|
|
670
|
-
const
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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:
|
|
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
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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(
|
|
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,
|
|
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
|
});
|