claude-dashboard 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.
Files changed (88) hide show
  1. package/.claude/settings.local.json +10 -0
  2. package/LICENSE +21 -0
  3. package/README.md +99 -0
  4. package/README.zh-TW.md +99 -0
  5. package/bin/cdb.ts +60 -0
  6. package/bun.lock +1612 -0
  7. package/bunfig.toml +4 -0
  8. package/components.json +20 -0
  9. package/next.config.ts +19 -0
  10. package/package.json +62 -0
  11. package/postcss.config.mjs +9 -0
  12. package/prompts/pm-system.md +61 -0
  13. package/prompts/rd-system.md +68 -0
  14. package/prompts/sec-system.md +93 -0
  15. package/prompts/test-system.md +71 -0
  16. package/prompts/ui-system.md +72 -0
  17. package/server.ts +118 -0
  18. package/sql.js.d.ts +33 -0
  19. package/src/__tests__/api/usage/route.test.ts +193 -0
  20. package/src/__tests__/components/layout/TopNav.test.tsx +155 -0
  21. package/src/__tests__/components/layout/UsageIndicator.test.tsx +503 -0
  22. package/src/__tests__/hooks/useUsage.test.tsx +174 -0
  23. package/src/__tests__/lib/usage/get-token.test.ts +117 -0
  24. package/src/__tests__/react-sanity.test.tsx +14 -0
  25. package/src/__tests__/sanity.test.ts +7 -0
  26. package/src/__tests__/setup.ts +1 -0
  27. package/src/app/api/health/route.ts +8 -0
  28. package/src/app/api/usage/route.ts +86 -0
  29. package/src/app/api/workflows/[id]/route.ts +17 -0
  30. package/src/app/api/workflows/route.ts +14 -0
  31. package/src/app/globals.css +74 -0
  32. package/src/app/history/page.tsx +15 -0
  33. package/src/app/layout.tsx +24 -0
  34. package/src/app/page.tsx +112 -0
  35. package/src/components/agent/AgentCard.tsx +117 -0
  36. package/src/components/agent/AgentCardGrid.tsx +14 -0
  37. package/src/components/agent/AgentOutput.tsx +87 -0
  38. package/src/components/agent/AgentStatusBadge.tsx +20 -0
  39. package/src/components/events/EventLog.tsx +65 -0
  40. package/src/components/events/EventLogItem.tsx +39 -0
  41. package/src/components/history/HistoryTable.tsx +105 -0
  42. package/src/components/layout/DashboardShell.tsx +12 -0
  43. package/src/components/layout/TopNav.tsx +86 -0
  44. package/src/components/layout/UsageIndicator.tsx +163 -0
  45. package/src/components/pipeline/PipelineBar.tsx +59 -0
  46. package/src/components/pipeline/PipelineNode.tsx +55 -0
  47. package/src/components/terminal/TerminalPanel.tsx +138 -0
  48. package/src/components/terminal/XTermRenderer.tsx +129 -0
  49. package/src/components/ui/badge.tsx +37 -0
  50. package/src/components/ui/button.tsx +55 -0
  51. package/src/components/ui/card.tsx +80 -0
  52. package/src/components/ui/input.tsx +26 -0
  53. package/src/components/ui/scroll-area.tsx +52 -0
  54. package/src/components/ui/separator.tsx +31 -0
  55. package/src/components/ui/textarea.tsx +25 -0
  56. package/src/components/ui/tooltip.tsx +73 -0
  57. package/src/components/workflow/WorkflowLauncher.tsx +102 -0
  58. package/src/hooks/useAgentStream.ts +27 -0
  59. package/src/hooks/useAutoScroll.ts +24 -0
  60. package/src/hooks/useUsage.ts +66 -0
  61. package/src/hooks/useWebSocket.ts +289 -0
  62. package/src/lib/agents/prompts.ts +341 -0
  63. package/src/lib/db/connection.ts +263 -0
  64. package/src/lib/db/queries.ts +257 -0
  65. package/src/lib/db/schema.ts +39 -0
  66. package/src/lib/output-buffer.ts +41 -0
  67. package/src/lib/terminal/pty-manager.ts +106 -0
  68. package/src/lib/usage/get-token.ts +48 -0
  69. package/src/lib/utils.ts +6 -0
  70. package/src/lib/websocket/connection-manager.ts +71 -0
  71. package/src/lib/websocket/protocol.ts +90 -0
  72. package/src/lib/websocket/server.ts +231 -0
  73. package/src/lib/workflow/agent-runner.ts +254 -0
  74. package/src/lib/workflow/context-builder.ts +62 -0
  75. package/src/lib/workflow/engine.ts +310 -0
  76. package/src/lib/workflow/pipeline.ts +28 -0
  77. package/src/lib/workflow/types.ts +111 -0
  78. package/src/stores/agentStore.ts +152 -0
  79. package/src/stores/eventStore.ts +35 -0
  80. package/src/stores/terminalStore.ts +20 -0
  81. package/src/stores/uiStore.ts +35 -0
  82. package/src/stores/workflowStore.ts +57 -0
  83. package/src/types/css.d.ts +4 -0
  84. package/src/types/index.ts +12 -0
  85. package/tailwind.config.ts +65 -0
  86. package/tsconfig.json +25 -0
  87. package/tsconfig.server.json +21 -0
  88. package/vitest.config.ts +25 -0
