@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.d.ts +1244 -260
- package/dist/index.js +1700 -1158
- package/package.json +2 -2
- package/src/context/index.test.ts +425 -0
- package/src/context/index.ts +90 -0
- package/src/e2e/server.test.ts +215 -433
- package/src/handlers/framework.ts +294 -0
- package/src/handlers/http.test.ts +215 -0
- package/src/handlers/http.ts +189 -0
- package/src/handlers/index.ts +55 -0
- package/src/handlers/unified.ts +114 -0
- package/src/handlers/ws-types.ts +126 -0
- package/src/handlers/ws.ts +669 -0
- package/src/index.ts +127 -24
- package/src/plugin/index.ts +41 -0
- package/src/plugin/op-log.ts +286 -0
- package/src/plugin/optimistic.ts +375 -0
- package/src/plugin/types.ts +551 -0
- package/src/reconnect/index.ts +9 -0
- package/src/reconnect/operation-log.test.ts +480 -0
- package/src/reconnect/operation-log.ts +450 -0
- package/src/server/create.test.ts +256 -2193
- package/src/server/create.ts +285 -1481
- package/src/server/dataloader.ts +60 -0
- package/src/server/selection.ts +44 -0
- package/src/server/types.ts +289 -0
- package/src/sse/handler.ts +123 -56
- package/src/state/index.ts +9 -11
- package/src/storage/index.ts +26 -0
- package/src/storage/memory.ts +279 -0
- package/src/storage/types.ts +205 -0
- package/src/state/graph-state-manager.test.ts +0 -1105
- package/src/state/graph-state-manager.ts +0 -890
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sylphx/lens-server - Operation Log
|
|
3
|
+
*
|
|
4
|
+
* Bounded log of recent state changes for efficient reconnection.
|
|
5
|
+
* Stores patches that can be replayed to bring disconnected clients up to date.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
OperationLogConfig,
|
|
10
|
+
OperationLogEntry,
|
|
11
|
+
OperationLogStats,
|
|
12
|
+
PatchOperation,
|
|
13
|
+
Version,
|
|
14
|
+
} from "@sylphx/lens-core";
|
|
15
|
+
import { DEFAULT_OPERATION_LOG_CONFIG } from "@sylphx/lens-core";
|
|
16
|
+
|
|
17
|
+
// =============================================================================
|
|
18
|
+
// Operation Log
|
|
19
|
+
// =============================================================================
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Operation log with efficient lookup and bounded memory.
|
|
23
|
+
*
|
|
24
|
+
* Features:
|
|
25
|
+
* - O(1) lookup by entity key (via index)
|
|
26
|
+
* - Automatic eviction based on count, age, and memory
|
|
27
|
+
* - Version tracking for efficient reconnect
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```typescript
|
|
31
|
+
* const log = new OperationLog({ maxEntries: 10000, maxAge: 300000 });
|
|
32
|
+
*
|
|
33
|
+
* // Append operation
|
|
34
|
+
* log.append({
|
|
35
|
+
* entityKey: "user:123",
|
|
36
|
+
* version: 5,
|
|
37
|
+
* timestamp: Date.now(),
|
|
38
|
+
* patch: [{ op: "replace", path: "/name", value: "Alice" }],
|
|
39
|
+
* patchSize: 50,
|
|
40
|
+
* });
|
|
41
|
+
*
|
|
42
|
+
* // Get patches since version
|
|
43
|
+
* const patches = log.getSince("user:123", 3);
|
|
44
|
+
* // Returns patches for versions 4 and 5, or null if too old
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export class OperationLog {
|
|
48
|
+
private entries: OperationLogEntry[] = [];
|
|
49
|
+
private config: OperationLogConfig;
|
|
50
|
+
private totalMemory = 0;
|
|
51
|
+
|
|
52
|
+
// Indices for O(1) lookup
|
|
53
|
+
private entityIndex = new Map<string, number[]>(); // entityKey → entry indices
|
|
54
|
+
private oldestVersionIndex = new Map<string, Version>(); // entityKey → oldest version
|
|
55
|
+
private newestVersionIndex = new Map<string, Version>(); // entityKey → newest version
|
|
56
|
+
|
|
57
|
+
// Cleanup timer
|
|
58
|
+
private cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
59
|
+
|
|
60
|
+
constructor(config: Partial<OperationLogConfig> = {}) {
|
|
61
|
+
this.config = { ...DEFAULT_OPERATION_LOG_CONFIG, ...config };
|
|
62
|
+
|
|
63
|
+
// Start cleanup timer
|
|
64
|
+
if (this.config.cleanupInterval > 0) {
|
|
65
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), this.config.cleanupInterval);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ===========================================================================
|
|
70
|
+
// Core Operations
|
|
71
|
+
// ===========================================================================
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Append new operation to log.
|
|
75
|
+
* Automatically evicts old entries if limits exceeded.
|
|
76
|
+
*/
|
|
77
|
+
append(entry: OperationLogEntry): void {
|
|
78
|
+
const index = this.entries.length;
|
|
79
|
+
this.entries.push(entry);
|
|
80
|
+
this.totalMemory += entry.patchSize;
|
|
81
|
+
|
|
82
|
+
// Update entity index
|
|
83
|
+
let indices = this.entityIndex.get(entry.entityKey);
|
|
84
|
+
if (!indices) {
|
|
85
|
+
indices = [];
|
|
86
|
+
this.entityIndex.set(entry.entityKey, indices);
|
|
87
|
+
this.oldestVersionIndex.set(entry.entityKey, entry.version);
|
|
88
|
+
this.newestVersionIndex.set(entry.entityKey, entry.version);
|
|
89
|
+
} else {
|
|
90
|
+
// Track actual min/max versions (handles out-of-order appends)
|
|
91
|
+
const currentOldest = this.oldestVersionIndex.get(entry.entityKey)!;
|
|
92
|
+
const currentNewest = this.newestVersionIndex.get(entry.entityKey)!;
|
|
93
|
+
if (entry.version < currentOldest) {
|
|
94
|
+
this.oldestVersionIndex.set(entry.entityKey, entry.version);
|
|
95
|
+
}
|
|
96
|
+
if (entry.version > currentNewest) {
|
|
97
|
+
this.newestVersionIndex.set(entry.entityKey, entry.version);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
indices.push(index);
|
|
101
|
+
|
|
102
|
+
// Check limits and cleanup if needed
|
|
103
|
+
this.checkLimits();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Append batch of operations efficiently.
|
|
108
|
+
*/
|
|
109
|
+
appendBatch(entries: OperationLogEntry[]): void {
|
|
110
|
+
for (const entry of entries) {
|
|
111
|
+
this.append(entry);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Get all operations for entity since given version.
|
|
117
|
+
* Returns null if version is too old (not in log).
|
|
118
|
+
* Returns empty array if client is already at latest version.
|
|
119
|
+
*/
|
|
120
|
+
getSince(entityKey: string, fromVersion: Version): OperationLogEntry[] | null {
|
|
121
|
+
const oldestVersion = this.oldestVersionIndex.get(entityKey);
|
|
122
|
+
const newestVersion = this.newestVersionIndex.get(entityKey);
|
|
123
|
+
|
|
124
|
+
// No entries for this entity
|
|
125
|
+
if (oldestVersion === undefined || newestVersion === undefined) {
|
|
126
|
+
return fromVersion === 0 ? [] : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Client is already up to date
|
|
130
|
+
if (fromVersion >= newestVersion) {
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Version too old - not in log
|
|
135
|
+
if (fromVersion < oldestVersion - 1) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Get entries from index
|
|
140
|
+
const indices = this.entityIndex.get(entityKey) ?? [];
|
|
141
|
+
const result: OperationLogEntry[] = [];
|
|
142
|
+
|
|
143
|
+
for (const idx of indices) {
|
|
144
|
+
const entry = this.entries[idx];
|
|
145
|
+
// Entry might be undefined if index is stale (after cleanup)
|
|
146
|
+
if (entry && entry.version > fromVersion) {
|
|
147
|
+
result.push(entry);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Sort by version to ensure correct order
|
|
152
|
+
result.sort((a, b) => a.version - b.version);
|
|
153
|
+
|
|
154
|
+
// Verify continuity - patches must be consecutive
|
|
155
|
+
if (result.length > 0) {
|
|
156
|
+
// First entry must be fromVersion + 1
|
|
157
|
+
if (result[0].version !== fromVersion + 1) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// All subsequent entries must be consecutive
|
|
162
|
+
for (let i = 1; i < result.length; i++) {
|
|
163
|
+
if (result[i].version !== result[i - 1].version + 1) {
|
|
164
|
+
// Gap in versions - can't use patches
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Check if version is within log range for entity.
|
|
175
|
+
*/
|
|
176
|
+
hasVersion(entityKey: string, version: Version): boolean {
|
|
177
|
+
const oldest = this.oldestVersionIndex.get(entityKey);
|
|
178
|
+
const newest = this.newestVersionIndex.get(entityKey);
|
|
179
|
+
|
|
180
|
+
if (oldest === undefined || newest === undefined) {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return version >= oldest && version <= newest;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Get oldest version available for entity.
|
|
189
|
+
*/
|
|
190
|
+
getOldestVersion(entityKey: string): Version | null {
|
|
191
|
+
return this.oldestVersionIndex.get(entityKey) ?? null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Get newest version for entity.
|
|
196
|
+
*/
|
|
197
|
+
getNewestVersion(entityKey: string): Version | null {
|
|
198
|
+
return this.newestVersionIndex.get(entityKey) ?? null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Get all patches for entity (for debugging/testing).
|
|
203
|
+
*/
|
|
204
|
+
getAll(entityKey: string): OperationLogEntry[] {
|
|
205
|
+
const indices = this.entityIndex.get(entityKey) ?? [];
|
|
206
|
+
const result: OperationLogEntry[] = [];
|
|
207
|
+
|
|
208
|
+
for (const idx of indices) {
|
|
209
|
+
const entry = this.entries[idx];
|
|
210
|
+
if (entry) {
|
|
211
|
+
result.push(entry);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return result.sort((a, b) => a.version - b.version);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ===========================================================================
|
|
219
|
+
// Cleanup & Eviction
|
|
220
|
+
// ===========================================================================
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Cleanup expired entries.
|
|
224
|
+
* Called automatically on interval or manually.
|
|
225
|
+
*/
|
|
226
|
+
cleanup(): void {
|
|
227
|
+
const now = Date.now();
|
|
228
|
+
let removedCount = 0;
|
|
229
|
+
|
|
230
|
+
// Time-based eviction
|
|
231
|
+
const minTimestamp = now - this.config.maxAge;
|
|
232
|
+
while (this.entries.length > 0 && this.entries[0].timestamp < minTimestamp) {
|
|
233
|
+
this.removeOldest();
|
|
234
|
+
removedCount++;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Count-based eviction
|
|
238
|
+
while (this.entries.length > this.config.maxEntries) {
|
|
239
|
+
this.removeOldest();
|
|
240
|
+
removedCount++;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Memory-based eviction
|
|
244
|
+
while (this.totalMemory > this.config.maxMemory && this.entries.length > 0) {
|
|
245
|
+
this.removeOldest();
|
|
246
|
+
removedCount++;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Rebuild indices if significant cleanup occurred
|
|
250
|
+
if (removedCount > this.entries.length * 0.1) {
|
|
251
|
+
this.rebuildIndices();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Remove oldest entry and update tracking.
|
|
257
|
+
*/
|
|
258
|
+
private removeOldest(): void {
|
|
259
|
+
const removed = this.entries.shift();
|
|
260
|
+
if (!removed) return;
|
|
261
|
+
|
|
262
|
+
this.totalMemory -= removed.patchSize;
|
|
263
|
+
|
|
264
|
+
// Update oldest version for entity
|
|
265
|
+
const indices = this.entityIndex.get(removed.entityKey);
|
|
266
|
+
if (indices && indices.length > 0) {
|
|
267
|
+
// Remove first index (oldest)
|
|
268
|
+
indices.shift();
|
|
269
|
+
|
|
270
|
+
if (indices.length === 0) {
|
|
271
|
+
// No more entries for this entity
|
|
272
|
+
this.entityIndex.delete(removed.entityKey);
|
|
273
|
+
this.oldestVersionIndex.delete(removed.entityKey);
|
|
274
|
+
this.newestVersionIndex.delete(removed.entityKey);
|
|
275
|
+
} else {
|
|
276
|
+
// Update oldest version to next entry
|
|
277
|
+
// Note: indices are now stale (off by 1) until rebuildIndices
|
|
278
|
+
const nextEntry = this.entries[indices[0] - 1]; // -1 because we shifted
|
|
279
|
+
if (nextEntry) {
|
|
280
|
+
this.oldestVersionIndex.set(removed.entityKey, nextEntry.version);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Rebuild indices after cleanup.
|
|
288
|
+
* O(n) operation, should be called sparingly.
|
|
289
|
+
*/
|
|
290
|
+
private rebuildIndices(): void {
|
|
291
|
+
this.entityIndex.clear();
|
|
292
|
+
this.oldestVersionIndex.clear();
|
|
293
|
+
this.newestVersionIndex.clear();
|
|
294
|
+
this.totalMemory = 0;
|
|
295
|
+
|
|
296
|
+
for (let i = 0; i < this.entries.length; i++) {
|
|
297
|
+
const entry = this.entries[i];
|
|
298
|
+
this.totalMemory += entry.patchSize;
|
|
299
|
+
|
|
300
|
+
let indices = this.entityIndex.get(entry.entityKey);
|
|
301
|
+
if (!indices) {
|
|
302
|
+
indices = [];
|
|
303
|
+
this.entityIndex.set(entry.entityKey, indices);
|
|
304
|
+
this.oldestVersionIndex.set(entry.entityKey, entry.version);
|
|
305
|
+
}
|
|
306
|
+
indices.push(i);
|
|
307
|
+
this.newestVersionIndex.set(entry.entityKey, entry.version);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Check limits and trigger cleanup if needed.
|
|
313
|
+
*/
|
|
314
|
+
private checkLimits(): void {
|
|
315
|
+
const needsCleanup =
|
|
316
|
+
this.entries.length > this.config.maxEntries || this.totalMemory > this.config.maxMemory;
|
|
317
|
+
|
|
318
|
+
if (needsCleanup) {
|
|
319
|
+
this.cleanup();
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ===========================================================================
|
|
324
|
+
// Statistics & Lifecycle
|
|
325
|
+
// ===========================================================================
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Get statistics about the operation log.
|
|
329
|
+
*/
|
|
330
|
+
getStats(): OperationLogStats {
|
|
331
|
+
return {
|
|
332
|
+
entryCount: this.entries.length,
|
|
333
|
+
entityCount: this.entityIndex.size,
|
|
334
|
+
memoryUsage: this.totalMemory,
|
|
335
|
+
oldestTimestamp: this.entries[0]?.timestamp ?? null,
|
|
336
|
+
newestTimestamp: this.entries[this.entries.length - 1]?.timestamp ?? null,
|
|
337
|
+
config: { ...this.config },
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Clear all entries.
|
|
343
|
+
*/
|
|
344
|
+
clear(): void {
|
|
345
|
+
this.entries = [];
|
|
346
|
+
this.entityIndex.clear();
|
|
347
|
+
this.oldestVersionIndex.clear();
|
|
348
|
+
this.newestVersionIndex.clear();
|
|
349
|
+
this.totalMemory = 0;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Stop cleanup timer and release resources.
|
|
354
|
+
*/
|
|
355
|
+
dispose(): void {
|
|
356
|
+
if (this.cleanupTimer) {
|
|
357
|
+
clearInterval(this.cleanupTimer);
|
|
358
|
+
this.cleanupTimer = null;
|
|
359
|
+
}
|
|
360
|
+
this.clear();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Update configuration.
|
|
365
|
+
*/
|
|
366
|
+
updateConfig(config: Partial<OperationLogConfig>): void {
|
|
367
|
+
this.config = { ...this.config, ...config };
|
|
368
|
+
|
|
369
|
+
// Restart cleanup timer if interval changed
|
|
370
|
+
if (this.cleanupTimer) {
|
|
371
|
+
clearInterval(this.cleanupTimer);
|
|
372
|
+
}
|
|
373
|
+
if (this.config.cleanupInterval > 0) {
|
|
374
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), this.config.cleanupInterval);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Apply new limits
|
|
378
|
+
this.cleanup();
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// =============================================================================
|
|
383
|
+
// Patch Utilities (Server-side)
|
|
384
|
+
// =============================================================================
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Coalesce multiple patches into single optimized patch.
|
|
388
|
+
* Removes redundant operations and combines sequential changes.
|
|
389
|
+
*
|
|
390
|
+
* @param patches - Array of patch arrays (one per version)
|
|
391
|
+
* @returns Single coalesced patch array
|
|
392
|
+
*/
|
|
393
|
+
export function coalescePatches(patches: PatchOperation[][]): PatchOperation[] {
|
|
394
|
+
const flatPatches = patches.flat();
|
|
395
|
+
const pathMap = new Map<string, PatchOperation>();
|
|
396
|
+
|
|
397
|
+
for (const op of flatPatches) {
|
|
398
|
+
const existing = pathMap.get(op.path);
|
|
399
|
+
|
|
400
|
+
if (!existing) {
|
|
401
|
+
pathMap.set(op.path, op);
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Coalesce based on operation type
|
|
406
|
+
switch (op.op) {
|
|
407
|
+
case "replace":
|
|
408
|
+
case "add":
|
|
409
|
+
// Later value wins
|
|
410
|
+
pathMap.set(op.path, op);
|
|
411
|
+
break;
|
|
412
|
+
|
|
413
|
+
case "remove":
|
|
414
|
+
// Remove trumps add/replace
|
|
415
|
+
pathMap.set(op.path, op);
|
|
416
|
+
break;
|
|
417
|
+
|
|
418
|
+
case "move":
|
|
419
|
+
case "copy":
|
|
420
|
+
// These are complex - just keep the latest
|
|
421
|
+
pathMap.set(op.path, op);
|
|
422
|
+
break;
|
|
423
|
+
|
|
424
|
+
case "test":
|
|
425
|
+
// Test operations can be dropped in coalescing
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Convert back to array, maintaining a reasonable order
|
|
431
|
+
const result = Array.from(pathMap.values());
|
|
432
|
+
|
|
433
|
+
// Sort by path depth (shallower first) for proper application order
|
|
434
|
+
result.sort((a, b) => {
|
|
435
|
+
const depthA = a.path.split("/").length;
|
|
436
|
+
const depthB = b.path.split("/").length;
|
|
437
|
+
if (depthA !== depthB) return depthA - depthB;
|
|
438
|
+
return a.path.localeCompare(b.path);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
return result;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Estimate memory size of patch operations.
|
|
446
|
+
*/
|
|
447
|
+
export function estimatePatchSize(patch: PatchOperation[]): number {
|
|
448
|
+
// Rough estimate: JSON stringify length
|
|
449
|
+
return JSON.stringify(patch).length;
|
|
450
|
+
}
|