@bod.ee/db 0.7.0 → 0.8.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.
@@ -0,0 +1,172 @@
1
+ import { generatePushId } from './StorageEngine.ts';
2
+ import type { BodDB } from './BodDB.ts';
3
+ import { normalizePath } from '../shared/pathUtils.ts';
4
+ import type { FileStat } from '../shared/protocol.ts';
5
+
6
+ // --- Backend interface ---
7
+
8
+ export interface VFSBackend {
9
+ read(fileId: string): Promise<Uint8Array>;
10
+ write(fileId: string, data: Uint8Array): Promise<void>;
11
+ delete(fileId: string): Promise<void>;
12
+ exists(fileId: string): Promise<boolean>;
13
+ }
14
+
15
+ // --- LocalBackend ---
16
+
17
+ export class LocalBackend implements VFSBackend {
18
+ constructor(private storageRoot: string) {}
19
+
20
+ private filePath(fileId: string): string {
21
+ if (/[\/\\]/.test(fileId)) throw new Error(`Invalid fileId: ${fileId}`);
22
+ return `${this.storageRoot}/${fileId}`;
23
+ }
24
+
25
+ async read(fileId: string): Promise<Uint8Array> {
26
+ const f = Bun.file(this.filePath(fileId));
27
+ if (!(await f.exists())) throw new Error(`File not found: ${fileId}`);
28
+ return new Uint8Array(await f.arrayBuffer());
29
+ }
30
+
31
+ async write(fileId: string, data: Uint8Array): Promise<void> {
32
+ await Bun.write(this.filePath(fileId), data);
33
+ }
34
+
35
+ async delete(fileId: string): Promise<void> {
36
+ const { unlink } = require('fs/promises');
37
+ try { await unlink(this.filePath(fileId)); } catch {}
38
+ }
39
+
40
+ async exists(fileId: string): Promise<boolean> {
41
+ return Bun.file(this.filePath(fileId)).exists();
42
+ }
43
+ }
44
+
45
+ export type { FileStat };
46
+
47
+ /** Reserved key for directory/file own metadata under its parent path */
48
+ const META_KEY = '__meta';
49
+
50
+ // --- VFSEngine ---
51
+
52
+ export class VFSEngineOptions {
53
+ storageRoot: string = '.data/vfs';
54
+ metaPrefix: string = '_vfs';
55
+ }
56
+
57
+ export class VFSEngine {
58
+ readonly options: VFSEngineOptions;
59
+ readonly backend: VFSBackend;
60
+ private db: BodDB;
61
+
62
+ constructor(db: BodDB, options?: Partial<VFSEngineOptions>) {
63
+ this.options = { ...new VFSEngineOptions(), ...options };
64
+ this.db = db;
65
+ this.backend = new LocalBackend(this.options.storageRoot);
66
+ const { mkdirSync } = require('fs');
67
+ try { mkdirSync(this.options.storageRoot, { recursive: true }); } catch {}
68
+ }
69
+
70
+ private metaPath(virtualPath: string): string {
71
+ return `${this.options.metaPrefix}/${normalizePath(virtualPath)}/${META_KEY}`;
72
+ }
73
+
74
+ /** Parent container path (for listing children) */
75
+ private containerPath(virtualPath: string): string {
76
+ const np = normalizePath(virtualPath);
77
+ return np ? `${this.options.metaPrefix}/${np}` : this.options.metaPrefix;
78
+ }
79
+
80
+ async write(virtualPath: string, data: Uint8Array, mime?: string): Promise<FileStat> {
81
+ const vp = normalizePath(virtualPath);
82
+ const existing = this.db.get(this.metaPath(vp)) as Record<string, unknown> | null;
83
+ const fileId = (existing?.fileId as string) || generatePushId();
84
+
85
+ await this.backend.write(fileId, data);
86
+
87
+ const name = vp.split('/').pop()!;
88
+ const stat: FileStat = {
89
+ name,
90
+ path: vp,
91
+ size: data.byteLength,
92
+ mime: mime || guessMime(name),
93
+ mtime: Date.now(),
94
+ fileId,
95
+ isDir: false,
96
+ };
97
+ this.db.set(this.metaPath(vp), stat);
98
+ return stat;
99
+ }
100
+
101
+ async read(virtualPath: string): Promise<Uint8Array> {
102
+ const vp = normalizePath(virtualPath);
103
+ const meta = this.db.get(this.metaPath(vp)) as Record<string, unknown> | null;
104
+ if (!meta?.fileId) throw new Error(`File not found: ${vp}`);
105
+ return this.backend.read(meta.fileId as string);
106
+ }
107
+
108
+ stat(virtualPath: string): FileStat | null {
109
+ const vp = normalizePath(virtualPath);
110
+ const meta = this.db.get(this.metaPath(vp)) as FileStat | null;
111
+ return meta || null;
112
+ }
113
+
114
+ list(virtualPath: string): FileStat[] {
115
+ const vp = normalizePath(virtualPath);
116
+ const children = this.db.getShallow(this.containerPath(vp));
117
+ const results: FileStat[] = [];
118
+ for (const c of children) {
119
+ if (c.key === META_KEY) continue; // skip own metadata
120
+ const childPath = vp ? `${vp}/${c.key}` : c.key;
121
+ const meta = this.db.get(this.metaPath(childPath)) as FileStat | null;
122
+ if (meta) {
123
+ results.push(meta);
124
+ } else {
125
+ // Implicit directory (has children but no explicit metadata)
126
+ results.push({ name: c.key, path: childPath, size: 0, mime: '', mtime: 0, isDir: true });
127
+ }
128
+ }
129
+ return results;
130
+ }
131
+
132
+ mkdir(virtualPath: string): FileStat {
133
+ const vp = normalizePath(virtualPath);
134
+ const name = vp.split('/').pop()!;
135
+ const stat: FileStat = { name, path: vp, size: 0, mime: '', mtime: Date.now(), isDir: true };
136
+ this.db.set(this.metaPath(vp), stat);
137
+ return stat;
138
+ }
139
+
140
+ async remove(virtualPath: string): Promise<void> {
141
+ const vp = normalizePath(virtualPath);
142
+ const meta = this.db.get(this.metaPath(vp)) as Record<string, unknown> | null;
143
+ if (meta?.fileId) {
144
+ await this.backend.delete(meta.fileId as string);
145
+ }
146
+ this.db.delete(this.containerPath(vp));
147
+ }
148
+
149
+ async move(src: string, dst: string): Promise<FileStat> {
150
+ const srcPath = normalizePath(src);
151
+ const dstPath = normalizePath(dst);
152
+ const meta = this.db.get(this.metaPath(srcPath)) as FileStat | null;
153
+ if (!meta) throw new Error(`File not found: ${srcPath}`);
154
+
155
+ const newName = dstPath.split('/').pop()!;
156
+ const updated: FileStat = { ...meta, name: newName, path: dstPath, mtime: Date.now() };
157
+ this.db.set(this.metaPath(dstPath), updated);
158
+ this.db.delete(this.containerPath(srcPath));
159
+ return updated;
160
+ }
161
+ }
162
+
163
+ function guessMime(name: string): string {
164
+ const ext = name.split('.').pop()?.toLowerCase();
165
+ const mimes: Record<string, string> = {
166
+ txt: 'text/plain', html: 'text/html', css: 'text/css', js: 'application/javascript',
167
+ json: 'application/json', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
168
+ gif: 'image/gif', svg: 'image/svg+xml', pdf: 'application/pdf',
169
+ zip: 'application/zip', mp3: 'audio/mpeg', mp4: 'video/mp4',
170
+ };
171
+ return mimes[ext || ''] || 'application/octet-stream';
172
+ }
@@ -28,15 +28,26 @@ export type ClientMessage =
28
28
  | { id: string; op: 'stream-snapshot'; path: string }
