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
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @import { SubscriptionIssueStore, SubscriptionIssueStoreOptions } from '../../types/subscription-issue-store.js'
|
|
3
|
-
*/
|
|
4
|
-
import { cmpPriorityThenCreated } from './sort.js';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Per-subscription issue store. Holds full Issue objects and exposes a
|
|
8
|
-
* deterministic, read-only snapshot for rendering. Applies snapshot/upsert/
|
|
9
|
-
* delete messages in revision order and preserves object identity per id.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
// Sort comparator is centralized in app/data/sort.js
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Create a SubscriptionIssueStore for a given subscription id.
|
|
16
|
-
* @param {string} id
|
|
17
|
-
* @param {SubscriptionIssueStoreOptions} [options]
|
|
18
|
-
* @returns {SubscriptionIssueStore}
|
|
19
|
-
*/
|
|
20
|
-
export function createSubscriptionIssueStore(id, options = {}) {
|
|
21
|
-
/** @type {Map<string, any>} */
|
|
22
|
-
const items_by_id = new Map();
|
|
23
|
-
/** @type {any[]} */
|
|
24
|
-
let ordered = [];
|
|
25
|
-
/** @type {number} */
|
|
26
|
-
let last_revision = 0;
|
|
27
|
-
/** @type {Set<() => void>} */
|
|
28
|
-
const listeners = new Set();
|
|
29
|
-
/** @type {boolean} */
|
|
30
|
-
let is_disposed = false;
|
|
31
|
-
/** @type {(a:any,b:any)=>number} */
|
|
32
|
-
const sort = options.sort || cmpPriorityThenCreated;
|
|
33
|
-
|
|
34
|
-
function emit() {
|
|
35
|
-
for (const fn of Array.from(listeners)) {
|
|
36
|
-
try {
|
|
37
|
-
fn();
|
|
38
|
-
} catch {
|
|
39
|
-
// ignore listener errors
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function rebuildOrdered() {
|
|
45
|
-
ordered = Array.from(items_by_id.values()).sort(sort);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Apply snapshot/upsert/delete in revision order. Snapshots reset state.
|
|
50
|
-
* - Ignore messages with revision <= last_revision (except snapshot which resets first).
|
|
51
|
-
* - Preserve object identity when updating an existing item by mutating
|
|
52
|
-
* fields in place rather than replacing the object reference.
|
|
53
|
-
* @param {{ type: 'snapshot'|'upsert'|'delete', id: string, revision: number, issues?: any[], issue?: any, issue_id?: string }} msg
|
|
54
|
-
*/
|
|
55
|
-
function applyPush(msg) {
|
|
56
|
-
if (is_disposed) {
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
if (!msg || msg.id !== id) {
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
const rev = Number(msg.revision) || 0;
|
|
63
|
-
// Ignore stale messages for all types, including snapshots
|
|
64
|
-
if (rev <= last_revision && msg.type !== 'snapshot') {
|
|
65
|
-
return; // stale or duplicate non-snapshot
|
|
66
|
-
}
|
|
67
|
-
if (msg.type === 'snapshot') {
|
|
68
|
-
if (rev <= last_revision) {
|
|
69
|
-
return; // ignore stale snapshot
|
|
70
|
-
}
|
|
71
|
-
items_by_id.clear();
|
|
72
|
-
const items = Array.isArray(msg.issues) ? msg.issues : [];
|
|
73
|
-
for (const it of items) {
|
|
74
|
-
if (it && typeof it.id === 'string' && it.id.length > 0) {
|
|
75
|
-
items_by_id.set(it.id, it);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
rebuildOrdered();
|
|
79
|
-
last_revision = rev;
|
|
80
|
-
emit();
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
if (msg.type === 'upsert') {
|
|
84
|
-
const it = msg.issue;
|
|
85
|
-
if (it && typeof it.id === 'string' && it.id.length > 0) {
|
|
86
|
-
const existing = items_by_id.get(it.id);
|
|
87
|
-
if (!existing) {
|
|
88
|
-
items_by_id.set(it.id, it);
|
|
89
|
-
} else {
|
|
90
|
-
// Guard with updated_at; prefer newer
|
|
91
|
-
const prev_ts = Number.isFinite(existing.updated_at)
|
|
92
|
-
? /** @type {number} */ (existing.updated_at)
|
|
93
|
-
: 0;
|
|
94
|
-
const next_ts = Number.isFinite(it.updated_at)
|
|
95
|
-
? /** @type {number} */ (it.updated_at)
|
|
96
|
-
: 0;
|
|
97
|
-
if (prev_ts <= next_ts) {
|
|
98
|
-
// Mutate existing object to preserve reference
|
|
99
|
-
for (const k of Object.keys(existing)) {
|
|
100
|
-
if (!(k in it)) {
|
|
101
|
-
// remove keys that disappeared to avoid stale fields
|
|
102
|
-
delete existing[k];
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
for (const [k, v] of Object.entries(it)) {
|
|
106
|
-
// @ts-ignore - dynamic assignment
|
|
107
|
-
existing[k] = v;
|
|
108
|
-
}
|
|
109
|
-
} else {
|
|
110
|
-
// stale by timestamp; ignore
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
rebuildOrdered();
|
|
114
|
-
}
|
|
115
|
-
last_revision = rev;
|
|
116
|
-
emit();
|
|
117
|
-
} else if (msg.type === 'delete') {
|
|
118
|
-
const rid = String(msg.issue_id || '');
|
|
119
|
-
if (rid) {
|
|
120
|
-
items_by_id.delete(rid);
|
|
121
|
-
rebuildOrdered();
|
|
122
|
-
}
|
|
123
|
-
last_revision = rev;
|
|
124
|
-
emit();
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
return {
|
|
129
|
-
id,
|
|
130
|
-
/**
|
|
131
|
-
* @param {() => void} fn
|
|
132
|
-
*/
|
|
133
|
-
subscribe(fn) {
|
|
134
|
-
listeners.add(fn);
|
|
135
|
-
return () => {
|
|
136
|
-
listeners.delete(fn);
|
|
137
|
-
};
|
|
138
|
-
},
|
|
139
|
-
applyPush,
|
|
140
|
-
snapshot() {
|
|
141
|
-
// Return as read-only view; callers must not mutate
|
|
142
|
-
return ordered;
|
|
143
|
-
},
|
|
144
|
-
size() {
|
|
145
|
-
return items_by_id.size;
|
|
146
|
-
},
|
|
147
|
-
/**
|
|
148
|
-
* @param {string} xid
|
|
149
|
-
*/
|
|
150
|
-
getById(xid) {
|
|
151
|
-
return items_by_id.get(xid);
|
|
152
|
-
},
|
|
153
|
-
dispose() {
|
|
154
|
-
is_disposed = true;
|
|
155
|
-
items_by_id.clear();
|
|
156
|
-
ordered = [];
|
|
157
|
-
listeners.clear();
|
|
158
|
-
last_revision = 0;
|
|
159
|
-
}
|
|
160
|
-
};
|
|
161
|
-
}
|
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @import { SubscriptionIssueStoreOptions } from '../../types/subscription-issue-store.js'
|
|
3
|
-
* @import { IssueLite } from './list-selectors.js'
|
|
4
|
-
*/
|
|
5
|
-
import { createSubscriptionIssueStore } from './subscription-issue-store.js';
|
|
6
|
-
import { subKeyOf } from './subscriptions-store.js';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Registry managing per-subscription issue stores. Stores receive full-issue
|
|
10
|
-
* push envelopes (snapshot/upsert/delete) per subscription id and expose
|
|
11
|
-
* read-only snapshots for rendering.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
*/
|
|
16
|
-
export function createSubscriptionIssueStores() {
|
|
17
|
-
/** @type {Map<string, ReturnType<typeof createSubscriptionIssueStore>>} */
|
|
18
|
-
const stores_by_id = new Map();
|
|
19
|
-
/** @type {Map<string, string>} */
|
|
20
|
-
const key_by_id = new Map();
|
|
21
|
-
/** @type {Set<() => void>} */
|
|
22
|
-
const listeners = new Set();
|
|
23
|
-
/** @type {Map<string, () => void>} */
|
|
24
|
-
const store_unsubs = new Map();
|
|
25
|
-
|
|
26
|
-
function emit() {
|
|
27
|
-
for (const fn of Array.from(listeners)) {
|
|
28
|
-
try {
|
|
29
|
-
fn();
|
|
30
|
-
} catch {
|
|
31
|
-
// ignore
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Ensure a store exists for client_id and attach a listener that fans out
|
|
38
|
-
* store-level updates to global listeners.
|
|
39
|
-
* @param {string} client_id
|
|
40
|
-
* @param {{ type: string, params?: Record<string, string|number|boolean> }} [spec]
|
|
41
|
-
* @param {SubscriptionIssueStoreOptions} [options]
|
|
42
|
-
*/
|
|
43
|
-
function register(client_id, spec, options) {
|
|
44
|
-
key_by_id.set(client_id, spec ? subKeyOf(spec) : '');
|
|
45
|
-
if (!stores_by_id.has(client_id)) {
|
|
46
|
-
const store = createSubscriptionIssueStore(client_id, options);
|
|
47
|
-
stores_by_id.set(client_id, store);
|
|
48
|
-
// Fan out per-store events to global subscribers
|
|
49
|
-
const off = store.subscribe(() => emit());
|
|
50
|
-
store_unsubs.set(client_id, off);
|
|
51
|
-
}
|
|
52
|
-
return () => unregister(client_id);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* @param {string} client_id
|
|
57
|
-
*/
|
|
58
|
-
function unregister(client_id) {
|
|
59
|
-
key_by_id.delete(client_id);
|
|
60
|
-
const store = stores_by_id.get(client_id);
|
|
61
|
-
if (store) {
|
|
62
|
-
store.dispose();
|
|
63
|
-
stores_by_id.delete(client_id);
|
|
64
|
-
}
|
|
65
|
-
const off = store_unsubs.get(client_id);
|
|
66
|
-
if (off) {
|
|
67
|
-
try {
|
|
68
|
-
off();
|
|
69
|
-
} catch {
|
|
70
|
-
// ignore
|
|
71
|
-
}
|
|
72
|
-
store_unsubs.delete(client_id);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
return {
|
|
77
|
-
register,
|
|
78
|
-
unregister,
|
|
79
|
-
/**
|
|
80
|
-
* @param {string} client_id
|
|
81
|
-
*/
|
|
82
|
-
getStore(client_id) {
|
|
83
|
-
return stores_by_id.get(client_id) || null;
|
|
84
|
-
},
|
|
85
|
-
/**
|
|
86
|
-
* @param {string} client_id
|
|
87
|
-
* @returns {IssueLite[]}
|
|
88
|
-
*/
|
|
89
|
-
snapshotFor(client_id) {
|
|
90
|
-
const s = stores_by_id.get(client_id);
|
|
91
|
-
return s ? /** @type {IssueLite[]} */ (s.snapshot().slice()) : [];
|
|
92
|
-
},
|
|
93
|
-
/**
|
|
94
|
-
* @param {() => void} fn
|
|
95
|
-
*/
|
|
96
|
-
subscribe(fn) {
|
|
97
|
-
listeners.add(fn);
|
|
98
|
-
return () => listeners.delete(fn);
|
|
99
|
-
}
|
|
100
|
-
// No recompute helpers in vNext; stores are updated directly via push
|
|
101
|
-
};
|
|
102
|
-
}
|
|
@@ -1,219 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @import { MessageType } from '../protocol.js'
|
|
3
|
-
*/
|
|
4
|
-
/**
|
|
5
|
-
* Client-side list subscription store.
|
|
6
|
-
*
|
|
7
|
-
* Maintains per-subscription state keyed by client-provided `id`.
|
|
8
|
-
* Applies server `list-delta` events per subscription key and exposes simple
|
|
9
|
-
* selectors for rendering.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* @typedef {{ type: string, params?: Record<string, string|number|boolean> }} SubscriptionSpec
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Generate a stable subscription key string from a spec.
|
|
18
|
-
* Mirrors server `keyOf` implementation (sorted params, URLSearchParams).
|
|
19
|
-
* @param {SubscriptionSpec} spec
|
|
20
|
-
* @returns {string}
|
|
21
|
-
*/
|
|
22
|
-
export function subKeyOf(spec) {
|
|
23
|
-
const type = String(spec.type || '').trim();
|
|
24
|
-
/** @type {Record<string, string>} */
|
|
25
|
-
const flat = {};
|
|
26
|
-
if (spec.params && typeof spec.params === 'object') {
|
|
27
|
-
const keys = Object.keys(spec.params).sort();
|
|
28
|
-
for (const k of keys) {
|
|
29
|
-
const v = spec.params[k];
|
|
30
|
-
flat[k] = String(v);
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
const enc = new URLSearchParams(flat).toString();
|
|
34
|
-
return enc.length > 0 ? `${type}?${enc}` : type;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Create a list subscription store.
|
|
39
|
-
*
|
|
40
|
-
* Wiring:
|
|
41
|
-
* - Use `subscribeList` to register a subscription and send the request.
|
|
42
|
-
*
|
|
43
|
-
* Selectors are synchronous and return derived state by client id.
|
|
44
|
-
* @param {(type: MessageType, payload?: unknown) => Promise<unknown>} send - ws send.
|
|
45
|
-
*/
|
|
46
|
-
export function createSubscriptionStore(send) {
|
|
47
|
-
/** @type {Map<string, { key: string, itemsById: Map<string, true> }>} */
|
|
48
|
-
const subs_by_id = new Map();
|
|
49
|
-
/** @type {Map<string, Set<string>>} */
|
|
50
|
-
const ids_by_key = new Map();
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Apply a delta to all client ids mapped to a given key.
|
|
54
|
-
* @param {string} key
|
|
55
|
-
* @param {{ added: string[], updated: string[], removed: string[] }} delta
|
|
56
|
-
*/
|
|
57
|
-
function applyDelta(key, delta) {
|
|
58
|
-
const id_set = ids_by_key.get(key);
|
|
59
|
-
if (!id_set || id_set.size === 0) {
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
const added = Array.isArray(delta.added) ? delta.added : [];
|
|
63
|
-
const updated = Array.isArray(delta.updated) ? delta.updated : [];
|
|
64
|
-
const removed = Array.isArray(delta.removed) ? delta.removed : [];
|
|
65
|
-
|
|
66
|
-
for (const client_id of Array.from(id_set)) {
|
|
67
|
-
const entry = subs_by_id.get(client_id);
|
|
68
|
-
if (!entry) {
|
|
69
|
-
continue;
|
|
70
|
-
}
|
|
71
|
-
const items = entry.itemsById;
|
|
72
|
-
for (const id of added) {
|
|
73
|
-
if (typeof id === 'string' && id.length > 0) {
|
|
74
|
-
items.set(id, true);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
for (const id of updated) {
|
|
78
|
-
if (typeof id === 'string' && id.length > 0) {
|
|
79
|
-
items.set(id, true);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
for (const id of removed) {
|
|
83
|
-
if (typeof id === 'string' && id.length > 0) {
|
|
84
|
-
items.delete(id);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Subscribe to a list spec with a client-provided id.
|
|
92
|
-
* Returns an unsubscribe function.
|
|
93
|
-
* Creates an empty items store immediately; server will publish deltas.
|
|
94
|
-
* @param {string} client_id
|
|
95
|
-
* @param {SubscriptionSpec} spec
|
|
96
|
-
* @returns {Promise<() => Promise<void>>}
|
|
97
|
-
*/
|
|
98
|
-
async function subscribeList(client_id, spec) {
|
|
99
|
-
const key = subKeyOf(spec);
|
|
100
|
-
// Initialize local entry immediately to capture early deltas
|
|
101
|
-
if (!subs_by_id.has(client_id)) {
|
|
102
|
-
subs_by_id.set(client_id, { key, itemsById: new Map() });
|
|
103
|
-
} else {
|
|
104
|
-
// Update key mapping if client id is reused for a different spec
|
|
105
|
-
const prev = subs_by_id.get(client_id);
|
|
106
|
-
if (prev && prev.key !== key) {
|
|
107
|
-
const prev_ids = ids_by_key.get(prev.key);
|
|
108
|
-
if (prev_ids) {
|
|
109
|
-
prev_ids.delete(client_id);
|
|
110
|
-
if (prev_ids.size === 0) {
|
|
111
|
-
ids_by_key.delete(prev.key);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
subs_by_id.set(client_id, { key, itemsById: new Map() });
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
if (!ids_by_key.has(key)) {
|
|
118
|
-
ids_by_key.set(key, new Set());
|
|
119
|
-
}
|
|
120
|
-
const set = ids_by_key.get(key);
|
|
121
|
-
if (set) {
|
|
122
|
-
set.add(client_id);
|
|
123
|
-
}
|
|
124
|
-
try {
|
|
125
|
-
await send('subscribe-list', {
|
|
126
|
-
id: client_id,
|
|
127
|
-
type: spec.type,
|
|
128
|
-
params: spec.params
|
|
129
|
-
});
|
|
130
|
-
} catch {
|
|
131
|
-
// keep local mapping to allow retries; caller may unsubscribe or retry
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
return async () => {
|
|
135
|
-
try {
|
|
136
|
-
await send('unsubscribe-list', { id: client_id });
|
|
137
|
-
} catch {
|
|
138
|
-
// ignore transport errors on unsubscribe
|
|
139
|
-
}
|
|
140
|
-
// Cleanup local mappings
|
|
141
|
-
const entry = subs_by_id.get(client_id) || null;
|
|
142
|
-
if (entry) {
|
|
143
|
-
const s = ids_by_key.get(entry.key);
|
|
144
|
-
if (s) {
|
|
145
|
-
s.delete(client_id);
|
|
146
|
-
if (s.size === 0) {
|
|
147
|
-
ids_by_key.delete(entry.key);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
subs_by_id.delete(client_id);
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Selectors by client id.
|
|
157
|
-
*/
|
|
158
|
-
const selectors = {
|
|
159
|
-
/**
|
|
160
|
-
* Get an array of item ids for a subscription.
|
|
161
|
-
* @param {string} client_id
|
|
162
|
-
* @returns {string[]}
|
|
163
|
-
*/
|
|
164
|
-
getIds(client_id) {
|
|
165
|
-
const entry = subs_by_id.get(client_id);
|
|
166
|
-
if (!entry) {
|
|
167
|
-
return [];
|
|
168
|
-
}
|
|
169
|
-
return Array.from(entry.itemsById.keys());
|
|
170
|
-
},
|
|
171
|
-
/**
|
|
172
|
-
* Check if an id exists in a subscription.
|
|
173
|
-
* @param {string} client_id
|
|
174
|
-
* @param {string} id
|
|
175
|
-
* @returns {boolean}
|
|
176
|
-
*/
|
|
177
|
-
has(client_id, id) {
|
|
178
|
-
const entry = subs_by_id.get(client_id);
|
|
179
|
-
if (!entry) {
|
|
180
|
-
return false;
|
|
181
|
-
}
|
|
182
|
-
return entry.itemsById.has(id);
|
|
183
|
-
},
|
|
184
|
-
/**
|
|
185
|
-
* Count items for a subscription.
|
|
186
|
-
* @param {string} client_id
|
|
187
|
-
* @returns {number}
|
|
188
|
-
*/
|
|
189
|
-
count(client_id) {
|
|
190
|
-
const entry = subs_by_id.get(client_id);
|
|
191
|
-
return entry ? entry.itemsById.size : 0;
|
|
192
|
-
},
|
|
193
|
-
/**
|
|
194
|
-
* Return a shallow object copy `{ [id]: true }` for rendering helpers.
|
|
195
|
-
* @param {string} client_id
|
|
196
|
-
* @returns {Record<string, true>}
|
|
197
|
-
*/
|
|
198
|
-
getItemsById(client_id) {
|
|
199
|
-
const entry = subs_by_id.get(client_id);
|
|
200
|
-
/** @type {Record<string, true>} */
|
|
201
|
-
const out = {};
|
|
202
|
-
if (!entry) {
|
|
203
|
-
return out;
|
|
204
|
-
}
|
|
205
|
-
for (const id of entry.itemsById.keys()) {
|
|
206
|
-
out[id] = true;
|
|
207
|
-
}
|
|
208
|
-
return out;
|
|
209
|
-
}
|
|
210
|
-
};
|
|
211
|
-
|
|
212
|
-
return {
|
|
213
|
-
subscribeList,
|
|
214
|
-
// test/diagnostics helpers
|
|
215
|
-
_applyDelta: applyDelta,
|
|
216
|
-
_subKeyOf: subKeyOf,
|
|
217
|
-
selectors
|
|
218
|
-
};
|
|
219
|
-
}
|