beads-ui 0.3.1 → 0.4.1

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 (56) hide show
  1. package/CHANGES.md +24 -0
  2. package/README.md +15 -6
  3. package/app/main.bundle.js +617 -0
  4. package/app/main.bundle.js.map +7 -0
  5. package/bin/bdui.js +2 -1
  6. package/package.json +27 -14
  7. package/server/app.js +38 -36
  8. package/server/bd.js +2 -2
  9. package/server/cli/commands.js +8 -8
  10. package/server/cli/daemon.js +13 -5
  11. package/server/cli/index.js +17 -31
  12. package/server/cli/usage.js +3 -2
  13. package/server/db.js +6 -6
  14. package/server/index.js +10 -4
  15. package/server/logging.js +23 -0
  16. package/server/watcher.js +7 -5
  17. package/server/ws.js +23 -11
  18. package/app/data/list-selectors.js +0 -103
  19. package/app/data/providers.js +0 -78
  20. package/app/data/sort.js +0 -47
  21. package/app/data/subscription-issue-store.js +0 -161
  22. package/app/data/subscription-issue-stores.js +0 -128
  23. package/app/data/subscriptions-store.js +0 -227
  24. package/app/main.js +0 -706
  25. package/app/protocol.md +0 -66
  26. package/app/router.js +0 -117
  27. package/app/state.js +0 -105
  28. package/app/utils/issue-id-renderer.js +0 -72
  29. package/app/utils/issue-id.js +0 -11
  30. package/app/utils/issue-type.js +0 -29
  31. package/app/utils/issue-url.js +0 -10
  32. package/app/utils/markdown.js +0 -16
  33. package/app/utils/priority-badge.js +0 -48
  34. package/app/utils/priority.js +0 -1
  35. package/app/utils/status-badge.js +0 -33
  36. package/app/utils/status.js +0 -25
  37. package/app/utils/toast.js +0 -35
  38. package/app/utils/type-badge.js +0 -34
  39. package/app/views/board.js +0 -537
  40. package/app/views/detail.js +0 -1252
  41. package/app/views/epics.js +0 -281
  42. package/app/views/issue-dialog.js +0 -164
  43. package/app/views/issue-row.js +0 -191
  44. package/app/views/list.js +0 -468
  45. package/app/views/nav.js +0 -68
  46. package/app/views/new-issue-dialog.js +0 -348
  47. package/app/ws.js +0 -282
  48. package/docs/adr/001-push-only-lists.md +0 -134
  49. package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +0 -200
  50. package/docs/architecture.md +0 -194
  51. package/docs/data-exchange-subscription-plan.md +0 -198
  52. package/docs/db-watching.md +0 -30
  53. package/docs/migration-v2.md +0 -54
  54. package/docs/protocol/issues-push-v2.md +0 -179
  55. package/docs/subscription-issue-store.md +0 -112
  56. package/server/protocol.js +0 -3
package/bin/bdui.js CHANGED
@@ -4,6 +4,7 @@
4
4
  * Delegates to `server/cli/index.js` and sets the process exit code.
5
5
  */
6
6
  import { main } from '../server/cli/index.js';
7
+ import { debug } from '../server/logging.js';
7
8
 
8
9
  const argv = process.argv.slice(2);
9
10
 
@@ -13,6 +14,6 @@ try {
13
14
  process.exitCode = code;
14
15
  }
15
16
  } catch (err) {
16
- console.error(String(/** @type {any} */ (err)?.message || err));
17
+ debug('cli')('fatal %o', err);
17
18
  process.exitCode = 1;
18
19
  }
