@bod.ee/db 0.7.0

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 (65) hide show
  1. package/.claude/settings.local.json +23 -0
  2. package/.claude/skills/config-file.md +54 -0
  3. package/.claude/skills/deploying-bod-db.md +29 -0
  4. package/.claude/skills/developing-bod-db.md +127 -0
  5. package/.claude/skills/using-bod-db.md +403 -0
  6. package/CLAUDE.md +110 -0
  7. package/README.md +252 -0
  8. package/admin/rules.ts +12 -0
  9. package/admin/server.ts +523 -0
  10. package/admin/ui.html +2281 -0
  11. package/cli.ts +177 -0
  12. package/client.ts +2 -0
  13. package/config.ts +20 -0
  14. package/deploy/.env.example +1 -0
  15. package/deploy/base.yaml +18 -0
  16. package/deploy/boddb-logs.yaml +10 -0
  17. package/deploy/boddb.yaml +10 -0
  18. package/deploy/demo.html +196 -0
  19. package/deploy/deploy.ts +32 -0
  20. package/deploy/prod-logs.config.ts +15 -0
  21. package/deploy/prod.config.ts +15 -0
  22. package/index.ts +20 -0
  23. package/mcp.ts +78 -0
  24. package/package.json +29 -0
  25. package/react.ts +1 -0
  26. package/src/client/BodClient.ts +515 -0
  27. package/src/react/hooks.ts +121 -0
  28. package/src/server/BodDB.ts +319 -0
  29. package/src/server/ExpressionRules.ts +250 -0
  30. package/src/server/FTSEngine.ts +76 -0
  31. package/src/server/FileAdapter.ts +116 -0
  32. package/src/server/MCPAdapter.ts +409 -0
  33. package/src/server/MQEngine.ts +286 -0
  34. package/src/server/QueryEngine.ts +45 -0
  35. package/src/server/RulesEngine.ts +108 -0
  36. package/src/server/StorageEngine.ts +464 -0
  37. package/src/server/StreamEngine.ts +320 -0
  38. package/src/server/SubscriptionEngine.ts +120 -0
  39. package/src/server/Transport.ts +479 -0
  40. package/src/server/VectorEngine.ts +115 -0
  41. package/src/shared/errors.ts +15 -0
  42. package/src/shared/pathUtils.ts +94 -0
  43. package/src/shared/protocol.ts +59 -0
  44. package/src/shared/transforms.ts +99 -0
  45. package/tests/batch.test.ts +60 -0
  46. package/tests/bench.ts +205 -0
  47. package/tests/e2e.test.ts +284 -0
  48. package/tests/expression-rules.test.ts +114 -0
  49. package/tests/file-adapter.test.ts +57 -0
  50. package/tests/fts.test.ts +58 -0
  51. package/tests/mq-flow.test.ts +204 -0
  52. package/tests/mq.test.ts +326 -0
  53. package/tests/push.test.ts +55 -0
  54. package/tests/query.test.ts +60 -0
  55. package/tests/rules.test.ts +78 -0
  56. package/tests/sse.test.ts +78 -0
  57. package/tests/storage.test.ts +199 -0
  58. package/tests/stream.test.ts +385 -0
  59. package/tests/stress.test.ts +202 -0
  60. package/tests/subscriptions.test.ts +86 -0
  61. package/tests/transforms.test.ts +92 -0
  62. package/tests/transport.test.ts +209 -0
  63. package/tests/ttl.test.ts +70 -0
  64. package/tests/vector.test.ts +69 -0
  65. package/tsconfig.json +27 -0
