beads-ui 0.2.0 → 0.3.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 (58) hide show
  1. package/CHANGES.md +14 -0
  2. package/README.md +4 -4
  3. package/app/data/list-selectors.js +103 -0
  4. package/app/data/providers.js +7 -138
  5. package/app/data/sort.js +47 -0
  6. package/app/data/subscription-issue-store.js +161 -0
  7. package/app/data/subscription-issue-stores.js +128 -0
  8. package/app/data/subscriptions-store.js +227 -0
  9. package/app/main.js +346 -66
  10. package/app/protocol.js +23 -17
  11. package/app/protocol.md +18 -15
  12. package/app/router.js +3 -0
  13. package/app/state.js +2 -0
  14. package/app/styles.css +222 -197
  15. package/app/utils/issue-id-renderer.js +2 -1
  16. package/app/utils/issue-id.js +1 -0
  17. package/app/utils/issue-type.js +2 -0
  18. package/app/utils/issue-url.js +1 -0
  19. package/app/utils/markdown.js +13 -198
  20. package/app/utils/priority-badge.js +1 -2
  21. package/app/utils/status-badge.js +1 -1
  22. package/app/utils/status.js +2 -0
  23. package/app/utils/toast.js +1 -1
  24. package/app/utils/type-badge.js +1 -3
  25. package/app/views/board.js +172 -148
  26. package/app/views/detail.js +79 -66
  27. package/app/views/epics.js +127 -74
  28. package/app/views/issue-dialog.js +9 -15
  29. package/app/views/issue-row.js +2 -3
  30. package/app/views/list.js +105 -104
  31. package/app/views/nav.js +1 -0
  32. package/app/views/new-issue-dialog.js +30 -34
  33. package/app/ws.js +10 -10
  34. package/bin/bdui.js +1 -1
  35. package/docs/adr/001-push-only-lists.md +134 -0
  36. package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +200 -0
  37. package/docs/architecture.md +34 -84
  38. package/docs/data-exchange-subscription-plan.md +198 -0
  39. package/docs/db-watching.md +2 -1
  40. package/docs/migration-v2.md +54 -0
  41. package/docs/protocol/issues-push-v2.md +179 -0
  42. package/docs/subscription-issue-store.md +112 -0
  43. package/package.json +5 -4
  44. package/server/app.js +2 -0
  45. package/server/bd.js +4 -2
  46. package/server/cli/commands.js +5 -2
  47. package/server/cli/daemon.js +19 -5
  48. package/server/cli/index.js +2 -2
  49. package/server/cli/open.js +3 -0
  50. package/server/cli/usage.js +2 -1
  51. package/server/config.js +13 -6
  52. package/server/db.js +3 -1
  53. package/server/index.js +9 -5
  54. package/server/list-adapters.js +224 -0
  55. package/server/subscriptions.js +289 -0
  56. package/server/validators.js +113 -0
  57. package/server/watcher.js +8 -8
  58. package/server/ws.js +457 -229
