@sylphx/lens-server 1.0.4 → 1.2.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 +72 -1
- package/dist/index.js +257 -18
- package/package.json +2 -2
- package/src/e2e/server.test.ts +10 -10
- package/src/server/create.test.ts +11 -11
- package/src/server/create.ts +46 -20
- 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,5 @@
|
|
|
1
1
|
import { ContextValue, EntityDef, EntityDefinition, EntityResolvers, EntityResolversDefinition, MutationDef, QueryDef, RelationDef, RelationTypeWithForeignKey, RouterDef } from "@sylphx/lens-core";
|
|
2
|
-
import { EntityKey, Update } from "@sylphx/lens-core";
|
|
2
|
+
import { EntityKey, Update, EmitCommand, InternalFieldUpdate, ArrayOperation } from "@sylphx/lens-core";
|
|
3
3
|
/** Client connection interface */
|
|
4
4
|
interface StateClient {
|
|
5
5
|
id: string;
|
|
@@ -56,8 +56,12 @@ declare class GraphStateManager {
|
|
|
56
56
|
private clients;
|
|
57
57
|
/** Canonical state per entity (server truth) */
|
|
58
58
|
private canonical;
|
|
59
|
+
/** Canonical array state per entity (server truth for array outputs) */
|
|
60
|
+
private canonicalArrays;
|
|
59
61
|
/** Per-client state tracking */
|
|
60
62
|
private clientStates;
|
|
63
|
+
/** Per-client array state tracking */
|
|
64
|
+
private clientArrayStates;
|
|
61
65
|
/** Entity → subscribed client IDs */
|
|
62
66
|
private entitySubscribers;
|
|
63
67
|
/** Configuration */
|
|
@@ -96,6 +100,63 @@ declare class GraphStateManager {
|
|
|
96
100
|
replace?: boolean;
|
|
97
101
|
}): void;
|
|
98
102
|
/**
|
|
103
|
+
* Emit a field-level update with a specific strategy.
|
|
104
|
+
* Applies the update to canonical state and pushes to clients.
|
|
105
|
+
*
|
|
106
|
+
* @param entity - Entity name
|
|
107
|
+
* @param id - Entity ID
|
|
108
|
+
* @param field - Field name to update
|
|
109
|
+
* @param update - Update with strategy (value/delta/patch)
|
|
110
|
+
*/
|
|
111
|
+
emitField(entity: string, id: string, field: string, update: Update): void;
|
|
112
|
+
/**
|
|
113
|
+
* Emit multiple field updates in a batch.
|
|
114
|
+
* More efficient than multiple emitField calls.
|
|
115
|
+
*
|
|
116
|
+
* @param entity - Entity name
|
|
117
|
+
* @param id - Entity ID
|
|
118
|
+
* @param updates - Array of field updates
|
|
119
|
+
*/
|
|
120
|
+
emitBatch(entity: string, id: string, updates: InternalFieldUpdate[]): void;
|
|
121
|
+
/**
|
|
122
|
+
* Process an EmitCommand from the Emit API.
|
|
123
|
+
* Routes to appropriate emit method.
|
|
124
|
+
*
|
|
125
|
+
* @param entity - Entity name
|
|
126
|
+
* @param id - Entity ID
|
|
127
|
+
* @param command - Emit command from resolver
|
|
128
|
+
*/
|
|
129
|
+
processCommand(entity: string, id: string, command: EmitCommand): void;
|
|
130
|
+
/**
|
|
131
|
+
* Emit array data (replace entire array).
|
|
132
|
+
*
|
|
133
|
+
* @param entity - Entity name
|
|
134
|
+
* @param id - Entity ID
|
|
135
|
+
* @param items - Array items
|
|
136
|
+
*/
|
|
137
|
+
emitArray(entity: string, id: string, items: unknown[]): void;
|
|
138
|
+
/**
|
|
139
|
+
* Apply an array operation to the canonical state.
|
|
140
|
+
*
|
|
141
|
+
* @param entity - Entity name
|
|
142
|
+
* @param id - Entity ID
|
|
143
|
+
* @param operation - Array operation to apply
|
|
144
|
+
*/
|
|
145
|
+
emitArrayOperation(entity: string, id: string, operation: ArrayOperation): void;
|
|
146
|
+
/**
|
|
147
|
+
* Apply an array operation and return new array.
|
|
148
|
+
*/
|
|
149
|
+
private applyArrayOperation;
|
|
150
|
+
/**
|
|
151
|
+
* Push array update to a specific client.
|
|
152
|
+
* Computes optimal diff strategy.
|
|
153
|
+
*/
|
|
154
|
+
private pushArrayToClient;
|
|
155
|
+
/**
|
|
156
|
+
* Get current canonical array state
|
|
157
|
+
*/
|
|
158
|
+
getArrayState(entity: string, id: string): unknown[] | undefined;
|
|
159
|
+
/**
|
|
99
160
|
* Get current canonical state for an entity
|
|
100
161
|
*/
|
|
101
162
|
getState(entity: string, id: string): Record<string, unknown> | undefined;
|
|
@@ -108,6 +169,16 @@ declare class GraphStateManager {
|
|
|
108
169
|
*/
|
|
109
170
|
private pushToClient;
|
|
110
171
|
/**
|
|
172
|
+
* Push a single field update to a client.
|
|
173
|
+
* Computes optimal transfer strategy.
|
|
174
|
+
*/
|
|
175
|
+
private pushFieldToClient;
|
|
176
|
+
/**
|
|
177
|
+
* Push multiple field updates to a client.
|
|
178
|
+
* Computes optimal transfer strategy for each field.
|
|
179
|
+
*/
|
|
180
|
+
private pushFieldsToClient;
|
|
181
|
+
/**
|
|
111
182
|
* Send initial data to a newly subscribed client
|
|
112
183
|
*/
|
|
113
184
|
private sendInitialData;
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// src/server/create.ts
|
|
2
2
|
import {
|
|
3
3
|
createContext,
|
|
4
|
+
createEmit,
|
|
4
5
|
createUpdate as createUpdate2,
|
|
5
6
|
flattenRouter,
|
|
6
7
|
isBatchResolver,
|
|
@@ -10,12 +11,18 @@ import {
|
|
|
10
11
|
} from "@sylphx/lens-core";
|
|
11
12
|
|
|
12
13
|
// src/state/graph-state-manager.ts
|
|
13
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
createUpdate,
|
|
16
|
+
applyUpdate,
|
|
17
|
+
makeEntityKey
|
|
18
|
+
} from "@sylphx/lens-core";
|
|
14
19
|
|
|
15
20
|
class GraphStateManager {
|
|
16
21
|
clients = new Map;
|
|
17
22
|
canonical = new Map;
|
|
23
|
+
canonicalArrays = new Map;
|
|
18
24
|
clientStates = new Map;
|
|
25
|
+
clientArrayStates = new Map;
|
|
19
26
|
entitySubscribers = new Map;
|
|
20
27
|
config;
|
|
21
28
|
constructor(config = {}) {
|
|
@@ -24,6 +31,7 @@ class GraphStateManager {
|
|
|
24
31
|
addClient(client) {
|
|
25
32
|
this.clients.set(client.id, client);
|
|
26
33
|
this.clientStates.set(client.id, new Map);
|
|
34
|
+
this.clientArrayStates.set(client.id, new Map);
|
|
27
35
|
}
|
|
28
36
|
removeClient(clientId) {
|
|
29
37
|
for (const [key, subscribers] of this.entitySubscribers) {
|
|
@@ -34,6 +42,7 @@ class GraphStateManager {
|
|
|
34
42
|
}
|
|
35
43
|
this.clients.delete(clientId);
|
|
36
44
|
this.clientStates.delete(clientId);
|
|
45
|
+
this.clientArrayStates.delete(clientId);
|
|
37
46
|
}
|
|
38
47
|
subscribe(clientId, entity, id, fields = "*") {
|
|
39
48
|
const key = this.makeKey(entity, id);
|
|
@@ -96,6 +105,148 @@ class GraphStateManager {
|
|
|
96
105
|
this.pushToClient(clientId, entity, id, key, currentCanonical);
|
|
97
106
|
}
|
|
98
107
|
}
|
|
108
|
+
emitField(entity, id, field, update) {
|
|
109
|
+
const key = this.makeKey(entity, id);
|
|
110
|
+
let currentCanonical = this.canonical.get(key);
|
|
111
|
+
if (!currentCanonical) {
|
|
112
|
+
currentCanonical = {};
|
|
113
|
+
}
|
|
114
|
+
const oldValue = currentCanonical[field];
|
|
115
|
+
const newValue = applyUpdate(oldValue, update);
|
|
116
|
+
currentCanonical = { ...currentCanonical, [field]: newValue };
|
|
117
|
+
this.canonical.set(key, currentCanonical);
|
|
118
|
+
const subscribers = this.entitySubscribers.get(key);
|
|
119
|
+
if (!subscribers)
|
|
120
|
+
return;
|
|
121
|
+
for (const clientId of subscribers) {
|
|
122
|
+
this.pushFieldToClient(clientId, entity, id, key, field, newValue);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
emitBatch(entity, id, updates) {
|
|
126
|
+
const key = this.makeKey(entity, id);
|
|
127
|
+
let currentCanonical = this.canonical.get(key);
|
|
128
|
+
if (!currentCanonical) {
|
|
129
|
+
currentCanonical = {};
|
|
130
|
+
}
|
|
131
|
+
const changedFields = [];
|
|
132
|
+
for (const { field, update } of updates) {
|
|
133
|
+
const oldValue = currentCanonical[field];
|
|
134
|
+
const newValue = applyUpdate(oldValue, update);
|
|
135
|
+
currentCanonical[field] = newValue;
|
|
136
|
+
changedFields.push(field);
|
|
137
|
+
}
|
|
138
|
+
this.canonical.set(key, currentCanonical);
|
|
139
|
+
const subscribers = this.entitySubscribers.get(key);
|
|
140
|
+
if (!subscribers)
|
|
141
|
+
return;
|
|
142
|
+
for (const clientId of subscribers) {
|
|
143
|
+
this.pushFieldsToClient(clientId, entity, id, key, changedFields, currentCanonical);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
processCommand(entity, id, command) {
|
|
147
|
+
switch (command.type) {
|
|
148
|
+
case "full":
|
|
149
|
+
this.emit(entity, id, command.data, {
|
|
150
|
+
replace: command.replace
|
|
151
|
+
});
|
|
152
|
+
break;
|
|
153
|
+
case "field":
|
|
154
|
+
this.emitField(entity, id, command.field, command.update);
|
|
155
|
+
break;
|
|
156
|
+
case "batch":
|
|
157
|
+
this.emitBatch(entity, id, command.updates);
|
|
158
|
+
break;
|
|
159
|
+
case "array":
|
|
160
|
+
this.emitArrayOperation(entity, id, command.operation);
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
emitArray(entity, id, items) {
|
|
165
|
+
const key = this.makeKey(entity, id);
|
|
166
|
+
this.canonicalArrays.set(key, [...items]);
|
|
167
|
+
const subscribers = this.entitySubscribers.get(key);
|
|
168
|
+
if (!subscribers)
|
|
169
|
+
return;
|
|
170
|
+
for (const clientId of subscribers) {
|
|
171
|
+
this.pushArrayToClient(clientId, entity, id, key, items);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
emitArrayOperation(entity, id, operation) {
|
|
175
|
+
const key = this.makeKey(entity, id);
|
|
176
|
+
let currentArray = this.canonicalArrays.get(key);
|
|
177
|
+
if (!currentArray) {
|
|
178
|
+
currentArray = [];
|
|
179
|
+
}
|
|
180
|
+
const newArray = this.applyArrayOperation([...currentArray], operation);
|
|
181
|
+
this.canonicalArrays.set(key, newArray);
|
|
182
|
+
const subscribers = this.entitySubscribers.get(key);
|
|
183
|
+
if (!subscribers)
|
|
184
|
+
return;
|
|
185
|
+
for (const clientId of subscribers) {
|
|
186
|
+
this.pushArrayToClient(clientId, entity, id, key, newArray);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
applyArrayOperation(array, operation) {
|
|
190
|
+
switch (operation.op) {
|
|
191
|
+
case "push":
|
|
192
|
+
return [...array, operation.item];
|
|
193
|
+
case "unshift":
|
|
194
|
+
return [operation.item, ...array];
|
|
195
|
+
case "insert":
|
|
196
|
+
return [
|
|
197
|
+
...array.slice(0, operation.index),
|
|
198
|
+
operation.item,
|
|
199
|
+
...array.slice(operation.index)
|
|
200
|
+
];
|
|
201
|
+
case "remove":
|
|
202
|
+
return [...array.slice(0, operation.index), ...array.slice(operation.index + 1)];
|
|
203
|
+
case "removeById": {
|
|
204
|
+
const idx = array.findIndex((item) => typeof item === "object" && item !== null && ("id" in item) && item.id === operation.id);
|
|
205
|
+
if (idx === -1)
|
|
206
|
+
return array;
|
|
207
|
+
return [...array.slice(0, idx), ...array.slice(idx + 1)];
|
|
208
|
+
}
|
|
209
|
+
case "update":
|
|
210
|
+
return array.map((item, i) => i === operation.index ? operation.item : item);
|
|
211
|
+
case "updateById":
|
|
212
|
+
return array.map((item) => typeof item === "object" && item !== null && ("id" in item) && item.id === operation.id ? operation.item : item);
|
|
213
|
+
case "merge":
|
|
214
|
+
return array.map((item, i) => i === operation.index && typeof item === "object" && item !== null ? { ...item, ...operation.partial } : item);
|
|
215
|
+
case "mergeById":
|
|
216
|
+
return array.map((item) => typeof item === "object" && item !== null && ("id" in item) && item.id === operation.id ? { ...item, ...operation.partial } : item);
|
|
217
|
+
default:
|
|
218
|
+
return array;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
pushArrayToClient(clientId, entity, id, key, newArray) {
|
|
222
|
+
const client = this.clients.get(clientId);
|
|
223
|
+
if (!client)
|
|
224
|
+
return;
|
|
225
|
+
const clientArrayStateMap = this.clientArrayStates.get(clientId);
|
|
226
|
+
if (!clientArrayStateMap)
|
|
227
|
+
return;
|
|
228
|
+
let clientArrayState = clientArrayStateMap.get(key);
|
|
229
|
+
if (!clientArrayState) {
|
|
230
|
+
clientArrayState = { lastState: [] };
|
|
231
|
+
clientArrayStateMap.set(key, clientArrayState);
|
|
232
|
+
}
|
|
233
|
+
const { lastState } = clientArrayState;
|
|
234
|
+
if (JSON.stringify(lastState) === JSON.stringify(newArray)) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
client.send({
|
|
238
|
+
type: "update",
|
|
239
|
+
entity,
|
|
240
|
+
id,
|
|
241
|
+
updates: {
|
|
242
|
+
_items: { strategy: "value", data: newArray }
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
clientArrayState.lastState = [...newArray];
|
|
246
|
+
}
|
|
247
|
+
getArrayState(entity, id) {
|
|
248
|
+
return this.canonicalArrays.get(this.makeKey(entity, id));
|
|
249
|
+
}
|
|
99
250
|
getState(entity, id) {
|
|
100
251
|
return this.canonical.get(this.makeKey(entity, id));
|
|
101
252
|
}
|
|
@@ -143,6 +294,77 @@ class GraphStateManager {
|
|
|
143
294
|
}
|
|
144
295
|
}
|
|
145
296
|
}
|
|
297
|
+
pushFieldToClient(clientId, entity, id, key, field, newValue) {
|
|
298
|
+
const client = this.clients.get(clientId);
|
|
299
|
+
if (!client)
|
|
300
|
+
return;
|
|
301
|
+
const clientStateMap = this.clientStates.get(clientId);
|
|
302
|
+
if (!clientStateMap)
|
|
303
|
+
return;
|
|
304
|
+
const clientEntityState = clientStateMap.get(key);
|
|
305
|
+
if (!clientEntityState)
|
|
306
|
+
return;
|
|
307
|
+
const { lastState, fields } = clientEntityState;
|
|
308
|
+
if (fields !== "*" && !fields.has(field)) {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const oldValue = lastState[field];
|
|
312
|
+
if (oldValue === newValue)
|
|
313
|
+
return;
|
|
314
|
+
if (typeof oldValue === "object" && typeof newValue === "object" && JSON.stringify(oldValue) === JSON.stringify(newValue)) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const update = createUpdate(oldValue, newValue);
|
|
318
|
+
client.send({
|
|
319
|
+
type: "update",
|
|
320
|
+
entity,
|
|
321
|
+
id,
|
|
322
|
+
updates: { [field]: update }
|
|
323
|
+
});
|
|
324
|
+
clientEntityState.lastState[field] = newValue;
|
|
325
|
+
}
|
|
326
|
+
pushFieldsToClient(clientId, entity, id, key, changedFields, newState) {
|
|
327
|
+
const client = this.clients.get(clientId);
|
|
328
|
+
if (!client)
|
|
329
|
+
return;
|
|
330
|
+
const clientStateMap = this.clientStates.get(clientId);
|
|
331
|
+
if (!clientStateMap)
|
|
332
|
+
return;
|
|
333
|
+
const clientEntityState = clientStateMap.get(key);
|
|
334
|
+
if (!clientEntityState)
|
|
335
|
+
return;
|
|
336
|
+
const { lastState, fields } = clientEntityState;
|
|
337
|
+
const updates = {};
|
|
338
|
+
let hasChanges = false;
|
|
339
|
+
for (const field of changedFields) {
|
|
340
|
+
if (fields !== "*" && !fields.has(field)) {
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
const oldValue = lastState[field];
|
|
344
|
+
const newValue = newState[field];
|
|
345
|
+
if (oldValue === newValue)
|
|
346
|
+
continue;
|
|
347
|
+
if (typeof oldValue === "object" && typeof newValue === "object" && JSON.stringify(oldValue) === JSON.stringify(newValue)) {
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
const update = createUpdate(oldValue, newValue);
|
|
351
|
+
updates[field] = update;
|
|
352
|
+
hasChanges = true;
|
|
353
|
+
}
|
|
354
|
+
if (!hasChanges)
|
|
355
|
+
return;
|
|
356
|
+
client.send({
|
|
357
|
+
type: "update",
|
|
358
|
+
entity,
|
|
359
|
+
id,
|
|
360
|
+
updates
|
|
361
|
+
});
|
|
362
|
+
for (const field of changedFields) {
|
|
363
|
+
if (newState[field] !== undefined) {
|
|
364
|
+
clientEntityState.lastState[field] = newState[field];
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
146
368
|
sendInitialData(clientId, entity, id, state, fields) {
|
|
147
369
|
const client = this.clients.get(clientId);
|
|
148
370
|
if (!client)
|
|
@@ -195,7 +417,9 @@ class GraphStateManager {
|
|
|
195
417
|
clear() {
|
|
196
418
|
this.clients.clear();
|
|
197
419
|
this.canonical.clear();
|
|
420
|
+
this.canonicalArrays.clear();
|
|
198
421
|
this.clientStates.clear();
|
|
422
|
+
this.clientArrayStates.clear();
|
|
199
423
|
this.entitySubscribers.clear();
|
|
200
424
|
}
|
|
201
425
|
}
|
|
@@ -505,21 +729,31 @@ class LensServerImpl {
|
|
|
505
729
|
if (!resolver) {
|
|
506
730
|
throw new Error(`Query ${sub.operation} has no resolver`);
|
|
507
731
|
}
|
|
508
|
-
const
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
732
|
+
const emit = createEmit((command) => {
|
|
733
|
+
const entityName = this.getEntityNameFromOutput(queryDef._output);
|
|
734
|
+
if (entityName) {
|
|
735
|
+
const entities = this.extractEntities(entityName, command.type === "full" ? command.data : {});
|
|
736
|
+
for (const { entity, id } of entities) {
|
|
737
|
+
this.stateManager.processCommand(entity, id, command);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
if (command.type === "full") {
|
|
741
|
+
emitData(command.data);
|
|
518
742
|
}
|
|
743
|
+
});
|
|
744
|
+
const onCleanup = (fn) => {
|
|
745
|
+
sub.cleanups.push(fn);
|
|
746
|
+
return () => {
|
|
747
|
+
const idx = sub.cleanups.indexOf(fn);
|
|
748
|
+
if (idx >= 0)
|
|
749
|
+
sub.cleanups.splice(idx, 1);
|
|
750
|
+
};
|
|
519
751
|
};
|
|
520
752
|
const result = resolver({
|
|
521
753
|
input: sub.input,
|
|
522
|
-
ctx:
|
|
754
|
+
ctx: context,
|
|
755
|
+
emit,
|
|
756
|
+
onCleanup
|
|
523
757
|
});
|
|
524
758
|
if (isAsyncIterable(result)) {
|
|
525
759
|
for await (const value of result) {
|
|
@@ -668,13 +902,14 @@ class LensServerImpl {
|
|
|
668
902
|
if (!resolver) {
|
|
669
903
|
throw new Error(`Query ${name} has no resolver`);
|
|
670
904
|
}
|
|
671
|
-
const
|
|
905
|
+
const emit = createEmit(() => {});
|
|
906
|
+
const onCleanup = () => () => {};
|
|
907
|
+
const result = resolver({
|
|
672
908
|
input: cleanInput,
|
|
673
909
|
ctx: context,
|
|
674
|
-
emit
|
|
675
|
-
onCleanup
|
|
676
|
-
};
|
|
677
|
-
const result = resolver(resolverCtx);
|
|
910
|
+
emit,
|
|
911
|
+
onCleanup
|
|
912
|
+
});
|
|
678
913
|
let data;
|
|
679
914
|
if (isAsyncIterable(result)) {
|
|
680
915
|
for await (const value of result) {
|
|
@@ -711,9 +946,13 @@ class LensServerImpl {
|
|
|
711
946
|
if (!resolver) {
|
|
712
947
|
throw new Error(`Mutation ${name} has no resolver`);
|
|
713
948
|
}
|
|
949
|
+
const emit = createEmit(() => {});
|
|
950
|
+
const onCleanup = () => () => {};
|
|
714
951
|
const result = await resolver({
|
|
715
952
|
input,
|
|
716
|
-
ctx: context
|
|
953
|
+
ctx: context,
|
|
954
|
+
emit,
|
|
955
|
+
onCleanup
|
|
717
956
|
});
|
|
718
957
|
const entityName = this.getEntityNameFromMutation(name);
|
|
719
958
|
const entities = this.extractEntities(entityName, result);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sylphx/lens-server",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.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.2.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
|
|
|
@@ -644,8 +644,8 @@ describe("Minimum Transfer", () => {
|
|
|
644
644
|
|
|
645
645
|
const liveQuery = query()
|
|
646
646
|
.returns(User)
|
|
647
|
-
.resolve(({
|
|
648
|
-
emitFn =
|
|
647
|
+
.resolve(({ emit }) => {
|
|
648
|
+
emitFn = emit;
|
|
649
649
|
return { id: "1", name: "Alice", email: "alice@example.com" };
|
|
650
650
|
});
|
|
651
651
|
|
|
@@ -681,13 +681,13 @@ describe("Minimum Transfer", () => {
|
|
|
681
681
|
}
|
|
682
682
|
});
|
|
683
683
|
|
|
684
|
-
it("sends updates via
|
|
684
|
+
it("sends updates via emit", async () => {
|
|
685
685
|
let emitFn: ((data: unknown) => void) | null = null;
|
|
686
686
|
|
|
687
687
|
const liveQuery = query()
|
|
688
688
|
.returns(User)
|
|
689
|
-
.resolve(({
|
|
690
|
-
emitFn =
|
|
689
|
+
.resolve(({ emit }) => {
|
|
690
|
+
emitFn = emit;
|
|
691
691
|
return { id: "1", name: "Alice", email: "alice@example.com" };
|
|
692
692
|
});
|
|
693
693
|
|
|
@@ -723,17 +723,17 @@ describe("Minimum Transfer", () => {
|
|
|
723
723
|
});
|
|
724
724
|
|
|
725
725
|
// =============================================================================
|
|
726
|
-
// Test:
|
|
726
|
+
// Test: onCleanup
|
|
727
727
|
// =============================================================================
|
|
728
728
|
|
|
729
|
-
describe("
|
|
729
|
+
describe("onCleanup", () => {
|
|
730
730
|
it("calls cleanup function on unsubscribe", async () => {
|
|
731
731
|
let cleanedUp = false;
|
|
732
732
|
|
|
733
733
|
const liveQuery = query()
|
|
734
734
|
.returns(User)
|
|
735
|
-
.resolve(({
|
|
736
|
-
|
|
735
|
+
.resolve(({ onCleanup }) => {
|
|
736
|
+
onCleanup(() => {
|
|
737
737
|
cleanedUp = true;
|
|
738
738
|
});
|
|
739
739
|
return mockUsers[0];
|
|
@@ -772,8 +772,8 @@ describe("ctx.onCleanup", () => {
|
|
|
772
772
|
|
|
773
773
|
const liveQuery = query()
|
|
774
774
|
.returns(User)
|
|
775
|
-
.resolve(({
|
|
776
|
-
|
|
775
|
+
.resolve(({ onCleanup }) => {
|
|
776
|
+
onCleanup(() => {
|
|
777
777
|
cleanedUp = true;
|
|
778
778
|
});
|
|
779
779
|
return mockUsers[0];
|
package/src/server/create.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
import {
|
|
12
12
|
type ContextValue,
|
|
13
|
+
type Emit,
|
|
14
|
+
type EmitCommand,
|
|
13
15
|
type EntityDef,
|
|
14
16
|
type EntityDefinition,
|
|
15
17
|
type EntityResolvers,
|
|
@@ -22,6 +24,7 @@ import {
|
|
|
22
24
|
type RouterDef,
|
|
23
25
|
type Update,
|
|
24
26
|
createContext,
|
|
27
|
+
createEmit,
|
|
25
28
|
createUpdate,
|
|
26
29
|
flattenRouter,
|
|
27
30
|
isBatchResolver,
|
|
@@ -666,22 +669,37 @@ class LensServerImpl<
|
|
|
666
669
|
throw new Error(`Query ${sub.operation} has no resolver`);
|
|
667
670
|
}
|
|
668
671
|
|
|
669
|
-
//
|
|
670
|
-
const
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
}
|
|
679
|
-
}
|
|
672
|
+
// Create emit API for this subscription
|
|
673
|
+
const emit = createEmit((command: EmitCommand) => {
|
|
674
|
+
// Route emit commands to appropriate handler
|
|
675
|
+
const entityName = this.getEntityNameFromOutput(queryDef._output);
|
|
676
|
+
if (entityName) {
|
|
677
|
+
// For entity-typed outputs, use GraphStateManager
|
|
678
|
+
const entities = this.extractEntities(entityName, command.type === "full" ? command.data : {});
|
|
679
|
+
for (const { entity, id } of entities) {
|
|
680
|
+
this.stateManager.processCommand(entity, id, command);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
// Also emit the raw data for operation-level updates
|
|
684
|
+
if (command.type === "full") {
|
|
685
|
+
emitData(command.data);
|
|
686
|
+
}
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
// Create onCleanup function
|
|
690
|
+
const onCleanup = (fn: () => void) => {
|
|
691
|
+
sub.cleanups.push(fn);
|
|
692
|
+
return () => {
|
|
693
|
+
const idx = sub.cleanups.indexOf(fn);
|
|
694
|
+
if (idx >= 0) sub.cleanups.splice(idx, 1);
|
|
695
|
+
};
|
|
680
696
|
};
|
|
681
697
|
|
|
682
698
|
const result = resolver({
|
|
683
699
|
input: sub.input,
|
|
684
|
-
ctx:
|
|
700
|
+
ctx: context,
|
|
701
|
+
emit,
|
|
702
|
+
onCleanup,
|
|
685
703
|
});
|
|
686
704
|
|
|
687
705
|
if (isAsyncIterable(result)) {
|
|
@@ -892,14 +910,16 @@ class LensServerImpl<
|
|
|
892
910
|
throw new Error(`Query ${name} has no resolver`);
|
|
893
911
|
}
|
|
894
912
|
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
emit: () => {},
|
|
899
|
-
onCleanup: () => () => {},
|
|
900
|
-
};
|
|
913
|
+
// Create no-op emit for one-shot queries (emit is only meaningful in subscriptions)
|
|
914
|
+
const emit = createEmit(() => {});
|
|
915
|
+
const onCleanup = () => () => {};
|
|
901
916
|
|
|
902
|
-
const result = resolver(
|
|
917
|
+
const result = resolver({
|
|
918
|
+
input: cleanInput as TInput,
|
|
919
|
+
ctx: context,
|
|
920
|
+
emit,
|
|
921
|
+
onCleanup,
|
|
922
|
+
});
|
|
903
923
|
|
|
904
924
|
let data: TOutput;
|
|
905
925
|
if (isAsyncIterable(result)) {
|
|
@@ -944,9 +964,15 @@ class LensServerImpl<
|
|
|
944
964
|
throw new Error(`Mutation ${name} has no resolver`);
|
|
945
965
|
}
|
|
946
966
|
|
|
967
|
+
// Create no-op emit for mutations (emit is primarily for subscriptions)
|
|
968
|
+
const emit = createEmit(() => {});
|
|
969
|
+
const onCleanup = () => () => {};
|
|
970
|
+
|
|
947
971
|
const result = await resolver({
|
|
948
972
|
input: input as TInput,
|
|
949
|
-
ctx: context,
|
|
973
|
+
ctx: context,
|
|
974
|
+
emit,
|
|
975
|
+
onCleanup,
|
|
950
976
|
});
|
|
951
977
|
|
|
952
978
|
// Emit to GraphStateManager
|
|
@@ -331,4 +331,219 @@ describe("GraphStateManager", () => {
|
|
|
331
331
|
expect(stats.totalSubscriptions).toBe(3);
|
|
332
332
|
});
|
|
333
333
|
});
|
|
334
|
+
|
|
335
|
+
describe("array operations", () => {
|
|
336
|
+
interface User {
|
|
337
|
+
id: string;
|
|
338
|
+
name: string;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
it("emits array data", () => {
|
|
342
|
+
manager.subscribe("client-1", "Users", "list", "*");
|
|
343
|
+
mockClient.messages = [];
|
|
344
|
+
|
|
345
|
+
manager.emitArray("Users", "list", [
|
|
346
|
+
{ id: "1", name: "Alice" },
|
|
347
|
+
{ id: "2", name: "Bob" },
|
|
348
|
+
]);
|
|
349
|
+
|
|
350
|
+
expect(mockClient.messages.length).toBe(1);
|
|
351
|
+
expect(mockClient.messages[0]).toMatchObject({
|
|
352
|
+
type: "update",
|
|
353
|
+
entity: "Users",
|
|
354
|
+
id: "list",
|
|
355
|
+
});
|
|
356
|
+
expect(mockClient.messages[0].updates._items.data).toEqual([
|
|
357
|
+
{ id: "1", name: "Alice" },
|
|
358
|
+
{ id: "2", name: "Bob" },
|
|
359
|
+
]);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("gets array state", () => {
|
|
363
|
+
manager.emitArray("Users", "list", [{ id: "1", name: "Alice" }]);
|
|
364
|
+
|
|
365
|
+
expect(manager.getArrayState("Users", "list")).toEqual([{ id: "1", name: "Alice" }]);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("applies push operation", () => {
|
|
369
|
+
manager.emitArray("Users", "list", [{ id: "1", name: "Alice" }]);
|
|
370
|
+
manager.emitArrayOperation("Users", "list", {
|
|
371
|
+
op: "push",
|
|
372
|
+
item: { id: "2", name: "Bob" },
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
expect(manager.getArrayState("Users", "list")).toEqual([
|
|
376
|
+
{ id: "1", name: "Alice" },
|
|
377
|
+
{ id: "2", name: "Bob" },
|
|
378
|
+
]);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("applies unshift operation", () => {
|
|
382
|
+
manager.emitArray("Users", "list", [{ id: "1", name: "Alice" }]);
|
|
383
|
+
manager.emitArrayOperation("Users", "list", {
|
|
384
|
+
op: "unshift",
|
|
385
|
+
item: { id: "0", name: "Zero" },
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
expect(manager.getArrayState("Users", "list")).toEqual([
|
|
389
|
+
{ id: "0", name: "Zero" },
|
|
390
|
+
{ id: "1", name: "Alice" },
|
|
391
|
+
]);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("applies insert operation", () => {
|
|
395
|
+
manager.emitArray("Users", "list", [
|
|
396
|
+
{ id: "1", name: "Alice" },
|
|
397
|
+
{ id: "3", name: "Charlie" },
|
|
398
|
+
]);
|
|
399
|
+
manager.emitArrayOperation("Users", "list", {
|
|
400
|
+
op: "insert",
|
|
401
|
+
index: 1,
|
|
402
|
+
item: { id: "2", name: "Bob" },
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
expect(manager.getArrayState("Users", "list")).toEqual([
|
|
406
|
+
{ id: "1", name: "Alice" },
|
|
407
|
+
{ id: "2", name: "Bob" },
|
|
408
|
+
{ id: "3", name: "Charlie" },
|
|
409
|
+
]);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("applies remove operation", () => {
|
|
413
|
+
manager.emitArray("Users", "list", [
|
|
414
|
+
{ id: "1", name: "Alice" },
|
|
415
|
+
{ id: "2", name: "Bob" },
|
|
416
|
+
]);
|
|
417
|
+
manager.emitArrayOperation("Users", "list", { op: "remove", index: 0 });
|
|
418
|
+
|
|
419
|
+
expect(manager.getArrayState("Users", "list")).toEqual([{ id: "2", name: "Bob" }]);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("applies removeById operation", () => {
|
|
423
|
+
manager.emitArray("Users", "list", [
|
|
424
|
+
{ id: "1", name: "Alice" },
|
|
425
|
+
{ id: "2", name: "Bob" },
|
|
426
|
+
]);
|
|
427
|
+
manager.emitArrayOperation("Users", "list", { op: "removeById", id: "1" });
|
|
428
|
+
|
|
429
|
+
expect(manager.getArrayState("Users", "list")).toEqual([{ id: "2", name: "Bob" }]);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("handles removeById for non-existent id", () => {
|
|
433
|
+
manager.emitArray("Users", "list", [{ id: "1", name: "Alice" }]);
|
|
434
|
+
manager.emitArrayOperation("Users", "list", { op: "removeById", id: "999" });
|
|
435
|
+
|
|
436
|
+
expect(manager.getArrayState("Users", "list")).toEqual([{ id: "1", name: "Alice" }]);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("applies update operation", () => {
|
|
440
|
+
manager.emitArray("Users", "list", [
|
|
441
|
+
{ id: "1", name: "Alice" },
|
|
442
|
+
{ id: "2", name: "Bob" },
|
|
443
|
+
]);
|
|
444
|
+
manager.emitArrayOperation("Users", "list", {
|
|
445
|
+
op: "update",
|
|
446
|
+
index: 1,
|
|
447
|
+
item: { id: "2", name: "Robert" },
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
expect(manager.getArrayState("Users", "list")).toEqual([
|
|
451
|
+
{ id: "1", name: "Alice" },
|
|
452
|
+
{ id: "2", name: "Robert" },
|
|
453
|
+
]);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("applies updateById operation", () => {
|
|
457
|
+
manager.emitArray("Users", "list", [
|
|
458
|
+
{ id: "1", name: "Alice" },
|
|
459
|
+
{ id: "2", name: "Bob" },
|
|
460
|
+
]);
|
|
461
|
+
manager.emitArrayOperation("Users", "list", {
|
|
462
|
+
op: "updateById",
|
|
463
|
+
id: "2",
|
|
464
|
+
item: { id: "2", name: "Robert" },
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
expect(manager.getArrayState("Users", "list")).toEqual([
|
|
468
|
+
{ id: "1", name: "Alice" },
|
|
469
|
+
{ id: "2", name: "Robert" },
|
|
470
|
+
]);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("applies merge operation", () => {
|
|
474
|
+
manager.emitArray("Users", "list", [
|
|
475
|
+
{ id: "1", name: "Alice" },
|
|
476
|
+
{ id: "2", name: "Bob" },
|
|
477
|
+
]);
|
|
478
|
+
manager.emitArrayOperation("Users", "list", {
|
|
479
|
+
op: "merge",
|
|
480
|
+
index: 0,
|
|
481
|
+
partial: { name: "Alicia" },
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
expect(manager.getArrayState("Users", "list")).toEqual([
|
|
485
|
+
{ id: "1", name: "Alicia" },
|
|
486
|
+
{ id: "2", name: "Bob" },
|
|
487
|
+
]);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("applies mergeById operation", () => {
|
|
491
|
+
manager.emitArray("Users", "list", [
|
|
492
|
+
{ id: "1", name: "Alice" },
|
|
493
|
+
{ id: "2", name: "Bob" },
|
|
494
|
+
]);
|
|
495
|
+
manager.emitArrayOperation("Users", "list", {
|
|
496
|
+
op: "mergeById",
|
|
497
|
+
id: "2",
|
|
498
|
+
partial: { name: "Bobby" },
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
expect(manager.getArrayState("Users", "list")).toEqual([
|
|
502
|
+
{ id: "1", name: "Alice" },
|
|
503
|
+
{ id: "2", name: "Bobby" },
|
|
504
|
+
]);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("processCommand handles array operations", () => {
|
|
508
|
+
manager.emitArray("Users", "list", [{ id: "1", name: "Alice" }]);
|
|
509
|
+
|
|
510
|
+
manager.processCommand("Users", "list", {
|
|
511
|
+
type: "array",
|
|
512
|
+
operation: { op: "push", item: { id: "2", name: "Bob" } },
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
expect(manager.getArrayState("Users", "list")).toEqual([
|
|
516
|
+
{ id: "1", name: "Alice" },
|
|
517
|
+
{ id: "2", name: "Bob" },
|
|
518
|
+
]);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it("sends array updates to subscribed clients", () => {
|
|
522
|
+
manager.subscribe("client-1", "Users", "list", "*");
|
|
523
|
+
mockClient.messages = [];
|
|
524
|
+
|
|
525
|
+
manager.emitArray("Users", "list", [{ id: "1", name: "Alice" }]);
|
|
526
|
+
manager.emitArrayOperation("Users", "list", {
|
|
527
|
+
op: "push",
|
|
528
|
+
item: { id: "2", name: "Bob" },
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
expect(mockClient.messages.length).toBe(2);
|
|
532
|
+
expect(mockClient.messages[1].updates._items.data).toEqual([
|
|
533
|
+
{ id: "1", name: "Alice" },
|
|
534
|
+
{ id: "2", name: "Bob" },
|
|
535
|
+
]);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it("does not send update if array unchanged", () => {
|
|
539
|
+
manager.subscribe("client-1", "Users", "list", "*");
|
|
540
|
+
manager.emitArray("Users", "list", [{ id: "1", name: "Alice" }]);
|
|
541
|
+
mockClient.messages = [];
|
|
542
|
+
|
|
543
|
+
// Remove by non-existent id (no change)
|
|
544
|
+
manager.emitArrayOperation("Users", "list", { op: "removeById", id: "999" });
|
|
545
|
+
|
|
546
|
+
expect(mockClient.messages.length).toBe(0);
|
|
547
|
+
});
|
|
548
|
+
});
|
|
334
549
|
});
|
|
@@ -9,7 +9,16 @@
|
|
|
9
9
|
* - Pushes updates to subscribed clients
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
type EntityKey,
|
|
14
|
+
type Update,
|
|
15
|
+
type EmitCommand,
|
|
16
|
+
type InternalFieldUpdate,
|
|
17
|
+
type ArrayOperation,
|
|
18
|
+
createUpdate,
|
|
19
|
+
applyUpdate,
|
|
20
|
+
makeEntityKey,
|
|
21
|
+
} from "@sylphx/lens-core";
|
|
13
22
|
|
|
14
23
|
// Re-export for convenience
|
|
15
24
|
export type { EntityKey };
|
|
@@ -51,6 +60,12 @@ interface ClientEntityState {
|
|
|
51
60
|
fields: Set<string> | "*";
|
|
52
61
|
}
|
|
53
62
|
|
|
63
|
+
/** Per-client state for an array */
|
|
64
|
+
interface ClientArrayState {
|
|
65
|
+
/** Last array state sent to this client */
|
|
66
|
+
lastState: unknown[];
|
|
67
|
+
}
|
|
68
|
+
|
|
54
69
|
/** Configuration */
|
|
55
70
|
export interface GraphStateManagerConfig {
|
|
56
71
|
/** Called when an entity has no more subscribers */
|
|
@@ -89,9 +104,15 @@ export class GraphStateManager {
|
|
|
89
104
|
/** Canonical state per entity (server truth) */
|
|
90
105
|
private canonical = new Map<EntityKey, Record<string, unknown>>();
|
|
91
106
|
|
|
107
|
+
/** Canonical array state per entity (server truth for array outputs) */
|
|
108
|
+
private canonicalArrays = new Map<EntityKey, unknown[]>();
|
|
109
|
+
|
|
92
110
|
/** Per-client state tracking */
|
|
93
111
|
private clientStates = new Map<string, Map<EntityKey, ClientEntityState>>();
|
|
94
112
|
|
|
113
|
+
/** Per-client array state tracking */
|
|
114
|
+
private clientArrayStates = new Map<string, Map<EntityKey, ClientArrayState>>();
|
|
115
|
+
|
|
95
116
|
/** Entity → subscribed client IDs */
|
|
96
117
|
private entitySubscribers = new Map<EntityKey, Set<string>>();
|
|
97
118
|
|
|
@@ -112,6 +133,7 @@ export class GraphStateManager {
|
|
|
112
133
|
addClient(client: StateClient): void {
|
|
113
134
|
this.clients.set(client.id, client);
|
|
114
135
|
this.clientStates.set(client.id, new Map());
|
|
136
|
+
this.clientArrayStates.set(client.id, new Map());
|
|
115
137
|
}
|
|
116
138
|
|
|
117
139
|
/**
|
|
@@ -128,6 +150,7 @@ export class GraphStateManager {
|
|
|
128
150
|
|
|
129
151
|
this.clients.delete(clientId);
|
|
130
152
|
this.clientStates.delete(clientId);
|
|
153
|
+
this.clientArrayStates.delete(clientId);
|
|
131
154
|
}
|
|
132
155
|
|
|
133
156
|
// ===========================================================================
|
|
@@ -245,6 +268,278 @@ export class GraphStateManager {
|
|
|
245
268
|
}
|
|
246
269
|
}
|
|
247
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Emit a field-level update with a specific strategy.
|
|
273
|
+
* Applies the update to canonical state and pushes to clients.
|
|
274
|
+
*
|
|
275
|
+
* @param entity - Entity name
|
|
276
|
+
* @param id - Entity ID
|
|
277
|
+
* @param field - Field name to update
|
|
278
|
+
* @param update - Update with strategy (value/delta/patch)
|
|
279
|
+
*/
|
|
280
|
+
emitField(entity: string, id: string, field: string, update: Update): void {
|
|
281
|
+
const key = this.makeKey(entity, id);
|
|
282
|
+
|
|
283
|
+
// Get or create canonical state
|
|
284
|
+
let currentCanonical = this.canonical.get(key);
|
|
285
|
+
if (!currentCanonical) {
|
|
286
|
+
currentCanonical = {};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Apply update to canonical state based on strategy
|
|
290
|
+
const oldValue = currentCanonical[field];
|
|
291
|
+
const newValue = applyUpdate(oldValue, update);
|
|
292
|
+
currentCanonical = { ...currentCanonical, [field]: newValue };
|
|
293
|
+
|
|
294
|
+
this.canonical.set(key, currentCanonical);
|
|
295
|
+
|
|
296
|
+
// Push updates to all subscribed clients
|
|
297
|
+
const subscribers = this.entitySubscribers.get(key);
|
|
298
|
+
if (!subscribers) return;
|
|
299
|
+
|
|
300
|
+
for (const clientId of subscribers) {
|
|
301
|
+
this.pushFieldToClient(clientId, entity, id, key, field, newValue);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Emit multiple field updates in a batch.
|
|
307
|
+
* More efficient than multiple emitField calls.
|
|
308
|
+
*
|
|
309
|
+
* @param entity - Entity name
|
|
310
|
+
* @param id - Entity ID
|
|
311
|
+
* @param updates - Array of field updates
|
|
312
|
+
*/
|
|
313
|
+
emitBatch(entity: string, id: string, updates: InternalFieldUpdate[]): void {
|
|
314
|
+
const key = this.makeKey(entity, id);
|
|
315
|
+
|
|
316
|
+
// Get or create canonical state
|
|
317
|
+
let currentCanonical = this.canonical.get(key);
|
|
318
|
+
if (!currentCanonical) {
|
|
319
|
+
currentCanonical = {};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Apply all updates to canonical state
|
|
323
|
+
const changedFields: string[] = [];
|
|
324
|
+
for (const { field, update } of updates) {
|
|
325
|
+
const oldValue = currentCanonical[field];
|
|
326
|
+
const newValue = applyUpdate(oldValue, update);
|
|
327
|
+
currentCanonical[field] = newValue;
|
|
328
|
+
changedFields.push(field);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
this.canonical.set(key, currentCanonical);
|
|
332
|
+
|
|
333
|
+
// Push updates to all subscribed clients
|
|
334
|
+
const subscribers = this.entitySubscribers.get(key);
|
|
335
|
+
if (!subscribers) return;
|
|
336
|
+
|
|
337
|
+
for (const clientId of subscribers) {
|
|
338
|
+
this.pushFieldsToClient(clientId, entity, id, key, changedFields, currentCanonical);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Process an EmitCommand from the Emit API.
|
|
344
|
+
* Routes to appropriate emit method.
|
|
345
|
+
*
|
|
346
|
+
* @param entity - Entity name
|
|
347
|
+
* @param id - Entity ID
|
|
348
|
+
* @param command - Emit command from resolver
|
|
349
|
+
*/
|
|
350
|
+
processCommand(entity: string, id: string, command: EmitCommand): void {
|
|
351
|
+
switch (command.type) {
|
|
352
|
+
case "full":
|
|
353
|
+
this.emit(entity, id, command.data as Record<string, unknown>, {
|
|
354
|
+
replace: command.replace,
|
|
355
|
+
});
|
|
356
|
+
break;
|
|
357
|
+
case "field":
|
|
358
|
+
this.emitField(entity, id, command.field, command.update);
|
|
359
|
+
break;
|
|
360
|
+
case "batch":
|
|
361
|
+
this.emitBatch(entity, id, command.updates);
|
|
362
|
+
break;
|
|
363
|
+
case "array":
|
|
364
|
+
this.emitArrayOperation(entity, id, command.operation);
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ===========================================================================
|
|
370
|
+
// Array State Emission
|
|
371
|
+
// ===========================================================================
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Emit array data (replace entire array).
|
|
375
|
+
*
|
|
376
|
+
* @param entity - Entity name
|
|
377
|
+
* @param id - Entity ID
|
|
378
|
+
* @param items - Array items
|
|
379
|
+
*/
|
|
380
|
+
emitArray(entity: string, id: string, items: unknown[]): void {
|
|
381
|
+
const key = this.makeKey(entity, id);
|
|
382
|
+
this.canonicalArrays.set(key, [...items]);
|
|
383
|
+
|
|
384
|
+
// Push updates to all subscribed clients
|
|
385
|
+
const subscribers = this.entitySubscribers.get(key);
|
|
386
|
+
if (!subscribers) return;
|
|
387
|
+
|
|
388
|
+
for (const clientId of subscribers) {
|
|
389
|
+
this.pushArrayToClient(clientId, entity, id, key, items);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Apply an array operation to the canonical state.
|
|
395
|
+
*
|
|
396
|
+
* @param entity - Entity name
|
|
397
|
+
* @param id - Entity ID
|
|
398
|
+
* @param operation - Array operation to apply
|
|
399
|
+
*/
|
|
400
|
+
emitArrayOperation(entity: string, id: string, operation: ArrayOperation): void {
|
|
401
|
+
const key = this.makeKey(entity, id);
|
|
402
|
+
|
|
403
|
+
// Get or create canonical array state
|
|
404
|
+
let currentArray = this.canonicalArrays.get(key);
|
|
405
|
+
if (!currentArray) {
|
|
406
|
+
currentArray = [];
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Apply operation
|
|
410
|
+
const newArray = this.applyArrayOperation([...currentArray], operation);
|
|
411
|
+
this.canonicalArrays.set(key, newArray);
|
|
412
|
+
|
|
413
|
+
// Push updates to all subscribed clients
|
|
414
|
+
const subscribers = this.entitySubscribers.get(key);
|
|
415
|
+
if (!subscribers) return;
|
|
416
|
+
|
|
417
|
+
for (const clientId of subscribers) {
|
|
418
|
+
this.pushArrayToClient(clientId, entity, id, key, newArray);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Apply an array operation and return new array.
|
|
424
|
+
*/
|
|
425
|
+
private applyArrayOperation(array: unknown[], operation: ArrayOperation): unknown[] {
|
|
426
|
+
switch (operation.op) {
|
|
427
|
+
case "push":
|
|
428
|
+
return [...array, operation.item];
|
|
429
|
+
|
|
430
|
+
case "unshift":
|
|
431
|
+
return [operation.item, ...array];
|
|
432
|
+
|
|
433
|
+
case "insert":
|
|
434
|
+
return [
|
|
435
|
+
...array.slice(0, operation.index),
|
|
436
|
+
operation.item,
|
|
437
|
+
...array.slice(operation.index),
|
|
438
|
+
];
|
|
439
|
+
|
|
440
|
+
case "remove":
|
|
441
|
+
return [...array.slice(0, operation.index), ...array.slice(operation.index + 1)];
|
|
442
|
+
|
|
443
|
+
case "removeById": {
|
|
444
|
+
const idx = array.findIndex(
|
|
445
|
+
(item) =>
|
|
446
|
+
typeof item === "object" &&
|
|
447
|
+
item !== null &&
|
|
448
|
+
"id" in item &&
|
|
449
|
+
(item as { id: string }).id === operation.id,
|
|
450
|
+
);
|
|
451
|
+
if (idx === -1) return array;
|
|
452
|
+
return [...array.slice(0, idx), ...array.slice(idx + 1)];
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
case "update":
|
|
456
|
+
return array.map((item, i) => (i === operation.index ? operation.item : item));
|
|
457
|
+
|
|
458
|
+
case "updateById":
|
|
459
|
+
return array.map((item) =>
|
|
460
|
+
typeof item === "object" &&
|
|
461
|
+
item !== null &&
|
|
462
|
+
"id" in item &&
|
|
463
|
+
(item as { id: string }).id === operation.id
|
|
464
|
+
? operation.item
|
|
465
|
+
: item,
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
case "merge":
|
|
469
|
+
return array.map((item, i) =>
|
|
470
|
+
i === operation.index && typeof item === "object" && item !== null
|
|
471
|
+
? { ...item, ...(operation.partial as object) }
|
|
472
|
+
: item,
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
case "mergeById":
|
|
476
|
+
return array.map((item) =>
|
|
477
|
+
typeof item === "object" &&
|
|
478
|
+
item !== null &&
|
|
479
|
+
"id" in item &&
|
|
480
|
+
(item as { id: string }).id === operation.id
|
|
481
|
+
? { ...item, ...(operation.partial as object) }
|
|
482
|
+
: item,
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
default:
|
|
486
|
+
return array;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Push array update to a specific client.
|
|
492
|
+
* Computes optimal diff strategy.
|
|
493
|
+
*/
|
|
494
|
+
private pushArrayToClient(
|
|
495
|
+
clientId: string,
|
|
496
|
+
entity: string,
|
|
497
|
+
id: string,
|
|
498
|
+
key: EntityKey,
|
|
499
|
+
newArray: unknown[],
|
|
500
|
+
): void {
|
|
501
|
+
const client = this.clients.get(clientId);
|
|
502
|
+
if (!client) return;
|
|
503
|
+
|
|
504
|
+
const clientArrayStateMap = this.clientArrayStates.get(clientId);
|
|
505
|
+
if (!clientArrayStateMap) return;
|
|
506
|
+
|
|
507
|
+
let clientArrayState = clientArrayStateMap.get(key);
|
|
508
|
+
if (!clientArrayState) {
|
|
509
|
+
// Initialize client array state
|
|
510
|
+
clientArrayState = { lastState: [] };
|
|
511
|
+
clientArrayStateMap.set(key, clientArrayState);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const { lastState } = clientArrayState;
|
|
515
|
+
|
|
516
|
+
// Skip if unchanged
|
|
517
|
+
if (JSON.stringify(lastState) === JSON.stringify(newArray)) {
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// For now, send full array replacement
|
|
522
|
+
// TODO: Compute array diff for optimal transfer
|
|
523
|
+
client.send({
|
|
524
|
+
type: "update",
|
|
525
|
+
entity,
|
|
526
|
+
id,
|
|
527
|
+
updates: {
|
|
528
|
+
_items: { strategy: "value", data: newArray },
|
|
529
|
+
},
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
// Update client's last known state
|
|
533
|
+
clientArrayState.lastState = [...newArray];
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Get current canonical array state
|
|
538
|
+
*/
|
|
539
|
+
getArrayState(entity: string, id: string): unknown[] | undefined {
|
|
540
|
+
return this.canonicalArrays.get(this.makeKey(entity, id));
|
|
541
|
+
}
|
|
542
|
+
|
|
248
543
|
/**
|
|
249
544
|
* Get current canonical state for an entity
|
|
250
545
|
*/
|
|
@@ -330,6 +625,131 @@ export class GraphStateManager {
|
|
|
330
625
|
}
|
|
331
626
|
}
|
|
332
627
|
|
|
628
|
+
/**
|
|
629
|
+
* Push a single field update to a client.
|
|
630
|
+
* Computes optimal transfer strategy.
|
|
631
|
+
*/
|
|
632
|
+
private pushFieldToClient(
|
|
633
|
+
clientId: string,
|
|
634
|
+
entity: string,
|
|
635
|
+
id: string,
|
|
636
|
+
key: EntityKey,
|
|
637
|
+
field: string,
|
|
638
|
+
newValue: unknown,
|
|
639
|
+
): void {
|
|
640
|
+
const client = this.clients.get(clientId);
|
|
641
|
+
if (!client) return;
|
|
642
|
+
|
|
643
|
+
const clientStateMap = this.clientStates.get(clientId);
|
|
644
|
+
if (!clientStateMap) return;
|
|
645
|
+
|
|
646
|
+
const clientEntityState = clientStateMap.get(key);
|
|
647
|
+
if (!clientEntityState) return;
|
|
648
|
+
|
|
649
|
+
const { lastState, fields } = clientEntityState;
|
|
650
|
+
|
|
651
|
+
// Check if client is subscribed to this field
|
|
652
|
+
if (fields !== "*" && !fields.has(field)) {
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const oldValue = lastState[field];
|
|
657
|
+
|
|
658
|
+
// Skip if unchanged
|
|
659
|
+
if (oldValue === newValue) return;
|
|
660
|
+
if (
|
|
661
|
+
typeof oldValue === "object" &&
|
|
662
|
+
typeof newValue === "object" &&
|
|
663
|
+
JSON.stringify(oldValue) === JSON.stringify(newValue)
|
|
664
|
+
) {
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Compute optimal update for transfer
|
|
669
|
+
const update = createUpdate(oldValue, newValue);
|
|
670
|
+
|
|
671
|
+
// Send update
|
|
672
|
+
client.send({
|
|
673
|
+
type: "update",
|
|
674
|
+
entity,
|
|
675
|
+
id,
|
|
676
|
+
updates: { [field]: update },
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
// Update client's last known state
|
|
680
|
+
clientEntityState.lastState[field] = newValue;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Push multiple field updates to a client.
|
|
685
|
+
* Computes optimal transfer strategy for each field.
|
|
686
|
+
*/
|
|
687
|
+
private pushFieldsToClient(
|
|
688
|
+
clientId: string,
|
|
689
|
+
entity: string,
|
|
690
|
+
id: string,
|
|
691
|
+
key: EntityKey,
|
|
692
|
+
changedFields: string[],
|
|
693
|
+
newState: Record<string, unknown>,
|
|
694
|
+
): void {
|
|
695
|
+
const client = this.clients.get(clientId);
|
|
696
|
+
if (!client) return;
|
|
697
|
+
|
|
698
|
+
const clientStateMap = this.clientStates.get(clientId);
|
|
699
|
+
if (!clientStateMap) return;
|
|
700
|
+
|
|
701
|
+
const clientEntityState = clientStateMap.get(key);
|
|
702
|
+
if (!clientEntityState) return;
|
|
703
|
+
|
|
704
|
+
const { lastState, fields } = clientEntityState;
|
|
705
|
+
|
|
706
|
+
// Compute updates for changed fields
|
|
707
|
+
const updates: Record<string, Update> = {};
|
|
708
|
+
let hasChanges = false;
|
|
709
|
+
|
|
710
|
+
for (const field of changedFields) {
|
|
711
|
+
// Check if client is subscribed to this field
|
|
712
|
+
if (fields !== "*" && !fields.has(field)) {
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const oldValue = lastState[field];
|
|
717
|
+
const newValue = newState[field];
|
|
718
|
+
|
|
719
|
+
// Skip if unchanged
|
|
720
|
+
if (oldValue === newValue) continue;
|
|
721
|
+
if (
|
|
722
|
+
typeof oldValue === "object" &&
|
|
723
|
+
typeof newValue === "object" &&
|
|
724
|
+
JSON.stringify(oldValue) === JSON.stringify(newValue)
|
|
725
|
+
) {
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Compute optimal update for transfer
|
|
730
|
+
const update = createUpdate(oldValue, newValue);
|
|
731
|
+
updates[field] = update;
|
|
732
|
+
hasChanges = true;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (!hasChanges) return;
|
|
736
|
+
|
|
737
|
+
// Send update
|
|
738
|
+
client.send({
|
|
739
|
+
type: "update",
|
|
740
|
+
entity,
|
|
741
|
+
id,
|
|
742
|
+
updates,
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
// Update client's last known state
|
|
746
|
+
for (const field of changedFields) {
|
|
747
|
+
if (newState[field] !== undefined) {
|
|
748
|
+
clientEntityState.lastState[field] = newState[field];
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
333
753
|
/**
|
|
334
754
|
* Send initial data to a newly subscribed client
|
|
335
755
|
*/
|
|
@@ -426,7 +846,9 @@ export class GraphStateManager {
|
|
|
426
846
|
clear(): void {
|
|
427
847
|
this.clients.clear();
|
|
428
848
|
this.canonical.clear();
|
|
849
|
+
this.canonicalArrays.clear();
|
|
429
850
|
this.clientStates.clear();
|
|
851
|
+
this.clientArrayStates.clear();
|
|
430
852
|
this.entitySubscribers.clear();
|
|
431
853
|
}
|
|
432
854
|
}
|