jotdb 0.1.8 → 0.1.9
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/README.md +296 -6
- package/dist/index.d.ts +58 -1
- package/dist/index.js +552 -31
- package/dist/site-worker.d.ts +7 -0
- package/dist/site-worker.js +6 -0
- package/dist/src/index.d.ts +107 -0
- package/dist/src/index.js +802 -0
- package/dist/src/site-worker.d.ts +7 -0
- package/dist/src/site-worker.js +6 -0
- package/dist/src/store.d.ts +60 -0
- package/dist/src/store.js +63 -0
- package/dist/store.d.ts +60 -0
- package/dist/store.js +64 -0
- package/package.json +18 -7
- package/bun.lock +0 -220
- package/src/index.ts +0 -538
- package/tsconfig.json +0 -33
- package/wrangler.jsonc +0 -29
package/dist/index.js
CHANGED
|
@@ -3,17 +3,64 @@ import { Hono } from 'hono';
|
|
|
3
3
|
import { cors } from 'hono/cors';
|
|
4
4
|
import { prettyJSON } from 'hono/pretty-json';
|
|
5
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
|
+
}
|
|
6
50
|
export class JotDB extends DurableObject {
|
|
51
|
+
data = {};
|
|
52
|
+
rawSchema = {};
|
|
53
|
+
zodSchema = null;
|
|
54
|
+
options = {
|
|
55
|
+
autoStrip: false,
|
|
56
|
+
readOnly: false,
|
|
57
|
+
};
|
|
58
|
+
auditLog = [];
|
|
59
|
+
store = null;
|
|
7
60
|
constructor(state, env) {
|
|
8
61
|
super(state, env);
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
this.zodSchema = null;
|
|
12
|
-
this.options = {
|
|
13
|
-
autoStrip: false,
|
|
14
|
-
readOnly: false,
|
|
15
|
-
};
|
|
16
|
-
this.auditLog = [];
|
|
62
|
+
if (state.storage?.sql)
|
|
63
|
+
this.store = new JotStore(new SQLiteStoreAdapter(state.storage.sql));
|
|
17
64
|
}
|
|
18
65
|
async load() {
|
|
19
66
|
if (this.data == null || (typeof this.data === 'object' && Object.keys(this.data).length === 0)) {
|
|
@@ -36,19 +83,94 @@ export class JotDB extends DurableObject {
|
|
|
36
83
|
isArrayMode() {
|
|
37
84
|
return Array.isArray(this.data);
|
|
38
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
|
+
}
|
|
39
116
|
async push(item) {
|
|
40
117
|
await this.load();
|
|
41
118
|
if (!Array.isArray(this.data)) {
|
|
42
119
|
this.data = [];
|
|
43
120
|
}
|
|
44
|
-
this.
|
|
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);
|
|
45
138
|
await this.save();
|
|
46
139
|
await this.logAudit("push", []);
|
|
47
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
|
+
}
|
|
48
152
|
async setAll(objOrArr) {
|
|
49
153
|
await this.load();
|
|
50
|
-
if (!
|
|
51
|
-
|
|
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)));
|
|
52
174
|
}
|
|
53
175
|
this.data = objOrArr;
|
|
54
176
|
await this.save();
|
|
@@ -107,11 +229,17 @@ export class JotDB extends DurableObject {
|
|
|
107
229
|
}
|
|
108
230
|
warnSchemaDiff(newSchema) {
|
|
109
231
|
const current = this.rawSchema;
|
|
232
|
+
if (isArraySchema(newSchema) || isArraySchema(current))
|
|
233
|
+
return;
|
|
110
234
|
for (const key in newSchema) {
|
|
111
235
|
if (!(key in current))
|
|
112
236
|
console.warn(`[JotDB] New key added: ${key}`);
|
|
113
|
-
else
|
|
114
|
-
|
|
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
|
+
}
|
|
115
243
|
}
|
|
116
244
|
}
|
|
117
245
|
for (const key in current) {
|
|
@@ -129,6 +257,113 @@ export class JotDB extends DurableObject {
|
|
|
129
257
|
this.zodSchema = this.buildZodSchema(schemaObj);
|
|
130
258
|
await this.ctx.storage.put("__schema__", schemaObj);
|
|
131
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
|
+
}
|
|
132
367
|
async setOptions(opts) {
|
|
133
368
|
await this.load();
|
|
134
369
|
Object.assign(this.options, opts);
|
|
@@ -147,45 +382,110 @@ export class JotDB extends DurableObject {
|
|
|
147
382
|
await this.ctx.storage.put("__audit__", []);
|
|
148
383
|
}
|
|
149
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
|
|
150
402
|
const shape = {};
|
|
151
|
-
for (const [key,
|
|
152
|
-
|
|
403
|
+
for (const [key, spec] of Object.entries(schema)) {
|
|
404
|
+
const t = fieldType(spec);
|
|
405
|
+
let zt;
|
|
406
|
+
switch (t) {
|
|
153
407
|
case "string":
|
|
154
|
-
|
|
408
|
+
zt = z.string();
|
|
155
409
|
break;
|
|
156
410
|
case "number":
|
|
157
|
-
|
|
411
|
+
zt = z.number();
|
|
158
412
|
break;
|
|
159
413
|
case "boolean":
|
|
160
|
-
|
|
414
|
+
zt = z.boolean();
|
|
161
415
|
break;
|
|
162
416
|
case "email":
|
|
163
|
-
|
|
417
|
+
zt = z.string().email();
|
|
164
418
|
break;
|
|
165
419
|
case "array":
|
|
166
|
-
|
|
420
|
+
zt = z.array(z.any());
|
|
167
421
|
break;
|
|
168
422
|
case "object":
|
|
169
|
-
|
|
423
|
+
zt = z.record(z.string(), z.any());
|
|
170
424
|
break;
|
|
171
|
-
default:
|
|
172
|
-
|
|
425
|
+
default: zt = z.any();
|
|
426
|
+
}
|
|
427
|
+
const def = fieldDefault(spec);
|
|
428
|
+
if (def.has) {
|
|
429
|
+
zt = zt.default(def.value);
|
|
173
430
|
}
|
|
431
|
+
else if (fieldOptional(spec)) {
|
|
432
|
+
zt = zt.optional();
|
|
433
|
+
}
|
|
434
|
+
shape[key] = zt;
|
|
174
435
|
}
|
|
175
436
|
return z.object(shape);
|
|
176
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
|
+
}
|
|
177
451
|
async fetch(request) {
|
|
178
452
|
return new Response("Hello, World!");
|
|
179
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
|
+
}
|
|
180
474
|
async set(key, value) {
|
|
181
475
|
await this.load();
|
|
182
476
|
if (this.options.readOnly) {
|
|
183
477
|
throw new Error("Database is in read-only mode");
|
|
184
478
|
}
|
|
185
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
|
+
}
|
|
186
486
|
this.data[key] = value;
|
|
187
487
|
if (this.zodSchema) {
|
|
188
|
-
this.zodSchema.parse(this.data);
|
|
488
|
+
handleZod(() => this.zodSchema.parse(this.data));
|
|
189
489
|
}
|
|
190
490
|
await this.save();
|
|
191
491
|
await this.logAudit("set", key);
|
|
@@ -196,12 +496,32 @@ export class JotDB extends DurableObject {
|
|
|
196
496
|
}
|
|
197
497
|
}
|
|
198
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
|
+
});
|
|
199
518
|
// Middleware
|
|
200
519
|
app.use('*', cors());
|
|
201
520
|
app.use('*', prettyJSON());
|
|
202
521
|
// Test endpoint
|
|
203
522
|
app.get('/test', async (c) => {
|
|
204
|
-
const
|
|
523
|
+
const JOB_ID = Date.now().toString();
|
|
524
|
+
const id = c.env.JOTDB.idFromName(JOB_ID);
|
|
205
525
|
const db = c.env.JOTDB.get(id);
|
|
206
526
|
const results = {
|
|
207
527
|
timestamp: Date.now(),
|
|
@@ -265,17 +585,218 @@ app.get('/test', async (c) => {
|
|
|
265
585
|
passed: !("extra" in stripped),
|
|
266
586
|
value: stripped
|
|
267
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
|
+
const itemSchema = arrSchema.__arrayType;
|
|
641
|
+
arrPassed = itemSchema.foo === "string" && itemSchema.count === "number";
|
|
642
|
+
}
|
|
643
|
+
results.tests.push({
|
|
644
|
+
name: "Array mode: inferred schema",
|
|
645
|
+
passed: arrPassed,
|
|
646
|
+
value: arrSchema
|
|
647
|
+
});
|
|
648
|
+
let arrError = null;
|
|
649
|
+
try {
|
|
650
|
+
await arrDb.push({ foo: 123, count: "not a number" });
|
|
651
|
+
}
|
|
652
|
+
catch (e) {
|
|
653
|
+
arrError = e instanceof Error ? e.message : String(e);
|
|
654
|
+
}
|
|
655
|
+
results.tests.push({
|
|
656
|
+
name: "Array mode: invalid item fails",
|
|
657
|
+
passed: !!arrError,
|
|
658
|
+
error: arrError
|
|
659
|
+
});
|
|
660
|
+
// Test 0: Clear database, set options to read-only: false
|
|
661
|
+
await db.clear();
|
|
662
|
+
results.tests.push({
|
|
663
|
+
name: "Clear database",
|
|
664
|
+
passed: true,
|
|
665
|
+
value: await db.getAll()
|
|
666
|
+
});
|
|
268
667
|
// Get audit log
|
|
269
668
|
results.auditLog = await db.getAuditLog();
|
|
270
|
-
|
|
669
|
+
// HTML output
|
|
670
|
+
let html = `<!DOCTYPE html><html><head><title>JotDB Test Results</title>
|
|
671
|
+
<style>
|
|
672
|
+
body { font-family: sans-serif; margin: 2em; }
|
|
673
|
+
.pass { color: green; }
|
|
674
|
+
.fail { color: red; }
|
|
675
|
+
.test { margin-bottom: 1em; }
|
|
676
|
+
pre { background: #f4f4f4; padding: 0.5em; }
|
|
677
|
+
</style>
|
|
678
|
+
</head><body>
|
|
679
|
+
<h1>JotDB Test Results</h1>
|
|
680
|
+
<p><b>Timestamp:</b> ${new Date(results.timestamp).toLocaleString()}</p>
|
|
681
|
+
<div>
|
|
682
|
+
${results.tests.map(test => `
|
|
683
|
+
<div class="test">
|
|
684
|
+
<b>${test.name}:</b> <span class="${test.passed ? 'pass' : 'fail'}">${test.passed ? 'PASS' : 'FAIL'}</span><br/>
|
|
685
|
+
<pre>${JSON.stringify(test.value ?? test.error, null, 2)}</pre>
|
|
686
|
+
</div>
|
|
687
|
+
`).join('')}
|
|
688
|
+
</div>
|
|
689
|
+
<h2>Audit Log</h2>
|
|
690
|
+
<pre>${JSON.stringify(results.auditLog, null, 2)}</pre>
|
|
691
|
+
</body></html>`;
|
|
692
|
+
return new Response(html, { headers: { 'Content-Type': 'text/html' } });
|
|
271
693
|
}
|
|
272
694
|
catch (error) {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
695
|
+
console.error(error);
|
|
696
|
+
let errorMessage = error instanceof Error ? error.message : String(error);
|
|
697
|
+
if (isZodError(error)) {
|
|
698
|
+
console.error(error.issues);
|
|
699
|
+
errorMessage = error.issues.map(issue => issue.message).join('\n');
|
|
700
|
+
}
|
|
701
|
+
let html = `<!DOCTYPE html><html><head><title>JotDB Test Error</title></head><body>` +
|
|
702
|
+
`<h1 style="color:red">Error</h1>` +
|
|
703
|
+
`<pre>${errorMessage}</pre>` +
|
|
704
|
+
`<h2>Partial Results</h2>` +
|
|
705
|
+
`<pre>${JSON.stringify(results.tests, null, 2)}</pre>` +
|
|
706
|
+
`<h2>Audit Log</h2>` +
|
|
707
|
+
`<pre>${JSON.stringify(results.auditLog, null, 2)}</pre>` +
|
|
708
|
+
`</body></html>`;
|
|
709
|
+
return new Response(html, { headers: { 'Content-Type': 'text/html' } });
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
function percentile(values, p) {
|
|
713
|
+
if (values.length === 0)
|
|
714
|
+
return 0;
|
|
715
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
716
|
+
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))];
|
|
717
|
+
}
|
|
718
|
+
async function measure(fn, timings) {
|
|
719
|
+
const start = performance.now();
|
|
720
|
+
const result = await fn();
|
|
721
|
+
timings.push(performance.now() - start);
|
|
722
|
+
return result;
|
|
723
|
+
}
|
|
724
|
+
app.get('/bench', async (c) => {
|
|
725
|
+
const mode = c.req.query('mode') ?? 'user-prefs';
|
|
726
|
+
const count = Math.min(Number(c.req.query('count') ?? 100), 1000);
|
|
727
|
+
const timings = [];
|
|
728
|
+
const started = performance.now();
|
|
729
|
+
let operations = 0;
|
|
730
|
+
if (mode === 'user-prefs') {
|
|
731
|
+
const db = c.env.JOTDB.getByName(`bench:prefs:${Date.now()}`);
|
|
732
|
+
await db.setSchema({ theme: 'string', notifications: 'boolean', locale: 'string' });
|
|
733
|
+
for (let i = 0; i < count; i++) {
|
|
734
|
+
await measure(() => db.setAll({ theme: i % 2 ? 'dark' : 'light', notifications: true, locale: 'en' }), timings);
|
|
735
|
+
operations++;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
else if (mode === 'feature-flags') {
|
|
739
|
+
const db = c.env.JOTDB.getByName(`bench:flags:${Date.now()}`);
|
|
740
|
+
await db.setSchema({ darkMode: 'boolean', betaEditor: 'boolean', aiSearch: 'boolean' });
|
|
741
|
+
await db.setAll({ darkMode: true, betaEditor: false, aiSearch: true });
|
|
742
|
+
for (let i = 0; i < count; i++) {
|
|
743
|
+
await measure(() => db.getAll(), timings);
|
|
744
|
+
operations++;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
else if (mode === 'chat-append') {
|
|
748
|
+
const db = c.env.JOTDB.getByName(`bench:chat:${Date.now()}`);
|
|
749
|
+
await db.setAll([]);
|
|
750
|
+
for (let i = 0; i < count; i++) {
|
|
751
|
+
await measure(() => db.push({ id: i, body: `message ${i}` }), timings);
|
|
752
|
+
operations++;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
else if (mode === 'hot-key') {
|
|
756
|
+
const db = c.env.JOTDB.getByName(`bench:hot:${Date.now()}`);
|
|
757
|
+
await db.setSchema({ counter: 'number' });
|
|
758
|
+
await db.setAll({ counter: 0 });
|
|
759
|
+
await Promise.all(Array.from({ length: count }, async (_, i) => { await measure(() => db.setAll({ counter: i }), timings); }));
|
|
760
|
+
operations = count;
|
|
761
|
+
}
|
|
762
|
+
else if (mode === 'multi-instance') {
|
|
763
|
+
await Promise.all(Array.from({ length: count }, async (_, i) => {
|
|
764
|
+
const db = c.env.JOTDB.getByName(`bench:user:${Date.now()}:${i}`);
|
|
765
|
+
await measure(() => db.set('id', i), timings);
|
|
766
|
+
}));
|
|
767
|
+
operations = count;
|
|
768
|
+
}
|
|
769
|
+
else if (mode === 'cold-warm') {
|
|
770
|
+
const db = c.env.JOTDB.getByName(`bench:cold:${Date.now()}`);
|
|
771
|
+
await measure(() => db.set('first', true), timings);
|
|
772
|
+
operations++;
|
|
773
|
+
for (let i = 0; i < count; i++) {
|
|
774
|
+
await measure(() => db.get('first'), timings);
|
|
775
|
+
operations++;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
else if (mode === 'schema-validation') {
|
|
779
|
+
const db = c.env.JOTDB.getByName(`bench:schema:${Date.now()}`);
|
|
780
|
+
await db.setSchema({ name: 'string', age: 'number', email: 'email' });
|
|
781
|
+
for (let i = 0; i < count; i++) {
|
|
782
|
+
await measure(() => db.setAll({ name: `User ${i}`, age: i, email: `u${i}@example.com` }), timings);
|
|
783
|
+
operations++;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
else {
|
|
787
|
+
return c.json({ error: `Unknown benchmark mode: ${mode}` }, 400);
|
|
278
788
|
}
|
|
789
|
+
const durationMs = performance.now() - started;
|
|
790
|
+
return c.json({
|
|
791
|
+
mode,
|
|
792
|
+
operations,
|
|
793
|
+
durationMs,
|
|
794
|
+
opsPerSecond: operations / (durationMs / 1000),
|
|
795
|
+
p50Ms: percentile(timings, 0.5),
|
|
796
|
+
p95Ms: percentile(timings, 0.95),
|
|
797
|
+
p99Ms: percentile(timings, 0.99),
|
|
798
|
+
errors: 0,
|
|
799
|
+
});
|
|
279
800
|
});
|
|
280
801
|
// Health check endpoint
|
|
281
802
|
app.get('/', (c) => c.text('JotDB Durable Object'));
|