@@ -0,0 +1,94 @@
1
+ /** Normalize path: strip leading/trailing slashes, collapse double slashes */
2
+ export function normalizePath(path: string): string {
3
+ return path.replace(/\/+/g, '/').replace(/^\/+|\/+$/g, '');
4
+ }
5
+
6
+ /** Validate path segments — no empty segments, no special chars */
7
+ export function validatePath(path: string): string {
8
+ const normalized = normalizePath(path);
9
+ if (!normalized) throw new Error('Path cannot be empty');
10
+ if (/\/\//.test(normalized) || normalized.split('/').some(s => !s)) {
11
+ throw new Error(`Invalid path: "${path}"`);
12
+ }
13
+ return normalized;
14
+ }
15
+
16
+ /** Get all ancestor paths: 'a/b/c' → ['a/b', 'a'] */
17
+ export function ancestors(path: string): string[] {
18
+ const parts = path.split('/');
19
+ const result: string[] = [];
20
+ for (let i = parts.length - 1; i > 0; i--) {
21
+ result.push(parts.slice(0, i).join('/'));
22
+ }
23
+ return result;
24
+ }
25
+
26
+ /** Get the parent path, or null for root-level */
27
+ export function parentPath(path: string): string | null {
28
+ const idx = path.lastIndexOf('/');
29
+ return idx > 0 ? path.slice(0, idx) : null;
30
+ }
31
+
32
+ /** Get the key (last segment) of a path */
33
+ export function pathKey(path: string): string {
34
+ const idx = path.lastIndexOf('/');
35
+ return idx >= 0 ? path.slice(idx + 1) : path;
36
+ }
37
+
38
+ /**
39
+ * Compute the exclusive upper bound for prefix scans.
40
+ * Appends high Unicode char instead of char increment — safe for all valid path chars.
41
+ */
42
+ export function prefixEnd(prefix: string): string {
43
+ return prefix + '\uffff';
44
+ }
45
+
46
+ /** Flatten a nested object into leaf path→value pairs */
47
+ export function flatten(basePath: string, value: unknown): Array<{ path: string; value: string }> {
48
+ const results: Array<{ path: string; value: string }> = [];
49
+
50
+ function walk(currentPath: string, val: unknown) {
51
+ if (val === null || val === undefined) return;
52
+ if (typeof val === 'object' && !Array.isArray(val)) {
53
+ const obj = val as Record<string, unknown>;
54
+ const keys = Object.keys(obj);
55
+ if (keys.length === 0) {
56
+ results.push({ path: currentPath, value: JSON.stringify(val) });
57
+ return;
58
+ }
59
+ for (const key of keys) {
60
+ walk(currentPath ? `${currentPath}/${key}` : key, obj[key]);
61
+ }
62
+ } else {
63
+ results.push({ path: currentPath, value: JSON.stringify(val) });
64
+ }
65
+ }
66
+
67
+ walk(basePath, value);
68
+ return results;
69
+ }
70
+
71
+ /** Reconstruct a nested object from leaf path→value rows */
72
+ export function reconstruct(basePath: string, rows: Array<{ path: string; value: string }>): unknown {
73
+ if (rows.length === 0) return null;
74
+ if (rows.length === 1 && rows[0].path === basePath) {
75
+ return JSON.parse(rows[0].value);
76
+ }
77
+
78
+ const root: Record<string, unknown> = {};
79
+ for (const row of rows) {
80
+ // For empty basePath, use the full row.path; otherwise strip basePath + '/'
81
+ const relPath = basePath ? row.path.slice(basePath.length + 1) : row.path;
82
+ if (!relPath) continue;
83
+ const parts = relPath.split('/');
84
+ let current: Record<string, unknown> = root;
85
+ for (let i = 0; i < parts.length - 1; i++) {
86
+ if (!(parts[i] in current) || typeof current[parts[i]] !== 'object') {
87
+ current[parts[i]] = {};
88
+ }
89
+ current = current[parts[i]] as Record<string, unknown>;
90
+ }
91
+ current[parts[parts.length - 1]] = JSON.parse(row.value);
92
+ }
93
+ return root;
94
+ }
@@ -0,0 +1,59 @@
1
+ // Client → Server messages
2
+ export type ClientMessage =
3
+ | { id: string; op: 'auth'; token: string }
4
+ | { id: string; op: 'get'; path: string; shallow?: boolean }
5
+ | { id: string; op: 'set'; path: string; value: unknown; ttl?: number }
6
+ | { id: string; op: 'update'; updates: Record<string, unknown> }
7
+ | { id: string; op: 'delete'; path: string }
8
+ | { id: string; op: 'query'; path: string; filters?: QueryFilter[]; order?: OrderClause; limit?: number; offset?: number }
9
+ | { id: string; op: 'sub'; path: string; event: SubEvent }
10
+ | { id: string; op: 'unsub'; path: string; event: SubEvent }
11
+ | { id: string; op: 'batch'; operations: BatchOp[] }
12
+ | { id: string; op: 'push'; path: string; value: unknown; idempotencyKey?: string }
13
+ | { id: string; op: 'stream-read'; path: string; groupId: string; limit?: number }
14
+ | { id: string; op: 'stream-ack'; path: string; groupId: string; key: string }
15
+ | { id: string; op: 'stream-sub'; path: string; groupId: string }
16
+ | { id: string; op: 'stream-unsub'; path: string; groupId: string }
17
+ | { id: string; op: 'mq-push'; path: string; value: unknown; idempotencyKey?: string }
18
+ | { id: string; op: 'mq-fetch'; path: string; count?: number }
19
+ | { id: string; op: 'mq-ack'; path: string; key: string }
20
+ | { id: string; op: 'mq-nack'; path: string; key: string }
21
+ | { id: string; op: 'mq-peek'; path: string; count?: number }
22
+ | { id: string; op: 'mq-dlq'; path: string }
23
+ | { id: string; op: 'mq-purge'; path: string; all?: boolean }
24
+ | { id: string; op: 'fts-search'; text: string; path?: string; limit?: number }
25
+ | { id: string; op: 'fts-index'; path: string; content?: string; fields?: string[] }
26
+ | { id: string; op: 'vector-search'; query: number[]; path?: string; limit?: number; threshold?: number }
27
+ | { id: string; op: 'vector-store'; path: string; embedding: number[] }
28
+ | { id: string; op: 'stream-snapshot'; path: string }
29
+ | { id: string; op: 'stream-materialize'; path: string; keepKey?: string }
30
+ | { id: string; op: 'stream-compact'; path: string; maxAge?: number; maxCount?: number; keepKey?: string }
31
+ | { id: string; op: 'stream-reset'; path: string };
32
+
33
+ // Server → Client messages
34
+ export type ServerMessage =
35
+ | { id: string; ok: true; data?: unknown }
36
+ | { id: string; ok: false; error: string; code: string }
37
+ | { type: 'value'; path: string; data: unknown }
38
+ | { type: 'child'; path: string; key: string; data: unknown; event: ChildEvent }
39
+ | { type: 'stream'; path: string; groupId: string; events: Array<{ key: string; data: unknown }> };
40
+
41
+ export type SubEvent = 'value' | 'child';
42
+ export type ChildEvent = 'added' | 'changed' | 'removed';
43
+
44
+ export interface QueryFilter {
45
+ field: string;
46
+ op: '==' | '!=' | '<' | '<=' | '>' | '>=';
47
+ value: unknown;
48
+ }
49
+
50
+ export interface OrderClause {
51
+ field: string;
52
+ dir: 'asc' | 'desc';
53
+ }
54
+
55
+ export type BatchOp =
56
+ | { op: 'set'; path: string; value: unknown }
57
+ | { op: 'update'; updates: Record<string, unknown> }
58
+ | { op: 'delete'; path: string }
59
+ | { op: 'push'; path: string; value: unknown };
@@ -0,0 +1,99 @@
1
+ /** Sentinel marker for transform operations */
2
+ const SENTINEL = Symbol.for('bod-db:sentinel');
3
+
4
+ export type SentinelType = 'increment' | 'serverTimestamp' | 'arrayUnion' | 'arrayRemove' | 'ref';
5
+
6
+ interface SentinelBase {
7
+ [SENTINEL]: SentinelType;
8
+ }
9
+
10
+ export interface IncrementSentinel extends SentinelBase { delta: number }
11
+ export interface ServerTimestampSentinel extends SentinelBase {}
12
+ export interface ArrayUnionSentinel extends SentinelBase { items: unknown[] }
13
+ export interface ArrayRemoveSentinel extends SentinelBase { items: unknown[] }
14
+ export interface RefSentinel extends SentinelBase { path: string }
15
+
16
+ export type Sentinel = IncrementSentinel | ServerTimestampSentinel | ArrayUnionSentinel | ArrayRemoveSentinel | RefSentinel;
17
+
18
+ export function isSentinel(val: unknown): val is Sentinel {
19
+ return val !== null && typeof val === 'object' && SENTINEL in val;
20
+ }
21
+
22
+ export function sentinelType(val: Sentinel): SentinelType {
23
+ return val[SENTINEL];
24
+ }
25
+
26
+ // Factory functions
27
+ export function increment(delta: number): IncrementSentinel {
28
+ return { [SENTINEL]: 'increment', delta };
29
+ }
30
+
31
+ export function serverTimestamp(): ServerTimestampSentinel {
32
+ return { [SENTINEL]: 'serverTimestamp' };
33
+ }
34
+
35
+ export function arrayUnion(...items: unknown[]): ArrayUnionSentinel {
36
+ return { [SENTINEL]: 'arrayUnion', items };
37
+ }
38
+
39
+ export function arrayRemove(...items: unknown[]): ArrayRemoveSentinel {
40
+ return { [SENTINEL]: 'arrayRemove', items };
41
+ }
42
+
43
+ export function ref(path: string): RefSentinel {
44
+ return { [SENTINEL]: 'ref', path };
45
+ }
46
+
47
+ /** Resolve sentinels in a value tree against current data */
48
+ export function resolveTransforms(
49
+ value: unknown,
50
+ getCurrentValue: () => unknown,
51
+ ): unknown {
52
+ if (isSentinel(value)) {
53
+ return resolveSentinel(value, getCurrentValue());
54
+ }
55
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
56
+ const obj = value as Record<string, unknown>;
57
+ const current = getCurrentValue();
58
+ const currentObj = (current !== null && typeof current === 'object' && !Array.isArray(current))
59
+ ? current as Record<string, unknown>
60
+ : {};
61
+ const result: Record<string, unknown> = {};
62
+ for (const [key, val] of Object.entries(obj)) {
63
+ result[key] = resolveTransforms(val, () => currentObj[key] ?? null);
64
+ }
65
+ return result;
66
+ }
67
+ return value;
68
+ }
69
+
70
+ function resolveSentinel(sentinel: Sentinel, currentValue: unknown): unknown {
71
+ switch (sentinelType(sentinel)) {
72
+ case 'increment': {
73
+ const current = typeof currentValue === 'number' ? currentValue : 0;
74
+ return current + (sentinel as IncrementSentinel).delta;
75
+ }
76
+ case 'serverTimestamp':
77
+ return Date.now();
78
+ case 'arrayUnion': {
79
+ const current = Array.isArray(currentValue) ? currentValue : [];
80
+ const items = (sentinel as ArrayUnionSentinel).items;
81
+ const set = new Set(current.map(i => JSON.stringify(i)));
82
+ const result = [...current];
83
+ for (const item of items) {
84
+ if (!set.has(JSON.stringify(item))) result.push(item);
85
+ }
86
+ return result;
87
+ }
88
+ case 'arrayRemove': {
89
+ const current = Array.isArray(currentValue) ? currentValue : [];
90
+ const remove = new Set((sentinel as ArrayRemoveSentinel).items.map(i => JSON.stringify(i)));
91
+ return current.filter(i => !remove.has(JSON.stringify(i)));
92
+ }
93
+ case 'ref':
94
+ // Refs are stored as-is; resolved at read time
95
+ return { _ref: (sentinel as RefSentinel).path };
96
+ default:
97
+ return sentinel;
98
+ }
99
+ }
@@ -0,0 +1,60 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { BodDB } from '../src/server/BodDB.ts';
3
+
4
+ describe('Transaction & Batch', () => {
5
+ test('transaction wraps multiple ops atomically', () => {
6
+ const db = new BodDB({ sweepInterval: 0 });
7
+ db.transaction((tx) => {
8
+ tx.set('a', 1);
9
+ tx.set('b', 2);
10
+ tx.set('c', 3);
11
+ });
12
+ expect(db.get('a')).toBe(1);
13
+ expect(db.get('b')).toBe(2);
14
+ expect(db.get('c')).toBe(3);
15
+ db.close();
16
+ });
17
+
18
+ test('transaction rolls back on error', () => {
19
+ const db = new BodDB({ sweepInterval: 0 });
20
+ db.set('x', 'original');
21
+ try {
22
+ db.transaction((tx) => {
23
+ tx.set('x', 'modified');
24
+ throw new Error('rollback');
25
+ });
26
+ } catch {}
27
+ expect(db.get('x')).toBe('original');
28
+ db.close();
29
+ });
30
+
31
+ test('transaction fires notifications after commit', () => {
32
+ const db = new BodDB({ sweepInterval: 0 });
33
+ const events: unknown[] = [];
34
+ db.on('a', (snap) => events.push(snap.val()));
35
+
36
+ db.transaction((tx) => {
37
+ tx.set('a', 'hello');
38
+ });
39
+ expect(events).toEqual(['hello']);
40
+ db.close();
41
+ });
42
+
43
+ test('transaction supports get/set/update/delete', () => {
44
+ const db = new BodDB({ sweepInterval: 0 });
45
+ db.set('item', { name: 'test', count: 1 });
46
+
47
+ db.transaction((tx) => {
48
+ const val = tx.get('item') as Record<string, unknown>;
49
+ tx.update({ 'item': { count: (val.count as number) + 1 } });
50
+ });
51
+ expect(db.get('item/count')).toBe(2);
52
+ expect(db.get('item/name')).toBe('test');
53
+
54
+ db.transaction((tx) => {
55
+ tx.delete('item/count');
56
+ });
57
+ expect(db.get('item/count')).toBeNull();
58
+ db.close();
59
+ });
60
+ });
package/tests/bench.ts ADDED
@@ -0,0 +1,205 @@
1
+ import { BodDB } from '../src/server/BodDB.ts';
2
+
3
+ const SEED_COUNT = 1000;
4
+ const OPS = 10000;
5
+ const SUB_WRITES = 1000;
6
+ const SUB_COUNT = 100;
7
+ const ROLES = ['admin', 'user', 'editor', 'viewer'];
8
+
9
+ interface Result {
10
+ name: string;
11
+ ops: number;
12
+ opsPerSec: number;
13
+ p50: number;
14
+ p99: number;
15
+ totalMs: number;
16
+ }
17
+
18
+ function percentile(sorted: number[], p: number): number {
19
+ const idx = Math.ceil((p / 100) * sorted.length) - 1;
20
+ return sorted[Math.max(0, idx)];
21
+ }
22
+
23
+ function fmt(n: number): string {
24
+ return n.toLocaleString('en-US', { maximumFractionDigits: 0 });
25
+ }
26
+
27
+ function fmtMs(n: number): string {
28
+ return n.toFixed(2);
29
+ }
30
+
31
+ async function seed(db: BodDB) {
32
+ for (let i = 0; i < SEED_COUNT; i++) {
33
+ db.set(`bench/item${i}`, {
34
+ i,
35
+ name: `item_${i}`,
36
+ score: Math.random() * 1000,
37
+ role: ROLES[Math.floor(Math.random() * ROLES.length)],
38
+ });
39
+ }
40
+ }
41
+
42
+ function randKey(): string {
43
+ return `bench/item${Math.floor(Math.random() * SEED_COUNT)}`;
44
+ }
45
+
46
+ function randRecord(i: number) {
47
+ return { i, name: `item_${i}`, score: Math.random() * 1000, role: ROLES[Math.floor(Math.random() * ROLES.length)] };
48
+ }
49
+
50
+ async function runWorkload(name: string, fn: (db: BodDB) => Promise<{ latencies: number[]; ops: number }>): Promise<Result> {
51
+ const db = new BodDB({ path: ':memory:' });
52
+ await seed(db);
53
+ const { latencies, ops } = await fn(db);
54
+ db.close();
55
+
56
+ latencies.sort((a, b) => a - b);
57
+ const totalMs = latencies.reduce((s, v) => s + v, 0);
58
+ return {
59
+ name,
60
+ ops,
61
+ opsPerSec: Math.round(ops / (totalMs / 1000)),
62
+ p50: percentile(latencies, 50),
63
+ p99: percentile(latencies, 99),
64
+ totalMs,
65
+ };
66
+ }
67
+
68
+ // Workload A: 50% read, 50% write
69
+ async function workloadA(db: BodDB) {
70
+ const latencies: number[] = [];
71
+ for (let i = 0; i < OPS; i++) {
72
+ const t0 = performance.now();
73
+ if (Math.random() < 0.5) {
74
+ db.get(randKey());
75
+ } else {
76
+ db.set(randKey(), randRecord(SEED_COUNT + i));
77
+ }
78
+ latencies.push(performance.now() - t0);
79
+ }
80
+ return { latencies, ops: OPS };
81
+ }
82
+
83
+ // Workload B: 95% read, 5% write
84
+ async function workloadB(db: BodDB) {
85
+ const latencies: number[] = [];
86
+ for (let i = 0; i < OPS; i++) {
87
+ const t0 = performance.now();
88
+ if (Math.random() < 0.95) {
89
+ db.get(randKey());
90
+ } else {
91
+ db.set(randKey(), randRecord(SEED_COUNT + i));
92
+ }
93
+ latencies.push(performance.now() - t0);
94
+ }
95
+ return { latencies, ops: OPS };
96
+ }
97
+
98
+ // Workload C: 100% read
99
+ async function workloadC(db: BodDB) {
100
+ const latencies: number[] = [];
101
+ for (let i = 0; i < OPS; i++) {
102
+ const t0 = performance.now();
103
+ db.get(randKey());
104
+ latencies.push(performance.now() - t0);
105
+ }
106
+ return { latencies, ops: OPS };
107
+ }
108
+
109
+ // Workload D: 95% insert, 5% read
110
+ async function workloadD(db: BodDB) {
111
+ const latencies: number[] = [];
112
+ let insertIdx = SEED_COUNT;
113
+ for (let i = 0; i < OPS; i++) {
114
+ const t0 = performance.now();
115
+ if (Math.random() < 0.95) {
116
+ db.set(`bench/item${insertIdx++}`, randRecord(insertIdx));
117
+ } else {
118
+ db.get(randKey());
119
+ }
120
+ latencies.push(performance.now() - t0);
121
+ }
122
+ return { latencies, ops: OPS };
123
+ }
124
+
125
+ // Workload E: 95% prefix scan, 5% insert
126
+ async function workloadE(db: BodDB) {
127
+ const latencies: number[] = [];
128
+ let insertIdx = SEED_COUNT;
129
+ for (let i = 0; i < OPS; i++) {
130
+ const t0 = performance.now();
131
+ if (Math.random() < 0.95) {
132
+ db.get('bench');
133
+ } else {
134
+ db.set(`bench/item${insertIdx++}`, randRecord(insertIdx));
135
+ }
136
+ latencies.push(performance.now() - t0);
137
+ }
138
+ return { latencies, ops: OPS };
139
+ }
140
+
141
+ // Workload F: Subscription notify latency
142
+ async function workloadF(): Promise<Result> {
143
+ const db = new BodDB({ path: ':memory:' });
144
+ await seed(db);
145
+
146
+ const notifyLatencies: number[] = [];
147
+ const unsubs: (() => void)[] = [];
148
+
149
+ for (let s = 0; s < SUB_COUNT; s++) {
150
+ const key = `bench/item${s}`;
151
+ const unsub = db.on(key, () => {
152
+ // callback fired
153
+ });
154
+ unsubs.push(unsub);
155
+ }
156
+
157
+ const latencies: number[] = [];
158
+ for (let i = 0; i < SUB_WRITES; i++) {
159
+ const key = `bench/item${i % SUB_COUNT}`;
160
+ const t0 = performance.now();
161
+ db.set(key, randRecord(i));
162
+ latencies.push(performance.now() - t0);
163
+ }
164
+
165
+ unsubs.forEach((u) => u());
166
+ db.close();
167
+
168
+ latencies.sort((a, b) => a - b);
169
+ const totalMs = latencies.reduce((s, v) => s + v, 0);
170
+ return {
171
+ name: 'Workload F (subscription)',
172
+ ops: SUB_WRITES,
173
+ opsPerSec: Math.round(SUB_WRITES / (totalMs / 1000)),
174
+ p50: percentile(latencies, 50),
175
+ p99: percentile(latencies, 99),
176
+ totalMs,
177
+ };
178
+ }
179
+
180
+ async function main() {
181
+ console.log(`\nBodDB YCSB-inspired Benchmark\n${'='.repeat(60)}\n`);
182
+
183
+ const results: Result[] = [];
184
+
185
+ results.push(await runWorkload('Workload A (update-heavy)', workloadA));
186
+ results.push(await runWorkload('Workload B (read-heavy)', workloadB));
187
+ results.push(await runWorkload('Workload C (read-only)', workloadC));
188
+ results.push(await runWorkload('Workload D (insert-heavy)', workloadD));
189
+ results.push(await runWorkload('Workload E (scan)', workloadE));
190
+ results.push(await workloadF());
191
+
192
+ for (const r of results) {
193
+ console.log(`${r.name.padEnd(32)} ${fmt(r.opsPerSec).padStart(10)} ops/sec p50: ${fmtMs(r.p50)}ms p99: ${fmtMs(r.p99)}ms`);
194
+ }
195
+
196
+ console.log(`\n${'='.repeat(60)}`);
197
+ console.log(`${'Workload'.padEnd(32)} ${'ops/sec'.padStart(10)} ${'p50'.padStart(8)} ${'p99'.padStart(8)}`);
198
+ console.log(`${'-'.repeat(60)}`);
199
+ for (const r of results) {
200
+ console.log(`${r.name.padEnd(32)} ${fmt(r.opsPerSec).padStart(10)} ${(fmtMs(r.p50) + 'ms').padStart(8)} ${(fmtMs(r.p99) + 'ms').padStart(8)}`);
201
+ }
202
+ console.log();
203
+ }
204
+
205
+ main();