beads-ui 0.2.0 → 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.
- package/CHANGES.md +5 -0
- package/README.md +4 -4
- package/app/data/list-selectors.js +98 -0
- package/app/data/providers.js +5 -138
- package/app/data/sort.js +45 -0
- package/app/data/subscription-issue-store.js +161 -0
- package/app/data/subscription-issue-stores.js +102 -0
- package/app/data/subscriptions-store.js +219 -0
- package/app/main.js +342 -66
- package/app/protocol.js +10 -14
- package/app/protocol.md +18 -15
- package/app/styles.css +222 -197
- package/app/utils/markdown.js +15 -194
- package/app/utils/priority-badge.js +0 -2
- package/app/utils/status-badge.js +0 -1
- package/app/utils/toast.js +0 -1
- package/app/utils/type-badge.js +0 -3
- package/app/views/board.js +166 -144
- package/app/views/detail.js +76 -66
- package/app/views/epics.js +126 -74
- package/app/views/issue-dialog.js +8 -15
- package/app/views/issue-row.js +1 -3
- package/app/views/list.js +101 -104
- package/app/views/new-issue-dialog.js +27 -34
- package/app/ws.js +6 -9
- 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 +4 -2
- package/server/bd.js +0 -2
- package/server/cli/commands.js +1 -2
- package/server/cli/daemon.js +12 -5
- package/server/cli/index.js +0 -2
- package/server/cli/usage.js +1 -1
- package/server/config.js +12 -6
- package/server/db.js +0 -1
- package/server/index.js +9 -5
- package/server/list-adapters.js +218 -0
- package/server/subscriptions.js +277 -0
- package/server/validators.js +111 -0
- package/server/watcher.js +5 -8
- package/server/ws.js +449 -230
package/server/ws.js
CHANGED
|
@@ -5,17 +5,175 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { WebSocketServer } from 'ws';
|
|
7
7
|
import { runBd, runBdJson } from './bd.js';
|
|
8
|
+
import { fetchListForSubscription } from './list-adapters.js';
|
|
8
9
|
import { isRequest, makeError, makeOk } from './protocol.js';
|
|
10
|
+
import { keyOf, registry } from './subscriptions.js';
|
|
11
|
+
import { validateSubscribeListPayload } from './validators.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Debounced refresh scheduling for active list subscriptions.
|
|
15
|
+
* A trailing window coalesces rapid change bursts into a single refresh run.
|
|
16
|
+
*/
|
|
17
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
18
|
+
let REFRESH_TIMER = null;
|
|
19
|
+
let REFRESH_DEBOUNCE_MS = 75;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Mutation refresh window gate. When active, watcher-driven list refresh
|
|
23
|
+
* scheduling is suppressed. The gate resolves either when a watcher event
|
|
24
|
+
* arrives (via scheduleListRefresh) or when a timeout elapses, at which
|
|
25
|
+
* point a single refresh pass over all active list subscriptions is run.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {Object} MutationGate
|
|
29
|
+
* @property {boolean} resolved
|
|
30
|
+
* @property {(reason: 'watcher'|'timeout') => void} resolve
|
|
31
|
+
* @property {ReturnType<typeof setTimeout>} timer
|
|
32
|
+
*/
|
|
33
|
+
/** @type {MutationGate | null} */
|
|
34
|
+
let MUTATION_GATE = null;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Start a mutation window gate if not already active. The gate resolves on the
|
|
38
|
+
* next watcher event or after `timeout_ms`, then triggers a single refresh run
|
|
39
|
+
* across all active list subscriptions. Watcher-driven refresh scheduling is
|
|
40
|
+
* suppressed during the window.
|
|
41
|
+
*
|
|
42
|
+
* Fire-and-forget; callers should not await this.
|
|
43
|
+
* @param {number} [timeout_ms]
|
|
44
|
+
*/
|
|
45
|
+
function triggerMutationRefreshOnce(timeout_ms = 500) {
|
|
46
|
+
if (MUTATION_GATE) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
/** @type {(r: 'watcher'|'timeout') => void} */
|
|
50
|
+
let doResolve = () => {};
|
|
51
|
+
const p = new Promise((resolve) => {
|
|
52
|
+
doResolve = resolve;
|
|
53
|
+
});
|
|
54
|
+
MUTATION_GATE = {
|
|
55
|
+
resolved: false,
|
|
56
|
+
resolve: (reason) => {
|
|
57
|
+
if (!MUTATION_GATE || MUTATION_GATE.resolved) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
MUTATION_GATE.resolved = true;
|
|
61
|
+
try {
|
|
62
|
+
doResolve(reason);
|
|
63
|
+
} catch {
|
|
64
|
+
// ignore resolve errors
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
timer: setTimeout(() => {
|
|
68
|
+
try {
|
|
69
|
+
MUTATION_GATE?.resolve('timeout');
|
|
70
|
+
} catch {
|
|
71
|
+
// ignore
|
|
72
|
+
}
|
|
73
|
+
}, timeout_ms)
|
|
74
|
+
};
|
|
75
|
+
MUTATION_GATE.timer.unref?.();
|
|
76
|
+
|
|
77
|
+
// After resolution, run a single refresh across active subs and clear gate
|
|
78
|
+
void p.then(async () => {
|
|
79
|
+
try {
|
|
80
|
+
await refreshAllActiveListSubscriptions();
|
|
81
|
+
} catch {
|
|
82
|
+
// ignore refresh errors
|
|
83
|
+
} finally {
|
|
84
|
+
try {
|
|
85
|
+
if (MUTATION_GATE?.timer) {
|
|
86
|
+
clearTimeout(MUTATION_GATE.timer);
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// ignore
|
|
90
|
+
}
|
|
91
|
+
MUTATION_GATE = null;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Collect unique active list subscription specs across all connected clients.
|
|
98
|
+
* @returns {Array<{ type: string, params?: Record<string,string|number|boolean> }>}
|
|
99
|
+
*/
|
|
100
|
+
function collectActiveListSpecs() {
|
|
101
|
+
/** @type {Array<{ type: string, params?: Record<string,string|number|boolean> }>} */
|
|
102
|
+
const specs = [];
|
|
103
|
+
/** @type {Set<string>} */
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
const wss = CURRENT_WSS;
|
|
106
|
+
if (!wss) {
|
|
107
|
+
return specs;
|
|
108
|
+
}
|
|
109
|
+
for (const ws of wss.clients) {
|
|
110
|
+
if (ws.readyState !== ws.OPEN) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const s = ensureSubs(/** @type {any} */ (ws));
|
|
114
|
+
if (!s.list_subs) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
for (const { key, spec } of s.list_subs.values()) {
|
|
118
|
+
if (!seen.has(key)) {
|
|
119
|
+
seen.add(key);
|
|
120
|
+
specs.push(spec);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return specs;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Run refresh for all active list subscription specs and publish deltas.
|
|
129
|
+
*/
|
|
130
|
+
async function refreshAllActiveListSubscriptions() {
|
|
131
|
+
const specs = collectActiveListSpecs();
|
|
132
|
+
// Run refreshes concurrently; locking is handled per key in the registry
|
|
133
|
+
await Promise.all(
|
|
134
|
+
specs.map(async (spec) => {
|
|
135
|
+
try {
|
|
136
|
+
await refreshAndPublish(spec);
|
|
137
|
+
} catch {
|
|
138
|
+
// ignore refresh errors per spec
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Schedule a coalesced refresh of all active list subscriptions.
|
|
146
|
+
*/
|
|
147
|
+
export function scheduleListRefresh() {
|
|
148
|
+
// Suppress watcher-driven refreshes during an active mutation gate; resolve gate once
|
|
149
|
+
if (MUTATION_GATE) {
|
|
150
|
+
try {
|
|
151
|
+
MUTATION_GATE.resolve('watcher');
|
|
152
|
+
} catch {
|
|
153
|
+
// ignore
|
|
154
|
+
}
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (REFRESH_TIMER) {
|
|
158
|
+
clearTimeout(REFRESH_TIMER);
|
|
159
|
+
}
|
|
160
|
+
REFRESH_TIMER = setTimeout(() => {
|
|
161
|
+
REFRESH_TIMER = null;
|
|
162
|
+
// Fire and forget; callers don't await scheduling
|
|
163
|
+
void refreshAllActiveListSubscriptions();
|
|
164
|
+
}, REFRESH_DEBOUNCE_MS);
|
|
165
|
+
REFRESH_TIMER.unref?.();
|
|
166
|
+
}
|
|
9
167
|
|
|
10
168
|
/**
|
|
11
169
|
* @typedef {{
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
170
|
+
* show_id?: string | null,
|
|
171
|
+
* list_subs?: Map<string, { key: string, spec: { type: string, params?: Record<string, string | number | boolean> } }>,
|
|
172
|
+
* list_revisions?: Map<string, number>
|
|
15
173
|
* }} ConnectionSubs
|
|
16
174
|
*/
|
|
17
175
|
|
|
18
|
-
/** @type {WeakMap<WebSocket,
|
|
176
|
+
/** @type {WeakMap<WebSocket, any>} */
|
|
19
177
|
const SUBS = new WeakMap();
|
|
20
178
|
|
|
21
179
|
/** @type {WebSocketServer | null} */
|
|
@@ -24,117 +182,224 @@ let CURRENT_WSS = null;
|
|
|
24
182
|
/**
|
|
25
183
|
* Get or initialize the subscription state for a socket.
|
|
26
184
|
* @param {WebSocket} ws
|
|
27
|
-
* @returns {
|
|
185
|
+
* @returns {any}
|
|
28
186
|
*/
|
|
29
|
-
function
|
|
187
|
+
function ensureSubs(ws) {
|
|
30
188
|
let s = SUBS.get(ws);
|
|
31
189
|
if (!s) {
|
|
32
|
-
s = {
|
|
190
|
+
s = {
|
|
191
|
+
show_id: null,
|
|
192
|
+
list_subs: new Map(),
|
|
193
|
+
list_revisions: new Map()
|
|
194
|
+
};
|
|
33
195
|
SUBS.set(ws, s);
|
|
34
196
|
}
|
|
35
197
|
return s;
|
|
36
198
|
}
|
|
37
199
|
|
|
38
200
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* last list filter likely includes the issue (status match or ready=true).
|
|
43
|
-
* - If only `hint` is provided, but contains ids, send to clients that show one of those ids.
|
|
44
|
-
* - Otherwise, send to all open clients.
|
|
45
|
-
* @param {{ ts?: number, hint?: { ids?: string[] } }} payload
|
|
46
|
-
* @param {{ issue?: any }} [options]
|
|
201
|
+
* Get next monotonically increasing revision for a subscription key on this connection.
|
|
202
|
+
* @param {WebSocket} ws
|
|
203
|
+
* @param {string} key
|
|
47
204
|
*/
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
205
|
+
/**
|
|
206
|
+
* @param {WebSocket} ws
|
|
207
|
+
* @param {string} key
|
|
208
|
+
*/
|
|
209
|
+
function nextListRevision(ws, key) {
|
|
210
|
+
const s = ensureSubs(ws);
|
|
211
|
+
const m = s.list_revisions || new Map();
|
|
212
|
+
s.list_revisions = m;
|
|
213
|
+
const prev = m.get(key) || 0;
|
|
214
|
+
const next = prev + 1;
|
|
215
|
+
m.set(key, next);
|
|
216
|
+
return next;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Emit per-subscription envelopes to a specific client id on a socket.
|
|
221
|
+
* Helpers for snapshot / upsert / delete.
|
|
222
|
+
*/
|
|
223
|
+
/**
|
|
224
|
+
* @param {WebSocket} ws
|
|
225
|
+
* @param {string} client_id
|
|
226
|
+
* @param {string} key
|
|
227
|
+
* @param {Array<Record<string, unknown>>} issues
|
|
228
|
+
*/
|
|
229
|
+
function emitSubscriptionSnapshot(ws, client_id, key, issues) {
|
|
230
|
+
const revision = nextListRevision(ws, key);
|
|
231
|
+
const payload = {
|
|
232
|
+
type: /** @type {const} */ ('snapshot'),
|
|
233
|
+
id: client_id,
|
|
234
|
+
revision,
|
|
235
|
+
issues
|
|
236
|
+
};
|
|
237
|
+
const msg = JSON.stringify({
|
|
238
|
+
id: `evt-${Date.now()}`,
|
|
239
|
+
ok: true,
|
|
240
|
+
type: /** @type {MessageType} */ ('snapshot'),
|
|
241
|
+
payload
|
|
242
|
+
});
|
|
243
|
+
try {
|
|
244
|
+
ws.send(msg);
|
|
245
|
+
} catch {
|
|
246
|
+
// ignore per-socket errors
|
|
52
247
|
}
|
|
53
|
-
|
|
54
|
-
const recipients = new Set();
|
|
248
|
+
}
|
|
55
249
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
250
|
+
/**
|
|
251
|
+
* @param {WebSocket} ws
|
|
252
|
+
* @param {string} client_id
|
|
253
|
+
* @param {string} key
|
|
254
|
+
* @param {Record<string, unknown>} issue
|
|
255
|
+
*/
|
|
256
|
+
function emitSubscriptionUpsert(ws, client_id, key, issue) {
|
|
257
|
+
const revision = nextListRevision(ws, key);
|
|
258
|
+
const payload = {
|
|
259
|
+
type: 'upsert',
|
|
260
|
+
id: client_id,
|
|
261
|
+
revision,
|
|
262
|
+
issue
|
|
263
|
+
};
|
|
264
|
+
const msg = JSON.stringify({
|
|
265
|
+
id: `evt-${Date.now()}`,
|
|
266
|
+
ok: true,
|
|
267
|
+
type: /** @type {MessageType} */ ('upsert'),
|
|
268
|
+
payload
|
|
269
|
+
});
|
|
270
|
+
try {
|
|
271
|
+
ws.send(msg);
|
|
272
|
+
} catch {
|
|
273
|
+
// ignore
|
|
274
|
+
}
|
|
275
|
+
}
|
|
62
276
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
277
|
+
/**
|
|
278
|
+
* @param {WebSocket} ws
|
|
279
|
+
* @param {string} client_id
|
|
280
|
+
* @param {string} key
|
|
281
|
+
* @param {string} issue_id
|
|
282
|
+
*/
|
|
283
|
+
function emitSubscriptionDelete(ws, client_id, key, issue_id) {
|
|
284
|
+
const revision = nextListRevision(ws, key);
|
|
285
|
+
const payload = {
|
|
286
|
+
type: 'delete',
|
|
287
|
+
id: client_id,
|
|
288
|
+
revision,
|
|
289
|
+
issue_id
|
|
290
|
+
};
|
|
291
|
+
const msg = JSON.stringify({
|
|
292
|
+
id: `evt-${Date.now()}`,
|
|
293
|
+
ok: true,
|
|
294
|
+
type: /** @type {MessageType} */ ('delete'),
|
|
295
|
+
payload
|
|
296
|
+
});
|
|
297
|
+
try {
|
|
298
|
+
ws.send(msg);
|
|
299
|
+
} catch {
|
|
300
|
+
// ignore
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// issues-changed removed in v2: detail and lists are pushed via subscriptions
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Refresh a subscription spec: fetch via adapter, apply to registry and emit
|
|
308
|
+
* per-subscription full-issue envelopes to subscribers. Serialized per key.
|
|
309
|
+
* @param {{ type: string, params?: Record<string, string|number|boolean> }} spec
|
|
310
|
+
*/
|
|
311
|
+
async function refreshAndPublish(spec) {
|
|
312
|
+
const key = keyOf(spec);
|
|
313
|
+
await registry.withKeyLock(key, async () => {
|
|
314
|
+
const res = await fetchListForSubscription(spec);
|
|
315
|
+
if (!res.ok) {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const items = applyClosedIssuesFilter(spec, res.items);
|
|
319
|
+
const prevSize = registry.get(key)?.itemsById.size || 0;
|
|
320
|
+
const delta = registry.applyItems(key, items);
|
|
321
|
+
const entry = registry.get(key);
|
|
322
|
+
if (!entry || entry.subscribers.size === 0) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
/** @type {Map<string, any>} */
|
|
326
|
+
const byId = new Map();
|
|
327
|
+
for (const it of items) {
|
|
328
|
+
if (it && typeof it.id === 'string') {
|
|
329
|
+
byId.set(it.id, it);
|
|
67
330
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
331
|
+
}
|
|
332
|
+
for (const ws of entry.subscribers) {
|
|
333
|
+
if (ws.readyState !== ws.OPEN) continue;
|
|
334
|
+
const s = ensureSubs(ws);
|
|
335
|
+
const subs = s.list_subs || new Map();
|
|
336
|
+
/** @type {string[]} */
|
|
337
|
+
const clientIds = [];
|
|
338
|
+
for (const [cid, v] of subs.entries()) {
|
|
339
|
+
if (v.key === key) clientIds.push(cid);
|
|
71
340
|
}
|
|
72
|
-
if (
|
|
73
|
-
|
|
341
|
+
if (clientIds.length === 0) continue;
|
|
342
|
+
if (prevSize === 0) {
|
|
343
|
+
for (const cid of clientIds) {
|
|
344
|
+
emitSubscriptionSnapshot(ws, cid, key, items);
|
|
345
|
+
}
|
|
74
346
|
continue;
|
|
75
347
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
348
|
+
for (const cid of clientIds) {
|
|
349
|
+
for (const id of [...delta.added, ...delta.updated]) {
|
|
350
|
+
const issue = byId.get(id);
|
|
351
|
+
if (issue) {
|
|
352
|
+
emitSubscriptionUpsert(ws, cid, key, issue);
|
|
353
|
+
}
|
|
81
354
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
s.list_filters.status &&
|
|
85
|
-
String(s.list_filters.status) === String(issue.status || '')
|
|
86
|
-
) {
|
|
87
|
-
recipients.add(ws);
|
|
88
|
-
continue;
|
|
355
|
+
for (const id of delta.removed) {
|
|
356
|
+
emitSubscriptionDelete(ws, cid, key, id);
|
|
89
357
|
}
|
|
90
358
|
}
|
|
91
359
|
}
|
|
92
|
-
} else if (hint_ids.length > 0) {
|
|
93
|
-
for (const ws of wss.clients) {
|
|
94
|
-
if (ws.readyState !== ws.OPEN) {
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
97
|
-
const s = getSubs(/** @type {any} */ (ws));
|
|
98
|
-
if (!s.subscribed) {
|
|
99
|
-
continue;
|
|
100
|
-
}
|
|
101
|
-
if (s.show_id && hint_ids.includes(s.show_id)) {
|
|
102
|
-
recipients.add(ws);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** @type {string} */
|
|
108
|
-
const msg = JSON.stringify({
|
|
109
|
-
id: `evt-${Date.now()}`,
|
|
110
|
-
ok: true,
|
|
111
|
-
type: /** @type {MessageType} */ ('issues-changed'),
|
|
112
|
-
payload: { ts: Date.now(), ...(payload || {}) }
|
|
113
360
|
});
|
|
361
|
+
}
|
|
114
362
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
363
|
+
/**
|
|
364
|
+
* Apply pre-diff filtering for closed-issues lists based on spec.params.since (epoch ms).
|
|
365
|
+
* @param {{ type: string, params?: Record<string, string|number|boolean> }} spec
|
|
366
|
+
* @param {Array<{ id: string, updated_at: number, closed_at: number | null } & Record<string, unknown>>} items
|
|
367
|
+
*/
|
|
368
|
+
function applyClosedIssuesFilter(spec, items) {
|
|
369
|
+
if (String(spec.type) !== 'closed-issues') {
|
|
370
|
+
return items;
|
|
371
|
+
}
|
|
372
|
+
const p = spec.params || {};
|
|
373
|
+
const since = typeof p.since === 'number' ? p.since : 0;
|
|
374
|
+
if (!Number.isFinite(since) || since <= 0) {
|
|
375
|
+
return items;
|
|
376
|
+
}
|
|
377
|
+
/** @type {typeof items} */
|
|
378
|
+
const out = [];
|
|
379
|
+
for (const it of items) {
|
|
380
|
+
const ca = it.closed_at;
|
|
381
|
+
if (typeof ca === 'number' && Number.isFinite(ca) && ca >= since) {
|
|
382
|
+
out.push(it);
|
|
125
383
|
}
|
|
126
384
|
}
|
|
385
|
+
return out;
|
|
127
386
|
}
|
|
128
387
|
|
|
129
388
|
/**
|
|
130
389
|
* Attach a WebSocket server to an existing HTTP server.
|
|
131
390
|
* @param {Server} http_server
|
|
132
|
-
* @param {{ path?: string, heartbeat_ms?: number }} [options]
|
|
133
|
-
* @returns {{ wss: WebSocketServer, broadcast: (type: MessageType, payload?: unknown) => void,
|
|
391
|
+
* @param {{ path?: string, heartbeat_ms?: number, refresh_debounce_ms?: number }} [options]
|
|
392
|
+
* @returns {{ wss: WebSocketServer, broadcast: (type: MessageType, payload?: unknown) => void, scheduleListRefresh: () => void }}
|
|
134
393
|
*/
|
|
135
394
|
export function attachWsServer(http_server, options = {}) {
|
|
136
395
|
const path = options.path || '/ws';
|
|
137
396
|
const heartbeat_ms = options.heartbeat_ms ?? 30000;
|
|
397
|
+
if (typeof options.refresh_debounce_ms === 'number') {
|
|
398
|
+
const n = options.refresh_debounce_ms;
|
|
399
|
+
if (Number.isFinite(n) && n >= 0) {
|
|
400
|
+
REFRESH_DEBOUNCE_MS = n;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
138
403
|
|
|
139
404
|
const wss = new WebSocketServer({ server: http_server, path });
|
|
140
405
|
CURRENT_WSS = wss;
|
|
@@ -145,7 +410,7 @@ export function attachWsServer(http_server, options = {}) {
|
|
|
145
410
|
ws.isAlive = true;
|
|
146
411
|
|
|
147
412
|
// Initialize subscription state for this connection
|
|
148
|
-
|
|
413
|
+
ensureSubs(ws);
|
|
149
414
|
|
|
150
415
|
ws.on('pong', () => {
|
|
151
416
|
// @ts-expect-error marker
|
|
@@ -155,6 +420,14 @@ export function attachWsServer(http_server, options = {}) {
|
|
|
155
420
|
ws.on('message', (data) => {
|
|
156
421
|
handleMessage(ws, data);
|
|
157
422
|
});
|
|
423
|
+
|
|
424
|
+
ws.on('close', () => {
|
|
425
|
+
try {
|
|
426
|
+
registry.onDisconnect(ws);
|
|
427
|
+
} catch {
|
|
428
|
+
// ignore cleanup errors
|
|
429
|
+
}
|
|
430
|
+
});
|
|
158
431
|
});
|
|
159
432
|
|
|
160
433
|
const interval = setInterval(() => {
|
|
@@ -195,7 +468,12 @@ export function attachWsServer(http_server, options = {}) {
|
|
|
195
468
|
}
|
|
196
469
|
}
|
|
197
470
|
|
|
198
|
-
return {
|
|
471
|
+
return {
|
|
472
|
+
wss,
|
|
473
|
+
broadcast,
|
|
474
|
+
scheduleListRefresh
|
|
475
|
+
// v2: list subscription refresh handles updates
|
|
476
|
+
};
|
|
199
477
|
}
|
|
200
478
|
|
|
201
479
|
/**
|
|
@@ -233,119 +511,50 @@ export async function handleMessage(ws, data) {
|
|
|
233
511
|
const req = json;
|
|
234
512
|
|
|
235
513
|
// Dispatch known types here as we implement them. For now, only a ping utility.
|
|
236
|
-
if (req.type === /** @type {
|
|
514
|
+
if (req.type === /** @type {MessageType} */ ('ping')) {
|
|
237
515
|
ws.send(JSON.stringify(makeOk(req, { ts: Date.now() })));
|
|
238
516
|
return;
|
|
239
517
|
}
|
|
240
518
|
|
|
241
|
-
// subscribe-
|
|
242
|
-
if (req.type === 'subscribe-
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
if (req.type === 'list-issues') {
|
|
251
|
-
const { filters } = /** @type {any} */ (req.payload || {});
|
|
252
|
-
// When "ready" is requested, use the dedicated bd subcommand
|
|
253
|
-
if (filters && typeof filters === 'object' && filters.ready === true) {
|
|
254
|
-
const res = await runBdJson(['ready', '--json']);
|
|
255
|
-
if (res.code !== 0) {
|
|
256
|
-
const err = makeError(req, 'bd_error', res.stderr || 'bd failed');
|
|
257
|
-
ws.send(JSON.stringify(err));
|
|
258
|
-
return;
|
|
259
|
-
}
|
|
260
|
-
// Remember subscription scope for this connection
|
|
261
|
-
try {
|
|
262
|
-
const s = getSubs(ws);
|
|
263
|
-
s.list_filters = { ready: true };
|
|
264
|
-
} catch {
|
|
265
|
-
// ignore tracking errors
|
|
266
|
-
}
|
|
267
|
-
ws.send(JSON.stringify(makeOk(req, res.stdoutJson)));
|
|
268
|
-
return;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// When "blocked" is requested, use the dedicated bd subcommand
|
|
272
|
-
if (filters && typeof filters === 'object' && filters.blocked === true) {
|
|
273
|
-
const res = await runBdJson(['blocked', '--json']);
|
|
274
|
-
if (res.code !== 0) {
|
|
275
|
-
const err = makeError(req, 'bd_error', res.stderr || 'bd failed');
|
|
276
|
-
ws.send(JSON.stringify(err));
|
|
277
|
-
return;
|
|
278
|
-
}
|
|
279
|
-
// Remember subscription scope for this connection
|
|
280
|
-
try {
|
|
281
|
-
const s = getSubs(ws);
|
|
282
|
-
s.list_filters = { blocked: true };
|
|
283
|
-
} catch {
|
|
284
|
-
// ignore tracking errors
|
|
285
|
-
}
|
|
286
|
-
ws.send(JSON.stringify(makeOk(req, res.stdoutJson)));
|
|
287
|
-
return;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/** @type {string[]} */
|
|
291
|
-
const args = ['list', '--json'];
|
|
292
|
-
if (filters && typeof filters === 'object') {
|
|
293
|
-
if (typeof filters.status === 'string') {
|
|
294
|
-
// Use long flag for clarity and compatibility
|
|
295
|
-
args.push('--status', filters.status);
|
|
296
|
-
}
|
|
297
|
-
if (typeof filters.priority === 'number') {
|
|
298
|
-
args.push('--priority', String(filters.priority));
|
|
299
|
-
}
|
|
300
|
-
if (typeof filters.limit === 'number' && filters.limit > 0) {
|
|
301
|
-
args.push('--limit', String(filters.limit));
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
const res = await runBdJson(args);
|
|
305
|
-
if (res.code !== 0) {
|
|
306
|
-
const err = makeError(req, 'bd_error', res.stderr || 'bd failed');
|
|
307
|
-
ws.send(JSON.stringify(err));
|
|
519
|
+
// subscribe-list: payload { id: string, type: string, params?: object }
|
|
520
|
+
if (req.type === 'subscribe-list') {
|
|
521
|
+
const validation = validateSubscribeListPayload(
|
|
522
|
+
/** @type {any} */ (req.payload || {})
|
|
523
|
+
);
|
|
524
|
+
if (!validation.ok) {
|
|
525
|
+
ws.send(
|
|
526
|
+
JSON.stringify(makeError(req, validation.code, validation.message))
|
|
527
|
+
);
|
|
308
528
|
return;
|
|
309
529
|
}
|
|
310
|
-
|
|
530
|
+
const client_id = validation.id;
|
|
531
|
+
const spec = validation.spec;
|
|
532
|
+
const s = ensureSubs(ws);
|
|
533
|
+
// Attach to registry
|
|
534
|
+
const { key } = registry.attach(spec, ws);
|
|
535
|
+
s.list_subs?.set(client_id, { key, spec });
|
|
536
|
+
// Send an initial snapshot for this client id only and store items
|
|
311
537
|
try {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
s.list_filters.status = st;
|
|
322
|
-
}
|
|
323
|
-
if (typeof lim === 'number') {
|
|
324
|
-
s.list_filters.limit = lim;
|
|
325
|
-
}
|
|
538
|
+
await registry.withKeyLock(key, async () => {
|
|
539
|
+
const res = await fetchListForSubscription(spec);
|
|
540
|
+
if (!res.ok) {
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const items = applyClosedIssuesFilter(spec, res.items);
|
|
544
|
+
void registry.applyItems(key, items);
|
|
545
|
+
emitSubscriptionSnapshot(ws, client_id, key, items);
|
|
546
|
+
});
|
|
326
547
|
} catch {
|
|
327
|
-
// ignore
|
|
548
|
+
// ignore snapshot errors
|
|
328
549
|
}
|
|
329
|
-
ws.send(JSON.stringify(makeOk(req,
|
|
550
|
+
ws.send(JSON.stringify(makeOk(req, { id: client_id, key })));
|
|
330
551
|
return;
|
|
331
552
|
}
|
|
332
553
|
|
|
333
|
-
//
|
|
334
|
-
if (req.type ===
|
|
335
|
-
const
|
|
336
|
-
if (
|
|
337
|
-
const err = makeError(req, 'bd_error', res.stderr || 'bd failed');
|
|
338
|
-
ws.send(JSON.stringify(err));
|
|
339
|
-
return;
|
|
340
|
-
}
|
|
341
|
-
ws.send(JSON.stringify(makeOk(req, res.stdoutJson)));
|
|
342
|
-
return;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
// show-issue
|
|
346
|
-
if (req.type === 'show-issue') {
|
|
347
|
-
const { id } = /** @type {any} */ (req.payload);
|
|
348
|
-
if (typeof id !== 'string' || id.length === 0) {
|
|
554
|
+
// unsubscribe-list: payload { id: string }
|
|
555
|
+
if (req.type === 'unsubscribe-list') {
|
|
556
|
+
const { id: client_id } = /** @type {any} */ (req.payload || {});
|
|
557
|
+
if (typeof client_id !== 'string' || client_id.length === 0) {
|
|
349
558
|
ws.send(
|
|
350
559
|
JSON.stringify(
|
|
351
560
|
makeError(req, 'bad_request', 'payload.id must be a non-empty string')
|
|
@@ -353,37 +562,38 @@ export async function handleMessage(ws, data) {
|
|
|
353
562
|
);
|
|
354
563
|
return;
|
|
355
564
|
}
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
s.show_id = String(id);
|
|
376
|
-
} catch {
|
|
377
|
-
// ignore
|
|
378
|
-
}
|
|
379
|
-
ws.send(JSON.stringify(makeOk(req, out)));
|
|
565
|
+
const s = ensureSubs(ws);
|
|
566
|
+
const sub = s.list_subs?.get(client_id) || null;
|
|
567
|
+
let removed = false;
|
|
568
|
+
if (sub) {
|
|
569
|
+
try {
|
|
570
|
+
removed = registry.detach(sub.spec, ws);
|
|
571
|
+
} catch {
|
|
572
|
+
removed = false;
|
|
573
|
+
}
|
|
574
|
+
s.list_subs?.delete(client_id);
|
|
575
|
+
}
|
|
576
|
+
ws.send(
|
|
577
|
+
JSON.stringify(
|
|
578
|
+
makeOk(req, {
|
|
579
|
+
id: client_id,
|
|
580
|
+
unsubscribed: removed
|
|
581
|
+
})
|
|
582
|
+
)
|
|
583
|
+
);
|
|
380
584
|
return;
|
|
381
585
|
}
|
|
382
586
|
|
|
587
|
+
// Removed: subscribe-updates and subscribe-issues. No-ops in v2.
|
|
588
|
+
|
|
589
|
+
// list-issues and epic-status were removed in favor of push-only subscriptions
|
|
590
|
+
|
|
591
|
+
// Removed: show-issue. Details flow is push-only via `subscribe-list { type: 'issue-detail' }`.
|
|
592
|
+
|
|
383
593
|
// type updates are not exposed via UI; no handler
|
|
384
594
|
|
|
385
595
|
// update-assignee
|
|
386
|
-
if (req.type ===
|
|
596
|
+
if (req.type === 'update-assignee') {
|
|
387
597
|
const { id, assignee } = /** @type {any} */ (req.payload || {});
|
|
388
598
|
if (
|
|
389
599
|
typeof id !== 'string' ||
|
|
@@ -417,6 +627,11 @@ export async function handleMessage(ws, data) {
|
|
|
417
627
|
return;
|
|
418
628
|
}
|
|
419
629
|
ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
|
|
630
|
+
try {
|
|
631
|
+
triggerMutationRefreshOnce();
|
|
632
|
+
} catch {
|
|
633
|
+
// ignore
|
|
634
|
+
}
|
|
420
635
|
return;
|
|
421
636
|
}
|
|
422
637
|
|
|
@@ -456,11 +671,11 @@ export async function handleMessage(ws, data) {
|
|
|
456
671
|
return;
|
|
457
672
|
}
|
|
458
673
|
ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
|
|
459
|
-
//
|
|
674
|
+
// After mutation, refresh active subscriptions once (watcher or timeout)
|
|
460
675
|
try {
|
|
461
|
-
|
|
676
|
+
triggerMutationRefreshOnce();
|
|
462
677
|
} catch {
|
|
463
|
-
// ignore
|
|
678
|
+
// ignore
|
|
464
679
|
}
|
|
465
680
|
return;
|
|
466
681
|
}
|
|
@@ -502,9 +717,9 @@ export async function handleMessage(ws, data) {
|
|
|
502
717
|
}
|
|
503
718
|
ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
|
|
504
719
|
try {
|
|
505
|
-
|
|
720
|
+
triggerMutationRefreshOnce();
|
|
506
721
|
} catch {
|
|
507
|
-
// ignore
|
|
722
|
+
// ignore
|
|
508
723
|
}
|
|
509
724
|
return;
|
|
510
725
|
}
|
|
@@ -565,15 +780,15 @@ export async function handleMessage(ws, data) {
|
|
|
565
780
|
}
|
|
566
781
|
ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
|
|
567
782
|
try {
|
|
568
|
-
|
|
783
|
+
triggerMutationRefreshOnce();
|
|
569
784
|
} catch {
|
|
570
|
-
// ignore
|
|
785
|
+
// ignore
|
|
571
786
|
}
|
|
572
787
|
return;
|
|
573
788
|
}
|
|
574
789
|
|
|
575
790
|
// create-issue
|
|
576
|
-
if (req.type ===
|
|
791
|
+
if (req.type === 'create-issue') {
|
|
577
792
|
const { title, type, priority, description } = /** @type {any} */ (
|
|
578
793
|
req.payload || {}
|
|
579
794
|
);
|
|
@@ -589,7 +804,6 @@ export async function handleMessage(ws, data) {
|
|
|
589
804
|
);
|
|
590
805
|
return;
|
|
591
806
|
}
|
|
592
|
-
/** @type {string[]} */
|
|
593
807
|
const args = ['create', title];
|
|
594
808
|
if (
|
|
595
809
|
typeof type === 'string' &&
|
|
@@ -614,13 +828,19 @@ export async function handleMessage(ws, data) {
|
|
|
614
828
|
);
|
|
615
829
|
return;
|
|
616
830
|
}
|
|
617
|
-
//
|
|
831
|
+
// Reply with a minimal ack
|
|
618
832
|
ws.send(JSON.stringify(makeOk(req, { created: true })));
|
|
833
|
+
// Refresh active subscriptions once (watcher or timeout)
|
|
834
|
+
try {
|
|
835
|
+
triggerMutationRefreshOnce();
|
|
836
|
+
} catch {
|
|
837
|
+
// ignore
|
|
838
|
+
}
|
|
619
839
|
return;
|
|
620
840
|
}
|
|
621
841
|
|
|
622
842
|
// dep-add: payload { a: string, b: string, view_id?: string }
|
|
623
|
-
if (req.type ===
|
|
843
|
+
if (req.type === 'dep-add') {
|
|
624
844
|
const { a, b, view_id } = /** @type {any} */ (req.payload || {});
|
|
625
845
|
if (
|
|
626
846
|
typeof a !== 'string' ||
|
|
@@ -656,16 +876,15 @@ export async function handleMessage(ws, data) {
|
|
|
656
876
|
}
|
|
657
877
|
ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
|
|
658
878
|
try {
|
|
659
|
-
|
|
660
|
-
notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
|
|
879
|
+
triggerMutationRefreshOnce();
|
|
661
880
|
} catch {
|
|
662
|
-
// ignore
|
|
881
|
+
// ignore
|
|
663
882
|
}
|
|
664
883
|
return;
|
|
665
884
|
}
|
|
666
885
|
|
|
667
886
|
// dep-remove: payload { a: string, b: string, view_id?: string }
|
|
668
|
-
if (req.type ===
|
|
887
|
+
if (req.type === 'dep-remove') {
|
|
669
888
|
const { a, b, view_id } = /** @type {any} */ (req.payload || {});
|
|
670
889
|
if (
|
|
671
890
|
typeof a !== 'string' ||
|
|
@@ -701,15 +920,15 @@ export async function handleMessage(ws, data) {
|
|
|
701
920
|
}
|
|
702
921
|
ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
|
|
703
922
|
try {
|
|
704
|
-
|
|
923
|
+
triggerMutationRefreshOnce();
|
|
705
924
|
} catch {
|
|
706
|
-
// ignore
|
|
925
|
+
// ignore
|
|
707
926
|
}
|
|
708
927
|
return;
|
|
709
928
|
}
|
|
710
929
|
|
|
711
930
|
// label-add: payload { id: string, label: string }
|
|
712
|
-
if (req.type ===
|
|
931
|
+
if (req.type === 'label-add') {
|
|
713
932
|
const { id, label } = /** @type {any} */ (req.payload || {});
|
|
714
933
|
if (
|
|
715
934
|
typeof id !== 'string' ||
|
|
@@ -744,7 +963,7 @@ export async function handleMessage(ws, data) {
|
|
|
744
963
|
}
|
|
745
964
|
ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
|
|
746
965
|
try {
|
|
747
|
-
|
|
966
|
+
triggerMutationRefreshOnce();
|
|
748
967
|
} catch {
|
|
749
968
|
// ignore
|
|
750
969
|
}
|
|
@@ -752,7 +971,7 @@ export async function handleMessage(ws, data) {
|
|
|
752
971
|
}
|
|
753
972
|
|
|
754
973
|
// label-remove: payload { id: string, label: string }
|
|
755
|
-
if (req.type ===
|
|
974
|
+
if (req.type === 'label-remove') {
|
|
756
975
|
const { id, label } = /** @type {any} */ (req.payload || {});
|
|
757
976
|
if (
|
|
758
977
|
typeof id !== 'string' ||
|
|
@@ -787,7 +1006,7 @@ export async function handleMessage(ws, data) {
|
|
|
787
1006
|
}
|
|
788
1007
|
ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
|
|
789
1008
|
try {
|
|
790
|
-
|
|
1009
|
+
triggerMutationRefreshOnce();
|
|
791
1010
|
} catch {
|
|
792
1011
|
// ignore
|
|
793
1012
|
}
|