@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 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 { createUpdate, makeEntityKey } from "@sylphx/lens-core";
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 contextWithHelpers = {
509
- ...context,
510
- emit: emitData,
511
- onCleanup: (fn) => {
512
- sub.cleanups.push(fn);
513
- return () => {
514
- const idx = sub.cleanups.indexOf(fn);
515
- if (idx >= 0)
516
- sub.cleanups.splice(idx, 1);
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: contextWithHelpers
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 resolverCtx = {
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.4",
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.4"
32
+ "@sylphx/lens-core": "^1.2.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "typescript": "^5.9.3",
@@ -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 ctx.emit", async () => {
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, ctx }) => {
316
- emitFn = ctx.emit;
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, ctx }) => {
359
- emitFn = ctx.emit;
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 (ctx.onCleanup)
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, ctx }) => {
461
- ctx.onCleanup(() => {
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, ctx }) => {
501
- emitFn = ctx.emit;
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(({ ctx }) => {
648
- emitFn = ctx.emit;
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 ctx.emit", async () => {
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(({ ctx }) => {
690
- emitFn = ctx.emit;
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: ctx.onCleanup
726
+ // Test: onCleanup
727
727
  // =============================================================================
728
728
 
729
- describe("ctx.onCleanup", () => {
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(({ ctx }) => {
736
- ctx.onCleanup(() => {
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(({ ctx }) => {
776
- ctx.onCleanup(() => {
775
+ .resolve(({ onCleanup }) => {
776
+ onCleanup(() => {
777
777
  cleanedUp = true;
778
778
  });
779
779
  return mockUsers[0];
@@ -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
- // Add emit and onCleanup to context for subscriptions
670
- const contextWithHelpers = {
671
- ...context,
672
- emit: emitData,
673
- onCleanup: (fn: () => void) => {
674
- sub.cleanups.push(fn);
675
- return () => {
676
- const idx = sub.cleanups.indexOf(fn);
677
- if (idx >= 0) sub.cleanups.splice(idx, 1);
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: contextWithHelpers,
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
- const resolverCtx = {
896
- input: cleanInput as TInput,
897
- ctx: context, // Pass context directly to resolver (tRPC style)
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(resolverCtx);
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, // Pass context directly to resolver (tRPC style)
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 { type EntityKey, type Update, createUpdate, makeEntityKey } from "@sylphx/lens-core";
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
  }