@@ -0,0 +1,289 @@
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 }>
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 }} ItemMeta
26
+ */
27
+
28
+ /**
29
+ * @typedef {{
30
+ * itemsById: Map<string, ItemMeta>,
31
+ * subscribers: Set<WebSocket>,
32
+ * lock: Promise<void>,
33
+ * lockTail: () => void
34
+ * }} Entry
35
+ */
36
+
37
+ /**
38
+ * Create a new, empty entry object.
39
+ *
40
+ * @returns {Entry}
41
+ */
42
+ function createEntry() {
43
+ return {
44
+ itemsById: new Map(),
45
+ subscribers: new Set(),
46
+ lock: Promise.resolve(),
47
+ lockTail: () => {}
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Generate a stable subscription key string from a spec. Sorts params keys.
53
+ *
54
+ * @param {SubscriptionSpec} spec
55
+ * @returns {string}
56
+ */
57
+ export function keyOf(spec) {
58
+ const type = String(spec.type || '').trim();
59
+ /** @type {Record<string, string>} */
60
+ const flat = {};
61
+ if (spec.params && typeof spec.params === 'object') {
62
+ const keys = Object.keys(spec.params).sort();
63
+ for (const k of keys) {
64
+ const v = spec.params[k];
65
+ flat[k] = String(v);
66
+ }
67
+ }
68
+ const enc = new URLSearchParams(flat).toString();
69
+ return enc.length > 0 ? `${type}?${enc}` : type;
70
+ }
71
+
72
+ /**
73
+ * Compute a delta between previous and next item maps.
74
+ *
75
+ * @param {Map<string, ItemMeta>} prev
76
+ * @param {Map<string, ItemMeta>} next
77
+ * @returns {{ added: string[], updated: string[], removed: string[] }}
78
+ */
79
+ export function computeDelta(prev, next) {
80
+ /** @type {string[]} */
81
+ const added = [];
82
+ /** @type {string[]} */
83
+ const updated = [];
84
+ /** @type {string[]} */
85
+ const removed = [];
86
+
87
+ for (const [id, meta] of next) {
88
+ const p = prev.get(id);
89
+ if (!p) {
90
+ added.push(id);
91
+ continue;
92
+ }
93
+ if (p.updated_at !== meta.updated_at || p.closed_at !== meta.closed_at) {
94
+ updated.push(id);
95
+ }
96
+ }
97
+ for (const id of prev.keys()) {
98
+ if (!next.has(id)) {
99
+ removed.push(id);
100
+ }
101
+ }
102
+ return { added, updated, removed };
103
+ }
104
+
105
+ /**
106
+ * Normalize array of issue-like objects into an itemsById map.
107
+ *
108
+ * @param {Array<{ id: string, updated_at: number, closed_at?: number|null }>} items
109
+ * @returns {Map<string, ItemMeta>}
110
+ */
111
+ export function toItemsMap(items) {
112
+ /** @type {Map<string, ItemMeta>} */
113
+ const map = new Map();
114
+ for (const it of items) {
115
+ if (!it || typeof it.id !== 'string') {
116
+ continue;
117
+ }
118
+ const updated_at = Number(it.updated_at) || 0;
119
+ /** @type {number|null} */
120
+ let closed_at = null;
121
+ if (it.closed_at === null || it.closed_at === undefined) {
122
+ closed_at = null;
123
+ } else {
124
+ const n = Number(it.closed_at);
125
+ closed_at = Number.isFinite(n) ? n : null;
126
+ }
127
+ map.set(it.id, { updated_at, closed_at });
128
+ }
129
+ return map;
130
+ }
131
+
132
+ /**
133
+ * Create a subscription registry with attach/detach and per-key locking.
134
+ */
135
+ export class SubscriptionRegistry {
136
+ constructor() {
137
+ /** @type {Map<string, Entry>} */
138
+ this._entries = new Map();
139
+ }
140
+
141
+ /**
142
+ * Get an entry by key, or null if missing.
143
+ *
144
+ * @param {string} key
145
+ * @returns {Entry | null}
146
+ */
147
+ get(key) {
148
+ return this._entries.get(key) || null;
149
+ }
150
+
151
+ /**
152
+ * Ensure an entry exists for a spec; returns the key and entry.
153
+ *
154
+ * @param {SubscriptionSpec} spec
155
+ * @returns {{ key: string, entry: Entry }}
156
+ */
157
+ ensure(spec) {
158
+ const key = keyOf(spec);
159
+ let entry = this._entries.get(key);
160
+ if (!entry) {
161
+ entry = createEntry();
162
+ this._entries.set(key, entry);
163
+ }
164
+ return { key, entry };
165
+ }
166
+
167
+ /**
168
+ * Attach a subscriber to a spec. Creates the entry if missing.
169
+ *
170
+ * @param {SubscriptionSpec} spec
171
+ * @param {WebSocket} ws
172
+ * @returns {{ key: string, subscribed: true }}
173
+ */
174
+ attach(spec, ws) {
175
+ const { key, entry } = this.ensure(spec);
176
+ entry.subscribers.add(ws);
177
+ return { key, subscribed: true };
178
+ }
179
+
180
+ /**
181
+ * Detach a subscriber from the spec. Keeps entry even if empty; eviction
182
+ * is handled by `onDisconnect` sweep.
183
+ *
184
+ * @param {SubscriptionSpec} spec
185
+ * @param {WebSocket} ws
186
+ * @returns {boolean} true when the subscriber was removed
187
+ */
188
+ detach(spec, ws) {
189
+ const key = keyOf(spec);
190
+ const entry = this._entries.get(key);
191
+ if (!entry) {
192
+ return false;
193
+ }
194
+ return entry.subscribers.delete(ws);
195
+ }
196
+
197
+ /**
198
+ * On socket disconnect, remove it from all subscriber sets and evict any
199
+ * entries that become empty as a result of this sweep.
200
+ *
201
+ * @param {WebSocket} ws
202
+ */
203
+ onDisconnect(ws) {
204
+ /** @type {string[]} */
205
+ const empties = [];
206
+ for (const [key, entry] of this._entries) {
207
+ entry.subscribers.delete(ws);
208
+ if (entry.subscribers.size === 0) {
209
+ empties.push(key);
210
+ }
211
+ }
212
+ for (const key of empties) {
213
+ this._entries.delete(key);
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Serialize a function against a key so only one runs at a time per key.
219
+ *
220
+ * @template T
221
+ * @param {string} key
222
+ * @param {() => Promise<T>} fn
223
+ * @returns {Promise<T>}
224
+ */
225
+ async withKeyLock(key, fn) {
226
+ let entry = this._entries.get(key);
227
+ if (!entry) {
228
+ entry = createEntry();
229
+ this._entries.set(key, entry);
230
+ }
231
+ // Chain onto the existing lock
232
+ const prev = entry.lock;
233
+ /** @type {(v?: void) => void} */
234
+ let release = () => {};
235
+ entry.lock = new Promise((resolve) => {
236
+ release = resolve;
237
+ });
238
+ entry.lockTail = release;
239
+ // Wait for previous operations to finish
240
+ await prev.catch(() => {});
241
+ try {
242
+ const result = await fn();
243
+ return result;
244
+ } finally {
245
+ // Release lock for next queued op
246
+ try {
247
+ entry.lockTail();
248
+ } catch {
249
+ // ignore
250
+ }
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Replace items for a key and compute the delta, storing the new map.
256
+ *
257
+ * @param {string} key
258
+ * @param {Map<string, ItemMeta>} next_map
259
+ * @returns {{ added: string[], updated: string[], removed: string[] }}
260
+ */
261
+ applyNextMap(key, next_map) {
262
+ let entry = this._entries.get(key);
263
+ if (!entry) {
264
+ entry = createEntry();
265
+ this._entries.set(key, entry);
266
+ }
267
+ const prev = entry.itemsById;
268
+ const delta = computeDelta(prev, next_map);
269
+ entry.itemsById = new Map(next_map);
270
+ return delta;
271
+ }
272
+
273
+ /**
274
+ * Convenience: update items from an array of objects with id/updated_at/closed_at.
275
+ *
276
+ * @param {string} key
277
+ * @param {Array<{ id: string, updated_at: number, closed_at?: number|null }>} items
278
+ * @returns {{ added: string[], updated: string[], removed: string[] }}
279
+ */
280
+ applyItems(key, items) {
281
+ const next_map = toItemsMap(items);
282
+ return this.applyNextMap(key, next_map);
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Default singleton registry used by the ws server.
288
+ */
289
+ export const registry = new SubscriptionRegistry();
@@ -0,0 +1,113 @@
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
+ 'blocked-issues',
16
+ 'ready-issues',
17
+ 'in-progress-issues',
18
+ 'closed-issues',
19
+ 'issue-detail'
20
+ ]);
21
+
22
+ /**
23
+ * Validate a subscribe-list payload and normalize to a SubscriptionSpec.
24
+ *
25
+ * @param {unknown} payload
26
+ * @returns {{ ok: true, id: string, spec: { type: string, params?: Record<string, string|number|boolean> } } | { ok: false, code: 'bad_request', message: string }}
27
+ */
28
+ export function validateSubscribeListPayload(payload) {
29
+ if (!payload || typeof payload !== 'object') {
30
+ return {
31
+ ok: false,
32
+ code: 'bad_request',
33
+ message: 'payload must be an object'
34
+ };
35
+ }
36
+ const any =
37
+ /** @type {{ id?: unknown, type?: unknown, params?: unknown }} */ (payload);
38
+
39
+ const id = typeof any.id === 'string' ? any.id : '';
40
+ if (id.length === 0) {
41
+ return {
42
+ ok: false,
43
+ code: 'bad_request',
44
+ message: 'payload.id must be a non-empty string'
45
+ };
46
+ }
47
+
48
+ const type = typeof any.type === 'string' ? any.type : '';
49
+ if (type.length === 0 || !SUBSCRIPTION_TYPES.has(type)) {
50
+ return {
51
+ ok: false,
52
+ code: 'bad_request',
53
+ message: `payload.type must be one of: ${Array.from(SUBSCRIPTION_TYPES).join(', ')}`
54
+ };
55
+ }
56
+
57
+ /** @type {Record<string, string|number|boolean> | undefined} */
58
+ let params;
59
+ if (any.params !== undefined) {
60
+ if (
61
+ !any.params ||
62
+ typeof any.params !== 'object' ||
63
+ Array.isArray(any.params)
64
+ ) {
65
+ return {
66
+ ok: false,
67
+ code: 'bad_request',
68
+ message: 'payload.params must be an object when provided'
69
+ };
70
+ }
71
+ params = /** @type {Record<string, string|number|boolean>} */ (any.params);
72
+ }
73
+
74
+ // Per-type param schemas
75
+ if (type === 'issue-detail') {
76
+ const id = String(params?.id ?? '').trim();
77
+ if (id.length === 0) {
78
+ return {
79
+ ok: false,
80
+ code: 'bad_request',
81
+ message: 'params.id must be a non-empty string'
82
+ };
83
+ }
84
+ params = { id };
85
+ } else if (type === 'closed-issues') {
86
+ if (params && 'since' in params) {
87
+ const since = params.since;
88
+ const n = typeof since === 'number' ? since : Number.NaN;
89
+ if (!Number.isFinite(n) || n < 0) {
90
+ return {
91
+ ok: false,
92
+ code: 'bad_request',
93
+ message: 'params.since must be a non-negative number (epoch ms)'
94
+ };
95
+ }
96
+ params = { since: n };
97
+ } else {
98
+ params = undefined;
99
+ }
100
+ } else {
101
+ // Other types do not accept params
102
+ if (params && Object.keys(params).length > 0) {
103
+ return {
104
+ ok: false,
105
+ code: 'bad_request',
106
+ message: `type ${type} does not accept params`
107
+ };
108
+ }
109
+ params = undefined;
110
+ }
111
+
112
+ return { ok: true, id, spec: { type, params } };
113
+ }
package/server/watcher.js CHANGED
@@ -5,8 +5,9 @@ import { resolveDbPath } from './db.js';
5
5
  /**
6
6
  * Watch the resolved beads SQLite DB file and invoke a callback after a debounce window.
7
7
  * The DB path is resolved following beads precedence and can be overridden via options.
8
+ *
8
9
  * @param {string} root_dir - Project root directory (starting point for resolution).
9
- * @param {(payload: { ts: number }) => void} onChange - Called when changes are detected.
10
+ * @param {() => void} onChange - Called when changes are detected.
10
11
  * @param {{ debounce_ms?: number, explicit_db?: string }} [options]
11
12
  * @returns {{ close: () => void, rebind: (opts?: { root_dir?: string, explicit_db?: string }) => void, path: string }}
12
13
  */
@@ -17,11 +18,8 @@ export function watchDb(root_dir, onChange, options = {}) {
17
18
  let timer;
18
19
  /** @type {fs.FSWatcher | undefined} */
19
20
  let watcher;
20
- /** @type {string} */
21
21
  let current_path = '';
22
- /** @type {string} */
23
22
  let current_dir = '';
24
- /** @type {string} */
25
23
  let current_file = '';
26
24
 
27
25
  const schedule = () => {
@@ -29,13 +27,14 @@ export function watchDb(root_dir, onChange, options = {}) {
29
27
  clearTimeout(timer);
30
28
  }
31
29
  timer = setTimeout(() => {
32
- onChange({ ts: Date.now() });
30
+ onChange();
33
31
  }, debounce_ms);
34
- timer.unref?.();
32
+ timer.unref();
35
33
  };
36
34
 
37
35
  /**
38
36
  * Attach a watcher to the directory containing the resolved DB path.
37
+ *
39
38
  * @param {string} base_dir
40
39
  * @param {string | undefined} explicit_db
41
40
  */
@@ -87,16 +86,17 @@ export function watchDb(root_dir, onChange, options = {}) {
87
86
  },
88
87
  /**
89
88
  * Re-resolve and reattach watcher when root_dir or explicit_db changes.
89
+ *
90
90
  * @param {{ root_dir?: string, explicit_db?: string }} [opts]
91
91
  */
92
92
  rebind(opts = {}) {
93
93
  const next_root = opts.root_dir ? String(opts.root_dir) : root_dir;
94
94
  const next_explicit = opts.explicit_db ?? options.explicit_db;
95
- const nextResolved = resolveDbPath({
95
+ const next_resolved = resolveDbPath({
96
96
  cwd: next_root,
97
97
  explicit_db: next_explicit
98
98
  });
99
- const next_path = nextResolved.path;
99
+ const next_path = next_resolved.path;
100
100
  if (next_path !== current_path) {
101
101
  // swap watcher
102
102
  watcher?.close();