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.
- package/CHANGES.md +14 -0
- package/README.md +4 -4
- package/app/data/list-selectors.js +103 -0
- package/app/data/providers.js +7 -138
- package/app/data/sort.js +47 -0
- package/app/data/subscription-issue-store.js +161 -0
- package/app/data/subscription-issue-stores.js +128 -0
- package/app/data/subscriptions-store.js +227 -0
- package/app/main.js +346 -66
- package/app/protocol.js +23 -17
- package/app/protocol.md +18 -15
- package/app/router.js +3 -0
- package/app/state.js +2 -0
- package/app/styles.css +222 -197
- package/app/utils/issue-id-renderer.js +2 -1
- package/app/utils/issue-id.js +1 -0
- package/app/utils/issue-type.js +2 -0
- package/app/utils/issue-url.js +1 -0
- package/app/utils/markdown.js +13 -198
- package/app/utils/priority-badge.js +1 -2
- package/app/utils/status-badge.js +1 -1
- package/app/utils/status.js +2 -0
- package/app/utils/toast.js +1 -1
- package/app/utils/type-badge.js +1 -3
- package/app/views/board.js +172 -148
- package/app/views/detail.js +79 -66
- package/app/views/epics.js +127 -74
- package/app/views/issue-dialog.js +9 -15
- package/app/views/issue-row.js +2 -3
- package/app/views/list.js +105 -104
- package/app/views/nav.js +1 -0
- package/app/views/new-issue-dialog.js +30 -34
- package/app/ws.js +10 -10
- package/bin/bdui.js +1 -1
- package/docs/adr/001-push-only-lists.md +134 -0
- package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +200 -0
- package/docs/architecture.md +34 -84
- package/docs/data-exchange-subscription-plan.md +198 -0
- package/docs/db-watching.md +2 -1
- package/docs/migration-v2.md +54 -0
- package/docs/protocol/issues-push-v2.md +179 -0
- package/docs/subscription-issue-store.md +112 -0
- package/package.json +5 -4
- package/server/app.js +2 -0
- package/server/bd.js +4 -2
- package/server/cli/commands.js +5 -2
- package/server/cli/daemon.js +19 -5
- package/server/cli/index.js +2 -2
- package/server/cli/open.js +3 -0
- package/server/cli/usage.js +2 -1
- package/server/config.js +13 -6
- package/server/db.js +3 -1
- package/server/index.js +9 -5
- package/server/list-adapters.js +224 -0
- package/server/subscriptions.js +289 -0
- package/server/validators.js +113 -0
- package/server/watcher.js +8 -8
- 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
|
-
-
|
|
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 -
|
|
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
|
+
}
|
package/app/data/providers.js
CHANGED
|
@@ -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
|
|
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
|
-
* @
|
|
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
|
|
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
|
}
|
package/app/data/sort.js
ADDED
|
@@ -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
|
+
}
|