@rsktash/beads-ui 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 (68) hide show
  1. package/.github/workflows/publish.yml +28 -0
  2. package/app/protocol.js +216 -0
  3. package/bin/bdui +19 -0
  4. package/client/index.html +12 -0
  5. package/client/postcss.config.js +11 -0
  6. package/client/src/App.tsx +35 -0
  7. package/client/src/components/IssueCard.tsx +73 -0
  8. package/client/src/components/Layout.tsx +175 -0
  9. package/client/src/components/Markdown.tsx +77 -0
  10. package/client/src/components/PriorityBadge.tsx +26 -0
  11. package/client/src/components/SearchDialog.tsx +137 -0
  12. package/client/src/components/SectionEditor.tsx +212 -0
  13. package/client/src/components/StatusBadge.tsx +64 -0
  14. package/client/src/components/TypeBadge.tsx +26 -0
  15. package/client/src/hooks/use-mutation.ts +55 -0
  16. package/client/src/hooks/use-search.ts +19 -0
  17. package/client/src/hooks/use-subscription.ts +187 -0
  18. package/client/src/index.css +133 -0
  19. package/client/src/lib/avatar.ts +17 -0
  20. package/client/src/lib/types.ts +115 -0
  21. package/client/src/lib/ws-client.ts +214 -0
  22. package/client/src/lib/ws-context.tsx +28 -0
  23. package/client/src/main.tsx +10 -0
  24. package/client/src/views/Board.tsx +200 -0
  25. package/client/src/views/Detail.tsx +398 -0
  26. package/client/src/views/List.tsx +461 -0
  27. package/client/tailwind.config.ts +68 -0
  28. package/client/tsconfig.json +16 -0
  29. package/client/vite.config.ts +20 -0
  30. package/package.json +43 -0
  31. package/server/app.js +120 -0
  32. package/server/app.test.js +30 -0
  33. package/server/bd.js +227 -0
  34. package/server/bd.test.js +194 -0
  35. package/server/cli/cli.test.js +207 -0
  36. package/server/cli/commands.integration.test.js +148 -0
  37. package/server/cli/commands.js +285 -0
  38. package/server/cli/commands.unit.test.js +408 -0
  39. package/server/cli/daemon.js +340 -0
  40. package/server/cli/daemon.test.js +31 -0
  41. package/server/cli/index.js +135 -0
  42. package/server/cli/open.js +178 -0
  43. package/server/cli/open.test.js +26 -0
  44. package/server/cli/usage.js +27 -0
  45. package/server/config.js +36 -0
  46. package/server/db.js +154 -0
  47. package/server/db.test.js +169 -0
  48. package/server/dolt-pool.js +257 -0
  49. package/server/dolt-queries.js +646 -0
  50. package/server/index.js +97 -0
  51. package/server/list-adapters.js +395 -0
  52. package/server/list-adapters.test.js +208 -0
  53. package/server/logging.js +23 -0
  54. package/server/registry-watcher.js +200 -0
  55. package/server/subscriptions.js +299 -0
  56. package/server/subscriptions.test.js +128 -0
  57. package/server/validators.js +124 -0
  58. package/server/watcher.js +139 -0
  59. package/server/watcher.test.js +120 -0
  60. package/server/ws.comments.test.js +262 -0
  61. package/server/ws.delete.test.js +119 -0
  62. package/server/ws.js +1309 -0
  63. package/server/ws.labels.test.js +95 -0
  64. package/server/ws.list-refresh.coalesce.test.js +95 -0
  65. package/server/ws.list-subscriptions.test.js +403 -0
  66. package/server/ws.mutation-window.test.js +147 -0
  67. package/server/ws.mutations.test.js +389 -0
  68. package/server/ws.test.js +52 -0
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Print CLI usage to a stream-like target.
3
+ *
4
+ * @param {{ write: (chunk: string) => any }} out_stream
5
+ */
6
+ export function printUsage(out_stream) {
7
+ const lines = [
8
+ 'Usage: bdui <command> [options]',
9
+ '',
10
+ 'Commands:',
11
+ ' start Start the UI server',
12
+ ' stop Stop the UI server',
13
+ ' restart Restart the UI server',
14
+ '',
15
+ 'Options:',
16
+ ' -h, --help Show this help message',
17
+ ' -v, --version Show the CLI version',
18
+ ' -d, --debug Enable debug logging',
19
+ ' --open Open the browser after start/restart',
20
+ ' --host <addr> Bind to a specific host (default: 127.0.0.1)',
21
+ ' --port <num> Bind to a specific port (default: 3000)',
22
+ ''
23
+ ];
24
+ for (const line of lines) {
25
+ out_stream.write(line + '\n');
26
+ }
27
+ }
@@ -0,0 +1,36 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ /**
5
+ * Resolve runtime configuration for the server.
6
+ * Notes:
7
+ * - `app_dir` is resolved relative to the installed package location.
8
+ * - `root_dir` represents the directory where the process was invoked
9
+ * (i.e., the current working directory) so DB resolution follows the
10
+ * caller's context rather than the install location.
11
+ *
12
+ * @returns {{ host: string, port: number, app_dir: string, root_dir: string, url: string }}
13
+ */
14
+ export function getConfig() {
15
+ const this_file = fileURLToPath(new URL(import.meta.url));
16
+ const server_dir = path.dirname(this_file);
17
+ const package_root = path.resolve(server_dir, '..');
18
+ // Always reflect the directory from which the process was started
19
+ const root_dir = process.cwd();
20
+
21
+ let port_value = Number.parseInt(process.env.PORT || '', 10);
22
+ if (!Number.isFinite(port_value)) {
23
+ port_value = 3333;
24
+ }
25
+
26
+ const host_env = process.env.HOST;
27
+ const host_value = host_env && host_env.length > 0 ? host_env : '127.0.0.1';
28
+
29
+ return {
30
+ host: host_value,
31
+ port: port_value,
32
+ app_dir: path.resolve(package_root, 'app'),
33
+ root_dir,
34
+ url: `http://${host_value}:${port_value}`
35
+ };
36
+ }
package/server/db.js ADDED
@@ -0,0 +1,154 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ /**
6
+ * Resolve the SQLite DB path used by beads according to precedence:
7
+ * 1) explicit --db flag (provided via options.explicit_db)
8
+ * 2) BEADS_DB environment variable
9
+ * 3) nearest ".beads/*.db" by walking up from cwd (excluding
10
+ * "~/.beads/default.db", which is reserved for fallback)
11
+ * 4) "~/.beads/default.db" fallback
12
+ *
13
+ * Returns a normalized absolute path and a `source` indicator. Existence is
14
+ * returned via the `exists` boolean.
15
+ *
16
+ * @param {{ cwd?: string, env?: Record<string, string | undefined>, explicit_db?: string }} [options]
17
+ * @returns {{ path: string, source: 'flag'|'env'|'nearest'|'home-default', exists: boolean }}
18
+ */
19
+ export function resolveDbPath(options = {}) {
20
+ const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd();
21
+ const env = options.env || process.env;
22
+ const home_default = path.join(os.homedir(), '.beads', 'default.db');
23
+
24
+ // 1) explicit flag
25
+ if (options.explicit_db && options.explicit_db.length > 0) {
26
+ const p = absFrom(options.explicit_db, cwd);
27
+ return { path: p, source: 'flag', exists: fileExists(p) };
28
+ }
29
+
30
+ // 2) BEADS_DB env
31
+ if (env.BEADS_DB && String(env.BEADS_DB).length > 0) {
32
+ const p = absFrom(String(env.BEADS_DB), cwd);
33
+ return { path: p, source: 'env', exists: fileExists(p) };
34
+ }
35
+
36
+ // 3) nearest .beads/*.db walking up
37
+ const nearest = findNearestBeadsDb(cwd);
38
+ if (nearest && path.normalize(nearest) !== path.normalize(home_default)) {
39
+ return { path: nearest, source: 'nearest', exists: fileExists(nearest) };
40
+ }
41
+
42
+ // 4) ~/.beads/default.db
43
+ return {
44
+ path: home_default,
45
+ source: 'home-default',
46
+ exists: fileExists(home_default)
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Resolve the workspace database location used by the UI/server.
52
+ *
53
+ * For non-SQLite backends (for example Dolt), this returns the nearest
54
+ * workspace `.beads` directory when metadata exists. This avoids collapsing
55
+ * all such workspaces onto the `~/.beads/default.db` fallback.
56
+ *
57
+ * @param {{ cwd?: string, env?: Record<string, string | undefined>, explicit_db?: string }} [options]
58
+ * @returns {{ path: string, source: 'flag'|'env'|'nearest'|'metadata'|'home-default', exists: boolean }}
59
+ */
60
+ export function resolveWorkspaceDatabase(options = {}) {
61
+ const sqlite_db = resolveDbPath(options);
62
+ if (sqlite_db.source !== 'home-default') {
63
+ return sqlite_db;
64
+ }
65
+
66
+ const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd();
67
+ const metadata_path = findNearestBeadsMetadata(cwd);
68
+ if (metadata_path) {
69
+ return {
70
+ path: path.dirname(metadata_path),
71
+ source: 'metadata',
72
+ exists: true
73
+ };
74
+ }
75
+
76
+ return sqlite_db;
77
+ }
78
+
79
+ /**
80
+ * Find nearest `.beads/metadata.json` by walking up from start.
81
+ *
82
+ * @param {string} start
83
+ * @returns {string | null}
84
+ */
85
+ export function findNearestBeadsMetadata(start) {
86
+ let dir = path.resolve(start);
87
+ for (let i = 0; i < 100; i++) {
88
+ const metadata_path = path.join(dir, '.beads', 'metadata.json');
89
+ if (fileExists(metadata_path)) {
90
+ return metadata_path;
91
+ }
92
+ const parent = path.dirname(dir);
93
+ if (parent === dir) {
94
+ break;
95
+ }
96
+ dir = parent;
97
+ }
98
+ return null;
99
+ }
100
+
101
+ /**
102
+ * Find nearest .beads/*.db by walking up from start.
103
+ * First alphabetical .db.
104
+ *
105
+ * @param {string} start
106
+ * @returns {string | null}
107
+ */
108
+ export function findNearestBeadsDb(start) {
109
+ let dir = path.resolve(start);
110
+ // Cap iterations to avoid infinite loop in degenerate cases
111
+ for (let i = 0; i < 100; i++) {
112
+ const beads_dir = path.join(dir, '.beads');
113
+ try {
114
+ const entries = fs.readdirSync(beads_dir, { withFileTypes: true });
115
+ const dbs = entries
116
+ .filter((e) => e.isFile() && e.name.endsWith('.db'))
117
+ .map((e) => e.name)
118
+ .sort();
119
+ if (dbs.length > 0) {
120
+ return path.join(beads_dir, dbs[0]);
121
+ }
122
+ } catch {
123
+ // ignore and walk up
124
+ }
125
+ const parent = path.dirname(dir);
126
+ if (parent === dir) {
127
+ break;
128
+ }
129
+ dir = parent;
130
+ }
131
+ return null;
132
+ }
133
+
134
+ /**
135
+ * Resolve possibly relative `p` against `cwd` to an absolute filesystem path.
136
+ *
137
+ * @param {string} p
138
+ * @param {string} cwd
139
+ */
140
+ function absFrom(p, cwd) {
141
+ return path.isAbsolute(p) ? path.normalize(p) : path.join(cwd, p);
142
+ }
143
+
144
+ /**
145
+ * @param {string} p
146
+ */
147
+ function fileExists(p) {
148
+ try {
149
+ fs.accessSync(p, fs.constants.F_OK);
150
+ return true;
151
+ } catch {
152
+ return false;
153
+ }
154
+ }
@@ -0,0 +1,169 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
5
+ import {
6
+ findNearestBeadsDb,
7
+ findNearestBeadsMetadata,
8
+ resolveDbPath,
9
+ resolveWorkspaceDatabase
10
+ } from './db.js';
11
+
12
+ /** @type {string[]} */
13
+ const tmps = [];
14
+
15
+ function mkdtemp() {
16
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'beads-ui-test-'));
17
+ tmps.push(dir);
18
+ return dir;
19
+ }
20
+
21
+ beforeEach(() => {
22
+ vi.resetModules();
23
+ });
24
+
25
+ afterEach(() => {
26
+ for (const d of tmps.splice(0)) {
27
+ try {
28
+ fs.rmSync(d, { recursive: true, force: true });
29
+ } catch {
30
+ // ignore cleanup errors
31
+ }
32
+ }
33
+ });
34
+
35
+ describe('resolveDbPath', () => {
36
+ test('uses explicit_db when provided', () => {
37
+ const res = resolveDbPath({ cwd: '/x', explicit_db: './my.db', env: {} });
38
+ expect(res.path.endsWith('/x/my.db')).toBe(true);
39
+ expect(res.source).toBe('flag');
40
+ });
41
+
42
+ test('uses BEADS_DB from env when set', () => {
43
+ const res = resolveDbPath({ cwd: '/x', env: { BEADS_DB: '/abs/env.db' } });
44
+ expect(res.path).toBe('/abs/env.db');
45
+ expect(res.source).toBe('env');
46
+ });
47
+
48
+ test('finds nearest .beads/ui.db walking up', () => {
49
+ const root = mkdtemp();
50
+ const nested = path.join(root, 'a', 'b', 'c');
51
+ fs.mkdirSync(nested, { recursive: true });
52
+ const beads = path.join(root, '.beads');
53
+ fs.mkdirSync(beads);
54
+ const ui_db = path.join(beads, 'ui.db');
55
+ fs.writeFileSync(ui_db, '');
56
+
57
+ const found = findNearestBeadsDb(nested);
58
+ expect(found).toBe(ui_db);
59
+
60
+ const res = resolveDbPath({ cwd: nested, env: {} });
61
+ expect(res.path).toBe(ui_db);
62
+ expect(res.source).toBe('nearest');
63
+ });
64
+
65
+ test('falls back to ~/.beads/default.db when none found', async () => {
66
+ // Mock os.homedir to a deterministic location using spy
67
+ const home = mkdtemp();
68
+ vi.spyOn(os, 'homedir').mockReturnValue(home);
69
+ const mod = await import('./db.js');
70
+ const res = mod.resolveDbPath({ cwd: '/no/db/here', env: {} });
71
+ expect(res.path).toBe(path.join(home, '.beads', 'default.db'));
72
+ expect(res.source).toBe('home-default');
73
+ });
74
+
75
+ test('treats ~/.beads/default.db as fallback, not nearest workspace db', async () => {
76
+ const home = mkdtemp();
77
+ const nested = path.join(home, 'projects', 'repo', 'deep');
78
+ fs.mkdirSync(nested, { recursive: true });
79
+ const home_beads = path.join(home, '.beads');
80
+ fs.mkdirSync(home_beads, { recursive: true });
81
+ fs.writeFileSync(path.join(home_beads, 'default.db'), '');
82
+ vi.spyOn(os, 'homedir').mockReturnValue(home);
83
+ const mod = await import('./db.js');
84
+
85
+ const res = mod.resolveDbPath({ cwd: nested, env: {} });
86
+
87
+ expect(res.path).toBe(path.join(home, '.beads', 'default.db'));
88
+ expect(res.source).toBe('home-default');
89
+ });
90
+ });
91
+
92
+ describe('findNearestBeadsMetadata', () => {
93
+ test('finds nearest metadata walking up', () => {
94
+ const root = mkdtemp();
95
+ const nested = path.join(root, 'a', 'b', 'c');
96
+ fs.mkdirSync(nested, { recursive: true });
97
+ const beads_dir = path.join(root, '.beads');
98
+ fs.mkdirSync(beads_dir, { recursive: true });
99
+ const metadata = path.join(beads_dir, 'metadata.json');
100
+ fs.writeFileSync(metadata, '{}');
101
+
102
+ const found = findNearestBeadsMetadata(nested);
103
+
104
+ expect(found).toBe(metadata);
105
+ });
106
+
107
+ test('returns null when metadata is missing', () => {
108
+ const root = mkdtemp();
109
+
110
+ const found = findNearestBeadsMetadata(root);
111
+
112
+ expect(found).toBeNull();
113
+ });
114
+ });
115
+
116
+ describe('resolveWorkspaceDatabase', () => {
117
+ test('uses metadata directory for non-SQLite workspace', () => {
118
+ const root = mkdtemp();
119
+ const nested = path.join(root, 'workspace', 'nested');
120
+ fs.mkdirSync(nested, { recursive: true });
121
+ const beads_dir = path.join(root, '.beads');
122
+ fs.mkdirSync(beads_dir, { recursive: true });
123
+ fs.writeFileSync(path.join(beads_dir, 'metadata.json'), '{}');
124
+
125
+ const found = resolveWorkspaceDatabase({ cwd: nested, env: {} });
126
+
127
+ expect(found.path).toBe(beads_dir);
128
+ expect(found.source).toBe('metadata');
129
+ expect(found.exists).toBe(true);
130
+ });
131
+
132
+ test('prefers metadata workspace when home default db exists', async () => {
133
+ const home = mkdtemp();
134
+ const root = path.join(home, 'project');
135
+ const nested = path.join(root, 'workspace', 'nested');
136
+ fs.mkdirSync(nested, { recursive: true });
137
+ const home_beads = path.join(home, '.beads');
138
+ fs.mkdirSync(home_beads, { recursive: true });
139
+ fs.writeFileSync(path.join(home_beads, 'default.db'), '');
140
+ const beads_dir = path.join(root, '.beads');
141
+ fs.mkdirSync(beads_dir, { recursive: true });
142
+ fs.writeFileSync(path.join(beads_dir, 'metadata.json'), '{}');
143
+ vi.spyOn(os, 'homedir').mockReturnValue(home);
144
+ const mod = await import('./db.js');
145
+
146
+ const found = mod.resolveWorkspaceDatabase({ cwd: nested, env: {} });
147
+
148
+ expect(found.path).toBe(beads_dir);
149
+ expect(found.source).toBe('metadata');
150
+ expect(found.exists).toBe(true);
151
+ });
152
+
153
+ test('prefers nearest sqlite database when present', () => {
154
+ const root = mkdtemp();
155
+ const nested = path.join(root, 'workspace', 'nested');
156
+ fs.mkdirSync(nested, { recursive: true });
157
+ const beads_dir = path.join(root, '.beads');
158
+ fs.mkdirSync(beads_dir, { recursive: true });
159
+ fs.writeFileSync(path.join(beads_dir, 'metadata.json'), '{}');
160
+ const sqlite_db = path.join(beads_dir, 'workspace.db');
161
+ fs.writeFileSync(sqlite_db, '');
162
+
163
+ const found = resolveWorkspaceDatabase({ cwd: nested, env: {} });
164
+
165
+ expect(found.path).toBe(sqlite_db);
166
+ expect(found.source).toBe('nearest');
167
+ expect(found.exists).toBe(true);
168
+ });
169
+ });
@@ -0,0 +1,257 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import mysql from 'mysql2/promise';
5
+ import { findNearestBeadsMetadata } from './db.js';
6
+ import { debug } from './logging.js';
7
+
8
+ const log = debug('dolt-pool');
9
+
10
+ const DOLT_PORT = 3307;
11
+ const DOLT_SOCK = '/tmp/bdui-dolt.sock';
12
+
13
+ /** @type {import('mysql2/promise').Pool | null} */
14
+ let pool = null;
15
+
16
+ /** @type {import('node:child_process').ChildProcess | null} */
17
+ let serverProcess = null;
18
+
19
+ /** @type {string | null} */
20
+ let currentDataDir = null;
21
+
22
+ /**
23
+ * Resolve the Dolt data directory from the workspace root.
24
+ * Walks up from cwd to find .beads/metadata.json, then checks for
25
+ * embeddeddolt/<database>/ structure.
26
+ *
27
+ * @param {string} cwd
28
+ * @returns {{ dataDir: string, database: string } | null}
29
+ */
30
+ export function resolveDoltDir(cwd) {
31
+ const metadataPath = findNearestBeadsMetadata(cwd);
32
+ if (!metadataPath) return null;
33
+
34
+ const beadsDir = path.dirname(metadataPath);
35
+ let metadata;
36
+ try {
37
+ metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
38
+ } catch {
39
+ return null;
40
+ }
41
+
42
+ if (metadata.backend !== 'dolt' && metadata.database !== 'dolt') return null;
43
+
44
+ const database = metadata.dolt_database || 'default';
45
+ const dataDir = path.join(beadsDir, 'embeddeddolt', database);
46
+
47
+ try {
48
+ fs.accessSync(path.join(dataDir, '.dolt'), fs.constants.F_OK);
49
+ } catch {
50
+ return null;
51
+ }
52
+
53
+ return { dataDir, database };
54
+ }
55
+
56
+ /**
57
+ * Start the Dolt SQL server and create a connection pool.
58
+ * Idempotent — returns existing pool if already running for the same dataDir.
59
+ *
60
+ * @param {string} cwd - Workspace root directory
61
+ * @returns {Promise<import('mysql2/promise').Pool | null>}
62
+ */
63
+ export async function startDoltServer(cwd) {
64
+ const resolved = resolveDoltDir(cwd);
65
+ if (!resolved) {
66
+ log('no Dolt database found for %s', cwd);
67
+ return null;
68
+ }
69
+
70
+ const { dataDir, database } = resolved;
71
+
72
+ // Already running for this data dir
73
+ if (pool && currentDataDir === dataDir && serverProcess && !serverProcess.killed) {
74
+ return pool;
75
+ }
76
+
77
+ // Clean up any previous server
78
+ await stopDoltServer();
79
+
80
+ log('starting dolt sql-server at %s (database=%s)', dataDir, database);
81
+
82
+ // Clean up stale socket
83
+ try { fs.unlinkSync(DOLT_SOCK); } catch { /* ignore */ }
84
+
85
+ currentDataDir = dataDir;
86
+
87
+ return new Promise((resolve) => {
88
+ const child = spawn('dolt', [
89
+ 'sql-server',
90
+ '-P', String(DOLT_PORT),
91
+ '--socket', DOLT_SOCK,
92
+ '-l', 'warning'
93
+ ], {
94
+ cwd: dataDir,
95
+ stdio: ['ignore', 'pipe', 'pipe'],
96
+ detached: false
97
+ });
98
+
99
+ serverProcess = child;
100
+
101
+ let resolved_already = false;
102
+
103
+ child.stderr?.setEncoding('utf8');
104
+ child.stderr?.on('data', (chunk) => {
105
+ // Log warnings/errors from dolt
106
+ if (chunk.trim()) log('dolt: %s', chunk.trim());
107
+ });
108
+
109
+ child.on('error', (err) => {
110
+ log('dolt sql-server spawn error: %o', err);
111
+ if (!resolved_already) {
112
+ resolved_already = true;
113
+ resolve(null);
114
+ }
115
+ });
116
+
117
+ child.on('exit', (code) => {
118
+ log('dolt sql-server exited with code %d', code);
119
+ pool = null;
120
+ serverProcess = null;
121
+ currentDataDir = null;
122
+ if (!resolved_already) {
123
+ resolved_already = true;
124
+ resolve(null);
125
+ }
126
+ });
127
+
128
+ // Poll for connectivity instead of parsing stdout
129
+ let attempts = 0;
130
+ const maxAttempts = 50; // 50 * 100ms = 5s max
131
+ const pollInterval = setInterval(async () => {
132
+ attempts++;
133
+ if (resolved_already) {
134
+ clearInterval(pollInterval);
135
+ return;
136
+ }
137
+ if (attempts > maxAttempts) {
138
+ clearInterval(pollInterval);
139
+ if (!resolved_already) {
140
+ resolved_already = true;
141
+ log('dolt sql-server start timeout after %d attempts', attempts);
142
+ resolve(null);
143
+ }
144
+ return;
145
+ }
146
+ try {
147
+ const p = await createPool(database);
148
+ clearInterval(pollInterval);
149
+ if (!resolved_already) {
150
+ resolved_already = true;
151
+ pool = p;
152
+ log('dolt sql-server ready on port %d (after %d polls)', DOLT_PORT, attempts);
153
+ ensureIndexes(p).catch((err) => log('ensureIndexes error: %o', err));
154
+ resolve(pool);
155
+ } else {
156
+ // Pool created after we already resolved (race); close it
157
+ try { await p.end(); } catch { /* ignore */ }
158
+ }
159
+ } catch (poolErr) {
160
+ // Not ready yet, retry. Clean up any partial pool.
161
+ if (poolErr && typeof /** @type {any} */ (poolErr).pool?.end === 'function') {
162
+ try { await /** @type {any} */ (poolErr).pool.end(); } catch { /* ignore */ }
163
+ }
164
+ }
165
+ }, 100);
166
+ });
167
+ }
168
+
169
+ /**
170
+ * Create a mysql2 connection pool.
171
+ *
172
+ * @param {string} database
173
+ * @returns {Promise<import('mysql2/promise').Pool>}
174
+ */
175
+ async function createPool(database) {
176
+ const p = mysql.createPool({
177
+ host: '127.0.0.1',
178
+ port: DOLT_PORT,
179
+ user: 'root',
180
+ database,
181
+ waitForConnections: true,
182
+ connectionLimit: 10,
183
+ queueLimit: 50,
184
+ enableKeepAlive: true,
185
+ keepAliveInitialDelay: 10000,
186
+ // Dolt dates come as strings; avoid extra parsing
187
+ dateStrings: true
188
+ });
189
+
190
+ // Verify connectivity
191
+ const conn = await p.getConnection();
192
+ conn.release();
193
+ return p;
194
+ }
195
+
196
+ /**
197
+ * Ensure required indexes exist. Idempotent — skips if already present.
198
+ *
199
+ * @param {import('mysql2/promise').Pool} p
200
+ */
201
+ async function ensureIndexes(p) {
202
+ const [rows] = await p.query(
203
+ `SELECT 1 FROM information_schema.statistics
204
+ WHERE table_schema = DATABASE() AND table_name = 'issues'
205
+ AND index_name = 'idx_ft_issues_title_desc' LIMIT 1`
206
+ );
207
+ if (/** @type {any[]} */ (rows).length > 0) {
208
+ log('FULLTEXT index already exists');
209
+ return;
210
+ }
211
+ log('creating FULLTEXT index on issues(title, description)');
212
+ await p.query(`ALTER TABLE issues ADD FULLTEXT idx_ft_issues_title_desc (title, description)`);
213
+ await p.query(`CALL dolt_commit('-Am', 'add FULLTEXT index on issues(title, description)')`);
214
+ log('FULLTEXT index created');
215
+ }
216
+
217
+ /**
218
+ * Get the current pool, or null if not started.
219
+ *
220
+ * @returns {import('mysql2/promise').Pool | null}
221
+ */
222
+ export function getPool() {
223
+ return pool;
224
+ }
225
+
226
+ /**
227
+ * Stop the Dolt SQL server and close the pool.
228
+ */
229
+ export async function stopDoltServer() {
230
+ if (pool) {
231
+ try { await pool.end(); } catch { /* ignore */ }
232
+ pool = null;
233
+ }
234
+ if (serverProcess && !serverProcess.killed) {
235
+ serverProcess.kill('SIGTERM');
236
+ serverProcess = null;
237
+ }
238
+ currentDataDir = null;
239
+ }
240
+
241
+ /**
242
+ * Rebind to a new workspace directory (e.g., after workspace switch).
243
+ *
244
+ * @param {string} cwd
245
+ * @returns {Promise<import('mysql2/promise').Pool | null>}
246
+ */
247
+ export async function rebindDoltServer(cwd) {
248
+ const resolved = resolveDoltDir(cwd);
249
+ if (!resolved) {
250
+ await stopDoltServer();
251
+ return null;
252
+ }
253
+ if (resolved.dataDir === currentDataDir && pool && serverProcess && !serverProcess.killed) {
254
+ return pool;
255
+ }
256
+ return startDoltServer(cwd);
257
+ }