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.
Files changed (58) hide show
  1. package/CHANGES.md +14 -0
  2. package/README.md +4 -4
  3. package/app/data/list-selectors.js +103 -0
  4. package/app/data/providers.js +7 -138
  5. package/app/data/sort.js +47 -0
  6. package/app/data/subscription-issue-store.js +161 -0
  7. package/app/data/subscription-issue-stores.js +128 -0
  8. package/app/data/subscriptions-store.js +227 -0
  9. package/app/main.js +346 -66
  10. package/app/protocol.js +23 -17
  11. package/app/protocol.md +18 -15
  12. package/app/router.js +3 -0
  13. package/app/state.js +2 -0
  14. package/app/styles.css +222 -197
  15. package/app/utils/issue-id-renderer.js +2 -1
  16. package/app/utils/issue-id.js +1 -0
  17. package/app/utils/issue-type.js +2 -0
  18. package/app/utils/issue-url.js +1 -0
  19. package/app/utils/markdown.js +13 -198
  20. package/app/utils/priority-badge.js +1 -2
  21. package/app/utils/status-badge.js +1 -1
  22. package/app/utils/status.js +2 -0
  23. package/app/utils/toast.js +1 -1
  24. package/app/utils/type-badge.js +1 -3
  25. package/app/views/board.js +172 -148
  26. package/app/views/detail.js +79 -66
  27. package/app/views/epics.js +127 -74
  28. package/app/views/issue-dialog.js +9 -15
  29. package/app/views/issue-row.js +2 -3
  30. package/app/views/list.js +105 -104
  31. package/app/views/nav.js +1 -0
  32. package/app/views/new-issue-dialog.js +30 -34
  33. package/app/ws.js +10 -10
  34. package/bin/bdui.js +1 -1
  35. package/docs/adr/001-push-only-lists.md +134 -0
  36. package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +200 -0
  37. package/docs/architecture.md +34 -84
  38. package/docs/data-exchange-subscription-plan.md +198 -0
  39. package/docs/db-watching.md +2 -1
  40. package/docs/migration-v2.md +54 -0
  41. package/docs/protocol/issues-push-v2.md +179 -0
  42. package/docs/subscription-issue-store.md +112 -0
  43. package/package.json +5 -4
  44. package/server/app.js +2 -0
  45. package/server/bd.js +4 -2
  46. package/server/cli/commands.js +5 -2
  47. package/server/cli/daemon.js +19 -5
  48. package/server/cli/index.js +2 -2
  49. package/server/cli/open.js +3 -0
  50. package/server/cli/usage.js +2 -1
  51. package/server/config.js +13 -6
  52. package/server/db.js +3 -1
  53. package/server/index.js +9 -5
  54. package/server/list-adapters.js +224 -0
  55. package/server/subscriptions.js +289 -0
  56. package/server/validators.js +113 -0
  57. package/server/watcher.js +8 -8
  58. package/server/ws.js +457 -229
package/app/main.js CHANGED
@@ -1,5 +1,11 @@
1
+ /**
2
+ * @import { MessageType } from './protocol.js'
3
+ */
1
4
  import { html, render } from 'lit-html';
5
+ import { createListSelectors } from './data/list-selectors.js';
2
6
  import { createDataLayer } from './data/providers.js';
7
+ import { createSubscriptionIssueStores } from './data/subscription-issue-stores.js';
8
+ import { createSubscriptionStore } from './data/subscriptions-store.js';
3
9
  import { createHashRouter, parseHash } from './router.js';
4
10
  import { createStore } from './state.js';
5
11
  import { showToast } from './utils/toast.js';
@@ -14,6 +20,7 @@ import { createWsClient } from './ws.js';
14
20
 
15
21
  /**
16
22
  * Bootstrap the SPA shell with two panels.
23
+ *
17
24
  * @param {HTMLElement} root_element - The container element to render into.
18
25
  */
