@sylphx/lens-server 1.0.4 → 1.3.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 +145 -11
- package/dist/index.js +267 -18
- package/package.json +2 -2
- package/src/e2e/server.test.ts +10 -10
- package/src/index.ts +21 -0
- package/src/server/create.test.ts +11 -11
- package/src/server/create.ts +125 -27
- package/src/state/graph-state-manager.test.ts +215 -0
- package/src/state/graph-state-manager.ts +423 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { query, mutation, router, QueryBuilder, MutationBuilder, QueryDef as QueryDef2, MutationDef as MutationDef2, RouterDef as RouterDef2, RouterRoutes, ResolverFn, ResolverContext, InferRouterContext as InferRouterContext2 } from "@sylphx/lens-core";
|
|
2
|
+
import { ContextValue, EntityDef, EntityDefinition, EntityResolvers, EntityResolversDefinition, InferRouterContext, MutationDef, QueryDef, RelationDef, RelationTypeWithForeignKey, RouterDef } from "@sylphx/lens-core";
|
|
3
|
+
import { EntityKey, Update, EmitCommand, InternalFieldUpdate, ArrayOperation } from "@sylphx/lens-core";
|
|
3
4
|
/** Client connection interface */
|
|
4
5
|
interface StateClient {
|
|
5
6
|
id: string;
|
|
@@ -56,8 +57,12 @@ declare class GraphStateManager {
|
|
|
56
57
|
private clients;
|
|
57
58
|
/** Canonical state per entity (server truth) */
|
|
58
59
|
private canonical;
|
|
60
|
+
/** Canonical array state per entity (server truth for array outputs) */
|
|
61
|
+
private canonicalArrays;
|
|
59
62
|
/** Per-client state tracking */
|
|
60
63
|
private clientStates;
|
|
64
|
+
/** Per-client array state tracking */
|
|
65
|
+
private clientArrayStates;
|
|
61
66
|
/** Entity → subscribed client IDs */
|
|
62
67
|
private entitySubscribers;
|
|
63
68
|
/** Configuration */
|
|
@@ -96,6 +101,63 @@ declare class GraphStateManager {
|
|
|
96
101
|
replace?: boolean;
|
|
97
102
|
}): void;
|
|
98
103
|
/**
|
|
104
|
+
* Emit a field-level update with a specific strategy.
|
|
105
|
+
* Applies the update to canonical state and pushes to clients.
|
|
106
|
+
*
|
|
107
|
+
* @param entity - Entity name
|
|
108
|
+
* @param id - Entity ID
|
|
109
|
+
* @param field - Field name to update
|
|
110
|
+
* @param update - Update with strategy (value/delta/patch)
|
|
111
|
+
*/
|
|
112
|
+
emitField(entity: string, id: string, field: string, update: Update): void;
|
|
113
|
+
/**
|
|
114
|
+
* Emit multiple field updates in a batch.
|
|
115
|
+
* More efficient than multiple emitField calls.
|
|
116
|
+
*
|
|
117
|
+
* @param entity - Entity name
|
|
118
|
+
* @param id - Entity ID
|
|
119
|
+
* @param updates - Array of field updates
|
|
120
|
+
*/
|
|
121
|
+
emitBatch(entity: string, id: string, updates: InternalFieldUpdate[]): void;
|
|
122
|
+
/**
|
|
123
|
+
* Process an EmitCommand from the Emit API.
|
|
124
|
+
* Routes to appropriate emit method.
|
|
125
|
+
*
|
|
126
|
+
* @param entity - Entity name
|
|
127
|
+
* @param id - Entity ID
|
|
128
|
+
* @param command - Emit command from resolver
|
|
129
|
+
*/
|
|
130
|
+
processCommand(entity: string, id: string, command: EmitCommand): void;
|
|
131
|
+
/**
|
|
132
|
+
* Emit array data (replace entire array).
|
|
133
|
+
*
|
|
134
|
+
* @param entity - Entity name
|
|
135
|
+
* @param id - Entity ID
|
|
136
|
+
* @param items - Array items
|
|
137
|
+
*/
|
|
138
|
+
emitArray(entity: string, id: string, items: unknown[]): void;
|
|
139
|
+
/**
|
|
140
|
+
* Apply an array operation to the canonical state.
|
|
141
|
+
*
|
|
142
|
+
* @param entity - Entity name
|
|
143
|
+
* @param id - Entity ID
|
|
144
|
+
* @param operation - Array operation to apply
|
|
145
|
+
*/
|
|
146
|
+
emitArrayOperation(entity: string, id: string, operation: ArrayOperation): void;
|
|
147
|
+
/**
|
|
148
|
+
* Apply an array operation and return new array.
|
|
149
|
+
*/
|
|
150
|
+
private applyArrayOperation;
|
|
151
|
+
/**
|
|
152
|
+
* Push array update to a specific client.
|
|
153
|
+
* Computes optimal diff strategy.
|
|
154
|
+
*/
|
|
155
|
+
private pushArrayToClient;
|
|
156
|
+
/**
|
|
157
|
+
* Get current canonical array state
|
|
158
|
+
*/
|
|
159
|
+
getArrayState(entity: string, id: string): unknown[] | undefined;
|
|
160
|
+
/**
|
|
99
161
|
* Get current canonical state for an entity
|
|
100
162
|
*/
|
|
101
163
|
getState(entity: string, id: string): Record<string, unknown> | undefined;
|
|
@@ -108,6 +170,16 @@ declare class GraphStateManager {
|
|
|
108
170
|
*/
|
|
109
171
|
private pushToClient;
|
|
110
172
|
/**
|
|
173
|
+
* Push a single field update to a client.
|
|
174
|
+
* Computes optimal transfer strategy.
|
|
175
|
+
*/
|
|
176
|
+
private pushFieldToClient;
|
|
177
|
+
/**
|
|
178
|
+
* Push multiple field updates to a client.
|
|
179
|
+
* Computes optimal transfer strategy for each field.
|
|
180
|
+
*/
|
|
181
|
+
private pushFieldsToClient;
|
|
182
|
+
/**
|
|
111
183
|
* Send initial data to a newly subscribed client
|
|
112
184
|
*/
|
|
113
185
|
private sendInitialData;
|
|
@@ -157,20 +229,23 @@ type OperationsMap = {
|
|
|
157
229
|
[key: string]: OperationMeta | OperationsMap;
|
|
158
230
|
};
|
|
159
231
|
/** Server configuration */
|
|
160
|
-
interface LensServerConfig<
|
|
232
|
+
interface LensServerConfig<
|
|
233
|
+
TContext extends ContextValue = ContextValue,
|
|
234
|
+
TRouter extends RouterDef = RouterDef
|
|
235
|
+
> {
|
|
161
236
|
/** Entity definitions */
|
|
162
237
|
entities?: EntitiesMap;
|
|
163
238
|
/** Relation definitions */
|
|
164
239
|
relations?: RelationsArray;
|
|
165
|
-
/** Router definition (namespaced operations) */
|
|
166
|
-
router?:
|
|
240
|
+
/** Router definition (namespaced operations) - context type is inferred */
|
|
241
|
+
router?: TRouter;
|
|
167
242
|
/** Query definitions (flat, legacy) */
|
|
168
243
|
queries?: QueriesMap;
|
|
169
244
|
/** Mutation definitions (flat, legacy) */
|
|
170
245
|
mutations?: MutationsMap;
|
|
171
246
|
/** Entity resolvers */
|
|
172
247
|
resolvers?: EntityResolvers<EntityResolversDefinition>;
|
|
173
|
-
/** Context factory */
|
|
248
|
+
/** Context factory - must return the context type expected by the router */
|
|
174
249
|
context?: (req?: unknown) => TContext | Promise<TContext>;
|
|
175
250
|
/** Server version */
|
|
176
251
|
version?: string;
|
|
@@ -351,19 +426,78 @@ type InferApi<T extends LensServer> = T extends LensServerImpl<infer Q, infer M>
|
|
|
351
426
|
mutations: M;
|
|
352
427
|
} : never;
|
|
353
428
|
/**
|
|
354
|
-
*
|
|
429
|
+
* Config helper type that infers context from router
|
|
355
430
|
*/
|
|
356
|
-
|
|
431
|
+
type ServerConfigWithInferredContext<
|
|
432
|
+
TRouter extends RouterDef,
|
|
433
|
+
Q extends QueriesMap = QueriesMap,
|
|
434
|
+
M extends MutationsMap = MutationsMap
|
|
435
|
+
> = {
|
|
436
|
+
entities?: EntitiesMap;
|
|
437
|
+
relations?: RelationsArray;
|
|
438
|
+
router: TRouter;
|
|
439
|
+
queries?: Q;
|
|
440
|
+
mutations?: M;
|
|
441
|
+
resolvers?: EntityResolvers<EntityResolversDefinition>;
|
|
442
|
+
/** Context factory - type is inferred from router's procedures */
|
|
443
|
+
context?: (req?: unknown) => InferRouterContext<TRouter> | Promise<InferRouterContext<TRouter>>;
|
|
444
|
+
version?: string;
|
|
445
|
+
};
|
|
446
|
+
/**
|
|
447
|
+
* Config without router (legacy flat queries/mutations)
|
|
448
|
+
*/
|
|
449
|
+
type ServerConfigLegacy<
|
|
357
450
|
TContext extends ContextValue = ContextValue,
|
|
358
451
|
Q extends QueriesMap = QueriesMap,
|
|
359
452
|
M extends MutationsMap = MutationsMap
|
|
360
|
-
>
|
|
453
|
+
> = {
|
|
454
|
+
entities?: EntitiesMap;
|
|
455
|
+
relations?: RelationsArray;
|
|
456
|
+
router?: undefined;
|
|
361
457
|
queries?: Q;
|
|
362
458
|
mutations?: M;
|
|
363
|
-
|
|
459
|
+
resolvers?: EntityResolvers<EntityResolversDefinition>;
|
|
460
|
+
context?: (req?: unknown) => TContext | Promise<TContext>;
|
|
461
|
+
version?: string;
|
|
462
|
+
};
|
|
463
|
+
/**
|
|
464
|
+
* Create Lens server with Operations API + Optimization Layer
|
|
465
|
+
*
|
|
466
|
+
* When using a router with typed context (from initLens), the context
|
|
467
|
+
* function's return type is automatically enforced to match.
|
|
468
|
+
*
|
|
469
|
+
* @example
|
|
470
|
+
* ```typescript
|
|
471
|
+
* // Context type is inferred from router's procedures
|
|
472
|
+
* const server = createServer({
|
|
473
|
+
* router: appRouter, // RouterDef with MyContext
|
|
474
|
+
* context: () => ({
|
|
475
|
+
* db: prisma,
|
|
476
|
+
* user: null,
|
|
477
|
+
* }), // Must match MyContext!
|
|
478
|
+
* })
|
|
479
|
+
* ```
|
|
480
|
+
*/
|
|
481
|
+
declare function createServer<
|
|
482
|
+
TRouter extends RouterDef,
|
|
483
|
+
Q extends QueriesMap = QueriesMap,
|
|
484
|
+
M extends MutationsMap = MutationsMap
|
|
485
|
+
>(config: ServerConfigWithInferredContext<TRouter, Q, M>): LensServer & {
|
|
486
|
+
_types: {
|
|
487
|
+
queries: Q;
|
|
488
|
+
mutations: M;
|
|
489
|
+
context: InferRouterContext<TRouter>;
|
|
490
|
+
};
|
|
491
|
+
};
|
|
492
|
+
declare function createServer<
|
|
493
|
+
TContext extends ContextValue = ContextValue,
|
|
494
|
+
Q extends QueriesMap = QueriesMap,
|
|
495
|
+
M extends MutationsMap = MutationsMap
|
|
496
|
+
>(config: ServerConfigLegacy<TContext, Q, M>): LensServer & {
|
|
364
497
|
_types: {
|
|
365
498
|
queries: Q;
|
|
366
499
|
mutations: M;
|
|
500
|
+
context: TContext;
|
|
367
501
|
};
|
|
368
502
|
};
|
|
369
503
|
/** SSE handler configuration */
|
|
@@ -436,4 +570,4 @@ declare class SSEHandler {
|
|
|
436
570
|
* Create SSE handler (transport adapter)
|
|
437
571
|
*/
|
|
438
572
|
declare function createSSEHandler(config: SSEHandlerConfig): SSEHandler;
|
|
439
|
-
export { createServer, createSSEHandler, createGraphStateManager, WebSocketLike, Subscription, StateUpdateMessage, StateFullMessage, StateClient, ServerMetadata, LensServerConfig as ServerConfig, SelectionObject, SSEHandlerConfig, SSEHandler, SSEClientInfo, RelationsArray, QueriesMap, OperationsMap, OperationMeta, MutationsMap, LensServer, LensResult, LensOperation, InferOutput, InferInput, InferApi, GraphStateManagerConfig, GraphStateManager, EntityKey, EntitiesMap };
|
|
573
|
+
export { router, query, mutation, createServer, createSSEHandler, createGraphStateManager, WebSocketLike, Subscription, StateUpdateMessage, StateFullMessage, StateClient, ServerMetadata, LensServerConfig as ServerConfig, SelectionObject, SSEHandlerConfig, SSEHandler, SSEClientInfo, RouterRoutes, RouterDef2 as RouterDef, ResolverFn, ResolverContext, RelationsArray, QueryDef2 as QueryDef, QueryBuilder, QueriesMap, OperationsMap, OperationMeta, MutationsMap, MutationDef2 as MutationDef, MutationBuilder, LensServer, LensResult, LensOperation, InferRouterContext2 as InferRouterContext, InferOutput, InferInput, InferApi, GraphStateManagerConfig, GraphStateManager, EntityKey, EntitiesMap };
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import {
|
|
3
|
+
query,
|
|
4
|
+
mutation,
|
|
5
|
+
router
|
|
6
|
+
} from "@sylphx/lens-core";
|
|
7
|
+
|
|
1
8
|
// src/server/create.ts
|
|
2
9
|
import {
|
|
3
10
|
createContext,
|
|
11
|
+
createEmit,
|
|
4
12
|
createUpdate as createUpdate2,
|
|
5
13
|
flattenRouter,
|
|
6
14
|
isBatchResolver,
|
|
@@ -10,12 +18,18 @@ import {
|
|
|
10
18
|
} from "@sylphx/lens-core";
|
|
11
19
|
|
|
12
20
|
// src/state/graph-state-manager.ts
|
|
13
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
createUpdate,
|
|
23
|
+
applyUpdate,
|
|
24
|
+
makeEntityKey
|
|
25
|
+
} from "@sylphx/lens-core";
|
|
14
26
|
|
|
15
27
|
class GraphStateManager {
|
|
16
28
|
clients = new Map;
|
|
17
29
|
canonical = new Map;
|
|
30
|
+
canonicalArrays = new Map;
|
|
18
31
|
clientStates = new Map;
|
|
32
|
+
clientArrayStates = new Map;
|
|
19
33
|
entitySubscribers = new Map;
|
|
20
34
|
config;
|
|
21
35
|
constructor(config = {}) {
|
|
@@ -24,6 +38,7 @@ class GraphStateManager {
|
|
|
24
38
|
addClient(client) {
|
|
25
39
|
this.clients.set(client.id, client);
|
|
26
40
|
this.clientStates.set(client.id, new Map);
|
|
41
|
+
this.clientArrayStates.set(client.id, new Map);
|
|
27
42
|
}
|
|
28
43
|
removeClient(clientId) {
|
|
29
44
|
for (const [key, subscribers] of this.entitySubscribers) {
|
|
@@ -34,6 +49,7 @@ class GraphStateManager {
|
|
|
34
49
|
}
|
|
35
50
|
this.clients.delete(clientId);
|
|
36
51
|
this.clientStates.delete(clientId);
|
|
52
|
+
this.clientArrayStates.delete(clientId);
|
|
37
53
|
}
|
|
38
54
|
subscribe(clientId, entity, id, fields = "*") {
|
|
39
55
|
const key = this.makeKey(entity, id);
|
|
@@ -96,6 +112,148 @@ class GraphStateManager {
|
|
|
96
112
|
this.pushToClient(clientId, entity, id, key, currentCanonical);
|
|
97
113
|
}
|
|
98
114
|
}
|
|
115
|
+
emitField(entity, id, field, update) {
|
|
116
|
+
const key = this.makeKey(entity, id);
|
|
117
|
+
let currentCanonical = this.canonical.get(key);
|
|
118
|
+
if (!currentCanonical) {
|
|
119
|
+
currentCanonical = {};
|
|
120
|
+
}
|
|
121
|
+
const oldValue = currentCanonical[field];
|
|
122
|
+
const newValue = applyUpdate(oldValue, update);
|
|
123
|
+
currentCanonical = { ...currentCanonical, [field]: newValue };
|
|
124
|
+
this.canonical.set(key, currentCanonical);
|
|
125
|
+
const subscribers = this.entitySubscribers.get(key);
|
|
126
|
+
if (!subscribers)
|
|
127
|
+
return;
|
|
128
|
+
for (const clientId of subscribers) {
|
|
129
|
+
this.pushFieldToClient(clientId, entity, id, key, field, newValue);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
emitBatch(entity, id, updates) {
|
|
133
|
+
const key = this.makeKey(entity, id);
|
|
134
|
+
let currentCanonical = this.canonical.get(key);
|
|
135
|
+
if (!currentCanonical) {
|
|
136
|
+
currentCanonical = {};
|
|
137
|
+
}
|
|
138
|
+
const changedFields = [];
|
|
139
|
+
for (const { field, update } of updates) {
|
|
140
|
+
const oldValue = currentCanonical[field];
|
|
141
|
+
const newValue = applyUpdate(oldValue, update);
|
|
142
|
+
currentCanonical[field] = newValue;
|
|
143
|
+
changedFields.push(field);
|
|
144
|
+
}
|
|
145
|
+
this.canonical.set(key, currentCanonical);
|
|
146
|
+
const subscribers = this.entitySubscribers.get(key);
|
|
147
|
+
if (!subscribers)
|
|
148
|
+
return;
|
|
149
|
+
for (const clientId of subscribers) {
|
|
150
|
+
this.pushFieldsToClient(clientId, entity, id, key, changedFields, currentCanonical);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
processCommand(entity, id, command) {
|
|
154
|
+
switch (command.type) {
|
|
155
|
+
case "full":
|
|
156
|
+
this.emit(entity, id, command.data, {
|
|
157
|
+
replace: command.replace
|
|
158
|
+
});
|
|
159
|
+
break;
|
|
160
|
+
case "field":
|
|
161
|
+
this.emitField(entity, id, command.field, command.update);
|
|
162
|
+
break;
|
|
163
|
+
case "batch":
|
|
164
|
+
this.emitBatch(entity, id, command.updates);
|
|
165
|
+
break;
|
|
166
|
+
case "array":
|
|
167
|
+
this.emitArrayOperation(entity, id, command.operation);
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
emitArray(entity, id, items) {
|
|
172
|
+
const key = this.makeKey(entity, id);
|
|
173
|
+
this.canonicalArrays.set(key, [...items]);
|
|
174
|
+
const subscribers = this.entitySubscribers.get(key);
|
|
175
|
+
if (!subscribers)
|
|
176
|
+
return;
|
|
177
|
+
for (const clientId of subscribers) {
|
|
178
|
+
this.pushArrayToClient(clientId, entity, id, key, items);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
emitArrayOperation(entity, id, operation) {
|
|
182
|
+
const key = this.makeKey(entity, id);
|
|
183
|
+
let currentArray = this.canonicalArrays.get(key);
|
|
184
|
+
if (!currentArray) {
|
|
185
|
+
currentArray = [];
|
|
186
|
+
}
|
|
187
|
+
const newArray = this.applyArrayOperation([...currentArray], operation);
|
|
188
|
+
this.canonicalArrays.set(key, newArray);
|
|
189
|
+
const subscribers = this.entitySubscribers.get(key);
|
|
190
|
+
if (!subscribers)
|
|
191
|
+
return;
|
|
192
|
+
for (const clientId of subscribers) {
|
|
193
|
+
this.pushArrayToClient(clientId, entity, id, key, newArray);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
applyArrayOperation(array, operation) {
|
|
197
|
+
switch (operation.op) {
|
|
198
|
+
case "push":
|
|
199
|
+
return [...array, operation.item];
|
|
200
|
+
case "unshift":
|
|
201
|
+
return [operation.item, ...array];
|
|
202
|
+
case "insert":
|
|
203
|
+
return [
|
|
204
|
+
...array.slice(0, operation.index),
|
|
205
|
+
operation.item,
|
|
206
|
+
...array.slice(operation.index)
|
|
207
|
+
];
|
|
208
|
+
case "remove":
|
|
209
|
+
return [...array.slice(0, operation.index), ...array.slice(operation.index + 1)];
|
|
210
|
+
case "removeById": {
|
|
211
|
+
const idx = array.findIndex((item) => typeof item === "object" && item !== null && ("id" in item) && item.id === operation.id);
|
|
212
|
+
if (idx === -1)
|
|
213
|
+
return array;
|
|
214
|
+
return [...array.slice(0, idx), ...array.slice(idx + 1)];
|
|
215
|
+
}
|
|
216
|
+
case "update":
|
|
217
|
+
return array.map((item, i) => i === operation.index ? operation.item : item);
|
|
218
|
+
case "updateById":
|
|
219
|
+
return array.map((item) => typeof item === "object" && item !== null && ("id" in item) && item.id === operation.id ? operation.item : item);
|
|
220
|
+
case "merge":
|
|
221
|
+
return array.map((item, i) => i === operation.index && typeof item === "object" && item !== null ? { ...item, ...operation.partial } : item);
|
|
222
|
+
case "mergeById":
|
|
223
|
+
return array.map((item) => typeof item === "object" && item !== null && ("id" in item) && item.id === operation.id ? { ...item, ...operation.partial } : item);
|
|
224
|
+
default:
|
|
225
|
+
return array;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
pushArrayToClient(clientId, entity, id, key, newArray) {
|
|
229
|
+
const client = this.clients.get(clientId);
|
|
230
|
+
if (!client)
|
|
231
|
+
return;
|
|
232
|
+
const clientArrayStateMap = this.clientArrayStates.get(clientId);
|
|
233
|
+
if (!clientArrayStateMap)
|
|
234
|
+
return;
|
|
235
|
+
let clientArrayState = clientArrayStateMap.get(key);
|
|
236
|
+
if (!clientArrayState) {
|
|
237
|
+
clientArrayState = { lastState: [] };
|
|
238
|
+
clientArrayStateMap.set(key, clientArrayState);
|
|
239
|
+
}
|
|
240
|
+
const { lastState } = clientArrayState;
|
|
241
|
+
if (JSON.stringify(lastState) === JSON.stringify(newArray)) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
client.send({
|
|
245
|
+
type: "update",
|
|
246
|
+
entity,
|
|
247
|
+
id,
|
|
248
|
+
updates: {
|
|
249
|
+
_items: { strategy: "value", data: newArray }
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
clientArrayState.lastState = [...newArray];
|
|
253
|
+
}
|
|
254
|
+
getArrayState(entity, id) {
|
|
255
|
+
return this.canonicalArrays.get(this.makeKey(entity, id));
|
|
256
|
+
}
|
|
99
257
|
getState(entity, id) {
|
|
100
258
|
return this.canonical.get(this.makeKey(entity, id));
|
|
101
259
|
}
|
|
@@ -143,6 +301,77 @@ class GraphStateManager {
|
|
|
143
301
|
}
|
|
144
302
|
}
|
|
145
303
|
}
|
|
304
|
+
pushFieldToClient(clientId, entity, id, key, field, newValue) {
|
|
305
|
+
const client = this.clients.get(clientId);
|
|
306
|
+
if (!client)
|
|
307
|
+
return;
|
|
308
|
+
const clientStateMap = this.clientStates.get(clientId);
|
|
309
|
+
if (!clientStateMap)
|
|
310
|
+
return;
|
|
311
|
+
const clientEntityState = clientStateMap.get(key);
|
|
312
|
+
if (!clientEntityState)
|
|
313
|
+
return;
|
|
314
|
+
const { lastState, fields } = clientEntityState;
|
|
315
|
+
if (fields !== "*" && !fields.has(field)) {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const oldValue = lastState[field];
|
|
319
|
+
if (oldValue === newValue)
|
|
320
|
+
return;
|
|
321
|
+
if (typeof oldValue === "object" && typeof newValue === "object" && JSON.stringify(oldValue) === JSON.stringify(newValue)) {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const update = createUpdate(oldValue, newValue);
|
|
325
|
+
client.send({
|
|
326
|
+
type: "update",
|
|
327
|
+
entity,
|
|
328
|
+
id,
|
|
329
|
+
updates: { [field]: update }
|
|
330
|
+
});
|
|
331
|
+
clientEntityState.lastState[field] = newValue;
|
|
332
|
+
}
|
|
333
|
+
pushFieldsToClient(clientId, entity, id, key, changedFields, newState) {
|
|
334
|
+
const client = this.clients.get(clientId);
|
|
335
|
+
if (!client)
|
|
336
|
+
return;
|
|
337
|
+
const clientStateMap = this.clientStates.get(clientId);
|
|
338
|
+
if (!clientStateMap)
|
|
339
|
+
return;
|
|
340
|
+
const clientEntityState = clientStateMap.get(key);
|
|
341
|
+
if (!clientEntityState)
|
|
342
|
+
return;
|
|
343
|
+
const { lastState, fields } = clientEntityState;
|
|
344
|
+
const updates = {};
|
|
345
|
+
let hasChanges = false;
|
|
346
|
+
for (const field of changedFields) {
|
|
347
|
+
if (fields !== "*" && !fields.has(field)) {
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
const oldValue = lastState[field];
|
|
351
|
+
const newValue = newState[field];
|
|
352
|
+
if (oldValue === newValue)
|
|
353
|
+
continue;
|
|
354
|
+
if (typeof oldValue === "object" && typeof newValue === "object" && JSON.stringify(oldValue) === JSON.stringify(newValue)) {
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
const update = createUpdate(oldValue, newValue);
|
|
358
|
+
updates[field] = update;
|
|
359
|
+
hasChanges = true;
|
|
360
|
+
}
|
|
361
|
+
if (!hasChanges)
|
|
362
|
+
return;
|
|
363
|
+
client.send({
|
|
364
|
+
type: "update",
|
|
365
|
+
entity,
|
|
366
|
+
id,
|
|
367
|
+
updates
|
|
368
|
+
});
|
|
369
|
+
for (const field of changedFields) {
|
|
370
|
+
if (newState[field] !== undefined) {
|
|
371
|
+
clientEntityState.lastState[field] = newState[field];
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
146
375
|
sendInitialData(clientId, entity, id, state, fields) {
|
|
147
376
|
const client = this.clients.get(clientId);
|
|
148
377
|
if (!client)
|
|
@@ -195,7 +424,9 @@ class GraphStateManager {
|
|
|
195
424
|
clear() {
|
|
196
425
|
this.clients.clear();
|
|
197
426
|
this.canonical.clear();
|
|
427
|
+
this.canonicalArrays.clear();
|
|
198
428
|
this.clientStates.clear();
|
|
429
|
+
this.clientArrayStates.clear();
|
|
199
430
|
this.entitySubscribers.clear();
|
|
200
431
|
}
|
|
201
432
|
}
|
|
@@ -505,21 +736,31 @@ class LensServerImpl {
|
|
|
505
736
|
if (!resolver) {
|
|
506
737
|
throw new Error(`Query ${sub.operation} has no resolver`);
|
|
507
738
|
}
|
|
508
|
-
const
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
if (idx >= 0)
|
|
516
|
-
sub.cleanups.splice(idx, 1);
|
|
517
|
-
};
|
|
739
|
+
const emit = createEmit((command) => {
|
|
740
|
+
const entityName = this.getEntityNameFromOutput(queryDef._output);
|
|
741
|
+
if (entityName) {
|
|
742
|
+
const entities = this.extractEntities(entityName, command.type === "full" ? command.data : {});
|
|
743
|
+
for (const { entity, id } of entities) {
|
|
744
|
+
this.stateManager.processCommand(entity, id, command);
|
|
745
|
+
}
|
|
518
746
|
}
|
|
747
|
+
if (command.type === "full") {
|
|
748
|
+
emitData(command.data);
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
const onCleanup = (fn) => {
|
|
752
|
+
sub.cleanups.push(fn);
|
|
753
|
+
return () => {
|
|
754
|
+
const idx = sub.cleanups.indexOf(fn);
|
|
755
|
+
if (idx >= 0)
|
|
756
|
+
sub.cleanups.splice(idx, 1);
|
|
757
|
+
};
|
|
519
758
|
};
|
|
520
759
|
const result = resolver({
|
|
521
760
|
input: sub.input,
|
|
522
|
-
ctx:
|
|
761
|
+
ctx: context,
|
|
762
|
+
emit,
|
|
763
|
+
onCleanup
|
|
523
764
|
});
|
|
524
765
|
if (isAsyncIterable(result)) {
|
|
525
766
|
for await (const value of result) {
|
|
@@ -668,13 +909,14 @@ class LensServerImpl {
|
|
|
668
909
|
if (!resolver) {
|
|
669
910
|
throw new Error(`Query ${name} has no resolver`);
|
|
670
911
|
}
|
|
671
|
-
const
|
|
912
|
+
const emit = createEmit(() => {});
|
|
913
|
+
const onCleanup = () => () => {};
|
|
914
|
+
const result = resolver({
|
|
672
915
|
input: cleanInput,
|
|
673
916
|
ctx: context,
|
|
674
|
-
emit
|
|
675
|
-
onCleanup
|
|
676
|
-
};
|
|
677
|
-
const result = resolver(resolverCtx);
|
|
917
|
+
emit,
|
|
918
|
+
onCleanup
|
|
919
|
+
});
|
|
678
920
|
let data;
|
|
679
921
|
if (isAsyncIterable(result)) {
|
|
680
922
|
for await (const value of result) {
|
|
@@ -711,9 +953,13 @@ class LensServerImpl {
|
|
|
711
953
|
if (!resolver) {
|
|
712
954
|
throw new Error(`Mutation ${name} has no resolver`);
|
|
713
955
|
}
|
|
956
|
+
const emit = createEmit(() => {});
|
|
957
|
+
const onCleanup = () => () => {};
|
|
714
958
|
const result = await resolver({
|
|
715
959
|
input,
|
|
716
|
-
ctx: context
|
|
960
|
+
ctx: context,
|
|
961
|
+
emit,
|
|
962
|
+
onCleanup
|
|
717
963
|
});
|
|
718
964
|
const entityName = this.getEntityNameFromMutation(name);
|
|
719
965
|
const entities = this.extractEntities(entityName, result);
|
|
@@ -1164,6 +1410,9 @@ function createSSEHandler(config) {
|
|
|
1164
1410
|
return new SSEHandler(config);
|
|
1165
1411
|
}
|
|
1166
1412
|
export {
|
|
1413
|
+
router,
|
|
1414
|
+
query,
|
|
1415
|
+
mutation,
|
|
1167
1416
|
createServer,
|
|
1168
1417
|
createSSEHandler,
|
|
1169
1418
|
createGraphStateManager,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sylphx/lens-server",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Server runtime for Lens API framework",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"author": "SylphxAI",
|
|
30
30
|
"license": "MIT",
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@sylphx/lens-core": "^1.0
|
|
32
|
+
"@sylphx/lens-core": "^1.3.0"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"typescript": "^5.9.3",
|
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
|
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,27 @@
|
|
|
5
5
|
* Operations-based server with GraphStateManager for reactive updates.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
// =============================================================================
|
|
9
|
+
// Re-exports from Core (commonly used with server)
|
|
10
|
+
// =============================================================================
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
// Operations
|
|
14
|
+
query,
|
|
15
|
+
mutation,
|
|
16
|
+
router,
|
|
17
|
+
// Types
|
|
18
|
+
type QueryBuilder,
|
|
19
|
+
type MutationBuilder,
|
|
20
|
+
type QueryDef,
|
|
21
|
+
type MutationDef,
|
|
22
|
+
type RouterDef,
|
|
23
|
+
type RouterRoutes,
|
|
24
|
+
type ResolverFn,
|
|
25
|
+
type ResolverContext,
|
|
26
|
+
type InferRouterContext,
|
|
27
|
+
} from "@sylphx/lens-core";
|
|
28
|
+
|
|
8
29
|
// =============================================================================
|
|
9
30
|
// Server
|
|
10
31
|
// =============================================================================
|