@sylphx/lens-server 2.9.0 → 2.10.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 CHANGED
@@ -10,20 +10,217 @@ bun add @sylphx/lens-server
10
10
 
11
11
  ## Usage
12
12
 
13
+ ### Basic Server Setup
14
+
15
+ ```typescript
16
+ import { createApp, createHandler } from "@sylphx/lens-server";
17
+ import { model, lens, router, list, nullable } from "@sylphx/lens-core";
18
+ import { z } from "zod";
19
+
20
+ // Define context type
21
+ interface AppContext {
22
+ db: Database;
23
+ user: User | null;
24
+ }
25
+
26
+ // Define models with inline resolvers
27
+ const User = model<AppContext>("User", (t) => ({
28
+ id: t.id(),
29
+ name: t.string(),
30
+ email: t.string(),
31
+ posts: t.many(() => Post).resolve(({ parent, ctx }) =>
32
+ ctx.db.posts.filter(p => p.authorId === parent.id)
33
+ ),
34
+ }));
35
+
36
+ const Post = model<AppContext>("Post", (t) => ({
37
+ id: t.id(),
38
+ title: t.string(),
39
+ authorId: t.string(),
40
+ }));
41
+
42
+ // Create typed builders
43
+ const { query, mutation } = lens<AppContext>();
44
+
45
+ // Define operations
46
+ const appRouter = router({
47
+ user: {
48
+ get: query()
49
+ .input(z.object({ id: z.string() }))
50
+ .returns(User)
51
+ .resolve(({ input, ctx }) => ctx.db.users.get(input.id)!),
52
+
53
+ find: query()
54
+ .input(z.object({ email: z.string() }))
55
+ .returns(nullable(User)) // User | null
56
+ .resolve(({ input, ctx }) => ctx.db.users.findByEmail(input.email)),
57
+
58
+ list: query()
59
+ .returns(list(User)) // User[]
60
+ .resolve(({ ctx }) => ctx.db.users.findMany()),
61
+
62
+ update: mutation()
63
+ .input(z.object({ id: z.string(), name: z.string() }))
64
+ .returns(User)
65
+ .resolve(({ input, ctx }) => ctx.db.users.update(input)),
66
+ },
67
+ });
68
+
69
+ // Create server - models auto-tracked from router!
70
+ const app = createApp({
71
+ router: appRouter, // Models extracted from .returns()
72
+ context: () => ({
73
+ db: database,
74
+ user: getCurrentUser(),
75
+ }),
76
+ });
77
+
78
+ // Start server
79
+ const handler = createHandler(app);
80
+ Bun.serve({ port: 3000, fetch: handler });
81
+ ```
82
+
83
+ ### With Optimistic Updates
84
+
13
85
  ```typescript
14
- import { createServer } from "@sylphx/lens-server";
15
- import { appRouter } from "./router";
86
+ import { createApp, optimisticPlugin } from "@sylphx/lens-server";
87
+ import { model, lens, router } from "@sylphx/lens-core";
88
+ import { entity as e, temp, now } from "@sylphx/reify";
16
89
 
17
- const server = createServer({ router: appRouter });
90
+ // Enable optimistic plugin
91
+ const { query, mutation, plugins } = lens<AppContext>()
92
+ .withPlugins([optimisticPlugin()]);
93
+
94
+ const appRouter = router({
95
+ user: {
96
+ // Sugar syntax
97
+ update: mutation()
98
+ .input(z.object({ id: z.string(), name: z.string() }))
99
+ .returns(User)
100
+ .optimistic("merge") // Instant UI update
101
+ .resolve(({ input, ctx }) => ctx.db.users.update(input)),
102
+ },
103
+ message: {
104
+ // Reify DSL (multi-entity)
105
+ send: mutation()
106
+ .input(z.object({ content: z.string(), userId: z.string() }))
107
+ .returns(Message)
108
+ .optimistic(({ input }) => [
109
+ e.create(Message, {
110
+ id: temp(),
111
+ content: input.content,
112
+ createdAt: now(),
113
+ }),
114
+ ])
115
+ .resolve(({ input, ctx }) => ctx.db.messages.create(input)),
116
+ },
117
+ });
118
+
119
+ const app = createApp({
120
+ router: appRouter,
121
+ plugins, // Include optimistic plugin
122
+ context: () => ({ ... }),
123
+ });
124
+ ```
125
+
126
+ ### Live Queries
127
+
128
+ ```typescript
129
+ const { query } = lens<AppContext>();
130
+
131
+ // Live query with Publisher pattern
132
+ const watchUser = query()
133
+ .input(z.object({ id: z.string() }))
134
+ .resolve(({ input, ctx }) => ctx.db.users.get(input.id)!) // Initial value
135
+ .subscribe(({ input, ctx }) => ({ emit, onCleanup }) => {
136
+ // Publisher callback - emit/onCleanup passed here
137
+ const unsub = ctx.db.users.onChange(input.id, (user) => {
138
+ emit(user); // Push update to clients
139
+ });
140
+ onCleanup(unsub); // Cleanup on disconnect
141
+ });
142
+ ```
143
+
144
+ ### WebSocket Handler
145
+
146
+ ```typescript
147
+ import { createApp, createHandler, createWSHandler } from "@sylphx/lens-server";
148
+
149
+ const app = createApp({ ... });
150
+
151
+ // HTTP handler
152
+ const httpHandler = createHandler(app);
153
+
154
+ // WebSocket handler
155
+ const wsHandler = createWSHandler(app);
18
156
 
19
- // Handle WebSocket connections
20
157
  Bun.serve({
21
158
  port: 3000,
22
- fetch: server.fetch,
23
- websocket: server.websocket,
159
+ fetch(req, server) {
160
+ if (req.headers.get("upgrade") === "websocket") {
161
+ return wsHandler.upgrade(req, server);
162
+ }
163
+ return httpHandler(req);
164
+ },
165
+ websocket: wsHandler.websocket,
166
+ });
167
+ ```
168
+
169
+ ## createApp Options
170
+
171
+ ```typescript
172
+ createApp({
173
+ // Required (at least one)
174
+ router: RouterDef, // Namespaced operations
175
+
176
+ // Optional (models auto-tracked from router!)
177
+ entities: EntitiesMap, // Explicit models (optional, for overrides)
178
+ plugins: ServerPlugin[], // Server plugins (optimistic, clientState, etc.)
179
+ context: () => TContext, // Context factory
180
+ logger: LensLogger, // Logging (default: silent)
181
+ version: string, // Server version (default: "1.0.0")
24
182
  });
25
183
  ```