29
29
  | { id: string; op: 'stream-materialize'; path: string; keepKey?: string }
30
30
  | { id: string; op: 'stream-compact'; path: string; maxAge?: number; maxCount?: number; keepKey?: string }
31
- | { id: string; op: 'stream-reset'; path: string };
31
+ | { id: string; op: 'stream-reset'; path: string }
32
+ // VFS ops
33
+ | { id: string; op: 'vfs-upload-init'; path: string; size: number; mime?: string }
34
+ | { id: string; op: 'vfs-upload-chunk'; transferId: string; data: string; seq: number }
35
+ | { id: string; op: 'vfs-upload-done'; transferId: string }
36
+ | { id: string; op: 'vfs-download-init'; path: string }
37
+ | { id: string; op: 'vfs-stat'; path: string }
38
+ | { id: string; op: 'vfs-list'; path: string }
39
+ | { id: string; op: 'vfs-delete'; path: string }
40
+ | { id: string; op: 'vfs-mkdir'; path: string }
41
+ | { id: string; op: 'vfs-move'; path: string; dst: string };
32
42
 
33
43
  // Server → Client messages
34
44
  export type ServerMessage =
35
- | { id: string; ok: true; data?: unknown }
45
+ | { id: string; ok: true; data?: unknown; updatedAt?: number }
36
46
  | { id: string; ok: false; error: string; code: string }
