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
package/CHANGES.md CHANGED
@@ -1,12 +1,39 @@
1
1
  # Changes
2
2
 
3
+ ## 0.3.0
4
+
5
+ - 🍏 Rewrite data-exchange layer to push-only updates via WebSocket.
6
+ - πŸ› Heaps of bug fixes.
7
+
8
+ ## 0.2.0
9
+
10
+ - 🍏 Add "Blocked" column to board
11
+ - 🍏 Support `design` in issue details
12
+ - 🍏 Add filter to closed column and improve sorting
13
+ - 🍏 Unblock issue description editing
14
+ - 🍏 CLI: require --open to launch browser, also on restart
15
+ - 🍏 Up/down/left/right keyboard navigation on board
16
+ - 🍏 Up/down keyboard navigation on issues list
17
+ - 🍏 CLI: require --open to launch browser
18
+ - 🍏 Make issue notes editable
19
+ - 🍏 Show toast on disconnect/reconnect
20
+ - 🍏 Support creating a new issue via "New" dialog
21
+ - 🍏 Copy issue IDs to clipboard
22
+ - 🍏 Open issue details in dialog
23
+ - πŸ› Remove --limit 10 when fetching closed issues
24
+ - ✨ Events: coalesce issues-changed to avoid redundant full refresh
25
+ - ✨ Update issues
26
+ - ✨ Align callback function naming
27
+ - πŸ“š Improve README
28
+ - πŸ“š Add package description, homepage and repo
29
+
3
30
  ## 0.1.2
4
31
 
5
- - πŸ“¦ Specify files to package (Maximilian Antoni)
32
+ - πŸ“¦ Specify files to package
6
33
 
7
34
  ## 0.1.1
8
35
 
9
- - πŸ“š Make screenshot src absolute and add license (Maximilian Antoni)
36
+ - πŸ“š Make screenshot src absolute and add license
10
37
 
11
38
  ## 0.1.0
12
39
 
package/README.md CHANGED
@@ -1,64 +1,63 @@
1
- # beads-ui
1
+ <h1 align="center">
2
+ Beads UI
3
+ </h1>
4
+ <p align="center">
5
+ <b>Local‑first UI for the <code>bd</code> CLI – <a href="https://github.com/steveyegge/beads">Beads</a></b>
6
+ </p>
7
+ <div align="center">
8
+ <a href="https://www.npmjs.com/package/beads-ui"><img src="https://img.shields.io/npm/v/beads-ui.svg" alt="npm Version"></a>
9
+ <a href="https://semver.org"><img src="https://img.shields.io/:semver-%E2%9C%93-blue.svg" alt="SemVer"></a>
10
+ <a href="https://github.com/mantoni/beads-ui/actions/worflows/ci.yml"><img src="https://github.com/mantoni/eslint_d.js/actions/workflows/ci.yml/badge.svg" alt="Build Status"></a>
11
+ <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/npm/l/eslint_d.svg" alt="MIT License"></a>
12
+ <br>
13
+ <br>
14
+ </div>
2
15
 
3
- Local‑first UI for the `bd` CLI (beads) β€” a fast, dependency‑aware issue
4
- tracker.
16
+ ## Features
5
17
 
6
- beads-ui complements the upstream beads project by providing a single‑page web
7
- app served from a local Node.js server. It talks to `bd` over a local WebSocket
8
- to list issues, show details, and apply edits. All changes happen by executing
9
- `bd` commands, and live updates flow in as the database changes on disk.
18
+ - ✨ **Zero setup** – just run `bdui start`
19
+ - πŸ“Ί **Live updates** – Monitors the beads database for changes
20
+ - πŸ”Ž **Issues view** – Filter and search issues, edit inline
21
+ - ⛰️ **Epics view** – Show progress per epic, expand rows, edit inline
22
+ - πŸ‚ **Board view** – Open / Blocked / Ready / In progress / Closed columns
23
+ - ⌨️ **Keyboard navigation** – Navigate and edit without touching the mouse
10
24
 
11
- Upstream beads (CLI and docs): https://github.com/steveyegge/beads
25
+ ## Setup
12
26
 
