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
|
@@ -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;
|