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,218 @@
1
+ import { runBdJson } from './bd.js';
2
+
3
+ /**
4
+ * Build concrete `bd` CLI args for a subscription type + params.
5
+ * Always includes `--json` for parseable output.
6
+ * @param {{ type: string, params?: Record<string, string | number | boolean> }} spec
7
+ * @returns {string[]}
8
+ */
9
+ export function mapSubscriptionToBdArgs(spec) {
10
+ const t = String(spec.type);
11
+ switch (t) {
12
+ case 'all-issues': {
13
+ return ['list', '--json'];
14
+ }
15
+ case 'epics': {
16
+ return ['epic', 'status', '--json'];
17
+ }
18
+ case 'blocked-issues': {
19
+ return ['blocked', '--json'];
20
+ }
21
+ case 'ready-issues': {
22
+ return ['ready', '--limit', '1000', '--json'];
23
+ }
24
+ case 'in-progress-issues': {
25
+ return ['list', '--json', '--status', 'in_progress'];
26
+ }
27
+ case 'closed-issues': {
28
+ return ['list', '--json', '--status', 'closed'];
29
+ }
30
+ case 'issue-detail': {
31
+ const p = spec.params || {};
32
+ const id = String(p.id || '').trim();
33
+ if (id.length === 0) {
34
+ throw badRequest('Missing param: params.id');
35
+ }
36
+ return ['show', id, '--json'];
37
+ }
38
+ default: {
39
+ throw badRequest(`Unknown subscription type: ${t}`);
40
+ }
41
+ }
42
+ }
43
+
44
+ /**
45
+ * 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
49
+ * @param {unknown} value
50
+ * @returns {Array<{ id: string, created_at: number, updated_at: number, closed_at: number | null } & Record<string, unknown>>}
51
+ */
52
+ export function normalizeIssueList(value) {
53
+ if (!Array.isArray(value)) {
54
+ return [];
55
+ }
56
+ /** @type {Array<{ id: string, created_at: number, updated_at: number, closed_at: number | null } & Record<string, unknown>>} */
57
+ const out = [];
58
+ for (const it of value) {
59
+ const id = String(it.id ?? '');
60
+ if (id.length === 0) {
61
+ continue;
62
+ }
63
+ const created_at = parseTimestamp(/** @type {any} */ (it).created_at);
64
+ const updated_at = parseTimestamp(it.updated_at);
65
+ const closed_raw = it.closed_at;
66
+ /** @type {number | null} */
67
+ let closed_at = null;
68
+ if (closed_raw !== undefined && closed_raw !== null) {
69
+ const n = parseTimestamp(closed_raw);
70
+ closed_at = Number.isFinite(n) ? n : null;
71
+ }
72
+ out.push({
73
+ ...it,
74
+ id,
75
+ created_at: Number.isFinite(created_at) ? created_at : 0,
76
+ updated_at: Number.isFinite(updated_at) ? updated_at : 0,
77
+ closed_at
78
+ });
79
+ }
80
+ return out;
81
+ }
82
+
83
+ /**
84
+ * @typedef {Object} FetchListResultSuccess
85
+ * @property {true} ok
86
+ * @property {Array<{ id: string, updated_at: number, closed_at: number | null } & Record<string, unknown>>} items
87
+ */
88
+
89
+ /**
90
+ * @typedef {Object} FetchListResultFailure
91
+ * @property {false} ok
92
+ * @property {{ code: string, message: string, details?: Record<string, unknown> }} error
93
+ */
94
+
95
+ /**
96
+ * Execute the mapped `bd` command for a subscription spec and return normalized items.
97
+ * Errors do not throw; they are surfaced as a structured object.
98
+ * @param {{ type: string, params?: Record<string, string | number | boolean> }} spec
99
+ * @returns {Promise<FetchListResultSuccess | FetchListResultFailure>}
100
+ */
101
+ export async function fetchListForSubscription(spec) {
102
+ /** @type {string[]} */
103
+ let args;
104
+ try {
105
+ args = mapSubscriptionToBdArgs(spec);
106
+ } catch (err) {
107
+ const e = toErrorObject(err);
108
+ return { ok: false, error: e };
109
+ }
110
+
111
+ try {
112
+ const res = await runBdJson(args);
113
+ if (!res || res.code !== 0 || !('stdoutJson' in res)) {
114
+ return {
115
+ ok: false,
116
+ error: {
117
+ code: 'bd_error',
118
+ message: String(res?.stderr || 'bd failed'),
119
+ details: { exit_code: res?.code ?? -1 }
120
+ }
121
+ };
122
+ }
123
+ // bd show may return a single object; normalize to an array first
124
+ let raw = Array.isArray(res.stdoutJson)
125
+ ? res.stdoutJson
126
+ : res.stdoutJson && typeof res.stdoutJson === 'object'
127
+ ? [res.stdoutJson]
128
+ : [];
129
+
130
+ // Special-case mapping for `epics`: current bd output nests the epic under
131
+ // an `epic` key and exposes counters at the top level. Flatten so that
132
+ // each entry has a top-level `id` and core fields expected by the registry.
133
+ if (String(spec.type) === 'epics') {
134
+ raw = raw.map((it) => {
135
+ if (it && typeof it === 'object' && 'epic' in it) {
136
+ const e = /** @type {any} */ (it).epic || {};
137
+ /** @type {Record<string, unknown>} */
138
+ const flat = {
139
+ // Required minimal fields for registry + client rendering
140
+ id: String(e.id ?? ''),
141
+ title: e.title,
142
+ status: e.status,
143
+ issue_type: e.issue_type || 'epic',
144
+ created_at: e.created_at,
145
+ updated_at: e.updated_at,
146
+ closed_at: e.closed_at ?? null,
147
+ // Preserve useful counters from bd output
148
+ total_children: /** @type {any} */ (it).total_children,
149
+ closed_children: /** @type {any} */ (it).closed_children,
150
+ eligible_for_close: /** @type {any} */ (it).eligible_for_close
151
+ };
152
+ return flat;
153
+ }
154
+ return it;
155
+ });
156
+ }
157
+
158
+ const items = normalizeIssueList(raw);
159
+ return { ok: true, items };
160
+ } catch (err) {
161
+ return {
162
+ ok: false,
163
+ error: {
164
+ code: 'bd_error',
165
+ message:
166
+ (err && /** @type {any} */ (err).message) || 'bd invocation failed'
167
+ }
168
+ };
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Create a `bad_request` error object.
174
+ * @param {string} message
175
+ */
176
+ function badRequest(message) {
177
+ const e = new Error(message);
178
+ // @ts-expect-error add code
179
+ e.code = 'bad_request';
180
+ return e;
181
+ }
182
+
183
+ /**
184
+ * Normalize arbitrary thrown values to a structured error object.
185
+ * @param {unknown} err
186
+ * @returns {FetchListResultFailure['error']}
187
+ */
188
+ function toErrorObject(err) {
189
+ if (err && typeof err === 'object') {
190
+ const any = /** @type {{ code?: unknown, message?: unknown }} */ (err);
191
+ const code = typeof any.code === 'string' ? any.code : 'bad_request';
192
+ const message =
193
+ typeof any.message === 'string' ? any.message : 'Request error';
194
+ return { code, message };
195
+ }
196
+ return { code: 'bad_request', message: 'Request error' };
197
+ }
198
+
199
+ /**
200
+ * Parse a bd timestamp string to epoch ms using Date.parse.
201
+ * Falls back to numeric coercion when parsing fails.
202
+ * @param {unknown} v
203
+ * @returns {number}
204
+ */
205
+ function parseTimestamp(v) {
206
+ if (typeof v === 'string') {
207
+ const ms = Date.parse(v);
208
+ if (Number.isFinite(ms)) {
209
+ return ms;
210
+ }
211
+ const n = Number(v);
212
+ return Number.isFinite(n) ? n : 0;
213
+ }
214
+ if (typeof v === 'number') {
215
+ return Number.isFinite(v) ? v : 0;
216
+ }
217
+ return 0;
218
+ }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * @import { WebSocket } from 'ws'
3
+ */
4
+ /**
5
+ * Server-side subscription registry for list-like data.
6
+ *
7
+ * Maintains per-subscription entries keyed by a stable string derived from
8
+ * `{ type, params }`. Each entry stores:
9
+ * - `itemsById`: Map<string, { updated_at: number, closed_at: number|null }>
10
+ * - `subscribers`: Set<WebSocket>
11
+ * - `lock`: Promise chain to serialize refresh/update operations per key
12
+ *
13
+ * No TTL eviction; entries are swept when sockets disconnect (and only when
14
+ * that leaves the subscriber set empty).
15
+ */
16
+
17
+ /**
18
+ * @typedef {{
19
+ * type: string,
20
+ * params?: Record<string, string | number | boolean>
21
+ * }} SubscriptionSpec
22
+ */
23
+
24
+ /**
25
+ * @typedef {{ updated_at: number, closed_at: number | null }} ItemMeta
26
+ */
27
+
28
+ /**
29
+ * @typedef {{
30
+ * itemsById: Map<string, ItemMeta>,
31
+ * subscribers: Set<WebSocket>,
32
+ * lock: Promise<void>,
33
+ * lockTail: () => void
34
+ * }} Entry
35
+ */
36
+
37
+ /**
38
+ * Create a new, empty entry object.
39
+ * @returns {Entry}
40
+ */
41
+ function createEntry() {
42
+ return {
43
+ itemsById: new Map(),
44
+ subscribers: new Set(),
45
+ lock: Promise.resolve(),
46
+ lockTail: () => {}
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Generate a stable subscription key string from a spec. Sorts params keys.
52
+ * @param {SubscriptionSpec} spec
53
+ * @returns {string}
54
+ */
55
+ export function keyOf(spec) {
56
+ const type = String(spec.type || '').trim();
57
+ /** @type {Record<string, string>} */
58
+ const flat = {};
59
+ if (spec.params && typeof spec.params === 'object') {
60
+ const keys = Object.keys(spec.params).sort();
61
+ for (const k of keys) {
62
+ const v = spec.params[k];
63
+ flat[k] = String(v);
64
+ }
65
+ }
66
+ const enc = new URLSearchParams(flat).toString();
67
+ return enc.length > 0 ? `${type}?${enc}` : type;
68
+ }
69
+
70
+ /**
71
+ * Compute a delta between previous and next item maps.
72
+ * @param {Map<string, ItemMeta>} prev
73
+ * @param {Map<string, ItemMeta>} next
74
+ * @returns {{ added: string[], updated: string[], removed: string[] }}
75
+ */
76
+ export function computeDelta(prev, next) {
77
+ /** @type {string[]} */
78
+ const added = [];
79
+ /** @type {string[]} */
80
+ const updated = [];
81
+ /** @type {string[]} */
82
+ const removed = [];
83
+
84
+ for (const [id, meta] of next) {
85
+ const p = prev.get(id);
86
+ if (!p) {
87
+ added.push(id);
88
+ continue;
89
+ }
90
+ if (p.updated_at !== meta.updated_at || p.closed_at !== meta.closed_at) {
91
+ updated.push(id);
92
+ }
93
+ }
94
+ for (const id of prev.keys()) {
95
+ if (!next.has(id)) {
96
+ removed.push(id);
97
+ }
98
+ }
99
+ return { added, updated, removed };
100
+ }
101
+
102
+ /**
103
+ * Normalize array of issue-like objects into an itemsById map.
104
+ * @param {Array<{ id: string, updated_at: number, closed_at?: number|null }>} items
105
+ * @returns {Map<string, ItemMeta>}
106
+ */
107
+ export function toItemsMap(items) {
108
+ /** @type {Map<string, ItemMeta>} */
109
+ const map = new Map();
110
+ for (const it of items) {
111
+ if (!it || typeof it.id !== 'string') {
112
+ continue;
113
+ }
114
+ const updated_at = Number(it.updated_at) || 0;
115
+ /** @type {number|null} */
116
+ let closed_at = null;
117
+ if (it.closed_at === null || it.closed_at === undefined) {
118
+ closed_at = null;
119
+ } else {
120
+ const n = Number(it.closed_at);
121
+ closed_at = Number.isFinite(n) ? n : null;
122
+ }
123
+ map.set(it.id, { updated_at, closed_at });
124
+ }
125
+ return map;
126
+ }
127
+
128
+ /**
129
+ * Create a subscription registry with attach/detach and per-key locking.
130
+ */
131
+ export class SubscriptionRegistry {
132
+ constructor() {
133
+ /** @type {Map<string, Entry>} */
134
+ this._entries = new Map();
135
+ }
136
+
137
+ /**
138
+ * Get an entry by key, or null if missing.
139
+ * @param {string} key
140
+ * @returns {Entry | null}
141
+ */
142
+ get(key) {
143
+ return this._entries.get(key) || null;
144
+ }
145
+
146
+ /**
147
+ * Ensure an entry exists for a spec; returns the key and entry.
148
+ * @param {SubscriptionSpec} spec
149
+ * @returns {{ key: string, entry: Entry }}
150
+ */
151
+ ensure(spec) {
152
+ const key = keyOf(spec);
153
+ let entry = this._entries.get(key);
154
+ if (!entry) {
155
+ entry = createEntry();
156
+ this._entries.set(key, entry);
157
+ }
158
+ return { key, entry };
159
+ }
160
+
161
+ /**
162
+ * Attach a subscriber to a spec. Creates the entry if missing.
163
+ * @param {SubscriptionSpec} spec
164
+ * @param {WebSocket} ws
165
+ * @returns {{ key: string, subscribed: true }}
166
+ */
167
+ attach(spec, ws) {
168
+ const { key, entry } = this.ensure(spec);
169
+ entry.subscribers.add(ws);
170
+ return { key, subscribed: true };
171
+ }
172
+
173
+ /**
174
+ * Detach a subscriber from the spec. Keeps entry even if empty; eviction
175
+ * is handled by `onDisconnect` sweep.
176
+ * @param {SubscriptionSpec} spec
177
+ * @param {WebSocket} ws
178
+ * @returns {boolean} true when the subscriber was removed
179
+ */
180
+ detach(spec, ws) {
181
+ const key = keyOf(spec);
182
+ const entry = this._entries.get(key);
183
+ if (!entry) {
184
+ return false;
185
+ }
186
+ return entry.subscribers.delete(ws);
187
+ }
188
+
189
+ /**
190
+ * On socket disconnect, remove it from all subscriber sets and evict any
191
+ * entries that become empty as a result of this sweep.
192
+ * @param {WebSocket} ws
193
+ */
194
+ onDisconnect(ws) {
195
+ /** @type {string[]} */
196
+ const empties = [];
197
+ for (const [key, entry] of this._entries) {
198
+ entry.subscribers.delete(ws);
199
+ if (entry.subscribers.size === 0) {
200
+ empties.push(key);
201
+ }
202
+ }
203
+ for (const key of empties) {
204
+ this._entries.delete(key);
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Serialize a function against a key so only one runs at a time per key.
210
+ * @template T
211
+ * @param {string} key
212
+ * @param {() => Promise<T>} fn
213
+ * @returns {Promise<T>}
214
+ */
215
+ async withKeyLock(key, fn) {
216
+ let entry = this._entries.get(key);
217
+ if (!entry) {
218
+ entry = createEntry();
219
+ this._entries.set(key, entry);
220
+ }
221
+ // Chain onto the existing lock
222
+ const prev = entry.lock;
223
+ /** @type {(v?: void) => void} */
224
+ let release = () => {};
225
+ entry.lock = new Promise((resolve) => {
226
+ release = resolve;
227
+ });
228
+ entry.lockTail = release;
229
+ // Wait for previous operations to finish
230
+ await prev.catch(() => {});
231
+ try {
232
+ const result = await fn();
233
+ return result;
234
+ } finally {
235
+ // Release lock for next queued op
236
+ try {
237
+ entry.lockTail();
238
+ } catch {
239
+ // ignore
240
+ }
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Replace items for a key and compute the delta, storing the new map.
246
+ * @param {string} key
247
+ * @param {Map<string, ItemMeta>} next_map
248
+ * @returns {{ added: string[], updated: string[], removed: string[] }}
249
+ */
250
+ applyNextMap(key, next_map) {
251
+ let entry = this._entries.get(key);
252
+ if (!entry) {
253
+ entry = createEntry();
254
+ this._entries.set(key, entry);
255
+ }
256
+ const prev = entry.itemsById;
257
+ const delta = computeDelta(prev, next_map);
258
+ entry.itemsById = new Map(next_map);
259
+ return delta;
260
+ }
261
+
262
+ /**
263
+ * Convenience: update items from an array of objects with id/updated_at/closed_at.
264
+ * @param {string} key
265
+ * @param {Array<{ id: string, updated_at: number, closed_at?: number|null }>} items
266
+ * @returns {{ added: string[], updated: string[], removed: string[] }}
267
+ */
268
+ applyItems(key, items) {
269
+ const next_map = toItemsMap(items);
270
+ return this.applyNextMap(key, next_map);
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Default singleton registry used by the ws server.
276
+ */
277
+ export const registry = new SubscriptionRegistry();
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Validation helpers for protocol payloads.
3
+ *
4
+ * Provides schema checks for subscription specs and selected mutations.
5
+ */
6
+
7
+ /**
8
+ * Known subscription types supported by the server.
9
+ * @type {Set<string>}
10
+ */
11
+ const SUBSCRIPTION_TYPES = new Set([
12
+ 'all-issues',
13
+ 'epics',
14
+ 'blocked-issues',
15
+ 'ready-issues',
16
+ 'in-progress-issues',
17
+ 'closed-issues',
18
+ 'issue-detail'
19
+ ]);
20
+
21
+ /**
22
+ * Validate a subscribe-list payload and normalize to a SubscriptionSpec.
23
+ * @param {unknown} payload
24
+ * @returns {{ ok: true, id: string, spec: { type: string, params?: Record<string, string|number|boolean> } } | { ok: false, code: 'bad_request', message: string }}
25
+ */
26
+ export function validateSubscribeListPayload(payload) {
27
+ if (!payload || typeof payload !== 'object') {
28
+ return {
29
+ ok: false,
30
+ code: 'bad_request',
31
+ message: 'payload must be an object'
32
+ };
33
+ }
34
+ const any =
35
+ /** @type {{ id?: unknown, type?: unknown, params?: unknown }} */ (payload);
36
+
37
+ const id = typeof any.id === 'string' ? any.id : '';
38
+ if (id.length === 0) {
39
+ return {
40
+ ok: false,
41
+ code: 'bad_request',
42
+ message: 'payload.id must be a non-empty string'
43
+ };
44
+ }
45
+
46
+ const type = typeof any.type === 'string' ? any.type : '';
47
+ if (type.length === 0 || !SUBSCRIPTION_TYPES.has(type)) {
48
+ return {
49
+ ok: false,
50
+ code: 'bad_request',
51
+ message: `payload.type must be one of: ${Array.from(SUBSCRIPTION_TYPES).join(', ')}`
52
+ };
53
+ }
54
+
55
+ /** @type {Record<string, string|number|boolean> | undefined} */
56
+ let params;
57
+ if (any.params !== undefined) {
58
+ if (
59
+ !any.params ||
60
+ typeof any.params !== 'object' ||
61
+ Array.isArray(any.params)
62
+ ) {
63
+ return {
64
+ ok: false,
65
+ code: 'bad_request',
66
+ message: 'payload.params must be an object when provided'
67
+ };
68
+ }
69
+ params = /** @type {Record<string, string|number|boolean>} */ (any.params);
70
+ }
71
+
72
+ // Per-type param schemas
73
+ if (type === 'issue-detail') {
74
+ const id = String(params?.id ?? '').trim();
75
+ if (id.length === 0) {
76
+ return {
77
+ ok: false,
78
+ code: 'bad_request',
79
+ message: 'params.id must be a non-empty string'
80
+ };
81
+ }
82
+ params = { id };
83
+ } else if (type === 'closed-issues') {
84
+ if (params && 'since' in params) {
85
+ const since = params.since;
86
+ const n = typeof since === 'number' ? since : Number.NaN;
87
+ if (!Number.isFinite(n) || n < 0) {
88
+ return {
89
+ ok: false,
90
+ code: 'bad_request',
91
+ message: 'params.since must be a non-negative number (epoch ms)'
92
+ };
93
+ }
94
+ params = { since: n };
95
+ } else {
96
+ params = undefined;
97
+ }
98
+ } else {
99
+ // Other types do not accept params
100
+ if (params && Object.keys(params).length > 0) {
101
+ return {
102
+ ok: false,
103
+ code: 'bad_request',
104
+ message: `type ${type} does not accept params`
105
+ };
106
+ }
107
+ params = undefined;
108
+ }
109
+
110
+ return { ok: true, id, spec: { type, params } };
111
+ }
package/server/watcher.js CHANGED
@@ -6,22 +6,19 @@ import { resolveDbPath } from './db.js';
6
6
  * Watch the resolved beads SQLite DB file and invoke a callback after a debounce window.
7
7
  * The DB path is resolved following beads precedence and can be overridden via options.
8
8
  * @param {string} root_dir - Project root directory (starting point for resolution).
9
- * @param {(payload: { ts: number }) => void} on_change - Called when changes are detected.
9
+ * @param {() => void} onChange - Called when changes are detected.
10
10
  * @param {{ debounce_ms?: number, explicit_db?: string }} [options]
11
11
  * @returns {{ close: () => void, rebind: (opts?: { root_dir?: string, explicit_db?: string }) => void, path: string }}
12
12
  */
13
- export function watchDb(root_dir, on_change, options = {}) {
13
+ export function watchDb(root_dir, onChange, options = {}) {
14
14
  const debounce_ms = options.debounce_ms ?? 250;
15
15
 
16
16
  /** @type {ReturnType<typeof setTimeout> | undefined} */
17
17
  let timer;
18
18
  /** @type {fs.FSWatcher | undefined} */
19
19
  let watcher;
20
- /** @type {string} */
21
20
  let current_path = '';
22
- /** @type {string} */
23
21
  let current_dir = '';
24
- /** @type {string} */
25
22
  let current_file = '';
26
23
 
27
24
  const schedule = () => {
@@ -29,9 +26,9 @@ export function watchDb(root_dir, on_change, options = {}) {
29
26
  clearTimeout(timer);
30
27
  }
31
28
  timer = setTimeout(() => {
32
- on_change({ ts: Date.now() });
29
+ onChange();
33
30
  }, debounce_ms);
34
- timer.unref?.();
31
+ timer.unref();
35
32
  };
36
33
 
37
34
  /**
@@ -92,11 +89,11 @@ export function watchDb(root_dir, on_change, options = {}) {
92
89
  rebind(opts = {}) {
93
90
  const next_root = opts.root_dir ? String(opts.root_dir) : root_dir;
94
91
  const next_explicit = opts.explicit_db ?? options.explicit_db;
95
- const nextResolved = resolveDbPath({
92
+ const next_resolved = resolveDbPath({
96
93
  cwd: next_root,
97
94
  explicit_db: next_explicit
98
95
  });
99
- const next_path = nextResolved.path;
96
+ const next_path = next_resolved.path;
100
97
  if (next_path !== current_path) {
101
98
  // swap watcher
102
99
  watcher?.close();