13
- ## Features
27
+ ```sh
28
+ npm i beads-ui -g
29
+ # In the project directory with a beads database:
30
+ bdui start --open
31
+ ```
14
32
 
15
- - Issues list with inline edits, quick filters, and keyboard navigation
16
- - Epics view grouped by epic (from `bd epic status --json`) with expandable rows
17
- - Board view with Ready / In progress / Closed columns
18
- - Deep links for navigation; state persists across reloads
19
- - Live updates via FS watch + WebSocket; optimistic UI with rollbacks on error
20
- - Dark theme toggle, saved per user
21
- - Local CLI helper `bdui` to daemonize the server and open your browser
33
+ See `bdui --help` for options.
22
34
 
23
35
  ## Screenshots
24
36
 
25
- Issues
37
+ **Issues**
26
38
 
27
39
  ![Issues view](https://github.com/mantoni/beads-ui/raw/main/media/bdui-issues.png)
28
40
 
29
- Epics
41
+ **Epics**
30
42
 
31
43
  ![Epics view](https://github.com/mantoni/beads-ui/raw/main/media/bdui-epics.png)
32
44
 
33
- Board
45
+ **Board**
34
46
 
35
47
  ![Board view](https://github.com/mantoni/beads-ui/raw/main/media/bdui-board.png)
36
48
 
37
- ## Quickstart
38
-
39
- Prerequisites:
40
-
41
- - Node.js >= 22
42
- - `bd` CLI on your PATH (or set `BD_BIN=/path/to/bd`)
43
-
44
- Install and start:
45
-
46
- ```sh
47
- npm install -g beads-ui
48
- bdui start
49
- ```
50
-
51
- See `bdui --help` for options.
52
-
53
- Environment variables:
49
+ ## Environment variables
54
50
 
51
+ - `BD_BIN`: path to the `bd` binary.
55
52
  - `BDUI_RUNTIME_DIR`: override runtime directory for PID/logs. Defaults to
56
53
  `$XDG_RUNTIME_DIR/beads-ui` or the system temp dir.
57
- - `BDUI_NO_OPEN=1`: disable opening the default browser on `start`.
54
+ - `BDUI_NO_OPEN=1`: disable opening the default browser on `start`. Note:
55
+ Opening the browser is disabled by default; use `--open` to explicitly launch
56
+ the browser, which overrides this env var.
58
57
  - `PORT`: overrides the listen port (default `3000`). The server binds to
59
58
  `127.0.0.1`.
60
59
 
61
- Platform notes:
60
+ ## Platform notes
62
61
 
63
62
  - macOS/Linux are fully supported. On Windows, the CLI uses `cmd /c start` to
64
63
  open URLs and relies on Node’s `process.kill` semantics for stopping the
@@ -66,13 +65,8 @@ Platform notes:
66
65
 
67
66
  ## Developer Workflow
68
67
 
69
- - Type check: `npm run typecheck`
70
- - Tests: `npm test`
71
- - Lint: `npm run lint`
72
- - Format: `npm run format`
73
-
74
- See `docs/quickstart.md` for details and `docs/architecture.md` for the protocol
75
- and component overview.
68
+ - πŸ“¦ Make sure you have `beads-mcp` installed.
69
+ - πŸ€– Ask your agent of choice. It will know.
76
70
 
77
71
  ## License
78
72
 
@@ -0,0 +1,98 @@
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
+ * @param {{ snapshotFor?: (client_id: string) => IssueLite[], subscribe?: (fn: () => void) => () => void }} [issue_stores]
17
+ */
18
+ export function createListSelectors(issue_stores = undefined) {
19
+ // Sorting comparators are centralized in app/data/sort.js
20
+
21
+ /**
22
+ * Get entities for a subscription id with Issues List sort (priority asc β†’ created asc).
23
+ * @param {string} client_id
24
+ * @returns {IssueLite[]}
25
+ */
26
+ function selectIssuesFor(client_id) {
27
+ if (!issue_stores || typeof issue_stores.snapshotFor !== 'function') {
28
+ return [];
29
+ }
30
+ return issue_stores
31
+ .snapshotFor(client_id)
32
+ .slice()
33
+ .sort(cmpPriorityThenCreated);
34
+ }
35
+
36
+ /**
37
+ * Get entities for a Board column with column-specific sort.
38
+ * @param {string} client_id
39
+ * @param {'ready'|'blocked'|'in_progress'|'closed'} mode
40
+ * @returns {IssueLite[]}
41
+ */
42
+ function selectBoardColumn(client_id, mode) {
43
+ const arr =
44
+ issue_stores && issue_stores.snapshotFor
45
+ ? issue_stores.snapshotFor(client_id).slice()
46
+ : [];
47
+ if (mode === 'in_progress') {
48
+ arr.sort(cmpPriorityThenCreated);
49
+ } else if (mode === 'closed') {
50
+ arr.sort(cmpClosedDesc);
51
+ } else {
52
+ // ready/blocked share the same sort
53
+ arr.sort(cmpPriorityThenCreated);
54
+ }
55
+ return arr;
56
+ }
57
+
58
+ /**
59
+ * Get children for an epic subscribed as client id `epic:${id}`.
60
+ * Sorted as Issues List (priority asc β†’ created asc).
61
+ * @param {string} epic_id
62
+ * @returns {IssueLite[]}
63
+ */
64
+ function selectEpicChildren(epic_id) {
65
+ if (!issue_stores || typeof issue_stores.snapshotFor !== 'function') {
66
+ return [];
67
+ }
68
+ // Epic detail subscription uses client id `detail:<id>` and contains the
69
+ // epic entity with a `dependents` array. Render children from that list.
70
+ const arr = /** @type {any[]} */ (
71
+ issue_stores.snapshotFor(`detail:${epic_id}`) || []
72
+ );
73
+ const epic = arr.find((it) => String(it?.id || '') === String(epic_id));
74
+ const dependents = Array.isArray(epic?.dependents) ? epic.dependents : [];
75
+ return /** @type {IssueLite[]} */ (
76
+ dependents.slice().sort(cmpPriorityThenCreated)
77
+ );
78
+ }
79
+
80
+ /**
81
+ * Subscribe for re-render; triggers once per issues envelope.
82
+ * @param {() => void} fn
83
+ * @returns {() => void}
84
+ */
85
+ function subscribe(fn) {
86
+ if (issue_stores && typeof issue_stores.subscribe === 'function') {
87
+ return issue_stores.subscribe(fn);
88
+ }
89
+ return () => {};
90
+ }
91
+
92
+ return {
93
+ selectIssuesFor,
94
+ selectBoardColumn,
95
+ selectEpicChildren,
96
+ subscribe
97
+ };
98
+ }
@@ -1,129 +1,19 @@
1
1
  /**
2
- * Data layer: typed wrappers around the ws transport for bd-backed queries.
3
- * @param {(type: import('../protocol.js').MessageType, payload?: unknown) => Promise<unknown>} transport - Request/response function.
4
- * @param {(type: import('../protocol.js').MessageType, handler: (payload: unknown) => void) => void} [on_event] - Optional event subscription (used to invalidate caches on push updates).
5
- * @returns {{ getEpicStatus: () => Promise<unknown[]>, getReady: () => 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, status?: 'open'|'in_progress'|'closed', priority?: number, assignee?: string }) => Promise<unknown> }}
2
+ * @import { MessageType } from '../protocol.js'
6
3
  */
7
- export function createDataLayer(transport, on_event) {
8
- /** @type {{ list_ready?: unknown, list_open?: unknown, list_in_progress?: unknown, list_closed_10?: unknown, epic_status?: unknown }} */
9
- const cache = {};
10
-
11
- // Invalidate caches on server push updates when available
12
- if (on_event) {
13
- try {
14
- on_event('issues-changed', () => {
15
- cache.list_ready = undefined;
16
- cache.list_open = undefined;
17
- cache.list_in_progress = undefined;
18
- cache.list_closed_10 = undefined;
19
- cache.epic_status = undefined;
20
- });
21
- } catch {
22
- // noop
23
- }
24
- }
25
-
26
- /**
27
- * Get epic status groups via `bd epic status --json`.
28
- * @returns {Promise<unknown[]>}
29
- */
30
- async function getEpicStatus() {
31
- if (Array.isArray(cache.epic_status)) {
32
- return /** @type {unknown[]} */ (cache.epic_status);
33
- }
34
- /** @type {unknown} */
35
- const res = await transport('epic-status');
36
- const arr = Array.isArray(res) ? res : [];
37
- cache.epic_status = arr;
38
- return arr;
39
- }
40
-
41
- /**
42
- * Ready issues: `bd ready --json`.
43
- * Sort by priority then updated_at on the UI; transport returns raw list.
44
- * @returns {Promise<unknown[]>}
45
- */
46
- async function getReady() {
47
- if (Array.isArray(cache.list_ready)) {
48
- return /** @type {unknown[]} */ (cache.list_ready);
49
- }
50
- /** @type {unknown} */
51
- const res = await transport('list-issues', { filters: { ready: true } });
52
- const arr = Array.isArray(res) ? res : [];
53
- cache.list_ready = arr;
54
- return arr;
55
- }
56
-
57
- /**
58
- * Open issues: `bd list -s open --json`.
59
- * @returns {Promise<unknown[]>}
60
- */
61
- async function getOpen() {
62
- if (Array.isArray(cache.list_open)) {
63
- return /** @type {unknown[]} */ (cache.list_open);
64
- }
65
- /** @type {unknown} */
66
- const res = await transport('list-issues', {
67
- filters: { status: 'open' }
68
- });
69
- const arr = Array.isArray(res) ? res : [];
70
- cache.list_open = arr;
71
- return arr;
72
- }
73
-
74
- /**
75
- * In progress issues: `bd list -s in_progress --json`.
76
- * @returns {Promise<unknown[]>}
77
- */
78
- async function getInProgress() {
79
- if (Array.isArray(cache.list_in_progress)) {
80
- return /** @type {unknown[]} */ (cache.list_in_progress);
81
- }
82
- /** @type {unknown} */
83
- const res = await transport('list-issues', {
84
- filters: { status: 'in_progress' }
85
- });
86
- const arr = Array.isArray(res) ? res : [];
87
- cache.list_in_progress = arr;
88
- return arr;
89
- }
90
-
91
- /**
92
- * Closed issues: `bd list -s closed -l 10 --json`.
93
- * @param {number} [limit] - Optional limit (defaults to 10).
94
- * @returns {Promise<unknown[]>}
95
- */
96
- async function getClosed(limit = 10) {
97
- if (limit === 10 && Array.isArray(cache.list_closed_10)) {
98
- return /** @type {unknown[]} */ (cache.list_closed_10);
99
- }
100
- /** @type {unknown} */
101
- const res = await transport('list-issues', {
102
- filters: { status: 'closed', limit }
103
- });
104
- const arr = Array.isArray(res) ? res : [];
105
- if (limit === 10) {
106
- cache.list_closed_10 = arr;
107
- }
108
- return arr;
109
- }
110
-
111
- /**
112
- * Show a single issue via `bd show <id> --json`.
113
- * @param {string} id
114
- * @returns {Promise<unknown>}
115
- */
116
- async function getIssue(id) {
117
- /** @type {unknown} */
118
- const res = await transport('show-issue', { id });
119
- return res;
120
- }
121
-
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
+ * @param {(type: MessageType, payload?: unknown) => Promise<unknown>} transport - Request/response function.
9
+ * @returns {{ updateIssue: (input: { id: string, title?: string, acceptance?: string, notes?: string, design?: string, status?: 'open'|'in_progress'|'closed', priority?: number, assignee?: string }) => Promise<unknown> }}
10
+ */
11
+ export function createDataLayer(transport) {
122
12
  /**
123
13
  * Update issue fields by dispatching specific mutations.
124
- * Supported fields: title, acceptance, status, priority, assignee.
14
+ * Supported fields: title, acceptance, notes, design, status, priority, assignee.
125
15
  * Returns the updated issue on success.
126
- * @param {{ id: string, title?: string, acceptance?: string, status?: 'open'|'in_progress'|'closed', priority?: number, assignee?: string }} input
16
+ * @param {{ id: string, title?: string, acceptance?: string, notes?: string, design?: string, status?: 'open'|'in_progress'|'closed', priority?: number, assignee?: string }} input
127
17
  * @returns {Promise<unknown>}
128
18
  */
129
19
  async function updateIssue(input) {
@@ -144,6 +34,20 @@ export function createDataLayer(transport, on_event) {
144
34
  value: input.acceptance
145
35
  });
146
36
  }
37
+ if (typeof input.notes === 'string') {
38
+ last = await transport('edit-text', {
39
+ id,
40
+ field: 'notes',
41
+ value: input.notes
42
+ });
43
+ }
44
+ if (typeof input.design === 'string') {
45
+ last = await transport('edit-text', {
46
+ id,
47
+ field: 'design',
48
+ value: input.design
49
+ });
50
+ }
147
51
  if (typeof input.status === 'string') {
148
52
  last = await transport('update-status', {
149
53
  id,
@@ -167,12 +71,6 @@ export function createDataLayer(transport, on_event) {
167
71
  }
168
72
 
169
73
  return {
170
- getEpicStatus,
171
- getReady,
172
- getOpen,
173
- getInProgress,
174
- getClosed,
175
- getIssue,
176
74
  updateIssue
177
75
  };
178
76
  }
@@ -0,0 +1,45 @@
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
+ * @param {IssueLite} a
13
+ * @param {IssueLite} b
14
+ */
15
+ export function cmpPriorityThenCreated(a, b) {
16
+ const pa = a.priority ?? 2;
17
+ const pb = b.priority ?? 2;
18
+ if (pa !== pb) {
19
+ return pa - pb;
20
+ }
21
+ const ca = a.created_at ?? 0;
22
+ const cb = b.created_at ?? 0;
23
+ if (ca !== cb) {
24
+ return ca < cb ? -1 : 1;
25
+ }
26
+ const ida = a.id;
27
+ const idb = b.id;
28
+ return ida < idb ? -1 : ida > idb ? 1 : 0;
29
+ }
30
+
31
+ /**
32
+ * Compare by closed_at desc, then id asc for stability.
33
+ * @param {IssueLite} a
34
+ * @param {IssueLite} b
35
+ */
36
+ export function cmpClosedDesc(a, b) {
37
+ const ca = a.closed_at ?? 0;
38
+ const cb = b.closed_at ?? 0;
39
+ if (ca !== cb) {
40
+ return ca < cb ? 1 : -1;
41
+ }
42
+ const ida = a?.id;
43
+ const idb = b?.id;
44
+ return ida < idb ? -1 : ida > idb ? 1 : 0;
45
+ }
@@ -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
+ // Sort comparator is centralized in app/data/sort.js
13
+
14
+ /**
15
+ * Create a SubscriptionIssueStore for a given subscription id.
16
+ * @param {string} id
17
+ * @param {SubscriptionIssueStoreOptions} [options]
18
+ * @returns {SubscriptionIssueStore}
19
+ */
20
+ export function createSubscriptionIssueStore(id, options = {}) {
21
+ /** @type {Map<string, any>} */
22
+ const items_by_id = new Map();
23
+ /** @type {any[]} */
24
+ let ordered = [];
25
+ /** @type {number} */
26
+ let last_revision = 0;
27
+ /** @type {Set<() => void>} */
28
+ const listeners = new Set();
29
+ /** @type {boolean} */
30
+ let is_disposed = false;
31
+ /** @type {(a:any,b:any)=>number} */
32
+ const sort = options.sort || cmpPriorityThenCreated;
33
+
34
+ function emit() {
35
+ for (const fn of Array.from(listeners)) {
36
+ try {
37
+ fn();
38
+ } catch {
39
+ // ignore listener errors
40
+ }
41
+ }
42
+ }
43
+
44
+ function rebuildOrdered() {
45
+ ordered = Array.from(items_by_id.values()).sort(sort);
46
+ }
47
+
48
+ /**
49
+ * Apply snapshot/upsert/delete in revision order. Snapshots reset state.
50
+ * - Ignore messages with revision <= last_revision (except snapshot which resets first).
51
+ * - Preserve object identity when updating an existing item by mutating
52
+ * fields in place rather than replacing the object reference.
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
+ }