@seungyeop-lee/beads-ui 0.13.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.
@@ -0,0 +1,305 @@
1
+ /**
2
+ * @import { WebSocket } from 'ws'
3
+ */
4
+ /**
5
+ * Server-side subscription registry for list-like data.
6
+ *
7
+ * Maintains per-subscription entries keyed by a stable string derived from
8
+ * `{ type, params }`. Each entry stores:
9
+ * - `itemsById`: Map<string, { updated_at: number, closed_at: number|null, _board_column?: string }>
10
+ * - `subscribers`: Set<WebSocket>
11
+ * - `lock`: Promise chain to serialize refresh/update operations per key
12
+ *
13
+ * No TTL eviction; entries are swept when sockets disconnect (and only when
14
+ * that leaves the subscriber set empty).
15
+ */
16
+
17
+ /**
18
+ * @typedef {{
19
+ * type: string,
20
+ * params?: Record<string, string | number | boolean>
21
+ * }} SubscriptionSpec
22
+ */
23
+
24
+ /**
25
+ * @typedef {{ updated_at: number, closed_at: number | null, _board_column?: string }} ItemMeta
26
+ */
27
+
28
+ /**
29
+ * @typedef {{
30
+ * itemsById: Map<string, ItemMeta>,
31
+ * subscribers: Set<WebSocket>,
32
+ * lock: Promise<void>
33
+ * }} Entry
34
+ */
35
+
36
+ /**
37
+ * Create a new, empty entry object.
38
+ *
39
+ * @returns {Entry}
40
+ */
41
+ function createEntry() {
42
+ return {
43
+ itemsById: new Map(),
44
+ subscribers: new Set(),
45
+ lock: Promise.resolve()
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Generate a stable subscription key string from a spec. Sorts params keys.
51
+ *
52
+ * @param {SubscriptionSpec} spec
53
+ * @returns {string}
54
+ */
55
+ export function keyOf(spec) {
56
+ const type = String(spec.type || '').trim();
57
+ /** @type {Record<string, string>} */
58
+ const flat = {};
59
+ if (spec.params && typeof spec.params === 'object') {
60
+ const keys = Object.keys(spec.params).sort();
61
+ for (const k of keys) {
62
+ const v = spec.params[k];
63
+ flat[k] = String(v);
64
+ }
65
+ }
66
+ const enc = new URLSearchParams(flat).toString();
67
+ return enc.length > 0 ? `${type}?${enc}` : type;
68
+ }
69
+
70
+ /**
71
+ * Compute a delta between previous and next item maps.
72
+ *
73
+ * @param {Map<string, ItemMeta>} prev
74
+ * @param {Map<string, ItemMeta>} next
75
+ * @returns {{ added: string[], updated: string[], removed: string[] }}
76
+ */
77
+ export function computeDelta(prev, next) {
78
+ /** @type {string[]} */
79
+ const added = [];
80
+ /** @type {string[]} */
81
+ const updated = [];
82
+ /** @type {string[]} */
83
+ const removed = [];
84
+
85
+ for (const [id, meta] of next) {
86
+ const p = prev.get(id);
87
+ if (!p) {
88
+ added.push(id);
89
+ continue;
90
+ }
91
+ if (
92
+ p.updated_at !== meta.updated_at ||
93
+ p.closed_at !== meta.closed_at ||
94
+ p._board_column !== meta._board_column
95
+ ) {
96
+ updated.push(id);
97
+ }
98
+ }
99
+ for (const id of prev.keys()) {
100
+ if (!next.has(id)) {
101
+ removed.push(id);
102
+ }
103
+ }
104
+ return { added, updated, removed };
105
+ }
106
+
107
+ /**
108
+ * Normalize array of issue-like objects into an itemsById map.
109
+ *
110
+ * @param {Array<{ id: string, updated_at: number, closed_at?: number|null, _board_column?: string }>} items
111
+ * @returns {Map<string, ItemMeta>}
112
+ */
113
+ export function toItemsMap(items) {
114
+ /** @type {Map<string, ItemMeta>} */
115
+ const map = new Map();
116
+ for (const it of items) {
117
+ if (!it || typeof it.id !== 'string') {
118
+ continue;
119
+ }
120
+ const updated_at = Number(it.updated_at) || 0;
121
+ /** @type {number|null} */
122
+ let closed_at = null;
123
+ if (it.closed_at === null || it.closed_at === undefined) {
124
+ closed_at = null;
125
+ } else {
126
+ const n = Number(it.closed_at);
127
+ closed_at = Number.isFinite(n) ? n : null;
128
+ }
129
+ const _board_column =
130
+ typeof it._board_column === 'string' ? it._board_column : undefined;
131
+ map.set(it.id, { updated_at, closed_at, _board_column });
132
+ }
133
+ return map;
134
+ }
135
+
136
+ /**
137
+ * Create a subscription registry with attach/detach and per-key locking.
138
+ */
139
+ export class SubscriptionRegistry {
140
+ constructor() {
141
+ /** @type {Map<string, Entry>} */
142
+ this._entries = new Map();
143
+ }
144
+
145
+ /**
146
+ * Get an entry by key, or null if missing.
147
+ *
148
+ * @param {string} key
149
+ * @returns {Entry | null}
150
+ */
151
+ get(key) {
152
+ return this._entries.get(key) || null;
153
+ }
154
+
155
+ /**
156
+ * Ensure an entry exists for a spec; returns the key and entry.
157
+ *
158
+ * @param {SubscriptionSpec} spec
159
+ * @returns {{ key: string, entry: Entry }}
160
+ */
161
+ ensure(spec) {
162
+ const key = keyOf(spec);
163
+ let entry = this._entries.get(key);
164
+ if (!entry) {
165
+ entry = createEntry();
166
+ this._entries.set(key, entry);
167
+ }
168
+ return { key, entry };
169
+ }
170
+
171
+ /**
172
+ * Attach a subscriber to a spec. Creates the entry if missing.
173
+ *
174
+ * @param {SubscriptionSpec} spec
175
+ * @param {WebSocket} ws
176
+ * @returns {{ key: string, subscribed: true }}
177
+ */
178
+ attach(spec, ws) {
179
+ const { key, entry } = this.ensure(spec);
180
+ entry.subscribers.add(ws);
181
+ return { key, subscribed: true };
182
+ }
183
+
184
+ /**
185
+ * Detach a subscriber from the spec. Keeps entry even if empty; eviction
186
+ * is handled by `onDisconnect` sweep.
187
+ *
188
+ * @param {SubscriptionSpec} spec
189
+ * @param {WebSocket} ws
190
+ * @returns {boolean} true when the subscriber was removed
191
+ */
192
+ detach(spec, ws) {
193
+ const key = keyOf(spec);
194
+ const entry = this._entries.get(key);
195
+ if (!entry) {
196
+ return false;
197
+ }
198
+ return entry.subscribers.delete(ws);
199
+ }
200
+
201
+ /**
202
+ * On socket disconnect, remove it from all subscriber sets and evict any
203
+ * entries that become empty as a result of this sweep.
204
+ *
205
+ * @param {WebSocket} ws
206
+ */
207
+ onDisconnect(ws) {
208
+ /** @type {string[]} */
209
+ const empties = [];
210
+ for (const [key, entry] of this._entries) {
211
+ entry.subscribers.delete(ws);
212
+ if (entry.subscribers.size === 0) {
213
+ empties.push(key);
214
+ }
215
+ }
216
+ for (const key of empties) {
217
+ this._entries.delete(key);
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Serialize a function against a key so only one runs at a time per key.
223
+ *
224
+ * @template T
225
+ * @param {string} key
226
+ * @param {() => Promise<T>} fn
227
+ * @returns {Promise<T>}
228
+ */
229
+ async withKeyLock(key, fn) {
230
+ let entry = this._entries.get(key);
231
+ if (!entry) {
232
+ entry = createEntry();
233
+ this._entries.set(key, entry);
234
+ }
235
+ // Chain onto the existing lock
236
+ const prev = entry.lock;
237
+ // Create our own release function and store it locally (not in shared state)
238
+ // to avoid race conditions when multiple operations queue concurrently
239
+ /** @type {(v?: void) => void} */
240
+ let release = () => {};
241
+ const our_lock = new Promise((resolve) => {
242
+ release = resolve;
243
+ });
244
+ // Update the entry's lock to our lock so the next operation waits on us
245
+ entry.lock = our_lock;
246
+ // Wait for previous operations to finish
247
+ await prev.catch(() => {});
248
+ try {
249
+ const result = await fn();
250
+ return result;
251
+ } finally {
252
+ // Release our lock for the next queued operation
253
+ // Use the locally-captured release function, not entry.lockTail
254
+ try {
255
+ release();
256
+ } catch {
257
+ // ignore
258
+ }
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Replace items for a key and compute the delta, storing the new map.
264
+ *
265
+ * @param {string} key
266
+ * @param {Map<string, ItemMeta>} next_map
267
+ * @returns {{ added: string[], updated: string[], removed: string[] }}
268
+ */
269
+ applyNextMap(key, next_map) {
270
+ let entry = this._entries.get(key);
271
+ if (!entry) {
272
+ entry = createEntry();
273
+ this._entries.set(key, entry);
274
+ }
275
+ const prev = entry.itemsById;
276
+ const delta = computeDelta(prev, next_map);
277
+ entry.itemsById = new Map(next_map);
278
+ return delta;
279
+ }
280
+
281
+ /**
282
+ * Convenience: update items from an array of objects with id/updated_at/closed_at.
283
+ *
284
+ * @param {string} key
285
+ * @param {Array<{ id: string, updated_at: number, closed_at?: number|null, _board_column?: string }>} items
286
+ * @returns {{ added: string[], updated: string[], removed: string[] }}
287
+ */
288
+ applyItems(key, items) {
289
+ const next_map = toItemsMap(items);
290
+ return this.applyNextMap(key, next_map);
291
+ }
292
+
293
+ /**
294
+ * Clear all entries from the registry. Used when switching workspaces.
295
+ * Does not close WebSocket connections; they will re-subscribe on refresh.
296
+ */
297
+ clear() {
298
+ this._entries.clear();
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Default singleton registry used by the ws server.
304
+ */
305
+ export const registry = new SubscriptionRegistry();
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Validation helpers for protocol payloads.
3
+ *
4
+ * Provides schema checks for subscription specs and selected mutations.
5
+ */
6
+
7
+ /**
8
+ * Known subscription types supported by the server.
9
+ *
10
+ * @type {Set<string>}
11
+ */
12
+ const SUBSCRIPTION_TYPES = new Set([
13
+ 'all-issues',
14
+ 'epics',
15
+ 'board-issues',
16
+ 'blocked-issues',
17
+ 'ready-issues',
18
+ 'in-progress-issues',
19
+ 'closed-issues',
20
+ 'filtered-issues',
21
+ 'issue-detail'
22
+ ]);
23
+
24
+ /**
25
+ * Allowed status tokens for `filtered-issues.params.statuses`.
26
+ *
27
+ * @type {Set<string>}
28
+ */
29
+ const FILTERED_ISSUE_STATUSES = new Set(['open', 'in_progress', 'closed']);
30
+
31
+ /**
32
+ * Validate a subscribe-list payload and normalize to a SubscriptionSpec.
33
+ *
34
+ * @param {unknown} payload
35
+ * @returns {{ ok: true, id: string, spec: { type: string, params?: Record<string, string|number|boolean> } } | { ok: false, code: 'bad_request', message: string }}
36
+ */
37
+ export function validateSubscribeListPayload(payload) {
38
+ if (!payload || typeof payload !== 'object') {
39
+ return {
40
+ ok: false,
41
+ code: 'bad_request',
42
+ message: 'payload must be an object'
43
+ };
44
+ }
45
+ const any =
46
+ /** @type {{ id?: unknown, type?: unknown, params?: unknown }} */ (payload);
47
+
48
+ const id = typeof any.id === 'string' ? any.id : '';
49
+ if (id.length === 0) {
50
+ return {
51
+ ok: false,
52
+ code: 'bad_request',
53
+ message: 'payload.id must be a non-empty string'
54
+ };
55
+ }
56
+
57
+ const type = typeof any.type === 'string' ? any.type : '';
58
+ if (type.length === 0 || !SUBSCRIPTION_TYPES.has(type)) {
59
+ return {
60
+ ok: false,
61
+ code: 'bad_request',
62
+ message: `payload.type must be one of: ${Array.from(SUBSCRIPTION_TYPES).join(', ')}`
63
+ };
64
+ }
65
+
66
+ /** @type {Record<string, string|number|boolean> | undefined} */
67
+ let params;
68
+ if (any.params !== undefined) {
69
+ if (
70
+ !any.params ||
71
+ typeof any.params !== 'object' ||
72
+ Array.isArray(any.params)
73
+ ) {
74
+ return {
75
+ ok: false,
76
+ code: 'bad_request',
77
+ message: 'payload.params must be an object when provided'
78
+ };
79
+ }
80
+ params = /** @type {Record<string, string|number|boolean>} */ (any.params);
81
+ }
82
+
83
+ // Per-type param schemas
84
+ if (type === 'issue-detail') {
85
+ const id = String(params?.id ?? '').trim();
86
+ if (id.length === 0) {
87
+ return {
88
+ ok: false,
89
+ code: 'bad_request',
90
+ message: 'params.id must be a non-empty string'
91
+ };
92
+ }
93
+ params = { id };
94
+ } else if (type === 'closed-issues') {
95
+ if (params && 'since' in params) {
96
+ const since = params.since;
97
+ const n = typeof since === 'number' ? since : Number.NaN;
98
+ if (!Number.isFinite(n) || n < 0) {
99
+ return {
100
+ ok: false,
101
+ code: 'bad_request',
102
+ message: 'params.since must be a non-negative number (epoch ms)'
103
+ };
104
+ }
105
+ params = { since: n };
106
+ } else {
107
+ params = undefined;
108
+ }
109
+ } else if (type === 'filtered-issues') {
110
+ if (params && 'statuses' in params) {
111
+ const raw = params.statuses;
112
+ if (typeof raw !== 'string') {
113
+ return {
114
+ ok: false,
115
+ code: 'bad_request',
116
+ message: 'params.statuses must be a comma-separated string'
117
+ };
118
+ }
119
+ const arr = raw
120
+ .split(',')
121
+ .map((s) => s.trim())
122
+ .filter((s) => s.length > 0);
123
+ if (arr.length === 0) {
124
+ params = undefined;
125
+ } else {
126
+ for (const s of arr) {
127
+ if (!FILTERED_ISSUE_STATUSES.has(s)) {
128
+ return {
129
+ ok: false,
130
+ code: 'bad_request',
131
+ message: `params.statuses contains invalid value: ${s}`
132
+ };
133
+ }
134
+ }
135
+ params = { statuses: arr.join(',') };
136
+ }
137
+ } else {
138
+ params = undefined;
139
+ }
140
+ } else {
141
+ // Other types do not accept params
142
+ if (params && Object.keys(params).length > 0) {
143
+ return {
144
+ ok: false,
145
+ code: 'bad_request',
146
+ message: `type ${type} does not accept params`
147
+ };
148
+ }
149
+ params = undefined;
150
+ }
151
+
152
+ return { ok: true, id, spec: { type, params } };
153
+ }
@@ -0,0 +1,139 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { resolveWorkspaceDatabase } from './db.js';
4
+ import { debug } from './logging.js';
5
+
6
+ /**
7
+ * Watch the resolved workspace database target and invoke a callback after a
8
+ * debounce window.
9
+ *
10
+ * For SQLite workspaces this watches the DB file's parent directory and filters
11
+ * by file name. For non-SQLite backends (for example Dolt), this watches the
12
+ * workspace `.beads` directory.
13
+ *
14
+ * @param {string} root_dir - Project root directory (starting point for resolution).
15
+ * @param {() => void} onChange - Called when changes are detected.
16
+ * @param {{ debounce_ms?: number, cooldown_ms?: number, explicit_db?: string }} [options]
17
+ * @returns {{ close: () => void, rebind: (opts?: { root_dir?: string, explicit_db?: string }) => void, path: string }}
18
+ */
19
+ export function watchDb(root_dir, onChange, options = {}) {
20
+ const debounce_ms = options.debounce_ms ?? 250;
21
+ const cooldown_ms = options.cooldown_ms ?? 1000;
22
+ const log = debug('watcher');
23
+
24
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
25
+ let timer;
26
+ /** @type {fs.FSWatcher | undefined} */
27
+ let watcher;
28
+ let cooldown_until = 0;
29
+ let current_path = '';
30
+ let current_dir = '';
31
+ let current_file = '';
32
+
33
+ /**
34
+ * Schedule the debounced onChange callback.
35
+ */
36
+ const schedule = () => {
37
+ if (timer) {
38
+ clearTimeout(timer);
39
+ }
40
+ timer = setTimeout(() => {
41
+ onChange();
42
+ cooldown_until = Date.now() + cooldown_ms;
43
+ }, debounce_ms);
44
+ timer.unref();
45
+ };
46
+
47
+ /**
48
+ * Attach a watcher to the directory containing the resolved DB path.
49
+ *
50
+ * @param {string} base_dir
51
+ * @param {string | undefined} explicit_db
52
+ */
53
+ const bind = (base_dir, explicit_db) => {
54
+ const resolved = resolveWorkspaceDatabase({ cwd: base_dir, explicit_db });
55
+ current_path = resolved.path;
56
+ if (pathIsDirectory(current_path)) {
57
+ current_dir = current_path;
58
+ current_file = '';
59
+ } else {
60
+ current_dir = path.dirname(current_path);
61
+ current_file = path.basename(current_path);
62
+ }
63
+ if (!resolved.exists) {
64
+ log(
65
+ 'resolved workspace database missing: %s – Hint: set --db, export BEADS_DB, or run `bd init` in your workspace.',
66
+ current_path
67
+ );
68
+ }
69
+
70
+ // (Re)create watcher
71
+ try {
72
+ watcher = fs.watch(
73
+ current_dir,
74
+ { persistent: true },
75
+ (event_type, filename) => {
76
+ if (current_file && filename && String(filename) !== current_file) {
77
+ return;
78
+ }
79
+ if (event_type === 'change' || event_type === 'rename') {
80
+ if (Date.now() < cooldown_until) {
81
+ return;
82
+ }
83
+ log('fs %s %s', event_type, filename || '');
84
+ schedule();
85
+ }
86
+ }
87
+ );
88
+ } catch (err) {
89
+ log('unable to watch directory %s %o', current_dir, err);
90
+ }
91
+ };
92
+
93
+ // initial bind
94
+ bind(root_dir, options.explicit_db);
95
+
96
+ return {
97
+ get path() {
98
+ return current_path;
99
+ },
100
+ close() {
101
+ if (timer) {
102
+ clearTimeout(timer);
103
+ timer = undefined;
104
+ }
105
+ watcher?.close();
106
+ },
107
+ /**
108
+ * Re-resolve and reattach watcher when root_dir or explicit_db changes.
109
+ *
110
+ * @param {{ root_dir?: string, explicit_db?: string }} [opts]
111
+ */
112
+ rebind(opts = {}) {
113
+ const next_root = opts.root_dir ? String(opts.root_dir) : root_dir;
114
+ const next_explicit = opts.explicit_db ?? options.explicit_db;
115
+ const next_resolved = resolveWorkspaceDatabase({
116
+ cwd: next_root,
117
+ explicit_db: next_explicit
118
+ });
119
+ const next_path = next_resolved.path;
120
+ if (next_path !== current_path) {
121
+ // swap watcher
122
+ watcher?.close();
123
+ cooldown_until = 0;
124
+ bind(next_root, next_explicit);
125
+ }
126
+ }
127
+ };
128
+ }
129
+
130
+ /**
131
+ * @param {string} file_path
132
+ */
133
+ function pathIsDirectory(file_path) {
134
+ try {
135
+ return fs.statSync(file_path).isDirectory();
136
+ } catch {
137
+ return false;
138
+ }
139
+ }