beads-ui 0.3.0 → 0.4.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 +26 -0
- package/README.md +15 -6
- package/app/main.bundle.js +617 -0
- package/app/main.bundle.js.map +7 -0
- package/bin/bdui.js +2 -1
- package/package.json +27 -16
- package/server/app.js +39 -35
- package/server/bd.js +6 -2
- package/server/cli/commands.js +12 -8
- package/server/cli/daemon.js +20 -5
- package/server/cli/index.js +19 -31
- package/server/cli/open.js +3 -0
- package/server/cli/usage.js +4 -2
- package/server/config.js +3 -2
- package/server/db.js +9 -6
- package/server/index.js +10 -4
- package/server/list-adapters.js +9 -3
- package/server/logging.js +23 -0
- package/server/subscriptions.js +12 -0
- package/server/validators.js +2 -0
- package/server/watcher.js +10 -5
- package/server/ws.js +31 -10
- package/app/data/list-selectors.js +0 -98
- package/app/data/providers.js +0 -76
- package/app/data/sort.js +0 -45
- package/app/data/subscription-issue-store.js +0 -161
- package/app/data/subscription-issue-stores.js +0 -102
- package/app/data/subscriptions-store.js +0 -219
- package/app/main.js +0 -702
- package/app/protocol.js +0 -196
- package/app/protocol.md +0 -66
- package/app/router.js +0 -114
- package/app/state.js +0 -103
- package/app/utils/issue-id-renderer.js +0 -71
- package/app/utils/issue-id.js +0 -10
- package/app/utils/issue-type.js +0 -27
- package/app/utils/issue-url.js +0 -9
- package/app/utils/markdown.js +0 -22
- package/app/utils/priority-badge.js +0 -47
- package/app/utils/priority.js +0 -1
- package/app/utils/status-badge.js +0 -32
- package/app/utils/status.js +0 -23
- package/app/utils/toast.js +0 -34
- package/app/utils/type-badge.js +0 -33
- package/app/views/board.js +0 -535
- package/app/views/detail.js +0 -1249
- package/app/views/epics.js +0 -280
- package/app/views/issue-dialog.js +0 -163
- package/app/views/issue-row.js +0 -190
- package/app/views/list.js +0 -464
- package/app/views/nav.js +0 -67
- package/app/views/new-issue-dialog.js +0 -345
- package/app/ws.js +0 -279
- package/docs/adr/001-push-only-lists.md +0 -134
- package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +0 -200
- package/docs/architecture.md +0 -194
- package/docs/data-exchange-subscription-plan.md +0 -198
- package/docs/db-watching.md +0 -30
- package/docs/migration-v2.md +0 -54
- package/docs/protocol/issues-push-v2.md +0 -179
- package/docs/subscription-issue-store.md +0 -112
package/server/list-adapters.js
CHANGED
|
@@ -3,6 +3,7 @@ import { runBdJson } from './bd.js';
|
|
|
3
3
|
/**
|
|
4
4
|
* Build concrete `bd` CLI args for a subscription type + params.
|
|
5
5
|
* Always includes `--json` for parseable output.
|
|
6
|
+
*
|
|
6
7
|
* @param {{ type: string, params?: Record<string, string | number | boolean> }} spec
|
|
7
8
|
* @returns {string[]}
|
|
8
9
|
*/
|
|
@@ -43,9 +44,10 @@ export function mapSubscriptionToBdArgs(spec) {
|
|
|
43
44
|
|
|
44
45
|
/**
|
|
45
46
|
* Normalize bd list output to minimal Issue shape used by the registry.
|
|
46
|
-
* - Ensures `id` is a string
|
|
47
|
-
* - Coerces timestamps to numbers
|
|
48
|
-
* - `closed_at` defaults to null when missing or invalid
|
|
47
|
+
* - Ensures `id` is a string.
|
|
48
|
+
* - Coerces timestamps to numbers.
|
|
49
|
+
* - `closed_at` defaults to null when missing or invalid.
|
|
50
|
+
*
|
|
49
51
|
* @param {unknown} value
|
|
50
52
|
* @returns {Array<{ id: string, created_at: number, updated_at: number, closed_at: number | null } & Record<string, unknown>>}
|
|
51
53
|
*/
|
|
@@ -95,6 +97,7 @@ export function normalizeIssueList(value) {
|
|
|
95
97
|
/**
|
|
96
98
|
* Execute the mapped `bd` command for a subscription spec and return normalized items.
|
|
97
99
|
* Errors do not throw; they are surfaced as a structured object.
|
|
100
|
+
*
|
|
98
101
|
* @param {{ type: string, params?: Record<string, string | number | boolean> }} spec
|
|
99
102
|
* @returns {Promise<FetchListResultSuccess | FetchListResultFailure>}
|
|
100
103
|
*/
|
|
@@ -171,6 +174,7 @@ export async function fetchListForSubscription(spec) {
|
|
|
171
174
|
|
|
172
175
|
/**
|
|
173
176
|
* Create a `bad_request` error object.
|
|
177
|
+
*
|
|
174
178
|
* @param {string} message
|
|
175
179
|
*/
|
|
176
180
|
function badRequest(message) {
|
|
@@ -182,6 +186,7 @@ function badRequest(message) {
|
|
|
182
186
|
|
|
183
187
|
/**
|
|
184
188
|
* Normalize arbitrary thrown values to a structured error object.
|
|
189
|
+
*
|
|
185
190
|
* @param {unknown} err
|
|
186
191
|
* @returns {FetchListResultFailure['error']}
|
|
187
192
|
*/
|
|
@@ -199,6 +204,7 @@ function toErrorObject(err) {
|
|
|
199
204
|
/**
|
|
200
205
|
* Parse a bd timestamp string to epoch ms using Date.parse.
|
|
201
206
|
* Falls back to numeric coercion when parsing fails.
|
|
207
|
+
*
|
|
202
208
|
* @param {unknown} v
|
|
203
209
|
* @returns {number}
|
|
204
210
|
*/
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debug logger helper for Node server/CLI.
|
|
3
|
+
*/
|
|
4
|
+
import createDebug from 'debug';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create a namespaced logger for Node runtime.
|
|
8
|
+
*
|
|
9
|
+
* @param {string} ns - Module namespace suffix (e.g., 'ws', 'watcher').
|
|
10
|
+
*/
|
|
11
|
+
export function debug(ns) {
|
|
12
|
+
return createDebug(`beads-ui:${ns}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Enable all `beads-ui:*` debug logs at runtime for Node/CLI.
|
|
17
|
+
* Safe to call multiple times.
|
|
18
|
+
*/
|
|
19
|
+
export function enableAllDebug() {
|
|
20
|
+
// `debug` exposes a global enable/disable API.
|
|
21
|
+
// Enabling after loggers are created updates their `.enabled` state.
|
|
22
|
+
createDebug.enable(process.env.DEBUG || 'beads-ui:*');
|
|
23
|
+
}
|
package/server/subscriptions.js
CHANGED
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
38
|
* Create a new, empty entry object.
|
|
39
|
+
*
|
|
39
40
|
* @returns {Entry}
|
|
40
41
|
*/
|
|
41
42
|
function createEntry() {
|
|
@@ -49,6 +50,7 @@ function createEntry() {
|
|
|
49
50
|
|
|
50
51
|
/**
|
|
51
52
|
* Generate a stable subscription key string from a spec. Sorts params keys.
|
|
53
|
+
*
|
|
52
54
|
* @param {SubscriptionSpec} spec
|
|
53
55
|
* @returns {string}
|
|
54
56
|
*/
|
|
@@ -69,6 +71,7 @@ export function keyOf(spec) {
|
|
|
69
71
|
|
|
70
72
|
/**
|
|
71
73
|
* Compute a delta between previous and next item maps.
|
|
74
|
+
*
|
|
72
75
|
* @param {Map<string, ItemMeta>} prev
|
|
73
76
|
* @param {Map<string, ItemMeta>} next
|
|
74
77
|
* @returns {{ added: string[], updated: string[], removed: string[] }}
|
|
@@ -101,6 +104,7 @@ export function computeDelta(prev, next) {
|
|
|
101
104
|
|
|
102
105
|
/**
|
|
103
106
|
* Normalize array of issue-like objects into an itemsById map.
|
|
107
|
+
*
|
|
104
108
|
* @param {Array<{ id: string, updated_at: number, closed_at?: number|null }>} items
|
|
105
109
|
* @returns {Map<string, ItemMeta>}
|
|
106
110
|
*/
|
|
@@ -136,6 +140,7 @@ export class SubscriptionRegistry {
|
|
|
136
140
|
|
|
137
141
|
/**
|
|
138
142
|
* Get an entry by key, or null if missing.
|
|
143
|
+
*
|
|
139
144
|
* @param {string} key
|
|
140
145
|
* @returns {Entry | null}
|
|
141
146
|
*/
|
|
@@ -145,6 +150,7 @@ export class SubscriptionRegistry {
|
|
|
145
150
|
|
|
146
151
|
/**
|
|
147
152
|
* Ensure an entry exists for a spec; returns the key and entry.
|
|
153
|
+
*
|
|
148
154
|
* @param {SubscriptionSpec} spec
|
|
149
155
|
* @returns {{ key: string, entry: Entry }}
|
|
150
156
|
*/
|
|
@@ -160,6 +166,7 @@ export class SubscriptionRegistry {
|
|
|
160
166
|
|
|
161
167
|
/**
|
|
162
168
|
* Attach a subscriber to a spec. Creates the entry if missing.
|
|
169
|
+
*
|
|
163
170
|
* @param {SubscriptionSpec} spec
|
|
164
171
|
* @param {WebSocket} ws
|
|
165
172
|
* @returns {{ key: string, subscribed: true }}
|
|
@@ -173,6 +180,7 @@ export class SubscriptionRegistry {
|
|
|
173
180
|
/**
|
|
174
181
|
* Detach a subscriber from the spec. Keeps entry even if empty; eviction
|
|
175
182
|
* is handled by `onDisconnect` sweep.
|
|
183
|
+
*
|
|
176
184
|
* @param {SubscriptionSpec} spec
|
|
177
185
|
* @param {WebSocket} ws
|
|
178
186
|
* @returns {boolean} true when the subscriber was removed
|
|
@@ -189,6 +197,7 @@ export class SubscriptionRegistry {
|
|
|
189
197
|
/**
|
|
190
198
|
* On socket disconnect, remove it from all subscriber sets and evict any
|
|
191
199
|
* entries that become empty as a result of this sweep.
|
|
200
|
+
*
|
|
192
201
|
* @param {WebSocket} ws
|
|
193
202
|
*/
|
|
194
203
|
onDisconnect(ws) {
|
|
@@ -207,6 +216,7 @@ export class SubscriptionRegistry {
|
|
|
207
216
|
|
|
208
217
|
/**
|
|
209
218
|
* Serialize a function against a key so only one runs at a time per key.
|
|
219
|
+
*
|
|
210
220
|
* @template T
|
|
211
221
|
* @param {string} key
|
|
212
222
|
* @param {() => Promise<T>} fn
|
|
@@ -243,6 +253,7 @@ export class SubscriptionRegistry {
|
|
|
243
253
|
|
|
244
254
|
/**
|
|
245
255
|
* Replace items for a key and compute the delta, storing the new map.
|
|
256
|
+
*
|
|
246
257
|
* @param {string} key
|
|
247
258
|
* @param {Map<string, ItemMeta>} next_map
|
|
248
259
|
* @returns {{ added: string[], updated: string[], removed: string[] }}
|
|
@@ -261,6 +272,7 @@ export class SubscriptionRegistry {
|
|
|
261
272
|
|
|
262
273
|
/**
|
|
263
274
|
* Convenience: update items from an array of objects with id/updated_at/closed_at.
|
|
275
|
+
*
|
|
264
276
|
* @param {string} key
|
|
265
277
|
* @param {Array<{ id: string, updated_at: number, closed_at?: number|null }>} items
|
|
266
278
|
* @returns {{ added: string[], updated: string[], removed: string[] }}
|
package/server/validators.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Known subscription types supported by the server.
|
|
9
|
+
*
|
|
9
10
|
* @type {Set<string>}
|
|
10
11
|
*/
|
|
11
12
|
const SUBSCRIPTION_TYPES = new Set([
|
|
@@ -20,6 +21,7 @@ const SUBSCRIPTION_TYPES = new Set([
|
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Validate a subscribe-list payload and normalize to a SubscriptionSpec.
|
|
24
|
+
*
|
|
23
25
|
* @param {unknown} payload
|
|
24
26
|
* @returns {{ ok: true, id: string, spec: { type: string, params?: Record<string, string|number|boolean> } } | { ok: false, code: 'bad_request', message: string }}
|
|
25
27
|
*/
|
package/server/watcher.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { resolveDbPath } from './db.js';
|
|
4
|
+
import { debug } from './logging.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Watch the resolved beads SQLite DB file and invoke a callback after a debounce window.
|
|
7
8
|
* The DB path is resolved following beads precedence and can be overridden via options.
|
|
9
|
+
*
|
|
8
10
|
* @param {string} root_dir - Project root directory (starting point for resolution).
|
|
9
11
|
* @param {() => void} onChange - Called when changes are detected.
|
|
10
12
|
* @param {{ debounce_ms?: number, explicit_db?: string }} [options]
|
|
@@ -12,6 +14,7 @@ import { resolveDbPath } from './db.js';
|
|
|
12
14
|
*/
|
|
13
15
|
export function watchDb(root_dir, onChange, options = {}) {
|
|
14
16
|
const debounce_ms = options.debounce_ms ?? 250;
|
|
17
|
+
const log = debug('watcher');
|
|
15
18
|
|
|
16
19
|
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
17
20
|
let timer;
|
|
@@ -33,6 +36,7 @@ export function watchDb(root_dir, onChange, options = {}) {
|
|
|
33
36
|
|
|
34
37
|
/**
|
|
35
38
|
* Attach a watcher to the directory containing the resolved DB path.
|
|
39
|
+
*
|
|
36
40
|
* @param {string} base_dir
|
|
37
41
|
* @param {string | undefined} explicit_db
|
|
38
42
|
*/
|
|
@@ -42,10 +46,9 @@ export function watchDb(root_dir, onChange, options = {}) {
|
|
|
42
46
|
current_dir = path.dirname(current_path);
|
|
43
47
|
current_file = path.basename(current_path);
|
|
44
48
|
if (!resolved.exists) {
|
|
45
|
-
|
|
46
|
-
'
|
|
47
|
-
current_path
|
|
48
|
-
'\nHint: set --db, export BEADS_DB, or run `bd init` in your workspace.'
|
|
49
|
+
log(
|
|
50
|
+
'resolved DB missing: %s – Hint: set --db, export BEADS_DB, or run `bd init` in your workspace.',
|
|
51
|
+
current_path
|
|
49
52
|
);
|
|
50
53
|
}
|
|
51
54
|
|
|
@@ -59,12 +62,13 @@ export function watchDb(root_dir, onChange, options = {}) {
|
|
|
59
62
|
return;
|
|
60
63
|
}
|
|
61
64
|
if (event_type === 'change' || event_type === 'rename') {
|
|
65
|
+
log('fs %s %s', event_type, filename || '');
|
|
62
66
|
schedule();
|
|
63
67
|
}
|
|
64
68
|
}
|
|
65
69
|
);
|
|
66
70
|
} catch (err) {
|
|
67
|
-
|
|
71
|
+
log('unable to watch directory %s %o', current_dir, err);
|
|
68
72
|
}
|
|
69
73
|
};
|
|
70
74
|
|
|
@@ -84,6 +88,7 @@ export function watchDb(root_dir, onChange, options = {}) {
|
|
|
84
88
|
},
|
|
85
89
|
/**
|
|
86
90
|
* Re-resolve and reattach watcher when root_dir or explicit_db changes.
|
|
91
|
+
*
|
|
87
92
|
* @param {{ root_dir?: string, explicit_db?: string }} [opts]
|
|
88
93
|
*/
|
|
89
94
|
rebind(opts = {}) {
|
package/server/ws.js
CHANGED
|
@@ -6,10 +6,13 @@
|
|
|
6
6
|
import { WebSocketServer } from 'ws';
|
|
7
7
|
import { runBd, runBdJson } from './bd.js';
|
|
8
8
|
import { fetchListForSubscription } from './list-adapters.js';
|
|
9
|
+
import { debug } from './logging.js';
|
|
9
10
|
import { isRequest, makeError, makeOk } from './protocol.js';
|
|
10
11
|
import { keyOf, registry } from './subscriptions.js';
|
|
11
12
|
import { validateSubscribeListPayload } from './validators.js';
|
|
12
13
|
|
|
14
|
+
const log = debug('ws');
|
|
15
|
+
|
|
13
16
|
/**
|
|
14
17
|
* Debounced refresh scheduling for active list subscriptions.
|
|
15
18
|
* A trailing window coalesces rapid change bursts into a single refresh run.
|
|
@@ -40,6 +43,7 @@ let MUTATION_GATE = null;
|
|
|
40
43
|
* suppressed during the window.
|
|
41
44
|
*
|
|
42
45
|
* Fire-and-forget; callers should not await this.
|
|
46
|
+
*
|
|
43
47
|
* @param {number} [timeout_ms]
|
|
44
48
|
*/
|
|
45
49
|
function triggerMutationRefreshOnce(timeout_ms = 500) {
|
|
@@ -76,6 +80,7 @@ function triggerMutationRefreshOnce(timeout_ms = 500) {
|
|
|
76
80
|
|
|
77
81
|
// After resolution, run a single refresh across active subs and clear gate
|
|
78
82
|
void p.then(async () => {
|
|
83
|
+
log('mutation window resolved → refresh active subs');
|
|
79
84
|
try {
|
|
80
85
|
await refreshAllActiveListSubscriptions();
|
|
81
86
|
} catch {
|
|
@@ -95,6 +100,7 @@ function triggerMutationRefreshOnce(timeout_ms = 500) {
|
|
|
95
100
|
|
|
96
101
|
/**
|
|
97
102
|
* Collect unique active list subscription specs across all connected clients.
|
|
103
|
+
*
|
|
98
104
|
* @returns {Array<{ type: string, params?: Record<string,string|number|boolean> }>}
|
|
99
105
|
*/
|
|
100
106
|
function collectActiveListSpecs() {
|
|
@@ -181,6 +187,7 @@ let CURRENT_WSS = null;
|
|
|
181
187
|
|
|
182
188
|
/**
|
|
183
189
|
* Get or initialize the subscription state for a socket.
|
|
190
|
+
*
|
|
184
191
|
* @param {WebSocket} ws
|
|
185
192
|
* @returns {any}
|
|
186
193
|
*/
|
|
@@ -199,6 +206,7 @@ function ensureSubs(ws) {
|
|
|
199
206
|
|
|
200
207
|
/**
|
|
201
208
|
* Get next monotonically increasing revision for a subscription key on this connection.
|
|
209
|
+
*
|
|
202
210
|
* @param {WebSocket} ws
|
|
203
211
|
* @param {string} key
|
|
204
212
|
*/
|
|
@@ -306,6 +314,7 @@ function emitSubscriptionDelete(ws, client_id, key, issue_id) {
|
|
|
306
314
|
/**
|
|
307
315
|
* Refresh a subscription spec: fetch via adapter, apply to registry and emit
|
|
308
316
|
* per-subscription full-issue envelopes to subscribers. Serialized per key.
|
|
317
|
+
*
|
|
309
318
|
* @param {{ type: string, params?: Record<string, string|number|boolean> }} spec
|
|
310
319
|
*/
|
|
311
320
|
async function refreshAndPublish(spec) {
|
|
@@ -316,17 +325,17 @@ async function refreshAndPublish(spec) {
|
|
|
316
325
|
return;
|
|
317
326
|
}
|
|
318
327
|
const items = applyClosedIssuesFilter(spec, res.items);
|
|
319
|
-
const
|
|
328
|
+
const prev_size = registry.get(key)?.itemsById.size || 0;
|
|
320
329
|
const delta = registry.applyItems(key, items);
|
|
321
330
|
const entry = registry.get(key);
|
|
322
331
|
if (!entry || entry.subscribers.size === 0) {
|
|
323
332
|
return;
|
|
324
333
|
}
|
|
325
334
|
/** @type {Map<string, any>} */
|
|
326
|
-
const
|
|
335
|
+
const by_id = new Map();
|
|
327
336
|
for (const it of items) {
|
|
328
337
|
if (it && typeof it.id === 'string') {
|
|
329
|
-
|
|
338
|
+
by_id.set(it.id, it);
|
|
330
339
|
}
|
|
331
340
|
}
|
|
332
341
|
for (const ws of entry.subscribers) {
|
|
@@ -334,20 +343,20 @@ async function refreshAndPublish(spec) {
|
|
|
334
343
|
const s = ensureSubs(ws);
|
|
335
344
|
const subs = s.list_subs || new Map();
|
|
336
345
|
/** @type {string[]} */
|
|
337
|
-
const
|
|
346
|
+
const client_ids = [];
|
|
338
347
|
for (const [cid, v] of subs.entries()) {
|
|
339
|
-
if (v.key === key)
|
|
348
|
+
if (v.key === key) client_ids.push(cid);
|
|
340
349
|
}
|
|
341
|
-
if (
|
|
342
|
-
if (
|
|
343
|
-
for (const cid of
|
|
350
|
+
if (client_ids.length === 0) continue;
|
|
351
|
+
if (prev_size === 0) {
|
|
352
|
+
for (const cid of client_ids) {
|
|
344
353
|
emitSubscriptionSnapshot(ws, cid, key, items);
|
|
345
354
|
}
|
|
346
355
|
continue;
|
|
347
356
|
}
|
|
348
|
-
for (const cid of
|
|
357
|
+
for (const cid of client_ids) {
|
|
349
358
|
for (const id of [...delta.added, ...delta.updated]) {
|
|
350
|
-
const issue =
|
|
359
|
+
const issue = by_id.get(id);
|
|
351
360
|
if (issue) {
|
|
352
361
|
emitSubscriptionUpsert(ws, cid, key, issue);
|
|
353
362
|
}
|
|
@@ -362,6 +371,7 @@ async function refreshAndPublish(spec) {
|
|
|
362
371
|
|
|
363
372
|
/**
|
|
364
373
|
* Apply pre-diff filtering for closed-issues lists based on spec.params.since (epoch ms).
|
|
374
|
+
*
|
|
365
375
|
* @param {{ type: string, params?: Record<string, string|number|boolean> }} spec
|
|
366
376
|
* @param {Array<{ id: string, updated_at: number, closed_at: number | null } & Record<string, unknown>>} items
|
|
367
377
|
*/
|
|
@@ -387,6 +397,7 @@ function applyClosedIssuesFilter(spec, items) {
|
|
|
387
397
|
|
|
388
398
|
/**
|
|
389
399
|
* Attach a WebSocket server to an existing HTTP server.
|
|
400
|
+
*
|
|
390
401
|
* @param {Server} http_server
|
|
391
402
|
* @param {{ path?: string, heartbeat_ms?: number, refresh_debounce_ms?: number }} [options]
|
|
392
403
|
* @returns {{ wss: WebSocketServer, broadcast: (type: MessageType, payload?: unknown) => void, scheduleListRefresh: () => void }}
|
|
@@ -406,6 +417,7 @@ export function attachWsServer(http_server, options = {}) {
|
|
|
406
417
|
|
|
407
418
|
// Heartbeat: track if client answered the last ping
|
|
408
419
|
wss.on('connection', (ws) => {
|
|
420
|
+
log('client connected');
|
|
409
421
|
// @ts-expect-error add marker property
|
|
410
422
|
ws.isAlive = true;
|
|
411
423
|
|
|
@@ -451,6 +463,7 @@ export function attachWsServer(http_server, options = {}) {
|
|
|
451
463
|
|
|
452
464
|
/**
|
|
453
465
|
* Broadcast a server-initiated event to all open clients.
|
|
466
|
+
*
|
|
454
467
|
* @param {MessageType} type
|
|
455
468
|
* @param {unknown} [payload]
|
|
456
469
|
*/
|
|
@@ -478,6 +491,7 @@ export function attachWsServer(http_server, options = {}) {
|
|
|
478
491
|
|
|
479
492
|
/**
|
|
480
493
|
* Handle an incoming message frame and respond to the same socket.
|
|
494
|
+
*
|
|
481
495
|
* @param {WebSocket} ws
|
|
482
496
|
* @param {RawData} data
|
|
483
497
|
*/
|
|
@@ -498,6 +512,7 @@ export async function handleMessage(ws, data) {
|
|
|
498
512
|
}
|
|
499
513
|
|
|
500
514
|
if (!isRequest(json)) {
|
|
515
|
+
log('invalid request');
|
|
501
516
|
const reply = {
|
|
502
517
|
id: 'unknown',
|
|
503
518
|
ok: false,
|
|
@@ -518,6 +533,7 @@ export async function handleMessage(ws, data) {
|
|
|
518
533
|
|
|
519
534
|
// subscribe-list: payload { id: string, type: string, params?: object }
|
|
520
535
|
if (req.type === 'subscribe-list') {
|
|
536
|
+
log('subscribe-list %s', /** @type {any} */ (req.payload)?.id || '');
|
|
521
537
|
const validation = validateSubscribeListPayload(
|
|
522
538
|
/** @type {any} */ (req.payload || {})
|
|
523
539
|
);
|
|
@@ -553,6 +569,7 @@ export async function handleMessage(ws, data) {
|
|
|
553
569
|
|
|
554
570
|
// unsubscribe-list: payload { id: string }
|
|
555
571
|
if (req.type === 'unsubscribe-list') {
|
|
572
|
+
log('unsubscribe-list %s', /** @type {any} */ (req.payload)?.id || '');
|
|
556
573
|
const { id: client_id } = /** @type {any} */ (req.payload || {});
|
|
557
574
|
if (typeof client_id !== 'string' || client_id.length === 0) {
|
|
558
575
|
ws.send(
|
|
@@ -637,6 +654,7 @@ export async function handleMessage(ws, data) {
|
|
|
637
654
|
|
|
638
655
|
// update-status
|
|
639
656
|
if (req.type === 'update-status') {
|
|
657
|
+
log('update-status');
|
|
640
658
|
const { id, status } = /** @type {any} */ (req.payload);
|
|
641
659
|
const allowed = new Set(['open', 'in_progress', 'closed']);
|
|
642
660
|
if (
|
|
@@ -682,6 +700,7 @@ export async function handleMessage(ws, data) {
|
|
|
682
700
|
|
|
683
701
|
// update-priority
|
|
684
702
|
if (req.type === 'update-priority') {
|
|
703
|
+
log('update-priority');
|
|
685
704
|
const { id, priority } = /** @type {any} */ (req.payload);
|
|
686
705
|
if (
|
|
687
706
|
typeof id !== 'string' ||
|
|
@@ -726,6 +745,7 @@ export async function handleMessage(ws, data) {
|
|
|
726
745
|
|
|
727
746
|
// edit-text
|
|
728
747
|
if (req.type === 'edit-text') {
|
|
748
|
+
log('edit-text');
|
|
729
749
|
const { id, field, value } = /** @type {any} */ (req.payload);
|
|
730
750
|
if (
|
|
731
751
|
typeof id !== 'string' ||
|
|
@@ -789,6 +809,7 @@ export async function handleMessage(ws, data) {
|
|
|
789
809
|
|
|
790
810
|
// create-issue
|
|
791
811
|
if (req.type === 'create-issue') {
|
|
812
|
+
log('create-issue');
|
|
792
813
|
const { title, type, priority, description } = /** @type {any} */ (
|
|
793
814
|
req.payload || {}
|
|
794
815
|
);
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* List selectors utility: compose subscription membership with issues entities
|
|
3
|
-
* and apply view-specific sorting. Provides a lightweight `subscribe` that
|
|
4
|
-
* triggers once per issues envelope to let views re-render.
|
|
5
|
-
*/
|
|
6
|
-
/**
|
|
7
|
-
* @typedef {{ id: string, title?: string, status?: 'open'|'in_progress'|'closed', priority?: number, issue_type?: string, created_at?: number, updated_at?: number, closed_at?: number }} IssueLite
|
|
8
|
-
*/
|
|
9
|
-
import { cmpClosedDesc, cmpPriorityThenCreated } from './sort.js';
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Factory for list selectors.
|
|
13
|
-
*
|
|
14
|
-
* Source of truth is per-subscription stores providing snapshots for a given
|
|
15
|
-
* client id. Central issues store fallback has been removed.
|
|
16
|
-
* @param {{ snapshotFor?: (client_id: string) => IssueLite[], subscribe?: (fn: () => void) => () => void }} [issue_stores]
|
|
17
|
-
*/
|
|
18
|
-
export function createListSelectors(issue_stores = undefined) {
|
|
19
|
-
// Sorting comparators are centralized in app/data/sort.js
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Get entities for a subscription id with Issues List sort (priority asc → created asc).
|
|
23
|
-
* @param {string} client_id
|
|
24
|
-
* @returns {IssueLite[]}
|
|
25
|
-
*/
|
|
26
|
-
function selectIssuesFor(client_id) {
|
|
27
|
-
if (!issue_stores || typeof issue_stores.snapshotFor !== 'function') {
|
|
28
|
-
return [];
|
|
29
|
-
}
|
|
30
|
-
return issue_stores
|
|
31
|
-
.snapshotFor(client_id)
|
|
32
|
-
.slice()
|
|
33
|
-
.sort(cmpPriorityThenCreated);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Get entities for a Board column with column-specific sort.
|
|
38
|
-
* @param {string} client_id
|
|
39
|
-
* @param {'ready'|'blocked'|'in_progress'|'closed'} mode
|
|
40
|
-
* @returns {IssueLite[]}
|
|
41
|
-
*/
|
|
42
|
-
function selectBoardColumn(client_id, mode) {
|
|
43
|
-
const arr =
|
|
44
|
-
issue_stores && issue_stores.snapshotFor
|
|
45
|
-
? issue_stores.snapshotFor(client_id).slice()
|
|
46
|
-
: [];
|
|
47
|
-
if (mode === 'in_progress') {
|
|
48
|
-
arr.sort(cmpPriorityThenCreated);
|
|
49
|
-
} else if (mode === 'closed') {
|
|
50
|
-
arr.sort(cmpClosedDesc);
|
|
51
|
-
} else {
|
|
52
|
-
// ready/blocked share the same sort
|
|
53
|
-
arr.sort(cmpPriorityThenCreated);
|
|
54
|
-
}
|
|
55
|
-
return arr;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Get children for an epic subscribed as client id `epic:${id}`.
|
|
60
|
-
* Sorted as Issues List (priority asc → created asc).
|
|
61
|
-
* @param {string} epic_id
|
|
62
|
-
* @returns {IssueLite[]}
|
|
63
|
-
*/
|
|
64
|
-
function selectEpicChildren(epic_id) {
|
|
65
|
-
if (!issue_stores || typeof issue_stores.snapshotFor !== 'function') {
|
|
66
|
-
return [];
|
|
67
|
-
}
|
|
68
|
-
// Epic detail subscription uses client id `detail:<id>` and contains the
|
|
69
|
-
// epic entity with a `dependents` array. Render children from that list.
|
|
70
|
-
const arr = /** @type {any[]} */ (
|
|
71
|
-
issue_stores.snapshotFor(`detail:${epic_id}`) || []
|
|
72
|
-
);
|
|
73
|
-
const epic = arr.find((it) => String(it?.id || '') === String(epic_id));
|
|
74
|
-
const dependents = Array.isArray(epic?.dependents) ? epic.dependents : [];
|
|
75
|
-
return /** @type {IssueLite[]} */ (
|
|
76
|
-
dependents.slice().sort(cmpPriorityThenCreated)
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Subscribe for re-render; triggers once per issues envelope.
|
|
82
|
-
* @param {() => void} fn
|
|
83
|
-
* @returns {() => void}
|
|
84
|
-
*/
|
|
85
|
-
function subscribe(fn) {
|
|
86
|
-
if (issue_stores && typeof issue_stores.subscribe === 'function') {
|
|
87
|
-
return issue_stores.subscribe(fn);
|
|
88
|
-
}
|
|
89
|
-
return () => {};
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
return {
|
|
93
|
-
selectIssuesFor,
|
|
94
|
-
selectBoardColumn,
|
|
95
|
-
selectEpicChildren,
|
|
96
|
-
subscribe
|
|
97
|
-
};
|
|
98
|
-
}
|
package/app/data/providers.js
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @import { MessageType } from '../protocol.js'
|
|
3
|
-
*/
|
|
4
|
-
/**
|
|
5
|
-
* Data layer: typed wrappers around the ws transport for mutations and
|
|
6
|
-
* single-issue fetch. List reads have been removed in favor of push-only
|
|
7
|
-
* stores and selectors (see docs/adr/001-push-only-lists.md).
|
|
8
|
-
* @param {(type: MessageType, payload?: unknown) => Promise<unknown>} transport - Request/response function.
|
|
9
|
-
* @returns {{ updateIssue: (input: { id: string, title?: string, acceptance?: string, notes?: string, design?: string, status?: 'open'|'in_progress'|'closed', priority?: number, assignee?: string }) => Promise<unknown> }}
|
|
10
|
-
*/
|
|
11
|
-
export function createDataLayer(transport) {
|
|
12
|
-
/**
|
|
13
|
-
* Update issue fields by dispatching specific mutations.
|
|
14
|
-
* Supported fields: title, acceptance, notes, design, status, priority, assignee.
|
|
15
|
-
* Returns the updated issue on success.
|
|
16
|
-
* @param {{ id: string, title?: string, acceptance?: string, notes?: string, design?: string, status?: 'open'|'in_progress'|'closed', priority?: number, assignee?: string }} input
|
|
17
|
-
* @returns {Promise<unknown>}
|
|
18
|
-
*/
|
|
19
|
-
async function updateIssue(input) {
|
|
20
|
-
const { id } = input;
|
|
21
|
-
/** @type {unknown} */
|
|
22
|
-
let last = null;
|
|
23
|
-
if (typeof input.title === 'string') {
|
|
24
|
-
last = await transport('edit-text', {
|
|
25
|
-
id,
|
|
26
|
-
field: 'title',
|
|
27
|
-
value: input.title
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
if (typeof input.acceptance === 'string') {
|
|
31
|
-
last = await transport('edit-text', {
|
|
32
|
-
id,
|
|
33
|
-
field: 'acceptance',
|
|
34
|
-
value: input.acceptance
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
if (typeof input.notes === 'string') {
|
|
38
|
-
last = await transport('edit-text', {
|
|
39
|
-
id,
|
|
40
|
-
field: 'notes',
|
|
41
|
-
value: input.notes
|
|
42
|
-
});
|
|
43
|
-
}
|
|
44
|
-
if (typeof input.design === 'string') {
|
|
45
|
-
last = await transport('edit-text', {
|
|
46
|
-
id,
|
|
47
|
-
field: 'design',
|
|
48
|
-
value: input.design
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
if (typeof input.status === 'string') {
|
|
52
|
-
last = await transport('update-status', {
|
|
53
|
-
id,
|
|
54
|
-
status: input.status
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
if (typeof input.priority === 'number') {
|
|
58
|
-
last = await transport('update-priority', {
|
|
59
|
-
id,
|
|
60
|
-
priority: input.priority
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
// type updates are not supported via UI
|
|
64
|
-
if (typeof input.assignee === 'string') {
|
|
65
|
-
last = await transport('update-assignee', {
|
|
66
|
-
id,
|
|
67
|
-
assignee: input.assignee
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
return last;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
return {
|
|
74
|
-
updateIssue
|
|
75
|
-
};
|
|
76
|
-
}
|
package/app/data/sort.js
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared sort comparators for issues lists.
|
|
3
|
-
* Centralizes sorting so views and stores stay consistent.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* @typedef {{ id: string, title?: string, status?: 'open'|'in_progress'|'closed', priority?: number, issue_type?: string, created_at?: number, updated_at?: number, closed_at?: number }} IssueLite
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Compare by priority asc, then created_at asc, then id asc.
|
|
12
|
-
* @param {IssueLite} a
|
|
13
|
-
* @param {IssueLite} b
|
|
14
|
-
*/
|
|
15
|
-
export function cmpPriorityThenCreated(a, b) {
|
|
16
|
-
const pa = a.priority ?? 2;
|
|
17
|
-
const pb = b.priority ?? 2;
|
|
18
|
-
if (pa !== pb) {
|
|
19
|
-
return pa - pb;
|
|
20
|
-
}
|
|
21
|
-
const ca = a.created_at ?? 0;
|
|
22
|
-
const cb = b.created_at ?? 0;
|
|
23
|
-
if (ca !== cb) {
|
|
24
|
-
return ca < cb ? -1 : 1;
|
|
25
|
-
}
|
|
26
|
-
const ida = a.id;
|
|
27
|
-
const idb = b.id;
|
|
28
|
-
return ida < idb ? -1 : ida > idb ? 1 : 0;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Compare by closed_at desc, then id asc for stability.
|
|
33
|
-
* @param {IssueLite} a
|
|
34
|
-
* @param {IssueLite} b
|
|
35
|
-
*/
|
|
36
|
-
export function cmpClosedDesc(a, b) {
|
|
37
|
-
const ca = a.closed_at ?? 0;
|
|
38
|
-
const cb = b.closed_at ?? 0;
|
|
39
|
-
if (ca !== cb) {
|
|
40
|
-
return ca < cb ? 1 : -1;
|
|
41
|
-
}
|
|
42
|
-
const ida = a?.id;
|
|
43
|
-
const idb = b?.id;
|
|
44
|
-
return ida < idb ? -1 : ida > idb ? 1 : 0;
|
|
45
|
-
}
|