@sylphx/lens-server 1.11.2 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5,524 +5,168 @@ import {
5
5
  router
6
6
  } from "@sylphx/lens-core";
7
7
 
8
+ // src/context/index.ts
9
+ import { AsyncLocalStorage } from "node:async_hooks";
10
+ var globalContextStore = new AsyncLocalStorage;
11
+ function createContext() {
12
+ return globalContextStore;
13
+ }
14
+ function useContext() {
15
+ const ctx = globalContextStore.getStore();
16
+ if (!ctx) {
17
+ throw new Error("useContext() called outside of context. " + "Make sure to wrap your code with runWithContext() or use explicit ctx parameter.");
18
+ }
19
+ return ctx;
20
+ }
21
+ function tryUseContext() {
22
+ return globalContextStore.getStore();
23
+ }
24
+ function runWithContext(_context, value, fn) {
25
+ return globalContextStore.run(value, fn);
26
+ }
27
+ async function runWithContextAsync(context, value, fn) {
28
+ return runWithContext(context, value, fn);
29
+ }
30
+ function hasContext() {
31
+ return globalContextStore.getStore() !== undefined;
32
+ }
33
+ function extendContext(current, extension) {
34
+ return { ...current, ...extension };
35
+ }
8
36
  // src/server/create.ts
9
37
  import {
10
- createContext,
11
38
  createEmit,
12
- createUpdate as createUpdate2,
13
39
  flattenRouter,
14
40
  isEntityDef,
15
41
  isMutationDef,
16
- isPipeline,
17
42
  isQueryDef,
18
- runWithContext,
19
43
  toResolverMap
20
44
  } from "@sylphx/lens-core";
21
45
 