26
184
 
185
+ ## Auto-tracking Models
186
+
187
+ Models are automatically collected from router return types:
188
+
189
+ ```typescript
190
+ // These models are auto-tracked:
191
+ const appRouter = router({
192
+ user: {
193
+ get: query().returns(User).resolve(...), // User tracked
194
+ list: query().returns(list(User)).resolve(...), // User tracked
195
+ find: query().returns(nullable(User)).resolve(...), // User tracked
196
+ },
197
+ post: {
198
+ get: query().returns(Post).resolve(...), // Post tracked
199
+ },
200
+ });
201
+
202
+ // No need to pass entities explicitly
203
+ const app = createApp({
204
+ router: appRouter, // User and Post auto-collected
205
+ });
206
+
207
+ // Or override/add explicit models
208
+ const app = createApp({
209
+ router: appRouter,
210
+ entities: { User, Post, ExtraModel }, // Explicit takes priority
211
+ });
212
+ ```
213
+
214
+ ## Optimistic Update Strategies
215
+
216
+ | Strategy | Description | Example |
217
+ |----------|-------------|---------|
218
+ | `"merge"` | Merge input into entity | `.optimistic("merge")` |
219
+ | `"create"` | Create with temp ID | `.optimistic("create")` |
220
+ | `"delete"` | Mark entity deleted | `.optimistic("delete")` |
221
+ | `{ merge: {...} }` | Merge with extra fields | `.optimistic({ merge: { status: "pending" } })` |
222
+ | Reify DSL | Multi-entity operations | `.optimistic(({ input }) => [...])` |
223
+
27
224
  ## License
