beads-ui 0.1.2 → 0.3.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 (54) hide show
  1. package/CHANGES.md +29 -2
  2. package/README.md +39 -45
  3. package/app/data/list-selectors.js +98 -0
  4. package/app/data/providers.js +25 -127
  5. package/app/data/sort.js +45 -0
  6. package/app/data/subscription-issue-store.js +161 -0
  7. package/app/data/subscription-issue-stores.js +102 -0
  8. package/app/data/subscriptions-store.js +219 -0
  9. package/app/index.html +8 -0
  10. package/app/main.js +483 -61
  11. package/app/protocol.js +10 -14
  12. package/app/protocol.md +21 -19
  13. package/app/router.js +45 -9
  14. package/app/state.js +27 -11
  15. package/app/styles.css +373 -184
  16. package/app/utils/issue-id-renderer.js +71 -0
  17. package/app/utils/issue-url.js +9 -0
  18. package/app/utils/markdown.js +15 -194
  19. package/app/utils/priority-badge.js +0 -2
  20. package/app/utils/status-badge.js +0 -1
  21. package/app/utils/toast.js +34 -0
  22. package/app/utils/type-badge.js +0 -3
  23. package/app/views/board.js +439 -87
  24. package/app/views/detail.js +364 -154
  25. package/app/views/epics.js +128 -76
  26. package/app/views/issue-dialog.js +163 -0
  27. package/app/views/issue-row.js +10 -11
  28. package/app/views/list.js +164 -93
  29. package/app/views/new-issue-dialog.js +345 -0
  30. package/app/ws.js +36 -9
  31. package/bin/bdui.js +1 -1
  32. package/docs/adr/001-push-only-lists.md +134 -0
  33. package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +200 -0
  34. package/docs/architecture.md +35 -85
  35. package/docs/data-exchange-subscription-plan.md +198 -0
  36. package/docs/db-watching.md +2 -1
  37. package/docs/migration-v2.md +54 -0
  38. package/docs/protocol/issues-push-v2.md +179 -0
  39. package/docs/subscription-issue-store.md +112 -0
  40. package/package.json +11 -3
  41. package/server/bd.js +0 -2
  42. package/server/cli/commands.js +12 -5
  43. package/server/cli/daemon.js +12 -5
  44. package/server/cli/index.js +34 -5
  45. package/server/cli/usage.js +2 -2
  46. package/server/config.js +12 -6
  47. package/server/db.js +0 -1
  48. package/server/index.js +9 -5
  49. package/server/list-adapters.js +218 -0
  50. package/server/subscriptions.js +277 -0
  51. package/server/validators.js +111 -0
  52. package/server/watcher.js +6 -9
  53. package/server/ws.js +466 -227
  54. package/docs/quickstart.md +0 -142
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @import { SubscriptionIssueStoreOptions } from '../../types/subscription-issue-store.js'
3
+ * @import { IssueLite } from './list-selectors.js'
4
+ */
5
+ import { createSubscriptionIssueStore } from './subscription-issue-store.js';
6
+ import { subKeyOf } from './subscriptions-store.js';
7
+
8
+ /**
9
+ * Registry managing per-subscription issue stores. Stores receive full-issue
10
+ * push envelopes (snapshot/upsert/delete) per subscription id and expose
11
+ * read-only snapshots for rendering.
12
+ */
13
+
14
+ /**
15
+ */
16
+ export function createSubscriptionIssueStores() {
17
+ /** @type {Map<string, ReturnType<typeof createSubscriptionIssueStore>>} */
18
+ const stores_by_id = new Map();
19
+ /** @type {Map<string, string>} */
20
+ const key_by_id = new Map();
21
+ /** @type {Set<() => void>} */
22
+ const listeners = new Set();
23
+ /** @type {Map<string, () => void>} */
24
+ const store_unsubs = new Map();
25
+
26
+ function emit() {
27
+ for (const fn of Array.from(listeners)) {
28
+ try {
29
+ fn();
30
+ } catch {
31
+ // ignore
32
+ }
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Ensure a store exists for client_id and attach a listener that fans out
38
+ * store-level updates to global listeners.
39
+ * @param {string} client_id
40
+ * @param {{ type: string, params?: Record<string, string|number|boolean> }} [spec]
41
+ * @param {SubscriptionIssueStoreOptions} [options]
42
+ */
43
+ function register(client_id, spec, options) {
44
+ key_by_id.set(client_id, spec ? subKeyOf(spec) : '');
45
+ if (!stores_by_id.has(client_id)) {
46
+ const store = createSubscriptionIssueStore(client_id, options);
47
+ stores_by_id.set(client_id, store);
48
+ // Fan out per-store events to global subscribers
49
+ const off = store.subscribe(() => emit());
50
+ store_unsubs.set(client_id, off);
51
+ }
52
+ return () => unregister(client_id);
53
+ }
54
+
55
+ /**
56
+ * @param {string} client_id
57
+ */
58
+ function unregister(client_id) {
59
+ key_by_id.delete(client_id);
60
+ const store = stores_by_id.get(client_id);
61
+ if (store) {
62
+ store.dispose();
63
+ stores_by_id.delete(client_id);
64
+ }
65
+ const off = store_unsubs.get(client_id);
66
+ if (off) {
67
+ try {
68
+ off();
69
+ } catch {
70
+ // ignore
71
+ }
72
+ store_unsubs.delete(client_id);
73
+ }
74
+ }
75
+
76
+ return {
77
+ register,
78
+ unregister,
79
+ /**
80
+ * @param {string} client_id
81
+ */
82
+ getStore(client_id) {
83
+ return stores_by_id.get(client_id) || null;
84
+ },
85
+ /**
86
+ * @param {string} client_id
87
+ * @returns {IssueLite[]}
88
+ */
89
+ snapshotFor(client_id) {
90
+ const s = stores_by_id.get(client_id);
91
+ return s ? /** @type {IssueLite[]} */ (s.snapshot().slice()) : [];
92
+ },
93
+ /**
94
+ * @param {() => void} fn
95
+ */
96
+ subscribe(fn) {
97
+ listeners.add(fn);
98
+ return () => listeners.delete(fn);
99
+ }
100
+ // No recompute helpers in vNext; stores are updated directly via push
101
+ };
102
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * @import { MessageType } from '../protocol.js'
3
+ */
4
+ /**
5
+ * Client-side list subscription store.
6
+ *
7
+ * Maintains per-subscription state keyed by client-provided `id`.
8
+ * Applies server `list-delta` events per subscription key and exposes simple
9
+ * selectors for rendering.
10
+ */
11
+
12
+ /**
13
+ * @typedef {{ type: string, params?: Record<string, string|number|boolean> }} SubscriptionSpec
14
+ */
15
+
16
+ /**
17
+ * Generate a stable subscription key string from a spec.
18
+ * Mirrors server `keyOf` implementation (sorted params, URLSearchParams).
19
+ * @param {SubscriptionSpec} spec
20
+ * @returns {string}
21
+ */
22
+ export function subKeyOf(spec) {
23
+ const type = String(spec.type || '').trim();
24
+ /** @type {Record<string, string>} */
25
+ const flat = {};
26
+ if (spec.params && typeof spec.params === 'object') {
27
+ const keys = Object.keys(spec.params).sort();
28
+ for (const k of keys) {
29
+ const v = spec.params[k];
30
+ flat[k] = String(v);
31
+ }
32
+ }
33
+ const enc = new URLSearchParams(flat).toString();
34
+ return enc.length > 0 ? `${type}?${enc}` : type;
35
+ }
36
+
37
+ /**
38
+ * Create a list subscription store.
39
+ *
40
+ * Wiring:
41
+ * - Use `subscribeList` to register a subscription and send the request.
42
+ *
43
+ * Selectors are synchronous and return derived state by client id.
44
+ * @param {(type: MessageType, payload?: unknown) => Promise<unknown>} send - ws send.
45
+ */
46
+ export function createSubscriptionStore(send) {
47
+ /** @type {Map<string, { key: string, itemsById: Map<string, true> }>} */
48
+ const subs_by_id = new Map();
49
+ /** @type {Map<string, Set<string>>} */
50
+ const ids_by_key = new Map();
51
+
52
+ /**
53
+ * Apply a delta to all client ids mapped to a given key.
54
+ * @param {string} key
55
+ * @param {{ added: string[], updated: string[], removed: string[] }} delta
56
+ */
57
+ function applyDelta(key, delta) {
58
+ const id_set = ids_by_key.get(key);
59
+ if (!id_set || id_set.size === 0) {
60
+ return;
61
+ }
62
+ const added = Array.isArray(delta.added) ? delta.added : [];
63
+ const updated = Array.isArray(delta.updated) ? delta.updated : [];
64
+ const removed = Array.isArray(delta.removed) ? delta.removed : [];
65
+
66
+ for (const client_id of Array.from(id_set)) {
67
+ const entry = subs_by_id.get(client_id);
68
+ if (!entry) {
69
+ continue;
70
+ }
71
+ const items = entry.itemsById;
72
+ for (const id of added) {
73
+ if (typeof id === 'string' && id.length > 0) {
74
+ items.set(id, true);
75
+ }
76
+ }
77
+ for (const id of updated) {
78
+ if (typeof id === 'string' && id.length > 0) {
79
+ items.set(id, true);
80
+ }
81
+ }
82
+ for (const id of removed) {
83
+ if (typeof id === 'string' && id.length > 0) {
84
+ items.delete(id);
85
+ }
86
+ }
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Subscribe to a list spec with a client-provided id.
92
+ * Returns an unsubscribe function.
93
+ * Creates an empty items store immediately; server will publish deltas.
94
+ * @param {string} client_id
95
+ * @param {SubscriptionSpec} spec
96
+ * @returns {Promise<() => Promise<void>>}
97
+ */
98
+ async function subscribeList(client_id, spec) {
99
+ const key = subKeyOf(spec);
100
+ // Initialize local entry immediately to capture early deltas
101
+ if (!subs_by_id.has(client_id)) {
102
+ subs_by_id.set(client_id, { key, itemsById: new Map() });
103
+ } else {
104
+ // Update key mapping if client id is reused for a different spec
105
+ const prev = subs_by_id.get(client_id);
106
+ if (prev && prev.key !== key) {
107
+ const prev_ids = ids_by_key.get(prev.key);
108
+ if (prev_ids) {
109
+ prev_ids.delete(client_id);
110
+ if (prev_ids.size === 0) {
111
+ ids_by_key.delete(prev.key);
112
+ }
113
+ }
114
+ subs_by_id.set(client_id, { key, itemsById: new Map() });
115
+ }
116
+ }
117
+ if (!ids_by_key.has(key)) {
118
+ ids_by_key.set(key, new Set());
119
+ }
120
+ const set = ids_by_key.get(key);
121
+ if (set) {
122
+ set.add(client_id);
123
+ }
124
+ try {
125
+ await send('subscribe-list', {
126
+ id: client_id,
127
+ type: spec.type,
128
+ params: spec.params
129
+ });
130
+ } catch {
131
+ // keep local mapping to allow retries; caller may unsubscribe or retry
132
+ }
133
+
134
+ return async () => {
135
+ try {
136
+ await send('unsubscribe-list', { id: client_id });
137
+ } catch {
138
+ // ignore transport errors on unsubscribe
139
+ }
140
+ // Cleanup local mappings
141
+ const entry = subs_by_id.get(client_id) || null;
142
+ if (entry) {
143
+ const s = ids_by_key.get(entry.key);
144
+ if (s) {
145
+ s.delete(client_id);
146
+ if (s.size === 0) {
147
+ ids_by_key.delete(entry.key);
148
+ }
149
+ }
150
+ }
151
+ subs_by_id.delete(client_id);
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Selectors by client id.
157
+ */
158
+ const selectors = {
159
+ /**
160
+ * Get an array of item ids for a subscription.
161
+ * @param {string} client_id
162
+ * @returns {string[]}
163
+ */
164
+ getIds(client_id) {
165
+ const entry = subs_by_id.get(client_id);
166
+ if (!entry) {
167
+ return [];
168
+ }
169
+ return Array.from(entry.itemsById.keys());
170
+ },
171
+ /**
172
+ * Check if an id exists in a subscription.
173
+ * @param {string} client_id
174
+ * @param {string} id
175
+ * @returns {boolean}
176
+ */
177
+ has(client_id, id) {
178
+ const entry = subs_by_id.get(client_id);
179
+ if (!entry) {
180
+ return false;
181
+ }
182
+ return entry.itemsById.has(id);
183
+ },
184
+ /**
185
+ * Count items for a subscription.
186
+ * @param {string} client_id
187
+ * @returns {number}
188
+ */
189
+ count(client_id) {
190
+ const entry = subs_by_id.get(client_id);
191
+ return entry ? entry.itemsById.size : 0;
192
+ },
193
+ /**
194
+ * Return a shallow object copy `{ [id]: true }` for rendering helpers.
195
+ * @param {string} client_id
196
+ * @returns {Record<string, true>}
197
+ */
198
+ getItemsById(client_id) {
199
+ const entry = subs_by_id.get(client_id);
200
+ /** @type {Record<string, true>} */
201
+ const out = {};
202
+ if (!entry) {
203
+ return out;
204
+ }
205
+ for (const id of entry.itemsById.keys()) {
206
+ out[id] = true;
207
+ }
208
+ return out;
209
+ }
210
+ };
211
+
212
+ return {
213
+ subscribeList,
214
+ // test/diagnostics helpers
215
+ _applyDelta: applyDelta,
216
+ _subKeyOf: subKeyOf,
217
+ selectors
218
+ };
219
+ }
package/app/index.html CHANGED
@@ -21,6 +21,14 @@
21
21
  aria-label="Toggle dark mode"
22
22
  />
23
23
  </label>
24
+ <button
25
+ id="new-issue-btn"
26
+ type="button"
27
+ aria-haspopup="dialog"
28
+ title="Create a new issue (Ctrl/Cmd+N)"
29
+ >
30
+ New issue
31
+ </button>
24
32
  </div>
25
33
  </header>
26
34
  <main id="app" class="app-shell" aria-live="polite"></main>