@uploadista/kv-store-cloudflare-kv 0.0.3
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/.turbo/turbo-build.log +5 -0
- package/.turbo/turbo-check.log +5 -0
- package/LICENSE +21 -0
- package/README.md +582 -0
- package/dist/cloudflare-kv-store.d.ts +9 -0
- package/dist/cloudflare-kv-store.d.ts.map +1 -0
- package/dist/cloudflare-kv-store.js +42 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/package.json +29 -0
- package/src/cloudflare-kv-store.ts +70 -0
- package/src/index.ts +1 -0
- package/tsconfig.json +13 -0
- package/tsconfig.tsbuildinfo +1 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 uploadista
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
# @uploadista/kv-store-cloudflare-kv
|
|
2
|
+
|
|
3
|
+
Cloudflare KV-backed key-value store for Uploadista. Deploy globally distributed storage on Cloudflare's edge network with zero-downtime updates.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The Cloudflare KV store uses the `@cloudflare/workers-types` KVNamespace API for storing state on Cloudflare's edge network. Perfect for:
|
|
8
|
+
|
|
9
|
+
- **Edge Deployment**: Store data globally at 300+ edge locations
|
|
10
|
+
- **Serverless Uploads**: Run on Cloudflare Workers with no origin server
|
|
11
|
+
- **Zero Cold Starts**: Pre-loaded KV namespaces at each edge
|
|
12
|
+
- **Low Latency**: ~10ms read/write latency from any location
|
|
13
|
+
- **Distributed by Default**: No replication config needed
|
|
14
|
+
|
|
15
|
+
Ideal for Hono-based upload servers deployed to Cloudflare Workers.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install @uploadista/kv-store-cloudflare-kv
|
|
21
|
+
# or
|
|
22
|
+
pnpm add @uploadista/kv-store-cloudflare-kv
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### Prerequisites
|
|
26
|
+
|
|
27
|
+
- Cloudflare Workers project with `wrangler`
|
|
28
|
+
- Cloudflare account with Workers enabled
|
|
29
|
+
- KV namespace created via `wrangler kv:namespace`
|
|
30
|
+
|
|
31
|
+
## Quick Start
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
import { cloudflareKvStore } from "@uploadista/kv-store-cloudflare-kv";
|
|
35
|
+
import { Effect } from "effect";
|
|
36
|
+
|
|
37
|
+
// In Cloudflare Worker environment, KV is provided as a binding
|
|
38
|
+
export default {
|
|
39
|
+
fetch(request: Request, env: { KV_STORE: KVNamespace }) {
|
|
40
|
+
const program = Effect.gen(function* () {
|
|
41
|
+
// The cloudflareKvStore is automatically available
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return Effect.runPromise(
|
|
45
|
+
program.pipe(
|
|
46
|
+
Effect.provide(cloudflareKvStore({ kv: env.KV_STORE }))
|
|
47
|
+
)
|
|
48
|
+
);
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Features
|
|
54
|
+
|
|
55
|
+
- ✅ **Global Distribution**: Data replicated to 300+ edge locations
|
|
56
|
+
- ✅ **Low Latency**: Sub-second read/write operations
|
|
57
|
+
- ✅ **Serverless**: No origin server infrastructure needed
|
|
58
|
+
- ✅ **Auto-Scaling**: Handles traffic spikes automatically
|
|
59
|
+
- ✅ **Strong Consistency**: Atomic reads and writes
|
|
60
|
+
- ✅ **No Cold Starts**: Workers start in ~1ms
|
|
61
|
+
- ✅ **Type Safe**: Full TypeScript support
|
|
62
|
+
|
|
63
|
+
## API Reference
|
|
64
|
+
|
|
65
|
+
### Main Exports
|
|
66
|
+
|
|
67
|
+
#### `cloudflareKvStore(config: CloudflareKvStoreConfig): Layer<BaseKvStoreService>`
|
|
68
|
+
|
|
69
|
+
Creates an Effect layer providing the `BaseKvStoreService` backed by Cloudflare KV.
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
import { cloudflareKvStore } from "@uploadista/kv-store-cloudflare-kv";
|
|
73
|
+
|
|
74
|
+
export default {
|
|
75
|
+
fetch(request: Request, env: { KV_STORE: KVNamespace }) {
|
|
76
|
+
const layer = cloudflareKvStore({ kv: env.KV_STORE });
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**Configuration**:
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
type CloudflareKvStoreConfig = {
|
|
85
|
+
kv: KVNamespace<string>; // Cloudflare KV namespace binding
|
|
86
|
+
};
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
#### `makeCloudflareBaseKvStore(config: CloudflareKvStoreConfig): BaseKvStore`
|
|
90
|
+
|
|
91
|
+
Factory function for creating a KV store with an existing KVNamespace.
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
import { makeCloudflareBaseKvStore } from "@uploadista/kv-store-cloudflare-kv";
|
|
95
|
+
|
|
96
|
+
const store = makeCloudflareBaseKvStore({ kv: env.KV_STORE });
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Available Operations
|
|
100
|
+
|
|
101
|
+
The Cloudflare KV store implements the `BaseKvStore` interface:
|
|
102
|
+
|
|
103
|
+
#### `get(key: string): Effect<string | null>`
|
|
104
|
+
|
|
105
|
+
Retrieve a value by key.
|
|
106
|
+
|
|
107
|
+
```typescript
|
|
108
|
+
const program = Effect.gen(function* () {
|
|
109
|
+
const value = yield* store.get("upload:abc123");
|
|
110
|
+
// Returns immediately from nearest edge location
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
#### `set(key: string, value: string): Effect<void>`
|
|
115
|
+
|
|
116
|
+
Store a string value globally.
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
const program = Effect.gen(function* () {
|
|
120
|
+
yield* store.set("upload:abc123", JSON.stringify(metadata));
|
|
121
|
+
// Replicated to all edge locations within seconds
|
|
122
|
+
});
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
#### `delete(key: string): Effect<void>`
|
|
126
|
+
|
|
127
|
+
Remove a key globally.
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
const program = Effect.gen(function* () {
|
|
131
|
+
yield* store.delete("upload:abc123");
|
|
132
|
+
});
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
#### `list(keyPrefix: string): Effect<string[]>`
|
|
136
|
+
|
|
137
|
+
List keys matching a prefix.
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
const program = Effect.gen(function* () {
|
|
141
|
+
const keys = yield* store.list("upload:");
|
|
142
|
+
// Returns matching keys
|
|
143
|
+
});
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Configuration
|
|
147
|
+
|
|
148
|
+
### Basic Setup in wrangler.toml
|
|
149
|
+
|
|
150
|
+
```toml
|
|
151
|
+
name = "uploadista-worker"
|
|
152
|
+
type = "javascript"
|
|
153
|
+
|
|
154
|
+
[env.production]
|
|
155
|
+
kv_namespaces = [
|
|
156
|
+
{ binding = "KV_STORE", id = "abc123def456", preview_id = "preview789" }
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
[env.staging]
|
|
160
|
+
kv_namespaces = [
|
|
161
|
+
{ binding = "KV_STORE", id = "staging-id", preview_id = "staging-preview" }
|
|
162
|
+
]
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Worker Environment Setup
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
export interface Env {
|
|
169
|
+
KV_STORE: KVNamespace<string>;
|
|
170
|
+
ENVIRONMENT: "production" | "staging" | "development";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export default {
|
|
174
|
+
async fetch(request: Request, env: Env) {
|
|
175
|
+
const program = Effect.gen(function* () {
|
|
176
|
+
// Use env.KV_STORE
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
return Effect.runPromise(
|
|
180
|
+
program.pipe(
|
|
181
|
+
Effect.provide(cloudflareKvStore({ kv: env.KV_STORE }))
|
|
182
|
+
)
|
|
183
|
+
);
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Environment-Specific Configuration
|
|
189
|
+
|
|
190
|
+
```typescript
|
|
191
|
+
export default {
|
|
192
|
+
async fetch(request: Request, env: Env) {
|
|
193
|
+
const isProduction = env.ENVIRONMENT === "production";
|
|
194
|
+
|
|
195
|
+
const program = Effect.gen(function* () {
|
|
196
|
+
// Use KV with environment awareness
|
|
197
|
+
const cacheKey = isProduction ? "cache:prod" : "cache:dev";
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
return Effect.runPromise(
|
|
201
|
+
program.pipe(
|
|
202
|
+
Effect.provide(cloudflareKvStore({ kv: env.KV_STORE }))
|
|
203
|
+
)
|
|
204
|
+
);
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Examples
|
|
210
|
+
|
|
211
|
+
### Example 1: Cloudflare Workers Upload Server
|
|
212
|
+
|
|
213
|
+
Complete serverless upload handler:
|
|
214
|
+
|
|
215
|
+
```typescript
|
|
216
|
+
import { cloudflareKvStore } from "@uploadista/kv-store-cloudflare-kv";
|
|
217
|
+
import { uploadServer } from "@uploadista/server";
|
|
218
|
+
import { Effect } from "effect";
|
|
219
|
+
|
|
220
|
+
export interface Env {
|
|
221
|
+
KV_STORE: KVNamespace;
|
|
222
|
+
BUCKET: R2Bucket; // Cloudflare R2 for file storage
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export default {
|
|
226
|
+
async fetch(request: Request, env: Env) {
|
|
227
|
+
const url = new URL(request.url);
|
|
228
|
+
|
|
229
|
+
if (url.pathname === "/api/upload" && request.method === "POST") {
|
|
230
|
+
const program = Effect.gen(function* () {
|
|
231
|
+
const server = yield* uploadServer;
|
|
232
|
+
|
|
233
|
+
// Create upload session
|
|
234
|
+
const upload = yield* server.createUpload(
|
|
235
|
+
{
|
|
236
|
+
filename: "user-file.pdf",
|
|
237
|
+
size: 5242880,
|
|
238
|
+
mimeType: "application/pdf",
|
|
239
|
+
},
|
|
240
|
+
"client:123"
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
return new Response(
|
|
244
|
+
JSON.stringify({
|
|
245
|
+
uploadId: upload.id,
|
|
246
|
+
chunkSize: 1048576,
|
|
247
|
+
})
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
return Effect.runPromise(
|
|
252
|
+
program.pipe(
|
|
253
|
+
Effect.provide(cloudflareKvStore({ kv: env.KV_STORE }))
|
|
254
|
+
)
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return new Response("Not found", { status: 404 });
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### Example 2: Request Deduplication
|
|
264
|
+
|
|
265
|
+
Use KV to prevent duplicate uploads:
|
|
266
|
+
|
|
267
|
+
```typescript
|
|
268
|
+
import { cloudflareKvStore } from "@uploadista/kv-store-cloudflare-kv";
|
|
269
|
+
import { Effect } from "effect";
|
|
270
|
+
|
|
271
|
+
const dedupKey = (clientId: string, checksum: string) =>
|
|
272
|
+
`dedup:${clientId}:${checksum}`;
|
|
273
|
+
|
|
274
|
+
const checkDedup = (kv: KVNamespace, clientId: string, checksum: string) =>
|
|
275
|
+
Effect.gen(function* () {
|
|
276
|
+
const key = dedupKey(clientId, checksum);
|
|
277
|
+
const existing = yield* Effect.tryPromise({
|
|
278
|
+
try: () => kv.get(key),
|
|
279
|
+
catch: () => null,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
if (existing) {
|
|
283
|
+
return JSON.parse(existing); // Return existing upload
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return null; // New upload
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const recordDedup = (
|
|
290
|
+
kv: KVNamespace,
|
|
291
|
+
clientId: string,
|
|
292
|
+
checksum: string,
|
|
293
|
+
uploadId: string
|
|
294
|
+
) =>
|
|
295
|
+
Effect.gen(function* () {
|
|
296
|
+
const key = dedupKey(clientId, checksum);
|
|
297
|
+
yield* Effect.tryPromise({
|
|
298
|
+
try: () =>
|
|
299
|
+
kv.put(key, JSON.stringify({ uploadId }), {
|
|
300
|
+
expirationTtl: 86400, // 24 hours
|
|
301
|
+
}),
|
|
302
|
+
catch: () => null,
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// Usage in worker
|
|
307
|
+
export default {
|
|
308
|
+
async fetch(request: Request, env: Env) {
|
|
309
|
+
const existing = yield* checkDedup(env.KV_STORE, clientId, checksum);
|
|
310
|
+
|
|
311
|
+
if (existing) {
|
|
312
|
+
return new Response(JSON.stringify(existing));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Create new upload...
|
|
316
|
+
yield* recordDedup(env.KV_STORE, clientId, checksum, uploadId);
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
### Example 3: Cache-Aside Pattern
|
|
322
|
+
|
|
323
|
+
Use KV for upload metadata caching:
|
|
324
|
+
|
|
325
|
+
```typescript
|
|
326
|
+
import { cloudflareKvStore } from "@uploadista/kv-store-cloudflare-kv";
|
|
327
|
+
import { Effect } from "effect";
|
|
328
|
+
|
|
329
|
+
const getUploadMetadata = (
|
|
330
|
+
kv: KVNamespace,
|
|
331
|
+
uploadId: string
|
|
332
|
+
) =>
|
|
333
|
+
Effect.gen(function* () {
|
|
334
|
+
// Check cache first
|
|
335
|
+
const cached = yield* Effect.tryPromise({
|
|
336
|
+
try: () => kv.get(`meta:${uploadId}`),
|
|
337
|
+
catch: () => null,
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
if (cached) {
|
|
341
|
+
return JSON.parse(cached);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Fetch from source (e.g., database)
|
|
345
|
+
const metadata = yield* fetchFromDatabase(uploadId);
|
|
346
|
+
|
|
347
|
+
// Cache result
|
|
348
|
+
yield* Effect.tryPromise({
|
|
349
|
+
try: () =>
|
|
350
|
+
kv.put(`meta:${uploadId}`, JSON.stringify(metadata), {
|
|
351
|
+
expirationTtl: 3600, // 1 hour
|
|
352
|
+
}),
|
|
353
|
+
catch: () => null,
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
return metadata;
|
|
357
|
+
});
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
## Performance Characteristics
|
|
361
|
+
|
|
362
|
+
| Operation | Latency | Global Sync |
|
|
363
|
+
|-----------|---------|-------------|
|
|
364
|
+
| get() | ~10ms | Immediate |
|
|
365
|
+
| set() | ~50ms | ~30 seconds |
|
|
366
|
+
| delete() | ~50ms | ~30 seconds |
|
|
367
|
+
| list() | ~100ms | N/A (edge) |
|
|
368
|
+
|
|
369
|
+
All operations are served from the nearest edge location to the client.
|
|
370
|
+
|
|
371
|
+
## Limits & Quotas
|
|
372
|
+
|
|
373
|
+
| Limit | Value |
|
|
374
|
+
|-------|-------|
|
|
375
|
+
| Key Size | 512 bytes max |
|
|
376
|
+
| Value Size | 25 MB max |
|
|
377
|
+
| Namespace Storage | Unlimited (Capped at billing) |
|
|
378
|
+
| Read/Write Rate | 10,000 ops/sec per namespace |
|
|
379
|
+
| API Requests | Included in Workers plan |
|
|
380
|
+
|
|
381
|
+
For most upload use cases, these limits are more than sufficient.
|
|
382
|
+
|
|
383
|
+
## Best Practices
|
|
384
|
+
|
|
385
|
+
### 1. Use Meaningful Key Prefixes
|
|
386
|
+
|
|
387
|
+
```typescript
|
|
388
|
+
// Good: Hierarchical, searchable
|
|
389
|
+
"upload:abc123"
|
|
390
|
+
"upload:abc123:chunk:0"
|
|
391
|
+
"session:user:xyz"
|
|
392
|
+
|
|
393
|
+
// Avoid: Unclear prefixes
|
|
394
|
+
"data1", "tmp", "x"
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
### 2. Set Expiration on Temporary Data
|
|
398
|
+
|
|
399
|
+
```typescript
|
|
400
|
+
// Temporary upload sessions expire after 24 hours
|
|
401
|
+
kv.put("session:user123", sessionData, {
|
|
402
|
+
expirationTtl: 86400, // seconds
|
|
403
|
+
});
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
### 3. Handle Eventual Consistency
|
|
407
|
+
|
|
408
|
+
```typescript
|
|
409
|
+
// Wait for global replication
|
|
410
|
+
const isReplicated = yield* Effect.gen(function* () {
|
|
411
|
+
const retries = 3;
|
|
412
|
+
for (let i = 0; i < retries; i++) {
|
|
413
|
+
const value = yield* store.get(key);
|
|
414
|
+
if (value === expectedValue) {
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
yield* Effect.sleep("100 millis");
|
|
418
|
+
}
|
|
419
|
+
return false;
|
|
420
|
+
});
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
### 4. Batch Operations Efficiently
|
|
424
|
+
|
|
425
|
+
```typescript
|
|
426
|
+
// Instead of individual writes
|
|
427
|
+
for (const item of items) {
|
|
428
|
+
yield* store.set(`item:${item.id}`, JSON.stringify(item));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Better: Batch at application level
|
|
432
|
+
// Or use metadata object
|
|
433
|
+
const batch = items.reduce(
|
|
434
|
+
(acc, item) => ({ ...acc, [`item:${item.id}`]: item }),
|
|
435
|
+
{}
|
|
436
|
+
);
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
## Deployment
|
|
440
|
+
|
|
441
|
+
### Local Development
|
|
442
|
+
|
|
443
|
+
```bash
|
|
444
|
+
# Create local KV namespace
|
|
445
|
+
wrangler kv:namespace create "KV_STORE" --preview
|
|
446
|
+
|
|
447
|
+
# Run locally
|
|
448
|
+
wrangler dev
|
|
449
|
+
|
|
450
|
+
# Access KV from http://localhost:8787
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
### Production Deployment
|
|
454
|
+
|
|
455
|
+
```bash
|
|
456
|
+
# Create production KV namespace
|
|
457
|
+
wrangler kv:namespace create "KV_STORE"
|
|
458
|
+
|
|
459
|
+
# Deploy to Cloudflare
|
|
460
|
+
wrangler publish
|
|
461
|
+
|
|
462
|
+
# Deploy to specific environment
|
|
463
|
+
wrangler publish --env production
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
### GitHub Actions
|
|
467
|
+
|
|
468
|
+
```yaml
|
|
469
|
+
name: Deploy
|
|
470
|
+
|
|
471
|
+
on:
|
|
472
|
+
push:
|
|
473
|
+
branches: [main]
|
|
474
|
+
|
|
475
|
+
jobs:
|
|
476
|
+
deploy:
|
|
477
|
+
runs-on: ubuntu-latest
|
|
478
|
+
steps:
|
|
479
|
+
- uses: actions/checkout@v3
|
|
480
|
+
- uses: actions/setup-node@v3
|
|
481
|
+
with:
|
|
482
|
+
node-version: "18"
|
|
483
|
+
- run: npm ci
|
|
484
|
+
- run: npm run build
|
|
485
|
+
- uses: cloudflare/wrangler-action@v3
|
|
486
|
+
with:
|
|
487
|
+
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
## Integration with Other Services
|
|
491
|
+
|
|
492
|
+
### Cloudflare R2 (File Storage)
|
|
493
|
+
|
|
494
|
+
```typescript
|
|
495
|
+
export interface Env {
|
|
496
|
+
KV_STORE: KVNamespace;
|
|
497
|
+
R2_BUCKET: R2Bucket;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export default {
|
|
501
|
+
async fetch(request: Request, env: Env) {
|
|
502
|
+
// Use KV for metadata, R2 for files
|
|
503
|
+
yield* store.set("file:abc", JSON.stringify({ bucket: "uploads", key: "abc.pdf" }));
|
|
504
|
+
await env.R2_BUCKET.put("uploads/abc.pdf", file);
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
### Cloudflare D1 (Database)
|
|
510
|
+
|
|
511
|
+
```typescript
|
|
512
|
+
export interface Env {
|
|
513
|
+
KV_STORE: KVNamespace;
|
|
514
|
+
DB: D1Database;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Use KV for cache, D1 for persistent queries
|
|
518
|
+
const metadata = yield* getCachedMetadata(env.KV_STORE, id);
|
|
519
|
+
if (!metadata) {
|
|
520
|
+
const result = await env.DB.prepare("SELECT * FROM uploads WHERE id = ?").bind(id).first();
|
|
521
|
+
yield* store.set(`meta:${id}`, JSON.stringify(result));
|
|
522
|
+
}
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
## Related Packages
|
|
526
|
+
|
|
527
|
+
- [@uploadista/core](../../core) - Core types
|
|
528
|
+
- [@uploadista/adapters-hono](../../servers/adapters-hono) - Hono adapter for Cloudflare
|
|
529
|
+
- [@uploadista/data-store-s3](../../data-stores/s3) - For file storage on R2
|
|
530
|
+
- [@uploadista/server](../../servers/server) - Upload server
|
|
531
|
+
- [@uploadista/kv-store-cloudflare-do](../cloudflare-do) - Durable Objects for real-time state
|
|
532
|
+
|
|
533
|
+
## Troubleshooting
|
|
534
|
+
|
|
535
|
+
### "KV_STORE not found" Error
|
|
536
|
+
|
|
537
|
+
Ensure namespace is defined in `wrangler.toml`:
|
|
538
|
+
|
|
539
|
+
```toml
|
|
540
|
+
kv_namespaces = [
|
|
541
|
+
{ binding = "KV_STORE", id = "your-namespace-id" }
|
|
542
|
+
]
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
Then run `wrangler dev` to load bindings.
|
|
546
|
+
|
|
547
|
+
### Data Not Visible Across Regions
|
|
548
|
+
|
|
549
|
+
KV replication takes 30-60 seconds globally. For immediate consistency:
|
|
550
|
+
|
|
551
|
+
```typescript
|
|
552
|
+
// Read from same origin temporarily
|
|
553
|
+
const data = await kv.get(key, { cacheTtl: 0 });
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
### Memory/Size Limits
|
|
557
|
+
|
|
558
|
+
If hitting 25MB limit for single key:
|
|
559
|
+
|
|
560
|
+
1. Split data: `"upload:123:part:0"`, `"upload:123:part:1"`
|
|
561
|
+
2. Use R2 for actual files, KV only for metadata
|
|
562
|
+
3. Implement cleanup: set `expirationTtl` on temporary data
|
|
563
|
+
|
|
564
|
+
### Rate Limiting (10k ops/sec)
|
|
565
|
+
|
|
566
|
+
If hitting rate limit:
|
|
567
|
+
|
|
568
|
+
1. Implement batching on client
|
|
569
|
+
2. Use longer cache TTL
|
|
570
|
+
3. Request quota increase from Cloudflare
|
|
571
|
+
|
|
572
|
+
## License
|
|
573
|
+
|
|
574
|
+
See [LICENSE](../../../LICENSE) in the main repository.
|
|
575
|
+
|
|
576
|
+
## See Also
|
|
577
|
+
|
|
578
|
+
- [KV Stores Comparison Guide](../KV_STORES_COMPARISON.md) - Compare with other stores
|
|
579
|
+
- [Cloudflare Workers Documentation](https://developers.cloudflare.com/workers/) - Official docs
|
|
580
|
+
- [Cloudflare KV API Reference](https://developers.cloudflare.com/workers/runtime-apis/kv/) - KV API
|
|
581
|
+
- [Server Setup with Cloudflare](../../../SERVER_SETUP.md#cloudflare-workers) - Server deployment
|
|
582
|
+
- [@uploadista/adapters-hono](../../servers/adapters-hono) - Hono integration example
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { KVNamespace } from "@cloudflare/workers-types";
|
|
2
|
+
import { type BaseKvStore, BaseKvStoreService } from "@uploadista/core/types";
|
|
3
|
+
import { Layer } from "effect";
|
|
4
|
+
export type CloudflareKvStoreConfig = {
|
|
5
|
+
kv: KVNamespace<string>;
|
|
6
|
+
};
|
|
7
|
+
export declare function makeCloudflareBaseKvStore({ kv, }: CloudflareKvStoreConfig): BaseKvStore;
|
|
8
|
+
export declare const cloudflareKvStore: (config: CloudflareKvStoreConfig) => Layer.Layer<BaseKvStoreService, never, never>;
|
|
9
|
+
//# sourceMappingURL=cloudflare-kv-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cloudflare-kv-store.d.ts","sourceRoot":"","sources":["../src/cloudflare-kv-store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,WAAW,EAEZ,MAAM,2BAA2B,CAAC;AAGnC,OAAO,EAAE,KAAK,WAAW,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AAC9E,OAAO,EAAU,KAAK,EAAE,MAAM,QAAQ,CAAC;AAEvC,MAAM,MAAM,uBAAuB,GAAG;IACpC,EAAE,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;CACzB,CAAC;AAGF,wBAAgB,yBAAyB,CAAC,EACxC,EAAE,GACH,EAAE,uBAAuB,GAAG,WAAW,CAiDvC;AAGD,eAAO,MAAM,iBAAiB,GAAI,QAAQ,uBAAuB,kDACK,CAAC"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { UploadistaError } from "@uploadista/core/errors";
|
|
2
|
+
import { BaseKvStoreService } from "@uploadista/core/types";
|
|
3
|
+
import { Effect, Layer } from "effect";
|
|
4
|
+
// Base CloudFlare KV store that stores raw strings
|
|
5
|
+
export function makeCloudflareBaseKvStore({ kv, }) {
|
|
6
|
+
return {
|
|
7
|
+
get: (key) => Effect.tryPromise({
|
|
8
|
+
try: () => kv.get(key),
|
|
9
|
+
catch: (cause) => UploadistaError.fromCode("UNKNOWN_ERROR", { cause }),
|
|
10
|
+
}),
|
|
11
|
+
set: (key, value) => Effect.tryPromise({
|
|
12
|
+
try: () => kv.put(key, value),
|
|
13
|
+
catch: (cause) => UploadistaError.fromCode("UNKNOWN_ERROR", { cause }),
|
|
14
|
+
}).pipe(Effect.asVoid),
|
|
15
|
+
delete: (key) => Effect.tryPromise({
|
|
16
|
+
try: () => kv.delete(key),
|
|
17
|
+
catch: (cause) => UploadistaError.fromCode("UNKNOWN_ERROR", { cause }),
|
|
18
|
+
}).pipe(Effect.asVoid),
|
|
19
|
+
list: (keyPrefix) => Effect.gen(function* (_) {
|
|
20
|
+
const keys = new Set();
|
|
21
|
+
let cursor = null;
|
|
22
|
+
do {
|
|
23
|
+
const result = yield* _(Effect.tryPromise({
|
|
24
|
+
try: () => kv.list({
|
|
25
|
+
prefix: keyPrefix,
|
|
26
|
+
limit: 20,
|
|
27
|
+
cursor,
|
|
28
|
+
}),
|
|
29
|
+
catch: (cause) => UploadistaError.fromCode("UNKNOWN_ERROR", { cause }),
|
|
30
|
+
}));
|
|
31
|
+
cursor = result.list_complete ? null : result.cursor;
|
|
32
|
+
for (const key of result.keys) {
|
|
33
|
+
const unprefixedKey = key.name.replace(keyPrefix, "");
|
|
34
|
+
keys.add(unprefixedKey);
|
|
35
|
+
}
|
|
36
|
+
} while (cursor);
|
|
37
|
+
return Array.from(keys);
|
|
38
|
+
}),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
// Base store layer
|
|
42
|
+
export const cloudflareKvStore = (config) => Layer.succeed(BaseKvStoreService, makeCloudflareBaseKvStore(config));
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,uBAAuB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./cloudflare-kv-store";
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@uploadista/kv-store-cloudflare-kv",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.0.3",
|
|
5
|
+
"description": "Cloudflare KV store for Uploadista",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Uploadista",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@cloudflare/workers-types": "4.20251011.0",
|
|
17
|
+
"effect": "3.18.4",
|
|
18
|
+
"@uploadista/core": "0.0.3"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@uploadista/typescript-config": "0.0.3"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc -b",
|
|
25
|
+
"format": "biome format --write ./src",
|
|
26
|
+
"lint": "biome lint --write ./src",
|
|
27
|
+
"check": "biome check --write ./src"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
KVNamespace,
|
|
3
|
+
KVNamespaceListResult,
|
|
4
|
+
} from "@cloudflare/workers-types";
|
|
5
|
+
import { UploadistaError } from "@uploadista/core/errors";
|
|
6
|
+
|
|
7
|
+
import { type BaseKvStore, BaseKvStoreService } from "@uploadista/core/types";
|
|
8
|
+
import { Effect, Layer } from "effect";
|
|
9
|
+
|
|
10
|
+
export type CloudflareKvStoreConfig = {
|
|
11
|
+
kv: KVNamespace<string>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Base CloudFlare KV store that stores raw strings
|
|
15
|
+
export function makeCloudflareBaseKvStore({
|
|
16
|
+
kv,
|
|
17
|
+
}: CloudflareKvStoreConfig): BaseKvStore {
|
|
18
|
+
return {
|
|
19
|
+
get: (key: string) =>
|
|
20
|
+
Effect.tryPromise({
|
|
21
|
+
try: () => kv.get(key),
|
|
22
|
+
catch: (cause) => UploadistaError.fromCode("UNKNOWN_ERROR", { cause }),
|
|
23
|
+
}),
|
|
24
|
+
|
|
25
|
+
set: (key: string, value: string) =>
|
|
26
|
+
Effect.tryPromise({
|
|
27
|
+
try: () => kv.put(key, value),
|
|
28
|
+
catch: (cause) => UploadistaError.fromCode("UNKNOWN_ERROR", { cause }),
|
|
29
|
+
}).pipe(Effect.asVoid),
|
|
30
|
+
|
|
31
|
+
delete: (key: string) =>
|
|
32
|
+
Effect.tryPromise({
|
|
33
|
+
try: () => kv.delete(key),
|
|
34
|
+
catch: (cause) => UploadistaError.fromCode("UNKNOWN_ERROR", { cause }),
|
|
35
|
+
}).pipe(Effect.asVoid),
|
|
36
|
+
|
|
37
|
+
list: (keyPrefix: string) =>
|
|
38
|
+
Effect.gen(function* (_) {
|
|
39
|
+
const keys = new Set<string>();
|
|
40
|
+
let cursor: string | null = null;
|
|
41
|
+
|
|
42
|
+
do {
|
|
43
|
+
const result: KVNamespaceListResult<unknown, string> = yield* _(
|
|
44
|
+
Effect.tryPromise({
|
|
45
|
+
try: () =>
|
|
46
|
+
kv.list({
|
|
47
|
+
prefix: keyPrefix,
|
|
48
|
+
limit: 20,
|
|
49
|
+
cursor,
|
|
50
|
+
}),
|
|
51
|
+
catch: (cause) =>
|
|
52
|
+
UploadistaError.fromCode("UNKNOWN_ERROR", { cause }),
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
cursor = result.list_complete ? null : result.cursor;
|
|
57
|
+
for (const key of result.keys) {
|
|
58
|
+
const unprefixedKey = key.name.replace(keyPrefix, "");
|
|
59
|
+
keys.add(unprefixedKey);
|
|
60
|
+
}
|
|
61
|
+
} while (cursor);
|
|
62
|
+
|
|
63
|
+
return Array.from(keys);
|
|
64
|
+
}),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Base store layer
|
|
69
|
+
export const cloudflareKvStore = (config: CloudflareKvStoreConfig) =>
|
|
70
|
+
Layer.succeed(BaseKvStoreService, makeCloudflareBaseKvStore(config));
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./cloudflare-kv-store";
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "@uploadista/typescript-config/server.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"baseUrl": "./",
|
|
5
|
+
"paths": {
|
|
6
|
+
"@/*": ["./src/*"]
|
|
7
|
+
},
|
|
8
|
+
"outDir": "./dist",
|
|
9
|
+
"rootDir": "./src",
|
|
10
|
+
"typeRoots": ["../../../../node_modules/@types"]
|
|
11
|
+
},
|
|
12
|
+
"include": ["src"]
|
|
13
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"root":["./src/cloudflare-kv-store.ts","./src/index.ts"],"version":"5.9.3"}
|