@radishbot/sdk 0.1.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/README.md ADDED
@@ -0,0 +1,192 @@
1
+ # Radish Logger
2
+
3
+ Flow-based logging SDK. Track what your app is doing with nested actions, timing, and structured logs — then inspect everything in the dashboard.
4
+
5
+ No account needed. Generate a key, start logging.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @radishbot/sdk
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```ts
16
+ import { RL, generateKey } from '@radishbot/sdk';
17
+
18
+ const key = generateKey(); // save this — it's your dashboard login
19
+
20
+ const root = await RL(key);
21
+
22
+ await root.a('handle-request', async (req) => {
23
+ console.log('GET /api/users'); // automatically captured
24
+
25
+ const users = await req.a('db-query', async () => {
26
+ return await db.query('SELECT * FROM users');
27
+ });
28
+
29
+ console.log('Done', { count: users.length });
30
+ });
31
+
32
+ await root.finish();
33
+ ```
34
+
35
+ Open the dashboard, paste your key, see everything.
36
+
37
+ ## Console Capture
38
+
39
+ Inside `.a()` callbacks, `console.log/warn/error/debug` are automatically captured as action logs. Output still prints to the terminal — but it also gets sent to the dashboard with full context.
40
+
41
+ ```ts
42
+ await root.a('migrate', async () => {
43
+ console.log('Starting migration'); // → info log
44
+ console.warn('Deprecated column found'); // → warn log
45
+ console.error('Failed to migrate users'); // → error log
46
+ console.debug('SQL: ALTER TABLE ...'); // → debug log
47
+ console.log('Done', { tables: 5 }); // → info log with data
48
+ });
49
+ ```
50
+
51
+ Objects are properly serialized — no more `[object Object]`.
52
+
53
+ ## Actions
54
+
55
+ Actions are nested scopes. Every `RL()` call creates a root action at `/`. Sub-actions branch off from there.
56
+
57
+ ```ts
58
+ const root = await RL(key);
59
+
60
+ await root.a('request', async (req) => {
61
+ console.log('handling request');
62
+
63
+ await req.a('database', async () => {
64
+ console.log('querying users');
65
+ }); // auto-finished
66
+
67
+ await req.a('response', async () => {
68
+ console.log('sending 200');
69
+ }); // auto-finished
70
+ }); // auto-finished
71
+
72
+ await root.finish();
73
+ ```
74
+
75
+ If your function throws, the action is marked as errored and the exception propagates:
76
+
77
+ ```ts
78
+ await root.a('risky-op', async () => {
79
+ throw new Error('something broke');
80
+ }); // action → error, rethrows
81
+ ```
82
+
83
+ Return values pass through:
84
+
85
+ ```ts
86
+ const users = await flow.a('db-query', async () => {
87
+ return await db.query('SELECT * FROM users');
88
+ });
89
+ // users is the query result
90
+ ```
91
+
92
+ Duplicate names are allowed — the dashboard shows them as `request:1`, `request:2`, etc:
93
+
94
+ ```ts
95
+ await root.a('batch', async (batch) => {
96
+ for (const item of items) {
97
+ await batch.a('request', async () => {
98
+ await processItem(item);
99
+ });
100
+ }
101
+ });
102
+ ```
103
+
104
+ For long-lived actions (websockets, streams), use the manual API:
105
+
106
+ ```ts
107
+ const stream = root.action('websocket');
108
+ stream.info('connected');
109
+ // ... hours later ...
110
+ stream.info('disconnected');
111
+ await stream.finish();
112
+ ```
113
+
114
+ ## Explicit Logging
115
+
116
+ Outside of `.a()` callbacks, or when you want to log to a specific action:
117
+
118
+ ```ts
119
+ action.info('request received', { method: 'POST', path: '/api/users' });
120
+ action.warn('rate limit approaching', { remaining: 3 });
121
+ action.error('query failed', new Error('connection refused'));
122
+ action.debug('cache hit', { key: 'user:123', ttl: 300 });
123
+ ```
124
+
125
+ ## Cross-Context Actions
126
+
127
+ Export an action's handle to continue logging from another process, worker, or service:
128
+
129
+ ```ts
130
+ // Service A
131
+ const action = root.action('job');
132
+ const handle = await action.exportID();
133
+ // pass handle to service B via queue, HTTP, etc.
134
+
135
+ // Service B
136
+ import { restoreFlow } from '@radishbot/sdk';
137
+ const action = await restoreFlow(key, handle);
138
+ action.info('continuing from service B');
139
+ await action.finish();
140
+ ```
141
+
142
+ ## Configuration
143
+
144
+ ```ts
145
+ const root = await RL(key, {
146
+ host: 'wss://maincloud.spacetimedb.com', // default
147
+ dbName: 'radish-log', // default
148
+ defaultTimeout: 100, // seconds, default 100
149
+ });
150
+ ```
151
+
152
+ Sub-actions can have their own timeout:
153
+
154
+ ```ts
155
+ await root.a('quick-task', async () => {
156
+ // ...
157
+ }, 10); // 10 second timeout
158
+
159
+ const slow = root.action('batch-job', 3600); // 1 hour
160
+ ```
161
+
162
+ Actions that exceed their timeout are automatically marked as timed out.
163
+
164
+ ## API Reference
165
+
166
+ ### `RL(secretKey, options?) → Promise<Flow>`
167
+
168
+ Connect and create a root action.
169
+
170
+ ### `generateKey() → string`
171
+
172
+ Generate a random secret key (prefix `rl_`).
173
+
174
+ ### `restoreFlow(secretKey, handle, options?) → Promise<Flow>`
175
+
176
+ Restore an action from an exported handle string.
177
+
178
+ ### Flow (Action)
179
+
180
+ | Method | Description |
181
+ |---|---|
182
+ | `.a(name, fn, timeout?)` | Run a sub-action. Auto-finish, console capture. Returns fn result. |
183
+ | `.action(name, timeout?)` | Create a sub-action manually. |
184
+ | `.finish()` | Finish the action. |
185
+ | `.finishWithError(err?)` | Finish the action as errored. |
186
+ | `.info(msg, data?)` | Log at info level. |
187
+ | `.warn(msg, data?)` | Log at warn level. |
188
+ | `.error(msg, data?)` | Log at error level. |
189
+ | `.debug(msg, data?)` | Log at debug level. |
190
+ | `.log(msg, data?, level?)` | Log at any level. |
191
+ | `.exportID()` | Export handle for cross-context restore. |
192
+ | `.getId()` | Get server-assigned action ID. |
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@radishbot/sdk",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts"
9
+ },
10
+ "bin": {
11
+ "radish": "./src/cli.ts"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "README.md"
16
+ ],
17
+ "scripts": {
18
+ "cli": "bun run ./src/cli.ts"
19
+ },
20
+ "dependencies": {
21
+ "spacetimedb": "^2.0.3",
22
+ "ws": "^8.19.0"
23
+ }
24
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,383 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Radish CLI — inspect flows and logs from the terminal.
4
+ *
5
+ * Usage:
6
+ * radish <key> List recent flows
7
+ * radish <key> <flowId> Show flow detail + logs
8
+ * radish <key> tail Live-tail all logs
9
+ * radish <key> tree <flowId> Show flow tree
10
+ */
11
+ import { DbConnection } from './module_bindings';
12
+
13
+ const HOST = 'wss://maincloud.spacetimedb.com';
14
+ const DB_NAME = 'radish-log';
15
+
16
+ // ── Colors ───────────────────────────────────────────────────
17
+
18
+ const c = {
19
+ reset: '\x1b[0m',
20
+ dim: '\x1b[2m',
21
+ bold: '\x1b[1m',
22
+ red: '\x1b[31m',
23
+ green: '\x1b[32m',
24
+ yellow: '\x1b[33m',
25
+ blue: '\x1b[34m',
26
+ magenta: '\x1b[35m',
27
+ cyan: '\x1b[36m',
28
+ gray: '\x1b[90m',
29
+ };
30
+
31
+ function levelColor(level: string): string {
32
+ switch (level) {
33
+ case 'info': return c.blue;
34
+ case 'warn': return c.yellow;
35
+ case 'error': return c.red;
36
+ case 'debug': return c.gray;
37
+ default: return c.dim;
38
+ }
39
+ }
40
+
41
+ function statusColor(status: string): string {
42
+ switch (status) {
43
+ case 'open': return c.blue;
44
+ case 'finished': return c.green;
45
+ case 'error': return c.red;
46
+ case 'timeout': return c.yellow;
47
+ default: return c.dim;
48
+ }
49
+ }
50
+
51
+ // ── Helpers ──────────────────────────────────────────────────
52
+
53
+ async function hashKey(key: string): Promise<string> {
54
+ const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(key));
55
+ return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
56
+ }
57
+
58
+ function tsMicros(ts: any): bigint { return ts.microsSinceUnixEpoch; }
59
+
60
+ function fmtTime(ts: any): string {
61
+ return new Date(Number(tsMicros(ts) / 1000n)).toLocaleTimeString();
62
+ }
63
+
64
+ function fmtDateTime(ts: any): string {
65
+ const d = new Date(Number(tsMicros(ts) / 1000n));
66
+ return d.toLocaleString();
67
+ }
68
+
69
+ function fmtMs(us: number): string {
70
+ const ms = us / 1000;
71
+ if (ms < 1) return '<1ms';
72
+ if (ms < 1000) return `${Math.round(ms)}ms`;
73
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
74
+ return `${(ms / 60000).toFixed(1)}m`;
75
+ }
76
+
77
+ function fmtDuration(startTs: any, endMicros: bigint): string {
78
+ return fmtMs(Number(endMicros - tsMicros(startTs)));
79
+ }
80
+
81
+ function fmtData(data: string): string {
82
+ if (!data || data === '{}') return '';
83
+ try {
84
+ const parsed = JSON.parse(data);
85
+ const str = JSON.stringify(parsed, null, 2);
86
+ // Indent continuation lines
87
+ return str.split('\n').map((line, i) => i === 0 ? ' ' + c.dim + line + c.reset : ' ' + c.dim + line + c.reset).join('\n');
88
+ } catch {
89
+ return ' ' + c.dim + data + c.reset;
90
+ }
91
+ }
92
+
93
+ function padRight(s: string, n: number): string {
94
+ return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length);
95
+ }
96
+
97
+ // ── Connect ──────────────────────────────────────────────────
98
+
99
+ function connect(keyHash: string): Promise<{ conn: DbConnection; flows: () => any[]; logs: () => any[] }> {
100
+ return new Promise((resolve, reject) => {
101
+ const conn = DbConnection.builder()
102
+ .withUri(HOST)
103
+ .withDatabaseName(DB_NAME)
104
+ .onConnect((c, _identity, _token) => {
105
+ c.subscriptionBuilder()
106
+ .onApplied(() => {
107
+ const flows = () => {
108
+ const result: any[] = [];
109
+ for (const f of c.db.flow.iter()) if (f.keyHash === keyHash) result.push(f);
110
+ return result;
111
+ };
112
+ const logs = () => {
113
+ const result: any[] = [];
114
+ for (const l of c.db.logEntry.iter()) result.push(l);
115
+ return result;
116
+ };
117
+ resolve({ conn: c, flows, logs });
118
+ })
119
+ .subscribeToAllTables();
120
+ })
121
+ .onConnectError((_ctx, err) => reject(new Error(`Connection failed: ${err}`)))
122
+ .build();
123
+ });
124
+ }
125
+
126
+ // ── Commands ─────────────────────────────────────────────────
127
+
128
+ function cmdList(flows: any[]) {
129
+ const roots = flows
130
+ .filter(f => f.parentFlowId === 0n)
131
+ .sort((a, b) => {
132
+ const am = tsMicros(a.createdAt), bm = tsMicros(b.createdAt);
133
+ return am > bm ? -1 : am < bm ? 1 : 0;
134
+ });
135
+
136
+ if (roots.length === 0) {
137
+ console.log(`${c.dim}No flows found for this key.${c.reset}`);
138
+ return;
139
+ }
140
+
141
+ console.log(`${c.bold}Flows${c.reset} ${c.dim}(${roots.length} total)${c.reset}\n`);
142
+ console.log(`${c.dim} ${'ID'.padEnd(8)} ${'STATUS'.padEnd(10)} ${'DURATION'.padEnd(10)} CREATED${c.reset}`);
143
+ console.log(`${c.dim} ${'─'.repeat(8)} ${'─'.repeat(10)} ${'─'.repeat(10)} ${'─'.repeat(24)}${c.reset}`);
144
+
145
+ for (const f of roots.slice(0, 30)) {
146
+ const id = f.id.toString().padEnd(8);
147
+ const sc = statusColor(f.status);
148
+ const status = padRight(f.status, 10);
149
+ const dur = f.finishedAt !== 0n ? padRight(fmtDuration(f.createdAt, f.finishedAt), 10) : padRight('—', 10);
150
+ const time = fmtDateTime(f.createdAt);
151
+ console.log(` ${c.dim}${id}${c.reset} ${sc}${status}${c.reset} ${dur} ${c.dim}${time}${c.reset}`);
152
+ }
153
+
154
+ if (roots.length > 30) {
155
+ console.log(`${c.dim} ... and ${roots.length - 30} more${c.reset}`);
156
+ }
157
+ }
158
+
159
+ function cmdShow(flowId: bigint, allFlows: any[], allLogs: any[]) {
160
+ const flow = allFlows.find(f => f.id === flowId);
161
+ if (!flow) {
162
+ console.error(`${c.red}Flow ${flowId} not found.${c.reset}`);
163
+ process.exit(1);
164
+ }
165
+
166
+ // Header
167
+ const sc = statusColor(flow.status);
168
+ console.log(`${c.bold}Flow #${flow.id}${c.reset} ${sc}${flow.status}${c.reset}`);
169
+ console.log(`${c.dim}Created: ${fmtDateTime(flow.createdAt)}${c.reset}`);
170
+ if (flow.finishedAt !== 0n) {
171
+ console.log(`${c.dim}Duration: ${fmtDuration(flow.createdAt, flow.finishedAt)}${c.reset}`);
172
+ }
173
+ console.log();
174
+
175
+ // Child flows
176
+ const children = allFlows
177
+ .filter(f => f.parentFlowId === flowId)
178
+ .sort((a, b) => {
179
+ const am = tsMicros(a.createdAt), bm = tsMicros(b.createdAt);
180
+ return am < bm ? -1 : am > bm ? 1 : 0;
181
+ });
182
+
183
+ if (children.length > 0) {
184
+ console.log(`${c.bold}Actions${c.reset}\n`);
185
+ for (const ch of children) {
186
+ const sc = statusColor(ch.status);
187
+ const dur = ch.finishedAt !== 0n ? fmtDuration(ch.createdAt, ch.finishedAt) : '…';
188
+ console.log(` ${sc}●${c.reset} ${c.bold}${ch.name}${c.reset} ${c.dim}#${ch.id}${c.reset} ${sc}${ch.status}${c.reset} ${c.dim}${dur}${c.reset}`);
189
+ }
190
+ console.log();
191
+ }
192
+
193
+ // Collect logs from this flow and all descendants
194
+ const flowIds = new Set<bigint>();
195
+ function collectIds(parentId: bigint) {
196
+ flowIds.add(parentId);
197
+ for (const f of allFlows) {
198
+ if (f.parentFlowId === parentId) collectIds(f.id);
199
+ }
200
+ }
201
+ collectIds(flowId);
202
+
203
+ const logs = allLogs
204
+ .filter(l => flowIds.has(l.flowId) && l.level !== 'flow' && l.level !== 'action')
205
+ .sort((a, b) => {
206
+ const am = tsMicros(a.createdAt), bm = tsMicros(b.createdAt);
207
+ return am < bm ? -1 : am > bm ? 1 : 0;
208
+ });
209
+
210
+ if (logs.length === 0) {
211
+ console.log(`${c.dim}No logs.${c.reset}`);
212
+ return;
213
+ }
214
+
215
+ // Find source flow name for each log
216
+ const flowMap = new Map<bigint, string>();
217
+ for (const f of allFlows) flowMap.set(f.id, f.name === '/' ? 'root' : f.name);
218
+
219
+ console.log(`${c.bold}Logs${c.reset}\n`);
220
+ for (const l of logs) {
221
+ const time = fmtTime(l.createdAt);
222
+ const lc = levelColor(l.level);
223
+ const lvl = padRight(l.level, 5);
224
+ const source = l.flowId !== flowId ? ` ${c.cyan}[${flowMap.get(l.flowId) || '?'}]${c.reset}` : '';
225
+ const data = fmtData(l.data);
226
+ console.log(` ${c.dim}${time}${c.reset} ${lc}${lvl}${c.reset}${source} ${l.message}${data}`);
227
+ }
228
+ }
229
+
230
+ function cmdTree(flowId: bigint, allFlows: any[]) {
231
+ const flow = allFlows.find(f => f.id === flowId);
232
+ if (!flow) {
233
+ console.error(`${c.red}Flow ${flowId} not found.${c.reset}`);
234
+ process.exit(1);
235
+ }
236
+
237
+ console.log(`${c.bold}Flow Tree #${flow.id}${c.reset}\n`);
238
+
239
+ function printNode(f: any, prefix: string, connector: string) {
240
+ const sc = statusColor(f.status);
241
+ const name = f.name === '/' ? 'root' : f.name;
242
+ const dur = f.finishedAt !== 0n ? ` ${c.dim}${fmtDuration(f.createdAt, f.finishedAt)}${c.reset}` : '';
243
+ console.log(`${prefix}${connector}${sc}●${c.reset} ${c.bold}${name}${c.reset} ${c.dim}#${f.id}${c.reset} ${sc}${f.status}${c.reset}${dur}`);
244
+ }
245
+
246
+ function printChildren(parentId: bigint, prefix: string) {
247
+ const children = allFlows
248
+ .filter(ch => ch.parentFlowId === parentId)
249
+ .sort((a, b) => {
250
+ const am = tsMicros(a.createdAt), bm = tsMicros(b.createdAt);
251
+ return am < bm ? -1 : am > bm ? 1 : 0;
252
+ });
253
+
254
+ for (let i = 0; i < children.length; i++) {
255
+ const isLast = i === children.length - 1;
256
+ printNode(children[i], prefix, isLast ? '└─ ' : '├─ ');
257
+ printChildren(children[i].id, prefix + (isLast ? ' ' : '│ '));
258
+ }
259
+ }
260
+
261
+ printNode(flow, '', '');
262
+ printChildren(flowId, '');
263
+ }
264
+
265
+ async function cmdTail(keyHash: string) {
266
+ console.log(`${c.bold}Tailing logs...${c.reset} ${c.dim}(Ctrl+C to stop)${c.reset}\n`);
267
+
268
+ const seen = new Set<bigint>();
269
+ const flowNames = new Map<bigint, string>();
270
+
271
+ const conn = await new Promise<DbConnection>((resolve, reject) => {
272
+ DbConnection.builder()
273
+ .withUri(HOST)
274
+ .withDatabaseName(DB_NAME)
275
+ .onConnect((c, _identity, _token) => {
276
+ c.subscriptionBuilder()
277
+ .onApplied(() => {
278
+ // Seed known flows and seen logs
279
+ for (const f of c.db.flow.iter()) {
280
+ if (f.keyHash === keyHash) flowNames.set(f.id, f.name === '/' ? 'root' : f.name);
281
+ }
282
+ for (const l of c.db.logEntry.iter()) seen.add(l.id);
283
+ resolve(c);
284
+ })
285
+ .subscribeToAllTables();
286
+ })
287
+ .onConnectError((_ctx, err) => reject(new Error(`Connection failed: ${err}`)))
288
+ .build();
289
+ });
290
+
291
+ // Poll for new logs
292
+ setInterval(() => {
293
+ // Update flow names
294
+ for (const f of conn.db.flow.iter()) {
295
+ if (f.keyHash === keyHash && !flowNames.has(f.id)) {
296
+ flowNames.set(f.id, f.name === '/' ? 'root' : f.name);
297
+ }
298
+ }
299
+
300
+ const newLogs: any[] = [];
301
+ for (const l of conn.db.logEntry.iter()) {
302
+ if (!seen.has(l.id) && l.level !== 'flow' && l.level !== 'action') {
303
+ // Only show logs belonging to this key's flows
304
+ if (flowNames.has(l.flowId)) {
305
+ seen.add(l.id);
306
+ newLogs.push(l);
307
+ }
308
+ }
309
+ }
310
+
311
+ newLogs.sort((a, b) => {
312
+ const am = tsMicros(a.createdAt), bm = tsMicros(b.createdAt);
313
+ return am < bm ? -1 : am > bm ? 1 : 0;
314
+ });
315
+
316
+ for (const l of newLogs) {
317
+ const time = fmtTime(l.createdAt);
318
+ const lc = levelColor(l.level);
319
+ const lvl = padRight(l.level, 5);
320
+ const source = `${c.cyan}[${flowNames.get(l.flowId) || '?'}]${c.reset}`;
321
+ const data = fmtData(l.data);
322
+ console.log(`${c.dim}${time}${c.reset} ${lc}${lvl}${c.reset} ${source} ${l.message}${data}`);
323
+ }
324
+ }, 500);
325
+ }
326
+
327
+ // ── Main ─────────────────────────────────────────────────────
328
+
329
+ async function main() {
330
+ const args = process.argv.slice(2);
331
+
332
+ if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
333
+ console.log(`
334
+ ${c.bold}radish${c.reset} — inspect flows and logs
335
+
336
+ ${c.bold}Usage:${c.reset}
337
+ radish <key> List recent flows
338
+ radish <key> <flowId> Show flow detail + logs
339
+ radish <key> tree <flowId> Show flow tree
340
+ radish <key> tail Live-tail all logs
341
+ `);
342
+ process.exit(0);
343
+ }
344
+
345
+ const secretKey = args[0];
346
+ const keyHash = await hashKey(secretKey);
347
+ const subcmd = args[1];
348
+
349
+ if (subcmd === 'tail') {
350
+ await cmdTail(keyHash);
351
+ return; // stays alive
352
+ }
353
+
354
+ // Connect and fetch data
355
+ process.stderr.write(`${c.dim}Connecting...${c.reset}`);
356
+ const { conn, flows, logs } = await connect(keyHash);
357
+ process.stderr.write(`\r${' '.repeat(20)}\r`);
358
+
359
+ const allFlows = flows();
360
+ const allLogs = logs();
361
+
362
+ if (!subcmd) {
363
+ cmdList(allFlows);
364
+ } else if (subcmd === 'tree') {
365
+ const flowId = BigInt(args[2] || '0');
366
+ if (flowId === 0n) {
367
+ console.error(`${c.red}Usage: radish <key> tree <flowId>${c.reset}`);
368
+ process.exit(1);
369
+ }
370
+ cmdTree(flowId, allFlows);
371
+ } else {
372
+ const flowId = BigInt(subcmd);
373
+ cmdShow(flowId, allFlows, allLogs);
374
+ }
375
+
376
+ conn.disconnect();
377
+ process.exit(0);
378
+ }
379
+
380
+ main().catch(err => {
381
+ console.error(`${c.red}${err.message}${c.reset}`);
382
+ process.exit(1);
383
+ });