@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 +204 -7
- package/dist/index.js +37 -4
- package/package.json +2 -2
- package/src/server/create.test.ts +376 -0
- package/src/server/create.ts +57 -7
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 {
|
|
15
|
-
import {
|
|
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
|
-
|
|
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
|
|
23
|
-
|
|
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
|
-
|
|
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
|
|
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 && !
|
|
362
|
-
|
|
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.
|
|
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.
|
|
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
|
+
});
|
package/src/server/create.ts
CHANGED
|
@@ -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
|
|
196
|
-
|
|
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 && !
|
|
201
|
-
|
|
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
|
-
|
|
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) {
|