package/package.json CHANGED
@@ -1,7 +1,13 @@
1
1
  {
2
2
  "name": "beads-ui",
3
- "version": "0.3.1",
4
- "description": "Local‑first UI for Beads — a fast issue tracker for your coding agent.",
3
+ "version": "0.4.1",
4
+ "description": "Local UI for Beads — Collaborate on issues with your coding agent.",
5
+ "keywords": [
6
+ "agent",
7
+ "issue-tracker",
8
+ "local-first",
9
+ "ai-tools"
10
+ ],
5
11
  "homepage": "https://github.com/mantoni/beads-ui",
6
12
  "type": "module",
7
13
  "bin": {
@@ -12,7 +18,8 @@
12
18
  },
13
19
  "scripts": {
14
20
  "all": "npm run lint && npm run typecheck && npm test && npm run format:check",
15
- "start": "node server/index.js",
21
+ "start": "node server/index.js --debug",
22
+ "build": "node scripts/build-frontend.js",
16
23
  "test": "vitest run",
17
24
  "test:watch": "vitest",
18
25
  "typecheck": "tsc -p tsconfig.json --noEmit",
@@ -21,15 +28,26 @@
21
28
  "format:check": "prettier --check .",
22
29
  "preversion": "npm run all",
23
30
  "version": "changes --commits --footer",
24
- "postversion": "git push --follow-tags && npm publish"
31
+ "postversion": "git push --follow-tags && npm publish",
32
+ "prepack": "NODE_ENV=production npm run build"
33
+ },
34
+ "dependencies": {
35
+ "debug": "^4.4.3",
36
+ "dompurify": "^3.3.0",
37
+ "express": "^5.1.0",
38
+ "lit-html": "^3.3.1",
39
+ "marked": "^16.4.1",
40
+ "ws": "^8.18.3"
25
41
  },
26
42
  "devDependencies": {
27
43
  "@eslint/js": "^9.38.0",
28
44
  "@studio/changes": "^3.0.0",
29
45
  "@trivago/prettier-plugin-sort-imports": "^5.2.2",
46
+ "@types/debug": "^4.1.12",
30
47
  "@types/express": "^5.0.3",
31
48
  "@types/node": "^22.7.4",
32
49
  "@types/ws": "^8.18.1",
50
+ "esbuild": "^0.25.11",
33
51
  "eslint": "^9.11.0",
34
52
  "eslint-plugin-import": "^2.29.1",
35
53
  "eslint-plugin-jsdoc": "^61.1.9",
@@ -40,18 +58,13 @@
40
58
  "typescript": "^5.6.3",
41
59
  "vitest": "^2.1.3"
42
60
  },
43
- "dependencies": {
44
- "dompurify": "^3.3.0",
45
- "esbuild": "^0.25.11",
46
- "express": "^5.1.0",
47
- "lit-html": "^3.3.1",
48
- "marked": "^16.4.1",
49
- "ws": "^8.18.3"
50
- },
51
61
  "files": [
52
- "app",
62
+ "app/index.html",
63
+ "app/styles.css",
64
+ "app/main.bundle.js",
65
+ "app/main.bundle.js.map",
66
+ "app/protocol.js",
53
67
  "bin",
54
- "docs",
55
68
  "server",
56
69
  "CHANGES.md",
57
70
  "LICENSE",
package/server/app.js CHANGED
@@ -26,44 +26,46 @@ export function createApp(config) {
26
26
  res.status(200).send({ ok: true });
27
27
  });
28
28
 
29
- /**
30
- * On-demand bundle for the browser using esbuild.
31
- * Note: esbuild is loaded lazily so tests don't require it to be installed.
32
- *
33
- * @param {Request} _req
34
- * @param {Response} res
35
- */
36
- app.get('/main.bundle.js', async (_req, res) => {
37
- try {
38
- const esbuild = await import('esbuild');
39
- const entry = path.join(config.app_dir, 'main.js');
40
- const result = await esbuild.build({
41
- entryPoints: [entry],
42
- bundle: true,
43
- format: 'esm',
44
- platform: 'browser',
45
- target: 'es2020',
46
- sourcemap: config.env === 'production' ? false : 'inline',
47
- minify: config.env === 'production',
48
- write: false
49
- });
50
- const out = result.outputFiles && result.outputFiles[0];
51
- if (!out) {
52
- res.status(500).type('text/plain').send('Bundle failed: no output');
53
- return;
54
- }
55
- res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
56
- if (config.env !== 'production') {
29
+ // In development, support on-demand bundling for a smooth DX.
30
+ // In production, we expect `app/main.bundle.js` to be pre-built and served statically.
31
+ if (config.env !== 'production') {
32
+ /**
33
+ * On-demand bundle for the browser using esbuild.
34
+ * Note: esbuild is loaded lazily so tests don't require it to be installed.
35
+ *
36
+ * @param {Request} _req
37
+ * @param {Response} res
38
+ */
39
+ app.get('/main.bundle.js', async (_req, res) => {
40
+ try {
41
+ const esbuild = await import('esbuild');
42
+ const entry = path.join(config.app_dir, 'main.js');
43
+ const result = await esbuild.build({
44
+ entryPoints: [entry],
45
+ bundle: true,
46
+ format: 'esm',
47
+ platform: 'browser',
48
+ target: 'es2020',
49
+ sourcemap: 'inline',
50
+ minify: false,
51
+ write: false
52
+ });
53
+ const out = result.outputFiles && result.outputFiles[0];
54
+ if (!out) {
55
+ res.status(500).type('text/plain').send('Bundle failed: no output');
56
+ return;
57
+ }
58
+ res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
57
59
  res.setHeader('Cache-Control', 'no-store');
60
+ res.send(out.text);
61
+ } catch (err) {
62
+ res
63
+ .status(500)
64
+ .type('text/plain')
65
+ .send('Bundle error: ' + (err && /** @type {any} */ (err).message));
58
66
  }
59
- res.send(out.text);
60
- } catch (err) {
61
- res
62
- .status(500)
63
- .type('text/plain')
64
- .send('Bundle error: ' + (err && /** @type {any} */ (err).message));
65
- }
66
- });
67
+ });
68
+ }
67
69
 
68
70
  // Static assets from /app
69
71
  app.use(express.static(config.app_dir));
package/server/bd.js CHANGED
@@ -32,10 +32,10 @@ export function runBd(args, options = {}) {
32
32
 
33
33
  // Ensure a consistent DB by injecting --db if missing, following beads precedence.
34
34
  /** @type {string[]} */
35
- const finalArgs = withDbArg(args, spawn_opts.cwd, spawn_opts.env);
35
+ const final_args = withDbArg(args, spawn_opts.cwd, spawn_opts.env);
36
36
 
37
37
  return new Promise((resolve) => {
38
- const child = spawn(bin, finalArgs, spawn_opts);
38
+ const child = spawn(bin, final_args, spawn_opts);
39
39
 
40
40
  /** @type {string[]} */
41
41
  const out_chunks = [];
@@ -17,14 +17,14 @@ import { openUrl, waitForServer } from './open.js';
17
17
  * @returns {Promise<number>} Exit code (0 on success)
18
18
  */
19
19
  /**
20
- * @param {{ no_open?: boolean }} [options]
20
+ * @param {{ open?: boolean, is_debug?: boolean }} [options]
21
21
  */
22
22
  export async function handleStart(options) {
23
- // Default behavior: do not open a browser unless explicitly requested.
24
- const no_open = options?.no_open !== false;
23
+ // Default: do not open a browser unless explicitly requested via `open: true`.
24
+ const should_open = options?.open === true;
25
25
  const existing_pid = readPidFile();
26
26
  if (existing_pid && isProcessRunning(existing_pid)) {
27
- printServerUrl();
27
+ console.warn('Server is already running.');
28
28
  return 0;
29
29
  }
30
30
  if (existing_pid && !isProcessRunning(existing_pid)) {
@@ -32,11 +32,11 @@ export async function handleStart(options) {
32
32
  removePidFile();
33
33
  }
34
34
 
35
- const started = startDaemon();
35
+ const started = startDaemon({ is_debug: options?.is_debug });
36
36
  if (started && started.pid > 0) {
37
37
  printServerUrl();
38
38
  // Auto-open the browser once for a fresh daemon start
39
- if (!no_open) {
39
+ if (should_open) {
40
40
  const { url } = getConfig();
41
41
  // Wait briefly for the server to accept connections (single retry window)
42
42
  await waitForServer(url, 600);
@@ -86,9 +86,9 @@ export async function handleStop() {
86
86
  /**
87
87
  * Handle `restart` command: stop (ignore not-running) then start.
88
88
  * Accepts the same options as `handleStart` and passes them through,
89
- * so restart only opens a browser when `no_open` is explicitly false.
89
+ * so restart only opens a browser when `open` is explicitly true.
90
90
  *
91
- * @param {{ no_open?: boolean }} [options]
91
+ * @param {{ open?: boolean }} [options]
92
92
  * @returns {Promise<number>}
93
93
  */
94
94
  export async function handleRestart(options) {
@@ -140,9 +140,10 @@ export function getServerEntryPath() {
140
140
  * Spawn the server as a detached daemon, redirecting stdio to the log file.
141
141
  * Writes the PID file upon success.
142
142
  *
143
+ * @param {{ is_debug?: boolean }} [options]
143
144
  * @returns {{ pid: number } | null} Returns child PID on success; null on failure.
144
145
  */
145
- export function startDaemon() {
146
+ export function startDaemon(options = {}) {
146
147
  const server_entry = getServerEntryPath();
147
148
  const log_file = getLogFilePath();
148
149
 
@@ -151,6 +152,9 @@ export function startDaemon() {
151
152
  let log_fd;
152
153
  try {
153
154
  log_fd = fs.openSync(log_file, 'a');
155
+ if (options.is_debug) {
156
+ console.debug('log file ', log_file);
157
+ }
154
158
  } catch {
155
159
  // If log cannot be opened, fallback to ignoring stdio
156
160
  log_fd = -1;
@@ -170,11 +174,15 @@ export function startDaemon() {
170
174
  child.unref();
171
175
  const child_pid = typeof child.pid === 'number' ? child.pid : -1;
172
176
  if (child_pid > 0) {
177
+ if (options.is_debug) {
178
+ console.debug('starting ', child_pid);
179
+ }
173
180
  writePidFile(child_pid);
174
181
  return { pid: child_pid };
175
182
  }
176
183
  return null;
177
184
  } catch (err) {
185
+ console.error('start error', err);
178
186
  // Log startup error to log file for traceability
179
187
  try {
180
188
  const message =
@@ -242,12 +250,12 @@ function sleep(ms) {
242
250
  * Print the server URL derived from current config.
243
251
  */
244
252
  export function printServerUrl() {
245
- const { url } = getConfig();
246
- console.log(url);
247
-
248
253
  // Resolve from the caller's working directory by default
249
254
  const resolved_db = resolveDbPath();
250
255
  console.log(
251
- `db: ${resolved_db.path} (${resolved_db.source}${resolved_db.exists ? '' : ', missing'})`
256
+ `beads db ${resolved_db.path} (${resolved_db.source}${resolved_db.exists ? '' : ', missing'})`
252
257
  );
258
+
259
+ const { url, env } = getConfig();
260
+ console.log(`beads ui listening on ${url} (${env})`);
253
261
  }
@@ -1,3 +1,4 @@
1
+ import { enableAllDebug } from '../logging.js';
1
2
  import { handleRestart, handleStart, handleStop } from './commands.js';
2
3
  import { printUsage } from './usage.js';
3
4
 
@@ -18,12 +19,12 @@ export function parseArgs(args) {
18
19
  flags.push('help');
19
20
  continue;
20
21
  }
21
- if (token === '--open') {
22
- flags.push('open');
22
+ if (token === '--debug' || token === '-d') {
23
+ flags.push('debug');
23
24
  continue;
24
25
  }
25
- if (token === '--no-open') {
26
- flags.push('no-open');
26
+ if (token === '--open') {
27
+ flags.push('open');
27
28
  continue;
28
29
  }
29
30
  if (
@@ -49,6 +50,11 @@ export function parseArgs(args) {
49
50
  export async function main(args) {
50
51
  const { command, flags } = parseArgs(args);
51
52
 
53
+ const is_debug = flags.includes('debug');
54
+ if (is_debug) {
55
+ enableAllDebug();
56
+ }
57
+
52
58
  if (flags.includes('help')) {
53
59
  printUsage(process.stdout);
54
60
  return 0;
@@ -60,42 +66,22 @@ export async function main(args) {
60
66
 
61
67
  if (command === 'start') {
62
68
  /**
63
- * Default behavior: do NOT open a browser.
64
- * `--open` explicitly opens, overriding env/config; `--no-open` forces closed.
69
+ * Default behavior: do NOT open a browser. `--open` explicitly opens.
65
70
  */
66
71
  const options = {
67
- no_open: true
72
+ open: flags.includes('open'),
73
+ is_debug: is_debug || Boolean(process.env.DEBUG)
68
74
  };
69
-
70
- const has_open = flags.includes('open');
71
- const has_no_open = flags.includes('no-open');
72
- const env_no_open = String(process.env.BDUI_NO_OPEN || '') === '1';
73
-
74
- if (has_open) {
75
- options.no_open = false;
76
- } else if (has_no_open) {
77
- options.no_open = true;
78
- } else if (env_no_open) {
79
- options.no_open = true;
80
- }
81
75
  return await handleStart(options);
82
76
  }
83
77
  if (command === 'stop') {
84
78
  return await handleStop();
85
79
  }
86
80
  if (command === 'restart') {
87
- const options = { no_open: true };
88
- const has_open = flags.includes('open');
89
- const has_no_open = flags.includes('no-open');
90
- const env_no_open = String(process.env.BDUI_NO_OPEN || '') === '1';
91
-
92
- if (has_open) {
93
- options.no_open = false;
94
- } else if (has_no_open) {
95
- options.no_open = true;
96
- } else if (env_no_open) {
97
- options.no_open = true;
98
- }
81
+ const options = {
82
+ open: flags.includes('open'),
83
+ is_debug: is_debug || Boolean(process.env.DEBUG)
84
+ };
99
85
  return await handleRestart(options);
100
86
  }
101
87
 
@@ -13,8 +13,9 @@ export function printUsage(out_stream) {
13
13
  ' restart Restart the UI server',
14
14
  '',
15
15
  'Options:',
16
- ' -h, --help Show this help message',
17
- ' --open Open the browser after start/restart',
16
+ ' -h, --help Show this help message',
17
+ ' -d, --debug Enable debug logging',
18
+ ' --open Open the browser after start/restart',
18
19
  ''
19
20
  ];
20
21
  for (const line of lines) {
package/server/db.js CHANGED
@@ -38,11 +38,11 @@ export function resolveDbPath(options = {}) {
38
38
  }
39
39
 
40
40
  // 4) ~/.beads/default.db
41
- const homeDefault = path.join(os.homedir(), '.beads', 'default.db');
41
+ const home_default = path.join(os.homedir(), '.beads', 'default.db');
42
42
  return {
43
- path: homeDefault,
43
+ path: home_default,
44
44
  source: 'home-default',
45
- exists: fileExists(homeDefault)
45
+ exists: fileExists(home_default)
46
46
  };
47
47
  }
48
48
 
@@ -57,15 +57,15 @@ export function findNearestBeadsDb(start) {
57
57
  let dir = path.resolve(start);
58
58
  // Cap iterations to avoid infinite loop in degenerate cases
59
59
  for (let i = 0; i < 100; i++) {
60
- const beadsDir = path.join(dir, '.beads');
60
+ const beads_dir = path.join(dir, '.beads');
61
61
  try {
62
- const entries = fs.readdirSync(beadsDir, { withFileTypes: true });
62
+ const entries = fs.readdirSync(beads_dir, { withFileTypes: true });
63
63
  const dbs = entries
64
64
  .filter((e) => e.isFile() && e.name.endsWith('.db'))
65
65
  .map((e) => e.name)
66
66
  .sort();
67
67
  if (dbs.length > 0) {
68
- return path.join(beadsDir, dbs[0]);
68
+ return path.join(beads_dir, dbs[0]);
69
69
  }
70
70
  } catch {
71
71
  // ignore and walk up
package/server/index.js CHANGED
@@ -1,12 +1,19 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { createApp } from './app.js';
3
+ import { printServerUrl } from './cli/daemon.js';
3
4
  import { getConfig } from './config.js';
5
+ import { debug, enableAllDebug } from './logging.js';
4
6
  import { watchDb } from './watcher.js';
5
7
  import { attachWsServer } from './ws.js';
6
8
 
9
+ if (process.argv.includes('--debug') || process.argv.includes('-d')) {
10
+ enableAllDebug();
11
+ }
12
+
7
13
  const config = getConfig();
8
14
  const app = createApp(config);
9
15
  const server = createServer(app);
16
+ const log = debug('server');
10
17
  const { scheduleListRefresh } = attachWsServer(server, {
11
18
  path: '/ws',
12
19
  heartbeat_ms: 30000,
@@ -17,17 +24,16 @@ const { scheduleListRefresh } = attachWsServer(server, {
17
24
  // Watch the active beads DB and schedule subscription refresh for active lists
18
25
  watchDb(config.root_dir, () => {
19
26
  // Schedule subscription list refresh run for active subscriptions
27
+ log('db change detected → schedule refresh');
20
28
  scheduleListRefresh();
21
29
  // v2: all updates flow via subscription push envelopes only
22
30
  });
23
31
 
24
32
  server.listen(config.port, config.host, () => {
25
- console.log(
26
- `beads-ui server listening on http://${config.host}:${config.port} (${config.env})`
27
- );
33
+ printServerUrl();
28
34
  });
29
35
 
30
36
  server.on('error', (err) => {
31
- console.error('Server error:', err);
37
+ log('server error %o', err);
32
38
  process.exitCode = 1;
33
39
  });
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Debug logger helper for Node server/CLI.
3
+ */
4
+ import createDebug from 'debug';
5
+
6
+ /**
7
+ * Create a namespaced logger for Node runtime.
8
+ *
9
+ * @param {string} ns - Module namespace suffix (e.g., 'ws', 'watcher').
10
+ */
11
+ export function debug(ns) {
12
+ return createDebug(`beads-ui:${ns}`);
13
+ }
14
+
15
+ /**
16
+ * Enable all `beads-ui:*` debug logs at runtime for Node/CLI.
17
+ * Safe to call multiple times.
18
+ */
19
+ export function enableAllDebug() {
20
+ // `debug` exposes a global enable/disable API.
21
+ // Enabling after loggers are created updates their `.enabled` state.
22
+ createDebug.enable(process.env.DEBUG || 'beads-ui:*');
23
+ }
package/server/watcher.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { resolveDbPath } from './db.js';
4
+ import { debug } from './logging.js';
4
5
 
5
6
  /**
6
7
  * Watch the resolved beads SQLite DB file and invoke a callback after a debounce window.
@@ -13,6 +14,7 @@ import { resolveDbPath } from './db.js';
13
14
  */
14
15
  export function watchDb(root_dir, onChange, options = {}) {
15
16
  const debounce_ms = options.debounce_ms ?? 250;
17
+ const log = debug('watcher');
16
18
 
17
19
  /** @type {ReturnType<typeof setTimeout> | undefined} */
18
20
  let timer;
@@ -44,10 +46,9 @@ export function watchDb(root_dir, onChange, options = {}) {
44
46
  current_dir = path.dirname(current_path);
45
47
  current_file = path.basename(current_path);
46
48
  if (!resolved.exists) {
47
- console.warn(
48
- 'watchDb: resolved DB does not exist yet:',
49
- current_path,
50
- '\nHint: set --db, export BEADS_DB, or run `bd init` in your workspace.'
49
+ log(
50
+ 'resolved DB missing: %s Hint: set --db, export BEADS_DB, or run `bd init` in your workspace.',
51
+ current_path
51
52
  );
52
53
  }
53
54
 
@@ -61,12 +62,13 @@ export function watchDb(root_dir, onChange, options = {}) {
61
62
  return;
62
63
  }
63
64
  if (event_type === 'change' || event_type === 'rename') {
65
+ log('fs %s %s', event_type, filename || '');
64
66
  schedule();
65
67
  }
66
68
  }
67
69
  );
68
70
  } catch (err) {
69
- console.warn('watchDb: unable to watch directory', current_dir, err);
71
+ log('unable to watch directory %s %o', current_dir, err);
70
72
  }
71
73
  };
72
74
 
package/server/ws.js CHANGED
@@ -4,12 +4,15 @@
4
4
  * @import { MessageType } from '../app/protocol.js'
5
5
  */
6
6
  import { WebSocketServer } from 'ws';
7
+ import { isRequest, makeError, makeOk } from '../app/protocol.js';
7
8
  import { runBd, runBdJson } from './bd.js';
8
9
  import { fetchListForSubscription } from './list-adapters.js';
9
- import { isRequest, makeError, makeOk } from './protocol.js';
10
+ import { debug } from './logging.js';
10
11
  import { keyOf, registry } from './subscriptions.js';
11
12
  import { validateSubscribeListPayload } from './validators.js';
12
13
 
14
+ const log = debug('ws');
15
+
13
16
  /**
14
17
  * Debounced refresh scheduling for active list subscriptions.
15
18
  * A trailing window coalesces rapid change bursts into a single refresh run.
@@ -77,6 +80,7 @@ function triggerMutationRefreshOnce(timeout_ms = 500) {
77
80
 
78
81
  // After resolution, run a single refresh across active subs and clear gate
79
82
  void p.then(async () => {
83
+ log('mutation window resolved → refresh active subs');
80
84
  try {
81
85
  await refreshAllActiveListSubscriptions();
82
86
  } catch {
@@ -321,17 +325,17 @@ async function refreshAndPublish(spec) {
321
325
  return;
322
326
  }
323
327
  const items = applyClosedIssuesFilter(spec, res.items);
324
- const prevSize = registry.get(key)?.itemsById.size || 0;
328
+ const prev_size = registry.get(key)?.itemsById.size || 0;
325
329
  const delta = registry.applyItems(key, items);
326
330
  const entry = registry.get(key);
327
331
  if (!entry || entry.subscribers.size === 0) {
328
332
  return;
329
333
  }
330
334
  /** @type {Map<string, any>} */
331
- const byId = new Map();
335
+ const by_id = new Map();
332
336
  for (const it of items) {
333
337
  if (it && typeof it.id === 'string') {
334
- byId.set(it.id, it);
338
+ by_id.set(it.id, it);
335
339
  }
336
340
  }
337
341
  for (const ws of entry.subscribers) {
@@ -339,20 +343,20 @@ async function refreshAndPublish(spec) {
339
343
  const s = ensureSubs(ws);
340
344
  const subs = s.list_subs || new Map();
341
345
  /** @type {string[]} */
342
- const clientIds = [];
346
+ const client_ids = [];
343
347
  for (const [cid, v] of subs.entries()) {
344
- if (v.key === key) clientIds.push(cid);
348
+ if (v.key === key) client_ids.push(cid);
345
349
  }
346
- if (clientIds.length === 0) continue;
347
- if (prevSize === 0) {
348
- for (const cid of clientIds) {
350
+ if (client_ids.length === 0) continue;
351
+ if (prev_size === 0) {
352
+ for (const cid of client_ids) {
349
353
  emitSubscriptionSnapshot(ws, cid, key, items);
350
354
  }
351
355
  continue;
352
356
  }
353
- for (const cid of clientIds) {
357
+ for (const cid of client_ids) {
354
358
  for (const id of [...delta.added, ...delta.updated]) {
355
- const issue = byId.get(id);
359
+ const issue = by_id.get(id);
356
360
  if (issue) {
357
361
  emitSubscriptionUpsert(ws, cid, key, issue);
358
362
  }
@@ -413,6 +417,7 @@ export function attachWsServer(http_server, options = {}) {
413
417
 
414
418
  // Heartbeat: track if client answered the last ping
415
419
  wss.on('connection', (ws) => {
420
+ log('client connected');
416
421
  // @ts-expect-error add marker property
417
422
  ws.isAlive = true;
418
423
 
@@ -507,6 +512,7 @@ export async function handleMessage(ws, data) {
507
512
  }
508
513
 
509
514
  if (!isRequest(json)) {
515
+ log('invalid request');
510
516
  const reply = {
511
517
  id: 'unknown',
512
518
  ok: false,
@@ -527,6 +533,7 @@ export async function handleMessage(ws, data) {
527
533
 
528
534
  // subscribe-list: payload { id: string, type: string, params?: object }
529
535
  if (req.type === 'subscribe-list') {
536
+ log('subscribe-list %s', /** @type {any} */ (req.payload)?.id || '');
530
537
  const validation = validateSubscribeListPayload(
531
538
  /** @type {any} */ (req.payload || {})
532
539
  );
@@ -562,6 +569,7 @@ export async function handleMessage(ws, data) {
562
569
 
563
570
  // unsubscribe-list: payload { id: string }
564
571
  if (req.type === 'unsubscribe-list') {
572
+ log('unsubscribe-list %s', /** @type {any} */ (req.payload)?.id || '');
565
573
  const { id: client_id } = /** @type {any} */ (req.payload || {});
566
574
  if (typeof client_id !== 'string' || client_id.length === 0) {
567
575
  ws.send(
@@ -646,6 +654,7 @@ export async function handleMessage(ws, data) {
646
654
 
647
655
  // update-status
648
656
  if (req.type === 'update-status') {
657
+ log('update-status');
649
658
  const { id, status } = /** @type {any} */ (req.payload);
650
659
  const allowed = new Set(['open', 'in_progress', 'closed']);
651
660
  if (
@@ -691,6 +700,7 @@ export async function handleMessage(ws, data) {
691
700
 
692
701
  // update-priority
693
702
  if (req.type === 'update-priority') {
703
+ log('update-priority');
694
704
  const { id, priority } = /** @type {any} */ (req.payload);
695
705
  if (
696
706
  typeof id !== 'string' ||
@@ -735,6 +745,7 @@ export async function handleMessage(ws, data) {
735
745
 
736
746
  // edit-text
737
747
  if (req.type === 'edit-text') {
748
+ log('edit-text');
738
749
  const { id, field, value } = /** @type {any} */ (req.payload);
739
750
  if (
740
751
  typeof id !== 'string' ||
@@ -798,6 +809,7 @@ export async function handleMessage(ws, data) {
798
809
 
799
810
  // create-issue
800
811
  if (req.type === 'create-issue') {
812
+ log('create-issue');
801
813
  const { title, type, priority, description } = /** @type {any} */ (
802
814
  req.payload || {}
803
815
  );