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.
- package/CHANGES.md +29 -2
- package/README.md +39 -45
- package/app/data/list-selectors.js +98 -0
- package/app/data/providers.js +25 -127
- 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/index.html +8 -0
- package/app/main.js +483 -61
- package/app/protocol.js +10 -14
- package/app/protocol.md +21 -19
- package/app/router.js +45 -9
- package/app/state.js +27 -11
- package/app/styles.css +373 -184
- package/app/utils/issue-id-renderer.js +71 -0
- package/app/utils/issue-url.js +9 -0
- 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 +34 -0
- package/app/utils/type-badge.js +0 -3
- package/app/views/board.js +439 -87
- package/app/views/detail.js +364 -154
- package/app/views/epics.js +128 -76
- package/app/views/issue-dialog.js +163 -0
- package/app/views/issue-row.js +10 -11
- package/app/views/list.js +164 -93
- package/app/views/new-issue-dialog.js +345 -0
- package/app/ws.js +36 -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 +35 -85
- 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 +11 -3
- package/server/bd.js +0 -2
- package/server/cli/commands.js +12 -5
- package/server/cli/daemon.js +12 -5
- package/server/cli/index.js +34 -5
- package/server/cli/usage.js +2 -2
- 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 +6 -9
- package/server/ws.js +466 -227
- package/docs/quickstart.md +0 -142
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { ISSUE_TYPES, typeLabel } from '../utils/issue-type.js';
|
|
2
|
+
import { priority_levels } from '../utils/priority.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Create and manage the New Issue dialog (native <dialog>).
|
|
6
|
+
* @param {HTMLElement} mount_element - Container to attach dialog (e.g., main#app)
|
|
7
|
+
* @param {(type: import('../protocol.js').MessageType, payload?: unknown) => Promise<unknown>} sendFn - Transport function
|
|
8
|
+
* @param {{ gotoIssue: (id: string) => void }} router - Router for opening details after create
|
|
9
|
+
* @param {{ setState: (patch: any) => void, getState: () => any }} [store]
|
|
10
|
+
* @returns {{ open: () => void, close: () => void }}
|
|
11
|
+
*/
|
|
12
|
+
export function createNewIssueDialog(mount_element, sendFn, router, store) {
|
|
13
|
+
const dialog = /** @type {HTMLDialogElement} */ (
|
|
14
|
+
document.createElement('dialog')
|
|
15
|
+
);
|
|
16
|
+
dialog.id = 'new-issue-dialog';
|
|
17
|
+
dialog.setAttribute('role', 'dialog');
|
|
18
|
+
dialog.setAttribute('aria-modal', 'true');
|
|
19
|
+
|
|
20
|
+
dialog.innerHTML = `
|
|
21
|
+
<div class="new-issue__container" part="container">
|
|
22
|
+
<header class="new-issue__header">
|
|
23
|
+
<div class="new-issue__title">New Issue</div>
|
|
24
|
+
<button type="button" class="new-issue__close" aria-label="Close">×</button>
|
|
25
|
+
</header>
|
|
26
|
+
<div class="new-issue__body">
|
|
27
|
+
<form id="new-issue-form" class="new-issue__form">
|
|
28
|
+
<label for="new-title">Title</label>
|
|
29
|
+
<input id="new-title" name="title" type="text" required placeholder="Short summary" />
|
|
30
|
+
|
|
31
|
+
<label for="new-type">Type</label>
|
|
32
|
+
<select id="new-type" name="type" aria-label="Issue type"></select>
|
|
33
|
+
|
|
34
|
+
<label for="new-priority">Priority</label>
|
|
35
|
+
<select id="new-priority" name="priority" aria-label="Priority"></select>
|
|
36
|
+
|
|
37
|
+
<label for="new-labels">Labels</label>
|
|
38
|
+
<input id="new-labels" name="labels" type="text" placeholder="comma,separated" />
|
|
39
|
+
|
|
40
|
+
<label for="new-description">Description</label>
|
|
41
|
+
<textarea id="new-description" name="description" rows="6" placeholder="Optional markdown description"></textarea>
|
|
42
|
+
|
|
43
|
+
<div aria-live="polite" role="status" class="new-issue__error" id="new-issue-error"></div>
|
|
44
|
+
|
|
45
|
+
<div class="new-issue__actions" style="grid-column: 1 / -1">
|
|
46
|
+
<button type="button" id="btn-cancel">Cancel (Esc)</button>
|
|
47
|
+
<button type="submit" id="btn-create">Create</button>
|
|
48
|
+
</div>
|
|
49
|
+
</form>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
mount_element.appendChild(dialog);
|
|
55
|
+
|
|
56
|
+
const form = /** @type {HTMLFormElement} */ (
|
|
57
|
+
dialog.querySelector('#new-issue-form')
|
|
58
|
+
);
|
|
59
|
+
const input_title = /** @type {HTMLInputElement} */ (
|
|
60
|
+
dialog.querySelector('#new-title')
|
|
61
|
+
);
|
|
62
|
+
const sel_type = /** @type {HTMLSelectElement} */ (
|
|
63
|
+
dialog.querySelector('#new-type')
|
|
64
|
+
);
|
|
65
|
+
const sel_priority = /** @type {HTMLSelectElement} */ (
|
|
66
|
+
dialog.querySelector('#new-priority')
|
|
67
|
+
);
|
|
68
|
+
const input_labels = /** @type {HTMLInputElement} */ (
|
|
69
|
+
dialog.querySelector('#new-labels')
|
|
70
|
+
);
|
|
71
|
+
const input_description = /** @type {HTMLTextAreaElement} */ (
|
|
72
|
+
dialog.querySelector('#new-description')
|
|
73
|
+
);
|
|
74
|
+
const error_box = /** @type {HTMLDivElement} */ (
|
|
75
|
+
dialog.querySelector('#new-issue-error')
|
|
76
|
+
);
|
|
77
|
+
const btn_cancel = /** @type {HTMLButtonElement} */ (
|
|
78
|
+
dialog.querySelector('#btn-cancel')
|
|
79
|
+
);
|
|
80
|
+
const btn_create = /** @type {HTMLButtonElement} */ (
|
|
81
|
+
dialog.querySelector('#btn-create')
|
|
82
|
+
);
|
|
83
|
+
const btn_close = /** @type {HTMLButtonElement} */ (
|
|
84
|
+
dialog.querySelector('.new-issue__close')
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// Populate selects
|
|
88
|
+
function populateSelects() {
|
|
89
|
+
sel_type.replaceChildren();
|
|
90
|
+
// Empty option to allow leaving type unspecified
|
|
91
|
+
const optEmpty = document.createElement('option');
|
|
92
|
+
optEmpty.value = '';
|
|
93
|
+
optEmpty.textContent = '— Select —';
|
|
94
|
+
sel_type.appendChild(optEmpty);
|
|
95
|
+
for (const t of ISSUE_TYPES) {
|
|
96
|
+
const o = document.createElement('option');
|
|
97
|
+
o.value = t;
|
|
98
|
+
o.textContent = typeLabel(t);
|
|
99
|
+
sel_type.appendChild(o);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
sel_priority.replaceChildren();
|
|
103
|
+
for (let i = 0; i <= 4; i += 1) {
|
|
104
|
+
const o = document.createElement('option');
|
|
105
|
+
o.value = String(i);
|
|
106
|
+
const label = priority_levels[i] || 'Medium';
|
|
107
|
+
o.textContent = `${i} – ${label}`;
|
|
108
|
+
sel_priority.appendChild(o);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
populateSelects();
|
|
112
|
+
|
|
113
|
+
function requestClose() {
|
|
114
|
+
try {
|
|
115
|
+
if (typeof dialog.close === 'function') {
|
|
116
|
+
dialog.close();
|
|
117
|
+
} else {
|
|
118
|
+
dialog.removeAttribute('open');
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
dialog.removeAttribute('open');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @param {boolean} is_busy
|
|
127
|
+
*/
|
|
128
|
+
function setBusy(is_busy) {
|
|
129
|
+
input_title.disabled = is_busy;
|
|
130
|
+
sel_type.disabled = is_busy;
|
|
131
|
+
sel_priority.disabled = is_busy;
|
|
132
|
+
input_labels.disabled = is_busy;
|
|
133
|
+
input_description.disabled = is_busy;
|
|
134
|
+
btn_cancel.disabled = is_busy;
|
|
135
|
+
btn_create.disabled = is_busy;
|
|
136
|
+
btn_create.textContent = is_busy ? 'Creating…' : 'Create';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function clearError() {
|
|
140
|
+
error_box.textContent = '';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @param {string} msg
|
|
145
|
+
*/
|
|
146
|
+
function setError(msg) {
|
|
147
|
+
error_box.textContent = msg;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function loadDefaults() {
|
|
151
|
+
try {
|
|
152
|
+
const t = window.localStorage.getItem('beads-ui.new.type');
|
|
153
|
+
if (t) {
|
|
154
|
+
sel_type.value = t;
|
|
155
|
+
} else {
|
|
156
|
+
sel_type.value = '';
|
|
157
|
+
}
|
|
158
|
+
const p = window.localStorage.getItem('beads-ui.new.priority');
|
|
159
|
+
if (p && /^\d$/.test(p)) {
|
|
160
|
+
sel_priority.value = p;
|
|
161
|
+
} else {
|
|
162
|
+
sel_priority.value = '2';
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
sel_type.value = '';
|
|
166
|
+
sel_priority.value = '2';
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function saveDefaults() {
|
|
171
|
+
try {
|
|
172
|
+
const t = sel_type.value || '';
|
|
173
|
+
const p = sel_priority.value || '';
|
|
174
|
+
if (t.length > 0) {
|
|
175
|
+
window.localStorage.setItem('beads-ui.new.type', t);
|
|
176
|
+
}
|
|
177
|
+
if (p.length > 0) {
|
|
178
|
+
window.localStorage.setItem('beads-ui.new.priority', p);
|
|
179
|
+
}
|
|
180
|
+
} catch {
|
|
181
|
+
// ignore persistence errors
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Extract numeric suffix from an id like "UI-123"; return -1 when absent.
|
|
187
|
+
* @param {string} id
|
|
188
|
+
*/
|
|
189
|
+
function idNumeric(id) {
|
|
190
|
+
const m = /-(\d+)$/.exec(String(id || ''));
|
|
191
|
+
return m && m[1] ? Number(m[1]) : -1;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Submit handler: validate, create, then open the created issue details.
|
|
196
|
+
* @returns {Promise<void>}
|
|
197
|
+
*/
|
|
198
|
+
async function createNow() {
|
|
199
|
+
clearError();
|
|
200
|
+
const title = String(input_title.value || '').trim();
|
|
201
|
+
if (title.length === 0) {
|
|
202
|
+
setError('Title is required');
|
|
203
|
+
input_title.focus();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const prio = Number(sel_priority.value || '2');
|
|
207
|
+
if (!(prio >= 0 && prio <= 4)) {
|
|
208
|
+
setError('Priority must be 0..4');
|
|
209
|
+
sel_priority.focus();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const type = String(sel_type.value || '');
|
|
213
|
+
const desc = String(input_description.value || '');
|
|
214
|
+
const labels = String(input_labels.value || '')
|
|
215
|
+
.split(',')
|
|
216
|
+
.map((s) => s.trim())
|
|
217
|
+
.filter((s) => s.length > 0);
|
|
218
|
+
|
|
219
|
+
/** @type {{ title: string, type?: string, priority?: number, description?: string }} */
|
|
220
|
+
const payload = { title };
|
|
221
|
+
if (type.length > 0) {
|
|
222
|
+
payload.type = type;
|
|
223
|
+
}
|
|
224
|
+
if (String(prio).length > 0) {
|
|
225
|
+
payload.priority = prio;
|
|
226
|
+
}
|
|
227
|
+
if (desc.length > 0) {
|
|
228
|
+
payload.description = desc;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
setBusy(true);
|
|
232
|
+
try {
|
|
233
|
+
await sendFn('create-issue', payload);
|
|
234
|
+
} catch {
|
|
235
|
+
setBusy(false);
|
|
236
|
+
setError('Failed to create issue');
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
saveDefaults();
|
|
241
|
+
|
|
242
|
+
// Best-effort: find the created id by matching title among open issues and picking the highest numeric id
|
|
243
|
+
/** @type {any} */
|
|
244
|
+
let list = null;
|
|
245
|
+
try {
|
|
246
|
+
list = await sendFn('list-issues', {
|
|
247
|
+
filters: { status: 'open', limit: 50 }
|
|
248
|
+
});
|
|
249
|
+
} catch {
|
|
250
|
+
list = null;
|
|
251
|
+
}
|
|
252
|
+
let created_id = '';
|
|
253
|
+
if (Array.isArray(list)) {
|
|
254
|
+
const matches = list.filter((it) => String(it.title || '') === title);
|
|
255
|
+
if (matches.length > 0) {
|
|
256
|
+
/** @type {any} */
|
|
257
|
+
let best = matches[0];
|
|
258
|
+
for (const it of matches) {
|
|
259
|
+
const ai = idNumeric(best.id || '');
|
|
260
|
+
const bi = idNumeric(it.id || '');
|
|
261
|
+
if (bi > ai) {
|
|
262
|
+
best = it;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
created_id = String(best.id || '');
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Apply labels if any
|
|
270
|
+
if (created_id && labels.length > 0) {
|
|
271
|
+
for (const label of labels) {
|
|
272
|
+
try {
|
|
273
|
+
await sendFn('label-add', { id: created_id, label });
|
|
274
|
+
} catch {
|
|
275
|
+
// ignore label failures
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Navigate to created issue if found
|
|
281
|
+
if (created_id) {
|
|
282
|
+
try {
|
|
283
|
+
router.gotoIssue(created_id);
|
|
284
|
+
} catch {
|
|
285
|
+
// ignore routing errors
|
|
286
|
+
}
|
|
287
|
+
// Also set state directly to ensure dialog opens even if hash routing is suppressed in tests
|
|
288
|
+
try {
|
|
289
|
+
if (store) {
|
|
290
|
+
store.setState({ selected_id: created_id });
|
|
291
|
+
}
|
|
292
|
+
} catch {
|
|
293
|
+
// ignore
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
setBusy(false);
|
|
298
|
+
requestClose();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Events
|
|
302
|
+
dialog.addEventListener('cancel', (ev) => {
|
|
303
|
+
ev.preventDefault();
|
|
304
|
+
requestClose();
|
|
305
|
+
});
|
|
306
|
+
btn_close.addEventListener('click', () => requestClose());
|
|
307
|
+
btn_cancel.addEventListener('click', () => requestClose());
|
|
308
|
+
dialog.addEventListener('keydown', (ev) => {
|
|
309
|
+
if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) {
|
|
310
|
+
ev.preventDefault();
|
|
311
|
+
void createNow();
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
form.addEventListener('submit', (ev) => {
|
|
315
|
+
ev.preventDefault();
|
|
316
|
+
void createNow();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
open() {
|
|
321
|
+
form.reset();
|
|
322
|
+
clearError();
|
|
323
|
+
loadDefaults();
|
|
324
|
+
try {
|
|
325
|
+
if ('showModal' in dialog && typeof dialog.showModal === 'function') {
|
|
326
|
+
dialog.showModal();
|
|
327
|
+
} else {
|
|
328
|
+
dialog.setAttribute('open', '');
|
|
329
|
+
}
|
|
330
|
+
} catch {
|
|
331
|
+
dialog.setAttribute('open', '');
|
|
332
|
+
}
|
|
333
|
+
setTimeout(() => {
|
|
334
|
+
try {
|
|
335
|
+
input_title.focus();
|
|
336
|
+
} catch {
|
|
337
|
+
// ignore
|
|
338
|
+
}
|
|
339
|
+
}, 0);
|
|
340
|
+
},
|
|
341
|
+
close() {
|
|
342
|
+
requestClose();
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
}
|
package/app/ws.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Usage:
|
|
10
10
|
* const ws = createWsClient();
|
|
11
11
|
* const data = await ws.send('list-issues', { filters: {} });
|
|
12
|
-
* const off = ws.on('
|
|
12
|
+
* const off = ws.on('snapshot', (payload) => { <push event> });
|
|
13
13
|
*/
|
|
14
14
|
import { MESSAGE_TYPES, makeRequest, nextId } from './protocol.js';
|
|
15
15
|
|
|
@@ -73,12 +73,28 @@ export function createWsClient(options = {}) {
|
|
|
73
73
|
const queue = [];
|
|
74
74
|
/** @type {Map<string, Set<(payload: any) => void>>} */
|
|
75
75
|
const handlers = new Map();
|
|
76
|
+
/** @type {Set<(s: ConnectionState) => void>} */
|
|
77
|
+
const connection_handlers = new Set();
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {ConnectionState} s
|
|
81
|
+
*/
|
|
82
|
+
function notifyConnection(s) {
|
|
83
|
+
for (const fn of Array.from(connection_handlers)) {
|
|
84
|
+
try {
|
|
85
|
+
fn(s);
|
|
86
|
+
} catch {
|
|
87
|
+
// ignore listener errors
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
76
91
|
|
|
77
92
|
function scheduleReconnect() {
|
|
78
93
|
if (!should_reconnect || reconnect_timer) {
|
|
79
94
|
return;
|
|
80
95
|
}
|
|
81
96
|
state = 'reconnecting';
|
|
97
|
+
notifyConnection(state);
|
|
82
98
|
const base = Math.min(
|
|
83
99
|
backoff.maxMs || 0,
|
|
84
100
|
(backoff.initialMs || 0) * Math.pow(backoff.factor || 1, attempts)
|
|
@@ -105,9 +121,8 @@ export function createWsClient(options = {}) {
|
|
|
105
121
|
|
|
106
122
|
function onOpen() {
|
|
107
123
|
state = 'open';
|
|
124
|
+
notifyConnection(state);
|
|
108
125
|
attempts = 0;
|
|
109
|
-
// subscribe first
|
|
110
|
-
sendRaw(makeRequest('subscribe-updates', {}));
|
|
111
126
|
// flush queue
|
|
112
127
|
while (queue.length) {
|
|
113
128
|
const req = queue.shift();
|
|
@@ -160,6 +175,7 @@ export function createWsClient(options = {}) {
|
|
|
160
175
|
|
|
161
176
|
function onClose() {
|
|
162
177
|
state = 'closed';
|
|
178
|
+
notifyConnection(state);
|
|
163
179
|
// fail all pending
|
|
164
180
|
for (const [id, p] of pending.entries()) {
|
|
165
181
|
p.reject(new Error('ws disconnected'));
|
|
@@ -175,15 +191,15 @@ export function createWsClient(options = {}) {
|
|
|
175
191
|
}
|
|
176
192
|
const url = resolveUrl();
|
|
177
193
|
try {
|
|
178
|
-
ws =
|
|
194
|
+
ws = new WebSocket(url);
|
|
179
195
|
state = 'connecting';
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
196
|
+
notifyConnection(state);
|
|
197
|
+
ws.addEventListener('open', onOpen);
|
|
198
|
+
ws.addEventListener('message', onMessage);
|
|
199
|
+
ws.addEventListener('error', () => {
|
|
184
200
|
// let close handler handle reconnect
|
|
185
201
|
});
|
|
186
|
-
|
|
202
|
+
ws.addEventListener('close', onClose);
|
|
187
203
|
} catch (err) {
|
|
188
204
|
logger.error('ws connect failed', err);
|
|
189
205
|
scheduleReconnect();
|
|
@@ -231,6 +247,17 @@ export function createWsClient(options = {}) {
|
|
|
231
247
|
set?.delete(handler);
|
|
232
248
|
};
|
|
233
249
|
},
|
|
250
|
+
/**
|
|
251
|
+
* Subscribe to connection state changes.
|
|
252
|
+
* @param {(state: ConnectionState) => void} handler
|
|
253
|
+
* @returns {() => void}
|
|
254
|
+
*/
|
|
255
|
+
onConnection(handler) {
|
|
256
|
+
connection_handlers.add(handler);
|
|
257
|
+
return () => {
|
|
258
|
+
connection_handlers.delete(handler);
|
|
259
|
+
};
|
|
260
|
+
},
|
|
234
261
|
/** Close and stop reconnecting. */
|
|
235
262
|
close() {
|
|
236
263
|
should_reconnect = false;
|
package/bin/bdui.js
CHANGED
|
@@ -10,7 +10,7 @@ const argv = process.argv.slice(2);
|
|
|
10
10
|
try {
|
|
11
11
|
const code = await main(argv);
|
|
12
12
|
if (Number.isFinite(code)) {
|
|
13
|
-
process.exitCode =
|
|
13
|
+
process.exitCode = code;
|
|
14
14
|
}
|
|
15
15
|
} catch (err) {
|
|
16
16
|
console.error(String(/** @type {any} */ (err)?.message || err));
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# ADR 001 — Push‑Only Lists (v2)
|
|
2
|
+
|
|
3
|
+
```
|
|
4
|
+
Date: 2025-10-26
|
|
5
|
+
Status: Accepted (data‑flow details superseded by ADR 002)
|
|
6
|
+
Owner: agent
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Context
|
|
10
|
+
|
|
11
|
+
The UI currently mixes push updates with read RPCs like `list-issues` and
|
|
12
|
+
`epic-status`. This ADR establishes the push‑only direction for list data and
|
|
13
|
+
removing read RPCs in list views. It predated ADR 002 which later simplified the
|
|
14
|
+
data flow further (per‑subscription stores + full‑issue payloads).
|
|
15
|
+
|
|
16
|
+
- Push streams provide everything lists need to render. See
|
|
17
|
+
`docs/protocol/issues-push-v2.md` and ADR 002. Earlier iterations used a
|
|
18
|
+
central `issues` entity cache plus `list-delta` membership; this has been
|
|
19
|
+
replaced by per‑subscription stores receiving full issue payloads.
|
|
20
|
+
|
|
21
|
+
We want every list‑shaped view (Issues, Board, Epics → children) to render
|
|
22
|
+
exclusively from local push data. Reads remain only for mutations that return a
|
|
23
|
+
single updated entity (e.g. detail view refresh).
|
|
24
|
+
|
|
25
|
+
Related docs:
|
|
26
|
+
|
|
27
|
+
- Protocol: `docs/protocol/issues-push-v2.md`
|
|
28
|
+
- Server plan: `docs/data-exchange-subscription-plan.md`
|
|
29
|
+
|
|
30
|
+
## Decision
|
|
31
|
+
|
|
32
|
+
- One active subscription per visible list. Examples (client ids):
|
|
33
|
+
- Issues tab: `tab:issues` with spec from filters via `computeIssuesSpec()`
|
|
34
|
+
- Board: `tab:board:ready|in-progress|closed|blocked`
|
|
35
|
+
- Epics list: `tab:epics` (for epic entities); children subscribe on expand as
|
|
36
|
+
`detail:{id}` with `{ type: 'issue-detail', params: { id } }`
|
|
37
|
+
- Rendering reads from two local stores only:
|
|
38
|
+
- `per‑subscription stores`: one store per active client subscription id.
|
|
39
|
+
Stores receive versioned `snapshot`/`upsert`/`delete` push envelopes with
|
|
40
|
+
full issue payloads and expose deterministic, sorted snapshots for the
|
|
41
|
+
owning view.
|
|
42
|
+
- `subscriptions`: manages subscription lifecycle and keys. Rendering reads
|
|
43
|
+
from per‑subscription stores, not from membership ids.
|
|
44
|
+
- Introduce a small selectors utility to apply view‑specific sort rules on store
|
|
45
|
+
snapshots (no composition from a central cache).
|
|
46
|
+
- Remove read RPCs used for lists: `list-issues`, `epic-status`. Keep mutation
|
|
47
|
+
RPCs and `show-issue` until detail view also reads from push cache.
|
|
48
|
+
- Tests drive views with push envelopes and `list-delta`; no RPC stubs for
|
|
49
|
+
reads.
|
|
50
|
+
|
|
51
|
+
## API Shape (Client)
|
|
52
|
+
|
|
53
|
+
Subscriptions store (already implemented):
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
// app/data/subscriptions-store.js
|
|
57
|
+
createSubscriptionStore(send) -> {
|
|
58
|
+
wireEvents(on), subscribeList(client_id, spec) -> unsubscribe,
|
|
59
|
+
selectors: { getIds(client_id), has(client_id), count(client_id) }
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Selectors utility (implemented):
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
// app/data/list-selectors.js
|
|
67
|
+
/** Compose from per‑subscription store snapshots and apply stable sort. */
|
|
68
|
+
export function createListSelectors(issueStores) {
|
|
69
|
+
return {
|
|
70
|
+
selectIssuesFor(client_id) {},
|
|
71
|
+
selectBoardColumn(client_id, mode) {},
|
|
72
|
+
selectEpicChildren(epic_id) {},
|
|
73
|
+
subscribe(fn) {}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Sorting rules:
|
|
79
|
+
|
|
80
|
+
- Issues list: priority asc (0..4), then `created_at` desc, then id asc.
|
|
81
|
+
- Board columns: preserve existing view rules (ready → priority asc, then
|
|
82
|
+
`updated_at` desc; in‑progress → `updated_at` desc; closed → `closed_at`
|
|
83
|
+
desc).
|
|
84
|
+
- Epics children: same as Issues list unless view specifies otherwise.
|
|
85
|
+
|
|
86
|
+
## Consequences
|
|
87
|
+
|
|
88
|
+
Pros:
|
|
89
|
+
|
|
90
|
+
- Consistent, snappy UI with minimal fetch logic; views are pure derives.
|
|
91
|
+
- Server can batch and coalesce; client renders at most once per envelope.
|
|
92
|
+
- Clear separation: mutations via RPC, reads via push caches.
|
|
93
|
+
|
|
94
|
+
Cons / Risks:
|
|
95
|
+
|
|
96
|
+
- Initial implementation work in views and tests.
|
|
97
|
+
- Need disciplined subscription lifecycle on route/tab changes.
|
|
98
|
+
- Requires follow‑up to migrate detail view fully to the push cache.
|
|
99
|
+
|
|
100
|
+
## Migration Checklist
|
|
101
|
+
|
|
102
|
+
Views
|
|
103
|
+
|
|
104
|
+
- [x] Issues view renders from per‑subscription stores; no `list-issues`.
|
|
105
|
+
- [x] Board renders from per‑subscription stores; no `get*` list reads.
|
|
106
|
+
- [x] Epics list/children render from per‑subscription stores; children use
|
|
107
|
+
`issue-detail` for the epic id; children come from `dependents`.
|
|
108
|
+
|
|
109
|
+
Client Data Layer
|
|
110
|
+
|
|
111
|
+
- [x] Add `app/data/list-selectors.js` with helpers listed above (UI-156).
|
|
112
|
+
- [x] Remove list read functions from `app/data/providers.js` (UI-159).
|
|
113
|
+
- [ ] Keep `getIssue` and all mutation helpers until detail view push migration
|
|
114
|
+
happens (follow‑up).
|
|
115
|
+
|
|
116
|
+
Tests
|
|
117
|
+
|
|
118
|
+
- [x] Update list/board/epics tests to use per‑subscription push envelopes
|
|
119
|
+
(UI-158).
|
|
120
|
+
- [x] Remove RPC read stubs from tests.
|
|
121
|
+
|
|
122
|
+
Docs
|
|
123
|
+
|
|
124
|
+
- [x] This ADR committed (UI-152).
|
|
125
|
+
- [x] Update protocol and architecture docs for push‑only model (UI-160).
|
|
126
|
+
|
|
127
|
+
## Notes
|
|
128
|
+
|
|
129
|
+
- Client ids used in this repo today:
|
|
130
|
+
- `tab:issues` for the Issues view
|
|
131
|
+
- `tab:board:ready|in-progress|closed|blocked` for Board columns
|
|
132
|
+
- `tab:epics` for the Epics tab; `epic:${id}` for expanded children
|
|
133
|
+
- See `app/main.js` for current subscription wiring, filter → spec mapping, and
|
|
134
|
+
per‑subscription push routing.
|