@@ -0,0 +1,341 @@
1
+ import { type AgentRole, AGENT_CONFIG, PIPELINE_STAGES, getStageForRole } from '../workflow/types.ts';
2
+ import { readFileSync } from 'fs';
3
+ import { join, dirname } from 'path';
4
+ import { fileURLToPath } from 'url';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Prompt template loading
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const PROMPTS_DIR = join(__dirname, '..', '..', '..', 'prompts');
12
+
13
+ /**
14
+ * Load a markdown prompt template from the prompts/ directory.
15
+ * Falls back to the built-in default if the file cannot be read.
16
+ */
17
+ function loadPromptTemplate(role: AgentRole): string {
18
+ try {
19
+ const filePath = join(PROMPTS_DIR, `${role}-system.md`);
20
+ return readFileSync(filePath, 'utf-8');
21
+ } catch {
22
+ return DEFAULT_SYSTEM_PROMPTS[role];
23
+ }
24
+ }
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Built-in default system prompts (used as fallback)
28
+ // ---------------------------------------------------------------------------
29
+
30
+ const DEFAULT_SYSTEM_PROMPTS: Record<AgentRole, string> = {
31
+ pm: `# PM Agent
32
+
33
+ You are the PM (Product Manager) Agent. Your job is to analyze requirements and produce a structured specification.
34
+
35
+ ## Access: Read-only
36
+
37
+ ## Output the following sections:
38
+ - Summary
39
+ - User Stories (with acceptance criteria for each)
40
+ - Acceptance Criteria (consolidated)
41
+ - Technical Requirements
42
+ - Out of Scope`,
43
+
44
+ rd: `# RD Agent
45
+
46
+ You are the RD (Backend Development) Agent. Your job is to design the backend architecture and implement server-side code.
47
+
48
+ ## Access: Full (Read, Edit, Bash)
49
+
50
+ ## Output the following sections, then implement:
51
+ - Architecture Overview
52
+ - API Endpoints
53
+ - Database Schema
54
+ - Implementation Plan
55
+
56
+ Then create/modify the actual code files.`,
57
+
58
+ ui: `# UI Agent
59
+
60
+ You are the UI (Frontend) Agent. Your job is to design and implement **pure frontend** components, pages, and styling.
61
+
62
+ ## Access: Frontend files ONLY (Read all, Edit/Create frontend only, Bash)
63
+
64
+ ## Scope: You may ONLY modify React components, pages, layouts, stylesheets, hooks, UI stores, and static assets. Do NOT modify backend code, API routes, or src/lib/**. If there is no frontend work, state "No frontend changes required." and finish.
65
+
66
+ ## Output the following sections, then implement (if applicable):
67
+ - Component Structure
68
+ - UI/UX Plan
69
+
70
+ Then create/modify the actual frontend code files, or state that no changes are needed.`,
71
+
72
+ test: `# TEST Agent
73
+
74
+ You are the TEST (Quality Assurance) Agent. Your job is to write and execute tests.
75
+
76
+ ## Access: Full (Read, Edit, Bash)
77
+
78
+ ## Output the following sections:
79
+ - Test Plan
80
+ - Test Files Created
81
+ - Test Results
82
+ - Coverage Notes
83
+ - Bugs Found
84
+
85
+ Write test files and run them.`,
86
+
87
+ sec: `# SEC Agent
88
+
89
+ You are the SEC (Security) Agent. Your job is to perform a security assessment.
90
+
91
+ ## Access: Read + Bash
92
+
93
+ ## Output the following sections:
94
+ - Security Assessment
95
+ - Vulnerabilities Found
96
+ - OWASP Top 10 Assessment
97
+ - Dependency Audit
98
+ - Recommendations
99
+ - Risk Rating
100
+
101
+ You must NOT modify any source code.`,
102
+ };
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Tool permission descriptions per role
106
+ // ---------------------------------------------------------------------------
107
+
108
+ const TOOL_DESCRIPTIONS: Record<AgentRole, string> = {
109
+ pm: [
110
+ 'You have READ-ONLY access to the project.',
111
+ 'You may read any file to understand the codebase.',
112
+ 'You must NOT create, modify, or delete any files.',
113
+ 'You must NOT execute any shell commands.',
114
+ ].join('\n'),
115
+
116
+ rd: [
117
+ 'You have FULL access to the project.',
118
+ 'You may read, create, and modify files.',
119
+ 'You may execute bash commands to install dependencies, compile, and validate your work.',
120
+ 'Do NOT implement frontend code (that is the UI agent\'s job).',
121
+ 'Do NOT write tests (that is the TEST agent\'s job).',
122
+ 'NOTE: The UI agent is running IN PARALLEL with you. You own backend/server files; the UI agent owns frontend files. Avoid touching frontend code to prevent conflicts.',
123
+ ].join('\n'),
124
+
125
+ ui: [
126
+ 'You have access to read, create, and modify **frontend/UI files ONLY**.',
127
+ 'You may execute bash commands to install frontend dependencies and validate your work.',
128
+ '',
129
+ '## File Scope (STRICTLY ENFORCED)',
130
+ 'You may ONLY create or modify files in these categories:',
131
+ '- React components, pages, layouts (e.g. src/components/**, src/app/**/page.tsx, src/app/**/layout.tsx)',
132
+ '- Stylesheets and CSS (e.g. *.css, *.scss, tailwind config)',
133
+ '- Frontend hooks for UI state/interaction (e.g. src/hooks/**)',
134
+ '- Frontend stores for UI state (e.g. src/stores/**)',
135
+ '- TypeScript type definitions for UI props/state (e.g. src/types/**)',
136
+ '- Static assets (e.g. public/**)',
137
+ '',
138
+ 'You must NOT create or modify:',
139
+ '- API routes with business logic (e.g. src/app/api/**/route.ts)',
140
+ '- Backend/server modules (e.g. src/lib/**)',
141
+ '- Database schemas, migrations, or queries',
142
+ '- Server-side utilities, workflow logic, or agent code',
143
+ '- Configuration files for backend services',
144
+ '',
145
+ 'If the task has NO frontend work to do, output your design documentation and state "No frontend changes required." — do NOT force unnecessary modifications.',
146
+ ].join('\n'),
147
+
148
+ test: [
149
+ 'You have FULL access to the project.',
150
+ 'You may read, create, and modify files.',
151
+ 'You may execute bash commands to run tests and install test dependencies.',
152
+ 'Focus on testing new/modified code from the RD and UI agents.',
153
+ ].join('\n'),
154
+
155
+ sec: [
156
+ 'You have READ + BASH access.',
157
+ 'You may read all files and execute bash commands for security analysis.',
158
+ 'You must NOT modify any source code.',
159
+ 'You may run security scanning tools (npm audit, etc.).',
160
+ ].join('\n'),
161
+ };
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Expected output structure per role
165
+ // ---------------------------------------------------------------------------
166
+
167
+ const OUTPUT_STRUCTURE: Record<AgentRole, string> = {
168
+ pm: [
169
+ 'Structure your output with these exact markdown sections:',
170
+ '',
171
+ '## Summary',
172
+ 'A concise 2-3 sentence overview of the request.',
173
+ '',
174
+ '## User Stories',
175
+ 'Numbered user stories in the format: As a [role], I want [feature] so that [benefit].',
176
+ 'Each with specific acceptance criteria.',
177
+ '',
178
+ '## Acceptance Criteria',
179
+ 'A consolidated, numbered list of all criteria that define "done".',
180
+ '',
181
+ '## Technical Requirements',
182
+ 'Tech stack, dependencies, constraints, and file structure expectations.',
183
+ '',
184
+ '## Out of Scope',
185
+ 'Items explicitly not included in this work.',
186
+ ].join('\n'),
187
+
188
+ rd: [
189
+ 'Structure your output in two parts:',
190
+ '',
191
+ '### Part 1: Design Documentation',
192
+ '',
193
+ '## Architecture Overview',
194
+ 'Architectural approach, patterns, and how it fits the existing codebase.',
195
+ '',
196
+ '## API Endpoints',
197
+ 'Table with Method, Path, Description, Request Body, Response.',
198
+ '',
199
+ '## Database Schema',
200
+ 'Table definitions with columns, types, and indexes.',
201
+ '',
202
+ '## Implementation Plan',
203
+ 'Ordered list of files to create/modify with rationale.',
204
+ '',
205
+ '### Part 2: Implementation',
206
+ 'After documenting the design, create and modify the actual code files.',
207
+ ].join('\n'),
208
+
209
+ ui: [
210
+ 'Structure your output in two parts:',
211
+ '',
212
+ '### Part 1: Design',
213
+ '',
214
+ '## Component Structure',
215
+ 'Component hierarchy showing parent-child relationships.',
216
+ '',
217
+ '## UI/UX Plan',
218
+ 'Layout, state management, data flow, interactions, and styling approach.',
219
+ '',
220
+ '### Part 2: Implementation',
221
+ 'After documenting the design, create and modify the actual frontend files.',
222
+ ].join('\n'),
223
+
224
+ test: [
225
+ 'Structure your output with these sections:',
226
+ '',
227
+ '## Test Plan',
228
+ 'Overview of testing strategy: unit tests, integration tests, edge cases.',
229
+ '',
230
+ '## Test Files Created',
231
+ 'List of test files written and what each tests.',
232
+ '',
233
+ '## Test Results',
234
+ 'Execution summary: total, passed, failed, skipped.',
235
+ '',
236
+ '## Coverage Notes',
237
+ 'Areas well-covered and areas with gaps.',
238
+ '',
239
+ '## Bugs Found',
240
+ 'Any bugs discovered during testing with description, location, and severity.',
241
+ ].join('\n'),
242
+
243
+ sec: [
244
+ 'Structure your output with these sections:',
245
+ '',
246
+ '## Security Assessment',
247
+ 'Overview of what was reviewed and the overall security posture.',
248
+ '',
249
+ '## Vulnerabilities Found',
250
+ 'Each vulnerability with: Severity, OWASP Category, Location, Description, Impact, Recommendation.',
251
+ '',
252
+ '## OWASP Top 10 Assessment',
253
+ 'Table assessing each of the OWASP Top 10 (2021) categories: PASS/WARN/FAIL with notes.',
254
+ '',
255
+ '## Dependency Audit',
256
+ 'Results of npm audit or equivalent.',
257
+ '',
258
+ '## Recommendations',
259
+ 'Prioritized list of security improvements.',
260
+ '',
261
+ '## Risk Rating',
262
+ 'Overall risk rating (LOW/MEDIUM/HIGH/CRITICAL) with justification.',
263
+ ].join('\n'),
264
+ };
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // Public API
268
+ // ---------------------------------------------------------------------------
269
+
270
+ /**
271
+ * Get the full system prompt for a given agent role.
272
+ *
273
+ * This loads the markdown template from prompts/{role}-system.md and augments
274
+ * it with tool permissions, output structure expectations, and project context.
275
+ *
276
+ * @param role - The agent role (pm, rd, ui, test, sec)
277
+ * @param projectPath - Absolute path to the project being worked on
278
+ * @returns The complete system prompt string
279
+ */
280
+ export function getSystemPrompt(role: AgentRole, projectPath: string): string {
281
+ const template = loadPromptTemplate(role);
282
+ const config = AGENT_CONFIG[role];
283
+ const currentStage = getStageForRole(role);
284
+
285
+ const pipelinePosition = currentStage.index === 0
286
+ ? 'You are in Stage 1 of 3 (first stage). There is no prior context.'
287
+ : `You are in Stage ${currentStage.index + 1} of ${PIPELINE_STAGES.length}.`;
288
+
289
+ const peers = currentStage.roles
290
+ .filter((r) => r !== role)
291
+ .map((r) => AGENT_CONFIG[r].label);
292
+
293
+ const upstreamAgents = PIPELINE_STAGES
294
+ .filter((s) => s.index < currentStage.index)
295
+ .flatMap((s) => s.roles.map((r) => AGENT_CONFIG[r].label));
296
+
297
+ const downstreamAgents = PIPELINE_STAGES
298
+ .filter((s) => s.index > currentStage.index)
299
+ .flatMap((s) => s.roles.map((r) => AGENT_CONFIG[r].label));
300
+
301
+ const sections: string[] = [
302
+ template,
303
+ '',
304
+ '---',
305
+ '',
306
+ '# Operational Context',
307
+ '',
308
+ `## Project Path`,
309
+ projectPath,
310
+ '',
311
+ `## Pipeline Position`,
312
+ pipelinePosition,
313
+ ...(peers.length > 0
314
+ ? [`Running IN PARALLEL with: ${peers.join(', ')}`]
315
+ : []),
316
+ ...(upstreamAgents.length > 0
317
+ ? [`Agents that ran before you (prior stages): ${upstreamAgents.join(', ')}`]
318
+ : []),
319
+ ...(downstreamAgents.length > 0
320
+ ? [`Agents that will run after this stage: ${downstreamAgents.join(', ')}`]
321
+ : ['You are in the FINAL stage of the pipeline.']),
322
+ '',
323
+ '## Tool Permissions',
324
+ TOOL_DESCRIPTIONS[role],
325
+ '',
326
+ `## Available Tools`,
327
+ `You have access to: ${config.tools.join(', ')}`,
328
+ '',
329
+ `## Timeout`,
330
+ `You have ${config.timeoutMs / 1000} seconds to complete your work.`,
331
+ '',
332
+ '## Expected Output Structure',
333
+ OUTPUT_STRUCTURE[role],
334
+ '',
335
+ '## Output Language',
336
+ 'IMPORTANT: You MUST respond in the same language as the text under "# User Request" in your prompt. The structural template around it (headers, labels, instructions) is always in English — ignore the template language. Focus ONLY on the language the user actually wrote in. If the user wrote in Traditional Chinese, you MUST respond entirely in Traditional Chinese. If in English, respond in English. Technical terms and code identifiers should remain in their original form.',
337
+ ];
338
+
339
+ return sections.join('\n');
340
+ }
341
+
@@ -0,0 +1,263 @@
1
+ import initSqlJs, { type Database as SqlJsDatabase } from 'sql.js';
2
+ import path from 'path';
3
+ import fs from 'fs';
4
+ import { fileURLToPath } from 'url';
5
+ import { SCHEMA } from './schema.ts';
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Module-level state
9
+ // ---------------------------------------------------------------------------
10
+
11
+ let wrapper: SqlJsWrapper | null = null;
12
+ let inTransaction = false;
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // PreparedStatement — mimics better-sqlite3's Statement API
16
+ // ---------------------------------------------------------------------------
17
+
18
+ class PreparedStatement {
19
+ db: SqlJsDatabase;
20
+ sql: string;
21
+ owner: SqlJsWrapper;
22
+
23
+ constructor(db: SqlJsDatabase, sql: string, owner: SqlJsWrapper) {
24
+ this.db = db;
25
+ this.sql = sql;
26
+ this.owner = owner;
27
+ }
28
+
29
+ /**
30
+ * Convert better-sqlite3 style named params `{ id, title }` to sql.js
31
+ * style `{ "@id": ..., "@title": ... }`. Positional args are passed as
32
+ * arrays.
33
+ */
34
+ normalizeParams(args: unknown[]): unknown[] | Record<string, unknown> {
35
+ if (args.length === 0) return [];
36
+
37
+ // Single object arg → named parameters
38
+ if (
39
+ args.length === 1 &&
40
+ args[0] !== null &&
41
+ typeof args[0] === 'object' &&
42
+ !Array.isArray(args[0])
43
+ ) {
44
+ const obj = args[0] as Record<string, unknown>;
45
+ const mapped: Record<string, unknown> = {};
46
+ for (const [key, value] of Object.entries(obj)) {
47
+ mapped[`@${key}`] = value;
48
+ }
49
+ return mapped;
50
+ }
51
+
52
+ // Positional args
53
+ return args;
54
+ }
55
+
56
+ /**
57
+ * Execute a write statement (INSERT / UPDATE / DELETE).
58
+ */
59
+ run(...args: unknown[]): { changes: number; lastInsertRowid: number } {
60
+ const params = this.normalizeParams(args);
61
+ this.db.run(this.sql, params as any);
62
+ const changes = this.db.getRowsModified();
63
+
64
+ // Persist after every write unless we're inside a transaction
65
+ if (!inTransaction) {
66
+ this.owner.persist();
67
+ }
68
+
69
+ return { changes, lastInsertRowid: 0 };
70
+ }
71
+
72
+ /**
73
+ * Fetch a single row (like better-sqlite3's `.get()`).
74
+ */
75
+ get(...args: unknown[]): any {
76
+ const params = this.normalizeParams(args);
77
+ const stmt = this.db.prepare(this.sql);
78
+ try {
79
+ if (Array.isArray(params)) {
80
+ stmt.bind(params.length > 0 ? params : undefined);
81
+ } else {
82
+ stmt.bind(params as any);
83
+ }
84
+ if (stmt.step()) {
85
+ return stmt.getAsObject() as Record<string, unknown>;
86
+ }
87
+ return undefined;
88
+ } finally {
89
+ stmt.free();
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Fetch all matching rows (like better-sqlite3's `.all()`).
95
+ */
96
+ all(...args: unknown[]): any[] {
97
+ const params = this.normalizeParams(args);
98
+ const rows: any[] = [];
99
+ const stmt = this.db.prepare(this.sql);
100
+ try {
101
+ if (Array.isArray(params)) {
102
+ stmt.bind(params.length > 0 ? params : undefined);
103
+ } else {
104
+ stmt.bind(params as any);
105
+ }
106
+ while (stmt.step()) {
107
+ rows.push(stmt.getAsObject() as Record<string, unknown>);
108
+ }
109
+ return rows;
110
+ } finally {
111
+ stmt.free();
112
+ }
113
+ }
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // SqlJsWrapper — drop-in replacement for the better-sqlite3 Database object
118
+ // ---------------------------------------------------------------------------
119
+
120
+ class SqlJsWrapper {
121
+ db: SqlJsDatabase;
122
+ dbPath: string;
123
+
124
+ constructor(db: SqlJsDatabase, dbPath: string) {
125
+ this.db = db;
126
+ this.dbPath = dbPath;
127
+ }
128
+
129
+ prepare(sql: string): PreparedStatement {
130
+ return new PreparedStatement(this.db, sql, this);
131
+ }
132
+
133
+ exec(sql: string): void {
134
+ this.db.exec(sql);
135
+ }
136
+
137
+ pragma(pragmaStr: string): unknown {
138
+ // WAL mode is meaningless for sql.js (in-memory) — silently skip
139
+ if (/journal_mode\s*=\s*wal/i.test(pragmaStr)) {
140
+ return undefined;
141
+ }
142
+ const result = this.db.exec(`PRAGMA ${pragmaStr}`);
143
+ if (result.length > 0 && result[0].values.length > 0) {
144
+ return result[0].values[0][0];
145
+ }
146
+ return undefined;
147
+ }
148
+
149
+ /**
150
+ * Return a function that, when called, executes `fn` inside a transaction.
151
+ */
152
+ transaction<T>(fn: () => T): () => T {
153
+ return () => {
154
+ this.db.run('BEGIN');
155
+ inTransaction = true;
156
+ try {
157
+ const result = fn();
158
+ this.db.run('COMMIT');
159
+ inTransaction = false;
160
+ this.persist();
161
+ return result;
162
+ } catch (err) {
163
+ inTransaction = false;
164
+ this.db.run('ROLLBACK');
165
+ throw err;
166
+ }
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Persist the in-memory database to disk using atomic write-to-tmp +
172
+ * rename.
173
+ */
174
+ persist(): void {
175
+ const data = this.db.export();
176
+ const buffer = Buffer.from(data);
177
+ const tmpPath = `${this.dbPath}.tmp`;
178
+ fs.writeFileSync(tmpPath, buffer);
179
+ fs.renameSync(tmpPath, this.dbPath);
180
+ }
181
+
182
+ close(): void {
183
+ try {
184
+ this.persist();
185
+ } catch {
186
+ // best-effort on close
187
+ }
188
+ this.db.close();
189
+ }
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Public API
194
+ // ---------------------------------------------------------------------------
195
+
196
+ /**
197
+ * Asynchronously initialise the sql.js database.
198
+ *
199
+ * Loads the WASM binary, reads an existing DB file from disk (if present),
200
+ * applies the schema, and enables foreign keys.
201
+ */
202
+ export async function initDb(): Promise<SqlJsWrapper> {
203
+ if (wrapper) return wrapper;
204
+
205
+ const dataDir = path.join(process.cwd(), 'data');
206
+ if (!fs.existsSync(dataDir)) {
207
+ fs.mkdirSync(dataDir, { recursive: true });
208
+ }
209
+ const dbPath = path.join(dataDir, 'dashboard.db');
210
+
211
+ // Locate the sql.js WASM file
212
+ const currentDir = path.dirname(fileURLToPath(import.meta.url));
213
+ const projectRoot = path.resolve(currentDir, '..', '..', '..');
214
+ const wasmPath = path.join(
215
+ projectRoot,
216
+ 'node_modules',
217
+ 'sql.js',
218
+ 'dist',
219
+ 'sql-wasm.wasm',
220
+ );
221
+
222
+ const SQL = await initSqlJs({
223
+ locateFile: () => wasmPath,
224
+ });
225
+
226
+ // Load existing DB file if available
227
+ let db: SqlJsDatabase;
228
+ if (fs.existsSync(dbPath)) {
229
+ const fileBuffer = fs.readFileSync(dbPath);
230
+ db = new SQL.Database(fileBuffer);
231
+ } else {
232
+ db = new SQL.Database();
233
+ }
234
+
235
+ wrapper = new SqlJsWrapper(db, dbPath);
236
+ wrapper.pragma('foreign_keys = ON');
237
+ wrapper.exec(SCHEMA);
238
+
239
+ return wrapper;
240
+ }
241
+
242
+ /**
243
+ * Return the initialised wrapper (synchronous).
244
+ * Throws if `initDb()` has not been called yet.
245
+ */
246
+ export function getDb(): SqlJsWrapper {
247
+ if (!wrapper) {
248
+ throw new Error(
249
+ 'Database not initialised. Call initDb() before getDb().',
250
+ );
251
+ }
252
+ return wrapper;
253
+ }
254
+
255
+ /**
256
+ * Persist and close the database, resetting the singleton.
257
+ */
258
+ export function closeDb(): void {
259
+ if (wrapper) {
260
+ wrapper.close();
261
+ wrapper = null;
262
+ }
263
+ }