@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,26 @@
1
+ /**
2
+ * @sylphx/lens-server - Storage
3
+ *
4
+ * Storage adapters for opLog plugin.
5
+ *
6
+ * Built-in:
7
+ * - `memoryStorage()` - In-memory (default, for long-running servers)
8
+ *
9
+ * External packages (install separately):
10
+ * - `@sylphx/lens-storage-redis` - Redis via ioredis
11
+ * - `@sylphx/lens-storage-upstash` - Upstash Redis HTTP (serverless/edge)
12
+ * - `@sylphx/lens-storage-vercel-kv` - Vercel KV (Next.js/Vercel)
13
+ */
14
+
15
+ // In-memory (default)
16
+ export { memoryStorage } from "./memory.js";
17
+
18
+ // Types (for implementing custom storage adapters)
19
+ export {
20
+ DEFAULT_STORAGE_CONFIG,
21
+ type EmitResult,
22
+ type OpLogStorage,
23
+ type OpLogStorageConfig,
24
+ type StoredEntityState,
25
+ type StoredPatchEntry,
26
+ } from "./types.js";
@@ -0,0 +1,279 @@
1
+ /**
2
+ * @sylphx/lens-server - Memory Storage
3
+ *
4
+ * In-memory storage adapter for opLog plugin.
5
+ * Default storage for long-running servers.
6
+ *
7
+ * Features:
8
+ * - O(1) state and version lookups
9
+ * - Bounded patch history per entity
10
+ * - Automatic cleanup of old patches
11
+ *
12
+ * Memory: O(entities × maxPatchesPerEntity)
13
+ */
14
+
15
+ import type { PatchOperation } from "@sylphx/lens-core";
16
+ import {
17
+ DEFAULT_STORAGE_CONFIG,
18
+ type EmitResult,
19
+ type OpLogStorage,
20
+ type OpLogStorageConfig,
21
+ type StoredPatchEntry,
22
+ } from "./types.js";
23
+
24
+ /**
25
+ * Entity key for internal storage.
26
+ */
27
+ type EntityKey = string;
28
+
29
+ /**
30
+ * Internal entity state.
31
+ */
32
+ interface EntityState {
33
+ data: Record<string, unknown>;
34
+ version: number;
35
+ patches: StoredPatchEntry[];
36
+ }
37
+
38
+ /**
39
+ * Create entity key from entity type and ID.
40
+ */
41
+ function makeKey(entity: string, entityId: string): EntityKey {
42
+ return `${entity}:${entityId}`;
43
+ }
44
+
45
+ /**
46
+ * Compute JSON Patch operations between two states.
47
+ */
48
+ function computePatch(
49
+ oldState: Record<string, unknown>,
50
+ newState: Record<string, unknown>,
51
+ ): PatchOperation[] {
52
+ const patch: PatchOperation[] = [];
53
+ const oldKeys = new Set(Object.keys(oldState));
54
+ const newKeys = new Set(Object.keys(newState));
55
+
56
+ // Additions and replacements
57
+ for (const key of newKeys) {
58
+ const oldValue = oldState[key];
59
+ const newValue = newState[key];
60
+
61
+ if (!oldKeys.has(key)) {
62
+ // New field
63
+ patch.push({ op: "add", path: `/${key}`, value: newValue });
64
+ } else if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
65
+ // Changed field
66
+ patch.push({ op: "replace", path: `/${key}`, value: newValue });
67
+ }
68
+ }
69
+
70
+ // Deletions
71
+ for (const key of oldKeys) {
72
+ if (!newKeys.has(key)) {
73
+ patch.push({ op: "remove", path: `/${key}` });
74
+ }
75
+ }
76
+
77
+ return patch;
78
+ }
79
+
80
+ /**
81
+ * Hash entity state for change detection.
82
+ */
83
+ function hashState(state: Record<string, unknown>): string {
84
+ return JSON.stringify(state);
85
+ }
86
+
87
+ /**
88
+ * Create an in-memory storage adapter.
89
+ *
90
+ * @example
91
+ * ```typescript
92
+ * const storage = memoryStorage();
93
+ *
94
+ * // Or with custom config
95
+ * const storage = memoryStorage({
96
+ * maxPatchesPerEntity: 500,
97
+ * maxPatchAge: 60000,
98
+ * });
99
+ * ```
100
+ */
101
+ export function memoryStorage(config: OpLogStorageConfig = {}): OpLogStorage {
102
+ const cfg = { ...DEFAULT_STORAGE_CONFIG, ...config };
103
+ const entities = new Map<EntityKey, EntityState>();
104
+ let cleanupTimer: ReturnType<typeof setInterval> | null = null;
105
+
106
+ // Start cleanup timer
107
+ if (cfg.cleanupInterval > 0) {
108
+ cleanupTimer = setInterval(() => cleanup(), cfg.cleanupInterval);
109
+ }
110
+
111
+ /**
112
+ * Cleanup old patches based on age.
113
+ */
114
+ function cleanup(): void {
115
+ const now = Date.now();
116
+ const minTimestamp = now - cfg.maxPatchAge;
117
+
118
+ for (const state of entities.values()) {
119
+ // Remove patches older than maxPatchAge
120
+ state.patches = state.patches.filter((p) => p.timestamp >= minTimestamp);
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Trim patches to maxPatchesPerEntity.
126
+ */
127
+ function trimPatches(state: EntityState): void {
128
+ if (state.patches.length > cfg.maxPatchesPerEntity) {
129
+ // Remove oldest patches
130
+ state.patches = state.patches.slice(-cfg.maxPatchesPerEntity);
131
+ }
132
+ }
133
+
134
+ return {
135
+ async emit(entity, entityId, data): Promise<EmitResult> {
136
+ const key = makeKey(entity, entityId);
137
+ const existing = entities.get(key);
138
+ const now = Date.now();
139
+
140
+ if (!existing) {
141
+ // First emit - no previous state
142
+ const newState: EntityState = {
143
+ data: { ...data },
144
+ version: 1,
145
+ patches: [],
146
+ };
147
+
148
+ // No patch for first emit (full state is sent instead)
149
+ entities.set(key, newState);
150
+
151
+ return {
152
+ version: 1,
153
+ patch: null,
154
+ changed: true,
155
+ };
156
+ }
157
+
158
+ // Check if state actually changed
159
+ const oldHash = hashState(existing.data);
160
+ const newHash = hashState(data);
161
+
162
+ if (oldHash === newHash) {
163
+ return {
164
+ version: existing.version,
165
+ patch: null,
166
+ changed: false,
167
+ };
168
+ }
169
+
170
+ // Compute patch
171
+ const patch = computePatch(existing.data, data);
172
+
173
+ // Update state
174
+ const newVersion = existing.version + 1;
175
+ existing.data = { ...data };
176
+ existing.version = newVersion;
177
+
178
+ // Append patch to log
179
+ if (patch.length > 0) {
180
+ existing.patches.push({
181
+ version: newVersion,
182
+ patch,
183
+ timestamp: now,
184
+ });
185
+ trimPatches(existing);
186
+ }
187
+
188
+ return {
189
+ version: newVersion,
190
+ patch: patch.length > 0 ? patch : null,
191
+ changed: true,
192
+ };
193
+ },
194
+
195
+ async getState(entity, entityId): Promise<Record<string, unknown> | null> {
196
+ const key = makeKey(entity, entityId);
197
+ const state = entities.get(key);
198
+ return state ? { ...state.data } : null;
199
+ },
200
+
201
+ async getVersion(entity, entityId): Promise<number> {
202
+ const key = makeKey(entity, entityId);
203
+ const state = entities.get(key);
204
+ return state?.version ?? 0;
205
+ },
206
+
207
+ async getLatestPatch(entity, entityId): Promise<PatchOperation[] | null> {
208
+ const key = makeKey(entity, entityId);
209
+ const state = entities.get(key);
210
+
211
+ if (!state || state.patches.length === 0) {
212
+ return null;
213
+ }
214
+
215
+ return state.patches[state.patches.length - 1].patch;
216
+ },
217
+
218
+ async getPatchesSince(entity, entityId, sinceVersion): Promise<PatchOperation[][] | null> {
219
+ const key = makeKey(entity, entityId);
220
+ const state = entities.get(key);
221
+
222
+ if (!state) {
223
+ return sinceVersion === 0 ? [] : null;
224
+ }
225
+
226
+ // Already up to date
227
+ if (sinceVersion >= state.version) {
228
+ return [];
229
+ }
230
+
231
+ // Find patches since the given version
232
+ const relevantPatches = state.patches.filter((p) => p.version > sinceVersion);
233
+
234
+ if (relevantPatches.length === 0) {
235
+ // No patches in log - version too old
236
+ return null;
237
+ }
238
+
239
+ // Verify continuity
240
+ relevantPatches.sort((a, b) => a.version - b.version);
241
+
242
+ // First patch must be sinceVersion + 1
243
+ if (relevantPatches[0].version !== sinceVersion + 1) {
244
+ return null;
245
+ }
246
+
247
+ // Check for gaps
248
+ for (let i = 1; i < relevantPatches.length; i++) {
249
+ if (relevantPatches[i].version !== relevantPatches[i - 1].version + 1) {
250
+ return null;
251
+ }
252
+ }
253
+
254
+ return relevantPatches.map((p) => p.patch);
255
+ },
256
+
257
+ async has(entity, entityId): Promise<boolean> {
258
+ const key = makeKey(entity, entityId);
259
+ return entities.has(key);
260
+ },
261
+
262
+ async delete(entity, entityId): Promise<void> {
263
+ const key = makeKey(entity, entityId);
264
+ entities.delete(key);
265
+ },
266
+
267
+ async clear(): Promise<void> {
268
+ entities.clear();
269
+ },
270
+
271
+ async dispose(): Promise<void> {
272
+ if (cleanupTimer) {
273
+ clearInterval(cleanupTimer);
274
+ cleanupTimer = null;
275
+ }
276
+ entities.clear();
277
+ },
278
+ };
279
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * @sylphx/lens-server - Storage Types
3
+ *
4
+ * Storage adapter interface for opLog plugin.
5
+ * Enables serverless support by abstracting state/version/patch storage.
6
+ */
7
+
8
+ import type { PatchOperation } from "@sylphx/lens-core";
9
+
10
+ /**
11
+ * Entity state stored in the operation log.
12
+ */
13
+ export interface StoredEntityState {
14
+ /** Canonical state data */
15
+ data: Record<string, unknown>;
16
+ /** Current version */
17
+ version: number;
18
+ /** Timestamp of last update */
19
+ updatedAt: number;
20
+ }
21
+
22
+ /**
23
+ * Operation log entry stored in storage.
24
+ */
25
+ export interface StoredPatchEntry {
26
+ /** Version this patch creates */
27
+ version: number;
28
+ /** Patch operations */
29
+ patch: PatchOperation[];
30
+ /** Timestamp when patch was created */
31
+ timestamp: number;
32
+ }
33
+
34
+ /**
35
+ * Result from emit operation.
36
+ */
37
+ export interface EmitResult {
38
+ /** New version after emit */
39
+ version: number;
40
+ /** Computed patch (null if first emit) */
41
+ patch: PatchOperation[] | null;
42
+ /** Whether state actually changed */
43
+ changed: boolean;
44
+ }
45
+
46
+ /**
47
+ * Storage adapter interface for opLog.
48
+ *
49
+ * Implementations:
50
+ * - `memoryStorage()` - In-memory (default, for long-running servers)
51
+ * - `redisStorage()` - Redis/Upstash (for serverless)
52
+ * - `kvStorage()` - Cloudflare KV, Vercel KV
53
+ *
54
+ * All methods are async to support external storage.
55
+ *
56
+ * @example
57
+ * ```typescript
58
+ * // Default (in-memory)
59
+ * const app = createApp({
60
+ * router,
61
+ * plugins: [opLog()],
62
+ * });
63
+ *
64
+ * // With Redis for serverless
65
+ * const app = createApp({
66
+ * router,
67
+ * plugins: [opLog({
68
+ * storage: redisStorage({ url: process.env.REDIS_URL }),
69
+ * })],
70
+ * });
71
+ * ```
72
+ */
73
+ export interface OpLogStorage {
74
+ /**
75
+ * Emit new state for an entity.
76
+ * This is an atomic operation that:
77
+ * 1. Computes patch from previous state (if exists)
78
+ * 2. Stores new state with incremented version
79
+ * 3. Appends patch to operation log
80
+ *
81
+ * @param entity - Entity type name
82
+ * @param entityId - Entity ID
83
+ * @param data - New state data
84
+ * @returns Emit result with version, patch, and changed flag
85
+ */
86
+ emit(entity: string, entityId: string, data: Record<string, unknown>): Promise<EmitResult>;
87
+
88
+ /**
89
+ * Get current canonical state for an entity.
90
+ *
91
+ * @param entity - Entity type name
92
+ * @param entityId - Entity ID
93
+ * @returns State data or null if not found
94
+ */
95
+ getState(entity: string, entityId: string): Promise<Record<string, unknown> | null>;
96
+
97
+ /**
98
+ * Get current version for an entity.
99
+ * Returns 0 if entity doesn't exist.
100
+ *
101
+ * @param entity - Entity type name
102
+ * @param entityId - Entity ID
103
+ * @returns Current version (0 if not found)
104
+ */
105
+ getVersion(entity: string, entityId: string): Promise<number>;
106
+
107
+ /**
108
+ * Get the latest patch for an entity.
109
+ * Returns null if no patches available.
110
+ *
111
+ * @param entity - Entity type name
112
+ * @param entityId - Entity ID
113
+ * @returns Latest patch or null
114
+ */
115
+ getLatestPatch(entity: string, entityId: string): Promise<PatchOperation[] | null>;
116
+
117
+ /**
118
+ * Get all patches since a given version.
119
+ * Used for reconnection to bring client up to date.
120
+ *
121
+ * @param entity - Entity type name
122
+ * @param entityId - Entity ID
123
+ * @param sinceVersion - Client's current version
124
+ * @returns Array of patches (one per version), or null if too old
125
+ */
126
+ getPatchesSince(
127
+ entity: string,
128
+ entityId: string,
129
+ sinceVersion: number,
130
+ ): Promise<PatchOperation[][] | null>;
131
+
132
+ /**
133
+ * Check if entity exists in storage.
134
+ *
135
+ * @param entity - Entity type name
136
+ * @param entityId - Entity ID
137
+ * @returns True if entity exists
138
+ */
139
+ has(entity: string, entityId: string): Promise<boolean>;
140
+
141
+ /**
142
+ * Delete an entity from storage.
143
+ * Removes state, version, and all patches.
144
+ *
145
+ * @param entity - Entity type name
146
+ * @param entityId - Entity ID
147
+ */
148
+ delete(entity: string, entityId: string): Promise<void>;
149
+
150
+ /**
151
+ * Clear all data from storage.
152
+ * Used for testing.
153
+ */
154
+ clear(): Promise<void>;
155
+
156
+ /**
157
+ * Dispose storage resources.
158
+ * Called when shutting down.
159
+ */
160
+ dispose?(): Promise<void>;
161
+ }
162
+
163
+ /**
164
+ * Configuration for operation log storage.
165
+ */
166
+ export interface OpLogStorageConfig {
167
+ /**
168
+ * Maximum number of patches to keep per entity.
169
+ * Older patches are evicted when limit is reached.
170
+ * @default 1000
171
+ */
172
+ maxPatchesPerEntity?: number;
173
+
174
+ /**
175
+ * Maximum age of patches in milliseconds.
176
+ * Patches older than this are evicted.
177
+ * @default 300000 (5 minutes)
178
+ */
179
+ maxPatchAge?: number;
180
+
181
+ /**
182
+ * Cleanup interval in milliseconds.
183
+ * Set to 0 to disable automatic cleanup.
184
+ * @default 60000 (1 minute)
185
+ */
186
+ cleanupInterval?: number;
187
+
188
+ /**
189
+ * Maximum retries for emit operations on version conflict.
190
+ * Only applies to external storage (Redis, Upstash, Vercel KV).
191
+ * Set to 0 to disable retries (fail immediately on conflict).
192
+ * @default 3
193
+ */
194
+ maxRetries?: number;
195
+ }
196
+
197
+ /**
198
+ * Default storage configuration.
199
+ */
200
+ export const DEFAULT_STORAGE_CONFIG: Required<OpLogStorageConfig> = {
201
+ maxPatchesPerEntity: 1000,
202
+ maxPatchAge: 5 * 60 * 1000, // 5 minutes
203
+ cleanupInterval: 60 * 1000, // 1 minute
204
+ maxRetries: 3,
205
+ };