beads-ui 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGES.md +14 -0
- package/README.md +4 -4
- package/app/data/list-selectors.js +103 -0
- package/app/data/providers.js +7 -138
- package/app/data/sort.js +47 -0
- package/app/data/subscription-issue-store.js +161 -0
- package/app/data/subscription-issue-stores.js +128 -0
- package/app/data/subscriptions-store.js +227 -0
- package/app/main.js +346 -66
- package/app/protocol.js +23 -17
- package/app/protocol.md +18 -15
- package/app/router.js +3 -0
- package/app/state.js +2 -0
- package/app/styles.css +222 -197
- package/app/utils/issue-id-renderer.js +2 -1
- package/app/utils/issue-id.js +1 -0
- package/app/utils/issue-type.js +2 -0
- package/app/utils/issue-url.js +1 -0
- package/app/utils/markdown.js +13 -198
- package/app/utils/priority-badge.js +1 -2
- package/app/utils/status-badge.js +1 -1
- package/app/utils/status.js +2 -0
- package/app/utils/toast.js +1 -1
- package/app/utils/type-badge.js +1 -3
- package/app/views/board.js +172 -148
- package/app/views/detail.js +79 -66
- package/app/views/epics.js +127 -74
- package/app/views/issue-dialog.js +9 -15
- package/app/views/issue-row.js +2 -3
- package/app/views/list.js +105 -104
- package/app/views/nav.js +1 -0
- package/app/views/new-issue-dialog.js +30 -34
- package/app/ws.js +10 -10
- package/bin/bdui.js +1 -1
- package/docs/adr/001-push-only-lists.md +134 -0
- package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +200 -0
- package/docs/architecture.md +34 -84
- package/docs/data-exchange-subscription-plan.md +198 -0
- package/docs/db-watching.md +2 -1
- package/docs/migration-v2.md +54 -0
- package/docs/protocol/issues-push-v2.md +179 -0
- package/docs/subscription-issue-store.md +112 -0
- package/package.json +5 -4
- package/server/app.js +2 -0
- package/server/bd.js +4 -2
- package/server/cli/commands.js +5 -2
- package/server/cli/daemon.js +19 -5
- package/server/cli/index.js +2 -2
- package/server/cli/open.js +3 -0
- package/server/cli/usage.js +2 -1
- package/server/config.js +13 -6
- package/server/db.js +3 -1
- package/server/index.js +9 -5
- package/server/list-adapters.js +224 -0
- package/server/subscriptions.js +289 -0
- package/server/validators.js +113 -0
- package/server/watcher.js +8 -8
- package/server/ws.js +457 -229
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# Data Exchange Model — Subscription‑Based Updates (Full‑Issue)
|
|
2
|
+
|
|
3
|
+
```
|
|
4
|
+
Date: 2025-10-25
|
|
5
|
+
Status: Implemented
|
|
6
|
+
Owner: agent
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Goals
|
|
10
|
+
|
|
11
|
+
- Replace ad-hoc list fetching with subscription-based incremental updates.
|
|
12
|
+
- Minimize complexity; send full‑issue payloads in envelopes targeted to a
|
|
13
|
+
specific subscription key.
|
|
14
|
+
- Ensure consistent, race-free updates around user-triggered mutations.
|
|
15
|
+
- Keep UI models per-subscription to simplify rendering and memory usage.
|
|
16
|
+
|
|
17
|
+
## Scope
|
|
18
|
+
|
|
19
|
+
- Server and client for `beads-ui`.
|
|
20
|
+
- Uses `bd` CLI for data access; no DB schema changes.
|
|
21
|
+
|
|
22
|
+
## Subscription Types
|
|
23
|
+
|
|
24
|
+
- `all-issues`
|
|
25
|
+
- `epics` // Removed: `issues-for-epic` (use `issue-detail` for the epic and
|
|
26
|
+
render its `dependents`)
|
|
27
|
+
- `blocked-issues`
|
|
28
|
+
- `ready-issues`
|
|
29
|
+
- `in-progress-issues`
|
|
30
|
+
- `closed-issues` (special filtering noted below)
|
|
31
|
+
|
|
32
|
+
## Server Architecture
|
|
33
|
+
|
|
34
|
+
### Subscription Registry (Issue List Subscriptions)
|
|
35
|
+
|
|
36
|
+
- Keyed by `subscriptionKey = type + JSON.stringify(params)`.
|
|
37
|
+
- Value:
|
|
38
|
+
`{ itemsById: Map<string, { updated_at: string, closed_at: string|null }>, subscribers: Set<SubscriberId>, lastRunAt?: number }`.
|
|
39
|
+
- Each subscribe request either attaches to an existing registry entry or
|
|
40
|
+
creates a new one.
|
|
41
|
+
- No TTL: subscriptions are evicted only on WebSocket disconnect. Unsubscribe
|
|
42
|
+
removes a subscriber from the set but keeps the registry entry until the
|
|
43
|
+
connection closes.
|
|
44
|
+
|
|
45
|
+
### Mapping to `bd` Commands
|
|
46
|
+
|
|
47
|
+
- `all-issues` → `bd list` (default/open)
|
|
48
|
+
- `epics` → `bd list --type epic` (or equivalent)
|
|
49
|
+
- `detail:{id}` → `bd show <id> --json` (use `dependents` from the epic detail
|
|
50
|
+
for children)
|
|
51
|
+
- `blocked-issues` → `bd list --blocked`
|
|
52
|
+
- `ready-issues` → `bd ready --limit 1000`
|
|
53
|
+
- `in-progress-issues` → `bd list --status in_progress`
|
|
54
|
+
- `closed-issues` → `bd list --status closed` (then filter first; see Special
|
|
55
|
+
Cases)
|
|
56
|
+
|
|
57
|
+
Notes:
|
|
58
|
+
|
|
59
|
+
- Exact flags depend on `bd`; create adapters that encapsulate CLI details and
|
|
60
|
+
normalize results.
|
|
61
|
+
|
|
62
|
+
### Refresh Algorithm (per run)
|
|
63
|
+
|
|
64
|
+
1. Execute mapped `bd` command to get the full list of `issues` for the spec.
|
|
65
|
+
2. If subscription is `closed-issues` with a filter, apply it before step 3.
|
|
66
|
+
3. Compare with the registry’s last known items for this subscription key.
|
|
67
|
+
4. For new or changed items, emit `upsert` envelopes with the full issue payload
|
|
68
|
+
to all subscribers of the key on the current connection.
|
|
69
|
+
5. For removed items, emit `delete` envelopes with `issue_id`.
|
|
70
|
+
6. Update the registry’s state for the key.
|
|
71
|
+
|
|
72
|
+
### Special Case: Closed Issues Filtering
|
|
73
|
+
|
|
74
|
+
- Apply `since` filter (epoch milliseconds) before diffing to avoid spurious
|
|
75
|
+
updates when reloading older closed items. Only items with
|
|
76
|
+
`closed_at >= since` are included. Invalid or non-positive `since` values are
|
|
77
|
+
ignored.
|
|
78
|
+
- Filters are part of subscription params to keep deterministic diffing.
|
|
79
|
+
|
|
80
|
+
### Migration
|
|
81
|
+
|
|
82
|
+
This change replaces request/response list reads and id‑only deltas with
|
|
83
|
+
subscription‑based, full‑issue push envelopes.
|
|
84
|
+
|
|
85
|
+
Client migration steps:
|
|
86
|
+
|
|
87
|
+
- Replace list fetch calls with `subscribe-list`/`unsubscribe-list` messages.
|
|
88
|
+
- Maintain a per‑subscription local store keyed by the client `id`.
|
|
89
|
+
- Apply `snapshot`/`upsert`/`delete` envelopes in revision order; render from
|
|
90
|
+
`store.snapshot()`.
|
|
91
|
+
- Remove any legacy polling timers; updates now arrive via server push.
|
|
92
|
+
- For closed issue feeds, pass a `params.since` value (epoch ms) that reflects
|
|
93
|
+
the UI’s filter horizon if needed server‑side.
|
|
94
|
+
|
|
95
|
+
### Watcher Integration (DB Updates)
|
|
96
|
+
|
|
97
|
+
- A file/DB watcher signals any data change.
|
|
98
|
+
- On signal, for each active subscription: re-run its mapped `bd` command → diff
|
|
99
|
+
→ push deltas to all subscribers.
|
|
100
|
+
- Backpressure: coalesce multiple watcher events into a single run per
|
|
101
|
+
subscription (leading-edge, with trailing-edge within 50–100ms).
|
|
102
|
+
|
|
103
|
+
### User Mutations (Race Control)
|
|
104
|
+
|
|
105
|
+
When client requests a change (e.g., update status):
|
|
106
|
+
|
|
107
|
+
1. Execute the explicit protocol mutation (mapped to a concrete `bd` command
|
|
108
|
+
under the hood; no arbitrary commands allowed).
|
|
109
|
+
2. In parallel, attach a once-listener to the watcher that resolves on the next
|
|
110
|
+
change event (no debounce) or a 500ms timeout, whichever occurs first.
|
|
111
|
+
3. After the promise resolves, for each affected subscription, run the standard
|
|
112
|
+
refresh/diff/push routine exactly once.
|
|
113
|
+
4. During the pending mutation window, suppress watcher-triggered refreshes for
|
|
114
|
+
affected subscriptions to avoid duplicate pushes.
|
|
115
|
+
|
|
116
|
+
### Error Handling
|
|
117
|
+
|
|
118
|
+
- Validate subscription params; return structured errors.
|
|
119
|
+
- For `bd` failures, include stderr and exit code; do not crash subscriptions.
|
|
120
|
+
- If a subscriber disconnects mid-push, drop silently and clean up.
|
|
121
|
+
|
|
122
|
+
## Client Architecture
|
|
123
|
+
|
|
124
|
+
### Local Store per Subscription
|
|
125
|
+
|
|
126
|
+
- Keyed by `subscriptionKey`.
|
|
127
|
+
- Value: `{ itemsById: Map<string, Issue>, lastAppliedAt: number }`.
|
|
128
|
+
- On `{ added, updated, removed }`, update `itemsById` accordingly and request
|
|
129
|
+
view re-render.
|
|
130
|
+
- Tabs and epic expansion toggle subscribe/unsubscribe appropriately.
|
|
131
|
+
|
|
132
|
+
### UI Flow
|
|
133
|
+
|
|
134
|
+
- Tab switch: unsubscribe previous, subscribe new.
|
|
135
|
+
- Epic toggle: subscribe/unsubscribe `detail:{id}` with
|
|
136
|
+
`{ type: 'issue-detail', params: { id } }`.
|
|
137
|
+
- Components derive view state from the local store snapshot.
|
|
138
|
+
|
|
139
|
+
## Wire Protocol (vNext)
|
|
140
|
+
|
|
141
|
+
### Messages: Client → Server
|
|
142
|
+
|
|
143
|
+
- `subscribe-list` `{ id: string, type: string, params?: object }`
|
|
144
|
+
- `unsubscribe-list` `{ id: string }`
|
|
145
|
+
- Explicit mutation messages (enumerated in the protocol; no generic command
|
|
146
|
+
pipe). The set mirrors the main protocol (update-status, edit-text,
|
|
147
|
+
update-priority, update-assignee, create-issue, dep-add/remove,
|
|
148
|
+
label-add/remove).
|
|
149
|
+
|
|
150
|
+
### Messages: Server → Client (Per‑Subscription)
|
|
151
|
+
|
|
152
|
+
All envelopes include a per‑subscription `revision` (monotonic, starting at 1),
|
|
153
|
+
and the client subscription `id`.
|
|
154
|
+
|
|
155
|
+
- `snapshot` `{ id, schema, revision, issues: Issue[] }`
|
|
156
|
+
- `upsert` `{ id, schema, revision, issue: Issue }`
|
|
157
|
+
- `delete` `{ id, schema, revision, issue_id: string }`
|
|
158
|
+
|
|
159
|
+
Notes
|
|
160
|
+
|
|
161
|
+
- Initial subscribe triggers a single `snapshot` for the requesting `id` only.
|
|
162
|
+
- Subsequent refresh runs emit `upsert`/`delete` events to all subscribers of
|
|
163
|
+
the same subscription key on that connection.
|
|
164
|
+
- Clients MUST apply envelopes in `revision` order and ignore stale revisions.
|
|
165
|
+
|
|
166
|
+
## Concurrency & Ordering Guarantees
|
|
167
|
+
|
|
168
|
+
- Per-subscription ordering: server serializes diff runs per key.
|
|
169
|
+
- Deltas are applied in order on the client; no interleaving for a given `id`.
|
|
170
|
+
- Mutations provide “eventually up-to-date” guarantee via the once-listener +
|
|
171
|
+
timeout.
|
|
172
|
+
|
|
173
|
+
## Observability
|
|
174
|
+
|
|
175
|
+
- Basic development logging only; no telemetry collection for message rates.
|
|
176
|
+
|
|
177
|
+
## Security
|
|
178
|
+
|
|
179
|
+
- Only explicit mutation operations are implemented by the protocol; no
|
|
180
|
+
arbitrary commands from clients.
|
|
181
|
+
- Reject unknown subscription types; enforce param schemas.
|
|
182
|
+
|
|
183
|
+
## Testing Strategy
|
|
184
|
+
|
|
185
|
+
- Unit: diffing, registry, adapter mapping, filter logic.
|
|
186
|
+
- Integration: watcher → refresh → push flow; mutation window once-only
|
|
187
|
+
behavior.
|
|
188
|
+
- E2E: tab switching, epic expansion, status changes while updates stream.
|
|
189
|
+
|
|
190
|
+
## Release Notes
|
|
191
|
+
|
|
192
|
+
- Breaking change: Clients must adopt `snapshot`/`upsert`/`delete` envelopes and
|
|
193
|
+
per‑subscription stores. Previous polling and id‑only list deltas are removed.
|
|
194
|
+
|
|
195
|
+
## Open Questions
|
|
196
|
+
|
|
197
|
+
- Exact `bd` flags for each list type; confirm and codify.
|
|
198
|
+
- Closed-issue filter semantics (date range vs. other criteria).
|
package/docs/db-watching.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# DB Watching and Resolution
|
|
2
2
|
|
|
3
3
|
The server watches the active beads SQLite database file for changes and
|
|
4
|
-
|
|
4
|
+
schedules a refresh of active list subscriptions. Clients receive
|
|
5
|
+
`snapshot`/`upsert`/`delete` envelopes for their active subscriptions.
|
|
5
6
|
|
|
6
7
|
## Resolution Order
|
|
7
8
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Migration: Push‑Only Per‑Subscription Stores (Breaking)
|
|
2
|
+
|
|
3
|
+
```
|
|
4
|
+
Date: 2025-10-26
|
|
5
|
+
Status: Final
|
|
6
|
+
Owner: agent
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
This release replaces legacy list reads and id‑only list deltas with
|
|
10
|
+
per‑subscription push envelopes that carry full issue payloads. There is no
|
|
11
|
+
compatibility mode and no feature flags.
|
|
12
|
+
|
|
13
|
+
## Required Versions
|
|
14
|
+
|
|
15
|
+
- beads‑ui: 0.2.0 or later (includes the server)
|
|
16
|
+
- Node.js: >= 22 (see `package.json` engines)
|
|
17
|
+
|
|
18
|
+
Upgrade:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
npm i -g beads-ui@latest
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## What Changed
|
|
25
|
+
|
|
26
|
+
- New protocol: `snapshot` / `upsert` / `delete` envelopes and a
|
|
27
|
+
per‑subscription `revision`.
|
|
28
|
+
- One store per list: views render from a `SubscriptionIssueStore` created for
|
|
29
|
+
each active subscription id.
|
|
30
|
+
- Removed: central issues store and delta fan‑out.
|
|
31
|
+
- Removed: legacy read RPCs `list-issues` and `epic-status`.
|
|
32
|
+
|
|
33
|
+
## Migration Checklist
|
|
34
|
+
|
|
35
|
+
- Replace list reads with `subscribe-list`/`unsubscribe-list`.
|
|
36
|
+
- Create a `SubscriptionIssueStore` at view mount and wire the WS client to call
|
|
37
|
+
`store.applyPush(payload)` for `snapshot`/`upsert`/`delete`.
|
|
38
|
+
- Render from `store.snapshot()`; remove code paths that read from a central
|
|
39
|
+
issue cache.
|
|
40
|
+
- Delete dead selectors/helpers that depended on the central cache.
|
|
41
|
+
- Verify reconnect flows: a fresh `snapshot` (rev 1) replaces state cleanly.
|
|
42
|
+
|
|
43
|
+
## Notes
|
|
44
|
+
|
|
45
|
+
- No telemetry or phased rollout was implemented; ensure the UI and server are
|
|
46
|
+
updated together. Older clients will not function with the new server.
|
|
47
|
+
- For closed‑issues feeds, prefer passing a `since` param where applicable to
|
|
48
|
+
keep snapshots small.
|
|
49
|
+
|
|
50
|
+
## References
|
|
51
|
+
|
|
52
|
+
- `docs/protocol/issues-push-v2.md`
|
|
53
|
+
- `docs/subscription-issue-store.md`
|
|
54
|
+
- ADR 002 — Per‑Subscription Stores and Full‑Issue Push
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# Subscription Push Protocol — per‑subscription full‑issue envelopes (Breaking)
|
|
2
|
+
|
|
3
|
+
```
|
|
4
|
+
Date: 2025-10-26
|
|
5
|
+
Status: Implemented
|
|
6
|
+
Owner: agent
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
This document specifies the push‑only protocol used by beads‑ui to deliver list
|
|
10
|
+
updates from the local server to the client. It replaces the legacy
|
|
11
|
+
notify‑then‑fetch model. There is no version negotiation or fallback.
|
|
12
|
+
|
|
13
|
+
## Overview
|
|
14
|
+
|
|
15
|
+
- Transport: single WebSocket connection per client
|
|
16
|
+
- Encoding: JSON text frames
|
|
17
|
+
- Subscriptions: one client‑chosen `id` per active list subscription
|
|
18
|
+
- Delivery: per‑subscription envelopes with full issue payloads
|
|
19
|
+
- Messages: `snapshot` | `upsert` | `delete`
|
|
20
|
+
- Ordering: strictly increasing `revision` per subscription key and connection
|
|
21
|
+
|
|
22
|
+
## Envelopes
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
export type SnapshotEnvelope = {
|
|
26
|
+
type: 'snapshot';
|
|
27
|
+
id: string; // client subscription id
|
|
28
|
+
revision: number; // starts at 1 and increments per envelope
|
|
29
|
+
issues: Issue[]; // full list for this subscription
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type UpsertEnvelope = {
|
|
33
|
+
type: 'upsert';
|
|
34
|
+
id: string;
|
|
35
|
+
revision: number;
|
|
36
|
+
issue: Issue; // full issue payload
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type DeleteEnvelope = {
|
|
40
|
+
type: 'delete';
|
|
41
|
+
id: string;
|
|
42
|
+
revision: number;
|
|
43
|
+
issue_id: string; // id only
|
|
44
|
+
};
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Notes
|
|
48
|
+
|
|
49
|
+
- Server serializes refresh runs per subscription key and emits envelopes in
|
|
50
|
+
`revision` order. Clients MUST ignore any envelope with `revision <=` the last
|
|
51
|
+
applied for the same `id`.
|
|
52
|
+
- Clients SHOULD treat `upsert` as idempotent and MAY additionally guard on an
|
|
53
|
+
`issue.updated_at` timestamp to ignore stale updates racing with local state.
|
|
54
|
+
|
|
55
|
+
## Handshake (subscribe‑list)
|
|
56
|
+
|
|
57
|
+
Client subscribes to a list with a chosen `id`, a `type`, and optional `params`.
|
|
58
|
+
|
|
59
|
+
Client → Server
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"id": "req-1",
|
|
64
|
+
"type": "subscribe-list",
|
|
65
|
+
"payload": { "id": "ready", "type": "ready-issues" }
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Server → Client
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"id": "req-1",
|
|
74
|
+
"ok": true,
|
|
75
|
+
"type": "subscribe-list",
|
|
76
|
+
"payload": { "id": "ready", "key": "ready-issues:{}" }
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Immediately after the ack, the server sends a `snapshot` envelope containing the
|
|
81
|
+
full list for that subscription `id` with `revision: 1`.
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{
|
|
85
|
+
"id": "evt-1730000000000",
|
|
86
|
+
"ok": true,
|
|
87
|
+
"type": "snapshot",
|
|
88
|
+
"payload": {
|
|
89
|
+
"type": "snapshot",
|
|
90
|
+
"id": "ready",
|
|
91
|
+
"revision": 1,
|
|
92
|
+
"issues": [{ "id": "UI-1", "title": "..." }]
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Updates
|
|
98
|
+
|
|
99
|
+
Subsequent refreshes emit `upsert` and `delete` envelopes as the list changes.
|
|
100
|
+
|
|
101
|
+
```json
|
|
102
|
+
{
|
|
103
|
+
"id": "evt-1730000000100",
|
|
104
|
+
"ok": true,
|
|
105
|
+
"type": "upsert",
|
|
106
|
+
"payload": {
|
|
107
|
+
"type": "upsert",
|
|
108
|
+
"id": "ready",
|
|
109
|
+
"revision": 2,
|
|
110
|
+
"issue": { "id": "UI-2", "status": "in_progress" }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
```json
|
|
116
|
+
{
|
|
117
|
+
"id": "evt-1730000000200",
|
|
118
|
+
"ok": true,
|
|
119
|
+
"type": "delete",
|
|
120
|
+
"payload": {
|
|
121
|
+
"type": "delete",
|
|
122
|
+
"id": "ready",
|
|
123
|
+
"revision": 3,
|
|
124
|
+
"issue_id": "UI-9"
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Reconnect Behavior
|
|
130
|
+
|
|
131
|
+
- On reconnect, clients resubscribe using the same `id` values as needed. The
|
|
132
|
+
server treats this as a new connection and sends a fresh `snapshot` with
|
|
133
|
+
`revision: 1` for each active subscription.
|
|
134
|
+
|
|
135
|
+
## Diagrams
|
|
136
|
+
|
|
137
|
+
```mermaid
|
|
138
|
+
sequenceDiagram
|
|
139
|
+
participant C as Client
|
|
140
|
+
participant S as Server
|
|
141
|
+
C->>S: subscribe-list { id, type, params }
|
|
142
|
+
S-->>C: ack { id, key }
|
|
143
|
+
S-->>C: snapshot { id, schema, revision:1, issues:[...] }
|
|
144
|
+
S-->>C: upsert/delete { id, schema, revision:n, ... }
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
```mermaid
|
|
148
|
+
stateDiagram-v2
|
|
149
|
+
[*] --> Idle
|
|
150
|
+
Idle --> Subscribed: subscribe-list(id)
|
|
151
|
+
Subscribed --> Subscribed: snapshot(rev=1)
|
|
152
|
+
Subscribed --> Subscribed: upsert/delete(rev++)
|
|
153
|
+
Subscribed --> Idle: unsubscribe-list(id) / disconnect
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Client Responsibilities
|
|
157
|
+
|
|
158
|
+
- Maintain one `SubscriptionIssueStore` per active subscription `id`.
|
|
159
|
+
- Apply envelopes strictly in `revision` order; ignore stale revisions.
|
|
160
|
+
- Render list components from `store.snapshot()` (deterministic order).
|
|
161
|
+
- Dispose stores on route/tab changes.
|
|
162
|
+
|
|
163
|
+
Detail view
|
|
164
|
+
|
|
165
|
+
- Detail pages use the same mechanism with a single‑item subscription, e.g.
|
|
166
|
+
`{ type: 'issue-detail', params: { id: 'UI-1' } }` under a client id like
|
|
167
|
+
`detail:UI-1`. The server returns a one‑element list for `snapshot` and
|
|
168
|
+
`upsert` events.
|
|
169
|
+
|
|
170
|
+
## Rollout and Compatibility
|
|
171
|
+
|
|
172
|
+
- Breaking change: no flags and no compatibility layer with the legacy
|
|
173
|
+
notify‑then‑fetch flow. Update both client and server together.
|
|
174
|
+
|
|
175
|
+
## See Also
|
|
176
|
+
|
|
177
|
+
- ADR 002 — Per‑Subscription Stores and Full‑Issue Push
|
|
178
|
+
- `docs/data-exchange-subscription-plan.md` (server refresh and publish model)
|
|
179
|
+
- `docs/subscription-issue-store.md` (store API and usage examples)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# SubscriptionIssueStore — API and Usage Examples
|
|
2
|
+
|
|
3
|
+
```
|
|
4
|
+
Date: 2025-10-26
|
|
5
|
+
Status: Implemented
|
|
6
|
+
Owner: agent
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The `SubscriptionIssueStore` is a per‑subscription in‑memory store that owns the
|
|
10
|
+
issues for a single list subscription. It applies server push envelopes
|
|
11
|
+
(`snapshot`/`upsert`/`delete`) in revision order and exposes a deterministic,
|
|
12
|
+
read‑only snapshot for rendering.
|
|
13
|
+
|
|
14
|
+
See also: `docs/protocol/issues-push-v2.md` for the wire protocol.
|
|
15
|
+
|
|
16
|
+
## API
|
|
17
|
+
|
|
18
|
+
Factory: `app/data/subscription-issue-store.js`
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
import { createSubscriptionIssueStore } from '../app/data/subscription-issue-store.js';
|
|
22
|
+
|
|
23
|
+
// Create at view mount (id is client-chosen)
|
|
24
|
+
const store = createSubscriptionIssueStore('ready');
|
|
25
|
+
|
|
26
|
+
// Listen for changes
|
|
27
|
+
const unsubscribe = store.subscribe(() => {
|
|
28
|
+
render(store.snapshot());
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Apply push envelopes from the WebSocket client
|
|
32
|
+
ws.on('message', (evt) => {
|
|
33
|
+
const msg = JSON.parse(evt.data);
|
|
34
|
+
if (msg && msg.ok === true && msg.payload && msg.payload.id === 'ready') {
|
|
35
|
+
// payload has { type, id, schema, revision, ... }
|
|
36
|
+
store.applyPush(msg.payload);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Read helpers
|
|
41
|
+
store.size(); // number of issues
|
|
42
|
+
store.getById('UI-1'); // lookup by id
|
|
43
|
+
|
|
44
|
+
// Dispose on unmount
|
|
45
|
+
unsubscribe();
|
|
46
|
+
store.dispose();
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Options: deterministic sort can be customized per list via the optional
|
|
50
|
+
`{ sort(a,b) }` parameter when constructing the store.
|
|
51
|
+
|
|
52
|
+
## Subscribing to a List
|
|
53
|
+
|
|
54
|
+
Pair store creation with the subscribe‑list handshake. The server will send a
|
|
55
|
+
`snapshot` immediately after the ack, followed by `upsert`/`delete`.
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
// Request a subscription
|
|
59
|
+
socket.send(
|
|
60
|
+
JSON.stringify({
|
|
61
|
+
id: 'req-1',
|
|
62
|
+
type: 'subscribe-list',
|
|
63
|
+
payload: { id: 'ready', type: 'ready-issues' }
|
|
64
|
+
})
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
socket.addEventListener('message', (ev) => {
|
|
68
|
+
const frame = JSON.parse(ev.data);
|
|
69
|
+
if (frame.ok && frame.type === 'snapshot' && frame.payload.id === 'ready') {
|
|
70
|
+
store.applyPush(frame.payload);
|
|
71
|
+
}
|
|
72
|
+
if (frame.ok && frame.type === 'upsert' && frame.payload.id === 'ready') {
|
|
73
|
+
store.applyPush(frame.payload);
|
|
74
|
+
}
|
|
75
|
+
if (frame.ok && frame.type === 'delete' && frame.payload.id === 'ready') {
|
|
76
|
+
store.applyPush(frame.payload);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Rendering Pattern (List component)
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
/** @param {{ store: ReturnType<typeof createSubscriptionIssueStore> }} props */
|
|
85
|
+
export function ListView({ store }) {
|
|
86
|
+
let items = store.snapshot();
|
|
87
|
+
|
|
88
|
+
const un = store.subscribe(() => {
|
|
89
|
+
items = store.snapshot();
|
|
90
|
+
requestRender();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// framework-specific teardown
|
|
94
|
+
onUnmount(() => un());
|
|
95
|
+
|
|
96
|
+
return html`<ul>
|
|
97
|
+
${items.map((it) => html`<li data-id=${it.id}>${it.title}</li>`)}
|
|
98
|
+
</ul>`;
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Ordering and Identity
|
|
103
|
+
|
|
104
|
+
- Default sort: priority asc, then `created_at` desc, then id asc.
|
|
105
|
+
- When upserting, the store preserves object identity for existing ids by
|
|
106
|
+
mutating fields in place. This reduces unnecessary re‑renders.
|
|
107
|
+
|
|
108
|
+
## Reconnects
|
|
109
|
+
|
|
110
|
+
- On reconnect, repeat the subscribe‑list call. The server sends a fresh
|
|
111
|
+
`snapshot` with `revision: 1`. The store ignores stale envelopes using the
|
|
112
|
+
`revision` guard.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "beads-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Local‑first UI for Beads — a fast issue tracker for your coding agent.",
|
|
5
5
|
"homepage": "https://github.com/mantoni/beads-ui",
|
|
6
6
|
"type": "module",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"format": "prettier --write .",
|
|
21
21
|
"format:check": "prettier --check .",
|
|
22
22
|
"preversion": "npm run all",
|
|
23
|
-
"version": "changes",
|
|
23
|
+
"version": "changes --commits --footer",
|
|
24
24
|
"postversion": "git push --follow-tags && npm publish"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
@@ -32,9 +32,8 @@
|
|
|
32
32
|
"@types/ws": "^8.18.1",
|
|
33
33
|
"eslint": "^9.11.0",
|
|
34
34
|
"eslint-plugin-import": "^2.29.1",
|
|
35
|
-
"eslint-plugin-jsdoc": "^
|
|
35
|
+
"eslint-plugin-jsdoc": "^61.1.9",
|
|
36
36
|
"eslint-plugin-n": "^17.9.0",
|
|
37
|
-
"eslint-plugin-promise": "^6.1.1",
|
|
38
37
|
"globals": "^16.4.0",
|
|
39
38
|
"jsdom": "^27.0.1",
|
|
40
39
|
"prettier": "^3.3.3",
|
|
@@ -42,9 +41,11 @@
|
|
|
42
41
|
"vitest": "^2.1.3"
|
|
43
42
|
},
|
|
44
43
|
"dependencies": {
|
|
44
|
+
"dompurify": "^3.3.0",
|
|
45
45
|
"esbuild": "^0.25.11",
|
|
46
46
|
"express": "^5.1.0",
|
|
47
47
|
"lit-html": "^3.3.1",
|
|
48
|
+
"marked": "^16.4.1",
|
|
48
49
|
"ws": "^8.18.3"
|
|
49
50
|
},
|
|
50
51
|
"files": [
|
package/server/app.js
CHANGED
|
@@ -6,6 +6,7 @@ import path from 'node:path';
|
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Create and configure the Express application.
|
|
9
|
+
*
|
|
9
10
|
* @param {{ host: string, port: number, env: string, app_dir: string, root_dir: string }} config - Server configuration.
|
|
10
11
|
* @returns {Express} Configured Express app instance.
|
|
11
12
|
*/
|
|
@@ -28,6 +29,7 @@ export function createApp(config) {
|
|
|
28
29
|
/**
|
|
29
30
|
* On-demand bundle for the browser using esbuild.
|
|
30
31
|
* Note: esbuild is loaded lazily so tests don't require it to be installed.
|
|
32
|
+
*
|
|
31
33
|
* @param {Request} _req
|
|
32
34
|
* @param {Response} res
|
|
33
35
|
*/
|
package/server/bd.js
CHANGED
|
@@ -3,6 +3,7 @@ import { resolveDbPath } from './db.js';
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Resolve the bd executable path.
|
|
6
|
+
*
|
|
6
7
|
* @returns {string}
|
|
7
8
|
*/
|
|
8
9
|
export function getBdBin() {
|
|
@@ -16,6 +17,7 @@ export function getBdBin() {
|
|
|
16
17
|
/**
|
|
17
18
|
* Run the `bd` CLI with provided arguments.
|
|
18
19
|
* Shell is not used to avoid injection; args must be pre-split.
|
|
20
|
+
*
|
|
19
21
|
* @param {string[]} args - Arguments to pass (e.g., ["list", "--json"]).
|
|
20
22
|
* @param {{ cwd?: string, env?: Record<string, string | undefined>, timeout_ms?: number }} [options]
|
|
21
23
|
* @returns {Promise<{ code: number, stdout: string, stderr: string }>}
|
|
@@ -42,14 +44,12 @@ export function runBd(args, options = {}) {
|
|
|
42
44
|
|
|
43
45
|
if (child.stdout) {
|
|
44
46
|
child.stdout.setEncoding('utf8');
|
|
45
|
-
/** @param {string} chunk */
|
|
46
47
|
child.stdout.on('data', (chunk) => {
|
|
47
48
|
out_chunks.push(String(chunk));
|
|
48
49
|
});
|
|
49
50
|
}
|
|
50
51
|
if (child.stderr) {
|
|
51
52
|
child.stderr.setEncoding('utf8');
|
|
52
|
-
/** @param {string} chunk */
|
|
53
53
|
child.stderr.on('data', (chunk) => {
|
|
54
54
|
err_chunks.push(String(chunk));
|
|
55
55
|
});
|
|
@@ -90,6 +90,7 @@ export function runBd(args, options = {}) {
|
|
|
90
90
|
|
|
91
91
|
/**
|
|
92
92
|
* Run `bd` and parse JSON from stdout if exit code is 0.
|
|
93
|
+
*
|
|
93
94
|
* @param {string[]} args - Must include flags that cause JSON to be printed (e.g., `--json`).
|
|
94
95
|
* @param {{ cwd?: string, env?: Record<string, string | undefined>, timeout_ms?: number }} [options]
|
|
95
96
|
* @returns {Promise<{ code: number, stdoutJson?: unknown, stderr?: string }>}
|
|
@@ -111,6 +112,7 @@ export async function runBdJson(args, options = {}) {
|
|
|
111
112
|
|
|
112
113
|
/**
|
|
113
114
|
* Add a resolved "--db <path>" pair to args when none present.
|
|
115
|
+
*
|
|
114
116
|
* @param {string[]} args
|
|
115
117
|
* @param {string} cwd
|
|
116
118
|
* @param {Record<string, string | undefined>} env
|
package/server/cli/commands.js
CHANGED
|
@@ -13,6 +13,7 @@ import { openUrl, waitForServer } from './open.js';
|
|
|
13
13
|
* Handle `start` command. Idempotent when already running.
|
|
14
14
|
* - Spawns a detached server process, writes PID file, returns 0.
|
|
15
15
|
* - If already running (PID file present and process alive), prints URL and returns 0.
|
|
16
|
+
*
|
|
16
17
|
* @returns {Promise<number>} Exit code (0 on success)
|
|
17
18
|
*/
|
|
18
19
|
/**
|
|
@@ -36,8 +37,7 @@ export async function handleStart(options) {
|
|
|
36
37
|
printServerUrl();
|
|
37
38
|
// Auto-open the browser once for a fresh daemon start
|
|
38
39
|
if (!no_open) {
|
|
39
|
-
const
|
|
40
|
-
const url = 'http://' + cfg.host + ':' + String(cfg.port);
|
|
40
|
+
const { url } = getConfig();
|
|
41
41
|
// Wait briefly for the server to accept connections (single retry window)
|
|
42
42
|
await waitForServer(url, 600);
|
|
43
43
|
// Best-effort open; ignore result
|
|
@@ -53,6 +53,7 @@ export async function handleStart(options) {
|
|
|
53
53
|
* Handle `stop` command.
|
|
54
54
|
* - Sends SIGTERM and waits for exit (with SIGKILL fallback), removes PID file.
|
|
55
55
|
* - Returns 2 if not running.
|
|
56
|
+
*
|
|
56
57
|
* @returns {Promise<number>} Exit code
|
|
57
58
|
*/
|
|
58
59
|
export async function handleStop() {
|
|
@@ -79,12 +80,14 @@ export async function handleStop() {
|
|
|
79
80
|
|
|
80
81
|
/**
|
|
81
82
|
* Handle `restart` command: stop (ignore not-running) then start.
|
|
83
|
+
*
|
|
82
84
|
* @returns {Promise<number>} Exit code (0 on success)
|
|
83
85
|
*/
|
|
84
86
|
/**
|
|
85
87
|
* Handle `restart` command: stop (ignore not-running) then start.
|
|
86
88
|
* Accepts the same options as `handleStart` and passes them through,
|
|
87
89
|
* so restart only opens a browser when `no_open` is explicitly false.
|
|
90
|
+
*
|
|
88
91
|
* @param {{ no_open?: boolean }} [options]
|
|
89
92
|
* @returns {Promise<number>}
|
|
90
93
|
*/
|