22
- // src/state/graph-state-manager.ts
23
- import {
24
- applyUpdate,
25
- computeArrayDiff,
26
- createUpdate,
27
- makeEntityKey
28
- } from "@sylphx/lens-core";
29
-
30
- class GraphStateManager {
31
- clients = new Map;
32
- canonical = new Map;
33
- canonicalArrays = new Map;
34
- clientStates = new Map;
35
- clientArrayStates = new Map;
36
- entitySubscribers = new Map;
37
- config;
38
- constructor(config = {}) {
39
- this.config = config;
40
- }
41
- addClient(client) {
42
- this.clients.set(client.id, client);
43
- this.clientStates.set(client.id, new Map);
44
- this.clientArrayStates.set(client.id, new Map);
45
- }
46
- removeClient(clientId) {
47
- for (const [key, subscribers] of this.entitySubscribers) {
48
- subscribers.delete(clientId);
49
- if (subscribers.size === 0) {
50
- this.cleanupEntity(key);
51
- }
52
- }
53
- this.clients.delete(clientId);
54
- this.clientStates.delete(clientId);
55
- this.clientArrayStates.delete(clientId);
56
- }
57
- subscribe(clientId, entity, id, fields = "*") {
58
- const key = this.makeKey(entity, id);
59
- let subscribers = this.entitySubscribers.get(key);
60
- if (!subscribers) {
61
- subscribers = new Set;
62
- this.entitySubscribers.set(key, subscribers);
63
- }
64
- subscribers.add(clientId);
65
- const clientStateMap = this.clientStates.get(clientId);
66
- if (clientStateMap) {
67
- const fieldSet = fields === "*" ? "*" : new Set(fields);
68
- clientStateMap.set(key, {
69
- lastState: {},
70
- fields: fieldSet
71
- });
72
- }
73
- const canonicalState = this.canonical.get(key);
74
- if (canonicalState) {
75
- this.sendInitialData(clientId, entity, id, canonicalState, fields);
76
- }
77
- }
78
- unsubscribe(clientId, entity, id) {
79
- const key = this.makeKey(entity, id);
80
- const subscribers = this.entitySubscribers.get(key);
81
- if (subscribers) {
82
- subscribers.delete(clientId);
83
- if (subscribers.size === 0) {
84
- this.cleanupEntity(key);
46
+ // src/plugin/types.ts
47
+ class PluginManager {
48
+ plugins = [];
49
+ register(plugin) {
50
+ this.plugins.push(plugin);
51
+ }
52
+ getPlugins() {
53
+ return this.plugins;
54
+ }
55
+ async runOnConnect(ctx) {
56
+ for (const plugin of this.plugins) {
57
+ if (plugin.onConnect) {
58
+ const result = await plugin.onConnect(ctx);
59
+ if (result === false)
60
+ return false;
85
61
  }
86
62
  }
87
- const clientStateMap = this.clientStates.get(clientId);
88
- if (clientStateMap) {
89
- clientStateMap.delete(key);
90
- }
63
+ return true;
91
64
  }
92
- updateSubscription(clientId, entity, id, fields) {
93
- const key = this.makeKey(entity, id);
94
- const clientStateMap = this.clientStates.get(clientId);
95
- if (clientStateMap) {
96
- const state = clientStateMap.get(key);
97
- if (state) {
98
- state.fields = fields === "*" ? "*" : new Set(fields);
65
+ async runOnDisconnect(ctx) {
66
+ for (const plugin of this.plugins) {
67
+ if (plugin.onDisconnect) {
68
+ await plugin.onDisconnect(ctx);
99
69
  }
100
70
  }
101
71
  }
102
- emit(entity, id, data, options = {}) {
103
- const key = this.makeKey(entity, id);
104
- let currentCanonical = this.canonical.get(key);
105
- if (options.replace || !currentCanonical) {
106
- currentCanonical = { ...data };
107
- } else {
108
- currentCanonical = { ...currentCanonical, ...data };
109
- }
110
- this.canonical.set(key, currentCanonical);
111
- const subscribers = this.entitySubscribers.get(key);
112
- if (!subscribers)
113
- return;
114
- for (const clientId of subscribers) {
115
- this.pushToClient(clientId, entity, id, key, currentCanonical);
116
- }
117
- }
118
- emitField(entity, id, field, update) {
119
- const key = this.makeKey(entity, id);
120
- let currentCanonical = this.canonical.get(key);
121
- if (!currentCanonical) {
122
- currentCanonical = {};
123
- }
124
- const oldValue = currentCanonical[field];
125
- const newValue = applyUpdate(oldValue, update);
126
- currentCanonical = { ...currentCanonical, [field]: newValue };
127
- this.canonical.set(key, currentCanonical);
128
- const subscribers = this.entitySubscribers.get(key);
129
- if (!subscribers)
130
- return;
131
- for (const clientId of subscribers) {
132
- this.pushFieldToClient(clientId, entity, id, key, field, newValue);
133
- }
134
- }
135
- emitBatch(entity, id, updates) {
136
- const key = this.makeKey(entity, id);
137
- let currentCanonical = this.canonical.get(key);
138
- if (!currentCanonical) {
139
- currentCanonical = {};
140
- }
141
- const changedFields = [];
142
- for (const { field, update } of updates) {
143
- const oldValue = currentCanonical[field];
144
- const newValue = applyUpdate(oldValue, update);
145
- currentCanonical[field] = newValue;
146
- changedFields.push(field);
147
- }
148
- this.canonical.set(key, currentCanonical);
149
- const subscribers = this.entitySubscribers.get(key);
150
- if (!subscribers)
151
- return;
152
- for (const clientId of subscribers) {
153
- this.pushFieldsToClient(clientId, entity, id, key, changedFields, currentCanonical);
154
- }
155
- }
156
- processCommand(entity, id, command) {
157
- switch (command.type) {
158
- case "full":
159
- this.emit(entity, id, command.data, {
160
- replace: command.replace
161
- });
162
- break;
163
- case "field":
164
- this.emitField(entity, id, command.field, command.update);
165
- break;
166
- case "batch":
167
- this.emitBatch(entity, id, command.updates);
168
- break;
169
- case "array":
170
- this.emitArrayOperation(entity, id, command.operation);
171
- break;
72
+ async runOnSubscribe(ctx) {
73
+ for (const plugin of this.plugins) {
74
+ if (plugin.onSubscribe) {
75
+ const result = await plugin.onSubscribe(ctx);
76
+ if (result === false)
77
+ return false;
78
+ }
172
79
  }
80
+ return true;
173
81
  }
174
- emitArray(entity, id, items) {
175
- const key = this.makeKey(entity, id);
176
- this.canonicalArrays.set(key, [...items]);
177
- const subscribers = this.entitySubscribers.get(key);
178
- if (!subscribers)
179
- return;
180
- for (const clientId of subscribers) {
181
- this.pushArrayToClient(clientId, entity, id, key, items);
82
+ async runOnUnsubscribe(ctx) {
83
+ for (const plugin of this.plugins) {
84
+ if (plugin.onUnsubscribe) {
85
+ await plugin.onUnsubscribe(ctx);
86
+ }
182
87
  }
183
88
  }
184
- emitArrayOperation(entity, id, operation) {
185
- const key = this.makeKey(entity, id);
186
- let currentArray = this.canonicalArrays.get(key);
187
- if (!currentArray) {
188
- currentArray = [];
189
- }
190
- const newArray = this.applyArrayOperation([...currentArray], operation);
191
- this.canonicalArrays.set(key, newArray);
192
- const subscribers = this.entitySubscribers.get(key);
193
- if (!subscribers)
194
- return;
195
- for (const clientId of subscribers) {
196
- this.pushArrayToClient(clientId, entity, id, key, newArray);
197
- }
198
- }
199
- applyArrayOperation(array, operation) {
200
- switch (operation.op) {
201
- case "push":
202
- return [...array, operation.item];
203
- case "unshift":
204
- return [operation.item, ...array];
205
- case "insert":
206
- return [
207
- ...array.slice(0, operation.index),
208
- operation.item,
209
- ...array.slice(operation.index)
210
- ];
211
- case "remove":
212
- return [...array.slice(0, operation.index), ...array.slice(operation.index + 1)];
213
- case "removeById": {
214
- const idx = array.findIndex((item) => typeof item === "object" && item !== null && ("id" in item) && item.id === operation.id);
215
- if (idx === -1)
216
- return array;
217
- return [...array.slice(0, idx), ...array.slice(idx + 1)];
218
- }
219
- case "update":
220
- return array.map((item, i) => i === operation.index ? operation.item : item);
221
- case "updateById":
222
- return array.map((item) => typeof item === "object" && item !== null && ("id" in item) && item.id === operation.id ? operation.item : item);
223
- case "merge":
224
- return array.map((item, i) => i === operation.index && typeof item === "object" && item !== null ? { ...item, ...operation.partial } : item);
225
- case "mergeById":
226
- return array.map((item) => typeof item === "object" && item !== null && ("id" in item) && item.id === operation.id ? { ...item, ...operation.partial } : item);
227
- default:
228
- return array;
229
- }
230
- }
231
- pushArrayToClient(clientId, entity, id, key, newArray) {
232
- const client = this.clients.get(clientId);
233
- if (!client)
234
- return;
235
- const clientArrayStateMap = this.clientArrayStates.get(clientId);
236
- if (!clientArrayStateMap)
237
- return;
238
- let clientArrayState = clientArrayStateMap.get(key);
239
- if (!clientArrayState) {
240
- clientArrayState = { lastState: [] };
241
- clientArrayStateMap.set(key, clientArrayState);
242
- }
243
- const { lastState } = clientArrayState;
244
- if (JSON.stringify(lastState) === JSON.stringify(newArray)) {
245
- return;
246
- }
247
- const diff = computeArrayDiff(lastState, newArray);
248
- if (diff === null || diff.length === 0) {
249
- client.send({
250
- type: "update",
251
- entity,
252
- id,
253
- updates: {
254
- _items: { strategy: "value", data: newArray }
255
- }
256
- });
257
- } else if (diff.length === 1 && diff[0].op === "replace") {
258
- client.send({
259
- type: "update",
260
- entity,
261
- id,
262
- updates: {
263
- _items: { strategy: "value", data: newArray }
264
- }
265
- });
266
- } else {
267
- client.send({
268
- type: "update",
269
- entity,
270
- id,
271
- updates: {
272
- _items: { strategy: "array", data: diff }
89
+ async runBeforeSend(ctx) {
90
+ let data = ctx.data;
91
+ for (const plugin of this.plugins) {
92
+ if (plugin.beforeSend) {
93
+ const result = await plugin.beforeSend({ ...ctx, data });
94
+ if (result !== undefined) {
95
+ data = result;
273
96
  }
274
- });
275
- }
276
- clientArrayState.lastState = [...newArray];
277
- }
278
- getArrayState(entity, id) {
279
- return this.canonicalArrays.get(this.makeKey(entity, id));
280
- }
281
- getState(entity, id) {
282
- return this.canonical.get(this.makeKey(entity, id));
283
- }
284
- hasSubscribers(entity, id) {
285
- const subscribers = this.entitySubscribers.get(this.makeKey(entity, id));
286
- return subscribers !== undefined && subscribers.size > 0;
287
- }
288
- pushToClient(clientId, entity, id, key, newState) {
289
- const client = this.clients.get(clientId);
290
- if (!client)
291
- return;
292
- const clientStateMap = this.clientStates.get(clientId);
293
- if (!clientStateMap)
294
- return;
295
- const clientEntityState = clientStateMap.get(key);
296
- if (!clientEntityState)
297
- return;
298
- const { lastState, fields } = clientEntityState;
299
- const fieldsToCheck = fields === "*" ? Object.keys(newState) : Array.from(fields);
300
- const updates = {};
301
- let hasChanges = false;
302
- for (const field of fieldsToCheck) {
303
- const oldValue = lastState[field];
304
- const newValue = newState[field];
305
- if (oldValue === newValue)
306
- continue;
307
- if (typeof oldValue === "object" && typeof newValue === "object" && JSON.stringify(oldValue) === JSON.stringify(newValue)) {
308
- continue;
309
- }
310
- const update = createUpdate(oldValue, newValue);
311
- updates[field] = update;
312
- hasChanges = true;
313
- }
314
- if (!hasChanges)
315
- return;
316
- client.send({
317
- type: "update",
318
- entity,
319
- id,
320
- updates
321
- });
322
- for (const field of fieldsToCheck) {
323
- if (newState[field] !== undefined) {
324
- clientEntityState.lastState[field] = newState[field];
325
97
  }
326
98
  }
99
+ return data;
327
100
  }
328
- pushFieldToClient(clientId, entity, id, key, field, newValue) {
329
- const client = this.clients.get(clientId);
330
- if (!client)
331
- return;
332
- const clientStateMap = this.clientStates.get(clientId);
333
- if (!clientStateMap)
334
- return;
335
- const clientEntityState = clientStateMap.get(key);
336
- if (!clientEntityState)
337
- return;
338
- const { lastState, fields } = clientEntityState;
339
- if (fields !== "*" && !fields.has(field)) {
340
- return;
341
- }
342
- const oldValue = lastState[field];
343
- if (oldValue === newValue)
344
- return;
345
- if (typeof oldValue === "object" && typeof newValue === "object" && JSON.stringify(oldValue) === JSON.stringify(newValue)) {
346
- return;
347
- }
348
- const update = createUpdate(oldValue, newValue);
349
- client.send({
350
- type: "update",
351
- entity,
352
- id,
353
- updates: { [field]: update }
354
- });
355
- clientEntityState.lastState[field] = newValue;
356
- }
357
- pushFieldsToClient(clientId, entity, id, key, changedFields, newState) {
358
- const client = this.clients.get(clientId);
359
- if (!client)
360
- return;
361
- const clientStateMap = this.clientStates.get(clientId);
362
- if (!clientStateMap)
363
- return;
364
- const clientEntityState = clientStateMap.get(key);
365
- if (!clientEntityState)
366
- return;
367
- const { lastState, fields } = clientEntityState;
368
- const updates = {};
369
- let hasChanges = false;
370
- for (const field of changedFields) {
371
- if (fields !== "*" && !fields.has(field)) {
372
- continue;
373
- }
374
- const oldValue = lastState[field];
375
- const newValue = newState[field];
376
- if (oldValue === newValue)
377
- continue;
378
- if (typeof oldValue === "object" && typeof newValue === "object" && JSON.stringify(oldValue) === JSON.stringify(newValue)) {
379
- continue;
380
- }
381
- const update = createUpdate(oldValue, newValue);
382
- updates[field] = update;
383
- hasChanges = true;
384
- }
385
- if (!hasChanges)
386
- return;
387
- client.send({
388
- type: "update",
389
- entity,
390
- id,
391
- updates
392
- });
393
- for (const field of changedFields) {
394
- if (newState[field] !== undefined) {
395
- clientEntityState.lastState[field] = newState[field];
101
+ async runAfterSend(ctx) {
102
+ for (const plugin of this.plugins) {
103
+ if (plugin.afterSend) {
104
+ await plugin.afterSend(ctx);
396
105
  }
397
106
  }
398
107
  }
399
- sendInitialData(clientId, entity, id, state, fields) {
400
- const client = this.clients.get(clientId);
401
- if (!client)
402
- return;
403
- const key = this.makeKey(entity, id);
404
- const clientStateMap = this.clientStates.get(clientId);
405
- if (!clientStateMap)
406
- return;
407
- const fieldsToSend = fields === "*" ? Object.keys(state) : fields;
408
- const dataToSend = {};
409
- const updates = {};
410
- for (const field of fieldsToSend) {
411
- if (state[field] !== undefined) {
412
- dataToSend[field] = state[field];
413
- updates[field] = { strategy: "value", data: state[field] };
108
+ async runBeforeMutation(ctx) {
109
+ for (const plugin of this.plugins) {
110
+ if (plugin.beforeMutation) {
111
+ const result = await plugin.beforeMutation(ctx);
112
+ if (result === false)
113
+ return false;
414
114
  }
415
115
  }
416
- client.send({
417
- type: "update",
418
- entity,
419
- id,
420
- updates
421
- });
422
- const clientEntityState = clientStateMap.get(key);
423
- if (clientEntityState) {
424
- clientEntityState.lastState = { ...dataToSend };
425
- }
116
+ return true;
426
117
  }
427
- cleanupEntity(key) {
428
- const [entity, id] = key.split(":");
429
- if (this.config.onEntityUnsubscribed) {
430
- this.config.onEntityUnsubscribed(entity, id);
118
+ async runAfterMutation(ctx) {
119
+ for (const plugin of this.plugins) {
120
+ if (plugin.afterMutation) {
121
+ await plugin.afterMutation(ctx);
122
+ }
431
123
  }
432
- this.entitySubscribers.delete(key);
433
- }
434
- makeKey(entity, id) {
435
- return makeEntityKey(entity, id);
436
124
  }
437
- getStats() {
438
- let totalSubscriptions = 0;
439
- for (const subscribers of this.entitySubscribers.values()) {
440
- totalSubscriptions += subscribers.size;
125
+ async runOnReconnect(ctx) {
126
+ for (const plugin of this.plugins) {
127
+ if (plugin.onReconnect) {
128
+ const result = await plugin.onReconnect(ctx);
129
+ if (result !== null) {
130
+ return result;
131
+ }
132
+ }
441
133
  }
442
- return {
443
- clients: this.clients.size,
444
- entities: this.canonical.size,
445
- totalSubscriptions
446
- };
447
- }
448
- clear() {
449
- this.clients.clear();
450
- this.canonical.clear();
451
- this.canonicalArrays.clear();
452
- this.clientStates.clear();
453
- this.clientArrayStates.clear();
454
- this.entitySubscribers.clear();
455
- }
456
- }
457
- function createGraphStateManager(config) {
458
- return new GraphStateManager(config);
459
- }
460
-
461
- // src/server/create.ts
462
- function getEntityTypeName(returnSpec) {
463
- if (!returnSpec)
464
- return;
465
- if (isEntityDef(returnSpec)) {
466
- return returnSpec._name;
467
- }
468
- if (Array.isArray(returnSpec) && returnSpec.length === 1 && isEntityDef(returnSpec[0])) {
469
- return returnSpec[0]._name;
470
- }
471
- return;
472
- }
473
- function getInputFields(inputSchema) {
474
- if (!inputSchema?.shape)
475
- return [];
476
- return Object.keys(inputSchema.shape);
477
- }
478
- function sugarToPipeline(optimistic, entityType, inputFields) {
479
- if (isPipeline(optimistic)) {
480
- return optimistic;
481
- }
482
- if (!entityType) {
483
- return optimistic;
134
+ return null;
484
135
  }
485
- if (optimistic === "merge") {
486
- const args = { type: entityType };
487
- for (const field of inputFields) {
488
- args[field] = { $input: field };
136
+ async runOnUpdateFields(ctx) {
137
+ for (const plugin of this.plugins) {
138
+ if (plugin.onUpdateFields) {
139
+ await plugin.onUpdateFields(ctx);
140
+ }
489
141
  }
490
- return {
491
- $pipe: [{ $do: "entity.update", $with: args }]
492
- };
493
142
  }
494
- if (optimistic === "create") {
495
- const args = { type: entityType, id: { $temp: true } };
496
- for (const field of inputFields) {
497
- if (field !== "id") {
498
- args[field] = { $input: field };
143
+ runEnhanceOperationMeta(ctx) {
144
+ for (const plugin of this.plugins) {
145
+ if (plugin.enhanceOperationMeta) {
146
+ plugin.enhanceOperationMeta(ctx);
499
147
  }
500
148
  }
501
- return {
502
- $pipe: [{ $do: "entity.create", $with: args }]
503
- };
504
- }
505
- if (optimistic === "delete") {
506
- return {
507
- $pipe: [{ $do: "entity.delete", $with: { type: entityType, id: { $input: "id" } } }]
508
- };
509
149
  }
510
- if (typeof optimistic === "object" && optimistic !== null && "merge" in optimistic && typeof optimistic.merge === "object") {
511
- const extra = optimistic.merge;
512
- const args = { type: entityType };
513
- for (const field of inputFields) {
514
- args[field] = { $input: field };
515
- }
516
- for (const [key, value] of Object.entries(extra)) {
517
- args[key] = value;
150
+ async runOnBroadcast(ctx) {
151
+ for (const plugin of this.plugins) {
152
+ if (plugin.onBroadcast) {
153
+ const result = await plugin.onBroadcast(ctx);
154
+ if (result && typeof result === "object" && "version" in result) {
155
+ return result;
156
+ }
157
+ if (result === true) {
158
+ return { version: 0, patch: null, data: ctx.data };
159
+ }
160
+ }
518
161
  }
519
- return {
520
- $pipe: [{ $do: "entity.update", $with: args }]
521
- };
162
+ return null;
522
163
  }
523
- return optimistic;
164
+ }
165
+ function createPluginManager() {
166
+ return new PluginManager;
524
167
  }
525
168
 
169
+ // src/server/dataloader.ts
526
170
  class DataLoader {
527
171
  batchFn;
528
172
  batch = new Map;
@@ -538,16 +182,13 @@ class DataLoader {
538
182
  } else {
539
183
  this.batch.set(key, [{ resolve, reject }]);
540
184
  }
541
- this.scheduleDispatch();
185
+ if (!this.scheduled) {
186
+ this.scheduled = true;
187
+ queueMicrotask(() => this.flush());
188
+ }
542
189
  });
543
190
  }
544
- scheduleDispatch() {
545
- if (this.scheduled)
546
- return;
547
- this.scheduled = true;
548
- queueMicrotask(() => this.dispatch());
549
- }
550
- async dispatch() {
191
+ async flush() {
551
192
  this.scheduled = false;
552
193
  const batch = this.batch;
553
194
  this.batch = new Map;
@@ -556,22 +197,52 @@ class DataLoader {
556
197
  return;
557
198
  try {
558
199
  const results = await this.batchFn(keys);
559
- keys.forEach((key, index) => {
560
- const callbacks = batch.get(key);
561
- const result = results[index] ?? null;
562
- for (const { resolve } of callbacks)
200
+ let i = 0;
201
+ for (const [_key, callbacks] of batch) {
202
+ const result = results[i++];
203
+ for (const { resolve } of callbacks) {
563
204
  resolve(result);
564
- });
205
+ }
206
+ }
565
207
  } catch (error) {
566
- for (const callbacks of batch.values()) {
567
- for (const { reject } of callbacks)
568
- reject(error);
208
+ for (const [, callbacks] of batch) {
209
+ for (const { reject } of callbacks) {
210
+ reject(error instanceof Error ? error : new Error(String(error)));
211
+ }
569
212
  }
570
213
  }
571
214
  }
572
- clear() {
573
- this.batch.clear();
574
- }
215
+ }
216
+
217
+ // src/server/selection.ts
218
+ function applySelection(data, select) {
219
+ if (!data)
220
+ return data;
221
+ if (Array.isArray(data)) {
222
+ return data.map((item) => applySelection(item, select));
223
+ }
224
+ if (typeof data !== "object")
225
+ return data;
226
+ const obj = data;
227
+ const result = {};
228
+ if ("id" in obj)
229
+ result.id = obj.id;
230
+ for (const [key, value] of Object.entries(select)) {
231
+ if (!(key in obj))
232
+ continue;
233
+ if (value === true) {
234
+ result[key] = obj[key];
235
+ } else if (typeof value === "object" && value !== null) {
236
+ const nestedSelect = "select" in value ? value.select : value;
237
+ result[key] = applySelection(obj[key], nestedSelect);
238
+ }
239
+ }
240
+ return result;
241
+ }
242
+
243
+ // src/server/create.ts
244
+ function isAsyncIterable(value) {
245
+ return value != null && typeof value === "object" && Symbol.asyncIterator in value;
575
246
  }
576
247
  var noopLogger = {};
577
248
 
@@ -584,11 +255,8 @@ class LensServerImpl {
584
255
  version;
585
256
  logger;
586
257
  ctx = createContext();
587
- stateManager;
588
258
  loaders = new Map;
589
- connections = new Map;
590
- connectionCounter = 0;
591
- server = null;
259
+ pluginManager;
592
260
  constructor(config) {
593
261
  const queries = { ...config.queries ?? {} };
594
262
  const mutations = { ...config.mutations ?? {} };
@@ -609,6 +277,14 @@ class LensServerImpl {
609
277
  this.contextFactory = config.context ?? (() => ({}));
610
278
  this.version = config.version ?? "1.0.0";
611
279
  this.logger = config.logger ?? noopLogger;
280
+ this.pluginManager = createPluginManager();
281
+ for (const plugin of config.plugins ?? []) {
282
+ this.pluginManager.register(plugin);
283
+ }
284
+ this.injectNames();
285
+ this.validateDefinitions();
286
+ }
287
+ injectNames() {
612
288
  for (const [name, def] of Object.entries(this.entities)) {
613
289
  if (def && typeof def === "object" && !def._name) {
614
290
  def._name = name;
@@ -617,16 +293,6 @@ class LensServerImpl {
617
293
  for (const [name, def] of Object.entries(this.mutations)) {
618
294
  if (def && typeof def === "object") {
619
295
  def._name = name;
620
- const lastSegment = name.includes(".") ? name.split(".").pop() : name;
621
- if (!def._optimistic) {
622
- if (lastSegment.startsWith("update")) {
623
- def._optimistic = "merge";
624
- } else if (lastSegment.startsWith("create") || lastSegment.startsWith("add")) {
625
- def._optimistic = "create";
626
- } else if (lastSegment.startsWith("delete") || lastSegment.startsWith("remove")) {
627
- def._optimistic = "delete";
628
- }
629
- }
630
296
  }
631
297
  }
632
298
  for (const [name, def] of Object.entries(this.queries)) {
@@ -634,9 +300,8 @@ class LensServerImpl {
634
300
  def._name = name;
635
301
  }
636
302
  }
637
- this.stateManager = new GraphStateManager({
638
- onEntityUnsubscribed: (_entity, _id) => {}
639
- });
303
+ }
304
+ validateDefinitions() {
640
305
  for (const [name, def] of Object.entries(this.queries)) {
641
306
  if (!isQueryDef(def)) {
642
307
  throw new Error(`Invalid query definition: ${name}`);
@@ -648,9 +313,6 @@ class LensServerImpl {
648
313
  }
649
314
  }
650
315
  }
651
- getStateManager() {
652
- return this.stateManager;
653
- }
654
316
  getMetadata() {
655
317
  return {
656
318
  version: this.version,
@@ -673,76 +335,645 @@ class LensServerImpl {
673
335
  return { error: error instanceof Error ? error : new Error(String(error)) };
674
336
  }
675
337
  }
676
- buildOperationsMap() {
677
- const result = {};
678
- const setNested = (path, meta) => {
679
- const parts = path.split(".");
680
- let current = result;
681
- for (let i = 0;i < parts.length - 1; i++) {
682
- const part = parts[i];
683
- if (!current[part] || "type" in current[part]) {
684
- current[part] = {};
685
- }
686
- current = current[part];
687
- }
688
- current[parts[parts.length - 1]] = meta;
689
- };
690
- for (const [name, _def] of Object.entries(this.queries)) {
691
- setNested(name, { type: "query" });
338
+ async executeQuery(name, input) {
339
+ const queryDef = this.queries[name];
340
+ if (!queryDef)
341
+ throw new Error(`Query not found: ${name}`);
342
+ let select;
343
+ let cleanInput = input;
344
+ if (input && typeof input === "object" && "$select" in input) {
345
+ const { $select, ...rest } = input;
346
+ select = $select;
347
+ cleanInput = Object.keys(rest).length > 0 ? rest : undefined;
692
348
  }
693
- for (const [name, def] of Object.entries(this.mutations)) {
694
- const meta = { type: "mutation" };
695
- if (def._optimistic) {
696
- const entityType = getEntityTypeName(def._output);
697
- const inputFields = getInputFields(def._input);
698
- meta.optimistic = sugarToPipeline(def._optimistic, entityType, inputFields);
349
+ if (queryDef._input && cleanInput !== undefined) {
350
+ const result = queryDef._input.safeParse(cleanInput);
351
+ if (!result.success) {
352
+ throw new Error(`Invalid input: ${JSON.stringify(result.error)}`);
699
353
  }
700
- setNested(name, meta);
701
354
  }
702
- return result;
355
+ const context = await this.contextFactory();
356
+ try {
357
+ return await runWithContext(this.ctx, context, async () => {
358
+ const resolver = queryDef._resolve;
359
+ if (!resolver)
360
+ throw new Error(`Query ${name} has no resolver`);
361
+ const emit = createEmit(() => {});
362
+ const onCleanup = () => () => {};
363
+ const lensContext = { ...context, emit, onCleanup };
364
+ const result = resolver({ input: cleanInput, ctx: lensContext });
365
+ let data;
366
+ if (isAsyncIterable(result)) {
367
+ for await (const value of result) {
368
+ data = value;
369
+ break;
370
+ }
371
+ if (data === undefined) {
372
+ throw new Error(`Query ${name} returned empty stream`);
373
+ }
374
+ } else {
375
+ data = await result;
376
+ }
377
+ return this.processQueryResult(name, data, select);
378
+ });
379
+ } finally {
380
+ this.clearLoaders();
381
+ }
703
382
  }
704
- handleWebSocket(ws) {
705
- const clientId = `client_${++this.connectionCounter}`;
706
- const conn = {
707
- id: clientId,
708
- ws,
709
- subscriptions: new Map
710
- };
711
- this.connections.set(clientId, conn);
712
- this.stateManager.addClient({
713
- id: clientId,
714
- send: (msg) => {
715
- ws.send(JSON.stringify(msg));
383
+ async executeMutation(name, input) {
384
+ const mutationDef = this.mutations[name];
385
+ if (!mutationDef)
386
+ throw new Error(`Mutation not found: ${name}`);
387
+ if (mutationDef._input) {
388
+ const result = mutationDef._input.safeParse(input);
389
+ if (!result.success) {
390
+ throw new Error(`Invalid input: ${JSON.stringify(result.error)}`);
716
391
  }
717
- });
718
- ws.onmessage = (event) => {
719
- this.handleMessage(conn, event.data);
720
- };
721
- ws.onclose = () => {
722
- this.handleDisconnect(conn);
392
+ }
393
+ const context = await this.contextFactory();
394
+ try {
395
+ return await runWithContext(this.ctx, context, async () => {
396
+ const resolver = mutationDef._resolve;
397
+ if (!resolver)
398
+ throw new Error(`Mutation ${name} has no resolver`);
399
+ const emit = createEmit(() => {});
400
+ const onCleanup = () => () => {};
401
+ const lensContext = { ...context, emit, onCleanup };
402
+ return await resolver({ input, ctx: lensContext });
403
+ });
404
+ } finally {
405
+ this.clearLoaders();
406
+ }
407
+ }
408
+ async processQueryResult(_operationName, data, select) {
409
+ if (!data)
410
+ return data;
411
+ const processed = await this.resolveEntityFields(data);
412
+ if (select) {
413
+ return applySelection(processed, select);
414
+ }
415
+ return processed;
416
+ }
417
+ async resolveEntityFields(data) {
418
+ if (!data || !this.resolverMap)
419
+ return data;
420
+ if (Array.isArray(data)) {
421
+ return Promise.all(data.map((item) => this.resolveEntityFields(item)));
422
+ }
423
+ if (typeof data !== "object")
424
+ return data;
425
+ const obj = data;
426
+ const typeName = this.getTypeName(obj);
427
+ if (!typeName)
428
+ return data;
429
+ const resolverDef = this.resolverMap.get(typeName);
430
+ if (!resolverDef)
431
+ return data;
432
+ const result = { ...obj };
433
+ for (const fieldName of resolverDef.getFieldNames()) {
434
+ const field = String(fieldName);
435
+ if (resolverDef.isExposed(field))
436
+ continue;
437
+ const existingValue = result[field];
438
+ if (existingValue !== undefined) {
439
+ result[field] = await this.resolveEntityFields(existingValue);
440
+ continue;
441
+ }
442
+ const loaderKey = `${typeName}.${field}`;
443
+ const loader = this.getOrCreateLoaderForField(loaderKey, resolverDef, field);
444
+ result[field] = await loader.load(obj);
445
+ result[field] = await this.resolveEntityFields(result[field]);
446
+ }
447
+ return result;
448
+ }
449
+ getTypeName(obj) {
450
+ if ("__typename" in obj)
451
+ return obj.__typename;
452
+ if ("_type" in obj)
453
+ return obj._type;
454
+ for (const [name, def] of Object.entries(this.entities)) {
455
+ if (isEntityDef(def) && this.matchesEntity(obj, def)) {
456
+ return name;
457
+ }
458
+ }
459
+ return;
460
+ }
461
+ matchesEntity(obj, entityDef) {
462
+ return "id" in obj || entityDef._name in obj;
463
+ }
464
+ getOrCreateLoaderForField(loaderKey, resolverDef, fieldName) {
465
+ let loader = this.loaders.get(loaderKey);
466
+ if (!loader) {
467
+ loader = new DataLoader(async (parents) => {
468
+ const results = [];
469
+ for (const parent of parents) {
470
+ try {
471
+ const result = await resolverDef.resolveField(fieldName, parent, {}, {});
472
+ results.push(result);
473
+ } catch {
474
+ results.push(null);
475
+ }
476
+ }
477
+ return results;
478
+ });
479
+ this.loaders.set(loaderKey, loader);
480
+ }
481
+ return loader;
482
+ }
483
+ clearLoaders() {
484
+ this.loaders.clear();
485
+ }
486
+ buildOperationsMap() {
487
+ const result = {};
488
+ const setNested = (path, meta) => {
489
+ const parts = path.split(".");
490
+ let current = result;
491
+ for (let i = 0;i < parts.length - 1; i++) {
492
+ const part = parts[i];
493
+ if (!current[part] || "type" in current[part]) {
494
+ current[part] = {};
495
+ }
496
+ current = current[part];
497
+ }
498
+ current[parts[parts.length - 1]] = meta;
723
499
  };
500
+ for (const [name, def] of Object.entries(this.queries)) {
501
+ const meta = { type: "query" };
502
+ this.pluginManager.runEnhanceOperationMeta({
503
+ path: name,
504
+ type: "query",
505
+ meta,
506
+ definition: def
507
+ });
508
+ setNested(name, meta);
509
+ }
510
+ for (const [name, def] of Object.entries(this.mutations)) {
511
+ const meta = { type: "mutation" };
512
+ this.pluginManager.runEnhanceOperationMeta({
513
+ path: name,
514
+ type: "mutation",
515
+ meta,
516
+ definition: def
517
+ });
518
+ setNested(name, meta);
519
+ }
520
+ return result;
521
+ }
522
+ async addClient(clientId, send) {
523
+ const allowed = await this.pluginManager.runOnConnect({
524
+ clientId,
525
+ send: (msg) => send(msg)
526
+ });
527
+ return allowed;
528
+ }
529
+ removeClient(clientId, subscriptionCount) {
530
+ this.pluginManager.runOnDisconnect({ clientId, subscriptionCount });
531
+ }
532
+ async subscribe(ctx) {
533
+ return this.pluginManager.runOnSubscribe(ctx);
534
+ }
535
+ unsubscribe(ctx) {
536
+ this.pluginManager.runOnUnsubscribe(ctx);
537
+ }
538
+ async broadcast(entity, entityId, data) {
539
+ await this.pluginManager.runOnBroadcast({ entity, entityId, data });
540
+ }
541
+ async send(clientId, subscriptionId, entity, entityId, data, isInitial) {
542
+ const transformedData = await this.pluginManager.runBeforeSend({
543
+ clientId,
544
+ subscriptionId,
545
+ entity,
546
+ entityId,
547
+ data,
548
+ isInitial,
549
+ fields: "*"
550
+ });
551
+ await this.pluginManager.runAfterSend({
552
+ clientId,
553
+ subscriptionId,
554
+ entity,
555
+ entityId,
556
+ data: transformedData,
557
+ isInitial,
558
+ fields: "*",
559
+ timestamp: Date.now()
560
+ });
561
+ }
562
+ async handleReconnect(ctx) {
563
+ return this.pluginManager.runOnReconnect(ctx);
564
+ }
565
+ async updateFields(ctx) {
566
+ await this.pluginManager.runOnUpdateFields(ctx);
567
+ }
568
+ getPluginManager() {
569
+ return this.pluginManager;
570
+ }
571
+ }
572
+ function createApp(config) {
573
+ const server = new LensServerImpl(config);
574
+ return server;
575
+ }
576
+ // src/sse/handler.ts
577
+ class SSEHandler {
578
+ heartbeatInterval;
579
+ onConnectCallback;
580
+ onDisconnectCallback;
581
+ clients = new Map;
582
+ clientCounter = 0;
583
+ constructor(config = {}) {
584
+ this.heartbeatInterval = config.heartbeatInterval ?? 30000;
585
+ this.onConnectCallback = config.onConnect;
586
+ this.onDisconnectCallback = config.onDisconnect;
724
587
  }
725
- handleMessage(conn, data) {
588
+ handleConnection(_req) {
589
+ const clientId = `sse_${++this.clientCounter}_${Date.now()}`;
590
+ const encoder = new TextEncoder;
591
+ const stream = new ReadableStream({
592
+ start: (controller) => {
593
+ const heartbeat = setInterval(() => {
594
+ try {
595
+ controller.enqueue(encoder.encode(`: heartbeat ${Date.now()}
596
+
597
+ `));
598
+ } catch {
599
+ this.removeClient(clientId);
600
+ }
601
+ }, this.heartbeatInterval);
602
+ this.clients.set(clientId, { controller, heartbeat, encoder });
603
+ controller.enqueue(encoder.encode(`event: connected
604
+ data: ${JSON.stringify({ clientId })}
605
+
606
+ `));
607
+ const client = {
608
+ id: clientId,
609
+ send: (message) => this.send(clientId, message),
610
+ sendEvent: (event, data) => this.sendEvent(clientId, event, data),
611
+ close: () => this.closeClient(clientId)
612
+ };
613
+ this.onConnectCallback?.(client);
614
+ },
615
+ cancel: () => {
616
+ this.removeClient(clientId);
617
+ }
618
+ });
619
+ return new Response(stream, {
620
+ headers: {
621
+ "Content-Type": "text/event-stream",
622
+ "Cache-Control": "no-cache",
623
+ Connection: "keep-alive",
624
+ "Access-Control-Allow-Origin": "*"
625
+ }
626
+ });
627
+ }
628
+ send(clientId, message) {
629
+ const client = this.clients.get(clientId);
630
+ if (!client)
631
+ return false;
632
+ try {
633
+ const data = `data: ${JSON.stringify(message)}
634
+
635
+ `;
636
+ client.controller.enqueue(client.encoder.encode(data));
637
+ return true;
638
+ } catch {
639
+ this.removeClient(clientId);
640
+ return false;
641
+ }
642
+ }
643
+ sendEvent(clientId, event, data) {
644
+ const client = this.clients.get(clientId);
645
+ if (!client)
646
+ return false;
647
+ try {
648
+ const message = `event: ${event}
649
+ data: ${JSON.stringify(data)}
650
+
651
+ `;
652
+ client.controller.enqueue(client.encoder.encode(message));
653
+ return true;
654
+ } catch {
655
+ this.removeClient(clientId);
656
+ return false;
657
+ }
658
+ }
659
+ broadcast(message) {
660
+ for (const clientId of this.clients.keys()) {
661
+ this.send(clientId, message);
662
+ }
663
+ }
664
+ removeClient(clientId) {
665
+ const client = this.clients.get(clientId);
666
+ if (client) {
667
+ clearInterval(client.heartbeat);
668
+ this.clients.delete(clientId);
669
+ this.onDisconnectCallback?.(clientId);
670
+ }
671
+ }
672
+ closeClient(clientId) {
673
+ const client = this.clients.get(clientId);
674
+ if (client) {
675
+ try {
676
+ client.controller.close();
677
+ } catch {}
678
+ this.removeClient(clientId);
679
+ }
680
+ }
681
+ getClientCount() {
682
+ return this.clients.size;
683
+ }
684
+ getClientIds() {
685
+ return Array.from(this.clients.keys());
686
+ }
687
+ hasClient(clientId) {
688
+ return this.clients.has(clientId);
689
+ }
690
+ closeAll() {
691
+ for (const clientId of this.clients.keys()) {
692
+ this.closeClient(clientId);
693
+ }
694
+ }
695
+ }
696
+ function createSSEHandler(config = {}) {
697
+ return new SSEHandler(config);
698
+ }
699
+
700
+ // src/handlers/http.ts
701
+ function createHTTPHandler(server, options = {}) {
702
+ const { pathPrefix = "", cors } = options;
703
+ const corsHeaders = {
704
+ "Access-Control-Allow-Origin": cors?.origin ? Array.isArray(cors.origin) ? cors.origin.join(", ") : cors.origin : "*",
705
+ "Access-Control-Allow-Methods": cors?.methods?.join(", ") ?? "GET, POST, OPTIONS",
706
+ "Access-Control-Allow-Headers": cors?.headers?.join(", ") ?? "Content-Type, Authorization"
707
+ };
708
+ const handler = async (request) => {
709
+ const url = new URL(request.url);
710
+ const pathname = url.pathname;
711
+ if (request.method === "OPTIONS") {
712
+ return new Response(null, {
713
+ status: 204,
714
+ headers: corsHeaders
715
+ });
716
+ }
717
+ const metadataPath = `${pathPrefix}/__lens/metadata`;
718
+ if (request.method === "GET" && pathname === metadataPath) {
719
+ return new Response(JSON.stringify(server.getMetadata()), {
720
+ headers: {
721
+ "Content-Type": "application/json",
722
+ ...corsHeaders
723
+ }
724
+ });
725
+ }
726
+ const operationPath = pathPrefix || "/";
727
+ if (request.method === "POST" && (pathname === operationPath || pathname === `${pathPrefix}/`)) {
728
+ try {
729
+ const body = await request.json();
730
+ const operationPath2 = body.operation ?? body.path;
731
+ if (!operationPath2) {
732
+ return new Response(JSON.stringify({ error: "Missing operation path" }), {
733
+ status: 400,
734
+ headers: {
735
+ "Content-Type": "application/json",
736
+ ...corsHeaders
737
+ }
738
+ });
739
+ }
740
+ const result2 = await server.execute({
741
+ path: operationPath2,
742
+ input: body.input
743
+ });
744
+ if (result2.error) {
745
+ return new Response(JSON.stringify({ error: result2.error.message }), {
746
+ status: 500,
747
+ headers: {
748
+ "Content-Type": "application/json",
749
+ ...corsHeaders
750
+ }
751
+ });
752
+ }
753
+ return new Response(JSON.stringify({ data: result2.data }), {
754
+ headers: {
755
+ "Content-Type": "application/json",
756
+ ...corsHeaders
757
+ }
758
+ });
759
+ } catch (error) {
760
+ return new Response(JSON.stringify({ error: String(error) }), {
761
+ status: 500,
762
+ headers: {
763
+ "Content-Type": "application/json",
764
+ ...corsHeaders
765
+ }
766
+ });
767
+ }
768
+ }
769
+ return new Response(JSON.stringify({ error: "Not found" }), {
770
+ status: 404,
771
+ headers: {
772
+ "Content-Type": "application/json",
773
+ ...corsHeaders
774
+ }
775
+ });
776
+ };
777
+ const result = handler;
778
+ result.handle = handler;
779
+ return result;
780
+ }
781
+
782
+ // src/handlers/unified.ts
783
+ function createHandler(server, options = {}) {
784
+ const { ssePath = "/__lens/sse", heartbeatInterval, ...httpOptions } = options;
785
+ const pathPrefix = httpOptions.pathPrefix ?? "";
786
+ const fullSsePath = `${pathPrefix}${ssePath}`;
787
+ const httpHandler = createHTTPHandler(server, httpOptions);
788
+ const pluginManager = server.getPluginManager();
789
+ const sseHandler = new SSEHandler({
790
+ ...heartbeatInterval !== undefined && { heartbeatInterval },
791
+ onConnect: (client) => {
792
+ pluginManager.runOnConnect({ clientId: client.id });
793
+ },
794
+ onDisconnect: (clientId) => {
795
+ pluginManager.runOnDisconnect({ clientId, subscriptionCount: 0 });
796
+ }
797
+ });
798
+ const handler = async (request) => {
799
+ const url = new URL(request.url);
800
+ if (request.method === "GET" && url.pathname === fullSsePath) {
801
+ return sseHandler.handleConnection(request);
802
+ }
803
+ return httpHandler(request);
804
+ };
805
+ const result = handler;
806
+ result.handle = handler;
807
+ result.sse = sseHandler;
808
+ return result;
809
+ }
810
+ // src/handlers/framework.ts
811
+ function createServerClientProxy(server) {
812
+ function createProxy(path) {
813
+ return new Proxy(() => {}, {
814
+ get(_, prop) {
815
+ if (typeof prop === "symbol")
816
+ return;
817
+ if (prop === "then")
818
+ return;
819
+ const newPath = path ? `${path}.${prop}` : String(prop);
820
+ return createProxy(newPath);
821
+ },
822
+ async apply(_, __, args) {
823
+ const input = args[0];
824
+ const result = await server.execute({ path, input });
825
+ if (result.error) {
826
+ throw result.error;
827
+ }
828
+ return result.data;
829
+ }
830
+ });
831
+ }
832
+ return createProxy("");
833
+ }
834
+ async function handleWebQuery(server, path, url) {
835
+ try {
836
+ const inputParam = url.searchParams.get("input");
837
+ const input = inputParam ? JSON.parse(inputParam) : undefined;
838
+ const result = await server.execute({ path, input });
839
+ if (result.error) {
840
+ return Response.json({ error: result.error.message }, { status: 400 });
841
+ }
842
+ return Response.json({ data: result.data });
843
+ } catch (error) {
844
+ return Response.json({ error: error instanceof Error ? error.message : "Unknown error" }, { status: 500 });
845
+ }
846
+ }
847
+ async function handleWebMutation(server, path, request) {
848
+ try {
849
+ const body = await request.json();
850
+ const input = body.input;
851
+ const result = await server.execute({ path, input });
852
+ if (result.error) {
853
+ return Response.json({ error: result.error.message }, { status: 400 });
854
+ }
855
+ return Response.json({ data: result.data });
856
+ } catch (error) {
857
+ return Response.json({ error: error instanceof Error ? error.message : "Unknown error" }, { status: 500 });
858
+ }
859
+ }
860
+ function handleWebSSE(server, path, url, signal) {
861
+ const inputParam = url.searchParams.get("input");
862
+ const input = inputParam ? JSON.parse(inputParam) : undefined;
863
+ const stream = new ReadableStream({
864
+ start(controller) {
865
+ const encoder = new TextEncoder;
866
+ const result = server.execute({ path, input });
867
+ if (result && typeof result === "object" && "subscribe" in result) {
868
+ const observable = result;
869
+ const subscription = observable.subscribe({
870
+ next: (value) => {
871
+ const data = `data: ${JSON.stringify(value.data)}
872
+
873
+ `;
874
+ controller.enqueue(encoder.encode(data));
875
+ },
876
+ error: (err) => {
877
+ const data = `event: error
878
+ data: ${JSON.stringify({ error: err.message })}
879
+
880
+ `;
881
+ controller.enqueue(encoder.encode(data));
882
+ controller.close();
883
+ },
884
+ complete: () => {
885
+ controller.close();
886
+ }
887
+ });
888
+ if (signal) {
889
+ signal.addEventListener("abort", () => {
890
+ subscription.unsubscribe();
891
+ controller.close();
892
+ });
893
+ }
894
+ }
895
+ }
896
+ });
897
+ return new Response(stream, {
898
+ headers: {
899
+ "Content-Type": "text/event-stream",
900
+ "Cache-Control": "no-cache",
901
+ Connection: "keep-alive"
902
+ }
903
+ });
904
+ }
905
+ function createFrameworkHandler(server, options = {}) {
906
+ const basePath = options.basePath ?? "";
907
+ return async (request) => {
908
+ const url = new URL(request.url);
909
+ const path = url.pathname.replace(basePath, "").replace(/^\//, "");
910
+ if (request.headers.get("accept") === "text/event-stream") {
911
+ return handleWebSSE(server, path, url, request.signal);
912
+ }
913
+ if (request.method === "GET") {
914
+ return handleWebQuery(server, path, url);
915
+ }
916
+ if (request.method === "POST") {
917
+ return handleWebMutation(server, path, request);
918
+ }
919
+ return new Response("Method not allowed", { status: 405 });
920
+ };
921
+ }
922
+ // src/handlers/ws.ts
923
+ function createWSHandler(server, options = {}) {
924
+ const { logger = {} } = options;
925
+ const connections = new Map;
926
+ const wsToConnection = new WeakMap;
927
+ let connectionCounter = 0;
928
+ async function handleConnection(ws) {
929
+ const clientId = `client_${++connectionCounter}`;
930
+ const conn = {
931
+ id: clientId,
932
+ ws,
933
+ subscriptions: new Map
934
+ };
935
+ connections.set(clientId, conn);
936
+ wsToConnection.set(ws, conn);
937
+ const sendFn = (msg) => {
938
+ ws.send(JSON.stringify(msg));
939
+ };
940
+ const allowed = await server.addClient(clientId, sendFn);
941
+ if (!allowed) {
942
+ ws.close();
943
+ connections.delete(clientId);
944
+ return;
945
+ }
946
+ ws.onmessage = (event) => {
947
+ handleMessage(conn, event.data);
948
+ };
949
+ ws.onclose = () => {
950
+ handleDisconnect(conn);
951
+ };
952
+ }
953
+ function handleMessage(conn, data) {
726
954
  try {
727
955
  const message = JSON.parse(data);
728
956
  switch (message.type) {
729
957
  case "handshake":
730
- this.handleHandshake(conn, message);
958
+ handleHandshake(conn, message);
731
959
  break;
732
960
  case "subscribe":
733
- this.handleSubscribe(conn, message);
961
+ handleSubscribe(conn, message);
734
962
  break;
735
963
  case "updateFields":
736
- this.handleUpdateFields(conn, message);
964
+ handleUpdateFields(conn, message);
737
965
  break;
738
966
  case "unsubscribe":
739
- this.handleUnsubscribe(conn, message);
967
+ handleUnsubscribe(conn, message);
740
968
  break;
741
969
  case "query":
742
- this.handleQuery(conn, message);
970
+ handleQuery(conn, message);
743
971
  break;
744
972
  case "mutation":
745
- this.handleMutation(conn, message);
973
+ handleMutation(conn, message);
974
+ break;
975
+ case "reconnect":
976
+ handleReconnect(conn, message);
746
977
  break;
747
978
  }
748
979
  } catch (error) {
@@ -752,165 +983,125 @@ class LensServerImpl {
752
983
  }));
753
984
  }
754
985
  }
755
- handleHandshake(conn, message) {
986
+ function handleHandshake(conn, message) {
987
+ const metadata = server.getMetadata();
756
988
  conn.ws.send(JSON.stringify({
757
989
  type: "handshake",
758
990
  id: message.id,
759
- version: this.version,
760
- operations: this.buildOperationsMap()
991
+ version: metadata.version,
992
+ operations: metadata.operations
761
993
  }));
762
994
  }
763
- async handleSubscribe(conn, message) {
995
+ async function handleSubscribe(conn, message) {
764
996
  const { id, operation, input, fields } = message;
765
- const sub = {
766
- id,
767
- operation,
768
- input,
769
- fields,
770
- entityKeys: new Set,
771
- cleanups: [],
772
- lastData: null
773
- };
774
- conn.subscriptions.set(id, sub);
997
+ let result;
775
998
  try {
776
- await this.executeSubscription(conn, sub);
999
+ result = await server.execute({ path: operation, input });
1000
+ if (result.error) {
1001
+ conn.ws.send(JSON.stringify({
1002
+ type: "error",
1003
+ id,
1004
+ error: { code: "EXECUTION_ERROR", message: result.error.message }
1005
+ }));
1006
+ return;
1007
+ }
777
1008
  } catch (error) {
778
1009
  conn.ws.send(JSON.stringify({
779
1010
  type: "error",
780
1011
  id,
781
1012
  error: { code: "EXECUTION_ERROR", message: String(error) }
782
1013
  }));
1014
+ return;
783
1015
  }
784
- }
785
- async executeSubscription(conn, sub) {
786
- const queryDef = this.queries[sub.operation];
787
- if (!queryDef) {
788
- throw new Error(`Query not found: ${sub.operation}`);
789
- }
790
- if (queryDef._input && sub.input !== undefined) {
791
- const result = queryDef._input.safeParse(sub.input);
792
- if (!result.success) {
793
- throw new Error(`Invalid input: ${JSON.stringify(result.error)}`);
794
- }
795
- }
796
- const context = await this.contextFactory();
797
- let isFirstUpdate = true;
798
- const emitData = (data) => {
799
- if (!data)
800
- return;
801
- const entityName = this.getEntityNameFromOutput(queryDef._output);
802
- const entities = this.extractEntities(entityName, data);
803
- for (const { entity, id, entityData } of entities) {
804
- const entityKey = `${entity}:${id}`;
805
- sub.entityKeys.add(entityKey);
806
- this.stateManager.subscribe(conn.id, entity, id, sub.fields);
807
- this.stateManager.emit(entity, id, entityData);
808
- }
809
- if (isFirstUpdate) {
810
- conn.ws.send(JSON.stringify({
811
- type: "data",
812
- id: sub.id,
813
- data
814
- }));
815
- isFirstUpdate = false;
816
- sub.lastData = data;
817
- } else {
818
- const updates = this.computeUpdates(sub.lastData, data);
819
- if (updates && Object.keys(updates).length > 0) {
820
- conn.ws.send(JSON.stringify({
821
- type: "update",
822
- id: sub.id,
823
- updates
824
- }));
1016
+ const entities = result.data ? extractEntities(result.data) : [];
1017
+ const existingSub = conn.subscriptions.get(id);
1018
+ if (existingSub) {
1019
+ for (const cleanup of existingSub.cleanups) {
1020
+ try {
1021
+ cleanup();
1022
+ } catch (e) {
1023
+ logger.error?.("Cleanup error:", e);
825
1024
  }
826
- sub.lastData = data;
827
1025
  }
828
- };
829
- await runWithContext(this.ctx, context, async () => {
830
- const resolver = queryDef._resolve;
831
- if (!resolver) {
832
- throw new Error(`Query ${sub.operation} has no resolver`);
833
- }
834
- const emit = createEmit((command) => {
835
- const entityName = this.getEntityNameFromOutput(queryDef._output);
836
- if (entityName) {
837
- const entities = this.extractEntities(entityName, command.type === "full" ? command.data : {});
838
- for (const { entity, id } of entities) {
839
- this.stateManager.processCommand(entity, id, command);
840
- }
841
- }
842
- if (command.type === "full") {
843
- emitData(command.data);
844
- }
1026
+ server.unsubscribe({
1027
+ clientId: conn.id,
1028
+ subscriptionId: id,
1029
+ operation: existingSub.operation,
1030
+ entityKeys: Array.from(existingSub.entityKeys)
845
1031
  });
846
- const onCleanup = (fn) => {
847
- sub.cleanups.push(fn);
848
- return () => {
849
- const idx = sub.cleanups.indexOf(fn);
850
- if (idx >= 0)
851
- sub.cleanups.splice(idx, 1);
852
- };
853
- };
854
- const lensContext = {
855
- ...context,
856
- emit,
857
- onCleanup
858
- };
859
- const result = resolver({
860
- input: sub.input,
861
- ctx: lensContext
1032
+ conn.subscriptions.delete(id);
1033
+ }
1034
+ const sub = {
1035
+ id,
1036
+ operation,
1037
+ input,
1038
+ fields,
1039
+ entityKeys: new Set(entities.map(({ entity, entityId }) => `${entity}:${entityId}`)),
1040
+ cleanups: [],
1041
+ lastData: result.data
1042
+ };
1043
+ for (const { entity, entityId, entityData } of entities) {
1044
+ const allowed = await server.subscribe({
1045
+ clientId: conn.id,
1046
+ subscriptionId: id,
1047
+ operation,
1048
+ input,
1049
+ fields,
1050
+ entity,
1051
+ entityId
862
1052
  });
863
- if (isAsyncIterable(result)) {
864
- for await (const value of result) {
865
- emitData(value);
866
- }
867
- } else {
868
- const value = await result;
869
- emitData(value);
1053
+ if (!allowed) {
1054
+ conn.ws.send(JSON.stringify({
1055
+ type: "error",
1056
+ id,
1057
+ error: { code: "SUBSCRIPTION_REJECTED", message: "Subscription rejected by plugin" }
1058
+ }));
1059
+ return;
870
1060
  }
871
- });
1061
+ await server.send(conn.id, id, entity, entityId, entityData, true);
1062
+ }
1063
+ conn.subscriptions.set(id, sub);
872
1064
  }
873
- handleUpdateFields(conn, message) {
1065
+ async function handleUpdateFields(conn, message) {
874
1066
  const sub = conn.subscriptions.get(message.id);
875
1067
  if (!sub)
876
1068
  return;
1069
+ const previousFields = sub.fields;
1070
+ let newFields;
877
1071
  if (message.addFields?.includes("*")) {
878
- sub.fields = "*";
879
- for (const entityKey of sub.entityKeys) {
880
- const [entity, id] = entityKey.split(":");
881
- this.stateManager.updateSubscription(conn.id, entity, id, "*");
882
- }
883
- return;
884
- }
885
- if (message.setFields !== undefined) {
886
- sub.fields = message.setFields;
887
- for (const entityKey of sub.entityKeys) {
888
- const [entity, id] = entityKey.split(":");
889
- this.stateManager.updateSubscription(conn.id, entity, id, sub.fields);
890
- }
1072
+ newFields = "*";
1073
+ } else if (message.setFields !== undefined) {
1074
+ newFields = message.setFields;
1075
+ } else if (sub.fields === "*") {
891
1076
  return;
892
- }
893
- if (sub.fields === "*") {
894
- return;
895
- }
896
- const fields = new Set(sub.fields);
897
- if (message.addFields) {
898
- for (const field of message.addFields) {
899
- fields.add(field);
1077
+ } else {
1078
+ const fields = new Set(sub.fields);
1079
+ if (message.addFields) {
1080
+ for (const field of message.addFields) {
1081
+ fields.add(field);
1082
+ }
900
1083
  }
901
- }
902
- if (message.removeFields) {
903
- for (const field of message.removeFields) {
904
- fields.delete(field);
1084
+ if (message.removeFields) {
1085
+ for (const field of message.removeFields) {
1086
+ fields.delete(field);
1087
+ }
905
1088
  }
1089
+ newFields = Array.from(fields);
906
1090
  }
907
- sub.fields = Array.from(fields);
1091
+ sub.fields = newFields;
908
1092
  for (const entityKey of sub.entityKeys) {
909
- const [entity, id] = entityKey.split(":");
910
- this.stateManager.updateSubscription(conn.id, entity, id, sub.fields);
1093
+ const [entity, entityId] = entityKey.split(":");
1094
+ await server.updateFields({
1095
+ clientId: conn.id,
1096
+ subscriptionId: sub.id,
1097
+ entity,
1098
+ entityId,
1099
+ fields: newFields,
1100
+ previousFields
1101
+ });
911
1102
  }
912
1103
  }
913
- handleUnsubscribe(conn, message) {
1104
+ function handleUnsubscribe(conn, message) {
914
1105
  const sub = conn.subscriptions.get(message.id);
915
1106
  if (!sub)
916
1107
  return;
@@ -918,23 +1109,32 @@ class LensServerImpl {
918
1109
  try {
919
1110
  cleanup();
920
1111
  } catch (e) {
921
- this.logger.error?.("Cleanup error:", e);
1112
+ logger.error?.("Cleanup error:", e);
922
1113
  }
923
1114
  }
924
- for (const entityKey of sub.entityKeys) {
925
- const [entity, id] = entityKey.split(":");
926
- this.stateManager.unsubscribe(conn.id, entity, id);
927
- }
928
1115
  conn.subscriptions.delete(message.id);
1116
+ server.unsubscribe({
1117
+ clientId: conn.id,
1118
+ subscriptionId: message.id,
1119
+ operation: sub.operation,
1120
+ entityKeys: Array.from(sub.entityKeys)
1121
+ });
929
1122
  }
930
- async handleQuery(conn, message) {
1123
+ async function handleQuery(conn, message) {
931
1124
  try {
932
- let input = message.input;
933
- if (message.select) {
934
- input = { ...message.input || {}, $select: message.select };
1125
+ const result = await server.execute({
1126
+ path: message.operation,
1127
+ input: message.input
1128
+ });
1129
+ if (result.error) {
1130
+ conn.ws.send(JSON.stringify({
1131
+ type: "error",
1132
+ id: message.id,
1133
+ error: { code: "EXECUTION_ERROR", message: result.error.message }
1134
+ }));
1135
+ return;
935
1136
  }
936
- const result = await this.executeQuery(message.operation, input);
937
- const selected = message.fields && !message.select ? this.applySelection(result, message.fields) : result;
1137
+ const selected = message.fields ? applySelection2(result.data, message.fields) : result.data;
938
1138
  conn.ws.send(JSON.stringify({
939
1139
  type: "result",
940
1140
  id: message.id,
@@ -948,18 +1148,30 @@ class LensServerImpl {
948
1148
  }));
949
1149
  }
950
1150
  }
951
- async handleMutation(conn, message) {
1151
+ async function handleMutation(conn, message) {
952
1152
  try {
953
- const result = await this.executeMutation(message.operation, message.input);
954
- const entityName = this.getEntityNameFromMutation(message.operation);
955
- const entities = this.extractEntities(entityName, result);
956
- for (const { entity, id, entityData } of entities) {
957
- this.stateManager.emit(entity, id, entityData);
1153
+ const result = await server.execute({
1154
+ path: message.operation,
1155
+ input: message.input
1156
+ });
1157
+ if (result.error) {
1158
+ conn.ws.send(JSON.stringify({
1159
+ type: "error",
1160
+ id: message.id,
1161
+ error: { code: "EXECUTION_ERROR", message: result.error.message }
1162
+ }));
1163
+ return;
1164
+ }
1165
+ if (result.data) {
1166
+ const entities = extractEntities(result.data);
1167
+ for (const { entity, entityId, entityData } of entities) {
1168
+ await server.broadcast(entity, entityId, entityData);
1169
+ }
958
1170
  }
959
1171
  conn.ws.send(JSON.stringify({
960
1172
  type: "result",
961
1173
  id: message.id,
962
- data: result
1174
+ data: result.data
963
1175
  }));
964
1176
  } catch (error) {
965
1177
  conn.ws.send(JSON.stringify({
@@ -969,250 +1181,131 @@ class LensServerImpl {
969
1181
  }));
970
1182
  }
971
1183
  }
972
- handleDisconnect(conn) {
973
- for (const sub of conn.subscriptions.values()) {
974
- for (const cleanup of sub.cleanups) {
975
- try {
976
- cleanup();
977
- } catch (e) {
978
- this.logger.error?.("Cleanup error:", e);
979
- }
980
- }
981
- }
982
- this.stateManager.removeClient(conn.id);
983
- this.connections.delete(conn.id);
984
- }
985
- async executeQuery(name, input) {
986
- const queryDef = this.queries[name];
987
- if (!queryDef) {
988
- throw new Error(`Query not found: ${name}`);
989
- }
990
- let select;
991
- let cleanInput = input;
992
- if (input && typeof input === "object" && "$select" in input) {
993
- const { $select, ...rest } = input;
994
- select = $select;
995
- cleanInput = Object.keys(rest).length > 0 ? rest : undefined;
996
- }
997
- if (queryDef._input && cleanInput !== undefined) {
998
- const result = queryDef._input.safeParse(cleanInput);
999
- if (!result.success) {
1000
- throw new Error(`Invalid input: ${JSON.stringify(result.error)}`);
1001
- }
1002
- }
1003
- const context = await this.contextFactory();
1004
- try {
1005
- return await runWithContext(this.ctx, context, async () => {
1006
- const resolver = queryDef._resolve;
1007
- if (!resolver) {
1008
- throw new Error(`Query ${name} has no resolver`);
1009
- }
1010
- const emit = createEmit(() => {});
1011
- const onCleanup = () => () => {};
1012
- const lensContext = {
1013
- ...context,
1014
- emit,
1015
- onCleanup
1184
+ async function handleReconnect(conn, message) {
1185
+ const startTime = Date.now();
1186
+ const ctx = {
1187
+ clientId: conn.id,
1188
+ reconnectId: message.reconnectId,
1189
+ subscriptions: message.subscriptions.map((sub) => {
1190
+ const mapped = {
1191
+ id: sub.id,
1192
+ entity: sub.entity,
1193
+ entityId: sub.entityId,
1194
+ fields: sub.fields,
1195
+ version: sub.version
1016
1196
  };
1017
- const result = resolver({
1018
- input: cleanInput,
1019
- ctx: lensContext
1020
- });
1021
- let data;
1022
- if (isAsyncIterable(result)) {
1023
- for await (const value of result) {
1024
- data = value;
1025
- break;
1026
- }
1027
- if (data === undefined) {
1028
- throw new Error(`Query ${name} returned empty stream`);
1029
- }
1030
- } else {
1031
- data = await result;
1197
+ if (sub.dataHash !== undefined) {
1198
+ mapped.dataHash = sub.dataHash;
1032
1199
  }
1033
- return this.processQueryResult(name, data, select);
1034
- });
1035
- } finally {
1036
- this.clearLoaders();
1037
- }
1038
- }
1039
- async executeMutation(name, input) {
1040
- const mutationDef = this.mutations[name];
1041
- if (!mutationDef) {
1042
- throw new Error(`Mutation not found: ${name}`);
1043
- }
1044
- if (mutationDef._input) {
1045
- const result = mutationDef._input.safeParse(input);
1046
- if (!result.success) {
1047
- throw new Error(`Invalid input: ${JSON.stringify(result.error)}`);
1048
- }
1049
- }
1050
- const context = await this.contextFactory();
1051
- try {
1052
- return await runWithContext(this.ctx, context, async () => {
1053
- const resolver = mutationDef._resolve;
1054
- if (!resolver) {
1055
- throw new Error(`Mutation ${name} has no resolver`);
1200
+ if (sub.input !== undefined) {
1201
+ mapped.input = sub.input;
1056
1202
  }
1057
- const emit = createEmit(() => {});
1058
- const onCleanup = () => () => {};
1059
- const lensContext = {
1060
- ...context,
1061
- emit,
1062
- onCleanup
1063
- };
1064
- const result = await resolver({
1065
- input,
1066
- ctx: lensContext
1067
- });
1068
- const entityName = this.getEntityNameFromMutation(name);
1069
- const entities = this.extractEntities(entityName, result);
1070
- for (const { entity, id, entityData } of entities) {
1071
- this.stateManager.emit(entity, id, entityData);
1203
+ return mapped;
1204
+ })
1205
+ };
1206
+ const results = await server.handleReconnect(ctx);
1207
+ if (results === null) {
1208
+ conn.ws.send(JSON.stringify({
1209
+ type: "error",
1210
+ error: {
1211
+ code: "RECONNECT_ERROR",
1212
+ message: "State management not available for reconnection",
1213
+ reconnectId: message.reconnectId
1072
1214
  }
1073
- return result;
1074
- });
1075
- } finally {
1076
- this.clearLoaders();
1077
- }
1078
- }
1079
- async handleRequest(req) {
1080
- const url = new URL(req.url);
1081
- if (req.method === "GET" && url.pathname.endsWith("/__lens/metadata")) {
1082
- return new Response(JSON.stringify(this.getMetadata()), {
1083
- headers: { "Content-Type": "application/json" }
1084
- });
1215
+ }));
1216
+ return;
1085
1217
  }
1086
- if (req.method === "POST") {
1087
- try {
1088
- const body = await req.json();
1089
- if (this.queries[body.operation]) {
1090
- const result = await this.executeQuery(body.operation, body.input);
1091
- return new Response(JSON.stringify({ data: result }), {
1092
- headers: { "Content-Type": "application/json" }
1093
- });
1094
- }
1095
- if (this.mutations[body.operation]) {
1096
- const result = await this.executeMutation(body.operation, body.input);
1097
- return new Response(JSON.stringify({ data: result }), {
1098
- headers: { "Content-Type": "application/json" }
1099
- });
1218
+ try {
1219
+ for (const sub of message.subscriptions) {
1220
+ let clientSub = conn.subscriptions.get(sub.id);
1221
+ if (!clientSub) {
1222
+ clientSub = {
1223
+ id: sub.id,
1224
+ operation: "",
1225
+ input: sub.input,
1226
+ fields: sub.fields,
1227
+ entityKeys: new Set([`${sub.entity}:${sub.entityId}`]),
1228
+ cleanups: [],
1229
+ lastData: null
1230
+ };
1231
+ conn.subscriptions.set(sub.id, clientSub);
1100
1232
  }
1101
- return new Response(JSON.stringify({ error: `Operation not found: ${body.operation}` }), {
1102
- status: 404,
1103
- headers: { "Content-Type": "application/json" }
1104
- });
1105
- } catch (error) {
1106
- return new Response(JSON.stringify({ error: String(error) }), {
1107
- status: 500,
1108
- headers: { "Content-Type": "application/json" }
1109
- });
1110
1233
  }
1111
- }
1112
- return new Response("Method not allowed", { status: 405 });
1113
- }
1114
- async listen(port) {
1115
- this.server = Bun.serve({
1116
- port,
1117
- fetch: (req, server) => {
1118
- if (server.upgrade(req)) {
1119
- return;
1120
- }
1121
- return this.handleRequest(req);
1122
- },
1123
- websocket: {
1124
- message: (ws, message) => {
1125
- const conn = this.findConnectionByWs(ws);
1126
- if (conn) {
1127
- this.handleMessage(conn, String(message));
1128
- }
1129
- },
1130
- close: (ws) => {
1131
- const conn = this.findConnectionByWs(ws);
1132
- if (conn) {
1133
- this.handleDisconnect(conn);
1134
- }
1234
+ conn.ws.send(JSON.stringify({
1235
+ type: "reconnect_ack",
1236
+ results,
1237
+ serverTime: Date.now(),
1238
+ reconnectId: message.reconnectId,
1239
+ processingTime: Date.now() - startTime
1240
+ }));
1241
+ } catch (error) {
1242
+ conn.ws.send(JSON.stringify({
1243
+ type: "error",
1244
+ error: {
1245
+ code: "RECONNECT_ERROR",
1246
+ message: String(error),
1247
+ reconnectId: message.reconnectId
1135
1248
  }
1136
- }
1137
- });
1138
- this.logger.info?.(`Lens server listening on port ${port}`);
1139
- }
1140
- async close() {
1141
- if (this.server && typeof this.server.stop === "function") {
1142
- this.server.stop();
1143
- }
1144
- this.server = null;
1145
- }
1146
- findConnectionByWs(ws) {
1147
- for (const conn of this.connections.values()) {
1148
- if (conn.ws === ws) {
1149
- return conn;
1150
- }
1249
+ }));
1151
1250
  }
1152
- return;
1153
1251
  }
1154
- getEntityNameFromOutput(output) {
1155
- if (!output)
1156
- return "unknown";
1157
- if (typeof output === "object" && output !== null) {
1158
- if ("_name" in output) {
1159
- return output._name;
1160
- }
1161
- if ("name" in output) {
1162
- return output.name;
1163
- }
1164
- }
1165
- if (Array.isArray(output) && output.length > 0) {
1166
- const first = output[0];
1167
- if (typeof first === "object" && first !== null) {
1168
- if ("_name" in first) {
1169
- return first._name;
1170
- }
1171
- if ("name" in first) {
1172
- return first.name;
1252
+ function handleDisconnect(conn) {
1253
+ const subscriptionCount = conn.subscriptions.size;
1254
+ for (const sub of conn.subscriptions.values()) {
1255
+ for (const cleanup of sub.cleanups) {
1256
+ try {
1257
+ cleanup();
1258
+ } catch (e) {
1259
+ logger.error?.("Cleanup error:", e);
1173
1260
  }
1174
1261
  }
1175
1262
  }
1176
- return "unknown";
1177
- }
1178
- getEntityNameFromMutation(name) {
1179
- const mutationDef = this.mutations[name];
1180
- if (!mutationDef)
1181
- return "unknown";
1182
- return this.getEntityNameFromOutput(mutationDef._output);
1263
+ connections.delete(conn.id);
1264
+ server.removeClient(conn.id, subscriptionCount);
1183
1265
  }
1184
- extractEntities(entityName, data) {
1266
+ function extractEntities(data) {
1185
1267
  const results = [];
1186
1268
  if (!data)
1187
1269
  return results;
1188
1270
  if (Array.isArray(data)) {
1189
1271
  for (const item of data) {
1190
1272
  if (item && typeof item === "object" && "id" in item) {
1273
+ const entityName = getEntityName(item);
1191
1274
  results.push({
1192
1275
  entity: entityName,
1193
- id: String(item.id),
1276
+ entityId: String(item.id),
1194
1277
  entityData: item
1195
1278
  });
1196
1279
  }
1197
1280
  }
1198
1281
  } else if (typeof data === "object" && "id" in data) {
1282
+ const entityName = getEntityName(data);
1199
1283
  results.push({
1200
1284
  entity: entityName,
1201
- id: String(data.id),
1285
+ entityId: String(data.id),
1202
1286
  entityData: data
1203
1287
  });
1204
1288
  }
1205
1289
  return results;
1206
1290
  }
1207
- applySelection(data, fields) {
1291
+ function getEntityName(data) {
1292
+ if (!data || typeof data !== "object")
1293
+ return "unknown";
1294
+ if ("__typename" in data)
1295
+ return String(data.__typename);
1296
+ if ("_type" in data)
1297
+ return String(data._type);
1298
+ return "unknown";
1299
+ }
1300
+ function applySelection2(data, fields) {
1208
1301
  if (fields === "*" || !data)
1209
1302
  return data;
1210
1303
  if (Array.isArray(data)) {
1211
- return data.map((item) => this.applySelectionToObject(item, fields));
1304
+ return data.map((item) => applySelectionToObject(item, fields));
1212
1305
  }
1213
- return this.applySelectionToObject(data, fields);
1306
+ return applySelectionToObject(data, fields);
1214
1307
  }
1215
- applySelectionToObject(data, fields) {
1308
+ function applySelectionToObject(data, fields) {
1216
1309
  if (!data || typeof data !== "object")
1217
1310
  return null;
1218
1311
  const result = {};
@@ -1220,299 +1313,748 @@ class LensServerImpl {
1220
1313
  if ("id" in obj) {
1221
1314
  result.id = obj.id;
1222
1315
  }
1223
- if (Array.isArray(fields)) {
1224
- for (const field of fields) {
1225
- if (field in obj) {
1226
- result[field] = obj[field];
1227
- }
1228
- }
1229
- return result;
1230
- }
1231
- for (const [key, value] of Object.entries(fields)) {
1232
- if (value === false)
1233
- continue;
1234
- const dataValue = obj[key];
1235
- if (value === true) {
1236
- result[key] = dataValue;
1237
- } else if (typeof value === "object" && value !== null) {
1238
- const nestedSelect = value.select ?? value;
1239
- if (Array.isArray(dataValue)) {
1240
- result[key] = dataValue.map((item) => this.applySelectionToObject(item, nestedSelect));
1241
- } else if (dataValue !== null && typeof dataValue === "object") {
1242
- result[key] = this.applySelectionToObject(dataValue, nestedSelect);
1243
- } else {
1244
- result[key] = dataValue;
1245
- }
1316
+ for (const field of fields) {
1317
+ if (field in obj) {
1318
+ result[field] = obj[field];
1246
1319
  }
1247
1320
  }
1248
1321
  return result;
1249
1322
  }
1250
- async executeEntityResolvers(entityName, data, select) {
1251
- if (!data || !select || !this.resolverMap)
1252
- return data;
1253
- const resolverDef = this.resolverMap.get(entityName);
1254
- if (!resolverDef)
1255
- return data;
1256
- const result = { ...data };
1257
- const context = await this.contextFactory();
1258
- for (const [fieldName, fieldSelect] of Object.entries(select)) {
1259
- if (fieldSelect === false || fieldSelect === true)
1260
- continue;
1261
- if (!resolverDef.hasField(fieldName))
1262
- continue;
1263
- const fieldArgs = typeof fieldSelect === "object" && fieldSelect !== null && "args" in fieldSelect ? fieldSelect.args ?? {} : {};
1264
- result[fieldName] = await resolverDef.resolveField(fieldName, data, fieldArgs, context);
1265
- const nestedSelect = fieldSelect.select;
1266
- if (nestedSelect && result[fieldName]) {
1267
- const relationData = result[fieldName];
1268
- const targetEntity = this.getRelationTargetEntity(entityName, fieldName);
1269
- if (Array.isArray(relationData)) {
1270
- result[fieldName] = await Promise.all(relationData.map((item) => this.executeEntityResolvers(targetEntity, item, nestedSelect)));
1271
- } else {
1272
- result[fieldName] = await this.executeEntityResolvers(targetEntity, relationData, nestedSelect);
1323
+ const handler = {
1324
+ handleConnection,
1325
+ handler: {
1326
+ open(ws) {
1327
+ if (ws && typeof ws === "object" && "send" in ws) {
1328
+ handleConnection(ws);
1329
+ }
1330
+ },
1331
+ message(ws, message) {
1332
+ const conn = wsToConnection.get(ws);
1333
+ if (conn) {
1334
+ handleMessage(conn, String(message));
1335
+ } else if (ws && typeof ws === "object" && "send" in ws) {
1336
+ handleConnection(ws);
1337
+ const newConn = wsToConnection.get(ws);
1338
+ if (newConn) {
1339
+ handleMessage(newConn, String(message));
1340
+ }
1341
+ }
1342
+ },
1343
+ close(ws) {
1344
+ const conn = wsToConnection.get(ws);
1345
+ if (conn) {
1346
+ handleDisconnect(conn);
1273
1347
  }
1274
1348
  }
1349
+ },
1350
+ async close() {
1351
+ for (const conn of connections.values()) {
1352
+ handleDisconnect(conn);
1353
+ conn.ws.close();
1354
+ }
1355
+ connections.clear();
1356
+ }
1357
+ };
1358
+ return handler;
1359
+ }
1360
+ // src/storage/types.ts
1361
+ var DEFAULT_STORAGE_CONFIG = {
1362
+ maxPatchesPerEntity: 1000,
1363
+ maxPatchAge: 5 * 60 * 1000,
1364
+ cleanupInterval: 60 * 1000,
1365
+ maxRetries: 3
1366
+ };
1367
+
1368
+ // src/storage/memory.ts
1369
+ function makeKey(entity, entityId) {
1370
+ return `${entity}:${entityId}`;
1371
+ }
1372
+ function computePatch(oldState, newState) {
1373
+ const patch = [];
1374
+ const oldKeys = new Set(Object.keys(oldState));
1375
+ const newKeys = new Set(Object.keys(newState));
1376
+ for (const key of newKeys) {
1377
+ const oldValue = oldState[key];
1378
+ const newValue = newState[key];
1379
+ if (!oldKeys.has(key)) {
1380
+ patch.push({ op: "add", path: `/${key}`, value: newValue });
1381
+ } else if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
1382
+ patch.push({ op: "replace", path: `/${key}`, value: newValue });
1275
1383
  }
1276
- return result;
1277
1384
  }
1278
- getRelationTargetEntity(entityName, fieldName) {
1279
- const entityDef = this.entities[entityName];
1280
- if (!entityDef)
1281
- return fieldName;
1282
- const fields = entityDef.fields;
1283
- if (!fields)
1284
- return fieldName;
1285
- const fieldDef = fields[fieldName];
1286
- if (!fieldDef)
1287
- return fieldName;
1288
- if (fieldDef._type === "hasMany" || fieldDef._type === "hasOne" || fieldDef._type === "belongsTo") {
1289
- return fieldDef._target ?? fieldName;
1290
- }
1291
- return fieldName;
1292
- }
1293
- serializeEntity(entityName, data) {
1294
- if (data === null)
1295
- return null;
1296
- const entityDef = this.entities[entityName];
1297
- if (!entityDef)
1298
- return data;
1299
- const fields = entityDef.fields;
1300
- if (!fields)
1301
- return data;
1302
- const result = {};
1303
- for (const [fieldName, value] of Object.entries(data)) {
1304
- const fieldType = fields[fieldName];
1305
- if (!fieldType) {
1306
- result[fieldName] = value;
1307
- continue;
1385
+ for (const key of oldKeys) {
1386
+ if (!newKeys.has(key)) {
1387
+ patch.push({ op: "remove", path: `/${key}` });
1388
+ }
1389
+ }
1390
+ return patch;
1391
+ }
1392
+ function hashState(state) {
1393
+ return JSON.stringify(state);
1394
+ }
1395
+ function memoryStorage(config = {}) {
1396
+ const cfg = { ...DEFAULT_STORAGE_CONFIG, ...config };
1397
+ const entities = new Map;
1398
+ let cleanupTimer = null;
1399
+ if (cfg.cleanupInterval > 0) {
1400
+ cleanupTimer = setInterval(() => cleanup(), cfg.cleanupInterval);
1401
+ }
1402
+ function cleanup() {
1403
+ const now = Date.now();
1404
+ const minTimestamp = now - cfg.maxPatchAge;
1405
+ for (const state of entities.values()) {
1406
+ state.patches = state.patches.filter((p) => p.timestamp >= minTimestamp);
1407
+ }
1408
+ }
1409
+ function trimPatches(state) {
1410
+ if (state.patches.length > cfg.maxPatchesPerEntity) {
1411
+ state.patches = state.patches.slice(-cfg.maxPatchesPerEntity);
1412
+ }
1413
+ }
1414
+ return {
1415
+ async emit(entity, entityId, data) {
1416
+ const key = makeKey(entity, entityId);
1417
+ const existing = entities.get(key);
1418
+ const now = Date.now();
1419
+ if (!existing) {
1420
+ const newState = {
1421
+ data: { ...data },
1422
+ version: 1,
1423
+ patches: []
1424
+ };
1425
+ entities.set(key, newState);
1426
+ return {
1427
+ version: 1,
1428
+ patch: null,
1429
+ changed: true
1430
+ };
1308
1431
  }
1309
- if (value === null || value === undefined) {
1310
- result[fieldName] = value;
1311
- continue;
1432
+ const oldHash = hashState(existing.data);
1433
+ const newHash = hashState(data);
1434
+ if (oldHash === newHash) {
1435
+ return {
1436
+ version: existing.version,
1437
+ patch: null,
1438
+ changed: false
1439
+ };
1312
1440
  }
1313
- if (fieldType._type === "hasMany" || fieldType._type === "belongsTo" || fieldType._type === "hasOne") {
1314
- const targetEntity = fieldType._target;
1315
- if (targetEntity && Array.isArray(value)) {
1316
- result[fieldName] = value.map((item) => this.serializeEntity(targetEntity, item));
1317
- } else if (targetEntity && typeof value === "object") {
1318
- result[fieldName] = this.serializeEntity(targetEntity, value);
1319
- } else {
1320
- result[fieldName] = value;
1441
+ const patch = computePatch(existing.data, data);
1442
+ const newVersion = existing.version + 1;
1443
+ existing.data = { ...data };
1444
+ existing.version = newVersion;
1445
+ if (patch.length > 0) {
1446
+ existing.patches.push({
1447
+ version: newVersion,
1448
+ patch,
1449
+ timestamp: now
1450
+ });
1451
+ trimPatches(existing);
1452
+ }
1453
+ return {
1454
+ version: newVersion,
1455
+ patch: patch.length > 0 ? patch : null,
1456
+ changed: true
1457
+ };
1458
+ },
1459
+ async getState(entity, entityId) {
1460
+ const key = makeKey(entity, entityId);
1461
+ const state = entities.get(key);
1462
+ return state ? { ...state.data } : null;
1463
+ },
1464
+ async getVersion(entity, entityId) {
1465
+ const key = makeKey(entity, entityId);
1466
+ const state = entities.get(key);
1467
+ return state?.version ?? 0;
1468
+ },
1469
+ async getLatestPatch(entity, entityId) {
1470
+ const key = makeKey(entity, entityId);
1471
+ const state = entities.get(key);
1472
+ if (!state || state.patches.length === 0) {
1473
+ return null;
1474
+ }
1475
+ return state.patches[state.patches.length - 1].patch;
1476
+ },
1477
+ async getPatchesSince(entity, entityId, sinceVersion) {
1478
+ const key = makeKey(entity, entityId);
1479
+ const state = entities.get(key);
1480
+ if (!state) {
1481
+ return sinceVersion === 0 ? [] : null;
1482
+ }
1483
+ if (sinceVersion >= state.version) {
1484
+ return [];
1485
+ }
1486
+ const relevantPatches = state.patches.filter((p) => p.version > sinceVersion);
1487
+ if (relevantPatches.length === 0) {
1488
+ return null;
1489
+ }
1490
+ relevantPatches.sort((a, b) => a.version - b.version);
1491
+ if (relevantPatches[0].version !== sinceVersion + 1) {
1492
+ return null;
1493
+ }
1494
+ for (let i = 1;i < relevantPatches.length; i++) {
1495
+ if (relevantPatches[i].version !== relevantPatches[i - 1].version + 1) {
1496
+ return null;
1321
1497
  }
1322
- continue;
1323
1498
  }
1324
- if (typeof fieldType.serialize === "function") {
1325
- try {
1326
- result[fieldName] = fieldType.serialize(value);
1327
- } catch (error) {
1328
- this.logger.warn?.(`Failed to serialize field ${entityName}.${fieldName}:`, error);
1329
- result[fieldName] = value;
1499
+ return relevantPatches.map((p) => p.patch);
1500
+ },
1501
+ async has(entity, entityId) {
1502
+ const key = makeKey(entity, entityId);
1503
+ return entities.has(key);
1504
+ },
1505
+ async delete(entity, entityId) {
1506
+ const key = makeKey(entity, entityId);
1507
+ entities.delete(key);
1508
+ },
1509
+ async clear() {
1510
+ entities.clear();
1511
+ },
1512
+ async dispose() {
1513
+ if (cleanupTimer) {
1514
+ clearInterval(cleanupTimer);
1515
+ cleanupTimer = null;
1516
+ }
1517
+ entities.clear();
1518
+ }
1519
+ };
1520
+ }
1521
+ // src/plugin/op-log.ts
1522
+ function opLog(options = {}) {
1523
+ const storage = options.storage ?? memoryStorage(options);
1524
+ const debug = options.debug ?? false;
1525
+ const log = (...args) => {
1526
+ if (debug) {
1527
+ console.log("[opLog]", ...args);
1528
+ }
1529
+ };
1530
+ return {
1531
+ name: "opLog",
1532
+ getStorage() {
1533
+ return storage;
1534
+ },
1535
+ async getVersion(entity, entityId) {
1536
+ return storage.getVersion(entity, entityId);
1537
+ },
1538
+ async getState(entity, entityId) {
1539
+ return storage.getState(entity, entityId);
1540
+ },
1541
+ async getLatestPatch(entity, entityId) {
1542
+ return storage.getLatestPatch(entity, entityId);
1543
+ },
1544
+ async onBroadcast(ctx) {
1545
+ const { entity, entityId, data } = ctx;
1546
+ log("onBroadcast:", entity, entityId);
1547
+ const result = await storage.emit(entity, entityId, data);
1548
+ log(" Version:", result.version, "Patch ops:", result.patch?.length ?? 0);
1549
+ return {
1550
+ version: result.version,
1551
+ patch: result.patch,
1552
+ data
1553
+ };
1554
+ },
1555
+ async onReconnect(ctx) {
1556
+ log("Reconnect:", ctx.clientId, "subscriptions:", ctx.subscriptions.length);
1557
+ const results = [];
1558
+ for (const sub of ctx.subscriptions) {
1559
+ const currentVersion = await storage.getVersion(sub.entity, sub.entityId);
1560
+ const currentState = await storage.getState(sub.entity, sub.entityId);
1561
+ if (currentState === null) {
1562
+ results.push({
1563
+ id: sub.id,
1564
+ entity: sub.entity,
1565
+ entityId: sub.entityId,
1566
+ status: "deleted",
1567
+ version: 0
1568
+ });
1569
+ log(" Subscription", sub.id, `${sub.entity}:${sub.entityId}`, "status: deleted");
1570
+ continue;
1330
1571
  }
1331
- } else {
1332
- result[fieldName] = value;
1572
+ if (sub.version >= currentVersion) {
1573
+ results.push({
1574
+ id: sub.id,
1575
+ entity: sub.entity,
1576
+ entityId: sub.entityId,
1577
+ status: "current",
1578
+ version: currentVersion
1579
+ });
1580
+ log(" Subscription", sub.id, `${sub.entity}:${sub.entityId}`, "status: current", "version:", currentVersion);
1581
+ continue;
1582
+ }
1583
+ const patches = await storage.getPatchesSince(sub.entity, sub.entityId, sub.version);
1584
+ if (patches !== null && patches.length > 0) {
1585
+ results.push({
1586
+ id: sub.id,
1587
+ entity: sub.entity,
1588
+ entityId: sub.entityId,
1589
+ status: "patched",
1590
+ version: currentVersion,
1591
+ patches
1592
+ });
1593
+ log(" Subscription", sub.id, `${sub.entity}:${sub.entityId}`, "status: patched", "version:", currentVersion, "patches:", patches.length);
1594
+ continue;
1595
+ }
1596
+ results.push({
1597
+ id: sub.id,
1598
+ entity: sub.entity,
1599
+ entityId: sub.entityId,
1600
+ status: "snapshot",
1601
+ version: currentVersion,
1602
+ data: currentState
1603
+ });
1604
+ log(" Subscription", sub.id, `${sub.entity}:${sub.entityId}`, "status: snapshot", "version:", currentVersion);
1333
1605
  }
1606
+ return results;
1334
1607
  }
1335
- return result;
1608
+ };
1609
+ }
1610
+ function isOpLogPlugin(plugin) {
1611
+ return plugin.name === "opLog" && "getStorage" in plugin;
1612
+ }
1613
+ // src/plugin/optimistic.ts
1614
+ import {
1615
+ isPipeline,
1616
+ OPTIMISTIC_PLUGIN_SYMBOL
1617
+ } from "@sylphx/lens-core";
1618
+ import { isOptimisticPlugin } from "@sylphx/lens-core";
1619
+ function getEntityTypeName(returnSpec) {
1620
+ if (!returnSpec)
1621
+ return;
1622
+ if (typeof returnSpec !== "object")
1623
+ return;
1624
+ const spec = returnSpec;
1625
+ if ("_name" in spec && typeof spec._name === "string") {
1626
+ return spec._name;
1336
1627
  }
1337
- async processQueryResult(queryName, data, select) {
1338
- if (data === null || data === undefined)
1339
- return data;
1340
- const queryDef = this.queries[queryName];
1341
- const entityName = this.getEntityNameFromOutput(queryDef?._output);
1342
- if (Array.isArray(data)) {
1343
- const processedItems = await Promise.all(data.map(async (item) => {
1344
- let result2 = item;
1345
- if (select && this.resolverMap) {
1346
- result2 = await this.executeEntityResolvers(entityName, item, select);
1628
+ if ("~entity" in spec) {
1629
+ const entity = spec["~entity"];
1630
+ if (entity?.name)
1631
+ return entity.name;
1632
+ }
1633
+ if ("_tag" in spec) {
1634
+ if (spec._tag === "entity" && spec.entityDef) {
1635
+ const entityDef = spec.entityDef;
1636
+ if (entityDef._name)
1637
+ return entityDef._name;
1638
+ }
1639
+ if (spec._tag === "array" && spec.element) {
1640
+ return getEntityTypeName(spec.element);
1641
+ }
1642
+ }
1643
+ return;
1644
+ }
1645
+ function getInputFields(schema) {
1646
+ if (!schema?.shape)
1647
+ return [];
1648
+ return Object.keys(schema.shape);
1649
+ }
1650
+ function $input(field) {
1651
+ return { $input: field };
1652
+ }
1653
+ function sugarToPipeline(sugar, entityType, inputFields) {
1654
+ if (!sugar)
1655
+ return;
1656
+ if (isPipeline(sugar))
1657
+ return sugar;
1658
+ const entity = entityType ?? "Entity";
1659
+ switch (sugar) {
1660
+ case "merge": {
1661
+ const updateData = {
1662
+ type: entity,
1663
+ id: $input("id")
1664
+ };
1665
+ for (const field of inputFields) {
1666
+ if (field !== "id") {
1667
+ updateData[field] = $input(field);
1668
+ }
1669
+ }
1670
+ const pipeline = {
1671
+ $pipe: [
1672
+ {
1673
+ $do: "entity.update",
1674
+ $with: updateData,
1675
+ $as: "result"
1676
+ }
1677
+ ]
1678
+ };
1679
+ return pipeline;
1680
+ }
1681
+ case "create": {
1682
+ const pipeline = {
1683
+ $pipe: [
1684
+ {
1685
+ $do: "entity.create",
1686
+ $with: {
1687
+ type: entity,
1688
+ id: { $temp: true },
1689
+ $fromOutput: true
1690
+ },
1691
+ $as: "result"
1692
+ }
1693
+ ]
1694
+ };
1695
+ return pipeline;
1696
+ }
1697
+ case "delete": {
1698
+ const pipeline = {
1699
+ $pipe: [
1700
+ {
1701
+ $do: "entity.delete",
1702
+ $with: {
1703
+ type: entity,
1704
+ id: { id: $input("id") }
1705
+ },
1706
+ $as: "result"
1707
+ }
1708
+ ]
1709
+ };
1710
+ return pipeline;
1711
+ }
1712
+ default:
1713
+ if (typeof sugar === "object" && "merge" in sugar) {
1714
+ const updateData = {
1715
+ type: entity,
1716
+ id: $input("id")
1717
+ };
1718
+ for (const field of inputFields) {
1719
+ if (field !== "id") {
1720
+ updateData[field] = $input(field);
1721
+ }
1347
1722
  }
1348
- if (select) {
1349
- result2 = this.applySelection(result2, select);
1723
+ for (const [key, value] of Object.entries(sugar.merge)) {
1724
+ updateData[key] = value;
1350
1725
  }
1351
- if (entityName) {
1352
- return this.serializeEntity(entityName, result2);
1726
+ const pipeline = {
1727
+ $pipe: [
1728
+ {
1729
+ $do: "entity.update",
1730
+ $with: updateData,
1731
+ $as: "result"
1732
+ }
1733
+ ]
1734
+ };
1735
+ return pipeline;
1736
+ }
1737
+ return;
1738
+ }
1739
+ }
1740
+ function isOptimisticDSL(value) {
1741
+ if (value === "merge" || value === "create" || value === "delete")
1742
+ return true;
1743
+ if (isPipeline(value))
1744
+ return true;
1745
+ if (typeof value === "object" && value !== null && "merge" in value)
1746
+ return true;
1747
+ return false;
1748
+ }
1749
+ function optimisticPlugin(options = {}) {
1750
+ const { autoDerive = true, debug = false } = options;
1751
+ const log = (...args) => {
1752
+ if (debug) {
1753
+ console.log("[optimisticPlugin]", ...args);
1754
+ }
1755
+ };
1756
+ return {
1757
+ name: "optimistic",
1758
+ [OPTIMISTIC_PLUGIN_SYMBOL]: true,
1759
+ enhanceOperationMeta(ctx) {
1760
+ if (ctx.type !== "mutation")
1761
+ return;
1762
+ const def = ctx.definition;
1763
+ let optimisticSpec = def._optimistic;
1764
+ if (!optimisticSpec && autoDerive) {
1765
+ const lastSegment = ctx.path.includes(".") ? ctx.path.split(".").pop() : ctx.path;
1766
+ if (lastSegment.startsWith("update")) {
1767
+ optimisticSpec = "merge";
1768
+ } else if (lastSegment.startsWith("create") || lastSegment.startsWith("add")) {
1769
+ optimisticSpec = "create";
1770
+ } else if (lastSegment.startsWith("delete") || lastSegment.startsWith("remove")) {
1771
+ optimisticSpec = "delete";
1353
1772
  }
1354
- return result2;
1355
- }));
1356
- return processedItems;
1357
- }
1358
- let result = data;
1359
- if (select && this.resolverMap) {
1360
- result = await this.executeEntityResolvers(entityName, data, select);
1773
+ log(`Auto-derived optimistic for ${ctx.path}:`, optimisticSpec);
1774
+ }
1775
+ if (optimisticSpec && isOptimisticDSL(optimisticSpec)) {
1776
+ const entityType = getEntityTypeName(def._output);
1777
+ const inputFields = getInputFields(def._input);
1778
+ const pipeline = sugarToPipeline(optimisticSpec, entityType, inputFields);
1779
+ if (pipeline) {
1780
+ ctx.meta.optimistic = pipeline;
1781
+ log(`Added optimistic config for ${ctx.path}:`, pipeline);
1782
+ }
1783
+ }
1361
1784
  }
1362
- if (select) {
1363
- result = this.applySelection(result, select);
1785
+ };
1786
+ }
1787
+ // src/reconnect/operation-log.ts
1788
+ import { DEFAULT_OPERATION_LOG_CONFIG } from "@sylphx/lens-core";
1789
+
1790
+ class OperationLog {
1791
+ entries = [];
1792
+ config;
1793
+ totalMemory = 0;
1794
+ entityIndex = new Map;
1795
+ oldestVersionIndex = new Map;
1796
+ newestVersionIndex = new Map;
1797
+ cleanupTimer = null;
1798
+ constructor(config = {}) {
1799
+ this.config = { ...DEFAULT_OPERATION_LOG_CONFIG, ...config };
1800
+ if (this.config.cleanupInterval > 0) {
1801
+ this.cleanupTimer = setInterval(() => this.cleanup(), this.config.cleanupInterval);
1802
+ }
1803
+ }
1804
+ append(entry) {
1805
+ const index = this.entries.length;
1806
+ this.entries.push(entry);
1807
+ this.totalMemory += entry.patchSize;
1808
+ let indices = this.entityIndex.get(entry.entityKey);
1809
+ if (!indices) {
1810
+ indices = [];
1811
+ this.entityIndex.set(entry.entityKey, indices);
1812
+ this.oldestVersionIndex.set(entry.entityKey, entry.version);
1813
+ this.newestVersionIndex.set(entry.entityKey, entry.version);
1814
+ } else {
1815
+ const currentOldest = this.oldestVersionIndex.get(entry.entityKey);
1816
+ const currentNewest = this.newestVersionIndex.get(entry.entityKey);
1817
+ if (entry.version < currentOldest) {
1818
+ this.oldestVersionIndex.set(entry.entityKey, entry.version);
1819
+ }
1820
+ if (entry.version > currentNewest) {
1821
+ this.newestVersionIndex.set(entry.entityKey, entry.version);
1822
+ }
1364
1823
  }
1365
- if (entityName && typeof result === "object" && result !== null) {
1366
- return this.serializeEntity(entityName, result);
1824
+ indices.push(index);
1825
+ this.checkLimits();
1826
+ }
1827
+ appendBatch(entries) {
1828
+ for (const entry of entries) {
1829
+ this.append(entry);
1367
1830
  }
1368
- return result;
1369
1831
  }
1370
- computeUpdates(oldData, newData) {
1371
- if (!oldData || !newData)
1372
- return null;
1373
- if (typeof oldData !== "object" || typeof newData !== "object")
1832
+ getSince(entityKey, fromVersion) {
1833
+ const oldestVersion = this.oldestVersionIndex.get(entityKey);
1834
+ const newestVersion = this.newestVersionIndex.get(entityKey);
1835
+ if (oldestVersion === undefined || newestVersion === undefined) {
1836
+ return fromVersion === 0 ? [] : null;
1837
+ }
1838
+ if (fromVersion >= newestVersion) {
1839
+ return [];
1840
+ }
1841
+ if (fromVersion < oldestVersion - 1) {
1374
1842
  return null;
1375
- const updates = {};
1376
- const oldObj = oldData;
1377
- const newObj = newData;
1378
- for (const key of Object.keys(newObj)) {
1379
- const oldValue = oldObj[key];
1380
- const newValue = newObj[key];
1381
- if (!this.deepEqual(oldValue, newValue)) {
1382
- updates[key] = createUpdate2(oldValue, newValue);
1843
+ }
1844
+ const indices = this.entityIndex.get(entityKey) ?? [];
1845
+ const result = [];
1846
+ for (const idx of indices) {
1847
+ const entry = this.entries[idx];
1848
+ if (entry && entry.version > fromVersion) {
1849
+ result.push(entry);
1850
+ }
1851
+ }
1852
+ result.sort((a, b) => a.version - b.version);
1853
+ if (result.length > 0) {
1854
+ if (result[0].version !== fromVersion + 1) {
1855
+ return null;
1856
+ }
1857
+ for (let i = 1;i < result.length; i++) {
1858
+ if (result[i].version !== result[i - 1].version + 1) {
1859
+ return null;
1860
+ }
1383
1861
  }
1384
1862
  }
1385
- return Object.keys(updates).length > 0 ? updates : null;
1863
+ return result;
1386
1864
  }
1387
- deepEqual(a, b) {
1388
- if (a === b)
1389
- return true;
1390
- if (typeof a !== typeof b)
1391
- return false;
1392
- if (typeof a !== "object" || a === null || b === null)
1865
+ hasVersion(entityKey, version) {
1866
+ const oldest = this.oldestVersionIndex.get(entityKey);
1867
+ const newest = this.newestVersionIndex.get(entityKey);
1868
+ if (oldest === undefined || newest === undefined) {
1393
1869
  return false;
1394
- const aObj = a;
1395
- const bObj = b;
1396
- const aKeys = Object.keys(aObj);
1397
- const bKeys = Object.keys(bObj);
1398
- if (aKeys.length !== bKeys.length)
1399
- return false;
1400
- for (const key of aKeys) {
1401
- if (!this.deepEqual(aObj[key], bObj[key]))
1402
- return false;
1403
1870
  }
1404
- return true;
1871
+ return version >= oldest && version <= newest;
1405
1872
  }
1406
- clearLoaders() {
1407
- for (const loader of this.loaders.values()) {
1408
- loader.clear();
1409
- }
1410
- this.loaders.clear();
1873
+ getOldestVersion(entityKey) {
1874
+ return this.oldestVersionIndex.get(entityKey) ?? null;
1411
1875
  }
1412
- }
1413
- function isAsyncIterable(value) {
1414
- return value !== null && typeof value === "object" && Symbol.asyncIterator in value;
1415
- }
1416
- function createServer(config) {
1417
- const server = new LensServerImpl(config);
1418
- return server;
1419
- }
1420
- // src/sse/handler.ts
1421
- class SSEHandler {
1422
- stateManager;
1423
- heartbeatInterval;
1424
- clients = new Map;
1425
- clientCounter = 0;
1426
- constructor(config) {
1427
- this.stateManager = config.stateManager;
1428
- this.heartbeatInterval = config.heartbeatInterval ?? 30000;
1876
+ getNewestVersion(entityKey) {
1877
+ return this.newestVersionIndex.get(entityKey) ?? null;
1429
1878
  }
1430
- handleConnection(_req) {
1431
- const clientId = `sse_${++this.clientCounter}_${Date.now()}`;
1432
- const encoder = new TextEncoder;
1433
- const stream = new ReadableStream({
1434
- start: (controller) => {
1435
- const stateClient = {
1436
- id: clientId,
1437
- send: (msg) => {
1438
- try {
1439
- const data = `data: ${JSON.stringify(msg)}
1440
-
1441
- `;
1442
- controller.enqueue(encoder.encode(data));
1443
- } catch {
1444
- this.removeClient(clientId);
1445
- }
1446
- }
1447
- };
1448
- this.stateManager.addClient(stateClient);
1449
- controller.enqueue(encoder.encode(`event: connected
1450
- data: ${JSON.stringify({ clientId })}
1451
-
1452
- `));
1453
- const heartbeat = setInterval(() => {
1454
- try {
1455
- controller.enqueue(encoder.encode(`: heartbeat ${Date.now()}
1456
-
1457
- `));
1458
- } catch {
1459
- this.removeClient(clientId);
1460
- }
1461
- }, this.heartbeatInterval);
1462
- this.clients.set(clientId, { controller, heartbeat });
1463
- },
1464
- cancel: () => {
1465
- this.removeClient(clientId);
1879
+ getAll(entityKey) {
1880
+ const indices = this.entityIndex.get(entityKey) ?? [];
1881
+ const result = [];
1882
+ for (const idx of indices) {
1883
+ const entry = this.entries[idx];
1884
+ if (entry) {
1885
+ result.push(entry);
1466
1886
  }
1467
- });
1468
- return new Response(stream, {
1469
- headers: {
1470
- "Content-Type": "text/event-stream",
1471
- "Cache-Control": "no-cache",
1472
- Connection: "keep-alive",
1473
- "Access-Control-Allow-Origin": "*"
1887
+ }
1888
+ return result.sort((a, b) => a.version - b.version);
1889
+ }
1890
+ cleanup() {
1891
+ const now = Date.now();
1892
+ let removedCount = 0;
1893
+ const minTimestamp = now - this.config.maxAge;
1894
+ while (this.entries.length > 0 && this.entries[0].timestamp < minTimestamp) {
1895
+ this.removeOldest();
1896
+ removedCount++;
1897
+ }
1898
+ while (this.entries.length > this.config.maxEntries) {
1899
+ this.removeOldest();
1900
+ removedCount++;
1901
+ }
1902
+ while (this.totalMemory > this.config.maxMemory && this.entries.length > 0) {
1903
+ this.removeOldest();
1904
+ removedCount++;
1905
+ }
1906
+ if (removedCount > this.entries.length * 0.1) {
1907
+ this.rebuildIndices();
1908
+ }
1909
+ }
1910
+ removeOldest() {
1911
+ const removed = this.entries.shift();
1912
+ if (!removed)
1913
+ return;
1914
+ this.totalMemory -= removed.patchSize;
1915
+ const indices = this.entityIndex.get(removed.entityKey);
1916
+ if (indices && indices.length > 0) {
1917
+ indices.shift();
1918
+ if (indices.length === 0) {
1919
+ this.entityIndex.delete(removed.entityKey);
1920
+ this.oldestVersionIndex.delete(removed.entityKey);
1921
+ this.newestVersionIndex.delete(removed.entityKey);
1922
+ } else {
1923
+ const nextEntry = this.entries[indices[0] - 1];
1924
+ if (nextEntry) {
1925
+ this.oldestVersionIndex.set(removed.entityKey, nextEntry.version);
1926
+ }
1474
1927
  }
1475
- });
1928
+ }
1476
1929
  }
1477
- removeClient(clientId) {
1478
- const client = this.clients.get(clientId);
1479
- if (client) {
1480
- clearInterval(client.heartbeat);
1481
- this.clients.delete(clientId);
1930
+ rebuildIndices() {
1931
+ this.entityIndex.clear();
1932
+ this.oldestVersionIndex.clear();
1933
+ this.newestVersionIndex.clear();
1934
+ this.totalMemory = 0;
1935
+ for (let i = 0;i < this.entries.length; i++) {
1936
+ const entry = this.entries[i];
1937
+ this.totalMemory += entry.patchSize;
1938
+ let indices = this.entityIndex.get(entry.entityKey);
1939
+ if (!indices) {
1940
+ indices = [];
1941
+ this.entityIndex.set(entry.entityKey, indices);
1942
+ this.oldestVersionIndex.set(entry.entityKey, entry.version);
1943
+ }
1944
+ indices.push(i);
1945
+ this.newestVersionIndex.set(entry.entityKey, entry.version);
1482
1946
  }
1483
- this.stateManager.removeClient(clientId);
1484
1947
  }
1485
- closeClient(clientId) {
1486
- const client = this.clients.get(clientId);
1487
- if (client) {
1488
- try {
1489
- client.controller.close();
1490
- } catch {}
1491
- this.removeClient(clientId);
1948
+ checkLimits() {
1949
+ const needsCleanup = this.entries.length > this.config.maxEntries || this.totalMemory > this.config.maxMemory;
1950
+ if (needsCleanup) {
1951
+ this.cleanup();
1492
1952
  }
1493
1953
  }
1494
- getClientCount() {
1495
- return this.clients.size;
1954
+ getStats() {
1955
+ return {
1956
+ entryCount: this.entries.length,
1957
+ entityCount: this.entityIndex.size,
1958
+ memoryUsage: this.totalMemory,
1959
+ oldestTimestamp: this.entries[0]?.timestamp ?? null,
1960
+ newestTimestamp: this.entries[this.entries.length - 1]?.timestamp ?? null,
1961
+ config: { ...this.config }
1962
+ };
1496
1963
  }
1497
- getClientIds() {
1498
- return Array.from(this.clients.keys());
1964
+ clear() {
1965
+ this.entries = [];
1966
+ this.entityIndex.clear();
1967
+ this.oldestVersionIndex.clear();
1968
+ this.newestVersionIndex.clear();
1969
+ this.totalMemory = 0;
1499
1970
  }
1500
- closeAll() {
1501
- for (const clientId of this.clients.keys()) {
1502
- this.closeClient(clientId);
1971
+ dispose() {
1972
+ if (this.cleanupTimer) {
1973
+ clearInterval(this.cleanupTimer);
1974
+ this.cleanupTimer = null;
1975
+ }
1976
+ this.clear();
1977
+ }
1978
+ updateConfig(config) {
1979
+ this.config = { ...this.config, ...config };
1980
+ if (this.cleanupTimer) {
1981
+ clearInterval(this.cleanupTimer);
1503
1982
  }
1983
+ if (this.config.cleanupInterval > 0) {
1984
+ this.cleanupTimer = setInterval(() => this.cleanup(), this.config.cleanupInterval);
1985
+ }
1986
+ this.cleanup();
1504
1987
  }
1505
1988
  }
1506
- function createSSEHandler(config) {
1507
- return new SSEHandler(config);
1989
+ function coalescePatches(patches) {
1990
+ const flatPatches = patches.flat();
1991
+ const pathMap = new Map;
1992
+ for (const op of flatPatches) {
1993
+ const existing = pathMap.get(op.path);
1994
+ if (!existing) {
1995
+ pathMap.set(op.path, op);
1996
+ continue;
1997
+ }
1998
+ switch (op.op) {
1999
+ case "replace":
2000
+ case "add":
2001
+ pathMap.set(op.path, op);
2002
+ break;
2003
+ case "remove":
2004
+ pathMap.set(op.path, op);
2005
+ break;
2006
+ case "move":
2007
+ case "copy":
2008
+ pathMap.set(op.path, op);
2009
+ break;
2010
+ case "test":
2011
+ break;
2012
+ }
2013
+ }
2014
+ const result = Array.from(pathMap.values());
2015
+ result.sort((a, b) => {
2016
+ const depthA = a.path.split("/").length;
2017
+ const depthB = b.path.split("/").length;
2018
+ if (depthA !== depthB)
2019
+ return depthA - depthB;
2020
+ return a.path.localeCompare(b.path);
2021
+ });
2022
+ return result;
2023
+ }
2024
+ function estimatePatchSize(patch) {
2025
+ return JSON.stringify(patch).length;
1508
2026
  }
1509
2027
  export {
2028
+ useContext,
2029
+ tryUseContext,
2030
+ runWithContextAsync,
2031
+ runWithContext,
1510
2032
  router,
1511
2033
  query,
2034
+ optimisticPlugin,
2035
+ opLog,
1512
2036
  mutation,
1513
- createServer,
2037
+ memoryStorage,
2038
+ isOptimisticPlugin,
2039
+ isOpLogPlugin,
2040
+ hasContext,
2041
+ handleWebSSE,
2042
+ handleWebQuery,
2043
+ handleWebMutation,
2044
+ extendContext,
2045
+ estimatePatchSize,
2046
+ createWSHandler,
2047
+ createServerClientProxy,
1514
2048
  createSSEHandler,
1515
- createGraphStateManager,
2049
+ createPluginManager,
2050
+ createHandler,
2051
+ createHTTPHandler,
2052
+ createFrameworkHandler,
2053
+ createContext,
2054
+ createApp,
2055
+ coalescePatches,
1516
2056
  SSEHandler,
1517
- GraphStateManager
2057
+ PluginManager,
2058
+ OperationLog,
2059
+ DEFAULT_STORAGE_CONFIG
1518
2060
  };