jotdb 0.1.5 → 0.1.7

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.
Files changed (3) hide show
  1. package/README.md +105 -27
  2. package/package.json +3 -2
  3. package/src/index.ts +186 -30
package/README.md CHANGED
@@ -1,16 +1,56 @@
1
1
  # JotDB
2
2
 
3
- A lightweight, schema-less database built on Cloudflare Durable Objects. Perfect for quick prototyping and applications that need simple data storage without the complexity of traditional databases.
3
+ [![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/acoyfellow/jotdb)
4
+
5
+ A lightweight, schema-less database built on Cloudflare Durable Objects. Think of it as Firestore's security rules, but with Zod validation built-in. Perfect for both internal and external APIs, with automatic type safety and validation.
6
+
7
+ > **Cloudflare Products**: JotDB works with any Cloudflare product that supports Durable Objects:
8
+ > - Cloudflare Workers
9
+ > - Cloudflare Pages (with Functions)
10
+ > - Cloudflare Workflows
11
+ > - Cloudflare Queues
12
+ > - Cloudflare Cron Triggers
4
13
 
5
14
  ## Why JotDB?
6
15
 
7
- I needed a quick way to save data without dealing with schemas, SQL, or complex database setup. While Firestore is great, it can be overkill for simple use cases. JotDB provides a simpler alternative by leveraging Cloudflare Durable Objects, making it perfect for:
16
+ JotDB combines the best of both worlds: the simplicity of NoSQL with the safety of schema validation. Here's what makes it special:
17
+
18
+ - **Built-in Type Safety**: Automatic Zod validation ensures your data is always in the right shape
19
+ - **Edge-Native**: Runs directly on Cloudflare's edge network, with sub-millisecond latency
20
+ - **RPC-First**: Direct method calls instead of HTTP endpoints (though you can easily wrap it in HTTP)
21
+ - **Durable Storage**: Built on Durable Objects for reliable, consistent storage
22
+ - **Zero Setup**: No database configuration, no connection strings, just instantiate and go
23
+ - **Perfect for APIs**: Use it as an internal database or wrap it with auth for external APIs
24
+ - **Real-time Ready**: Durable Objects provide strong consistency guarantees
25
+
26
+ Perfect for:
27
+ - Quick prototypes that need data validation
28
+ - Small to medium applications that need reliable storage
29
+ - Serverless environments where you want type safety
30
+ - Real-time data storage with strong consistency
31
+ - Collaborative applications that need data validation
32
+ - APIs that need both flexibility and safety
33
+
34
+ ## Design Patterns
8
35
 
9
- - Quick prototypes
10
- - Small to medium applications
11
- - Serverless environments
12
- - Real-time data storage
13
- - Collaborative applications
36
+ JotDB uses Cloudflare Durable Objects under the hood, which means you can organize your data in several ways:
37
+
38
+ 1. **Global Store**: Use a single instance for your entire application
39
+ ```typescript
40
+ const db = env.JOTDB.get(env.JOTDB.idFromName("global"));
41
+ ```
42
+
43
+ 2. **Per-User Store**: Create a separate instance for each user
44
+ ```typescript
45
+ const userDb = env.JOTDB.get(env.JOTDB.idFromName(`user:${userId}`));
46
+ ```
47
+
48
+ 3. **Per-Event Store**: Create temporary stores for events or sessions
49
+ ```typescript
50
+ const eventDb = env.JOTDB.get(env.JOTDB.idFromName(`event:${eventId}`));
51
+ ```
52
+
53
+ Each instance is isolated and can have its own schema and options. This follows the Actor Model pattern, where each instance is an independent actor that manages its own state.
14
54
 
15
55
  ## Installation
16
56
 
@@ -25,24 +65,47 @@ yarn add jotdb
25
65
  pnpm add jotdb
26
66
  ```
27
67
 
68
+ ### Configure wrangler.jsonc
69
+
70
+ ```jsonc
71
+ {
72
+ "durable_objects": {
73
+ "bindings": [
74
+ {
75
+ "name": "JOTDB",
76
+ "class_name": "JotDB"
77
+ }
78
+ ]
79
+ }
80
+ }
81
+ ```
82
+
28
83
  ## Full Example
29
84
 
30
85
  ```typescript
31
86
  import { JotDB } from 'jotdb';
32
87
 
33
- // Initialize the database
34
- const jotId = env.JotDB.idFromName("my-db");
35
- const db = env.JotDB.get(jotId);
36
-
37
- // Set a value
38
- await db.set("user:123", { name: "John", age: 30 });
39
-
40
- // Get a value
41
- const user = await db.get("user:123");
42
- console.log(user); // { name: "John", age: 30 }
88
+ export interface Env {
89
+ JOTDB: DurableObjectNamespace;
90
+ }
43
91
 
44
- // Delete a value
45
- await db.delete("user:123");
92
+ export default {
93
+ async fetch(request: Request, env: Env) {
94
+ // Initialize the database
95
+ const jotId = env.JOTDB.idFromName("my-db");
96
+ const db = env.JOTDB.get(jotId);
97
+
98
+ // Example operations
99
+ await db.set("user:123", { name: "John", age: 30 });
100
+ const user = await db.get("user:123");
101
+ await db.delete("user:123");
102
+
103
+ // Return the result
104
+ return new Response(JSON.stringify({ user }), {
105
+ headers: { 'Content-Type': 'application/json' }
106
+ });
107
+ }
108
+ };
46
109
  ```
47
110
 
48
111
  ## API Reference
@@ -52,19 +115,34 @@ await db.delete("user:123");
52
115
  | `set(key, value)` | Store a value | `key: string`, `value: any` | `Promise<void>` |
53
116
  | `get(key)` | Retrieve a value | `key: string` | `Promise<any>` |
54
117
  | `delete(key)` | Remove a value | `key: string` | `Promise<void>` |
55
- | `list(prefix?)` | List all keys (optionally filtered by prefix) | `prefix?: string` | `Promise<string[]>` |
56
-
57
- ## Types
118
+ | `clear()` | Remove all values | none | `Promise<void>` |
119
+ | `keys()` | Get all keys | none | `Promise<string[]>` |
120
+ | `has(key)` | Check if key exists | `key: string` | `Promise<boolean>` |
121
+ | `getAll()` | Get all data | none | `Promise<Record<string, unknown> \| unknown[]>` |
122
+ | `setAll(objOrArr)` | Set all data at once | `objOrArr: Record<string, unknown> \| unknown[]` | `Promise<void>` |
123
+ | `push(item)` | Add item to array | `item: unknown` | `Promise<void>` |
124
+ | `getSchema()` | Get current schema | none | `Promise<SchemaDefinition>` |
125
+ | `setSchema(schema)` | Set data schema | `schema: SchemaDefinition` | `Promise<void>` |
126
+ | `getOptions()` | Get current options | none | `Promise<JotDBOptions>` |
127
+ | `setOptions(opts)` | Set database options | `opts: Partial<JotDBOptions>` | `Promise<void>` |
128
+ | `getAuditLog()` | Get audit log entries | none | `Promise<AuditLogEntry[]>` |
129
+ | `clearAuditLog()` | Clear audit log | none | `Promise<void>` |
130
+
131
+ ### Options
58
132
 
59
133
  ```typescript
60
- interface JotDB {
61
- set(key: string, value: any): Promise<void>;
62
- get(key: string): Promise<any>;
63
- delete(key: string): Promise<void>;
64
- list(prefix?: string): Promise<string[]>;
134
+ interface JotDBOptions {
135
+ autoStrip: boolean; // Automatically strip unknown fields
136
+ readOnly: boolean; // Enable read-only mode
65
137
  }
66
138
  ```
67
139
 
140
+ ### Schema Types
141
+
142
+ ```typescript
143
+ type SchemaType = "string" | "number" | "boolean" | "email" | "array" | "object" | "any";
144
+ ```
145
+
68
146
  ## License
69
147
 
70
148
  MIT License - feel free to use this in your own projects!
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jotdb",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -13,7 +13,8 @@
13
13
  "scripts": {
14
14
  "dev": "wrangler dev --port 5173",
15
15
  "deploy": "wrangler deploy",
16
- "patch": "npm version patch && npm publish --access public"
16
+ "patch": "npm version patch",
17
+ "publish": "npm publish --access public"
17
18
  },
18
19
  "devDependencies": {
19
20
  "typescript": "^5.8.3",
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { z, ZodTypeAny, ZodObject } from "zod";
1
+ import { z, ZodTypeAny, ZodObject, ZodError } from "zod";
2
2
  import { Hono } from 'hono';
3
3
  import { cors } from 'hono/cors';
4
4
  import { prettyJSON } from 'hono/pretty-json';
@@ -6,7 +6,9 @@ import { DurableObject } from "cloudflare:workers";
6
6
 
7
7
  // Type definitions
8
8
  type SchemaType = "string" | "number" | "boolean" | "email" | "array" | "object" | "any";
9
- type SchemaDefinition = Record<string, SchemaType>;
9
+ type ObjectSchema = Record<string, SchemaType>;
10
+ type ArraySchema = { __arrayType: SchemaType | ObjectSchema };
11
+ type SchemaDefinition = ObjectSchema | ArraySchema;
10
12
 
11
13
  interface JotDBOptions {
12
14
  autoStrip: boolean;
@@ -19,10 +21,39 @@ interface AuditLogEntry {
19
21
  keys: string[];
20
22
  }
21
23
 
24
+ function isZodError(e: any): e is ZodError {
25
+ return e && typeof e === 'object' && Array.isArray(e.issues);
26
+ }
27
+
28
+ function handleZod<T>(fn: () => T): T {
29
+ try {
30
+ return fn();
31
+ } catch (e) {
32
+ if (isZodError(e)) {
33
+ throw new Error('Validation failed: ' + e.issues.map(issue => issue.message).join('; '));
34
+ }
35
+ throw e;
36
+ }
37
+ }
38
+
39
+ function isArraySchema(schema: SchemaDefinition): schema is ArraySchema {
40
+ return '__arrayType' in schema;
41
+ }
42
+ function isObjectSchema(schema: SchemaDefinition): schema is ObjectSchema {
43
+ return !('__arrayType' in schema);
44
+ }
45
+
46
+ function inferPrimitiveType(v: any): SchemaType {
47
+ if (typeof v === "string") return v.includes("@") ? "email" : "string";
48
+ if (typeof v === "number") return "number";
49
+ if (typeof v === "boolean") return "boolean";
50
+ return "any";
51
+ }
52
+
22
53
  export class JotDB extends DurableObject {
23
54
  private data: Record<string, unknown> | unknown[] = {};
24
55
  private rawSchema: SchemaDefinition = {};
25
- private zodSchema: ZodObject<any> | null = null;
56
+ private zodSchema: ZodTypeAny | null = null;
26
57
  private options: JotDBOptions = {
27
58
  autoStrip: false,
28
59
  readOnly: false,
@@ -56,11 +87,49 @@ export class JotDB extends DurableObject {
56
87
  return Array.isArray(this.data);
57
88
  }
58
89
 
90
+ private inferSchemaFromValue(value: any): SchemaDefinition {
91
+ if (Array.isArray(value)) {
92
+ if (value.length === 0) return { __arrayType: "any" };
93
+ const first = value[0];
94
+ if (typeof first === "object" && first !== null && !Array.isArray(first)) {
95
+ // Array of objects
96
+ return { __arrayType: this.inferSchemaFromValue(first) };
97
+ }
98
+ // Array of primitives
99
+ return { __arrayType: inferPrimitiveType(first) };
100
+ }
101
+ if (typeof value === "object" && value !== null) {
102
+ const schema: ObjectSchema = {};
103
+ for (const [k, v] of Object.entries(value)) {
104
+ if (Array.isArray(v)) {
105
+ schema[k] = "array";
106
+ } else if (typeof v === "object" && v !== null) {
107
+ schema[k] = "object";
108
+ } else {
109
+ schema[k] = inferPrimitiveType(v);
110
+ }
111
+ }
112
+ return schema;
113
+ }
114
+ // Top-level primitive (shouldn't happen for objects, but fallback)
115
+ return {};
116
+ }
117
+
59
118
  async push(item: unknown): Promise<void> {
60
119
  await this.load();
61
120
  if (!Array.isArray(this.data)) {
62
121
  this.data = [];
63
122
  }
123
+ if (!this.zodSchema) {
124
+ const schema = this.inferSchemaFromValue([item]);
125
+ await this.setSchema(schema);
126
+ console.info('[JotDB] Auto-inferred and set schema from first push:', schema);
127
+ }
128
+ if (isArraySchema(this.rawSchema)) {
129
+ handleZod(() => (this.zodSchema as any).parse([item]));
130
+ } else if (this.zodSchema && isObjectSchema(this.rawSchema)) {
131
+ handleZod(() => this.zodSchema!.parse(item));
132
+ }
64
133
  (this.data as unknown[]).push(item);
65
134
  await this.save();
66
135
  await this.logAudit("push", []);
@@ -68,8 +137,17 @@ export class JotDB extends DurableObject {
68
137
 
69
138
  async setAll(objOrArr: Record<string, unknown> | unknown[]): Promise<void> {
70
139
  await this.load();
71
- if (!Array.isArray(objOrArr) && this.zodSchema) {
72
- objOrArr = this.zodSchema.parse(objOrArr);
140
+ if (!this.zodSchema) {
141
+ const schema = this.inferSchemaFromValue(objOrArr);
142
+ await this.setSchema(schema);
143
+ console.info('[JotDB] Auto-inferred and set schema from first setAll:', schema);
144
+ }
145
+ if (Array.isArray(objOrArr) && isArraySchema(this.rawSchema)) {
146
+ handleZod(() => (this.zodSchema as any).parse(objOrArr));
147
+ } else if (!Array.isArray(objOrArr) && this.zodSchema && isObjectSchema(this.rawSchema)) {
148
+ objOrArr = handleZod(() => this.zodSchema!.parse(objOrArr));
149
+ } else if (Array.isArray(objOrArr) && this.zodSchema && isObjectSchema(this.rawSchema)) {
150
+ objOrArr.forEach(item => handleZod(() => this.zodSchema!.parse(item)));
73
151
  }
74
152
  this.data = objOrArr;
75
153
  await this.save();
@@ -183,30 +261,33 @@ export class JotDB extends DurableObject {
183
261
  await this.ctx.storage.put("__audit__", []);
184
262
  }
185
263
 
186
- private buildZodSchema(schema: SchemaDefinition): ZodObject<any> {
264
+ private buildZodSchema(schema: SchemaDefinition): ZodTypeAny {
265
+ if (isArraySchema(schema)) {
266
+ const t = schema.__arrayType;
267
+ if (typeof t === "string") {
268
+ switch (t) {
269
+ case "string": return z.string().array();
270
+ case "number": return z.number().array();
271
+ case "boolean": return z.boolean().array();
272
+ case "email": return z.string().email().array();
273
+ default: return z.any().array();
274
+ }
275
+ } else {
276
+ // Array of objects
277
+ return this.buildZodSchema(t).array();
278
+ }
279
+ }
280
+ // Object schema
187
281
  const shape: Record<string, ZodTypeAny> = {};
188
282
  for (const [key, type] of Object.entries(schema)) {
189
283
  switch (type) {
190
- case "string":
191
- shape[key] = z.string();
192
- break;
193
- case "number":
194
- shape[key] = z.number();
195
- break;
196
- case "boolean":
197
- shape[key] = z.boolean();
198
- break;
199
- case "email":
200
- shape[key] = z.string().email();
201
- break;
202
- case "array":
203
- shape[key] = z.array(z.any());
204
- break;
205
- case "object":
206
- shape[key] = z.record(z.any());
207
- break;
208
- default:
209
- shape[key] = z.any();
284
+ case "string": shape[key] = z.string(); break;
285
+ case "number": shape[key] = z.number(); break;
286
+ case "boolean": shape[key] = z.boolean(); break;
287
+ case "email": shape[key] = z.string().email(); break;
288
+ case "array": shape[key] = z.array(z.any()); break;
289
+ case "object": shape[key] = z.record(z.any()); break;
290
+ default: shape[key] = z.any();
210
291
  }
211
292
  }
212
293
  return z.object(shape);
@@ -222,9 +303,15 @@ export class JotDB extends DurableObject {
222
303
  throw new Error("Database is in read-only mode");
223
304
  }
224
305
  if (!Array.isArray(this.data)) {
306
+ // Auto-infer schema if not set
307
+ if (!this.zodSchema) {
308
+ const schema = this.inferSchemaFromValue({ [key]: value });
309
+ await this.setSchema(schema);
310
+ console.info('[JotDB] Auto-inferred and set schema from first set:', schema);
311
+ }
225
312
  this.data[key] = value;
226
313
  if (this.zodSchema) {
227
- this.zodSchema.parse(this.data);
314
+ handleZod(() => this.zodSchema!.parse(this.data));
228
315
  }
229
316
  await this.save();
230
317
  await this.logAudit("set", key);
@@ -246,7 +333,8 @@ app.use('*', prettyJSON());
246
333
 
247
334
  // Test endpoint
248
335
  app.get('/test', async (c) => {
249
- const id = c.env.JOTDB.idFromName("test-db");
336
+ const JOB_ID = Date.now().toString();
337
+ const id = c.env.JOTDB.idFromName(JOB_ID);
250
338
  const db = c.env.JOTDB.get(id) as unknown as JotDB;
251
339
 
252
340
  const results = {
@@ -256,6 +344,7 @@ app.get('/test', async (c) => {
256
344
  };
257
345
 
258
346
  try {
347
+
259
348
  // Test 1: Basic set/get
260
349
  await db.set("test1", "hello");
261
350
  const value1 = await db.get("test1");
@@ -316,7 +405,7 @@ app.get('/test', async (c) => {
316
405
  });
317
406
 
318
407
  // Test 5: Array mode - setAll and getAll
319
- const arrayId = c.env.JOTDB.idFromName("test-array");
408
+ const arrayId = c.env.JOTDB.idFromName(JOB_ID + "-test-array");
320
409
  const arrayDb = c.env.JOTDB.get(arrayId) as unknown as JotDB;
321
410
  await arrayDb.setAll([1, 2, 3]);
322
411
  const arr = await arrayDb.getAll();
@@ -335,6 +424,67 @@ app.get('/test', async (c) => {
335
424
  value: arr2
336
425
  });
337
426
 
427
+ // --- Schema inference tests-- -
428
+ // Object mode
429
+ const objId = c.env.JOTDB.idFromName("schema-obj");
430
+ const objDb = c.env.JOTDB.get(objId) as unknown as JotDB;
431
+ await objDb.setAll({ foo: "bar", count: 1 });
432
+ const objSchema = await objDb.getSchema();
433
+ let objPassed = false;
434
+ if (!('__arrayType' in objSchema)) {
435
+ objPassed = (objSchema as any).foo === "string" && (objSchema as any).count === "number";
436
+ }
437
+ results.tests.push({
438
+ name: "Object mode: inferred schema",
439
+ passed: objPassed,
440
+ value: objSchema
441
+ });
442
+ let objError = null;
443
+ try {
444
+ await objDb.setAll({ foo: 123, count: "not a number" });
445
+ } catch (e) {
446
+ objError = e instanceof Error ? e.message : String(e);
447
+ }
448
+ results.tests.push({
449
+ name: "Object mode: invalid shape fails",
450
+ passed: !!objError,
451
+ error: objError
452
+ });
453
+
454
+ // Array mode
455
+ const arrId = c.env.JOTDB.idFromName("schema-arr");
456
+ const arrDb = c.env.JOTDB.get(arrId) as unknown as JotDB;
457
+ await arrDb.setAll([{ foo: "bar", count: 1 }]);
458
+ const arrSchema = await arrDb.getSchema();
459
+ let arrPassed = false;
460
+ if ('__arrayType' in arrSchema && typeof arrSchema.__arrayType === 'object') {
461
+ arrPassed = arrSchema.__arrayType.foo === "string" && arrSchema.__arrayType.count === "number";
462
+ }
463
+ results.tests.push({
464
+ name: "Array mode: inferred schema",
465
+ passed: arrPassed,
466
+ value: arrSchema
467
+ });
468
+ let arrError = null;
469
+ try {
470
+ await arrDb.push({ foo: 123, count: "not a number" });
471
+ } catch (e) {
472
+ arrError = e instanceof Error ? e.message : String(e);
473
+ }
474
+ results.tests.push({
475
+ name: "Array mode: invalid item fails",
476
+ passed: !!arrError,
477
+ error: arrError
478
+ });
479
+
480
+ // Test 0: Clear database, set options to read-only: false
481
+ await db.clear();
482
+ results.tests.push({
483
+ name: "Clear database",
484
+ passed: true,
485
+ value: await db.getAll()
486
+ });
487
+
338
488
  // Get audit log
339
489
  results.auditLog = await db.getAuditLog();
340
490
 
@@ -364,9 +514,15 @@ app.get('/test', async (c) => {
364
514
 
365
515
  return new Response(html, { headers: { 'Content-Type': 'text/html' } });
366
516
  } catch (error) {
517
+ console.error(error);
518
+ let errorMessage = error instanceof Error ? error.message : String(error);
519
+ if (isZodError(error)) {
520
+ console.error(error.issues);
521
+ errorMessage = error.issues.map(issue => issue.message).join('\n');
522
+ }
367
523
  let html = `<!DOCTYPE html><html><head><title>JotDB Test Error</title></head><body>` +
368
524
  `<h1 style="color:red">Error</h1>` +
369
- `<pre>${error instanceof Error ? error.message : String(error)}</pre>` +
525
+ `<pre>${errorMessage}</pre>` +
370
526
  `<h2>Partial Results</h2>` +
371
527
  `<pre>${JSON.stringify(results.tests, null, 2)}</pre>` +
372
528
  `<h2>Audit Log</h2>` +