28
225
 
29
226
  MIT
@@ -32,4 +229,4 @@ MIT
32
229
 
33
230
  Built with [@sylphx/lens-core](https://github.com/SylphxAI/Lens).
34
231
 
35
- Powered by Sylphx
232
+ Powered by Sylphx
package/dist/index.js CHANGED
@@ -35,14 +35,19 @@ function extendContext(current, extension) {
35
35
  }
36
36
  // src/server/create.ts
37
37
  import {
38
+ collectModelsFromOperations,
39
+ collectModelsFromRouter,
38
40
  createEmit,
39
41
  createResolverFromEntity,
40
42
  flattenRouter,
41
43
  hashValue,
42
44
  hasInlineResolvers,
43
45
  isEntityDef,
46
+ isLiveQueryDef,
47
+ isModelDef,
44
48
  isMutationDef,
45
49
  isQueryDef,
50
+ mergeModelCollections,
46
51
  valuesEqual
47
52
  } from "@sylphx/lens-core";
48
53
 
@@ -354,15 +359,23 @@ class LensServerImpl {
354
359
  }
355
360
  this.queries = queries;
356
361
  this.mutations = mutations;
357
- const entities = { ...config.entities ?? {} };
362
+ const autoCollected = config.router ? collectModelsFromRouter(config.router) : collectModelsFromOperations(queries, mutations);
363
+ const entitiesFromConfig = config.entities ?? {};
364
+ const mergedModels = mergeModelCollections(autoCollected, entitiesFromConfig);
358
365
  if (config.resolvers) {
359
366
  for (const resolver of config.resolvers) {
360
367
  const entityName = resolver.entity._name;
361
- if (entityName && !entities[entityName]) {
362
- entities[entityName] = resolver.entity;
368
+ if (entityName && !mergedModels.has(entityName)) {
369
+ mergedModels.set(entityName, resolver.entity);
363
370
  }
364
371
  }
365
372
  }
373
+ const entities = {};
374
+ for (const [name, model] of mergedModels) {
375
+ if (isEntityDef(model) || isModelDef(model)) {
376
+ entities[name] = model;
377
+ }
378
+ }
366
379
  this.entities = entities;
367
380
  this.resolverMap = this.buildResolverMap(config.resolvers, entities);
368
381
  this.contextFactory = config.context ?? (() => ({}));
@@ -415,7 +428,7 @@ class LensServerImpl {
415
428
  }
416
429
  }
417
430
  for (const [name, entity] of Object.entries(entities)) {
418
- if (!isEntityDef(entity))
431
+ if (!isEntityDef(entity) && !isModelDef(entity))
419
432
  continue;
420
433
  if (resolverMap.has(name))
421
434
  continue;
@@ -627,6 +640,26 @@ class LensServerImpl {
627
640
  currentState = value;
628
641
  const processed = isQuery ? await this.processQueryResult(path, value, select, context, onCleanup, createFieldEmit) : value;
629
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
+ input: cleanInput,
649
+ ctx: context
650
+ });
651
+ if (publisher) {
652
+ publisher({ emit, onCleanup });
653
+ }
654
+ } catch (err) {
655
+ if (!cancelled) {
656
+ observer.next?.({
657
+ error: err instanceof Error ? err : new Error(String(err))
658
+ });
659
+ }
660
+ }
661
+ }
662
+ }
630
663
  if (!isQuery && !cancelled) {
631
664
  observer.complete?.();
632
665
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylphx/lens-server",
3
- "version": "2.9.0",
3
+ "version": "2.10.1",
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.7.0"
33
+ "@sylphx/lens-core": "^2.8.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
+ });
@@ -17,6 +17,8 @@
17
17
 
