@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.
- package/.claude/settings.local.json +7 -1
- package/.claude/skills/config-file.md +7 -0
- package/.claude/skills/deploying-bod-db.md +34 -0
- package/.claude/skills/developing-bod-db.md +20 -2
- package/.claude/skills/using-bod-db.md +165 -0
- package/.github/workflows/test-and-publish.yml +111 -0
- package/CLAUDE.md +10 -1
- package/README.md +57 -2
- package/admin/proxy.ts +79 -0
- package/admin/rules.ts +1 -1
- package/admin/server.ts +134 -50
- package/admin/ui.html +729 -18
- package/cli.ts +10 -0
- package/client.ts +3 -2
- package/config.ts +1 -0
- package/deploy/boddb-il.yaml +14 -0
- package/deploy/prod-il.config.ts +19 -0
- package/deploy/prod.config.ts +1 -0
- package/index.ts +3 -0
- package/package.json +7 -2
- package/src/client/BodClient.ts +129 -6
- package/src/client/CachedClient.ts +228 -0
- package/src/server/BodDB.ts +145 -1
- package/src/server/ReplicationEngine.ts +332 -0
- package/src/server/StorageEngine.ts +19 -0
- package/src/server/Transport.ts +577 -360
- package/src/server/VFSEngine.ts +172 -0
- package/src/shared/protocol.ts +25 -4
- package/tests/cached-client.test.ts +143 -0
- package/tests/replication.test.ts +404 -0
- package/tests/vfs.test.ts +166 -0
package/src/server/Transport.ts
CHANGED
|
@@ -5,11 +5,18 @@ import type { ClientMessage } from '../shared/protocol.ts';
|
|
|
5
5
|
import { Errors } from '../shared/errors.ts';
|
|
6
6
|
import { normalizePath, pathKey } from '../shared/pathUtils.ts';
|
|
7
7
|
|
|
8
|
-
interface
|
|
8
|
+
interface VfsTransfer {
|
|
9
|
+
path: string;
|
|
10
|
+
mime?: string;
|
|
11
|
+
chunks: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface WsData {
|
|
9
15
|
auth: Record<string, unknown> | null;
|
|
10
16
|
valueSubs: Map<string, () => void>;
|
|
11
17
|
childSubs: Map<string, () => void>;
|
|
12
18
|
streamSubs: Map<string, () => void>;
|
|
19
|
+
vfsTransfers: Map<string, VfsTransfer>;
|
|
13
20
|
}
|
|
14
21
|
|
|
15
22
|
export class TransportOptions {
|
|
@@ -33,437 +40,647 @@ export class Transport {
|
|
|
33
40
|
this.options = { ...new TransportOptions(), ...options };
|
|
34
41
|
}
|
|
35
42
|
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
return { token };
|
|
44
|
-
};
|
|
45
|
-
this.server = Bun.serve({
|
|
46
|
-
port: this.options.port,
|
|
47
|
-
fetch(req, server) {
|
|
48
|
-
const url = new URL(req.url);
|
|
43
|
+
private async extractAuth(req: Request): Promise<Record<string, unknown> | null> {
|
|
44
|
+
const header = req.headers.get('authorization');
|
|
45
|
+
if (!header?.startsWith('Bearer ')) return null;
|
|
46
|
+
const token = header.slice(7);
|
|
47
|
+
if (this.options.auth) return await this.options.auth(token);
|
|
48
|
+
return { token };
|
|
49
|
+
}
|
|
49
50
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
}
|
|
51
|
+
/** Create fresh WsData for a new WebSocket connection */
|
|
52
|
+
newWsData(): WsData {
|
|
53
|
+
return { auth: null, valueSubs: new Map(), childSubs: new Map(), streamSubs: new Map(), vfsTransfers: new Map() };
|
|
54
|
+
}
|
|
56
55
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Handle an HTTP request. Returns a Response, or undefined if the request
|
|
58
|
+
* was upgraded to WebSocket (caller should return immediately in that case).
|
|
59
|
+
* Pass `server` to enable WebSocket upgrades.
|
|
60
|
+
*/
|
|
61
|
+
handleFetch(req: Request, server?: Server): Response | Promise<Response | undefined> | undefined {
|
|
62
|
+
const url = new URL(req.url);
|
|
63
|
+
|
|
64
|
+
// WebSocket upgrade (only if server provided and client requests it)
|
|
65
|
+
if (server && req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
|
|
66
|
+
if (server.upgrade(req, { data: this.newWsData() })) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// REST: GET /db/<path> → get
|
|
72
|
+
if (req.method === 'GET' && url.pathname.startsWith('/db/')) {
|
|
73
|
+
return (async () => {
|
|
74
|
+
const path = normalizePath(url.pathname.slice(4));
|
|
75
|
+
const auth = await this.extractAuth(req);
|
|
76
|
+
if (this.rules && !this.rules.check('read', path, auth)) {
|
|
77
|
+
return Response.json({ ok: false, error: 'Permission denied', code: Errors.PERMISSION_DENIED }, { status: 403 });
|
|
68
78
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
79
|
+
const meta = this.db.storage.getWithMeta(path);
|
|
80
|
+
return Response.json({ ok: true, data: meta?.data ?? null, updatedAt: meta?.updatedAt });
|
|
81
|
+
})();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// REST: PUT /db/<path> → set
|
|
85
|
+
if (req.method === 'PUT' && url.pathname.startsWith('/db/')) {
|
|
86
|
+
return (async () => {
|
|
87
|
+
const path = normalizePath(url.pathname.slice(4));
|
|
88
|
+
if (this.db.replication?.isReplica) {
|
|
89
|
+
try {
|
|
78
90
|
const body = await req.json();
|
|
79
|
-
|
|
91
|
+
await this.db.replication.proxyWrite({ op: 'set', path, value: body });
|
|
80
92
|
return Response.json({ ok: true });
|
|
81
|
-
}
|
|
93
|
+
} catch (e: any) {
|
|
94
|
+
return Response.json({ ok: false, error: e.message, code: Errors.INTERNAL }, { status: 500 });
|
|
95
|
+
}
|
|
82
96
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
97
|
+
const auth = await this.extractAuth(req);
|
|
98
|
+
if (this.rules && !this.rules.check('write', path, auth)) {
|
|
99
|
+
return Response.json({ ok: false, error: 'Permission denied', code: Errors.PERMISSION_DENIED }, { status: 403 });
|
|
100
|
+
}
|
|
101
|
+
const body = await req.json();
|
|
102
|
+
this.db.set(path, body);
|
|
103
|
+
return Response.json({ ok: true });
|
|
104
|
+
})();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// REST: DELETE /db/<path> → delete
|
|
108
|
+
if (req.method === 'DELETE' && url.pathname.startsWith('/db/')) {
|
|
109
|
+
return (async () => {
|
|
110
|
+
const path = normalizePath(url.pathname.slice(4));
|
|
111
|
+
if (this.db.replication?.isReplica) {
|
|
112
|
+
try {
|
|
113
|
+
await this.db.replication.proxyWrite({ op: 'delete', path });
|
|
93
114
|
return Response.json({ ok: true });
|
|
94
|
-
}
|
|
115
|
+
} catch (e: any) {
|
|
116
|
+
return Response.json({ ok: false, error: e.message, code: Errors.INTERNAL }, { status: 500 });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const auth = await this.extractAuth(req);
|
|
120
|
+
if (this.rules && !this.rules.check('write', path, auth)) {
|
|
121
|
+
return Response.json({ ok: false, error: 'Permission denied', code: Errors.PERMISSION_DENIED }, { status: 403 });
|
|
122
|
+
}
|
|
123
|
+
this.db.delete(path);
|
|
124
|
+
return Response.json({ ok: true });
|
|
125
|
+
})();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// SSE: GET /sse/<path>?event=value|child
|
|
129
|
+
if (req.method === 'GET' && url.pathname.startsWith('/sse/')) {
|
|
130
|
+
return (async () => {
|
|
131
|
+
const path = normalizePath(url.pathname.slice(5));
|
|
132
|
+
const event = url.searchParams.get('event') || 'value';
|
|
133
|
+
const auth = await this.extractAuth(req);
|
|
134
|
+
|
|
135
|
+
if (this.rules && !this.rules.check('read', path, auth)) {
|
|
136
|
+
return Response.json({ ok: false, error: 'Permission denied', code: Errors.PERMISSION_DENIED }, { status: 403 });
|
|
95
137
|
}
|
|
96
138
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const stream = new ReadableStream({
|
|
110
|
-
start(controller) {
|
|
111
|
-
const encoder = new TextEncoder();
|
|
112
|
-
// Initial SSE comment to establish the stream
|
|
113
|
-
controller.enqueue(encoder.encode(': ok\n\n'));
|
|
114
|
-
const send = (data: string) => {
|
|
115
|
-
try { controller.enqueue(encoder.encode(`data: ${data}\n\n`)); } catch {}
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
if (event === 'child') {
|
|
119
|
-
cleanup = self.db.onChild(path, (e) => {
|
|
120
|
-
send(JSON.stringify({ type: 'child', path, key: e.key, data: e.val(), event: e.type }));
|
|
121
|
-
});
|
|
122
|
-
} else {
|
|
123
|
-
cleanup = self.db.on(path, (snap) => {
|
|
124
|
-
send(JSON.stringify({ type: 'value', path: snap.path, data: snap.val() }));
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
},
|
|
128
|
-
cancel() {
|
|
129
|
-
cleanup?.();
|
|
130
|
-
},
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
return new Response(stream, {
|
|
134
|
-
headers: {
|
|
135
|
-
'Content-Type': 'text/event-stream',
|
|
136
|
-
'Cache-Control': 'no-cache',
|
|
137
|
-
'Connection': 'keep-alive',
|
|
138
|
-
},
|
|
139
|
+
let cleanup: (() => void) | null = null;
|
|
140
|
+
const stream = new ReadableStream({
|
|
141
|
+
start: (controller) => {
|
|
142
|
+
const encoder = new TextEncoder();
|
|
143
|
+
controller.enqueue(encoder.encode(': ok\n\n'));
|
|
144
|
+
const send = (data: string) => {
|
|
145
|
+
try { controller.enqueue(encoder.encode(`data: ${data}\n\n`)); } catch {}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
if (event === 'child') {
|
|
149
|
+
cleanup = this.db.onChild(path, (e) => {
|
|
150
|
+
send(JSON.stringify({ type: 'child', path, key: e.key, data: e.val(), event: e.type }));
|
|
139
151
|
});
|
|
140
|
-
}
|
|
152
|
+
} else {
|
|
153
|
+
cleanup = this.db.on(path, (snap) => {
|
|
154
|
+
send(JSON.stringify({ type: 'value', path: snap.path, data: snap.val() }));
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
cancel() {
|
|
159
|
+
cleanup?.();
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
return new Response(stream, {
|
|
164
|
+
headers: {
|
|
165
|
+
'Content-Type': 'text/event-stream',
|
|
166
|
+
'Cache-Control': 'no-cache',
|
|
167
|
+
'Connection': 'keep-alive',
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
})();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// VFS REST routes
|
|
174
|
+
if (this.db.vfs && url.pathname.startsWith('/files/')) {
|
|
175
|
+
const vfsPath = normalizePath(url.pathname.slice(7));
|
|
176
|
+
return (async () => {
|
|
177
|
+
const auth = await this.extractAuth(req);
|
|
178
|
+
|
|
179
|
+
if (req.method === 'GET' && url.searchParams.has('stat')) {
|
|
180
|
+
if (this.rules && !this.rules.check('read', `_vfs/${vfsPath}`, auth)) {
|
|
181
|
+
return Response.json({ ok: false, error: 'Permission denied', code: Errors.PERMISSION_DENIED }, { status: 403 });
|
|
182
|
+
}
|
|
183
|
+
const stat = this.db.vfs!.stat(vfsPath);
|
|
184
|
+
if (!stat) return Response.json({ ok: false, error: 'Not found', code: Errors.NOT_FOUND }, { status: 404 });
|
|
185
|
+
return Response.json({ ok: true, data: stat });
|
|
141
186
|
}
|
|
142
187
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
188
|
+
if (req.method === 'GET' && url.searchParams.has('list')) {
|
|
189
|
+
if (this.rules && !this.rules.check('read', `_vfs/${vfsPath}`, auth)) {
|
|
190
|
+
return Response.json({ ok: false, error: 'Permission denied', code: Errors.PERMISSION_DENIED }, { status: 403 });
|
|
191
|
+
}
|
|
192
|
+
return Response.json({ ok: true, data: this.db.vfs!.list(vfsPath) });
|
|
147
193
|
}
|
|
148
194
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
close(ws: ServerWebSocket<WsData>) {
|
|
154
|
-
self._clients.delete(ws);
|
|
155
|
-
// Cleanup all subscriptions
|
|
156
|
-
for (const off of ws.data.valueSubs.values()) off();
|
|
157
|
-
for (const off of ws.data.childSubs.values()) off();
|
|
158
|
-
for (const off of ws.data.streamSubs.values()) off();
|
|
159
|
-
ws.data.valueSubs.clear();
|
|
160
|
-
ws.data.childSubs.clear();
|
|
161
|
-
ws.data.streamSubs.clear();
|
|
162
|
-
},
|
|
163
|
-
async message(ws: ServerWebSocket<WsData>, raw: string | Buffer) {
|
|
164
|
-
let msg: ClientMessage;
|
|
195
|
+
if (req.method === 'GET') {
|
|
196
|
+
if (this.rules && !this.rules.check('read', `_vfs/${vfsPath}`, auth)) {
|
|
197
|
+
return Response.json({ ok: false, error: 'Permission denied', code: Errors.PERMISSION_DENIED }, { status: 403 });
|
|
198
|
+
}
|
|
165
199
|
try {
|
|
166
|
-
|
|
200
|
+
const data = await this.db.vfs!.read(vfsPath);
|
|
201
|
+
const stat = this.db.vfs!.stat(vfsPath);
|
|
202
|
+
return new Response(data, {
|
|
203
|
+
headers: { 'Content-Type': stat?.mime || 'application/octet-stream' },
|
|
204
|
+
});
|
|
167
205
|
} catch {
|
|
168
|
-
|
|
169
|
-
return;
|
|
206
|
+
return Response.json({ ok: false, error: 'Not found', code: Errors.NOT_FOUND }, { status: 404 });
|
|
170
207
|
}
|
|
208
|
+
}
|
|
171
209
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
210
|
+
if (req.method === 'POST' && url.searchParams.has('mkdir')) {
|
|
211
|
+
if (this.rules && !this.rules.check('write', `_vfs/${vfsPath}`, auth)) {
|
|
212
|
+
return Response.json({ ok: false, error: 'Permission denied', code: Errors.PERMISSION_DENIED }, { status: 403 });
|
|
213
|
+
}
|
|
214
|
+
const stat = this.db.vfs!.mkdir(vfsPath);
|
|
215
|
+
return Response.json({ ok: true, data: stat });
|
|
216
|
+
}
|
|
175
217
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
218
|
+
if (req.method === 'POST') {
|
|
219
|
+
if (this.rules && !this.rules.check('write', `_vfs/${vfsPath}`, auth)) {
|
|
220
|
+
return Response.json({ ok: false, error: 'Permission denied', code: Errors.PERMISSION_DENIED }, { status: 403 });
|
|
221
|
+
}
|
|
222
|
+
const buf = await req.arrayBuffer();
|
|
223
|
+
const mime = req.headers.get('content-type') || undefined;
|
|
224
|
+
const stat = await this.db.vfs!.write(vfsPath, new Uint8Array(buf), mime);
|
|
225
|
+
return Response.json({ ok: true, data: stat });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (req.method === 'PUT' && url.searchParams.has('move')) {
|
|
229
|
+
if (this.rules && !this.rules.check('write', `_vfs/${vfsPath}`, auth)) {
|
|
230
|
+
return Response.json({ ok: false, error: 'Permission denied', code: Errors.PERMISSION_DENIED }, { status: 403 });
|
|
231
|
+
}
|
|
232
|
+
const dst = url.searchParams.get('move')!;
|
|
233
|
+
const stat = await this.db.vfs!.move(vfsPath, dst);
|
|
234
|
+
return Response.json({ ok: true, data: stat });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (req.method === 'DELETE') {
|
|
238
|
+
if (this.rules && !this.rules.check('write', `_vfs/${vfsPath}`, auth)) {
|
|
239
|
+
return Response.json({ ok: false, error: 'Permission denied', code: Errors.PERMISSION_DENIED }, { status: 403 });
|
|
240
|
+
}
|
|
241
|
+
await this.db.vfs!.remove(vfsPath);
|
|
242
|
+
return Response.json({ ok: true });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return new Response('Method not allowed', { status: 405 });
|
|
246
|
+
})();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Static routes
|
|
250
|
+
if (this.options.staticRoutes) {
|
|
251
|
+
const filePath = this.options.staticRoutes[url.pathname];
|
|
252
|
+
if (filePath) return new Response(Bun.file(filePath));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return new Response('Not Found', { status: 404 });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** WebSocket handler config for Bun.serve() */
|
|
259
|
+
get websocketConfig() {
|
|
260
|
+
const self = this;
|
|
261
|
+
return {
|
|
262
|
+
open(ws: ServerWebSocket<WsData>) { self._clients.add(ws); },
|
|
263
|
+
close(ws: ServerWebSocket<WsData>) {
|
|
264
|
+
self._clients.delete(ws);
|
|
265
|
+
for (const off of ws.data.valueSubs.values()) off();
|
|
266
|
+
for (const off of ws.data.childSubs.values()) off();
|
|
267
|
+
for (const off of ws.data.streamSubs.values()) off();
|
|
268
|
+
ws.data.valueSubs.clear();
|
|
269
|
+
ws.data.childSubs.clear();
|
|
270
|
+
ws.data.streamSubs.clear();
|
|
271
|
+
},
|
|
272
|
+
async message(ws: ServerWebSocket<WsData>, raw: string | Buffer) {
|
|
273
|
+
let msg: ClientMessage;
|
|
274
|
+
try {
|
|
275
|
+
msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString());
|
|
276
|
+
} catch {
|
|
277
|
+
ws.send(JSON.stringify({ id: '?', ok: false, error: 'Invalid JSON', code: Errors.INTERNAL }));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const { id } = msg;
|
|
282
|
+
const reply = (data: unknown, extra?: Record<string, unknown>) => ws.send(JSON.stringify({ id, ok: true, data, ...extra }));
|
|
283
|
+
const error = (err: string, code: string) => ws.send(JSON.stringify({ id, ok: false, error: err, code }));
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
switch (msg.op) {
|
|
287
|
+
case 'auth': {
|
|
288
|
+
if (!self.options.auth) {
|
|
289
|
+
ws.data.auth = { token: msg.token };
|
|
290
|
+
} else {
|
|
291
|
+
const result = await self.options.auth(msg.token);
|
|
292
|
+
if (!result) return error('Authentication failed', Errors.AUTH_REQUIRED);
|
|
293
|
+
ws.data.auth = result;
|
|
187
294
|
}
|
|
295
|
+
return reply(null);
|
|
296
|
+
}
|
|
188
297
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
193
|
-
if (msg.shallow) return reply(self.db.getShallow(msg.path || undefined));
|
|
194
|
-
return reply(self.db.get(msg.path));
|
|
298
|
+
case 'get': {
|
|
299
|
+
if (self.rules && !self.rules.check('read', msg.path || '', ws.data.auth)) {
|
|
300
|
+
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
195
301
|
}
|
|
302
|
+
if (msg.shallow) return reply(self.db.getShallow(msg.path || undefined));
|
|
303
|
+
const meta = self.db.storage.getWithMeta(msg.path);
|
|
304
|
+
return reply(meta?.data ?? null, { updatedAt: meta?.updatedAt });
|
|
305
|
+
}
|
|
196
306
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
201
|
-
self.db.set(msg.path, msg.value, msg.ttl ? { ttl: msg.ttl } : undefined);
|
|
202
|
-
return reply(null);
|
|
307
|
+
case 'set': {
|
|
308
|
+
if (self.db.replication?.isReplica) {
|
|
309
|
+
try { return reply(await self.db.replication.proxyWrite(msg)); }
|
|
310
|
+
catch (e: any) { return error(e.message, Errors.INTERNAL); }
|
|
203
311
|
}
|
|
312
|
+
if (self.rules && !self.rules.check('write', msg.path, ws.data.auth, self.db.get(msg.path), msg.value)) {
|
|
313
|
+
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
314
|
+
}
|
|
315
|
+
self.db.set(msg.path, msg.value, msg.ttl ? { ttl: msg.ttl } : undefined);
|
|
316
|
+
return reply(null);
|
|
317
|
+
}
|
|
204
318
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
319
|
+
case 'update': {
|
|
320
|
+
if (self.db.replication?.isReplica) {
|
|
321
|
+
try { return reply(await self.db.replication.proxyWrite(msg)); }
|
|
322
|
+
catch (e: any) { return error(e.message, Errors.INTERNAL); }
|
|
323
|
+
}
|
|
324
|
+
for (const path of Object.keys(msg.updates)) {
|
|
325
|
+
if (self.rules && !self.rules.check('write', path, ws.data.auth)) {
|
|
326
|
+
return error(`Permission denied for ${path}`, Errors.PERMISSION_DENIED);
|
|
210
327
|
}
|
|
211
|
-
self.db.update(msg.updates);
|
|
212
|
-
return reply(null);
|
|
213
328
|
}
|
|
329
|
+
self.db.update(msg.updates);
|
|
330
|
+
return reply(null);
|
|
331
|
+
}
|
|
214
332
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
|
|
333
|
+
case 'delete': {
|
|
334
|
+
if (self.db.replication?.isReplica) {
|
|
335
|
+
try { return reply(await self.db.replication.proxyWrite(msg)); }
|
|
336
|
+
catch (e: any) { return error(e.message, Errors.INTERNAL); }
|
|
337
|
+
}
|
|
338
|
+
if (self.rules && !self.rules.check('write', msg.path, ws.data.auth)) {
|
|
339
|
+
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
221
340
|
}
|
|
341
|
+
self.db.delete(msg.path);
|
|
342
|
+
return reply(null);
|
|
343
|
+
}
|
|
222
344
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
}
|
|
227
|
-
let q = self.db.query(msg.path);
|
|
228
|
-
if (msg.filters) for (const f of msg.filters) q = q.where(f.field, f.op, f.value);
|
|
229
|
-
if (msg.order) q = q.order(msg.order.field, msg.order.dir);
|
|
230
|
-
if (msg.limit) q = q.limit(msg.limit);
|
|
231
|
-
if (msg.offset) q = q.offset(msg.offset);
|
|
232
|
-
return reply(q.get());
|
|
345
|
+
case 'query': {
|
|
346
|
+
if (self.rules && !self.rules.check('read', msg.path, ws.data.auth)) {
|
|
347
|
+
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
233
348
|
}
|
|
349
|
+
let q = self.db.query(msg.path);
|
|
350
|
+
if (msg.filters) for (const f of msg.filters) q = q.where(f.field, f.op, f.value);
|
|
351
|
+
if (msg.order) q = q.order(msg.order.field, msg.order.dir);
|
|
352
|
+
if (msg.limit) q = q.limit(msg.limit);
|
|
353
|
+
if (msg.offset) q = q.offset(msg.offset);
|
|
354
|
+
return reply(q.get());
|
|
355
|
+
}
|
|
234
356
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
239
|
-
const subKey = `${msg.event}:${msg.path}`;
|
|
240
|
-
if (msg.event === 'value') {
|
|
241
|
-
if (ws.data.valueSubs.has(subKey)) return reply(null); // already subscribed
|
|
242
|
-
const off = self.db.on(msg.path, (snap) => {
|
|
243
|
-
ws.send(JSON.stringify({ type: 'value', path: snap.path, data: snap.val() }));
|
|
244
|
-
});
|
|
245
|
-
ws.data.valueSubs.set(subKey, off);
|
|
246
|
-
} else if (msg.event === 'child') {
|
|
247
|
-
if (ws.data.childSubs.has(subKey)) return reply(null);
|
|
248
|
-
const subPath = msg.path;
|
|
249
|
-
const off = self.db.onChild(subPath, (event) => {
|
|
250
|
-
ws.send(JSON.stringify({ type: 'child', path: subPath, key: event.key, data: event.val(), event: event.type }));
|
|
251
|
-
});
|
|
252
|
-
ws.data.childSubs.set(subKey, off);
|
|
253
|
-
}
|
|
254
|
-
return reply(null);
|
|
357
|
+
case 'sub': {
|
|
358
|
+
if (self.rules && !self.rules.check('read', msg.path, ws.data.auth)) {
|
|
359
|
+
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
255
360
|
}
|
|
361
|
+
const subKey = `${msg.event}:${msg.path}`;
|
|
362
|
+
if (msg.event === 'value') {
|
|
363
|
+
if (ws.data.valueSubs.has(subKey)) return reply(null);
|
|
364
|
+
const off = self.db.on(msg.path, (snap) => {
|
|
365
|
+
const meta = self.db.storage.getWithMeta(snap.path);
|
|
366
|
+
ws.send(JSON.stringify({ type: 'value', path: snap.path, data: snap.val(), updatedAt: meta?.updatedAt }));
|
|
367
|
+
});
|
|
368
|
+
ws.data.valueSubs.set(subKey, off);
|
|
369
|
+
} else if (msg.event === 'child') {
|
|
370
|
+
if (ws.data.childSubs.has(subKey)) return reply(null);
|
|
371
|
+
const subPath = msg.path;
|
|
372
|
+
const off = self.db.onChild(subPath, (event) => {
|
|
373
|
+
ws.send(JSON.stringify({ type: 'child', path: subPath, key: event.key, data: event.val(), event: event.type }));
|
|
374
|
+
});
|
|
375
|
+
ws.data.childSubs.set(subKey, off);
|
|
376
|
+
}
|
|
377
|
+
return reply(null);
|
|
378
|
+
}
|
|
256
379
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
}
|
|
266
|
-
return reply(null);
|
|
380
|
+
case 'unsub': {
|
|
381
|
+
const unsubKey = `${msg.event}:${msg.path}`;
|
|
382
|
+
if (msg.event === 'value') {
|
|
383
|
+
ws.data.valueSubs.get(unsubKey)?.();
|
|
384
|
+
ws.data.valueSubs.delete(unsubKey);
|
|
385
|
+
} else if (msg.event === 'child') {
|
|
386
|
+
ws.data.childSubs.get(unsubKey)?.();
|
|
387
|
+
ws.data.childSubs.delete(unsubKey);
|
|
267
388
|
}
|
|
389
|
+
return reply(null);
|
|
390
|
+
}
|
|
268
391
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
self.db.
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
case 'delete':
|
|
289
|
-
if (self.rules && !self.rules.check('write', batchOp.path, ws.data.auth)) {
|
|
290
|
-
throw new Error(`Permission denied for ${batchOp.path}`);
|
|
291
|
-
}
|
|
292
|
-
tx.delete(batchOp.path);
|
|
293
|
-
break;
|
|
294
|
-
case 'push': {
|
|
295
|
-
if (self.rules && !self.rules.check('write', batchOp.path, ws.data.auth)) {
|
|
296
|
-
throw new Error(`Permission denied for ${batchOp.path}`);
|
|
392
|
+
case 'batch': {
|
|
393
|
+
if (self.db.replication?.isReplica) {
|
|
394
|
+
try { return reply(await self.db.replication.proxyWrite(msg)); }
|
|
395
|
+
catch (e: any) { return error(e.message, Errors.INTERNAL); }
|
|
396
|
+
}
|
|
397
|
+
const results: unknown[] = [];
|
|
398
|
+
self.db.transaction((tx) => {
|
|
399
|
+
for (const batchOp of msg.operations) {
|
|
400
|
+
switch (batchOp.op) {
|
|
401
|
+
case 'set':
|
|
402
|
+
if (self.rules && !self.rules.check('write', batchOp.path, ws.data.auth)) {
|
|
403
|
+
throw new Error(`Permission denied for ${batchOp.path}`);
|
|
404
|
+
}
|
|
405
|
+
tx.set(batchOp.path, batchOp.value);
|
|
406
|
+
break;
|
|
407
|
+
case 'update':
|
|
408
|
+
for (const path of Object.keys(batchOp.updates)) {
|
|
409
|
+
if (self.rules && !self.rules.check('write', path, ws.data.auth)) {
|
|
410
|
+
throw new Error(`Permission denied for ${path}`);
|
|
297
411
|
}
|
|
298
|
-
const key = self.db.push(batchOp.path, batchOp.value);
|
|
299
|
-
results.push(key);
|
|
300
|
-
break;
|
|
301
412
|
}
|
|
413
|
+
tx.update(batchOp.updates);
|
|
414
|
+
break;
|
|
415
|
+
case 'delete':
|
|
416
|
+
if (self.rules && !self.rules.check('write', batchOp.path, ws.data.auth)) {
|
|
417
|
+
throw new Error(`Permission denied for ${batchOp.path}`);
|
|
418
|
+
}
|
|
419
|
+
tx.delete(batchOp.path);
|
|
420
|
+
break;
|
|
421
|
+
case 'push': {
|
|
422
|
+
if (self.rules && !self.rules.check('write', batchOp.path, ws.data.auth)) {
|
|
423
|
+
throw new Error(`Permission denied for ${batchOp.path}`);
|
|
424
|
+
}
|
|
425
|
+
const key = self.db.push(batchOp.path, batchOp.value);
|
|
426
|
+
results.push(key);
|
|
427
|
+
break;
|
|
302
428
|
}
|
|
303
429
|
}
|
|
304
|
-
});
|
|
305
|
-
return reply(results.length ? results : null);
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
case 'push': {
|
|
309
|
-
if (self.rules && !self.rules.check('write', msg.path, ws.data.auth)) {
|
|
310
|
-
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
311
430
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
431
|
+
});
|
|
432
|
+
return reply(results.length ? results : null);
|
|
433
|
+
}
|
|
315
434
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
|
|
435
|
+
case 'push': {
|
|
436
|
+
if (self.db.replication?.isReplica) {
|
|
437
|
+
try { return reply(await self.db.replication.proxyWrite(msg)); }
|
|
438
|
+
catch (e: any) { return error(e.message, Errors.INTERNAL); }
|
|
439
|
+
}
|
|
440
|
+
if (self.rules && !self.rules.check('write', msg.path, ws.data.auth)) {
|
|
441
|
+
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
322
442
|
}
|
|
443
|
+
const key = self.db.push(msg.path, msg.value, msg.idempotencyKey ? { idempotencyKey: msg.idempotencyKey } : undefined);
|
|
444
|
+
return reply(key);
|
|
445
|
+
}
|
|
323
446
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
}
|
|
328
|
-
self.db.stream.ack(msg.path, msg.groupId, msg.key);
|
|
329
|
-
return reply(null);
|
|
447
|
+
case 'stream-read': {
|
|
448
|
+
if (self.rules && !self.rules.check('read', msg.path, ws.data.auth)) {
|
|
449
|
+
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
330
450
|
}
|
|
451
|
+
const events = self.db.stream.read(msg.path, msg.groupId, msg.limit);
|
|
452
|
+
return reply(events);
|
|
453
|
+
}
|
|
331
454
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
}
|
|
336
|
-
const streamKey = `${msg.path}:${msg.groupId}`;
|
|
337
|
-
// Unsub existing if any
|
|
338
|
-
ws.data.streamSubs.get(streamKey)?.();
|
|
339
|
-
const unsub = self.db.stream.subscribe(msg.path, msg.groupId, (events) => {
|
|
340
|
-
ws.send(JSON.stringify({ type: 'stream', path: msg.path, groupId: msg.groupId, events }));
|
|
341
|
-
});
|
|
342
|
-
ws.data.streamSubs.set(streamKey, unsub);
|
|
343
|
-
return reply(null);
|
|
455
|
+
case 'stream-ack': {
|
|
456
|
+
if (self.rules && !self.rules.check('write', msg.path, ws.data.auth)) {
|
|
457
|
+
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
344
458
|
}
|
|
459
|
+
self.db.stream.ack(msg.path, msg.groupId, msg.key);
|
|
460
|
+
return reply(null);
|
|
461
|
+
}
|
|
345
462
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
ws.data.streamSubs.delete(streamUnsubKey);
|
|
350
|
-
return reply(null);
|
|
463
|
+
case 'stream-sub': {
|
|
464
|
+
if (self.rules && !self.rules.check('read', msg.path, ws.data.auth)) {
|
|
465
|
+
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
351
466
|
}
|
|
467
|
+
const streamKey = `${msg.path}:${msg.groupId}`;
|
|
468
|
+
ws.data.streamSubs.get(streamKey)?.();
|
|
469
|
+
const unsub = self.db.stream.subscribe(msg.path, msg.groupId, (events) => {
|
|
470
|
+
ws.send(JSON.stringify({ type: 'stream', path: msg.path, groupId: msg.groupId, events }));
|
|
471
|
+
});
|
|
472
|
+
ws.data.streamSubs.set(streamKey, unsub);
|
|
473
|
+
return reply(null);
|
|
474
|
+
}
|
|
352
475
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
476
|
+
case 'stream-unsub': {
|
|
477
|
+
const streamUnsubKey = `${msg.path}:${msg.groupId}`;
|
|
478
|
+
ws.data.streamSubs.get(streamUnsubKey)?.();
|
|
479
|
+
ws.data.streamSubs.delete(streamUnsubKey);
|
|
480
|
+
return reply(null);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
case 'mq-push': {
|
|
484
|
+
if (self.rules && !self.rules.check('write', msg.path, ws.data.auth)) {
|
|
485
|
+
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
360
486
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
487
|
+
const mqKey = self.db.mq.push(msg.path, msg.value, msg.idempotencyKey ? { idempotencyKey: msg.idempotencyKey } : undefined);
|
|
488
|
+
return reply(mqKey);
|
|
489
|
+
}
|
|
490
|
+
case 'mq-fetch': {
|
|
491
|
+
if (self.rules && !self.rules.check('write', msg.path, ws.data.auth)) {
|
|
492
|
+
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
366
493
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
return reply(null);
|
|
494
|
+
return reply(self.db.mq.fetch(msg.path, msg.count));
|
|
495
|
+
}
|
|
496
|
+
case 'mq-ack': {
|
|
497
|
+
if (self.rules && !self.rules.check('write', msg.path, ws.data.auth)) {
|
|
498
|
+
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
373
499
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
return
|
|
500
|
+
self.db.mq.ack(msg.path, msg.key);
|
|
501
|
+
return reply(null);
|
|
502
|
+
}
|
|
503
|
+
case 'mq-nack': {
|
|
504
|
+
if (self.rules && !self.rules.check('write', msg.path, ws.data.auth)) {
|
|
505
|
+
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
380
506
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
507
|
+
self.db.mq.nack(msg.path, msg.key);
|
|
508
|
+
return reply(null);
|
|
509
|
+
}
|
|
510
|
+
case 'mq-peek': {
|
|
511
|
+
if (self.rules && !self.rules.check('read', msg.path, ws.data.auth)) {
|
|
512
|
+
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
386
513
|
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
return
|
|
514
|
+
return reply(self.db.mq.peek(msg.path, msg.count));
|
|
515
|
+
}
|
|
516
|
+
case 'mq-dlq': {
|
|
517
|
+
if (self.rules && !self.rules.check('read', msg.path, ws.data.auth)) {
|
|
518
|
+
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
392
519
|
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
return
|
|
520
|
+
return reply(self.db.mq.dlq(msg.path));
|
|
521
|
+
}
|
|
522
|
+
case 'mq-purge': {
|
|
523
|
+
if (self.rules && !self.rules.check('write', msg.path, ws.data.auth)) {
|
|
524
|
+
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
398
525
|
}
|
|
526
|
+
return reply(self.db.mq.purge(msg.path, { all: msg.all }));
|
|
527
|
+
}
|
|
399
528
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
404
|
-
}
|
|
405
|
-
return reply(self.db.search({ text: msg.text, path: msg.path, limit: msg.limit }));
|
|
529
|
+
case 'fts-search': {
|
|
530
|
+
if (self.rules && !self.rules.check('read', msg.path || '', ws.data.auth)) {
|
|
531
|
+
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
406
532
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
else if (msg.content) self.db.index(msg.path, msg.content);
|
|
413
|
-
return reply(null);
|
|
533
|
+
return reply(self.db.search({ text: msg.text, path: msg.path, limit: msg.limit }));
|
|
534
|
+
}
|
|
535
|
+
case 'fts-index': {
|
|
536
|
+
if (self.rules && !self.rules.check('write', msg.path, ws.data.auth)) {
|
|
537
|
+
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
414
538
|
}
|
|
539
|
+
if (msg.fields) self.db.index(msg.path, msg.fields);
|
|
540
|
+
else if (msg.content) self.db.index(msg.path, msg.content);
|
|
541
|
+
return reply(null);
|
|
542
|
+
}
|
|
415
543
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
420
|
-
}
|
|
421
|
-
return reply(self.db.vectorSearch({ query: msg.query, path: msg.path, limit: msg.limit, threshold: msg.threshold }));
|
|
544
|
+
case 'vector-search': {
|
|
545
|
+
if (self.rules && !self.rules.check('read', msg.path || '', ws.data.auth)) {
|
|
546
|
+
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
422
547
|
}
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
return reply(null);
|
|
548
|
+
return reply(self.db.vectorSearch({ query: msg.query, path: msg.path, limit: msg.limit, threshold: msg.threshold }));
|
|
549
|
+
}
|
|
550
|
+
case 'vector-store': {
|
|
551
|
+
if (self.rules && !self.rules.check('write', msg.path, ws.data.auth)) {
|
|
552
|
+
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
429
553
|
}
|
|
554
|
+
self.db.vectors!.store(msg.path, msg.embedding);
|
|
555
|
+
return reply(null);
|
|
556
|
+
}
|
|
430
557
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
435
|
-
}
|
|
436
|
-
return reply(self.db.stream.snapshot(msg.path));
|
|
558
|
+
case 'stream-snapshot': {
|
|
559
|
+
if (self.rules && !self.rules.check('read', msg.path, ws.data.auth)) {
|
|
560
|
+
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
437
561
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
return
|
|
562
|
+
return reply(self.db.stream.snapshot(msg.path));
|
|
563
|
+
}
|
|
564
|
+
case 'stream-materialize': {
|
|
565
|
+
if (self.rules && !self.rules.check('read', msg.path, ws.data.auth)) {
|
|
566
|
+
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
443
567
|
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
return
|
|
568
|
+
return reply(self.db.stream.materialize(msg.path, msg.keepKey ? { keepKey: msg.keepKey } : undefined));
|
|
569
|
+
}
|
|
570
|
+
case 'stream-compact': {
|
|
571
|
+
if (self.rules && !self.rules.check('write', msg.path, ws.data.auth)) {
|
|
572
|
+
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
449
573
|
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
return reply(null);
|
|
574
|
+
return reply(self.db.stream.compact(msg.path, { maxAge: msg.maxAge, maxCount: msg.maxCount, keepKey: msg.keepKey }));
|
|
575
|
+
}
|
|
576
|
+
case 'stream-reset': {
|
|
577
|
+
if (self.rules && !self.rules.check('write', msg.path, ws.data.auth)) {
|
|
578
|
+
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
456
579
|
}
|
|
580
|
+
self.db.stream.reset(msg.path);
|
|
581
|
+
return reply(null);
|
|
582
|
+
}
|
|
457
583
|
|
|
458
|
-
|
|
459
|
-
|
|
584
|
+
case 'vfs-upload-init': {
|
|
585
|
+
if (!self.db.vfs) return error('VFS not configured', Errors.INTERNAL);
|
|
586
|
+
if (self.rules && !self.rules.check('write', `_vfs/${msg.path}`, ws.data.auth)) {
|
|
587
|
+
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
588
|
+
}
|
|
589
|
+
const transferId = String(Date.now()) + Math.random().toString(36).slice(2, 6);
|
|
590
|
+
ws.data.vfsTransfers.set(transferId, { path: msg.path, mime: msg.mime, chunks: [] });
|
|
591
|
+
return reply(transferId);
|
|
592
|
+
}
|
|
593
|
+
case 'vfs-upload-chunk': {
|
|
594
|
+
const transfer = ws.data.vfsTransfers.get(msg.transferId);
|
|
595
|
+
if (!transfer) return error('Invalid transfer', Errors.INTERNAL);
|
|
596
|
+
transfer.chunks[msg.seq] = msg.data;
|
|
597
|
+
return reply(null);
|
|
598
|
+
}
|
|
599
|
+
case 'vfs-upload-done': {
|
|
600
|
+
if (!self.db.vfs) return error('VFS not configured', Errors.INTERNAL);
|
|
601
|
+
const t = ws.data.vfsTransfers.get(msg.transferId);
|
|
602
|
+
if (!t) return error('Invalid transfer', Errors.INTERNAL);
|
|
603
|
+
ws.data.vfsTransfers.delete(msg.transferId);
|
|
604
|
+
const combined = Buffer.from(t.chunks.join(''), 'base64');
|
|
605
|
+
const stat = await self.db.vfs.write(t.path, new Uint8Array(combined), t.mime);
|
|
606
|
+
return reply(stat);
|
|
607
|
+
}
|
|
608
|
+
case 'vfs-download-init': {
|
|
609
|
+
if (!self.db.vfs) return error('VFS not configured', Errors.INTERNAL);
|
|
610
|
+
if (self.rules && !self.rules.check('read', `_vfs/${msg.path}`, ws.data.auth)) {
|
|
611
|
+
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
612
|
+
}
|
|
613
|
+
try {
|
|
614
|
+
const data = await self.db.vfs.read(msg.path);
|
|
615
|
+
const b64 = Buffer.from(data).toString('base64');
|
|
616
|
+
const CHUNK_SIZE = 48000;
|
|
617
|
+
const totalChunks = Math.ceil(b64.length / CHUNK_SIZE) || 1;
|
|
618
|
+
const transferId = String(Date.now()) + Math.random().toString(36).slice(2, 6);
|
|
619
|
+
reply({ transferId, totalChunks });
|
|
620
|
+
for (let i = 0; i < totalChunks; i++) {
|
|
621
|
+
const chunk = b64.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
|
|
622
|
+
ws.send(JSON.stringify({ type: 'vfs-download-chunk', transferId, data: chunk, seq: i, done: i === totalChunks - 1 }));
|
|
623
|
+
}
|
|
624
|
+
} catch {
|
|
625
|
+
return error('File not found', Errors.INTERNAL);
|
|
626
|
+
}
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
case 'vfs-stat': {
|
|
630
|
+
if (!self.db.vfs) return error('VFS not configured', Errors.INTERNAL);
|
|
631
|
+
if (self.rules && !self.rules.check('read', `_vfs/${msg.path}`, ws.data.auth)) {
|
|
632
|
+
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
633
|
+
}
|
|
634
|
+
return reply(self.db.vfs.stat(msg.path));
|
|
635
|
+
}
|
|
636
|
+
case 'vfs-list': {
|
|
637
|
+
if (!self.db.vfs) return error('VFS not configured', Errors.INTERNAL);
|
|
638
|
+
if (self.rules && !self.rules.check('read', `_vfs/${msg.path}`, ws.data.auth)) {
|
|
639
|
+
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
640
|
+
}
|
|
641
|
+
return reply(self.db.vfs.list(msg.path));
|
|
642
|
+
}
|
|
643
|
+
case 'vfs-delete': {
|
|
644
|
+
if (!self.db.vfs) return error('VFS not configured', Errors.INTERNAL);
|
|
645
|
+
if (self.rules && !self.rules.check('write', `_vfs/${msg.path}`, ws.data.auth)) {
|
|
646
|
+
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
647
|
+
}
|
|
648
|
+
await self.db.vfs.remove(msg.path);
|
|
649
|
+
return reply(null);
|
|
650
|
+
}
|
|
651
|
+
case 'vfs-mkdir': {
|
|
652
|
+
if (!self.db.vfs) return error('VFS not configured', Errors.INTERNAL);
|
|
653
|
+
if (self.rules && !self.rules.check('write', `_vfs/${msg.path}`, ws.data.auth)) {
|
|
654
|
+
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
655
|
+
}
|
|
656
|
+
return reply(self.db.vfs.mkdir(msg.path));
|
|
657
|
+
}
|
|
658
|
+
case 'vfs-move': {
|
|
659
|
+
if (!self.db.vfs) return error('VFS not configured', Errors.INTERNAL);
|
|
660
|
+
if (self.rules && !self.rules.check('write', `_vfs/${msg.path}`, ws.data.auth)) {
|
|
661
|
+
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
662
|
+
}
|
|
663
|
+
const moved = await self.db.vfs.move(msg.path, msg.dst);
|
|
664
|
+
return reply(moved);
|
|
460
665
|
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
666
|
+
|
|
667
|
+
default:
|
|
668
|
+
return error('Unknown operation', Errors.INTERNAL);
|
|
464
669
|
}
|
|
465
|
-
}
|
|
670
|
+
} catch (e: unknown) {
|
|
671
|
+
const message = e instanceof Error ? e.message : 'Internal error';
|
|
672
|
+
return error(message, Errors.INTERNAL);
|
|
673
|
+
}
|
|
466
674
|
},
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** Start standalone Bun HTTP+WS server */
|
|
679
|
+
start(): Server {
|
|
680
|
+
this.server = Bun.serve({
|
|
681
|
+
port: this.options.port,
|
|
682
|
+
fetch: (req, server) => this.handleFetch(req, server),
|
|
683
|
+
websocket: this.websocketConfig,
|
|
467
684
|
});
|
|
468
685
|
return this.server;
|
|
469
686
|
}
|