@sylphx/lens-server 2.9.0 → 2.10.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/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,18 @@ 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
+ isModelDef,
44
47
  isMutationDef,
45
48
  isQueryDef,
49
+ mergeModelCollections,
46
50
  valuesEqual
47
51
  } from "@sylphx/lens-core";
48
52
 
@@ -354,15 +358,23 @@ class LensServerImpl {
354
358
  }
355
359
  this.queries = queries;
356
360
  this.mutations = mutations;
357
- const entities = { ...config.entities ?? {} };
361
+ const autoCollected = config.router ? collectModelsFromRouter(config.router) : collectModelsFromOperations(queries, mutations);
362
+ const entitiesFromConfig = config.entities ?? {};
363
+ const mergedModels = mergeModelCollections(autoCollected, entitiesFromConfig);
358
364
  if (config.resolvers) {
359
365
  for (const resolver of config.resolvers) {
360
366
  const entityName = resolver.entity._name;
361
- if (entityName && !entities[entityName]) {
362
- entities[entityName] = resolver.entity;
367
+ if (entityName && !mergedModels.has(entityName)) {
368
+ mergedModels.set(entityName, resolver.entity);
363
369
  }
364
370
  }
365
371
  }
372
+ const entities = {};
373
+ for (const [name, model] of mergedModels) {
374
+ if (isEntityDef(model) || isModelDef(model)) {
375
+ entities[name] = model;
376
+ }
377
+ }
366
378
  this.entities = entities;
367
379
  this.resolverMap = this.buildResolverMap(config.resolvers, entities);
368
380
  this.contextFactory = config.context ?? (() => ({}));
@@ -415,7 +427,7 @@ class LensServerImpl {
415
427
  }
416
428
  }
417
429
  for (const [name, entity] of Object.entries(entities)) {
418
- if (!isEntityDef(entity))
430
+ if (!isEntityDef(entity) && !isModelDef(entity))
419
431
  continue;
420
432
  if (resolverMap.has(name))
421
433
  continue;
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.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.7.0"
33
+ "@sylphx/lens-core": "^2.8.0"
34
34
  },
35
35
  "devDependencies": {
36
36
  "typescript": "^5.9.3",
@@ -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,10 @@ import {
27
29
  hasInlineResolvers,
28
30
  type InferRouterContext,
29
31
  isEntityDef,
32
+ isModelDef,
30
33
  isMutationDef,
31
34
  isQueryDef,
35
+ mergeModelCollections,
32
36
  type Observable,
33
37
  type ResolverDef,
34
38
  type RouterDef,
@@ -192,16 +196,34 @@ class LensServerImpl<
192
196
  this.queries = queries as Q;
193
197
  this.mutations = mutations as M;
194
198
 
195
- // Build entities map: explicit config + auto-extracted from resolvers
196
- const entities: EntitiesMap = { ...(config.entities ?? {}) };
199
+ // Build entities map (priority: explicit config > router > resolvers)
200
+ // Auto-track models from router return types (new behavior)
201
+ const autoCollected = config.router
202
+ ? collectModelsFromRouter(config.router)
203
+ : collectModelsFromOperations(queries, mutations);
204
+
205
+ // Merge: explicit entities override auto-collected
206
+ const entitiesFromConfig = config.entities ?? {};
207
+ const mergedModels = mergeModelCollections(autoCollected, entitiesFromConfig);
208
+
209
+ // Also extract from explicit resolvers (legacy)
197
210
  if (config.resolvers) {
198
211
  for (const resolver of config.resolvers) {
199
212
  const entityName = resolver.entity._name;
200
- if (entityName && !entities[entityName]) {
201
- entities[entityName] = resolver.entity;
213
+ if (entityName && !mergedModels.has(entityName)) {
214
+ mergedModels.set(entityName, resolver.entity);
202
215
  }
203
216
  }
204
217
  }
218
+
219
+ // Convert Map to Record for entities (supports both EntityDef and ModelDef)
220
+ const entities: EntitiesMap = {};
221
+ for (const [name, model] of mergedModels) {
222
+ // Both ModelDef and EntityDef work as EntitiesMap values
223
+ if (isEntityDef(model) || isModelDef(model)) {
224
+ entities[name] = model as EntityDef<string, any>;
225
+ }
226
+ }
205
227
  this.entities = entities;
206
228
 
207
229
  // Build resolver map: explicit resolvers + auto-converted from entities with inline resolvers
@@ -278,12 +300,13 @@ class LensServerImpl<
278
300
  }
279
301
  }
280
302
 
281
- // 2. Auto-convert entities with inline resolvers (if not already in map)
303
+ // 2. Auto-convert entities/models with inline resolvers (if not already in map)
282
304
  for (const [name, entity] of Object.entries(entities)) {
283
- if (!isEntityDef(entity)) continue;
305
+ // Support both EntityDef and ModelDef
306
+ if (!isEntityDef(entity) && !isModelDef(entity)) continue;
284
307
  if (resolverMap.has(name)) continue; // Explicit resolver takes priority
285
308
 
286
- // Check if entity has inline resolvers
309
+ // Check if entity/model has inline resolvers
287
310
  if (hasInlineResolvers(entity)) {
288
311
  const resolver = createResolverFromEntity(entity);
289
312
  resolverMap.set(name, resolver);