jotdb 0.1.8 → 0.1.10

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,802 @@
1
+ import { z } from "zod";
2
+ import { Hono } from 'hono';
3
+ import { cors } from 'hono/cors';
4
+ import { prettyJSON } from 'hono/pretty-json';
5
+ import { DurableObject } from "cloudflare:workers";
6
+ import { JotStore, SQLiteStoreAdapter } from './store';
7
+ function isFieldDescriptor(v) {
8
+ return typeof v === 'object' && v !== null && 'type' in v;
9
+ }
10
+ function fieldType(v) {
11
+ return isFieldDescriptor(v) ? v.type : v;
12
+ }
13
+ function fieldDefault(v) {
14
+ if (isFieldDescriptor(v) && 'default' in v)
15
+ return { has: true, value: v.default };
16
+ return { has: false, value: undefined };
17
+ }
18
+ function fieldOptional(v) {
19
+ return isFieldDescriptor(v) && v.optional === true;
20
+ }
21
+ function isZodError(e) {
22
+ return e && typeof e === 'object' && Array.isArray(e.issues);
23
+ }
24
+ function handleZod(fn) {
25
+ try {
26
+ return fn();
27
+ }
28
+ catch (e) {
29
+ if (isZodError(e)) {
30
+ throw new Error('Validation failed: ' + e.issues.map(issue => issue.message).join('; '));
31
+ }
32
+ throw e;
33
+ }
34
+ }
35
+ function isArraySchema(schema) {
36
+ return '__arrayType' in schema;
37
+ }
38
+ function isObjectSchema(schema) {
39
+ return !('__arrayType' in schema);
40
+ }
41
+ function inferPrimitiveType(v) {
42
+ if (typeof v === "string")
43
+ return v.includes("@") ? "email" : "string";
44
+ if (typeof v === "number")
45
+ return "number";
46
+ if (typeof v === "boolean")
47
+ return "boolean";
48
+ return "any";
49
+ }
50
+ export class JotDB extends DurableObject {
51
+ constructor(state, env) {
52
+ super(state, env);
53
+ this.data = {};
54
+ this.rawSchema = {};
55
+ this.zodSchema = null;
56
+ this.options = {
57
+ autoStrip: false,
58
+ readOnly: false,
59
+ };
60
+ this.auditLog = [];
61
+ this.store = null;
62
+ if (state.storage?.sql)
63
+ this.store = new JotStore(new SQLiteStoreAdapter(state.storage.sql));
64
+ }
65
+ async load() {
66
+ if (this.data == null || (typeof this.data === 'object' && Object.keys(this.data).length === 0)) {
67
+ this.data = (await this.ctx.storage.get("data")) ?? {};
68
+ }
69
+ if (Object.keys(this.rawSchema).length === 0) {
70
+ this.rawSchema = (await this.ctx.storage.get("__schema__")) || {};
71
+ if (Object.keys(this.rawSchema).length > 0) {
72
+ this.zodSchema = this.buildZodSchema(this.rawSchema);
73
+ }
74
+ }
75
+ const storedOptions = await this.ctx.storage.get("__options__");
76
+ if (storedOptions)
77
+ this.options = storedOptions;
78
+ this.auditLog = (await this.ctx.storage.get("__audit__")) || [];
79
+ }
80
+ async save() {
81
+ await this.ctx.storage.put("data", this.data);
82
+ }
83
+ isArrayMode() {
84
+ return Array.isArray(this.data);
85
+ }
86
+ inferSchemaFromValue(value) {
87
+ if (Array.isArray(value)) {
88
+ if (value.length === 0)
89
+ return { __arrayType: "any" };
90
+ const first = value[0];
91
+ if (typeof first === "object" && first !== null && !Array.isArray(first)) {
92
+ // Array of objects
93
+ return { __arrayType: this.inferSchemaFromValue(first) };
94
+ }
95
+ // Array of primitives
96
+ return { __arrayType: inferPrimitiveType(first) };
97
+ }
98
+ if (typeof value === "object" && value !== null) {
99
+ const schema = {};
100
+ for (const [k, v] of Object.entries(value)) {
101
+ if (Array.isArray(v)) {
102
+ schema[k] = "array";
103
+ }
104
+ else if (typeof v === "object" && v !== null) {
105
+ schema[k] = "object";
106
+ }
107
+ else {
108
+ schema[k] = inferPrimitiveType(v);
109
+ }
110
+ }
111
+ return schema;
112
+ }
113
+ // Top-level primitive (shouldn't happen for objects, but fallback)
114
+ return {};
115
+ }
116
+ async push(item) {
117
+ await this.load();
118
+ if (!Array.isArray(this.data)) {
119
+ this.data = [];
120
+ }
121
+ if (!this.zodSchema) {
122
+ const schema = this.inferSchemaFromValue([item]);
123
+ await this.setSchema(schema);
124
+ console.info('[JotDB] Auto-inferred and set schema from first push:', schema);
125
+ }
126
+ let toPush = item;
127
+ if (isArraySchema(this.rawSchema)) {
128
+ const at = this.rawSchema.__arrayType;
129
+ if (typeof at === 'object' && at !== null && toPush && typeof toPush === 'object' && !Array.isArray(toPush)) {
130
+ toPush = this.applyDefaultsForObjectSchema(at, toPush);
131
+ }
132
+ toPush = handleZod(() => this.zodSchema.parse([toPush]))[0];
133
+ }
134
+ else if (this.zodSchema && isObjectSchema(this.rawSchema)) {
135
+ toPush = handleZod(() => this.zodSchema.parse(toPush));
136
+ }
137
+ this.data.push(toPush);
138
+ await this.save();
139
+ await this.logAudit("push", []);
140
+ }
141
+ applyDefaultsForObjectSchema(schema, target) {
142
+ const out = { ...target };
143
+ for (const [key, spec] of Object.entries(schema)) {
144
+ if (out[key] === undefined) {
145
+ const def = fieldDefault(spec);
146
+ if (def.has)
147
+ out[key] = def.value;
148
+ }
149
+ }
150
+ return out;
151
+ }
152
+ async setAll(objOrArr) {
153
+ await this.load();
154
+ if (!this.zodSchema) {
155
+ const schema = this.inferSchemaFromValue(objOrArr);
156
+ await this.setSchema(schema);
157
+ console.info('[JotDB] Auto-inferred and set schema from first setAll:', schema);
158
+ }
159
+ if (Array.isArray(objOrArr) && isArraySchema(this.rawSchema)) {
160
+ const at = this.rawSchema.__arrayType;
161
+ if (typeof at === 'object' && at !== null) {
162
+ objOrArr = objOrArr.map(item => item && typeof item === 'object' && !Array.isArray(item)
163
+ ? this.applyDefaultsForObjectSchema(at, item)
164
+ : item);
165
+ }
166
+ objOrArr = handleZod(() => this.zodSchema.parse(objOrArr));
167
+ }
168
+ else if (!Array.isArray(objOrArr) && this.zodSchema && isObjectSchema(this.rawSchema)) {
169
+ objOrArr = this.applyObjectDefaults(objOrArr);
170
+ objOrArr = handleZod(() => this.zodSchema.parse(objOrArr));
171
+ }
172
+ else if (Array.isArray(objOrArr) && this.zodSchema && isObjectSchema(this.rawSchema)) {
173
+ objOrArr.forEach(item => handleZod(() => this.zodSchema.parse(item)));
174
+ }
175
+ this.data = objOrArr;
176
+ await this.save();
177
+ await this.logAudit("setAll", Array.isArray(objOrArr) ? [] : Object.keys(objOrArr));
178
+ }
179
+ async getAll() {
180
+ await this.load();
181
+ return this.data;
182
+ }
183
+ async logAudit(action, keys) {
184
+ const entry = {
185
+ timestamp: Date.now(),
186
+ action,
187
+ keys: Array.isArray(keys) ? keys : [keys],
188
+ };
189
+ this.auditLog.unshift(entry);
190
+ await this.ctx.storage.put("__audit__", this.auditLog.slice(0, 100)); // keep max 100 entries
191
+ }
192
+ async get(key) {
193
+ await this.load();
194
+ if (!Array.isArray(this.data)) {
195
+ return this.data[key];
196
+ }
197
+ return undefined;
198
+ }
199
+ async delete(key) {
200
+ await this.load();
201
+ if (!Array.isArray(this.data)) {
202
+ delete this.data[key];
203
+ await this.save();
204
+ await this.logAudit("delete", key);
205
+ }
206
+ }
207
+ async clear() {
208
+ this.data = {};
209
+ await this.save();
210
+ await this.logAudit("clear", []);
211
+ }
212
+ async keys() {
213
+ await this.load();
214
+ if (!Array.isArray(this.data)) {
215
+ return Object.keys(this.data);
216
+ }
217
+ return [];
218
+ }
219
+ async has(key) {
220
+ await this.load();
221
+ if (!Array.isArray(this.data)) {
222
+ return key in this.data;
223
+ }
224
+ return false;
225
+ }
226
+ async getSchema() {
227
+ await this.load();
228
+ return this.rawSchema;
229
+ }
230
+ warnSchemaDiff(newSchema) {
231
+ const current = this.rawSchema;
232
+ if (isArraySchema(newSchema) || isArraySchema(current))
233
+ return;
234
+ for (const key in newSchema) {
235
+ if (!(key in current))
236
+ console.warn(`[JotDB] New key added: ${key}`);
237
+ else {
238
+ const nt = fieldType(newSchema[key]);
239
+ const ct = fieldType(current[key]);
240
+ if (nt !== ct) {
241
+ console.warn(`[JotDB] Type changed for "${key}": ${ct} → ${nt}`);
242
+ }
243
+ }
244
+ }
245
+ for (const key in current) {
246
+ if (!(key in newSchema)) {
247
+ console.warn(`[JotDB] Key removed: ${key}`);
248
+ }
249
+ }
250
+ }
251
+ async setSchema(schemaObj) {
252
+ await this.load();
253
+ if (Object.keys(this.rawSchema).length > 0) {
254
+ this.warnSchemaDiff(schemaObj);
255
+ }
256
+ this.rawSchema = schemaObj;
257
+ this.zodSchema = this.buildZodSchema(schemaObj);
258
+ await this.ctx.storage.put("__schema__", schemaObj);
259
+ }
260
+ /**
261
+ * Non-destructively merge additional fields into the current object schema.
262
+ * Useful for additive schema evolution without a full migration.
263
+ * Throws if the current schema is an array schema.
264
+ * New fields with `default` are backfilled into existing stored data.
265
+ * New fields without `default` and without `optional: true` will cause
266
+ * existing stored objects to fail validation on next write — prefer adding
267
+ * `default` or `optional: true` for safe rollouts.
268
+ */
269
+ async extendSchema(partial) {
270
+ await this.load();
271
+ if (Object.keys(this.rawSchema).length > 0 && isArraySchema(this.rawSchema)) {
272
+ throw new Error("extendSchema is only supported for object schemas");
273
+ }
274
+ const merged = { ...this.rawSchema, ...partial };
275
+ this.warnSchemaDiff(merged);
276
+ this.rawSchema = merged;
277
+ this.zodSchema = this.buildZodSchema(merged);
278
+ await this.ctx.storage.put("__schema__", merged);
279
+ // Backfill defaults into existing stored data, if applicable.
280
+ let mutated = false;
281
+ if (!Array.isArray(this.data)) {
282
+ const next = this.applyObjectDefaults(this.data);
283
+ if (JSON.stringify(next) !== JSON.stringify(this.data)) {
284
+ this.data = next;
285
+ mutated = true;
286
+ }
287
+ }
288
+ if (mutated)
289
+ await this.save();
290
+ await this.logAudit("extendSchema", Object.keys(partial));
291
+ }
292
+ /**
293
+ * Validate arbitrary data (or current stored data if omitted) against the
294
+ * current schema without throwing. Returns `{ ok, errors, data }` where
295
+ * `data` is the (possibly default-applied / coerced) parsed result on
296
+ * success. If no schema is set, returns `{ ok: true }`.
297
+ */
298
+ async validate(data) {
299
+ await this.load();
300
+ if (!this.zodSchema)
301
+ return { ok: true, data: data ?? this.data };
302
+ const target = data === undefined ? this.data : data;
303
+ try {
304
+ const parsed = this.zodSchema.parse(target);
305
+ return { ok: true, data: parsed };
306
+ }
307
+ catch (e) {
308
+ if (isZodError(e)) {
309
+ return { ok: false, errors: e.issues.map(i => `${i.path.join('.') || '<root>'}: ${i.message}`) };
310
+ }
311
+ return { ok: false, errors: [e instanceof Error ? e.message : String(e)] };
312
+ }
313
+ }
314
+ /**
315
+ * Apply a transform to the stored data. For object mode, `fn` is called once
316
+ * with the entire object and must return the new object. For array mode, `fn`
317
+ * is called once per item and must return the replacement item (return
318
+ * undefined to drop the item).
319
+ * If a schema is set, the resulting data is validated before being saved;
320
+ * on validation failure the original data is left untouched and the function
321
+ * throws. Records an audit entry on success.
322
+ */
323
+ async migrate(fn) {
324
+ await this.load();
325
+ if (this.options.readOnly)
326
+ throw new Error("Database is in read-only mode");
327
+ let next;
328
+ if (Array.isArray(this.data)) {
329
+ const out = [];
330
+ for (let i = 0; i < this.data.length; i++) {
331
+ const r = fn(this.data[i], i);
332
+ if (r !== undefined)
333
+ out.push(r);
334
+ }
335
+ next = out;
336
+ }
337
+ else {
338
+ const r = fn({ ...this.data });
339
+ if (r === null || typeof r !== 'object' || Array.isArray(r)) {
340
+ throw new Error("migrate(fn) for object mode must return a plain object");
341
+ }
342
+ next = r;
343
+ }
344
+ if (this.zodSchema) {
345
+ try {
346
+ if (Array.isArray(next) && isArraySchema(this.rawSchema)) {
347
+ next = this.zodSchema.parse(next);
348
+ }
349
+ else if (!Array.isArray(next) && isObjectSchema(this.rawSchema)) {
350
+ next = this.zodSchema.parse(next);
351
+ }
352
+ else if (Array.isArray(next) && isObjectSchema(this.rawSchema)) {
353
+ next.forEach(item => this.zodSchema.parse(item));
354
+ }
355
+ }
356
+ catch (e) {
357
+ if (isZodError(e)) {
358
+ throw new Error('migrate validation failed: ' + e.issues.map(i => `${i.path.join('.') || '<root>'}: ${i.message}`).join('; '));
359
+ }
360
+ throw e;
361
+ }
362
+ }
363
+ this.data = next;
364
+ await this.save();
365
+ await this.logAudit("migrate", Array.isArray(next) ? [] : Object.keys(next));
366
+ }
367
+ async setOptions(opts) {
368
+ await this.load();
369
+ Object.assign(this.options, opts);
370
+ await this.ctx.storage.put("__options__", this.options);
371
+ }
372
+ async getOptions() {
373
+ await this.load();
374
+ return this.options;
375
+ }
376
+ async getAuditLog() {
377
+ await this.load();
378
+ return this.auditLog;
379
+ }
380
+ async clearAuditLog() {
381
+ this.auditLog = [];
382
+ await this.ctx.storage.put("__audit__", []);
383
+ }
384
+ buildZodSchema(schema) {
385
+ if (isArraySchema(schema)) {
386
+ const t = schema.__arrayType;
387
+ if (typeof t === "string") {
388
+ switch (t) {
389
+ case "string": return z.string().array();
390
+ case "number": return z.number().array();
391
+ case "boolean": return z.boolean().array();
392
+ case "email": return z.string().email().array();
393
+ default: return z.any().array();
394
+ }
395
+ }
396
+ else {
397
+ // Array of objects
398
+ return this.buildZodSchema(t).array();
399
+ }
400
+ }
401
+ // Object schema
402
+ const shape = {};
403
+ for (const [key, spec] of Object.entries(schema)) {
404
+ const t = fieldType(spec);
405
+ let zt;
406
+ switch (t) {
407
+ case "string":
408
+ zt = z.string();
409
+ break;
410
+ case "number":
411
+ zt = z.number();
412
+ break;
413
+ case "boolean":
414
+ zt = z.boolean();
415
+ break;
416
+ case "email":
417
+ zt = z.string().email();
418
+ break;
419
+ case "array":
420
+ zt = z.array(z.any());
421
+ break;
422
+ case "object":
423
+ zt = z.record(z.any());
424
+ break;
425
+ default: zt = z.any();
426
+ }
427
+ const def = fieldDefault(spec);
428
+ if (def.has) {
429
+ zt = zt.default(def.value);
430
+ }
431
+ else if (fieldOptional(spec)) {
432
+ zt = zt.optional();
433
+ }
434
+ shape[key] = zt;
435
+ }
436
+ return z.object(shape);
437
+ }
438
+ applyObjectDefaults(target) {
439
+ if (isArraySchema(this.rawSchema))
440
+ return target;
441
+ const out = { ...target };
442
+ for (const [key, spec] of Object.entries(this.rawSchema)) {
443
+ if (out[key] === undefined) {
444
+ const def = fieldDefault(spec);
445
+ if (def.has)
446
+ out[key] = def.value;
447
+ }
448
+ }
449
+ return out;
450
+ }
451
+ async fetch(request) {
452
+ return new Response("Hello, World!");
453
+ }
454
+ async scan(prefix = '', options) {
455
+ if (!this.store)
456
+ throw new Error('SQLite-backed store is not enabled');
457
+ return this.store.scan(prefix, options);
458
+ }
459
+ async append(stream, value) {
460
+ if (!this.store)
461
+ throw new Error('SQLite-backed store is not enabled');
462
+ return this.store.append(stream, value);
463
+ }
464
+ async appendCapped(stream, value, max) {
465
+ if (!this.store)
466
+ throw new Error('SQLite-backed store is not enabled');
467
+ return this.store.appendCapped(stream, value, max);
468
+ }
469
+ async retention(prefix, maxAgeMs) {
470
+ if (!this.store)
471
+ throw new Error('SQLite-backed store is not enabled');
472
+ return this.store.retention(prefix, maxAgeMs);
473
+ }
474
+ async set(key, value) {
475
+ await this.load();
476
+ if (this.options.readOnly) {
477
+ throw new Error("Database is in read-only mode");
478
+ }
479
+ if (!Array.isArray(this.data)) {
480
+ // Auto-infer schema if not set
481
+ if (!this.zodSchema) {
482
+ const schema = this.inferSchemaFromValue({ [key]: value });
483
+ await this.setSchema(schema);
484
+ console.info('[JotDB] Auto-inferred and set schema from first set:', schema);
485
+ }
486
+ this.data[key] = value;
487
+ if (this.zodSchema) {
488
+ handleZod(() => this.zodSchema.parse(this.data));
489
+ }
490
+ await this.save();
491
+ await this.logAudit("set", key);
492
+ }
493
+ else {
494
+ throw new Error("Cannot use set() in array mode");
495
+ }
496
+ }
497
+ }
498
+ const app = new Hono();
499
+ // Benchmarks are intentionally gated before any Durable Object access. Set this
500
+ // as a Worker secret before exposing a route, or put Cloudflare Access in front
501
+ // of the route. This avoids turning benchmark endpoints into public cost sinks.
502
+ app.use('/bench', async (c, next) => {
503
+ const token = c.env.BENCH_TOKEN;
504
+ if (!token || c.req.header('Authorization') !== `Bearer ${token}`) {
505
+ return c.json({ error: 'Unauthorized' }, 401);
506
+ }
507
+ await next();
508
+ });
509
+ // Refuse all HTTP traffic unless an explicit deploy-time gate is configured.
510
+ // This keeps local library use working while making accidental public Worker
511
+ // deployment fail closed.
512
+ app.use('*', async (c, next) => {
513
+ if (!c.env.HTTP_ENABLED) {
514
+ return c.json({ error: 'HTTP surface disabled' }, 403);
515
+ }
516
+ await next();
517
+ });
518
+ // Middleware
519
+ app.use('*', cors());
520
+ app.use('*', prettyJSON());
521
+ // Test endpoint
522
+ app.get('/test', async (c) => {
523
+ const JOB_ID = Date.now().toString();
524
+ const id = c.env.JOTDB.idFromName(JOB_ID);
525
+ const db = c.env.JOTDB.get(id);
526
+ const results = {
527
+ timestamp: Date.now(),
528
+ tests: [],
529
+ auditLog: []
530
+ };
531
+ try {
532
+ // Test 1: Basic set/get
533
+ await db.set("test1", "hello");
534
+ const value1 = await db.get("test1");
535
+ results.tests.push({
536
+ name: "Basic set/get",
537
+ passed: value1 === "hello",
538
+ value: value1
539
+ });
540
+ // Test 2: Schema validation
541
+ await db.setSchema({
542
+ name: "string",
543
+ age: "number",
544
+ email: "email"
545
+ });
546
+ await db.setAll({
547
+ name: "John",
548
+ age: 30,
549
+ email: "john@example.com"
550
+ });
551
+ const all = await db.getAll();
552
+ results.tests.push({
553
+ name: "Schema validation",
554
+ passed: all.name === "John" && all.age === 30,
555
+ value: all
556
+ });
557
+ // Test 3: Read-only mode
558
+ await db.setOptions({ readOnly: true });
559
+ try {
560
+ await db.set("test3", "should fail");
561
+ results.tests.push({
562
+ name: "Read-only mode",
563
+ passed: false,
564
+ error: "Should have thrown"
565
+ });
566
+ }
567
+ catch (e) {
568
+ results.tests.push({
569
+ name: "Read-only mode",
570
+ passed: true,
571
+ error: e instanceof Error ? e.message : String(e)
572
+ });
573
+ }
574
+ // Test 4: Auto-strip mode
575
+ await db.setOptions({ readOnly: false, autoStrip: true });
576
+ await db.setAll({
577
+ name: "Jane",
578
+ age: 25,
579
+ email: "jane@example.com",
580
+ extra: "should be stripped"
581
+ });
582
+ const stripped = await db.getAll();
583
+ results.tests.push({
584
+ name: "Auto-strip mode",
585
+ passed: !("extra" in stripped),
586
+ value: stripped
587
+ });
588
+ // Test 5: Array mode - setAll and getAll
589
+ const arrayId = c.env.JOTDB.idFromName(JOB_ID + "-test-array");
590
+ const arrayDb = c.env.JOTDB.get(arrayId);
591
+ await arrayDb.setAll([1, 2, 3]);
592
+ const arr = await arrayDb.getAll();
593
+ results.tests.push({
594
+ name: "Array mode setAll/getAll",
595
+ passed: Array.isArray(arr) && arr.length === 3 && arr[0] === 1 && arr[2] === 3,
596
+ value: arr
597
+ });
598
+ // Test 6: Array mode - push
599
+ await arrayDb.push(4);
600
+ const arr2 = await arrayDb.getAll();
601
+ results.tests.push({
602
+ name: "Array mode push",
603
+ passed: Array.isArray(arr2) && arr2.length === 4 && arr2[3] === 4,
604
+ value: arr2
605
+ });
606
+ // --- Schema inference tests-- -
607
+ // Object mode
608
+ const objId = c.env.JOTDB.idFromName("schema-obj");
609
+ const objDb = c.env.JOTDB.get(objId);
610
+ await objDb.setAll({ foo: "bar", count: 1 });
611
+ const objSchema = await objDb.getSchema();
612
+ let objPassed = false;
613
+ if (!('__arrayType' in objSchema)) {
614
+ objPassed = objSchema.foo === "string" && objSchema.count === "number";
615
+ }
616
+ results.tests.push({
617
+ name: "Object mode: inferred schema",
618
+ passed: objPassed,
619
+ value: objSchema
620
+ });
621
+ let objError = null;
622
+ try {
623
+ await objDb.setAll({ foo: 123, count: "not a number" });
624
+ }
625
+ catch (e) {
626
+ objError = e instanceof Error ? e.message : String(e);
627
+ }
628
+ results.tests.push({
629
+ name: "Object mode: invalid shape fails",
630
+ passed: !!objError,
631
+ error: objError
632
+ });
633
+ // Array mode
634
+ const arrId = c.env.JOTDB.idFromName("schema-arr");
635
+ const arrDb = c.env.JOTDB.get(arrId);
636
+ await arrDb.setAll([{ foo: "bar", count: 1 }]);
637
+ const arrSchema = await arrDb.getSchema();
638
+ let arrPassed = false;
639
+ if ('__arrayType' in arrSchema && typeof arrSchema.__arrayType === 'object') {
640
+ arrPassed = arrSchema.__arrayType.foo === "string" && arrSchema.__arrayType.count === "number";
641
+ }
642
+ results.tests.push({
643
+ name: "Array mode: inferred schema",
644
+ passed: arrPassed,
645
+ value: arrSchema
646
+ });
647
+ let arrError = null;
648
+ try {
649
+ await arrDb.push({ foo: 123, count: "not a number" });
650
+ }
651
+ catch (e) {
652
+ arrError = e instanceof Error ? e.message : String(e);
653
+ }
654
+ results.tests.push({
655
+ name: "Array mode: invalid item fails",
656
+ passed: !!arrError,
657
+ error: arrError
658
+ });
659
+ // Test 0: Clear database, set options to read-only: false
660
+ await db.clear();
661
+ results.tests.push({
662
+ name: "Clear database",
663
+ passed: true,
664
+ value: await db.getAll()
665
+ });
666
+ // Get audit log
667
+ results.auditLog = await db.getAuditLog();
668
+ // HTML output
669
+ let html = `<!DOCTYPE html><html><head><title>JotDB Test Results</title>
670
+ <style>
671
+ body { font-family: sans-serif; margin: 2em; }
672
+ .pass { color: green; }
673
+ .fail { color: red; }
674
+ .test { margin-bottom: 1em; }
675
+ pre { background: #f4f4f4; padding: 0.5em; }
676
+ </style>
677
+ </head><body>
678
+ <h1>JotDB Test Results</h1>
679
+ <p><b>Timestamp:</b> ${new Date(results.timestamp).toLocaleString()}</p>
680
+ <div>
681
+ ${results.tests.map(test => `
682
+ <div class="test">
683
+ <b>${test.name}:</b> <span class="${test.passed ? 'pass' : 'fail'}">${test.passed ? 'PASS' : 'FAIL'}</span><br/>
684
+ <pre>${JSON.stringify(test.value ?? test.error, null, 2)}</pre>
685
+ </div>
686
+ `).join('')}
687
+ </div>
688
+ <h2>Audit Log</h2>
689
+ <pre>${JSON.stringify(results.auditLog, null, 2)}</pre>
690
+ </body></html>`;
691
+ return new Response(html, { headers: { 'Content-Type': 'text/html' } });
692
+ }
693
+ catch (error) {
694
+ console.error(error);
695
+ let errorMessage = error instanceof Error ? error.message : String(error);
696
+ if (isZodError(error)) {
697
+ console.error(error.issues);
698
+ errorMessage = error.issues.map(issue => issue.message).join('\n');
699
+ }
700
+ let html = `<!DOCTYPE html><html><head><title>JotDB Test Error</title></head><body>` +
701
+ `<h1 style="color:red">Error</h1>` +
702
+ `<pre>${errorMessage}</pre>` +
703
+ `<h2>Partial Results</h2>` +
704
+ `<pre>${JSON.stringify(results.tests, null, 2)}</pre>` +
705
+ `<h2>Audit Log</h2>` +
706
+ `<pre>${JSON.stringify(results.auditLog, null, 2)}</pre>` +
707
+ `</body></html>`;
708
+ return new Response(html, { headers: { 'Content-Type': 'text/html' } });
709
+ }
710
+ });
711
+ function percentile(values, p) {
712
+ if (values.length === 0)
713
+ return 0;
714
+ const sorted = [...values].sort((a, b) => a - b);
715
+ return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))];
716
+ }
717
+ async function measure(fn, timings) {
718
+ const start = performance.now();
719
+ const result = await fn();
720
+ timings.push(performance.now() - start);
721
+ return result;
722
+ }
723
+ app.get('/bench', async (c) => {
724
+ const mode = c.req.query('mode') ?? 'user-prefs';
725
+ const count = Math.min(Number(c.req.query('count') ?? 100), 1000);
726
+ const timings = [];
727
+ const started = performance.now();
728
+ let operations = 0;
729
+ if (mode === 'user-prefs') {
730
+ const db = c.env.JOTDB.getByName(`bench:prefs:${Date.now()}`);
731
+ await db.setSchema({ theme: 'string', notifications: 'boolean', locale: 'string' });
732
+ for (let i = 0; i < count; i++) {
733
+ await measure(() => db.setAll({ theme: i % 2 ? 'dark' : 'light', notifications: true, locale: 'en' }), timings);
734
+ operations++;
735
+ }
736
+ }
737
+ else if (mode === 'feature-flags') {
738
+ const db = c.env.JOTDB.getByName(`bench:flags:${Date.now()}`);
739
+ await db.setSchema({ darkMode: 'boolean', betaEditor: 'boolean', aiSearch: 'boolean' });
740
+ await db.setAll({ darkMode: true, betaEditor: false, aiSearch: true });
741
+ for (let i = 0; i < count; i++) {
742
+ await measure(() => db.getAll(), timings);
743
+ operations++;
744
+ }
745
+ }
746
+ else if (mode === 'chat-append') {
747
+ const db = c.env.JOTDB.getByName(`bench:chat:${Date.now()}`);
748
+ await db.setAll([]);
749
+ for (let i = 0; i < count; i++) {
750
+ await measure(() => db.push({ id: i, body: `message ${i}` }), timings);
751
+ operations++;
752
+ }
753
+ }
754
+ else if (mode === 'hot-key') {
755
+ const db = c.env.JOTDB.getByName(`bench:hot:${Date.now()}`);
756
+ await db.setSchema({ counter: 'number' });
757
+ await db.setAll({ counter: 0 });
758
+ await Promise.all(Array.from({ length: count }, async (_, i) => { await measure(() => db.setAll({ counter: i }), timings); }));
759
+ operations = count;
760
+ }
761
+ else if (mode === 'multi-instance') {
762
+ await Promise.all(Array.from({ length: count }, async (_, i) => {
763
+ const db = c.env.JOTDB.getByName(`bench:user:${Date.now()}:${i}`);
764
+ await measure(() => db.set('id', i), timings);
765
+ }));
766
+ operations = count;
767
+ }
768
+ else if (mode === 'cold-warm') {
769
+ const db = c.env.JOTDB.getByName(`bench:cold:${Date.now()}`);
770
+ await measure(() => db.set('first', true), timings);
771
+ operations++;
772
+ for (let i = 0; i < count; i++) {
773
+ await measure(() => db.get('first'), timings);
774
+ operations++;
775
+ }
776
+ }
777
+ else if (mode === 'schema-validation') {
778
+ const db = c.env.JOTDB.getByName(`bench:schema:${Date.now()}`);
779
+ await db.setSchema({ name: 'string', age: 'number', email: 'email' });
780
+ for (let i = 0; i < count; i++) {
781
+ await measure(() => db.setAll({ name: `User ${i}`, age: i, email: `u${i}@example.com` }), timings);
782
+ operations++;
783
+ }
784
+ }
785
+ else {
786
+ return c.json({ error: `Unknown benchmark mode: ${mode}` }, 400);
787
+ }
788
+ const durationMs = performance.now() - started;
789
+ return c.json({
790
+ mode,
791
+ operations,
792
+ durationMs,
793
+ opsPerSecond: operations / (durationMs / 1000),
794
+ p50Ms: percentile(timings, 0.5),
795
+ p95Ms: percentile(timings, 0.95),
796
+ p99Ms: percentile(timings, 0.99),
797
+ errors: 0,
798
+ });
799
+ });
800
+ // Health check endpoint
801
+ app.get('/', (c) => c.text('JotDB Durable Object'));
802
+ export default app;