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,289 @@
|
|
|
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
|
+
*
|
|
40
|
+
* @returns {Entry}
|
|
41
|
+
*/
|
|
42
|
+
function createEntry() {
|
|
43
|
+
return {
|
|
44
|
+
itemsById: new Map(),
|
|
45
|
+
subscribers: new Set(),
|
|
46
|
+
lock: Promise.resolve(),
|
|
47
|
+
lockTail: () => {}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Generate a stable subscription key string from a spec. Sorts params keys.
|
|
53
|
+
*
|
|
54
|
+
* @param {SubscriptionSpec} spec
|
|
55
|
+
* @returns {string}
|
|
56
|
+
*/
|
|
57
|
+
export function keyOf(spec) {
|
|
58
|
+
const type = String(spec.type || '').trim();
|
|
59
|
+
/** @type {Record<string, string>} */
|
|
60
|
+
const flat = {};
|
|
61
|
+
if (spec.params && typeof spec.params === 'object') {
|
|
62
|
+
const keys = Object.keys(spec.params).sort();
|
|
63
|
+
for (const k of keys) {
|
|
64
|
+
const v = spec.params[k];
|
|
65
|
+
flat[k] = String(v);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const enc = new URLSearchParams(flat).toString();
|
|
69
|
+
return enc.length > 0 ? `${type}?${enc}` : type;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Compute a delta between previous and next item maps.
|
|
74
|
+
*
|
|
75
|
+
* @param {Map<string, ItemMeta>} prev
|
|
76
|
+
* @param {Map<string, ItemMeta>} next
|
|
77
|
+
* @returns {{ added: string[], updated: string[], removed: string[] }}
|
|
78
|
+
*/
|
|
79
|
+
export function computeDelta(prev, next) {
|
|
80
|
+
/** @type {string[]} */
|
|
81
|
+
const added = [];
|
|
82
|
+
/** @type {string[]} */
|
|
83
|
+
const updated = [];
|
|
84
|
+
/** @type {string[]} */
|
|
85
|
+
const removed = [];
|
|
86
|
+
|
|
87
|
+
for (const [id, meta] of next) {
|
|
88
|
+
const p = prev.get(id);
|
|
89
|
+
if (!p) {
|
|
90
|
+
added.push(id);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (p.updated_at !== meta.updated_at || p.closed_at !== meta.closed_at) {
|
|
94
|
+
updated.push(id);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
for (const id of prev.keys()) {
|
|
98
|
+
if (!next.has(id)) {
|
|
99
|
+
removed.push(id);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { added, updated, removed };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Normalize array of issue-like objects into an itemsById map.
|
|
107
|
+
*
|
|
108
|
+
* @param {Array<{ id: string, updated_at: number, closed_at?: number|null }>} items
|
|
109
|
+
* @returns {Map<string, ItemMeta>}
|
|
110
|
+
*/
|
|
111
|
+
export function toItemsMap(items) {
|
|
112
|
+
/** @type {Map<string, ItemMeta>} */
|
|
113
|
+
const map = new Map();
|
|
114
|
+
for (const it of items) {
|
|
115
|
+
if (!it || typeof it.id !== 'string') {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const updated_at = Number(it.updated_at) || 0;
|
|
119
|
+
/** @type {number|null} */
|
|
120
|
+
let closed_at = null;
|
|
121
|
+
if (it.closed_at === null || it.closed_at === undefined) {
|
|
122
|
+
closed_at = null;
|
|
123
|
+
} else {
|
|
124
|
+
const n = Number(it.closed_at);
|
|
125
|
+
closed_at = Number.isFinite(n) ? n : null;
|
|
126
|
+
}
|
|
127
|
+
map.set(it.id, { updated_at, closed_at });
|
|
128
|
+
}
|
|
129
|
+
return map;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Create a subscription registry with attach/detach and per-key locking.
|
|
134
|
+
*/
|
|
135
|
+
export class SubscriptionRegistry {
|
|
136
|
+
constructor() {
|
|
137
|
+
/** @type {Map<string, Entry>} */
|
|
138
|
+
this._entries = new Map();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Get an entry by key, or null if missing.
|
|
143
|
+
*
|
|
144
|
+
* @param {string} key
|
|
145
|
+
* @returns {Entry | null}
|
|
146
|
+
*/
|
|
147
|
+
get(key) {
|
|
148
|
+
return this._entries.get(key) || null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Ensure an entry exists for a spec; returns the key and entry.
|
|
153
|
+
*
|
|
154
|
+
* @param {SubscriptionSpec} spec
|
|
155
|
+
* @returns {{ key: string, entry: Entry }}
|
|
156
|
+
*/
|
|
157
|
+
ensure(spec) {
|
|
158
|
+
const key = keyOf(spec);
|
|
159
|
+
let entry = this._entries.get(key);
|
|
160
|
+
if (!entry) {
|
|
161
|
+
entry = createEntry();
|
|
162
|
+
this._entries.set(key, entry);
|
|
163
|
+
}
|
|
164
|
+
return { key, entry };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Attach a subscriber to a spec. Creates the entry if missing.
|
|
169
|
+
*
|
|
170
|
+
* @param {SubscriptionSpec} spec
|
|
171
|
+
* @param {WebSocket} ws
|
|
172
|
+
* @returns {{ key: string, subscribed: true }}
|
|
173
|
+
*/
|
|
174
|
+
attach(spec, ws) {
|
|
175
|
+
const { key, entry } = this.ensure(spec);
|
|
176
|
+
entry.subscribers.add(ws);
|
|
177
|
+
return { key, subscribed: true };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Detach a subscriber from the spec. Keeps entry even if empty; eviction
|
|
182
|
+
* is handled by `onDisconnect` sweep.
|
|
183
|
+
*
|
|
184
|
+
* @param {SubscriptionSpec} spec
|
|
185
|
+
* @param {WebSocket} ws
|
|
186
|
+
* @returns {boolean} true when the subscriber was removed
|
|
187
|
+
*/
|
|
188
|
+
detach(spec, ws) {
|
|
189
|
+
const key = keyOf(spec);
|
|
190
|
+
const entry = this._entries.get(key);
|
|
191
|
+
if (!entry) {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
return entry.subscribers.delete(ws);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* On socket disconnect, remove it from all subscriber sets and evict any
|
|
199
|
+
* entries that become empty as a result of this sweep.
|
|
200
|
+
*
|
|
201
|
+
* @param {WebSocket} ws
|
|
202
|
+
*/
|
|
203
|
+
onDisconnect(ws) {
|
|
204
|
+
/** @type {string[]} */
|
|
205
|
+
const empties = [];
|
|
206
|
+
for (const [key, entry] of this._entries) {
|
|
207
|
+
entry.subscribers.delete(ws);
|
|
208
|
+
if (entry.subscribers.size === 0) {
|
|
209
|
+
empties.push(key);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
for (const key of empties) {
|
|
213
|
+
this._entries.delete(key);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Serialize a function against a key so only one runs at a time per key.
|
|
219
|
+
*
|
|
220
|
+
* @template T
|
|
221
|
+
* @param {string} key
|
|
222
|
+
* @param {() => Promise<T>} fn
|
|
223
|
+
* @returns {Promise<T>}
|
|
224
|
+
*/
|
|
225
|
+
async withKeyLock(key, fn) {
|
|
226
|
+
let entry = this._entries.get(key);
|
|
227
|
+
if (!entry) {
|
|
228
|
+
entry = createEntry();
|
|
229
|
+
this._entries.set(key, entry);
|
|
230
|
+
}
|
|
231
|
+
// Chain onto the existing lock
|
|
232
|
+
const prev = entry.lock;
|
|
233
|
+
/** @type {(v?: void) => void} */
|
|
234
|
+
let release = () => {};
|
|
235
|
+
entry.lock = new Promise((resolve) => {
|
|
236
|
+
release = resolve;
|
|
237
|
+
});
|
|
238
|
+
entry.lockTail = release;
|
|
239
|
+
// Wait for previous operations to finish
|
|
240
|
+
await prev.catch(() => {});
|
|
241
|
+
try {
|
|
242
|
+
const result = await fn();
|
|
243
|
+
return result;
|
|
244
|
+
} finally {
|
|
245
|
+
// Release lock for next queued op
|
|
246
|
+
try {
|
|
247
|
+
entry.lockTail();
|
|
248
|
+
} catch {
|
|
249
|
+
// ignore
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Replace items for a key and compute the delta, storing the new map.
|
|
256
|
+
*
|
|
257
|
+
* @param {string} key
|
|
258
|
+
* @param {Map<string, ItemMeta>} next_map
|
|
259
|
+
* @returns {{ added: string[], updated: string[], removed: string[] }}
|
|
260
|
+
*/
|
|
261
|
+
applyNextMap(key, next_map) {
|
|
262
|
+
let entry = this._entries.get(key);
|
|
263
|
+
if (!entry) {
|
|
264
|
+
entry = createEntry();
|
|
265
|
+
this._entries.set(key, entry);
|
|
266
|
+
}
|
|
267
|
+
const prev = entry.itemsById;
|
|
268
|
+
const delta = computeDelta(prev, next_map);
|
|
269
|
+
entry.itemsById = new Map(next_map);
|
|
270
|
+
return delta;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Convenience: update items from an array of objects with id/updated_at/closed_at.
|
|
275
|
+
*
|
|
276
|
+
* @param {string} key
|
|
277
|
+
* @param {Array<{ id: string, updated_at: number, closed_at?: number|null }>} items
|
|
278
|
+
* @returns {{ added: string[], updated: string[], removed: string[] }}
|
|
279
|
+
*/
|
|
280
|
+
applyItems(key, items) {
|
|
281
|
+
const next_map = toItemsMap(items);
|
|
282
|
+
return this.applyNextMap(key, next_map);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Default singleton registry used by the ws server.
|
|
288
|
+
*/
|
|
289
|
+
export const registry = new SubscriptionRegistry();
|
|
@@ -0,0 +1,113 @@
|
|
|
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
|
+
*
|
|
10
|
+
* @type {Set<string>}
|
|
11
|
+
*/
|
|
12
|
+
const SUBSCRIPTION_TYPES = new Set([
|
|
13
|
+
'all-issues',
|
|
14
|
+
'epics',
|
|
15
|
+
'blocked-issues',
|
|
16
|
+
'ready-issues',
|
|
17
|
+
'in-progress-issues',
|
|
18
|
+
'closed-issues',
|
|
19
|
+
'issue-detail'
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Validate a subscribe-list payload and normalize to a SubscriptionSpec.
|
|
24
|
+
*
|
|
25
|
+
* @param {unknown} payload
|
|
26
|
+
* @returns {{ ok: true, id: string, spec: { type: string, params?: Record<string, string|number|boolean> } } | { ok: false, code: 'bad_request', message: string }}
|
|
27
|
+
*/
|
|
28
|
+
export function validateSubscribeListPayload(payload) {
|
|
29
|
+
if (!payload || typeof payload !== 'object') {
|
|
30
|
+
return {
|
|
31
|
+
ok: false,
|
|
32
|
+
code: 'bad_request',
|
|
33
|
+
message: 'payload must be an object'
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const any =
|
|
37
|
+
/** @type {{ id?: unknown, type?: unknown, params?: unknown }} */ (payload);
|
|
38
|
+
|
|
39
|
+
const id = typeof any.id === 'string' ? any.id : '';
|
|
40
|
+
if (id.length === 0) {
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
code: 'bad_request',
|
|
44
|
+
message: 'payload.id must be a non-empty string'
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const type = typeof any.type === 'string' ? any.type : '';
|
|
49
|
+
if (type.length === 0 || !SUBSCRIPTION_TYPES.has(type)) {
|
|
50
|
+
return {
|
|
51
|
+
ok: false,
|
|
52
|
+
code: 'bad_request',
|
|
53
|
+
message: `payload.type must be one of: ${Array.from(SUBSCRIPTION_TYPES).join(', ')}`
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @type {Record<string, string|number|boolean> | undefined} */
|
|
58
|
+
let params;
|
|
59
|
+
if (any.params !== undefined) {
|
|
60
|
+
if (
|
|
61
|
+
!any.params ||
|
|
62
|
+
typeof any.params !== 'object' ||
|
|
63
|
+
Array.isArray(any.params)
|
|
64
|
+
) {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
code: 'bad_request',
|
|
68
|
+
message: 'payload.params must be an object when provided'
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
params = /** @type {Record<string, string|number|boolean>} */ (any.params);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Per-type param schemas
|
|
75
|
+
if (type === 'issue-detail') {
|
|
76
|
+
const id = String(params?.id ?? '').trim();
|
|
77
|
+
if (id.length === 0) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
code: 'bad_request',
|
|
81
|
+
message: 'params.id must be a non-empty string'
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
params = { id };
|
|
85
|
+
} else if (type === 'closed-issues') {
|
|
86
|
+
if (params && 'since' in params) {
|
|
87
|
+
const since = params.since;
|
|
88
|
+
const n = typeof since === 'number' ? since : Number.NaN;
|
|
89
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
code: 'bad_request',
|
|
93
|
+
message: 'params.since must be a non-negative number (epoch ms)'
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
params = { since: n };
|
|
97
|
+
} else {
|
|
98
|
+
params = undefined;
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
// Other types do not accept params
|
|
102
|
+
if (params && Object.keys(params).length > 0) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
code: 'bad_request',
|
|
106
|
+
message: `type ${type} does not accept params`
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
params = undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { ok: true, id, spec: { type, params } };
|
|
113
|
+
}
|
package/server/watcher.js
CHANGED
|
@@ -5,8 +5,9 @@ import { resolveDbPath } from './db.js';
|
|
|
5
5
|
/**
|
|
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
9
|
* @param {string} root_dir - Project root directory (starting point for resolution).
|
|
9
|
-
* @param {(
|
|
10
|
+
* @param {() => void} onChange - Called when changes are detected.
|
|
10
11
|
* @param {{ debounce_ms?: number, explicit_db?: string }} [options]
|
|
11
12
|
* @returns {{ close: () => void, rebind: (opts?: { root_dir?: string, explicit_db?: string }) => void, path: string }}
|
|
12
13
|
*/
|
|
@@ -17,11 +18,8 @@ export function watchDb(root_dir, onChange, options = {}) {
|
|
|
17
18
|
let timer;
|
|
18
19
|
/** @type {fs.FSWatcher | undefined} */
|
|
19
20
|
let watcher;
|
|
20
|
-
/** @type {string} */
|
|
21
21
|
let current_path = '';
|
|
22
|
-
/** @type {string} */
|
|
23
22
|
let current_dir = '';
|
|
24
|
-
/** @type {string} */
|
|
25
23
|
let current_file = '';
|
|
26
24
|
|
|
27
25
|
const schedule = () => {
|
|
@@ -29,13 +27,14 @@ export function watchDb(root_dir, onChange, options = {}) {
|
|
|
29
27
|
clearTimeout(timer);
|
|
30
28
|
}
|
|
31
29
|
timer = setTimeout(() => {
|
|
32
|
-
onChange(
|
|
30
|
+
onChange();
|
|
33
31
|
}, debounce_ms);
|
|
34
|
-
timer.unref
|
|
32
|
+
timer.unref();
|
|
35
33
|
};
|
|
36
34
|
|
|
37
35
|
/**
|
|
38
36
|
* Attach a watcher to the directory containing the resolved DB path.
|
|
37
|
+
*
|
|
39
38
|
* @param {string} base_dir
|
|
40
39
|
* @param {string | undefined} explicit_db
|
|
41
40
|
*/
|
|
@@ -87,16 +86,17 @@ export function watchDb(root_dir, onChange, options = {}) {
|
|
|
87
86
|
},
|
|
88
87
|
/**
|
|
89
88
|
* Re-resolve and reattach watcher when root_dir or explicit_db changes.
|
|
89
|
+
*
|
|
90
90
|
* @param {{ root_dir?: string, explicit_db?: string }} [opts]
|
|
91
91
|
*/
|
|
92
92
|
rebind(opts = {}) {
|
|
93
93
|
const next_root = opts.root_dir ? String(opts.root_dir) : root_dir;
|
|
94
94
|
const next_explicit = opts.explicit_db ?? options.explicit_db;
|
|
95
|
-
const
|
|
95
|
+
const next_resolved = resolveDbPath({
|
|
96
96
|
cwd: next_root,
|
|
97
97
|
explicit_db: next_explicit
|
|
98
98
|
});
|
|
99
|
-
const next_path =
|
|
99
|
+
const next_path = next_resolved.path;
|
|
100
100
|
if (next_path !== current_path) {
|
|
101
101
|
// swap watcher
|
|
102
102
|
watcher?.close();
|