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
@@ -1,103 +0,0 @@
1
- /**
2
- * List selectors utility: compose subscription membership with issues entities
3
- * and apply view-specific sorting. Provides a lightweight `subscribe` that
4
- * triggers once per issues envelope to let views re-render.
5
- */
6
- /**
7
- * @typedef {{ id: string, title?: string, status?: 'open'|'in_progress'|'closed', priority?: number, issue_type?: string, created_at?: number, updated_at?: number, closed_at?: number }} IssueLite
8
- */
9
- import { cmpClosedDesc, cmpPriorityThenCreated } from './sort.js';
10
-
11
- /**
12
- * Factory for list selectors.
13
- *
14
- * Source of truth is per-subscription stores providing snapshots for a given
15
- * client id. Central issues store fallback has been removed.
16
- *
17
- * @param {{ snapshotFor?: (client_id: string) => IssueLite[], subscribe?: (fn: () => void) => () => void }} [issue_stores]
18
- */
19
- export function createListSelectors(issue_stores = undefined) {
20
- // Sorting comparators are centralized in app/data/sort.js
21
-
22
- /**
23
- * Get entities for a subscription id with Issues List sort (priority asc → created asc).
24
- *
25
- * @param {string} client_id
26
- * @returns {IssueLite[]}
27
- */
28
- function selectIssuesFor(client_id) {
29
- if (!issue_stores || typeof issue_stores.snapshotFor !== 'function') {
30
- return [];
31
- }
32
- return issue_stores
33
- .snapshotFor(client_id)
34
- .slice()
35
- .sort(cmpPriorityThenCreated);
36
- }
37
-
38
- /**
39
- * Get entities for a Board column with column-specific sort.
40
- *
41
- * @param {string} client_id
42
- * @param {'ready'|'blocked'|'in_progress'|'closed'} mode
43
- * @returns {IssueLite[]}
44
- */
45
- function selectBoardColumn(client_id, mode) {
46
- const arr =
47
- issue_stores && issue_stores.snapshotFor
48
- ? issue_stores.snapshotFor(client_id).slice()
49
- : [];
50
- if (mode === 'in_progress') {
51
- arr.sort(cmpPriorityThenCreated);
52
- } else if (mode === 'closed') {
53
- arr.sort(cmpClosedDesc);
54
- } else {
55
- // ready/blocked share the same sort
56
- arr.sort(cmpPriorityThenCreated);
57
- }
58
- return arr;
59
- }
60
-
61
- /**
62
- * Get children for an epic subscribed as client id `epic:${id}`.
63
- * Sorted as Issues List (priority asc → created asc).
64
- *
65
- * @param {string} epic_id
66
- * @returns {IssueLite[]}
67
- */
68
- function selectEpicChildren(epic_id) {
69
- if (!issue_stores || typeof issue_stores.snapshotFor !== 'function') {
70
- return [];
71
- }
72
- // Epic detail subscription uses client id `detail:<id>` and contains the
73
- // epic entity with a `dependents` array. Render children from that list.
74
- const arr = /** @type {any[]} */ (
75
- issue_stores.snapshotFor(`detail:${epic_id}`) || []
76
- );
77
- const epic = arr.find((it) => String(it?.id || '') === String(epic_id));
78
- const dependents = Array.isArray(epic?.dependents) ? epic.dependents : [];
79
- return /** @type {IssueLite[]} */ (
80
- dependents.slice().sort(cmpPriorityThenCreated)
81
- );
82
- }
83
-
84
- /**
85
- * Subscribe for re-render; triggers once per issues envelope.
86
- *
87
- * @param {() => void} fn
88
- * @returns {() => void}
89
- */
90
- function subscribe(fn) {
91
- if (issue_stores && typeof issue_stores.subscribe === 'function') {
92
- return issue_stores.subscribe(fn);
93
- }
94
- return () => {};
95
- }
96
-
97
- return {
98
- selectIssuesFor,
99
- selectBoardColumn,
100
- selectEpicChildren,
101
- subscribe
102
- };
103
- }
@@ -1,78 +0,0 @@
1
- /**
2
- * @import { MessageType } from '../protocol.js'
3
- */
4
- /**
5
- * Data layer: typed wrappers around the ws transport for mutations and
6
- * single-issue fetch. List reads have been removed in favor of push-only
7
- * stores and selectors (see docs/adr/001-push-only-lists.md).
8
- *
9
- * @param {(type: MessageType, payload?: unknown) => Promise<unknown>} transport - Request/response function.
10
- * @returns {{ updateIssue: (input: { id: string, title?: string, acceptance?: string, notes?: string, design?: string, status?: 'open'|'in_progress'|'closed', priority?: number, assignee?: string }) => Promise<unknown> }}
11
- */
12
- export function createDataLayer(transport) {
13
- /**
14
- * Update issue fields by dispatching specific mutations.
15
- * Supported fields: title, acceptance, notes, design, status, priority, assignee.
16
- * Returns the updated issue on success.
17
- *
18
- * @param {{ id: string, title?: string, acceptance?: string, notes?: string, design?: string, status?: 'open'|'in_progress'|'closed', priority?: number, assignee?: string }} input
19
- * @returns {Promise<unknown>}
20
- */
21
- async function updateIssue(input) {
22
- const { id } = input;
23
- /** @type {unknown} */
24
- let last = null;
25
- if (typeof input.title === 'string') {
26
- last = await transport('edit-text', {
27
- id,
28
- field: 'title',
29
- value: input.title
30
- });
31
- }
32
- if (typeof input.acceptance === 'string') {
33
- last = await transport('edit-text', {
34
- id,
35
- field: 'acceptance',
36
- value: input.acceptance
37
- });
38
- }
39
- if (typeof input.notes === 'string') {
40
- last = await transport('edit-text', {
41
- id,
42
- field: 'notes',
43
- value: input.notes
44
- });
45
- }
46
- if (typeof input.design === 'string') {
47
- last = await transport('edit-text', {
48
- id,
49
- field: 'design',
50
- value: input.design
51
- });
52
- }
53
- if (typeof input.status === 'string') {
54
- last = await transport('update-status', {
55
- id,
56
- status: input.status
57
- });
58
- }
59
- if (typeof input.priority === 'number') {
60
- last = await transport('update-priority', {
61
- id,
62
- priority: input.priority
63
- });
64
- }
65
- // type updates are not supported via UI
66
- if (typeof input.assignee === 'string') {
67
- last = await transport('update-assignee', {
68
- id,
69
- assignee: input.assignee
70
- });
71
- }
72
- return last;
73
- }
74
-
75
- return {
76
- updateIssue
77
- };
78
- }
package/app/data/sort.js DELETED
@@ -1,47 +0,0 @@
1
- /**
2
- * Shared sort comparators for issues lists.
3
- * Centralizes sorting so views and stores stay consistent.
4
- */
5
-
6
- /**
7
- * @typedef {{ id: string, title?: string, status?: 'open'|'in_progress'|'closed', priority?: number, issue_type?: string, created_at?: number, updated_at?: number, closed_at?: number }} IssueLite
8
- */
9
-
10
- /**
11
- * Compare by priority asc, then created_at asc, then id asc.
12
- *
13
- * @param {IssueLite} a
14
- * @param {IssueLite} b
15
- */
16
- export function cmpPriorityThenCreated(a, b) {
17
- const pa = a.priority ?? 2;
18
- const pb = b.priority ?? 2;
19
- if (pa !== pb) {
20
- return pa - pb;
21
- }
22
- const ca = a.created_at ?? 0;
23
- const cb = b.created_at ?? 0;
24
- if (ca !== cb) {
25
- return ca < cb ? -1 : 1;
26
- }
27
- const ida = a.id;
28
- const idb = b.id;
29
- return ida < idb ? -1 : ida > idb ? 1 : 0;
30
- }
31
-
32
- /**
33
- * Compare by closed_at desc, then id asc for stability.
34
- *
35
- * @param {IssueLite} a
36
- * @param {IssueLite} b
37
- */
38
- export function cmpClosedDesc(a, b) {
39
- const ca = a.closed_at ?? 0;
40
- const cb = b.closed_at ?? 0;
41
- if (ca !== cb) {
42
- return ca < cb ? 1 : -1;
43
- }
44
- const ida = a?.id;
45
- const idb = b?.id;
46
- return ida < idb ? -1 : ida > idb ? 1 : 0;
47
- }
@@ -1,161 +0,0 @@
1
- /**
2
- * @import { SubscriptionIssueStore, SubscriptionIssueStoreOptions } from '../../types/subscription-issue-store.js'
3
- */
4
- import { cmpPriorityThenCreated } from './sort.js';
5
-
6
- /**
7
- * Per-subscription issue store. Holds full Issue objects and exposes a
8
- * deterministic, read-only snapshot for rendering. Applies snapshot/upsert/
9
- * delete messages in revision order and preserves object identity per id.
10
- */
11
-
12
- /**
13
- * Create a SubscriptionIssueStore for a given subscription id.
14
- *
15
- * @param {string} id
16
- * @param {SubscriptionIssueStoreOptions} [options]
17
- * @returns {SubscriptionIssueStore}
18
- */
19
- export function createSubscriptionIssueStore(id, options = {}) {
20
- /** @type {Map<string, any>} */
21
- const items_by_id = new Map();
22
- /** @type {any[]} */
23
- let ordered = [];
24
- /** @type {number} */
25
- let last_revision = 0;
26
- /** @type {Set<() => void>} */
27
- const listeners = new Set();
28
- /** @type {boolean} */
29
- let is_disposed = false;
30
- /** @type {(a:any,b:any)=>number} */
31
- const sort = options.sort || cmpPriorityThenCreated;
32
-
33
- function emit() {
34
- for (const fn of Array.from(listeners)) {
35
- try {
36
- fn();
37
- } catch {
38
- // ignore listener errors
39
- }
40
- }
41
- }
42
-
43
- function rebuildOrdered() {
44
- ordered = Array.from(items_by_id.values()).sort(sort);
45
- }
46
-
47
- /**
48
- * Apply snapshot/upsert/delete in revision order. Snapshots reset state.
49
- * - Ignore messages with revision <= last_revision (except snapshot which resets first).
50
- * - Preserve object identity when updating an existing item by mutating
51
- * fields in place rather than replacing the object reference.
52
- *
53
- * @param {{ type: 'snapshot'|'upsert'|'delete', id: string, revision: number, issues?: any[], issue?: any, issue_id?: string }} msg
54
- */
55
- function applyPush(msg) {
56
- if (is_disposed) {
57
- return;
58
- }
59
- if (!msg || msg.id !== id) {
60
- return;
61
- }
62
- const rev = Number(msg.revision) || 0;
63
- // Ignore stale messages for all types, including snapshots
64
- if (rev <= last_revision && msg.type !== 'snapshot') {
65
- return; // stale or duplicate non-snapshot
66
- }
67
- if (msg.type === 'snapshot') {
68
- if (rev <= last_revision) {
69
- return; // ignore stale snapshot
70
- }
71
- items_by_id.clear();
72
- const items = Array.isArray(msg.issues) ? msg.issues : [];
73
- for (const it of items) {
74
- if (it && typeof it.id === 'string' && it.id.length > 0) {
75
- items_by_id.set(it.id, it);
76
- }
77
- }
78
- rebuildOrdered();
79
- last_revision = rev;
80
- emit();
81
- return;
82
- }
83
- if (msg.type === 'upsert') {
84
- const it = msg.issue;
85
- if (it && typeof it.id === 'string' && it.id.length > 0) {
86
- const existing = items_by_id.get(it.id);
87
- if (!existing) {
88
- items_by_id.set(it.id, it);
89
- } else {
90
- // Guard with updated_at; prefer newer
91
- const prev_ts = Number.isFinite(existing.updated_at)
92
- ? /** @type {number} */ (existing.updated_at)
93
- : 0;
94
- const next_ts = Number.isFinite(it.updated_at)
95
- ? /** @type {number} */ (it.updated_at)
96
- : 0;
97
- if (prev_ts <= next_ts) {
98
- // Mutate existing object to preserve reference
99
- for (const k of Object.keys(existing)) {
100
- if (!(k in it)) {
101
- // remove keys that disappeared to avoid stale fields
102
- delete existing[k];
103
- }
104
- }
105
- for (const [k, v] of Object.entries(it)) {
106
- // @ts-ignore - dynamic assignment
107
- existing[k] = v;
108
- }
109
- } else {
110
- // stale by timestamp; ignore
111
- }
112
- }
113
- rebuildOrdered();
114
- }
115
- last_revision = rev;
116
- emit();
117
- } else if (msg.type === 'delete') {
118
- const rid = String(msg.issue_id || '');
119
- if (rid) {
120
- items_by_id.delete(rid);
121
- rebuildOrdered();
122
- }
123
- last_revision = rev;
124
- emit();
125
- }
126
- }
127
-
128
- return {
129
- id,
130
- /**
131
- * @param {() => void} fn
132
- */
133
- subscribe(fn) {
134
- listeners.add(fn);
135
- return () => {
136
- listeners.delete(fn);
137
- };
138
- },
139
- applyPush,
140
- snapshot() {
141
- // Return as read-only view; callers must not mutate
142
- return ordered;
143
- },
144
- size() {
145
- return items_by_id.size;
146
- },
147
- /**
148
- * @param {string} xid
149
- */
150
- getById(xid) {
151
- return items_by_id.get(xid);
152
- },
153
- dispose() {
154
- is_disposed = true;
155
- items_by_id.clear();
156
- ordered = [];
157
- listeners.clear();
158
- last_revision = 0;
159
- }
160
- };
161
- }
@@ -1,128 +0,0 @@
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
- export function createSubscriptionIssueStores() {
14
- /** @type {Map<string, ReturnType<typeof createSubscriptionIssueStore>>} */
15
- const stores_by_id = new Map();
16
- /** @type {Map<string, string>} */
17
- const key_by_id = new Map();
18
- /** @type {Set<() => void>} */
19
- const listeners = new Set();
20
- /** @type {Map<string, () => void>} */
21
- const store_unsubs = new Map();
22
-
23
- function emit() {
24
- for (const fn of Array.from(listeners)) {
25
- try {
26
- fn();
27
- } catch {
28
- // ignore
29
- }
30
- }
31
- }
32
-
33
- /**
34
- * Ensure a store exists for client_id and attach a listener that fans out
35
- * store-level updates to global listeners.
36
- *
37
- * @param {string} client_id
38
- * @param {{ type: string, params?: Record<string, string|number|boolean> }} [spec]
39
- * @param {SubscriptionIssueStoreOptions} [options]
40
- */
41
- function register(client_id, spec, options) {
42
- const next_key = spec ? subKeyOf(spec) : '';
43
- const prev_key = key_by_id.get(client_id) || '';
44
- const has_store = stores_by_id.has(client_id);
45
- // If the subscription spec changed for an existing client id, replace the
46
- // underlying store to reset revision state and avoid ignoring a fresh
47
- // snapshot with a lower revision (different server list).
48
- if (has_store && prev_key && next_key && prev_key !== next_key) {
49
- const prev_store = stores_by_id.get(client_id);
50
- if (prev_store) {
51
- try {
52
- prev_store.dispose();
53
- } catch {
54
- // ignore
55
- }
56
- }
57
- const off_prev = store_unsubs.get(client_id);
58
- if (off_prev) {
59
- try {
60
- off_prev();
61
- } catch {
62
- // ignore
63
- }
64
- store_unsubs.delete(client_id);
65
- }
66
- const new_store = createSubscriptionIssueStore(client_id, options);
67
- stores_by_id.set(client_id, new_store);
68
- const off_new = new_store.subscribe(() => emit());
69
- store_unsubs.set(client_id, off_new);
70
- } else if (!has_store) {
71
- const store = createSubscriptionIssueStore(client_id, options);
72
- stores_by_id.set(client_id, store);
73
- // Fan out per-store events to global subscribers
74
- const off = store.subscribe(() => emit());
75
- store_unsubs.set(client_id, off);
76
- }
77
- key_by_id.set(client_id, next_key);
78
- return () => unregister(client_id);
79
- }
80
-
81
- /**
82
- * @param {string} client_id
83
- */
84
- function unregister(client_id) {
85
- key_by_id.delete(client_id);
86
- const store = stores_by_id.get(client_id);
87
- if (store) {
88
- store.dispose();
89
- stores_by_id.delete(client_id);
90
- }
91
- const off = store_unsubs.get(client_id);
92
- if (off) {
93
- try {
94
- off();
95
- } catch {
96
- // ignore
97
- }
98
- store_unsubs.delete(client_id);
99
- }
100
- }
101
-
102
- return {
103
- register,
104
- unregister,
105
- /**
106
- * @param {string} client_id
107
- */
108
- getStore(client_id) {
109
- return stores_by_id.get(client_id) || null;
110
- },
111
- /**
112
- * @param {string} client_id
113
- * @returns {IssueLite[]}
114
- */
115
- snapshotFor(client_id) {
116
- const s = stores_by_id.get(client_id);
117
- return s ? /** @type {IssueLite[]} */ (s.snapshot().slice()) : [];
118
- },
119
- /**
120
- * @param {() => void} fn
121
- */
122
- subscribe(fn) {
123
- listeners.add(fn);
124
- return () => listeners.delete(fn);
125
- }
126
- // No recompute helpers in vNext; stores are updated directly via push
127
- };
128
- }