@estiva-app/protocol 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.
@@ -0,0 +1,200 @@
1
+ /**
2
+ * One REQ per channel, refcounted (PEE-6).
3
+ *
4
+ * Two components can be looking at the same channel — the conversation and the
5
+ * sidebar — and the last one to unmount is the one that should close the REQ.
6
+ * This sits over `liveRelay` and owns exactly that: one relay subscription per
7
+ * channel uuid, shared by every consumer, closed when the last releases.
8
+ *
9
+ * Reconnection is deliberately **not** handled here. `createLiveRelay` re-issues
10
+ * every registered subscription after it re-authenticates, so a channel with a
11
+ * live refcount comes back on its own. Duplicating that logic would give two
12
+ * places to get it wrong.
13
+ *
14
+ * **Built inside Peek** (PEE-6) because Gate 2 had not happened when it was due.
15
+ * SHA-3 is the ticket that owed the move.
16
+ *
17
+ * ## The trap: one subscription per channel, always
18
+ *
19
+ * **Never build one subscription covering several channels.** Depending on the
20
+ * filter it either fails loudly or, worse, returns correct history and then
21
+ * receives **zero live events** — EOSE right, live empty, nothing to
22
+ * distinguish it from working until somebody notices nothing ever arrives.
23
+ *
24
+ * Verified in Buzz rather than taken on trust, because the whole point is that
25
+ * it is invisible:
26
+ *
27
+ * 1. `extract_channel_id_from_filters` (`handlers/req.rs`) returns `None` the
28
+ * moment two distinct `#h` values appear, or any filter lacks `#h`.
29
+ * 2. With `channel_id: None` the subscription registers in the **global**
30
+ * indexes (`subscription.rs`).
31
+ * 3. `fan_out_scoped` handles a channel-scoped event by consulting only
32
+ * `channel_kind_index` and `channel_wildcard_index`. A global subscription
33
+ * is in neither.
34
+ * 4. The file states it outright: *"Global subscriptions (channel_id = None)
35
+ * do NOT receive channel-scoped events."*
36
+ *
37
+ * Historical delivery at REQ time takes a different path (`per_filter_channel`)
38
+ * which handles multi-`#h` correctly. That asymmetry is the whole illusion.
39
+ *
40
+ * **Which of the two failures you get depends on whether the filter names
41
+ * kinds**, and this was measured against production rather than reasoned about.
42
+ * A global subscription must clear `p_gated_filters_authorized`
43
+ * (`handlers/req.rs`), whose first test is:
44
+ *
45
+ * let can_match_p_gated = filter.kinds.as_ref().is_none_or(|ks| …);
46
+ * if !can_match_p_gated { return true; }
47
+ *
48
+ * So a **kindless** multi-`#h` filter *could* match a p-gated kind, has no
49
+ * `#p`, and is refused outright — a live probe against
50
+ * `wss://estiva.estiva.app` got `CLOSED … "restricted: p-gated events require
51
+ * #p matching your pubkey"` immediately. But a filter naming only ordinary
52
+ * kinds — `{"#h":[a,b],"kinds":[9]}` — returns early as authorized, registers
53
+ * globally, and dies **silently**.
54
+ *
55
+ * That is the dangerous one, and it is the shape somebody optimising "one
56
+ * subscription for messages across all my channels" would naturally write. The
57
+ * ticket describes this variant; the loud one is a newer gate sitting in front
58
+ * of it.
59
+ *
60
+ * **There is a second entrance to the same trap, and it is the likelier one.**
61
+ * `extract_channel_id_from_filters` only counts an `#h` value it can
62
+ * `parse::<uuid::Uuid>()`; anything else leaves `filter_has_channel` false and
63
+ * falls through to the same global registration. So passing a **topic id**
64
+ * where a channel uuid belongs produces the same broken subscription — and
65
+ * Peek's topic ids are Convex ids, which are not uuids. Because this module
66
+ * always builds a kindless filter, that lands on the loud arm above rather than
67
+ * the silent one; it is still a subscription that never delivers, and it still
68
+ * fails asynchronously as a `CLOSED` frame the app would have to interpret.
69
+ * Throwing at the call site names the cause instead. RFC 0.3's note on this ticket
70
+ * asks specifically that a topic id never become the subscription key; that is
71
+ * why {@link createChannelSubscriptions} validates the shape and throws rather
72
+ * than letting a bad key reach the relay. In Peek `topics.channelUuid` is also
73
+ * `v.optional`, so "absent on older topics" is a real case, not a theoretical
74
+ * one, and it must not arrive here as `undefined`.
75
+ *
76
+ * ## One kindless filter is enough
77
+ *
78
+ * `{"#h":[uuid]}` with no `kinds` registers in the channel **wildcard** index
79
+ * and therefore receives every kind in the channel. Reactions (kind:7) and
80
+ * deletions (kind:5) carry no `h` tag of their own, but `filters_match`
81
+ * (`buzz-core/src/filter.rs`) falls back to `StoredEvent.channel_id` for `#h`
82
+ * when an event has no `h` tags at all — the channel is derived from the target
83
+ * at ingest. So messages, reactions, deletions and assertions all arrive on this
84
+ * one subscription.
85
+ *
86
+ * That is strictly better than the HTTP path it replaces, which needs four
87
+ * sequential round trips and caps reactions to the newest 100 messages per
88
+ * topic. The cap is deliberately not ported.
89
+ *
90
+ * `kinds: []` would be worse than useless — Buzz indexes such a subscription
91
+ * *nowhere* and it silently receives nothing — which is another reason this
92
+ * builds the filter itself rather than accepting one.
93
+ */
94
+ import type { SignedEvent } from './events.js'
95
+ import type { LiveRelay, Subscription } from './live.js'
96
+
97
+ /** Canonical v4-shaped uuid, as `crypto.randomUUID()` produces. */
98
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
99
+
100
+ export type ChannelEventHandler = (event: SignedEvent) => void
101
+
102
+ export interface ChannelSubscription {
103
+ /** Idempotent. Closes the REQ only when the last consumer releases. */
104
+ release(): void
105
+ }
106
+
107
+ export interface ChannelSubscriptions {
108
+ /**
109
+ * Watch one channel. Safe to call many times for the same channel — the
110
+ * relay sees one REQ, and every consumer sees every event.
111
+ *
112
+ * @throws if `channelUuid` is not a uuid. See the trap above: a topic id here
113
+ * would be accepted by the relay and then silently deliver nothing.
114
+ */
115
+ subscribe(channelUuid: string, onEvent: ChannelEventHandler): ChannelSubscription
116
+ /** Channels with at least one live consumer. Test and diagnostic seam. */
117
+ activeChannels(): string[]
118
+ /** How many consumers hold `channelUuid`. Test and diagnostic seam. */
119
+ subscriberCount(channelUuid: string): number
120
+ /** Release everything. Does not close the underlying relay connection. */
121
+ close(): void
122
+ }
123
+
124
+ interface ChannelEntry {
125
+ relaySub: Subscription
126
+ listeners: Set<ChannelEventHandler>
127
+ }
128
+
129
+ export function createChannelSubscriptions(
130
+ relay: Pick<LiveRelay, 'subscribe'>,
131
+ options: { onListenerError?: (error: unknown) => void } = {},
132
+ ): ChannelSubscriptions {
133
+ const channels = new Map<string, ChannelEntry>()
134
+
135
+ function dispatch(channelUuid: string, event: SignedEvent) {
136
+ const entry = channels.get(channelUuid)
137
+ if (!entry) return
138
+ // A copy, because a listener is allowed to release during dispatch — and
139
+ // one that throws must not stop the others from being told. A single
140
+ // component's bug should not silently stop the whole channel updating.
141
+ for (const listener of [...entry.listeners]) {
142
+ try {
143
+ listener(event)
144
+ } catch (error) {
145
+ options.onListenerError?.(error)
146
+ }
147
+ }
148
+ }
149
+
150
+ return {
151
+ subscribe(channelUuid, onEvent) {
152
+ if (!UUID.test(channelUuid)) {
153
+ throw new Error(
154
+ `channelSubscriptions: "${channelUuid}" is not a channel uuid. ` +
155
+ 'Buzz can only scope a subscription by an #h it can parse as a uuid; ' +
156
+ 'anything else registers globally and then receives no channel events at all. ' +
157
+ "Pass the channel's uuid, never an application id for the thing " +
158
+ 'rendered in it — Peek\'s topic ids are Convex ids, which are not uuids.',
159
+ )
160
+ }
161
+
162
+ let entry = channels.get(channelUuid)
163
+ if (!entry) {
164
+ const listeners = new Set<ChannelEventHandler>()
165
+ // One channel, one filter, no `kinds` — see the header. Built here
166
+ // rather than accepted from the caller so neither half of the trap is
167
+ // reachable through this API.
168
+ const relaySub = relay.subscribe([{ '#h': [channelUuid] }], (event) =>
169
+ dispatch(channelUuid, event),
170
+ )
171
+ entry = { relaySub, listeners }
172
+ channels.set(channelUuid, entry)
173
+ }
174
+ entry.listeners.add(onEvent)
175
+
176
+ let released = false
177
+ return {
178
+ release() {
179
+ if (released) return
180
+ released = true
181
+ const current = channels.get(channelUuid)
182
+ if (!current) return
183
+ current.listeners.delete(onEvent)
184
+ if (current.listeners.size > 0) return
185
+ // Last one out closes the REQ.
186
+ channels.delete(channelUuid)
187
+ current.relaySub.close()
188
+ },
189
+ }
190
+ },
191
+
192
+ activeChannels: () => [...channels.keys()],
193
+ subscriberCount: (channelUuid) => channels.get(channelUuid)?.listeners.size ?? 0,
194
+
195
+ close() {
196
+ for (const entry of channels.values()) entry.relaySub.close()
197
+ channels.clear()
198
+ },
199
+ }
200
+ }