18
18
  import {
19
19
  type ContextValue,
20
+ collectModelsFromOperations,
21
+ collectModelsFromRouter,
20
22
  createEmit,
21
23
  createResolverFromEntity,
22
24
  type Emit,
@@ -27,8 +29,12 @@ import {
27
29
  hasInlineResolvers,
28
30
  type InferRouterContext,
29
31
  isEntityDef,
32
+ isLiveQueryDef,
33
+ isModelDef,
30
34
  isMutationDef,
31
35
  isQueryDef,
36
+ type LiveQueryDef,
37
+ mergeModelCollections,
32
38
  type Observable,
33
39
  type ResolverDef,
34
40
  type RouterDef,
@@ -192,16 +198,34 @@ class LensServerImpl<
192
198
  this.queries = queries as Q;
193
199
  this.mutations = mutations as M;
194
200
 
195
- // Build entities map: explicit config + auto-extracted from resolvers
196
- const entities: EntitiesMap = { ...(config.entities ?? {}) };
201
+ // Build entities map (priority: explicit config > router > resolvers)
202
+ // Auto-track models from router return types (new behavior)
203
+ const autoCollected = config.router
204
+ ? collectModelsFromRouter(config.router)
205
+ : collectModelsFromOperations(queries, mutations);
206
+
207
+ // Merge: explicit entities override auto-collected
208
+ const entitiesFromConfig = config.entities ?? {};
209
+ const mergedModels = mergeModelCollections(autoCollected, entitiesFromConfig);
210
+
211
+ // Also extract from explicit resolvers (legacy)
197
212
  if (config.resolvers) {
198
213
  for (const resolver of config.resolvers) {
199
214
  const entityName = resolver.entity._name;
200
- if (entityName && !entities[entityName]) {
201
- entities[entityName] = resolver.entity;
215
+ if (entityName && !mergedModels.has(entityName)) {
216
+ mergedModels.set(entityName, resolver.entity);
202
217
  }
203
218
  }
204
219
  }
220
+
221
+ // Convert Map to Record for entities (supports both EntityDef and ModelDef)
222
+ const entities: EntitiesMap = {};
223
+ for (const [name, model] of mergedModels) {
224
+ // Both ModelDef and EntityDef work as EntitiesMap values
225
+ if (isEntityDef(model) || isModelDef(model)) {
226
+ entities[name] = model as EntityDef<string, any>;
227
+ }
228
+ }
205
229
  this.entities = entities;
206
230
 
207
231
  // Build resolver map: explicit resolvers + auto-converted from entities with inline resolvers
@@ -278,12 +302,13 @@ class LensServerImpl<
278
302
  }
279
303
  }
280
304
 
281
- // 2. Auto-convert entities with inline resolvers (if not already in map)
305
+ // 2. Auto-convert entities/models with inline resolvers (if not already in map)
282
306
  for (const [name, entity] of Object.entries(entities)) {
283
- if (!isEntityDef(entity)) continue;
307
+ // Support both EntityDef and ModelDef
308
+ if (!isEntityDef(entity) && !isModelDef(entity)) continue;
284
309
  if (resolverMap.has(name)) continue; // Explicit resolver takes priority
285
310
 
286
- // Check if entity has inline resolvers
311
+ // Check if entity/model has inline resolvers
287
312
  if (hasInlineResolvers(entity)) {
288
313
  const resolver = createResolverFromEntity(entity);
289
314
  resolverMap.set(name, resolver);
@@ -668,6 +693,31 @@ class LensServerImpl<
668
693
  : value;
669
694
  emitIfChanged(processed);
670
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
+ input: cleanInput as never, // Type-safe at runtime via input validation
705
+ ctx: context as TContext,
706
+ });
707
+ // Call publisher with emit/onCleanup callbacks
708
+ if (publisher) {
709
+ publisher({ emit, onCleanup });
710
+ }
711
+ } catch (err) {
712
+ if (!cancelled) {
713
+ observer.next?.({
714
+ error: err instanceof Error ? err : new Error(String(err)),
715
+ });
716
+ }
717
+ }
718
+ }
719
+ }
720
+
671
721
  // Mutations complete immediately - they're truly one-shot
672
722
  // Queries stay open for potential emit calls from field resolvers
673
723
  if (!isQuery && !cancelled) {