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
@@ -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('issues-changed', (payload) => { ... });
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 = /** @type {any} */ (new WebSocket(url));
194
+ ws = new WebSocket(url);
179
195
  state = 'connecting';
180
- const s = /** @type {any} */ (ws);
181
- s.addEventListener('open', onOpen);
182
- s.addEventListener('message', onMessage);
183
- s.addEventListener('error', () => {
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
- s.addEventListener('close', onClose);
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 = /** @type {number} */ (code);
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.