@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.
@@ -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
+ }