@rsktash/beads-ui 0.1.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 (68) hide show
  1. package/.github/workflows/publish.yml +28 -0
  2. package/app/protocol.js +216 -0
  3. package/bin/bdui +19 -0
  4. package/client/index.html +12 -0
  5. package/client/postcss.config.js +11 -0
  6. package/client/src/App.tsx +35 -0
  7. package/client/src/components/IssueCard.tsx +73 -0
  8. package/client/src/components/Layout.tsx +175 -0
  9. package/client/src/components/Markdown.tsx +77 -0
  10. package/client/src/components/PriorityBadge.tsx +26 -0
  11. package/client/src/components/SearchDialog.tsx +137 -0
  12. package/client/src/components/SectionEditor.tsx +212 -0
  13. package/client/src/components/StatusBadge.tsx +64 -0
  14. package/client/src/components/TypeBadge.tsx +26 -0
  15. package/client/src/hooks/use-mutation.ts +55 -0
  16. package/client/src/hooks/use-search.ts +19 -0
  17. package/client/src/hooks/use-subscription.ts +187 -0
  18. package/client/src/index.css +133 -0
  19. package/client/src/lib/avatar.ts +17 -0
  20. package/client/src/lib/types.ts +115 -0
  21. package/client/src/lib/ws-client.ts +214 -0
  22. package/client/src/lib/ws-context.tsx +28 -0
  23. package/client/src/main.tsx +10 -0
  24. package/client/src/views/Board.tsx +200 -0
  25. package/client/src/views/Detail.tsx +398 -0
  26. package/client/src/views/List.tsx +461 -0
  27. package/client/tailwind.config.ts +68 -0
  28. package/client/tsconfig.json +16 -0
  29. package/client/vite.config.ts +20 -0
  30. package/package.json +43 -0
  31. package/server/app.js +120 -0
  32. package/server/app.test.js +30 -0
  33. package/server/bd.js +227 -0
  34. package/server/bd.test.js +194 -0
  35. package/server/cli/cli.test.js +207 -0
  36. package/server/cli/commands.integration.test.js +148 -0
  37. package/server/cli/commands.js +285 -0
  38. package/server/cli/commands.unit.test.js +408 -0
  39. package/server/cli/daemon.js +340 -0
  40. package/server/cli/daemon.test.js +31 -0
  41. package/server/cli/index.js +135 -0
  42. package/server/cli/open.js +178 -0
  43. package/server/cli/open.test.js +26 -0
  44. package/server/cli/usage.js +27 -0
  45. package/server/config.js +36 -0
  46. package/server/db.js +154 -0
  47. package/server/db.test.js +169 -0
  48. package/server/dolt-pool.js +257 -0
  49. package/server/dolt-queries.js +646 -0
  50. package/server/index.js +97 -0
  51. package/server/list-adapters.js +395 -0
  52. package/server/list-adapters.test.js +208 -0
  53. package/server/logging.js +23 -0
  54. package/server/registry-watcher.js +200 -0
  55. package/server/subscriptions.js +299 -0
  56. package/server/subscriptions.test.js +128 -0
  57. package/server/validators.js +124 -0
  58. package/server/watcher.js +139 -0
  59. package/server/watcher.test.js +120 -0
  60. package/server/ws.comments.test.js +262 -0
  61. package/server/ws.delete.test.js +119 -0
  62. package/server/ws.js +1309 -0
  63. package/server/ws.labels.test.js +95 -0
  64. package/server/ws.list-refresh.coalesce.test.js +95 -0
  65. package/server/ws.list-subscriptions.test.js +403 -0
  66. package/server/ws.mutation-window.test.js +147 -0
  67. package/server/ws.mutations.test.js +389 -0
  68. package/server/ws.test.js +52 -0
