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
package/CHANGES.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changes
2
2
 
3
+ ## 0.3.1
4
+
5
+ - [`3912ae5`](https://github.com/mantoni/beads-ui/commit/3912ae552b1cc97e61fbaaa0815ca77675c542e4)
6
+ Status filter intermittently not applied on Issues screen
7
+ - [`a160484`](https://github.com/mantoni/beads-ui/commit/a16048479d1d7d61ed4ad4e53365a5736eb053af)
8
+ Upgrade eslint-plugin-jsdoc and switch config
9
+
10
+ _Released by [Maximilian Antoni](https://github.com/mantoni) on 2025-10-27._
11
+
12
+ ## 0.3.0
13
+
14
+ - 🍏 Rewrite data-exchange layer to push-only updates via WebSocket.
15
+ - 🐛 Heaps of bug fixes.
16
+
3
17
  ## 0.2.0
4
18
 
5
19
  - 🍏 Add "Blocked" column to board
package/README.md CHANGED
@@ -16,17 +16,17 @@
16
16
  ## Features
17
17
 
18
18
  - ✨ **Zero setup** – just run `bdui start`
19
- - 🎨 **Beautiful design** – Responsive and dark mode support
20
- - ⌨️ **Keyboard navigation** – Navigate and edit without touching the mouse
21
- - ⚡ **Live updates** – Monitors the beads database for changes
19
+ - 📺 **Live updates** – Monitors the beads database for changes
22
20
  - 🔎 **Issues view** – Filter and search issues, edit inline
23
21
  - ⛰️ **Epics view** – Show progress per epic, expand rows, edit inline
24
22
  - 🏂 **Board view** – Open / Blocked / Ready / In progress / Closed columns
23
+ - ⌨️ **Keyboard navigation** – Navigate and edit without touching the mouse
25
24
 
26
25
  ## Setup
27
26
 
28
27
  ```sh
29
- npm i -g beads-ui
28
+ npm i beads-ui -g
29
+ # In the project directory with a beads database:
30
30
  bdui start --open
31
31
  ```
32
32
 
@@ -0,0 +1,103 @@
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
+ }
@@ -2,143 +2,19 @@
2
2
  * @import { MessageType } from '../protocol.js'
3
3
  */
4
4
  /**
5
- * Data layer: typed wrappers around the ws transport for bd-backed queries.
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
+ *
6
9
  * @param {(type: MessageType, payload?: unknown) => Promise<unknown>} transport - Request/response function.
7
- * @param {(type: MessageType, handler: (payload: unknown) => void) => void} [onEvent] - Optional event subscription (used to invalidate caches on push updates).
8
- * @returns {{ getEpicStatus: () => Promise<unknown[]>, getReady: () => Promise<unknown[]>, getBlocked: () => Promise<unknown[]>, getOpen: () => Promise<unknown[]>, getInProgress: () => Promise<unknown[]>, getClosed: (limit?: number) => Promise<unknown[]>, getIssue: (id: string) => Promise<unknown>, updateIssue: (input: { id: string, title?: string, acceptance?: string, notes?: string, design?: string, status?: 'open'|'in_progress'|'closed', priority?: number, assignee?: string }) => Promise<unknown> }}
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> }}
9
11
  */
10
- export function createDataLayer(transport, onEvent) {
11
- /** @type {{ list_ready?: unknown, list_blocked?: unknown, list_open?: unknown, list_in_progress?: unknown, list_closed_10?: unknown, epic_status?: unknown }} */
12
- const cache = {};
13
-
14
- // Invalidate caches on server push updates when available
15
- if (onEvent) {
16
- try {
17
- onEvent('issues-changed', () => {
18
- cache.list_ready = undefined;
19
- cache.list_blocked = undefined;
20
- cache.list_open = undefined;
21
- cache.list_in_progress = undefined;
22
- cache.list_closed_10 = undefined;
23
- cache.epic_status = undefined;
24
- });
25
- } catch {
26
- // noop
27
- }
28
- }
29
-
30
- /**
31
- * Get epic status groups via `bd epic status --json`.
32
- * @returns {Promise<unknown[]>}
33
- */
34
- async function getEpicStatus() {
35
- if (Array.isArray(cache.epic_status)) {
36
- return cache.epic_status;
37
- }
38
- const res = await transport('epic-status');
39
- const arr = Array.isArray(res) ? res : [];
40
- cache.epic_status = arr;
41
- return arr;
42
- }
43
-
44
- /**
45
- * Ready issues: `bd ready --json`.
46
- * Sort by priority then updated_at on the UI; transport returns raw list.
47
- * @returns {Promise<unknown[]>}
48
- */
49
- async function getReady() {
50
- if (Array.isArray(cache.list_ready)) {
51
- return cache.list_ready;
52
- }
53
- /** @type {unknown} */
54
- const res = await transport('list-issues', { filters: { ready: true } });
55
- const arr = Array.isArray(res) ? res : [];
56
- cache.list_ready = arr;
57
- return arr;
58
- }
59
-
60
- /**
61
- * Blocked issues: `bd blocked --json`.
62
- * Sort by priority then updated_at on the UI; transport returns raw list.
63
- * @returns {Promise<unknown[]>}
64
- */
65
- async function getBlocked() {
66
- if (Array.isArray(cache.list_blocked)) {
67
- return cache.list_blocked;
68
- }
69
- /** @type {unknown} */
70
- const res = await transport('list-issues', { filters: { blocked: true } });
71
- const arr = Array.isArray(res) ? res : [];
72
- cache.list_blocked = arr;
73
- return arr;
74
- }
75
-
76
- /**
77
- * Open issues: `bd list -s open --json`.
78
- * @returns {Promise<unknown[]>}
79
- */
80
- async function getOpen() {
81
- if (Array.isArray(cache.list_open)) {
82
- return cache.list_open;
83
- }
84
- const res = await transport('list-issues', {
85
- filters: { status: 'open' }
86
- });
87
- const arr = Array.isArray(res) ? res : [];
88
- cache.list_open = arr;
89
- return arr;
90
- }
91
-
92
- /**
93
- * In progress issues: `bd list -s in_progress --json`.
94
- * @returns {Promise<unknown[]>}
95
- */
96
- async function getInProgress() {
97
- if (Array.isArray(cache.list_in_progress)) {
98
- return cache.list_in_progress;
99
- }
100
- const res = await transport('list-issues', {
101
- filters: { status: 'in_progress' }
102
- });
103
- const arr = Array.isArray(res) ? res : [];
104
- cache.list_in_progress = arr;
105
- return arr;
106
- }
107
-
108
- /**
109
- * Closed issues: `bd list --status closed --json`.
110
- * Note: Do not send a `limit` for closed. The board applies a timeframe
111
- * filter (today/3/7 days) client-side and needs the full closed set.
112
- * @returns {Promise<unknown[]>}
113
- */
114
- async function getClosed() {
115
- if (Array.isArray(cache.list_closed_10)) {
116
- // Reuse existing cache slot for closed list to avoid widening cache API
117
- return cache.list_closed_10;
118
- }
119
- const res = await transport('list-issues', {
120
- filters: { status: 'closed' }
121
- });
122
- const arr = Array.isArray(res) ? res : [];
123
- cache.list_closed_10 = arr;
124
- return arr;
125
- }
126
-
127
- /**
128
- * Show a single issue via `bd show <id> --json`.
129
- * @param {string} id
130
- * @returns {Promise<unknown>}
131
- */
132
- async function getIssue(id) {
133
- /** @type {unknown} */
134
- const res = await transport('show-issue', { id });
135
- return res;
136
- }
137
-
12
+ export function createDataLayer(transport) {
138
13
  /**
139
14
  * Update issue fields by dispatching specific mutations.
140
15
  * Supported fields: title, acceptance, notes, design, status, priority, assignee.
141
16
  * Returns the updated issue on success.
17
+ *
142
18
  * @param {{ id: string, title?: string, acceptance?: string, notes?: string, design?: string, status?: 'open'|'in_progress'|'closed', priority?: number, assignee?: string }} input
143
19
  * @returns {Promise<unknown>}
144
20
  */
@@ -197,13 +73,6 @@ export function createDataLayer(transport, onEvent) {
197
73
  }
198
74
 
199
75
  return {
200
- getEpicStatus,
201
- getReady,
202
- getBlocked,
203
- getOpen,
204
- getInProgress,
205
- getClosed,
206
- getIssue,
207
76
  updateIssue
208
77
  };
209
78
  }
@@ -0,0 +1,47 @@
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
+ }
@@ -0,0 +1,161 @@
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
+ }
@@ -0,0 +1,128 @@
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
+ }