beads-ui 0.3.1 → 0.4.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 +24 -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 -14
- package/server/app.js +38 -36
- package/server/bd.js +2 -2
- package/server/cli/commands.js +8 -8
- package/server/cli/daemon.js +13 -5
- package/server/cli/index.js +17 -31
- package/server/cli/usage.js +3 -2
- package/server/db.js +6 -6
- package/server/index.js +10 -4
- package/server/logging.js +23 -0
- package/server/watcher.js +7 -5
- package/server/ws.js +23 -11
- package/app/data/list-selectors.js +0 -103
- package/app/data/providers.js +0 -78
- package/app/data/sort.js +0 -47
- package/app/data/subscription-issue-store.js +0 -161
- package/app/data/subscription-issue-stores.js +0 -128
- package/app/data/subscriptions-store.js +0 -227
- package/app/main.js +0 -706
- package/app/protocol.md +0 -66
- package/app/router.js +0 -117
- package/app/state.js +0 -105
- package/app/utils/issue-id-renderer.js +0 -72
- package/app/utils/issue-id.js +0 -11
- package/app/utils/issue-type.js +0 -29
- package/app/utils/issue-url.js +0 -10
- package/app/utils/markdown.js +0 -16
- package/app/utils/priority-badge.js +0 -48
- package/app/utils/priority.js +0 -1
- package/app/utils/status-badge.js +0 -33
- package/app/utils/status.js +0 -25
- package/app/utils/toast.js +0 -35
- package/app/utils/type-badge.js +0 -34
- package/app/views/board.js +0 -537
- package/app/views/detail.js +0 -1252
- package/app/views/epics.js +0 -281
- package/app/views/issue-dialog.js +0 -164
- package/app/views/issue-row.js +0 -191
- package/app/views/list.js +0 -468
- package/app/views/nav.js +0 -68
- package/app/views/new-issue-dialog.js +0 -348
- package/app/ws.js +0 -282
- 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/protocol.js +0 -3
|
@@ -1,103 +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
|
-
*
|
|
17
|
-
* @param {{ snapshotFor?: (client_id: string) => IssueLite[], subscribe?: (fn: () => void) => () => void }} [issue_stores]
|
|
18
|
-
*/
|
|
19
|
-
export function createListSelectors(issue_stores = undefined) {
|
|
20
|
-
// Sorting comparators are centralized in app/data/sort.js
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Get entities for a subscription id with Issues List sort (priority asc → created asc).
|
|
24
|
-
*
|
|
25
|
-
* @param {string} client_id
|
|
26
|
-
* @returns {IssueLite[]}
|
|
27
|
-
*/
|
|
28
|
-
function selectIssuesFor(client_id) {
|
|
29
|
-
if (!issue_stores || typeof issue_stores.snapshotFor !== 'function') {
|
|
30
|
-
return [];
|
|
31
|
-
}
|
|
32
|
-
return issue_stores
|
|
33
|
-
.snapshotFor(client_id)
|
|
34
|
-
.slice()
|
|
35
|
-
.sort(cmpPriorityThenCreated);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Get entities for a Board column with column-specific sort.
|
|
40
|
-
*
|
|
41
|
-
* @param {string} client_id
|
|
42
|
-
* @param {'ready'|'blocked'|'in_progress'|'closed'} mode
|
|
43
|
-
* @returns {IssueLite[]}
|
|
44
|
-
*/
|
|
45
|
-
function selectBoardColumn(client_id, mode) {
|
|
46
|
-
const arr =
|
|
47
|
-
issue_stores && issue_stores.snapshotFor
|
|
48
|
-
? issue_stores.snapshotFor(client_id).slice()
|
|
49
|
-
: [];
|
|
50
|
-
if (mode === 'in_progress') {
|
|
51
|
-
arr.sort(cmpPriorityThenCreated);
|
|
52
|
-
} else if (mode === 'closed') {
|
|
53
|
-
arr.sort(cmpClosedDesc);
|
|
54
|
-
} else {
|
|
55
|
-
// ready/blocked share the same sort
|
|
56
|
-
arr.sort(cmpPriorityThenCreated);
|
|
57
|
-
}
|
|
58
|
-
return arr;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Get children for an epic subscribed as client id `epic:${id}`.
|
|
63
|
-
* Sorted as Issues List (priority asc → created asc).
|
|
64
|
-
*
|
|
65
|
-
* @param {string} epic_id
|
|
66
|
-
* @returns {IssueLite[]}
|
|
67
|
-
*/
|
|
68
|
-
function selectEpicChildren(epic_id) {
|
|
69
|
-
if (!issue_stores || typeof issue_stores.snapshotFor !== 'function') {
|
|
70
|
-
return [];
|
|
71
|
-
}
|
|
72
|
-
// Epic detail subscription uses client id `detail:<id>` and contains the
|
|
73
|
-
// epic entity with a `dependents` array. Render children from that list.
|
|
74
|
-
const arr = /** @type {any[]} */ (
|
|
75
|
-
issue_stores.snapshotFor(`detail:${epic_id}`) || []
|
|
76
|
-
);
|
|
77
|
-
const epic = arr.find((it) => String(it?.id || '') === String(epic_id));
|
|
78
|
-
const dependents = Array.isArray(epic?.dependents) ? epic.dependents : [];
|
|
79
|
-
return /** @type {IssueLite[]} */ (
|
|
80
|
-
dependents.slice().sort(cmpPriorityThenCreated)
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Subscribe for re-render; triggers once per issues envelope.
|
|
86
|
-
*
|
|
87
|
-
* @param {() => void} fn
|
|
88
|
-
* @returns {() => void}
|
|
89
|
-
*/
|
|
90
|
-
function subscribe(fn) {
|
|
91
|
-
if (issue_stores && typeof issue_stores.subscribe === 'function') {
|
|
92
|
-
return issue_stores.subscribe(fn);
|
|
93
|
-
}
|
|
94
|
-
return () => {};
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
return {
|
|
98
|
-
selectIssuesFor,
|
|
99
|
-
selectBoardColumn,
|
|
100
|
-
selectEpicChildren,
|
|
101
|
-
subscribe
|
|
102
|
-
};
|
|
103
|
-
}
|
package/app/data/providers.js
DELETED
|
@@ -1,78 +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
|
-
*
|
|
9
|
-
* @param {(type: MessageType, payload?: unknown) => Promise<unknown>} transport - Request/response function.
|
|
10
|
-
* @returns {{ updateIssue: (input: { id: string, title?: string, acceptance?: string, notes?: string, design?: string, status?: 'open'|'in_progress'|'closed', priority?: number, assignee?: string }) => Promise<unknown> }}
|
|
11
|
-
*/
|
|
12
|
-
export function createDataLayer(transport) {
|
|
13
|
-
/**
|
|
14
|
-
* Update issue fields by dispatching specific mutations.
|
|
15
|
-
* Supported fields: title, acceptance, notes, design, status, priority, assignee.
|
|
16
|
-
* Returns the updated issue on success.
|
|
17
|
-
*
|
|
18
|
-
* @param {{ id: string, title?: string, acceptance?: string, notes?: string, design?: string, status?: 'open'|'in_progress'|'closed', priority?: number, assignee?: string }} input
|
|
19
|
-
* @returns {Promise<unknown>}
|
|
20
|
-
*/
|
|
21
|
-
async function updateIssue(input) {
|
|
22
|
-
const { id } = input;
|
|
23
|
-
/** @type {unknown} */
|
|
24
|
-
let last = null;
|
|
25
|
-
if (typeof input.title === 'string') {
|
|
26
|
-
last = await transport('edit-text', {
|
|
27
|
-
id,
|
|
28
|
-
field: 'title',
|
|
29
|
-
value: input.title
|
|
30
|
-
});
|
|
31
|
-
}
|
|
32
|
-
if (typeof input.acceptance === 'string') {
|
|
33
|
-
last = await transport('edit-text', {
|
|
34
|
-
id,
|
|
35
|
-
field: 'acceptance',
|
|
36
|
-
value: input.acceptance
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
if (typeof input.notes === 'string') {
|
|
40
|
-
last = await transport('edit-text', {
|
|
41
|
-
id,
|
|
42
|
-
field: 'notes',
|
|
43
|
-
value: input.notes
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
if (typeof input.design === 'string') {
|
|
47
|
-
last = await transport('edit-text', {
|
|
48
|
-
id,
|
|
49
|
-
field: 'design',
|
|
50
|
-
value: input.design
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
if (typeof input.status === 'string') {
|
|
54
|
-
last = await transport('update-status', {
|
|
55
|
-
id,
|
|
56
|
-
status: input.status
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
if (typeof input.priority === 'number') {
|
|
60
|
-
last = await transport('update-priority', {
|
|
61
|
-
id,
|
|
62
|
-
priority: input.priority
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
// type updates are not supported via UI
|
|
66
|
-
if (typeof input.assignee === 'string') {
|
|
67
|
-
last = await transport('update-assignee', {
|
|
68
|
-
id,
|
|
69
|
-
assignee: input.assignee
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
return last;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
return {
|
|
76
|
-
updateIssue
|
|
77
|
-
};
|
|
78
|
-
}
|
package/app/data/sort.js
DELETED
|
@@ -1,47 +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
|
-
*
|
|
13
|
-
* @param {IssueLite} a
|
|
14
|
-
* @param {IssueLite} b
|
|
15
|
-
*/
|
|
16
|
-
export function cmpPriorityThenCreated(a, b) {
|
|
17
|
-
const pa = a.priority ?? 2;
|
|
18
|
-
const pb = b.priority ?? 2;
|
|
19
|
-
if (pa !== pb) {
|
|
20
|
-
return pa - pb;
|
|
21
|
-
}
|
|
22
|
-
const ca = a.created_at ?? 0;
|
|
23
|
-
const cb = b.created_at ?? 0;
|
|
24
|
-
if (ca !== cb) {
|
|
25
|
-
return ca < cb ? -1 : 1;
|
|
26
|
-
}
|
|
27
|
-
const ida = a.id;
|
|
28
|
-
const idb = b.id;
|
|
29
|
-
return ida < idb ? -1 : ida > idb ? 1 : 0;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Compare by closed_at desc, then id asc for stability.
|
|
34
|
-
*
|
|
35
|
-
* @param {IssueLite} a
|
|
36
|
-
* @param {IssueLite} b
|
|
37
|
-
*/
|
|
38
|
-
export function cmpClosedDesc(a, b) {
|
|
39
|
-
const ca = a.closed_at ?? 0;
|
|
40
|
-
const cb = b.closed_at ?? 0;
|
|
41
|
-
if (ca !== cb) {
|
|
42
|
-
return ca < cb ? 1 : -1;
|
|
43
|
-
}
|
|
44
|
-
const ida = a?.id;
|
|
45
|
-
const idb = b?.id;
|
|
46
|
-
return ida < idb ? -1 : ida > idb ? 1 : 0;
|
|
47
|
-
}
|
|
@@ -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
|
-
/**
|
|
13
|
-
* Create a SubscriptionIssueStore for a given subscription id.
|
|
14
|
-
*
|
|
15
|
-
* @param {string} id
|
|
16
|
-
* @param {SubscriptionIssueStoreOptions} [options]
|
|
17
|
-
* @returns {SubscriptionIssueStore}
|
|
18
|
-
*/
|
|
19
|
-
export function createSubscriptionIssueStore(id, options = {}) {
|
|
20
|
-
/** @type {Map<string, any>} */
|
|
21
|
-
const items_by_id = new Map();
|
|
22
|
-
/** @type {any[]} */
|
|
23
|
-
let ordered = [];
|
|
24
|
-
/** @type {number} */
|
|
25
|
-
let last_revision = 0;
|
|
26
|
-
/** @type {Set<() => void>} */
|
|
27
|
-
const listeners = new Set();
|
|
28
|
-
/** @type {boolean} */
|
|
29
|
-
let is_disposed = false;
|
|
30
|
-
/** @type {(a:any,b:any)=>number} */
|
|
31
|
-
const sort = options.sort || cmpPriorityThenCreated;
|
|
32
|
-
|
|
33
|
-
function emit() {
|
|
34
|
-
for (const fn of Array.from(listeners)) {
|
|
35
|
-
try {
|
|
36
|
-
fn();
|
|
37
|
-
} catch {
|
|
38
|
-
// ignore listener errors
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function rebuildOrdered() {
|
|
44
|
-
ordered = Array.from(items_by_id.values()).sort(sort);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Apply snapshot/upsert/delete in revision order. Snapshots reset state.
|
|
49
|
-
* - Ignore messages with revision <= last_revision (except snapshot which resets first).
|
|
50
|
-
* - Preserve object identity when updating an existing item by mutating
|
|
51
|
-
* fields in place rather than replacing the object reference.
|
|
52
|
-
*
|
|
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,128 +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
|
-
export function createSubscriptionIssueStores() {
|
|
14
|
-
/** @type {Map<string, ReturnType<typeof createSubscriptionIssueStore>>} */
|
|
15
|
-
const stores_by_id = new Map();
|
|
16
|
-
/** @type {Map<string, string>} */
|
|
17
|
-
const key_by_id = new Map();
|
|
18
|
-
/** @type {Set<() => void>} */
|
|
19
|
-
const listeners = new Set();
|
|
20
|
-
/** @type {Map<string, () => void>} */
|
|
21
|
-
const store_unsubs = new Map();
|
|
22
|
-
|
|
23
|
-
function emit() {
|
|
24
|
-
for (const fn of Array.from(listeners)) {
|
|
25
|
-
try {
|
|
26
|
-
fn();
|
|
27
|
-
} catch {
|
|
28
|
-
// ignore
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Ensure a store exists for client_id and attach a listener that fans out
|
|
35
|
-
* store-level updates to global listeners.
|
|
36
|
-
*
|
|
37
|
-
* @param {string} client_id
|
|
38
|
-
* @param {{ type: string, params?: Record<string, string|number|boolean> }} [spec]
|
|
39
|
-
* @param {SubscriptionIssueStoreOptions} [options]
|
|
40
|
-
*/
|
|
41
|
-
function register(client_id, spec, options) {
|
|
42
|
-
const next_key = spec ? subKeyOf(spec) : '';
|
|
43
|
-
const prev_key = key_by_id.get(client_id) || '';
|
|
44
|
-
const has_store = stores_by_id.has(client_id);
|
|
45
|
-
// If the subscription spec changed for an existing client id, replace the
|
|
46
|
-
// underlying store to reset revision state and avoid ignoring a fresh
|
|
47
|
-
// snapshot with a lower revision (different server list).
|
|
48
|
-
if (has_store && prev_key && next_key && prev_key !== next_key) {
|
|
49
|
-
const prev_store = stores_by_id.get(client_id);
|
|
50
|
-
if (prev_store) {
|
|
51
|
-
try {
|
|
52
|
-
prev_store.dispose();
|
|
53
|
-
} catch {
|
|
54
|
-
// ignore
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
const off_prev = store_unsubs.get(client_id);
|
|
58
|
-
if (off_prev) {
|
|
59
|
-
try {
|
|
60
|
-
off_prev();
|
|
61
|
-
} catch {
|
|
62
|
-
// ignore
|
|
63
|
-
}
|
|
64
|
-
store_unsubs.delete(client_id);
|
|
65
|
-
}
|
|
66
|
-
const new_store = createSubscriptionIssueStore(client_id, options);
|
|
67
|
-
stores_by_id.set(client_id, new_store);
|
|
68
|
-
const off_new = new_store.subscribe(() => emit());
|
|
69
|
-
store_unsubs.set(client_id, off_new);
|
|
70
|
-
} else if (!has_store) {
|
|
71
|
-
const store = createSubscriptionIssueStore(client_id, options);
|
|
72
|
-
stores_by_id.set(client_id, store);
|
|
73
|
-
// Fan out per-store events to global subscribers
|
|
74
|
-
const off = store.subscribe(() => emit());
|
|
75
|
-
store_unsubs.set(client_id, off);
|
|
76
|
-
}
|
|
77
|
-
key_by_id.set(client_id, next_key);
|
|
78
|
-
return () => unregister(client_id);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* @param {string} client_id
|
|
83
|
-
*/
|
|
84
|
-
function unregister(client_id) {
|
|
85
|
-
key_by_id.delete(client_id);
|
|
86
|
-
const store = stores_by_id.get(client_id);
|
|
87
|
-
if (store) {
|
|
88
|
-
store.dispose();
|
|
89
|
-
stores_by_id.delete(client_id);
|
|
90
|
-
}
|
|
91
|
-
const off = store_unsubs.get(client_id);
|
|
92
|
-
if (off) {
|
|
93
|
-
try {
|
|
94
|
-
off();
|
|
95
|
-
} catch {
|
|
96
|
-
// ignore
|
|
97
|
-
}
|
|
98
|
-
store_unsubs.delete(client_id);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
return {
|
|
103
|
-
register,
|
|
104
|
-
unregister,
|
|
105
|
-
/**
|
|
106
|
-
* @param {string} client_id
|
|
107
|
-
*/
|
|
108
|
-
getStore(client_id) {
|
|
109
|
-
return stores_by_id.get(client_id) || null;
|
|
110
|
-
},
|
|
111
|
-
/**
|
|
112
|
-
* @param {string} client_id
|
|
113
|
-
* @returns {IssueLite[]}
|
|
114
|
-
*/
|
|
115
|
-
snapshotFor(client_id) {
|
|
116
|
-
const s = stores_by_id.get(client_id);
|
|
117
|
-
return s ? /** @type {IssueLite[]} */ (s.snapshot().slice()) : [];
|
|
118
|
-
},
|
|
119
|
-
/**
|
|
120
|
-
* @param {() => void} fn
|
|
121
|
-
*/
|
|
122
|
-
subscribe(fn) {
|
|
123
|
-
listeners.add(fn);
|
|
124
|
-
return () => listeners.delete(fn);
|
|
125
|
-
}
|
|
126
|
-
// No recompute helpers in vNext; stores are updated directly via push
|
|
127
|
-
};
|
|
128
|
-
}
|