19
26
  export function bootstrap(root_element) {
@@ -43,10 +50,55 @@ export function bootstrap(root_element) {
43
50
  const detail_mount = document.getElementById('detail-panel');
44
51
  if (list_mount && issues_root && epics_root && board_root && detail_mount) {
45
52
  const client = createWsClient();
53
+ // Subscriptions: wire client events and expose subscribe/unsubscribe helpers
54
+ const subscriptions = createSubscriptionStore((type, payload) =>
55
+ client.send(type, payload)
56
+ );
57
+ // Per-subscription stores (source of truth)
58
+ const sub_issue_stores = createSubscriptionIssueStores();
59
+ // Route per-subscription push envelopes to the owning store
60
+ client.on('snapshot', (payload) => {
61
+ const p = /** @type {any} */ (payload);
62
+ const id = p && typeof p.id === 'string' ? p.id : '';
63
+ const store = id ? sub_issue_stores.getStore(id) : null;
64
+ if (store && p && p.type === 'snapshot') {
65
+ try {
66
+ store.applyPush(p);
67
+ } catch {
68
+ // ignore
69
+ }
70
+ }
71
+ });
72
+ client.on('upsert', (payload) => {
73
+ const p = /** @type {any} */ (payload);
74
+ const id = p && typeof p.id === 'string' ? p.id : '';
75
+ const store = id ? sub_issue_stores.getStore(id) : null;
76
+ if (store && p && p.type === 'upsert') {
77
+ try {
78
+ store.applyPush(p);
79
+ } catch {
80
+ // ignore
81
+ }
82
+ }
83
+ });
84
+ client.on('delete', (payload) => {
85
+ const p = /** @type {any} */ (payload);
86
+ const id = p && typeof p.id === 'string' ? p.id : '';
87
+ const store = id ? sub_issue_stores.getStore(id) : null;
88
+ if (store && p && p.type === 'delete') {
89
+ try {
90
+ store.applyPush(p);
91
+ } catch {
92
+ // ignore
93
+ }
94
+ }
95
+ });
96
+ // Derived list selectors: render from per-subscription snapshots
97
+ const listSelectors = createListSelectors(sub_issue_stores);
46
98
  // Show toasts for WebSocket connectivity changes
47
99
  /** @type {boolean} */
48
100
  let had_disconnect = false;
49
- if (typeof (/** @type {any} */ (client).onConnection) === 'function') {
101
+ if (typeof client.onConnection === 'function') {
50
102
  /** @type {(s: 'connecting'|'open'|'closed'|'reconnecting') => void} */
51
103
  const onConn = (s) => {
52
104
  if (s === 'reconnecting' || s === 'closed') {
@@ -57,11 +109,11 @@ export function bootstrap(root_element) {
57
109
  showToast('Reconnected', 'success', 2200);
58
110
  }
59
111
  };
60
- /** @type {any} */ (client).onConnection(onConn);
112
+ client.onConnection(onConn);
61
113
  }
62
114
  // Load persisted filters (status/search/type) from localStorage
63
115
  /** @type {{ status: 'all'|'open'|'in_progress'|'closed'|'ready', search: string, type: string }} */
64
- let persistedFilters = { status: 'all', search: '', type: '' };
116
+ let persisted_filters = { status: 'all', search: '', type: '' };
65
117
  try {
66
118
  const raw = window.localStorage.getItem('beads-ui.filters');
67
119
  if (raw) {
@@ -82,7 +134,7 @@ export function bootstrap(root_element) {
82
134
  }
83
135
  parsed_type = first_valid;
84
136
  }
85
- persistedFilters = {
137
+ persisted_filters = {
86
138
  status: ['all', 'open', 'in_progress', 'closed', 'ready'].includes(
87
139
  obj.status
88
140
  )
@@ -121,7 +173,7 @@ export function bootstrap(root_element) {
121
173
  if (obj && typeof obj === 'object') {
122
174
  const cf = String(obj.closed_filter || 'today');
123
175
  if (cf === 'today' || cf === '3' || cf === '7') {
124
- persistedBoard.closed_filter = /** @type {any} */ (cf);
176
+ persistedBoard.closed_filter = cf;
125
177
  }
126
178
  }
127
179
  }
@@ -130,7 +182,7 @@ export function bootstrap(root_element) {
130
182
  }
131
183
 
132
184
  const store = createStore({
133
- filters: persistedFilters,
185
+ filters: persisted_filters,
134
186
  view: last_view,
135
187
  board: persistedBoard
136
188
  });
@@ -142,7 +194,7 @@ export function bootstrap(root_element) {
142
194
  */
143
195
  const transport = async (type, payload) => {
144
196
  try {
145
- return await client.send(/** @type {any} */ (type), payload);
197
+ return await client.send(/** @type {MessageType} */ (type), payload);
146
198
  } catch {
147
199
  return [];
148
200
  }
@@ -155,7 +207,7 @@ export function bootstrap(root_element) {
155
207
  // Global New Issue dialog (UI-106) mounted at root so it is always visible
156
208
  const new_issue_dialog = createNewIssueDialog(
157
209
  root_element,
158
- (type, payload) => client.send(/** @type {any} */ (type), payload),
210
+ (type, payload) => client.send(type, payload),
159
211
  router,
160
212
  store
161
213
  );
@@ -171,16 +223,35 @@ export function bootstrap(root_element) {
171
223
  // ignore missing header
172
224
  }
173
225
 
226
+ // Local transport shim: for list-issues, serve from local listSelectors;
227
+ // otherwise forward to ws transport for mutations/show.
228
+ /**
229
+ * @param {MessageType} type
230
+ * @param {unknown} payload
231
+ */
232
+ const listTransport = async (type, payload) => {
233
+ if (type === 'list-issues') {
234
+ try {
235
+ return listSelectors.selectIssuesFor('tab:issues');
236
+ } catch {
237
+ return [];
238
+ }
239
+ }
240
+ return transport(type, payload);
241
+ };
242
+
174
243
  const issues_view = createListView(
175
244
  list_mount,
176
- transport,
245
+ /** @type {any} */ (listTransport),
177
246
  (hash) => {
178
247
  const id = parseHash(hash);
179
248
  if (id) {
180
249
  router.gotoIssue(id);
181
250
  }
182
251
  },
183
- store
252
+ store,
253
+ subscriptions,
254
+ sub_issue_stores
184
255
  );
185
256
  // Persist filter changes to localStorage
186
257
  store.subscribe((s) => {
@@ -225,12 +296,17 @@ export function bootstrap(root_element) {
225
296
  /** @type {ReturnType<typeof createDetailView> | null} */
226
297
  let detail = null;
227
298
  // Mount details into the dialog body only
228
- detail = createDetailView(dialog.getMount(), transport, (hash) => {
229
- const id = parseHash(hash);
230
- if (id) {
231
- router.gotoIssue(id);
232
- }
233
- });
299
+ detail = createDetailView(
300
+ dialog.getMount(),
301
+ transport,
302
+ (hash) => {
303
+ const id = parseHash(hash);
304
+ if (id) {
305
+ router.gotoIssue(id);
306
+ }
307
+ },
308
+ sub_issue_stores
309
+ );
234
310
 
235
311
  // If router already set a selected id (deep-link), open dialog now
236
312
  const initial_id = store.getState().selected_id;
@@ -240,9 +316,21 @@ export function bootstrap(root_element) {
240
316
  if (detail) {
241
317
  void detail.load(initial_id);
242
318
  }
319
+ // Ensure detail subscription is active on initial deep-link
320
+ const client_id = `detail:${initial_id}`;
321
+ const spec = { type: 'issue-detail', params: { id: initial_id } };
322
+ // Register store first to avoid dropping the initial snapshot
323
+ try {
324
+ sub_issue_stores.register(client_id, spec);
325
+ } catch {
326
+ // ignore
327
+ }
328
+ void subscriptions.subscribeList(client_id, spec).catch(() => {});
243
329
  }
244
330
 
245
331
  // Open/close dialog based on selected_id (always dialog; no page variant)
332
+ /** @type {null | (() => Promise<void>)} */
333
+ let unsub_detail = null;
246
334
  store.subscribe((s) => {
247
335
  const id = s.selected_id;
248
336
  if (id) {
@@ -251,6 +339,26 @@ export function bootstrap(root_element) {
251
339
  if (detail) {
252
340
  void detail.load(id);
253
341
  }
342
+ // Wire per-issue subscription for detail
343
+ const client_id = `detail:${id}`;
344
+ const spec = { type: 'issue-detail', params: { id } };
345
+ // Ensure per-subscription issue store exists before subscribing
346
+ try {
347
+ sub_issue_stores.register(client_id, spec);
348
+ } catch {
349
+ // ignore
350
+ }
351
+ // Subscribe server-side
352
+ void subscriptions
353
+ .subscribeList(client_id, spec)
354
+ .then((unsub) => {
355
+ // Unsubscribe previous if any
356
+ if (unsub_detail) {
357
+ void unsub_detail().catch(() => {});
358
+ }
359
+ unsub_detail = unsub;
360
+ })
361
+ .catch(() => {});
254
362
  } else {
255
363
  try {
256
364
  dialog.close();
@@ -261,69 +369,237 @@ export function bootstrap(root_element) {
261
369
  detail.clear();
262
370
  }
263
371
  detail_mount.hidden = true;
372
+ if (unsub_detail) {
373
+ void unsub_detail().catch(() => {});
374
+ unsub_detail = null;
375
+ }
264
376
  }
265
377
  });
266
378
 
267
- // Refresh views on push updates (target minimally and avoid flicker)
268
- // UI-114: Coalesce near-simultaneous events. When an ID-scoped update
269
- // arrives, suppress a trailing watcher-only full refresh for a short
270
- // window to avoid duplicate work and flicker.
271
- /** @type {number} */
272
- let suppress_full_until = 0;
273
- client.on('issues-changed', (payload) => {
274
- const s = store.getState();
275
- const hint_ids =
276
- payload && payload.hint && Array.isArray(payload.hint.ids)
277
- ? /** @type {string[]} */ (payload.hint.ids)
278
- : null;
379
+ // Removed: issues-changed handling. All views re-render from
380
+ // per-subscription stores which are updated by snapshot/upsert/delete.
279
381
 
280
- const now = Date.now();
281
- if (!hint_ids || hint_ids.length === 0) {
282
- if (now <= suppress_full_until) {
283
- // Drop redundant full refresh that follows a targeted update.
284
- return;
285
- }
286
- } else {
287
- // Prefer ID-scoped updates for a brief window.
288
- suppress_full_until = now + 500;
382
+ // Toggle route shells on view/detail change and persist
383
+ const data = createDataLayer(transport);
384
+ const epics_view = createEpicsView(
385
+ epics_root,
386
+ data,
387
+ (id) => router.gotoIssue(id),
388
+ subscriptions,
389
+ sub_issue_stores
390
+ );
391
+ const board_view = createBoardView(
392
+ board_root,
393
+ data,
394
+ (id) => router.gotoIssue(id),
395
+ store,
396
+ subscriptions,
397
+ sub_issue_stores
398
+ );
399
+ // Preload epics when switching to view
400
+ /**
401
+ * @param {{ selected_id: string | null, view: 'issues'|'epics'|'board', filters: any }} s
402
+ */
403
+ // --- Subscriptions: tab-level management and filter-driven updates ---
404
+ /** @type {null | (() => Promise<void>)} */
405
+ let unsub_issues_tab = null;
406
+ /** @type {null | (() => Promise<void>)} */
407
+ let unsub_epics_tab = null;
408
+ /** @type {null | (() => Promise<void>)} */
409
+ let unsub_board_ready = null;
410
+ /** @type {null | (() => Promise<void>)} */
411
+ let unsub_board_in_progress = null;
412
+ /** @type {null | (() => Promise<void>)} */
413
+ let unsub_board_closed = null;
414
+ /** @type {null | (() => Promise<void>)} */
415
+ let unsub_board_blocked = null;
416
+
417
+ /**
418
+ * Compute subscription spec for Issues tab based on filters.
419
+ *
420
+ * @param {{ status?: string }} filters
421
+ * @returns {{ type: string, params?: Record<string, string|number|boolean> }}
422
+ */
423
+ function computeIssuesSpec(filters) {
424
+ const st = String(filters?.status || 'all');
425
+ if (st === 'ready') {
426
+ return { type: 'ready-issues' };
427
+ }
428
+ if (st === 'in_progress') {
429
+ return { type: 'in-progress-issues' };
289
430
  }
431
+ if (st === 'closed') {
432
+ return { type: 'closed-issues' };
433
+ }
434
+ // "all" and "open" map to all-issues; client filters apply locally
435
+ return { type: 'all-issues' };
436
+ }
290
437
 
291
- const showing_detail = Boolean(s.selected_id);
438
+ /** @type {string|null} */
439
+ let last_issues_spec_key = null;
440
+ /**
441
+ * Ensure only the active tab has subscriptions; clean up previous.
442
+ *
443
+ * @param {{ view: 'issues'|'epics'|'board', filters: any }} s
444
+ */
445
+ function ensureTabSubscriptions(s) {
446
+ // Issues tab
447
+ if (s.view === 'issues') {
448
+ const spec = computeIssuesSpec(s.filters || {});
449
+ const key = JSON.stringify(spec);
450
+ // Register store first to capture the initial snapshot
451
+ try {
452
+ sub_issue_stores.register('tab:issues', spec);
453
+ } catch {
454
+ // ignore
455
+ }
456
+ // Only (re)subscribe if not yet subscribed or the spec changed
457
+ if (!unsub_issues_tab || key !== last_issues_spec_key) {
458
+ void subscriptions
459
+ .subscribeList('tab:issues', spec)
460
+ .then((unsub) => {
461
+ unsub_issues_tab = unsub;
462
+ last_issues_spec_key = key;
463
+ })
464
+ .catch(() => {
465
+ // ignore transport errors; retry on next change
466
+ });
467
+ }
468
+ } else if (unsub_issues_tab) {
469
+ void unsub_issues_tab().catch(() => {});
470
+ unsub_issues_tab = null;
471
+ last_issues_spec_key = null;
472
+ try {
473
+ sub_issue_stores.unregister('tab:issues');
474
+ } catch {
475
+ // ignore
476
+ }
477
+ }
292
478
 
293
- // If a top-level view is visible (and not detail), refresh that view
294
- if (!showing_detail) {
295
- if (s.view === 'issues') {
296
- void issues_view.load();
297
- } else if (s.view === 'epics') {
298
- void epics_view.load();
299
- } else if (s.view === 'board') {
300
- void board_view.load();
479
+ // Epics tab
480
+ if (s.view === 'epics') {
481
+ // Register store first to avoid race with initial snapshot
482
+ try {
483
+ sub_issue_stores.register('tab:epics', { type: 'epics' });
484
+ } catch {
485
+ // ignore
486
+ }
487
+ void subscriptions
488
+ .subscribeList('tab:epics', { type: 'epics' })
489
+ .then((unsub) => {
490
+ unsub_epics_tab = unsub;
491
+ })
492
+ .catch(() => {});
493
+ } else if (unsub_epics_tab) {
494
+ void unsub_epics_tab().catch(() => {});
495
+ unsub_epics_tab = null;
496
+ try {
497
+ sub_issue_stores.unregister('tab:epics');
498
+ } catch {
499
+ // ignore
301
500
  }
302
501
  }
303
502
 
304
- // If a detail is visible, re-fetch it when relevant or when hints are absent
305
- if (showing_detail && s.selected_id) {
306
- if (!hint_ids || hint_ids.includes(s.selected_id)) {
307
- if (detail) {
308
- void detail.load(s.selected_id);
503
+ // Board tab subscribes to lists used by columns
504
+ if (s.view === 'board') {
505
+ if (!unsub_board_ready) {
506
+ try {
507
+ sub_issue_stores.register('tab:board:ready', {
508
+ type: 'ready-issues'
509
+ });
510
+ } catch {
511
+ // ignore
512
+ }
513
+ void subscriptions
514
+ .subscribeList('tab:board:ready', { type: 'ready-issues' })
515
+ .then((u) => (unsub_board_ready = u))
516
+ .catch(() => {});
517
+ }
518
+ if (!unsub_board_in_progress) {
519
+ try {
520
+ sub_issue_stores.register('tab:board:in-progress', {
521
+ type: 'in-progress-issues'
522
+ });
523
+ } catch {
524
+ // ignore
525
+ }
526
+ void subscriptions
527
+ .subscribeList('tab:board:in-progress', {
528
+ type: 'in-progress-issues'
529
+ })
530
+ .then((u) => (unsub_board_in_progress = u))
531
+ .catch(() => {});
532
+ }
533
+ if (!unsub_board_closed) {
534
+ try {
535
+ sub_issue_stores.register('tab:board:closed', {
536
+ type: 'closed-issues'
537
+ });
538
+ } catch {
539
+ // ignore
540
+ }
541
+ void subscriptions
542
+ .subscribeList('tab:board:closed', { type: 'closed-issues' })
543
+ .then((u) => (unsub_board_closed = u))
544
+ .catch(() => {});
545
+ }
546
+ if (!unsub_board_blocked) {
547
+ try {
548
+ sub_issue_stores.register('tab:board:blocked', {
549
+ type: 'blocked-issues'
550
+ });
551
+ } catch {
552
+ // ignore
553
+ }
554
+ void subscriptions
555
+ .subscribeList('tab:board:blocked', { type: 'blocked-issues' })
556
+ .then((u) => (unsub_board_blocked = u))
557
+ .catch(() => {});
558
+ }
559
+ } else {
560
+ // Unsubscribe all board lists when leaving the board view
561
+ if (unsub_board_ready) {
562
+ void unsub_board_ready().catch(() => {});
563
+ unsub_board_ready = null;
564
+ try {
565
+ sub_issue_stores.unregister('tab:board:ready');
566
+ } catch {
567
+ // ignore
568
+ }
569
+ }
570
+ if (unsub_board_in_progress) {
571
+ void unsub_board_in_progress().catch(() => {});
572
+ unsub_board_in_progress = null;
573
+ try {
574
+ sub_issue_stores.unregister('tab:board:in-progress');
575
+ } catch {
576
+ // ignore
577
+ }
578
+ }
579
+ if (unsub_board_closed) {
580
+ void unsub_board_closed().catch(() => {});
581
+ unsub_board_closed = null;
582
+ try {
583
+ sub_issue_stores.unregister('tab:board:closed');
584
+ } catch {
585
+ // ignore
586
+ }
587
+ }
588
+ if (unsub_board_blocked) {
589
+ void unsub_board_blocked().catch(() => {});
590
+ unsub_board_blocked = null;
591
+ try {
592
+ sub_issue_stores.unregister('tab:board:blocked');
593
+ } catch {
594
+ // ignore
309
595
  }
310
596
  }
311
597
  }
312
- });
598
+ }
313
599
 
314
- // Toggle route shells on view/detail change and persist
315
- const data = createDataLayer(/** @type {any} */ (transport), client.on);
316
- const epics_view = createEpicsView(epics_root, data, (id) =>
317
- router.gotoIssue(id)
318
- );
319
- const board_view = createBoardView(
320
- board_root,
321
- data,
322
- (id) => router.gotoIssue(id),
323
- store
324
- );
325
- // Preload epics when switching to view
326
600
  /**
601
+ * Manage route visibility and list subscriptions per view.
602
+ *
327
603
  * @param {{ selected_id: string | null, view: 'issues'|'epics'|'board', filters: any }} s
328
604
  */
329
605
  const onRouteChange = (s) => {
@@ -334,6 +610,9 @@ export function bootstrap(root_element) {
334
610
  board_root.hidden = s.view !== 'board';
335
611
  // detail_mount visibility handled in subscription above
336
612
  }
613
+ // Ensure subscriptions for the active tab before loading the view to
614
+ // avoid empty initial renders due to racing list-delta.
615
+ ensureTabSubscriptions(s);
337
616
  if (!s.selected_id && s.view === 'epics') {
338
617
  void epics_view.load();
339
618
  }
@@ -350,12 +629,13 @@ export function bootstrap(root_element) {
350
629
  // Ensure initial state is reflected (fixes reload on #/epics)
351
630
  onRouteChange(store.getState());
352
631
 
632
+ // Removed redundant filter-change subscription: handled by ensureTabSubscriptions
633
+
353
634
  // Keyboard shortcuts: Ctrl/Cmd+N opens new issue; Ctrl/Cmd+Enter submits inside dialog
354
635
  window.addEventListener('keydown', (ev) => {
355
636
  const is_modifier = ev.ctrlKey || ev.metaKey;
356
637
  const key = String(ev.key || '').toLowerCase();
357
- /** @type {HTMLElement} */
358
- const target = /** @type {any} */ (ev.target);
638
+ const target = /** @type {HTMLElement} */ (ev.target);
359
639
  const tag =
360
640
  target && target.tagName ? String(target.tagName).toLowerCase() : '';
361
641
  const is_editable =
package/app/protocol.js CHANGED
@@ -6,17 +6,10 @@
6
6
  * - Client → Server uses RequestEnvelope.
7
7
  * - Server → Client uses ReplyEnvelope.
8
8
  * - Every request is correlated by `id` in replies.
9
- * - Server can also send unsolicited events (e.g., `issues-changed`).
10
- *
11
- * Versioning
12
- * - Increment `PROTOCOL_VERSION` on breaking changes.
13
- * - Add new message types without breaking existing ones when possible.
9
+ * - Server can also send unsolicited events (e.g., subscription `snapshot`).
14
10
  */
15
11
 
16
- /** @constant {string} */
17
- export const PROTOCOL_VERSION = '1.0.0';
18
-
19
- /** @typedef {'list-issues'|'show-issue'|'update-status'|'edit-text'|'update-priority'|'create-issue'|'list-ready'|'subscribe-updates'|'issues-changed'|'dep-add'|'dep-remove'|'epic-status'|'update-assignee'|'label-add'|'label-remove'} MessageType */
12
+ /** @typedef {'list-issues'|'update-status'|'edit-text'|'update-priority'|'create-issue'|'list-ready'|'dep-add'|'dep-remove'|'epic-status'|'update-assignee'|'label-add'|'label-remove'|'subscribe-list'|'unsubscribe-list'|'snapshot'|'upsert'|'delete'} MessageType */
20
13
 
21
14
  /**
22
15
  * @typedef {Object} RequestEnvelope
@@ -44,24 +37,28 @@ export const PROTOCOL_VERSION = '1.0.0';
44
37
  /** @type {MessageType[]} */
45
38
  export const MESSAGE_TYPES = /** @type {const} */ ([
46
39
  'list-issues',
47
- 'show-issue',
48
40
  'update-status',
49
41
  'edit-text',
50
42
  'update-priority',
51
43
  'create-issue',
52
44
  'list-ready',
53
- 'subscribe-updates',
54
- 'issues-changed',
55
45
  'dep-add',
56
46
  'dep-remove',
57
47
  'epic-status',
58
48
  'update-assignee',
59
49
  'label-add',
60
- 'label-remove'
50
+ 'label-remove',
51
+ 'subscribe-list',
52
+ 'unsubscribe-list',
53
+ // vNext per-subscription full-issue push events
54
+ 'snapshot',
55
+ 'upsert',
56
+ 'delete'
61
57
  ]);
62
58
 
63
59
  /**
64
60
  * Generate a lexically sortable request id.
61
+ *
65
62
  * @returns {string}
66
63
  */
67
64
  export function nextId() {
@@ -72,6 +69,7 @@ export function nextId() {
72
69
 
73
70
  /**
74
71
  * Create a request envelope.
72
+ *
75
73
  * @param {MessageType} type - Message type.
76
74
  * @param {unknown} [payload] - Message payload.
77
75
  * @param {string} [id] - Optional id; generated if omitted.
@@ -83,6 +81,7 @@ export function makeRequest(type, payload, id = nextId()) {
83
81
 
84
82
  /**
85
83
  * Create a successful reply envelope for a given request.
84
+ *
86
85
  * @param {RequestEnvelope} req - Original request.
87
86
  * @param {unknown} [payload] - Reply payload.
88
87
  * @returns {ReplyEnvelope}
@@ -93,10 +92,11 @@ export function makeOk(req, payload) {
93
92
 
94
93
  /**
95
94
  * Create an error reply envelope for a given request.
95
+ *
96
96
  * @param {RequestEnvelope} req - Original request.
97
- * @param {string} code - Error code.
98
- * @param {string} message - Error message.
99
- * @param {unknown} [details] - Extra details.
97
+ * @param {string} code
98
+ * @param {string} message
99
+ * @param {unknown} [details]
100
100
  * @returns {ReplyEnvelope}
101
101
  */
102
102
  export function makeError(req, code, message, details) {
@@ -110,6 +110,7 @@ export function makeError(req, code, message, details) {
110
110
 
111
111
  /**
112
112
  * Check if a value is a plain object.
113
+ *
113
114
  * @param {unknown} value
114
115
  * @returns {value is Record<string, unknown>}
115
116
  */
@@ -119,6 +120,7 @@ function isRecord(value) {
119
120
 
120
121
  /**
121
122
  * Type guard for MessageType values.
123
+ *
122
124
  * @param {unknown} value
123
125
  * @returns {value is MessageType}
124
126
  */
@@ -131,6 +133,7 @@ export function isMessageType(value) {
131
133
 
132
134
  /**
133
135
  * Type guard for RequestEnvelope.
136
+ *
134
137
  * @param {unknown} value
135
138
  * @returns {value is RequestEnvelope}
136
139
  */
@@ -147,6 +150,7 @@ export function isRequest(value) {
147
150
 
148
151
  /**
149
152
  * Type guard for ReplyEnvelope.
153
+ *
150
154
  * @param {unknown} value
151
155
  * @returns {value is ReplyEnvelope}
152
156
  */
@@ -162,7 +166,7 @@ export function isReply(value) {
162
166
  return false;
163
167
  }
164
168
  if (value.ok === false) {
165
- const err = /** @type {any} */ (value).error;
169
+ const err = value.error;
166
170
  if (
167
171
  !isRecord(err) ||
168
172
  typeof err.code !== 'string' ||
@@ -177,6 +181,7 @@ export function isReply(value) {
177
181
  /**
178
182
  * Normalize and validate an incoming JSON value as a RequestEnvelope.
179
183
  * Throws a user-friendly error if invalid.
184
+ *
180
185
  * @param {unknown} json
181
186
  * @returns {RequestEnvelope}
182
187
  */
@@ -189,6 +194,7 @@ export function decodeRequest(json) {
189
194
 
190
195
  /**
191
196
  * Normalize and validate an incoming JSON value as a ReplyEnvelope.
197
+ *
192
198
  * @param {unknown} json
193
199
  * @returns {ReplyEnvelope}
194
200
  */