@sylphx/lens-server 2.10.0 → 2.11.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.js CHANGED
@@ -43,6 +43,7 @@ import {
43
43
  hashValue,
44
44
  hasInlineResolvers,
45
45
  isEntityDef,
46
+ isLiveQueryDef,
46
47
  isModelDef,
47
48
  isMutationDef,
48
49
  isQueryDef,
@@ -622,7 +623,7 @@ class LensServerImpl {
622
623
  currentState = state;
623
624
  }, emitIfChanged, select, context, onCleanup) : undefined;
624
625
  const lensContext = { ...context, emit, onCleanup };
625
- const result = resolver({ input: cleanInput, ctx: lensContext });
626
+ const result = resolver({ args: cleanInput, input: cleanInput, ctx: lensContext });
626
627
  if (isAsyncIterable(result)) {
627
628
  for await (const value of result) {
628
629
  if (cancelled)
@@ -639,6 +640,27 @@ class LensServerImpl {
639
640
  currentState = value;
640
641
  const processed = isQuery ? await this.processQueryResult(path, value, select, context, onCleanup, createFieldEmit) : value;
641
642
  emitIfChanged(processed);
643
+ if (isQuery && isLiveQueryDef(def) && !cancelled) {
644
+ const liveQuery = def;
645
+ if (liveQuery._subscriber) {
646
+ try {
647
+ const publisher = liveQuery._subscriber({
648
+ args: cleanInput,
649
+ input: cleanInput,
650
+ ctx: context
651
+ });
652
+ if (publisher) {
653
+ publisher({ emit, onCleanup });
654
+ }
655
+ } catch (err) {
656
+ if (!cancelled) {
657
+ observer.next?.({
658
+ error: err instanceof Error ? err : new Error(String(err))
659
+ });
660
+ }
661
+ }
662
+ }
663
+ }
642
664
  if (!isQuery && !cancelled) {
643
665
  observer.complete?.();
644
666
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylphx/lens-server",
3
- "version": "2.10.0",
3
+ "version": "2.11.0",
4
4
  "description": "Server runtime for Lens API framework",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -30,7 +30,7 @@
30
30
  "author": "SylphxAI",
31
31
  "license": "MIT",
32
32
  "dependencies": {
33
- "@sylphx/lens-core": "^2.8.0"
33
+ "@sylphx/lens-core": "^2.9.0"
34
34
  },
35
35
  "devDependencies": {
36
36
  "typescript": "^5.9.3",
@@ -1725,3 +1725,379 @@ describe("Unified Entity Definition", () => {
1725
1725
  });
1726
1726
  });
1727
1727
  });
1728
+
1729
+ // =============================================================================
1730
+ // Operation-Level .resolve().subscribe() Tests (LiveQueryDef)
1731
+ // =============================================================================
1732
+
1733
+ describe("operation-level .resolve().subscribe() (LiveQueryDef)", () => {
1734
+ it("calls _subscriber after initial resolve for live updates", async () => {
1735
+ let subscriberCalled = false;
1736
+ let capturedEmit: ((value: unknown) => void) | undefined;
1737
+ let capturedOnCleanup: ((fn: () => void) => void) | undefined;
1738
+
1739
+ const liveUser = query()
1740
+ .input(z.object({ id: z.string() }))
1741
+ .resolve(({ input }) => ({ id: input.id, name: "Initial" }))
1742
+ .subscribe(({ input: _input }) => ({ emit, onCleanup }) => {
1743
+ subscriberCalled = true;
1744
+ capturedEmit = emit;
1745
+ capturedOnCleanup = onCleanup;
1746
+ });
1747
+
1748
+ const server = createApp({ queries: { liveUser } });
1749
+
1750
+ const results: unknown[] = [];
1751
+ const subscription = server
1752
+ .execute({
1753
+ path: "liveUser",
1754
+ input: { id: "1" },
1755
+ })
1756
+ .subscribe({
1757
+ next: (result) => results.push(result),
1758
+ });
1759
+
1760
+ // Wait for initial result and subscriber setup
1761
+ await new Promise((r) => setTimeout(r, 50));
1762
+
1763
+ expect(results.length).toBe(1);
1764
+ expect((results[0] as { data: { name: string } }).data.name).toBe("Initial");
1765
+ expect(subscriberCalled).toBe(true);
1766
+ expect(capturedEmit).toBeDefined();
1767
+ expect(capturedOnCleanup).toBeDefined();
1768
+
1769
+ subscription.unsubscribe();
1770
+ });
1771
+
1772
+ it("emits updates from subscriber emit function", async () => {
1773
+ let capturedEmit: ((value: { id: string; name: string }) => void) | undefined;
1774
+
1775
+ const liveUser = query()
1776
+ .input(z.object({ id: z.string() }))
1777
+ .resolve(({ input }) => ({ id: input.id, name: "Initial" }))
1778
+ .subscribe(() => ({ emit }) => {
1779
+ capturedEmit = emit;
1780
+ });
1781
+
1782
+ const server = createApp({ queries: { liveUser } });
1783
+
1784
+ const results: unknown[] = [];
1785
+ const subscription = server
1786
+ .execute({
1787
+ path: "liveUser",
1788
+ input: { id: "1" },
1789
+ })
1790
+ .subscribe({
1791
+ next: (result) => results.push(result),
1792
+ });
1793
+
1794
+ // Wait for initial result
1795
+ await new Promise((r) => setTimeout(r, 50));
1796
+ expect(results.length).toBe(1);
1797
+ expect((results[0] as { data: { name: string } }).data.name).toBe("Initial");
1798
+
1799
+ // Emit update via subscriber
1800
+ capturedEmit!({ id: "1", name: "Updated" });
1801
+ await new Promise((r) => setTimeout(r, 50));
1802
+
1803
+ expect(results.length).toBe(2);
1804
+ expect((results[1] as { data: { name: string } }).data.name).toBe("Updated");
1805
+
1806
+ // Emit another update
1807
+ capturedEmit!({ id: "1", name: "Updated Again" });
1808
+ await new Promise((r) => setTimeout(r, 50));
1809
+
1810
+ expect(results.length).toBe(3);
1811
+ expect((results[2] as { data: { name: string } }).data.name).toBe("Updated Again");
1812
+
1813
+ subscription.unsubscribe();
1814
+ });
1815
+
1816
+ it("calls onCleanup when subscription is unsubscribed", async () => {
1817
+ let cleanupCalled = false;
1818
+
1819
+ const liveUser = query()
1820
+ .input(z.object({ id: z.string() }))
1821
+ .resolve(({ input }) => ({ id: input.id, name: "Initial" }))
1822
+ .subscribe(() => ({ onCleanup }) => {
1823
+ onCleanup(() => {
1824
+ cleanupCalled = true;
1825
+ });
1826
+ });
1827
+
1828
+ const server = createApp({ queries: { liveUser } });
1829
+
1830
+ const subscription = server
1831
+ .execute({
1832
+ path: "liveUser",
1833
+ input: { id: "1" },
1834
+ })
1835
+ .subscribe({});
1836
+
1837
+ // Wait for subscription setup
1838
+ await new Promise((r) => setTimeout(r, 50));
1839
+ expect(cleanupCalled).toBe(false);
1840
+
1841
+ // Unsubscribe
1842
+ subscription.unsubscribe();
1843
+
1844
+ // Cleanup should be called
1845
+ expect(cleanupCalled).toBe(true);
1846
+ });
1847
+
1848
+ it("passes input and context to subscriber", async () => {
1849
+ interface TestContext {
1850
+ userId: string;
1851
+ }
1852
+
1853
+ let receivedInput: { id: string } | undefined;
1854
+ let receivedCtx: TestContext | undefined;
1855
+
1856
+ const liveUser = query<TestContext>()
1857
+ .input(z.object({ id: z.string() }))
1858
+ .resolve(({ input }) => ({ id: input.id, name: "Initial" }))
1859
+ .subscribe(({ input, ctx }) => ({ emit: _emit }) => {
1860
+ receivedInput = input;
1861
+ receivedCtx = ctx;
1862
+ });
1863
+
1864
+ const server = createApp({
1865
+ queries: { liveUser },
1866
+ context: () => ({ userId: "ctx-user-123" }),
1867
+ });
1868
+
1869
+ const subscription = server
1870
+ .execute({
1871
+ path: "liveUser",
1872
+ input: { id: "input-123" },
1873
+ })
1874
+ .subscribe({});
1875
+
1876
+ // Wait for subscription setup
1877
+ await new Promise((r) => setTimeout(r, 50));
1878
+
1879
+ expect(receivedInput).toEqual({ id: "input-123" });
1880
+ expect(receivedCtx).toEqual({ userId: "ctx-user-123" });
1881
+
1882
+ subscription.unsubscribe();
1883
+ });
1884
+
1885
+ it("handles subscriber errors gracefully", async () => {
1886
+ const liveUser = query()
1887
+ .input(z.object({ id: z.string() }))
1888
+ .resolve(({ input }) => ({ id: input.id, name: "Initial" }))
1889
+ .subscribe(() => () => {
1890
+ throw new Error("Subscriber error");
1891
+ });
1892
+
1893
+ const server = createApp({ queries: { liveUser } });
1894
+
1895
+ const results: unknown[] = [];
1896
+ const errors: Error[] = [];
1897
+
1898
+ const subscription = server
1899
+ .execute({
1900
+ path: "liveUser",
1901
+ input: { id: "1" },
1902
+ })
1903
+ .subscribe({
1904
+ next: (result) => {
1905
+ if (result.error) {
1906
+ errors.push(result.error);
1907
+ } else {
1908
+ results.push(result);
1909
+ }
1910
+ },
1911
+ });
1912
+
1913
+ // Wait for execution
1914
+ await new Promise((r) => setTimeout(r, 50));
1915
+
1916
+ // Initial result should be delivered
1917
+ expect(results.length).toBe(1);
1918
+ // Error from subscriber should be reported
1919
+ expect(errors.length).toBe(1);
1920
+ expect(errors[0].message).toBe("Subscriber error");
1921
+
1922
+ subscription.unsubscribe();
1923
+ });
1924
+
1925
+ it("works with router-based operations", async () => {
1926
+ let capturedEmit: ((value: { id: string; count: number }) => void) | undefined;
1927
+
1928
+ const liveCounter = query()
1929
+ .input(z.object({ id: z.string() }))
1930
+ .resolve(({ input }) => ({ id: input.id, count: 0 }))
1931
+ .subscribe(() => ({ emit }) => {
1932
+ capturedEmit = emit;
1933
+ });
1934
+
1935
+ const appRouter = router({
1936
+ counter: router({
1937
+ live: liveCounter,
1938
+ }),
1939
+ });
1940
+
1941
+ const server = createApp({ router: appRouter });
1942
+
1943
+ const results: unknown[] = [];
1944
+ const subscription = server
1945
+ .execute({
1946
+ path: "counter.live",
1947
+ input: { id: "c1" },
1948
+ })
1949
+ .subscribe({
1950
+ next: (result) => results.push(result),
1951
+ });
1952
+
1953
+ // Wait for initial result
1954
+ await new Promise((r) => setTimeout(r, 50));
1955
+ expect(results.length).toBe(1);
1956
+ expect((results[0] as { data: { count: number } }).data.count).toBe(0);
1957
+
1958
+ // Emit updates
1959
+ capturedEmit!({ id: "c1", count: 1 });
1960
+ await new Promise((r) => setTimeout(r, 50));
1961
+ expect(results.length).toBe(2);
1962
+ expect((results[1] as { data: { count: number } }).data.count).toBe(1);
1963
+
1964
+ capturedEmit!({ id: "c1", count: 5 });
1965
+ await new Promise((r) => setTimeout(r, 50));
1966
+ expect(results.length).toBe(3);
1967
+ expect((results[2] as { data: { count: number } }).data.count).toBe(5);
1968
+
1969
+ subscription.unsubscribe();
1970
+ });
1971
+
1972
+ it("supports emit.merge for partial updates", async () => {
1973
+ type EmitFn = ((value: unknown) => void) & { merge: (partial: unknown) => void };
1974
+ let capturedEmit: EmitFn | undefined;
1975
+
1976
+ const liveUser = query()
1977
+ .input(z.object({ id: z.string() }))
1978
+ .resolve(({ input }) => ({ id: input.id, name: "Initial", status: "offline" }))
1979
+ .subscribe(() => ({ emit }) => {
1980
+ capturedEmit = emit as EmitFn;
1981
+ });
1982
+
1983
+ const server = createApp({ queries: { liveUser } });
1984
+
1985
+ const results: unknown[] = [];
1986
+ const subscription = server
1987
+ .execute({
1988
+ path: "liveUser",
1989
+ input: { id: "1" },
1990
+ })
1991
+ .subscribe({
1992
+ next: (result) => results.push(result),
1993
+ });
1994
+
1995
+ // Wait for initial result
1996
+ await new Promise((r) => setTimeout(r, 50));
1997
+ expect(results.length).toBe(1);
1998
+ const initial = (results[0] as { data: { name: string; status: string } }).data;
1999
+ expect(initial.name).toBe("Initial");
2000
+ expect(initial.status).toBe("offline");
2001
+
2002
+ // Use merge for partial update
2003
+ capturedEmit!.merge({ status: "online" });
2004
+ await new Promise((r) => setTimeout(r, 50));
2005
+
2006
+ expect(results.length).toBe(2);
2007
+ const updated = (results[1] as { data: { name: string; status: string } }).data;
2008
+ expect(updated.name).toBe("Initial"); // Preserved
2009
+ expect(updated.status).toBe("online"); // Updated
2010
+
2011
+ subscription.unsubscribe();
2012
+ });
2013
+
2014
+ it("deduplicates identical emit values", async () => {
2015
+ let capturedEmit: ((value: { id: string; name: string }) => void) | undefined;
2016
+
2017
+ const liveUser = query()
2018
+ .input(z.object({ id: z.string() }))
2019
+ .resolve(({ input }) => ({ id: input.id, name: "Initial" }))
2020
+ .subscribe(() => ({ emit }) => {
2021
+ capturedEmit = emit;
2022
+ });
2023
+
2024
+ const server = createApp({ queries: { liveUser } });
2025
+
2026
+ const results: unknown[] = [];
2027
+ const subscription = server
2028
+ .execute({
2029
+ path: "liveUser",
2030
+ input: { id: "1" },
2031
+ })
2032
+ .subscribe({
2033
+ next: (result) => results.push(result),
2034
+ });
2035
+
2036
+ // Wait for initial result
2037
+ await new Promise((r) => setTimeout(r, 50));
2038
+ expect(results.length).toBe(1);
2039
+
2040
+ // Emit same value multiple times
2041
+ capturedEmit!({ id: "1", name: "Initial" });
2042
+ await new Promise((r) => setTimeout(r, 50));
2043
+ expect(results.length).toBe(1); // Should be deduplicated
2044
+
2045
+ // Emit different value
2046
+ capturedEmit!({ id: "1", name: "Changed" });
2047
+ await new Promise((r) => setTimeout(r, 50));
2048
+ expect(results.length).toBe(2);
2049
+
2050
+ // Emit same changed value again
2051
+ capturedEmit!({ id: "1", name: "Changed" });
2052
+ await new Promise((r) => setTimeout(r, 50));
2053
+ expect(results.length).toBe(2); // Should be deduplicated
2054
+
2055
+ subscription.unsubscribe();
2056
+ });
2057
+
2058
+ it("multiple subscriptions each get their own subscriber instance", async () => {
2059
+ let subscriberCallCount = 0;
2060
+ const emits: Array<(value: { id: string; name: string }) => void> = [];
2061
+
2062
+ const liveUser = query()
2063
+ .input(z.object({ id: z.string() }))
2064
+ .resolve(({ input }) => ({ id: input.id, name: "Initial" }))
2065
+ .subscribe(() => ({ emit }) => {
2066
+ subscriberCallCount++;
2067
+ emits.push(emit);
2068
+ });
2069
+
2070
+ const server = createApp({ queries: { liveUser } });
2071
+
2072
+ const results1: unknown[] = [];
2073
+ const results2: unknown[] = [];
2074
+
2075
+ const sub1 = server.execute({ path: "liveUser", input: { id: "1" } }).subscribe({ next: (r) => results1.push(r) });
2076
+
2077
+ const sub2 = server.execute({ path: "liveUser", input: { id: "2" } }).subscribe({ next: (r) => results2.push(r) });
2078
+
2079
+ // Wait for both subscriptions
2080
+ await new Promise((r) => setTimeout(r, 50));
2081
+
2082
+ expect(subscriberCallCount).toBe(2);
2083
+ expect(emits.length).toBe(2);
2084
+ expect(results1.length).toBe(1);
2085
+ expect(results2.length).toBe(1);
2086
+
2087
+ // Each emit only affects its subscription
2088
+ emits[0]({ id: "1", name: "Updated 1" });
2089
+ await new Promise((r) => setTimeout(r, 50));
2090
+
2091
+ expect(results1.length).toBe(2);
2092
+ expect(results2.length).toBe(1); // Unchanged
2093
+
2094
+ emits[1]({ id: "2", name: "Updated 2" });
2095
+ await new Promise((r) => setTimeout(r, 50));
2096
+
2097
+ expect(results1.length).toBe(2); // Unchanged
2098
+ expect(results2.length).toBe(2);
2099
+
2100
+ sub1.unsubscribe();
2101
+ sub2.unsubscribe();
2102
+ });
2103
+ });
@@ -29,9 +29,11 @@ import {
29
29
  hasInlineResolvers,
30
30
  type InferRouterContext,
31
31
  isEntityDef,
32
+ isLiveQueryDef,
32
33
  isModelDef,
33
34
  isMutationDef,
34
35
  isQueryDef,
36
+ type LiveQueryDef,
35
37
  mergeModelCollections,
36
38
  type Observable,
37
39
  type ResolverDef,
@@ -655,7 +657,7 @@ class LensServerImpl<
655
657
  : undefined;
656
658
 
657
659
  const lensContext = { ...context, emit, onCleanup };
658
- const result = resolver({ input: cleanInput, ctx: lensContext });
660
+ const result = resolver({ args: cleanInput, input: cleanInput, ctx: lensContext });
659
661
 
660
662
  if (isAsyncIterable(result)) {
661
663
  // Streaming: emit each yielded value
@@ -691,6 +693,32 @@ class LensServerImpl<
691
693
  : value;
692
694
  emitIfChanged(processed);
693
695
 
696
+ // LiveQueryDef: Call _subscriber for live updates (Publisher pattern)
697
+ // ADR-002: .resolve().subscribe() pattern for operation-level live queries
698
+ if (isQuery && isLiveQueryDef(def) && !cancelled) {
699
+ const liveQuery = def as LiveQueryDef<unknown, unknown, TContext>;
700
+ if (liveQuery._subscriber) {
701
+ try {
702
+ // Get publisher function from subscriber
703
+ const publisher = liveQuery._subscriber({
704
+ args: cleanInput as never, // Preferred parameter name
705
+ input: cleanInput as never, // Deprecated alias for backwards compatibility
706
+ ctx: context as TContext,
707
+ });
708
+ // Call publisher with emit/onCleanup callbacks
709
+ if (publisher) {
710
+ publisher({ emit, onCleanup });
711
+ }
712
+ } catch (err) {
713
+ if (!cancelled) {
714
+ observer.next?.({
715
+ error: err instanceof Error ? err : new Error(String(err)),
716
+ });
717
+ }
718
+ }
719
+ }
720
+ }
721
+
694
722
  // Mutations complete immediately - they're truly one-shot
695
723
  // Queries stay open for potential emit calls from field resolvers
696
724
  if (!isQuery && !cancelled) {