37
- | { type: 'value'; path: string; data: unknown }
47
+ | { type: 'value'; path: string; data: unknown; updatedAt?: number }
38
48
  | { type: 'child'; path: string; key: string; data: unknown; event: ChildEvent }
39
- | { type: 'stream'; path: string; groupId: string; events: Array<{ key: string; data: unknown }> };
49
+ | { type: 'stream'; path: string; groupId: string; events: Array<{ key: string; data: unknown }> }
50
+ | { type: 'vfs-download-chunk'; transferId: string; data: string; seq: number; done: boolean };
40
51
 
41
52
  export type SubEvent = 'value' | 'child';
42
53
  export type ChildEvent = 'added' | 'changed' | 'removed';
@@ -52,6 +63,16 @@ export interface OrderClause {
52
63
  dir: 'asc' | 'desc';
53
64
  }
54
65
 
66
+ export interface FileStat {
67
+ name: string;
68
+ path: string;
69
+ size: number;
70
+ mime: string;
71
+ mtime: number;
72
+ fileId?: string;
73
+ isDir: boolean;
74
+ }
75
+
55
76
  export type BatchOp =
56
77
  | { op: 'set'; path: string; value: unknown }
57
78
  | { op: 'update'; updates: Record<string, unknown> }
@@ -0,0 +1,143 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2
+ import { BodDB } from '../src/server/BodDB.ts';
3
+ import { BodClient, ValueSnapshot } from '../src/client/BodClient.ts';
4
+ import { CachedClient } from '../src/client/CachedClient.ts';
5
+
6
+ let db: BodDB;
7
+ let client: BodClient;
8
+ let cached: CachedClient;
9
+
10
+ beforeAll(async () => {
11
+ db = new BodDB({ port: 0 });
12
+ const server = db.serve();
13
+ client = new BodClient({ url: `ws://localhost:${server.port}`, reconnect: false });
14
+ await client.connect();
15
+ cached = new CachedClient(client, { enabled: true });
16
+ });
17
+
18
+ afterAll(() => {
19
+ client.disconnect();
20
+ db.close();
21
+ });
22
+
23
+ describe('CachedClient', () => {
24
+ test('get() fetches from network on first call', async () => {
25
+ db.set('cache/test1', { name: 'hello' });
26
+ const val = await cached.get('cache/test1');
27
+ expect(val).toEqual({ name: 'hello' });
28
+ });
29
+
30
+ test('get() returns cached value on second call', async () => {
31
+ db.set('cache/test2', 42);
32
+ await cached.get('cache/test2');
33
+ // Modify server-side without going through cached client
34
+ db.set('cache/test2', 99);
35
+ // Memory cache still has old value, returns stale + triggers revalidate
36
+ const val = await cached.get('cache/test2');
37
+ expect(val).toBe(42); // stale from memory
38
+ });
39
+
40
+ test('subscription updates memory cache', async () => {
41
+ const values: unknown[] = [];
42
+ const off = cached.on('cache/sub1', (snap) => {
43
+ values.push(snap.val());
44
+ });
45
+
46
+ // Wait for initial subscription fire
47
+ await new Promise(r => setTimeout(r, 100));
48
+
49
+ db.set('cache/sub1', 'first');
50
+ await new Promise(r => setTimeout(r, 100));
51
+
52
+ // get() should return cached value from sub
53
+ const val = await cached.get('cache/sub1');
54
+ expect(val).toBe('first');
55
+
56
+ off();
57
+ });
58
+
59
+ test('set() invalidates cache', async () => {
60
+ db.set('cache/inv1', 'original');
61
+ await cached.get('cache/inv1');
62
+
63
+ await cached.set('cache/inv1', 'updated');
64
+ // Cache invalidated, next get() fetches from network
65
+ const val = await cached.get('cache/inv1');
66
+ expect(val).toBe('updated');
67
+ });
68
+
69
+ test('delete() invalidates cache', async () => {
70
+ db.set('cache/del1', 'exists');
71
+ await cached.get('cache/del1');
72
+
73
+ await cached.delete('cache/del1');
74
+ const val = await cached.get('cache/del1');
75
+ expect(val).toBeNull();
76
+ });
77
+
78
+ test('update() invalidates affected paths', async () => {
79
+ db.set('cache/upd/a', 1);
80
+ db.set('cache/upd/b', 2);
81
+ await cached.get('cache/upd/a');
82
+ await cached.get('cache/upd/b');
83
+
84
+ await cached.update({ 'cache/upd/a': 10, 'cache/upd/b': 20 });
85
+ const a = await cached.get('cache/upd/a');
86
+ const b = await cached.get('cache/upd/b');
87
+ expect(a).toBe(10);
88
+ expect(b).toBe(20);
89
+ });
90
+
91
+ test('disabled mode is pure passthrough', async () => {
92
+ const passthrough = new CachedClient(client, { enabled: false });
93
+ db.set('cache/pt', 'val');
94
+ const v1 = await passthrough.get('cache/pt');
95
+ expect(v1).toBe('val');
96
+ db.set('cache/pt', 'changed');
97
+ const v2 = await passthrough.get('cache/pt');
98
+ expect(v2).toBe('changed');
99
+ });
100
+
101
+ test('memory eviction respects maxMemoryEntries', async () => {
102
+ const small = new CachedClient(client, { maxMemoryEntries: 3 });
103
+ for (let i = 0; i < 5; i++) {
104
+ db.set(`cache/evict/${i}`, i);
105
+ await small.get(`cache/evict/${i}`);
106
+ }
107
+ // Only last 3 should be in memory
108
+ // Modify server values
109
+ for (let i = 0; i < 5; i++) db.set(`cache/evict/${i}`, i + 100);
110
+ // First two should be evicted (network fetch = new value)
111
+ const v0 = await small.get('cache/evict/0');
112
+ expect(v0).toBe(100); // evicted, fetched fresh
113
+ const v4 = await small.get('cache/evict/4');
114
+ expect(v4).toBe(4); // still cached (stale)
115
+ });
116
+
117
+ test('getSnapshot returns updatedAt', async () => {
118
+ db.set('cache/meta1', 'val');
119
+ const snap = await client.getSnapshot('cache/meta1');
120
+ expect(snap.val()).toBe('val');
121
+ expect(snap.updatedAt).toBeGreaterThan(0);
122
+ expect(snap).toBeInstanceOf(ValueSnapshot);
123
+ });
124
+
125
+ test('onChild invalidates parent cache', async () => {
126
+ db.set('cache/parent2/child1', 'a');
127
+ await cached.get('cache/parent2');
128
+
129
+ const events: string[] = [];
130
+ const off = cached.onChild('cache/parent2', (e) => {
131
+ events.push(e.type);
132
+ });
133
+
134
+ // Wait for sub to be established
135
+ await new Promise(r => setTimeout(r, 50));
136
+
137
+ db.set('cache/parent2/child2', 'b');
138
+ await new Promise(r => setTimeout(r, 150));
139
+
140
+ expect(events.length).toBeGreaterThan(0);
141
+ off();
142
+ });
143
+ });