@sylphx/lens-storage-vercel-kv 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,65 @@
1
+ import { OpLogStorage, OpLogStorageConfig } from "@sylphx/lens-server";
2
+ /**
3
+ * Vercel KV client interface.
4
+ * Compatible with @vercel/kv.
5
+ */
6
+ interface VercelKVClient {
7
+ get<T>(key: string): Promise<T | null>;
8
+ set(key: string, value: unknown, options?: {
9
+ ex?: number;
10
+ }): Promise<unknown>;
11
+ del(...keys: string[]): Promise<number>;
12
+ keys(pattern: string): Promise<string[]>;
13
+ exists(...keys: string[]): Promise<number>;
14
+ }
15
+ /**
16
+ * Vercel KV storage options.
17
+ */
18
+ interface VercelKVStorageOptions extends OpLogStorageConfig {
19
+ /**
20
+ * Vercel KV client instance.
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * import { kv } from "@vercel/kv";
25
+ * // or
26
+ * import { createClient } from "@vercel/kv";
27
+ * const kv = createClient({ url, token });
28
+ * ```
29
+ */
30
+ kv: VercelKVClient;
31
+ /**
32
+ * Key prefix for all stored data.
33
+ * @default "lens"
34
+ */
35
+ prefix?: string;
36
+ /**
37
+ * TTL for state data in seconds.
38
+ * Set to 0 for no expiration.
39
+ * @default 0 (no expiration)
40
+ */
41
+ stateTTL?: number;
42
+ }
43
+ /**
44
+ * Create a Vercel KV storage adapter.
45
+ *
46
+ * Requires `@vercel/kv` as a peer dependency.
47
+ *
48
+ * Uses optimistic locking: if a concurrent write is detected,
49
+ * the operation is retried up to `maxRetries` times.
50
+ *
51
+ * @example
52
+ * ```typescript
53
+ * import { kv } from "@vercel/kv";
54
+ * import { vercelKVStorage } from "@sylphx/lens-storage-vercel-kv";
55
+ *
56
+ * const app = createApp({
57
+ * router,
58
+ * plugins: [opLog({
59
+ * storage: vercelKVStorage({ kv }),
60
+ * })],
61
+ * });
62
+ * ```
63
+ */
64
+ declare function vercelKVStorage(options: VercelKVStorageOptions): OpLogStorage;
65
+ export { vercelKVStorage, VercelKVStorageOptions, VercelKVClient };
package/dist/index.js ADDED
@@ -0,0 +1,179 @@
1
+ // src/index.ts
2
+ import {
3
+ DEFAULT_STORAGE_CONFIG
4
+ } from "@sylphx/lens-server";
5
+ function computePatch(oldState, newState) {
6
+ const patch = [];
7
+ const oldKeys = new Set(Object.keys(oldState));
8
+ const newKeys = new Set(Object.keys(newState));
9
+ for (const key of newKeys) {
10
+ const oldValue = oldState[key];
11
+ const newValue = newState[key];
12
+ if (!oldKeys.has(key)) {
13
+ patch.push({ op: "add", path: `/${key}`, value: newValue });
14
+ } else if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
15
+ patch.push({ op: "replace", path: `/${key}`, value: newValue });
16
+ }
17
+ }
18
+ for (const key of oldKeys) {
19
+ if (!newKeys.has(key)) {
20
+ patch.push({ op: "remove", path: `/${key}` });
21
+ }
22
+ }
23
+ return patch;
24
+ }
25
+ function vercelKVStorage(options) {
26
+ const { kv, prefix = "lens", stateTTL = 0 } = options;
27
+ const cfg = { ...DEFAULT_STORAGE_CONFIG, ...options };
28
+ function makeKey(entity, entityId) {
29
+ return `${prefix}:${entity}:${entityId}`;
30
+ }
31
+ async function getData(entity, entityId) {
32
+ const key = makeKey(entity, entityId);
33
+ const data = await kv.get(key);
34
+ return data;
35
+ }
36
+ async function setData(entity, entityId, data) {
37
+ const key = makeKey(entity, entityId);
38
+ if (stateTTL > 0) {
39
+ await kv.set(key, data, { ex: stateTTL });
40
+ } else {
41
+ await kv.set(key, data);
42
+ }
43
+ }
44
+ function trimPatches(patches, now) {
45
+ const minTimestamp = now - cfg.maxPatchAge;
46
+ let filtered = patches.filter((p) => p.timestamp >= minTimestamp);
47
+ if (filtered.length > cfg.maxPatchesPerEntity) {
48
+ filtered = filtered.slice(-cfg.maxPatchesPerEntity);
49
+ }
50
+ return filtered;
51
+ }
52
+ async function emitWithRetry(entity, entityId, data, retryCount = 0) {
53
+ const now = Date.now();
54
+ const existing = await getData(entity, entityId);
55
+ if (!existing) {
56
+ const newData2 = {
57
+ data: { ...data },
58
+ version: 1,
59
+ updatedAt: now,
60
+ patches: []
61
+ };
62
+ await setData(entity, entityId, newData2);
63
+ return {
64
+ version: 1,
65
+ patch: null,
66
+ changed: true
67
+ };
68
+ }
69
+ const expectedVersion = existing.version;
70
+ const oldHash = JSON.stringify(existing.data);
71
+ const newHash = JSON.stringify(data);
72
+ if (oldHash === newHash) {
73
+ return {
74
+ version: existing.version,
75
+ patch: null,
76
+ changed: false
77
+ };
78
+ }
79
+ const patch = computePatch(existing.data, data);
80
+ const newVersion = expectedVersion + 1;
81
+ let patches = [...existing.patches];
82
+ if (patch.length > 0) {
83
+ patches.push({
84
+ version: newVersion,
85
+ patch,
86
+ timestamp: now
87
+ });
88
+ patches = trimPatches(patches, now);
89
+ }
90
+ const newData = {
91
+ data: { ...data },
92
+ version: newVersion,
93
+ updatedAt: now,
94
+ patches
95
+ };
96
+ await setData(entity, entityId, newData);
97
+ const verify = await getData(entity, entityId);
98
+ if (verify && verify.version !== newVersion) {
99
+ if (retryCount < cfg.maxRetries) {
100
+ const delay = Math.min(10 * 2 ** retryCount, 100);
101
+ await new Promise((resolve) => setTimeout(resolve, delay));
102
+ return emitWithRetry(entity, entityId, data, retryCount + 1);
103
+ }
104
+ return {
105
+ version: verify.version,
106
+ patch: null,
107
+ changed: true
108
+ };
109
+ }
110
+ return {
111
+ version: newVersion,
112
+ patch: patch.length > 0 ? patch : null,
113
+ changed: true
114
+ };
115
+ }
116
+ return {
117
+ emit: (entity, entityId, data) => emitWithRetry(entity, entityId, data, 0),
118
+ async getState(entity, entityId) {
119
+ const stored = await getData(entity, entityId);
120
+ return stored ? { ...stored.data } : null;
121
+ },
122
+ async getVersion(entity, entityId) {
123
+ const stored = await getData(entity, entityId);
124
+ return stored?.version ?? 0;
125
+ },
126
+ async getLatestPatch(entity, entityId) {
127
+ const stored = await getData(entity, entityId);
128
+ if (!stored || stored.patches.length === 0) {
129
+ return null;
130
+ }
131
+ const lastPatch = stored.patches[stored.patches.length - 1];
132
+ return lastPatch ? lastPatch.patch : null;
133
+ },
134
+ async getPatchesSince(entity, entityId, sinceVersion) {
135
+ const stored = await getData(entity, entityId);
136
+ if (!stored) {
137
+ return sinceVersion === 0 ? [] : null;
138
+ }
139
+ if (sinceVersion >= stored.version) {
140
+ return [];
141
+ }
142
+ const relevantPatches = stored.patches.filter((p) => p.version > sinceVersion);
143
+ if (relevantPatches.length === 0) {
144
+ return null;
145
+ }
146
+ relevantPatches.sort((a, b) => a.version - b.version);
147
+ const firstPatch = relevantPatches[0];
148
+ if (!firstPatch || firstPatch.version !== sinceVersion + 1) {
149
+ return null;
150
+ }
151
+ for (let i = 1;i < relevantPatches.length; i++) {
152
+ const current = relevantPatches[i];
153
+ const previous = relevantPatches[i - 1];
154
+ if (!current || !previous || current.version !== previous.version + 1) {
155
+ return null;
156
+ }
157
+ }
158
+ return relevantPatches.map((p) => p.patch);
159
+ },
160
+ async has(entity, entityId) {
161
+ const key = makeKey(entity, entityId);
162
+ const count = await kv.exists(key);
163
+ return count > 0;
164
+ },
165
+ async delete(entity, entityId) {
166
+ const key = makeKey(entity, entityId);
167
+ await kv.del(key);
168
+ },
169
+ async clear() {
170
+ const keys = await kv.keys(`${prefix}:*`);
171
+ if (keys.length > 0) {
172
+ await kv.del(...keys);
173
+ }
174
+ }
175
+ };
176
+ }
177
+ export {
178
+ vercelKVStorage
179
+ };
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@sylphx/lens-storage-vercel-kv",
3
+ "version": "1.0.0",
4
+ "description": "Vercel KV storage adapter for Lens opLog plugin",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "build": "bunup",
16
+ "typecheck": "tsc --noEmit",
17
+ "test": "echo 'no tests yet'",
18
+ "prepack": "[ -d dist ] || bun run build"
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "src"
23
+ ],
24
+ "keywords": [
25
+ "lens",
26
+ "storage",
27
+ "vercel",
28
+ "kv",
29
+ "serverless",
30
+ "nextjs"
31
+ ],
32
+ "author": "SylphxAI",
33
+ "license": "MIT",
34
+ "dependencies": {
35
+ "@sylphx/lens-core": "^2.0.1",
36
+ "@sylphx/lens-server": "^2.0.1"
37
+ },
38
+ "peerDependencies": {
39
+ "@vercel/kv": ">=1.0.0"
40
+ },
41
+ "devDependencies": {
42
+ "@vercel/kv": "^3.0.0",
43
+ "typescript": "^5.9.3"
44
+ }
45
+ }
package/src/index.ts ADDED
@@ -0,0 +1,336 @@
1
+ /**
2
+ * @sylphx/lens-storage-vercel-kv
3
+ *
4
+ * Vercel KV storage adapter for Lens opLog plugin.
5
+ * Designed for Next.js and Vercel serverless functions.
6
+ *
7
+ * Features:
8
+ * - HTTP-based (no persistent connections)
9
+ * - Optimistic locking with retry on conflict
10
+ * - Automatic patch eviction
11
+ *
12
+ * @example
13
+ * ```typescript
14
+ * import { kv } from "@vercel/kv";
15
+ * import { vercelKVStorage } from "@sylphx/lens-storage-vercel-kv";
16
+ *
17
+ * const app = createApp({
18
+ * router,
19
+ * plugins: [opLog({
20
+ * storage: vercelKVStorage({ kv }),
21
+ * })],
22
+ * });
23
+ * ```
24
+ */
25
+
26
+ import type { PatchOperation } from "@sylphx/lens-core";
27
+ import {
28
+ DEFAULT_STORAGE_CONFIG,
29
+ type EmitResult,
30
+ type OpLogStorage,
31
+ type OpLogStorageConfig,
32
+ type StoredPatchEntry,
33
+ } from "@sylphx/lens-server";
34
+
35
+ /**
36
+ * Vercel KV client interface.
37
+ * Compatible with @vercel/kv.
38
+ */
39
+ export interface VercelKVClient {
40
+ get<T>(key: string): Promise<T | null>;
41
+ set(key: string, value: unknown, options?: { ex?: number }): Promise<unknown>;
42
+ del(...keys: string[]): Promise<number>;
43
+ keys(pattern: string): Promise<string[]>;
44
+ exists(...keys: string[]): Promise<number>;
45
+ }
46
+
47
+ /**
48
+ * Vercel KV storage options.
49
+ */
50
+ export interface VercelKVStorageOptions extends OpLogStorageConfig {
51
+ /**
52
+ * Vercel KV client instance.
53
+ *
54
+ * @example
55
+ * ```typescript
56
+ * import { kv } from "@vercel/kv";
57
+ * // or
58
+ * import { createClient } from "@vercel/kv";
59
+ * const kv = createClient({ url, token });
60
+ * ```
61
+ */
62
+ kv: VercelKVClient;
63
+
64
+ /**
65
+ * Key prefix for all stored data.
66
+ * @default "lens"
67
+ */
68
+ prefix?: string;
69
+
70
+ /**
71
+ * TTL for state data in seconds.
72
+ * Set to 0 for no expiration.
73
+ * @default 0 (no expiration)
74
+ */
75
+ stateTTL?: number;
76
+ }
77
+
78
+ /**
79
+ * Internal stored data structure.
80
+ */
81
+ interface StoredData {
82
+ data: Record<string, unknown>;
83
+ version: number;
84
+ updatedAt: number;
85
+ patches: StoredPatchEntry[];
86
+ }
87
+
88
+ /**
89
+ * Compute JSON Patch operations between two states.
90
+ */
91
+ function computePatch(
92
+ oldState: Record<string, unknown>,
93
+ newState: Record<string, unknown>,
94
+ ): PatchOperation[] {
95
+ const patch: PatchOperation[] = [];
96
+ const oldKeys = new Set(Object.keys(oldState));
97
+ const newKeys = new Set(Object.keys(newState));
98
+
99
+ for (const key of newKeys) {
100
+ const oldValue = oldState[key];
101
+ const newValue = newState[key];
102
+
103
+ if (!oldKeys.has(key)) {
104
+ patch.push({ op: "add", path: `/${key}`, value: newValue });
105
+ } else if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
106
+ patch.push({ op: "replace", path: `/${key}`, value: newValue });
107
+ }
108
+ }
109
+
110
+ for (const key of oldKeys) {
111
+ if (!newKeys.has(key)) {
112
+ patch.push({ op: "remove", path: `/${key}` });
113
+ }
114
+ }
115
+
116
+ return patch;
117
+ }
118
+
119
+ /**
120
+ * Create a Vercel KV storage adapter.
121
+ *
122
+ * Requires `@vercel/kv` as a peer dependency.
123
+ *
124
+ * Uses optimistic locking: if a concurrent write is detected,
125
+ * the operation is retried up to `maxRetries` times.
126
+ *
127
+ * @example
128
+ * ```typescript
129
+ * import { kv } from "@vercel/kv";
130
+ * import { vercelKVStorage } from "@sylphx/lens-storage-vercel-kv";
131
+ *
132
+ * const app = createApp({
133
+ * router,
134
+ * plugins: [opLog({
135
+ * storage: vercelKVStorage({ kv }),
136
+ * })],
137
+ * });
138
+ * ```
139
+ */
140
+ export function vercelKVStorage(options: VercelKVStorageOptions): OpLogStorage {
141
+ const { kv, prefix = "lens", stateTTL = 0 } = options;
142
+ const cfg = { ...DEFAULT_STORAGE_CONFIG, ...options };
143
+
144
+ function makeKey(entity: string, entityId: string): string {
145
+ return `${prefix}:${entity}:${entityId}`;
146
+ }
147
+
148
+ async function getData(entity: string, entityId: string): Promise<StoredData | null> {
149
+ const key = makeKey(entity, entityId);
150
+ const data = await kv.get<StoredData>(key);
151
+ return data;
152
+ }
153
+
154
+ async function setData(entity: string, entityId: string, data: StoredData): Promise<void> {
155
+ const key = makeKey(entity, entityId);
156
+ if (stateTTL > 0) {
157
+ await kv.set(key, data, { ex: stateTTL });
158
+ } else {
159
+ await kv.set(key, data);
160
+ }
161
+ }
162
+
163
+ function trimPatches(patches: StoredPatchEntry[], now: number): StoredPatchEntry[] {
164
+ const minTimestamp = now - cfg.maxPatchAge;
165
+ let filtered = patches.filter((p) => p.timestamp >= minTimestamp);
166
+
167
+ if (filtered.length > cfg.maxPatchesPerEntity) {
168
+ filtered = filtered.slice(-cfg.maxPatchesPerEntity);
169
+ }
170
+
171
+ return filtered;
172
+ }
173
+
174
+ /**
175
+ * Emit with optimistic locking.
176
+ * Retries on version conflict up to maxRetries times.
177
+ */
178
+ async function emitWithRetry(
179
+ entity: string,
180
+ entityId: string,
181
+ data: Record<string, unknown>,
182
+ retryCount = 0,
183
+ ): Promise<EmitResult> {
184
+ const now = Date.now();
185
+ const existing = await getData(entity, entityId);
186
+
187
+ if (!existing) {
188
+ const newData: StoredData = {
189
+ data: { ...data },
190
+ version: 1,
191
+ updatedAt: now,
192
+ patches: [],
193
+ };
194
+ await setData(entity, entityId, newData);
195
+
196
+ return {
197
+ version: 1,
198
+ patch: null,
199
+ changed: true,
200
+ };
201
+ }
202
+
203
+ const expectedVersion = existing.version;
204
+
205
+ const oldHash = JSON.stringify(existing.data);
206
+ const newHash = JSON.stringify(data);
207
+
208
+ if (oldHash === newHash) {
209
+ return {
210
+ version: existing.version,
211
+ patch: null,
212
+ changed: false,
213
+ };
214
+ }
215
+
216
+ const patch = computePatch(existing.data, data);
217
+ const newVersion = expectedVersion + 1;
218
+
219
+ let patches = [...existing.patches];
220
+ if (patch.length > 0) {
221
+ patches.push({
222
+ version: newVersion,
223
+ patch,
224
+ timestamp: now,
225
+ });
226
+ patches = trimPatches(patches, now);
227
+ }
228
+
229
+ const newData: StoredData = {
230
+ data: { ...data },
231
+ version: newVersion,
232
+ updatedAt: now,
233
+ patches,
234
+ };
235
+
236
+ await setData(entity, entityId, newData);
237
+
238
+ // Re-read to verify our write succeeded (optimistic check)
239
+ const verify = await getData(entity, entityId);
240
+ if (verify && verify.version !== newVersion) {
241
+ // Version conflict
242
+ if (retryCount < cfg.maxRetries) {
243
+ const delay = Math.min(10 * 2 ** retryCount, 100);
244
+ await new Promise((resolve) => setTimeout(resolve, delay));
245
+ return emitWithRetry(entity, entityId, data, retryCount + 1);
246
+ }
247
+ return {
248
+ version: verify.version,
249
+ patch: null,
250
+ changed: true,
251
+ };
252
+ }
253
+
254
+ return {
255
+ version: newVersion,
256
+ patch: patch.length > 0 ? patch : null,
257
+ changed: true,
258
+ };
259
+ }
260
+
261
+ return {
262
+ emit: (entity, entityId, data) => emitWithRetry(entity, entityId, data, 0),
263
+
264
+ async getState(entity, entityId): Promise<Record<string, unknown> | null> {
265
+ const stored = await getData(entity, entityId);
266
+ return stored ? { ...stored.data } : null;
267
+ },
268
+
269
+ async getVersion(entity, entityId): Promise<number> {
270
+ const stored = await getData(entity, entityId);
271
+ return stored?.version ?? 0;
272
+ },
273
+
274
+ async getLatestPatch(entity, entityId): Promise<PatchOperation[] | null> {
275
+ const stored = await getData(entity, entityId);
276
+ if (!stored || stored.patches.length === 0) {
277
+ return null;
278
+ }
279
+ const lastPatch = stored.patches[stored.patches.length - 1];
280
+ return lastPatch ? lastPatch.patch : null;
281
+ },
282
+
283
+ async getPatchesSince(entity, entityId, sinceVersion): Promise<PatchOperation[][] | null> {
284
+ const stored = await getData(entity, entityId);
285
+
286
+ if (!stored) {
287
+ return sinceVersion === 0 ? [] : null;
288
+ }
289
+
290
+ if (sinceVersion >= stored.version) {
291
+ return [];
292
+ }
293
+
294
+ const relevantPatches = stored.patches.filter((p) => p.version > sinceVersion);
295
+
296
+ if (relevantPatches.length === 0) {
297
+ return null;
298
+ }
299
+
300
+ relevantPatches.sort((a, b) => a.version - b.version);
301
+
302
+ const firstPatch = relevantPatches[0];
303
+ if (!firstPatch || firstPatch.version !== sinceVersion + 1) {
304
+ return null;
305
+ }
306
+
307
+ for (let i = 1; i < relevantPatches.length; i++) {
308
+ const current = relevantPatches[i];
309
+ const previous = relevantPatches[i - 1];
310
+ if (!current || !previous || current.version !== previous.version + 1) {
311
+ return null;
312
+ }
313
+ }
314
+
315
+ return relevantPatches.map((p) => p.patch);
316
+ },
317
+
318
+ async has(entity, entityId): Promise<boolean> {
319
+ const key = makeKey(entity, entityId);
320
+ const count = await kv.exists(key);
321
+ return count > 0;
322
+ },
323
+
324
+ async delete(entity, entityId): Promise<void> {
325
+ const key = makeKey(entity, entityId);
326
+ await kv.del(key);
327
+ },
328
+
329
+ async clear(): Promise<void> {
330
+ const keys = await kv.keys(`${prefix}:*`);
331
+ if (keys.length > 0) {
332
+ await kv.del(...keys);
333
+ }
334
+ },
335
+ };
336
+ }