@@ -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
+ }
@@ -0,0 +1,200 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { debug } from './logging.js';
5
+
6
+ const log = debug('registry-watcher');
7
+
8
+ /**
9
+ * In-memory registry of workspaces registered dynamically via the API.
10
+ * These supplement the file-based registry at ~/.beads/registry.json.
11
+ *
12
+ * @type {Map<string, { path: string, database: string, pid: number, version: string }>}
13
+ */
14
+ const inMemoryWorkspaces = new Map();
15
+
16
+ /**
17
+ * Register a workspace dynamically (in-memory).
18
+ * This allows `bdui start` to register workspaces when the server is already running.
19
+ *
20
+ * @param {{ path: string, database: string }} workspace
21
+ */
22
+ export function registerWorkspace(workspace) {
23
+ const normalized = path.resolve(workspace.path);
24
+ log('registering workspace: %s (db: %s)', normalized, workspace.database);
25
+ inMemoryWorkspaces.set(normalized, {
26
+ path: normalized,
27
+ database: workspace.database,
28
+ pid: process.pid,
29
+ version: 'dynamic'
30
+ });
31
+ }
32
+
33
+ /**
34
+ * Get all dynamically registered workspaces (in-memory only).
35
+ *
36
+ * @returns {Array<{ path: string, database: string, pid: number, version: string }>}
37
+ */
38
+ export function getInMemoryWorkspaces() {
39
+ return Array.from(inMemoryWorkspaces.values());
40
+ }
41
+
42
+ /**
43
+ * @typedef {Object} RegistryEntry
44
+ * @property {string} workspace_path
45
+ * @property {string} socket_path
46
+ * @property {string} database_path
47
+ * @property {number} pid
48
+ * @property {string} version
49
+ * @property {string} started_at
50
+ */
51
+
52
+ /**
53
+ * Get the path to the global beads registry file.
54
+ *
55
+ * @returns {string}
56
+ */
57
+ export function getRegistryPath() {
58
+ return path.join(os.homedir(), '.beads', 'registry.json');
59
+ }
60
+
61
+ /**
62
+ * Read and parse the registry file.
63
+ *
64
+ * @returns {RegistryEntry[]}
65
+ */
66
+ export function readRegistry() {
67
+ const registry_path = getRegistryPath();
68
+ try {
69
+ const content = fs.readFileSync(registry_path, 'utf8');
70
+ const data = JSON.parse(content);
71
+ if (Array.isArray(data)) {
72
+ return data;
73
+ }
74
+ return [];
75
+ } catch {
76
+ return [];
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Find the registry entry that matches the given root directory.
82
+ * Matches if the root_dir is the same as or a subdirectory of the workspace_path.
83
+ *
84
+ * @param {string} root_dir
85
+ * @returns {RegistryEntry | null}
86
+ */
87
+ export function findWorkspaceEntry(root_dir) {
88
+ const entries = readRegistry();
89
+ const normalized = path.resolve(root_dir);
90
+
91
+ // First, try exact match
92
+ for (const entry of entries) {
93
+ if (path.resolve(entry.workspace_path) === normalized) {
94
+ return entry;
95
+ }
96
+ }
97
+
98
+ // Then try to find if root_dir is inside a workspace
99
+ for (const entry of entries) {
100
+ const workspace = path.resolve(entry.workspace_path);
101
+ if (normalized.startsWith(workspace + path.sep)) {
102
+ return entry;
103
+ }
104
+ }
105
+
106
+ return null;
107
+ }
108
+
109
+ /**
110
+ * Get all available workspaces from both the file-based registry and
111
+ * dynamically registered in-memory workspaces.
112
+ *
113
+ * @returns {Array<{ path: string, database: string, pid: number, version: string }>}
114
+ */
115
+ export function getAvailableWorkspaces() {
116
+ const entries = readRegistry();
117
+ const fileWorkspaces = entries.map((entry) => ({
118
+ path: entry.workspace_path,
119
+ database: entry.database_path,
120
+ pid: entry.pid,
121
+ version: entry.version
122
+ }));
123
+
124
+ // Merge in-memory workspaces, avoiding duplicates by path
125
+ const seen = new Set(fileWorkspaces.map((w) => path.resolve(w.path)));
126
+ const inMemory = getInMemoryWorkspaces().filter(
127
+ (w) => !seen.has(path.resolve(w.path))
128
+ );
129
+
130
+ return [...fileWorkspaces, ...inMemory];
131
+ }
132
+
133
+ /**
134
+ * Watch the global beads registry file and invoke callback when it changes.
135
+ *
136
+ * @param {(entries: RegistryEntry[]) => void} onChange
137
+ * @param {{ debounce_ms?: number }} [options]
138
+ * @returns {{ close: () => void }}
139
+ */
140
+ export function watchRegistry(onChange, options = {}) {
141
+ const debounce_ms = options.debounce_ms ?? 500;
142
+ const registry_path = getRegistryPath();
143
+ const registry_dir = path.dirname(registry_path);
144
+ const registry_file = path.basename(registry_path);
145
+
146
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
147
+ let timer;
148
+ /** @type {fs.FSWatcher | undefined} */
149
+ let watcher;
150
+
151
+ const schedule = () => {
152
+ if (timer) {
153
+ clearTimeout(timer);
154
+ }
155
+ timer = setTimeout(() => {
156
+ try {
157
+ const entries = readRegistry();
158
+ onChange(entries);
159
+ } catch (err) {
160
+ log('error reading registry on change: %o', err);
161
+ }
162
+ }, debounce_ms);
163
+ timer.unref?.();
164
+ };
165
+
166
+ try {
167
+ // Ensure the directory exists before watching
168
+ if (!fs.existsSync(registry_dir)) {
169
+ log('registry directory does not exist: %s', registry_dir);
170
+ return { close: () => {} };
171
+ }
172
+
173
+ watcher = fs.watch(
174
+ registry_dir,
175
+ { persistent: true },
176
+ (event_type, filename) => {
177
+ if (filename && String(filename) !== registry_file) {
178
+ return;
179
+ }
180
+ if (event_type === 'change' || event_type === 'rename') {
181
+ log('registry %s %s', event_type, filename || '');
182
+ schedule();
183
+ }
184
+ }
185
+ );
186
+ } catch (err) {
187
+ log('unable to watch registry directory: %o', err);
188
+ return { close: () => {} };
189
+ }
190
+
191
+ return {
192
+ close() {
193
+ if (timer) {
194
+ clearTimeout(timer);
195
+ timer = undefined;
196
+ }
197
+ watcher?.close();
198
+ }
199
+ };
200
+ }
@@ -0,0 +1,299 @@
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
+ * }} Entry
34
+ */
35
+
36
+ /**
37
+ * Create a new, empty entry object.
38
+ *
39
+ * @returns {Entry}
40
+ */
41
+ function createEntry() {
42
+ return {
43
+ itemsById: new Map(),
44
+ subscribers: new Set(),
45
+ lock: Promise.resolve()
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Generate a stable subscription key string from a spec. Sorts params keys.
51
+ *
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
+ *
73
+ * @param {Map<string, ItemMeta>} prev
74
+ * @param {Map<string, ItemMeta>} next
75
+ * @returns {{ added: string[], updated: string[], removed: string[] }}
76
+ */
77
+ export function computeDelta(prev, next) {
78
+ /** @type {string[]} */
79
+ const added = [];
80
+ /** @type {string[]} */
81
+ const updated = [];
82
+ /** @type {string[]} */
83
+ const removed = [];
84
+
85
+ for (const [id, meta] of next) {
86
+ const p = prev.get(id);
87
+ if (!p) {
88
+ added.push(id);
89
+ continue;
90
+ }
91
+ if (p.updated_at !== meta.updated_at || p.closed_at !== meta.closed_at) {
92
+ updated.push(id);
93
+ }
94
+ }
95
+ for (const id of prev.keys()) {
96
+ if (!next.has(id)) {
97
+ removed.push(id);
98
+ }
99
+ }
100
+ return { added, updated, removed };
101
+ }
102
+
103
+ /**
104
+ * Normalize array of issue-like objects into an itemsById map.
105
+ *
106
+ * @param {Array<{ id: string, updated_at: number, closed_at?: number|null }>} items
107
+ * @returns {Map<string, ItemMeta>}
108
+ */
109
+ export function toItemsMap(items) {
110
+ /** @type {Map<string, ItemMeta>} */
111
+ const map = new Map();
112
+ for (const it of items) {
113
+ if (!it || typeof it.id !== 'string') {
114
+ continue;
115
+ }
116
+ const updated_at = Number(it.updated_at) || 0;
117
+ /** @type {number|null} */
118
+ let closed_at = null;
119
+ if (it.closed_at === null || it.closed_at === undefined) {
120
+ closed_at = null;
121
+ } else {
122
+ const n = Number(it.closed_at);
123
+ closed_at = Number.isFinite(n) ? n : null;
124
+ }
125
+ map.set(it.id, { updated_at, closed_at });
126
+ }
127
+ return map;
128
+ }
129
+
130
+ /**
131
+ * Create a subscription registry with attach/detach and per-key locking.
132
+ */
133
+ export class SubscriptionRegistry {
134
+ constructor() {
135
+ /** @type {Map<string, Entry>} */
136
+ this._entries = new Map();
137
+ }
138
+
139
+ /**
140
+ * Get an entry by key, or null if missing.
141
+ *
142
+ * @param {string} key
143
+ * @returns {Entry | null}
144
+ */
145
+ get(key) {
146
+ return this._entries.get(key) || null;
147
+ }
148
+
149
+ /**
150
+ * Ensure an entry exists for a spec; returns the key and entry.
151
+ *
152
+ * @param {SubscriptionSpec} spec
153
+ * @returns {{ key: string, entry: Entry }}
154
+ */
155
+ ensure(spec) {
156
+ const key = keyOf(spec);
157
+ let entry = this._entries.get(key);
158
+ if (!entry) {
159
+ entry = createEntry();
160
+ this._entries.set(key, entry);
161
+ }
162
+ return { key, entry };
163
+ }
164
+
165
+ /**
166
+ * Attach a subscriber to a spec. Creates the entry if missing.
167
+ *
168
+ * @param {SubscriptionSpec} spec
169
+ * @param {WebSocket} ws
170
+ * @returns {{ key: string, subscribed: true }}
171
+ */
172
+ attach(spec, ws) {
173
+ const { key, entry } = this.ensure(spec);
174
+ entry.subscribers.add(ws);
175
+ return { key, subscribed: true };
176
+ }
177
+
178
+ /**
179
+ * Detach a subscriber from the spec. Keeps entry even if empty; eviction
180
+ * is handled by `onDisconnect` sweep.
181
+ *
182
+ * @param {SubscriptionSpec} spec
183
+ * @param {WebSocket} ws
184
+ * @returns {boolean} true when the subscriber was removed
185
+ */
186
+ detach(spec, ws) {
187
+ const key = keyOf(spec);
188
+ const entry = this._entries.get(key);
189
+ if (!entry) {
190
+ return false;
191
+ }
192
+ return entry.subscribers.delete(ws);
193
+ }
194
+
195
+ /**
196
+ * On socket disconnect, remove it from all subscriber sets and evict any
197
+ * entries that become empty as a result of this sweep.
198
+ *
199
+ * @param {WebSocket} ws
200
+ */
201
+ onDisconnect(ws) {
202
+ /** @type {string[]} */
203
+ const empties = [];
204
+ for (const [key, entry] of this._entries) {
205
+ entry.subscribers.delete(ws);
206
+ if (entry.subscribers.size === 0) {
207
+ empties.push(key);
208
+ }
209
+ }
210
+ for (const key of empties) {
211
+ this._entries.delete(key);
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Serialize a function against a key so only one runs at a time per key.
217
+ *
218
+ * @template T
219
+ * @param {string} key
220
+ * @param {() => Promise<T>} fn
221
+ * @returns {Promise<T>}
222
+ */
223
+ async withKeyLock(key, fn) {
224
+ let entry = this._entries.get(key);
225
+ if (!entry) {
226
+ entry = createEntry();
227
+ this._entries.set(key, entry);
228
+ }
229
+ // Chain onto the existing lock
230
+ const prev = entry.lock;
231
+ // Create our own release function and store it locally (not in shared state)
232
+ // to avoid race conditions when multiple operations queue concurrently
233
+ /** @type {(v?: void) => void} */
234
+ let release = () => {};
235
+ const our_lock = new Promise((resolve) => {
236
+ release = resolve;
237
+ });
238
+ // Update the entry's lock to our lock so the next operation waits on us
239
+ entry.lock = our_lock;
240
+ // Wait for previous operations to finish
241
+ await prev.catch(() => {});
242
+ try {
243
+ const result = await fn();
244
+ return result;
245
+ } finally {
246
+ // Release our lock for the next queued operation
247
+ // Use the locally-captured release function, not entry.lockTail
248
+ try {
249
+ release();
250
+ } catch {
251
+ // ignore
252
+ }
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Replace items for a key and compute the delta, storing the new map.
258
+ *
259
+ * @param {string} key
260
+ * @param {Map<string, ItemMeta>} next_map
261
+ * @returns {{ added: string[], updated: string[], removed: string[] }}
262
+ */
263
+ applyNextMap(key, next_map) {
264
+ let entry = this._entries.get(key);
265
+ if (!entry) {
266
+ entry = createEntry();
267
+ this._entries.set(key, entry);
268
+ }
269
+ const prev = entry.itemsById;
270
+ const delta = computeDelta(prev, next_map);
271
+ entry.itemsById = new Map(next_map);
272
+ return delta;
273
+ }
274
+
275
+ /**
276
+ * Convenience: update items from an array of objects with id/updated_at/closed_at.
277
+ *
278
+ * @param {string} key
279
+ * @param {Array<{ id: string, updated_at: number, closed_at?: number|null }>} items
280
+ * @returns {{ added: string[], updated: string[], removed: string[] }}
281
+ */
282
+ applyItems(key, items) {
283
+ const next_map = toItemsMap(items);
284
+ return this.applyNextMap(key, next_map);
285
+ }
286
+
287
+ /**
288
+ * Clear all entries from the registry. Used when switching workspaces.
289
+ * Does not close WebSocket connections; they will re-subscribe on refresh.
290
+ */
291
+ clear() {
292
+ this._entries.clear();
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Default singleton registry used by the ws server.
298
+ */
299
+ export const registry = new SubscriptionRegistry();
@@ -0,0 +1,128 @@
1
+ import { describe, expect, test, vi } from 'vitest';
2
+ import {
3
+ SubscriptionRegistry,
4
+ computeDelta,
5
+ keyOf,
6
+ toItemsMap
7
+ } from './subscriptions.js';
8
+
9
+ describe('subscriptions registry', () => {
10
+ test('keyOf sorts params for stable keys', () => {
11
+ const a = keyOf({ type: 'list', params: { status: 'open', limit: 50 } });
12
+ const b = keyOf({ type: 'list', params: { limit: 50, status: 'open' } });
13
+ expect(a).toBe('list?limit=50&status=open');
14
+ expect(b).toBe('list?limit=50&status=open');
15
+ });
16
+
17
+ test('computeDelta returns added/updated/removed', () => {
18
+ const prev = toItemsMap([
19
+ { id: 'UI-1', updated_at: 1 },
20
+ { id: 'UI-2', updated_at: 2 }
21
+ ]);
22
+ const next = toItemsMap([
23
+ { id: 'UI-2', updated_at: 3 },
24
+ { id: 'UI-3', updated_at: 1 }
25
+ ]);
26
+ const d = computeDelta(prev, next);
27
+ expect(d.added).toEqual(['UI-3']);
28
+ expect(d.updated).toEqual(['UI-2']);
29
+ expect(d.removed).toEqual(['UI-1']);
30
+ });
31
+
32
+ test('computeDelta returns no changes for unchanged maps', () => {
33
+ const prev = toItemsMap([
34
+ { id: 'A', updated_at: 10, closed_at: null },
35
+ { id: 'B', updated_at: 20, closed_at: null }
36
+ ]);
37
+ const next = toItemsMap([
38
+ { id: 'A', updated_at: 10, closed_at: null },
39
+ { id: 'B', updated_at: 20, closed_at: null }
40
+ ]);
41
+ const d = computeDelta(prev, next);
42
+ expect(d.added).toEqual([]);
43
+ expect(d.updated).toEqual([]);
44
+ expect(d.removed).toEqual([]);
45
+ });
46
+
47
+ test('computeDelta handles empty sets', () => {
48
+ const empty = toItemsMap([]);
49
+ const some = toItemsMap([
50
+ { id: 'X', updated_at: 1, closed_at: null },
51
+ { id: 'Y', updated_at: 2, closed_at: null }
52
+ ]);
53
+
54
+ const d1 = computeDelta(empty, some);
55
+ expect(d1.added.sort()).toEqual(['X', 'Y']);
56
+ expect(d1.updated).toEqual([]);
57
+ expect(d1.removed).toEqual([]);
58
+
59
+ const d2 = computeDelta(some, empty);
60
+ expect(d2.added).toEqual([]);
61
+ expect(d2.updated).toEqual([]);
62
+ expect(d2.removed.sort()).toEqual(['X', 'Y']);
63
+ });
64
+
65
+ test('computeDelta returns only updates when ids unchanged', () => {
66
+ const prev = toItemsMap([
67
+ { id: 'A', updated_at: 1, closed_at: null },
68
+ { id: 'B', updated_at: 2, closed_at: null }
69
+ ]);
70
+ const next = toItemsMap([
71
+ { id: 'A', updated_at: 3, closed_at: null },
72
+ { id: 'B', updated_at: 5, closed_at: null }
73
+ ]);
74
+ const d = computeDelta(prev, next);
75
+ expect(d.added).toEqual([]);
76
+ expect(d.removed).toEqual([]);
77
+ expect(d.updated.sort()).toEqual(['A', 'B']);
78
+ });
79
+
80
+ test('attach/detach and disconnect-driven eviction', () => {
81
+ const reg = new SubscriptionRegistry();
82
+ /** @type {any} */
83
+ const ws_a = { OPEN: 1, readyState: 1, send: vi.fn() };
84
+ /** @type {any} */
85
+ const ws_b = { OPEN: 1, readyState: 1, send: vi.fn() };
86
+
87
+ const spec = { type: 'list', params: { status: 'open' } };
88
+ const { key } = reg.attach(spec, ws_a);
89
+ reg.attach(spec, ws_b);
90
+
91
+ const entry1 = reg.get(key);
92
+ expect(entry1 && entry1.subscribers.size).toBe(2);
93
+
94
+ const removed_a = reg.detach(spec, ws_a);
95
+ expect(removed_a).toBe(true);
96
+ const entry2 = reg.get(key);
97
+ expect(entry2 && entry2.subscribers.size).toBe(1);
98
+
99
+ // Disconnecting B should sweep it and remove empty entry
100
+ reg.onDisconnect(ws_b);
101
+ const entry3 = reg.get(key);
102
+ expect(entry3).toBeNull();
103
+ });
104
+
105
+ test('applyItems stores map and returns correct delta', () => {
106
+ const reg = new SubscriptionRegistry();
107
+ /** @type {any} */
108
+ const ws = { OPEN: 1, readyState: 1, send: vi.fn() };
109
+ const spec = { type: 'list', params: { ready: true } };
110
+ const { key } = reg.attach(spec, ws);
111
+
112
+ const d1 = reg.applyItems(key, [
113
+ { id: 'A', updated_at: 1 },
114
+ { id: 'B', updated_at: 1 }
115
+ ]);
116
+ expect(d1.added.sort()).toEqual(['A', 'B']);
117
+ expect(d1.updated).toEqual([]);
118
+ expect(d1.removed).toEqual([]);
119
+
120
+ const d2 = reg.applyItems(key, [
121
+ { id: 'B', updated_at: 2 },
122
+ { id: 'C', updated_at: 1 }
123
+ ]);
124
+ expect(d2.added).toEqual(['C']);
125
+ expect(d2.updated).toEqual(['B']);
126
+ expect(d2.removed).toEqual(['A']);
127
+ });
128
+ });