@kumiai/hub-server 0.1.0 → 0.3.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/lib/handlers.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { HandlerError } from '@enkaku/server';
2
+ import { hubErrorCodeOf } from '@kumiai/hub-protocol';
2
3
  import { fromB64, toB64 } from '@sozai/codec';
3
4
  import { createRateLimiter } from './rateLimit.js';
4
5
  export const DEFAULT_RATE_LIMITS = {
@@ -23,6 +24,20 @@ function getClientDID(ctx) {
23
24
  }
24
25
  return iss;
25
26
  }
27
+ /**
28
+ * Re-raise a named store error with its wire code, so the caller can tell a lost compare-and-set
29
+ * from an unreachable hub. Anything else is not ours and passes through untouched.
30
+ */ function rethrowAsHandlerError(error) {
31
+ const code = hubErrorCodeOf(error);
32
+ if (code != null) {
33
+ throw new HandlerError({
34
+ code,
35
+ message: error instanceof Error ? error.message : code,
36
+ cause: error
37
+ });
38
+ }
39
+ throw error;
40
+ }
26
41
  export function createHandlers(params) {
27
42
  const { store, registry } = params;
28
43
  const authorize = params.authorize ?? (()=>true);
@@ -88,23 +103,44 @@ export function createHandlers(params) {
88
103
  });
89
104
  }
90
105
  const payloadBytes = fromB64(payload);
91
- const sequenceID = await store.publish({
92
- senderDID,
93
- topicID,
94
- payload: payloadBytes
95
- });
96
- // Live-deliver to currently-connected subscribers (minus the sender).
97
- const subscribers = await store.getSubscribers(topicID);
98
- for (const recipientDID of subscribers){
99
- if (recipientDID === senderDID) continue;
100
- const client = registry.getClient(recipientDID);
101
- if (client?.sendMessage != null) {
102
- client.sendMessage({
103
- sequenceID,
104
- senderDID,
105
- topicID,
106
- payload: payloadBytes
107
- });
106
+ let sequenceID;
107
+ let deduped;
108
+ try {
109
+ const result = await store.publish({
110
+ senderDID,
111
+ topicID,
112
+ payload: payloadBytes,
113
+ retain: ctx.param.retain,
114
+ // Absent and null are different requests: null is the empty-topic sentinel, absent is
115
+ // an unconditional publish. Spreading only when present keeps them apart.
116
+ ...'expectedHead' in ctx.param ? {
117
+ expectedHead: ctx.param.expectedHead
118
+ } : {},
119
+ publishID: ctx.param.publishID
120
+ });
121
+ sequenceID = result.sequenceID;
122
+ deduped = result.deduped;
123
+ } catch (error) {
124
+ rethrowAsHandlerError(error);
125
+ }
126
+ // A deduped publish appended nothing: the frame was already accepted, already fanned out to
127
+ // whoever was subscribed then, and its sequenceID may since have been acked and its delivery
128
+ // row removed. Re-running the loop would push a frame every current subscriber has already
129
+ // applied, named by a dead sequenceID. Live fan-out is for a genuine append only.
130
+ if (!deduped) {
131
+ // Live-deliver to currently-connected subscribers (minus the sender).
132
+ const subscribers = await store.getSubscribers(topicID);
133
+ for (const recipientDID of subscribers){
134
+ if (recipientDID === senderDID) continue;
135
+ const client = registry.getClient(recipientDID);
136
+ if (client?.sendMessage != null) {
137
+ client.sendMessage({
138
+ sequenceID,
139
+ senderDID,
140
+ topicID,
141
+ payload: payloadBytes
142
+ });
143
+ }
108
144
  }
109
145
  }
110
146
  return {
@@ -120,11 +156,45 @@ export function createHandlers(params) {
120
156
  message: 'Not authorized to subscribe to topic'
121
157
  });
122
158
  }
123
- await store.subscribe(clientDID, topicID);
159
+ try {
160
+ await store.subscribe({
161
+ subscriberDID: clientDID,
162
+ topicID,
163
+ retention: ctx.param.retention
164
+ });
165
+ } catch (error) {
166
+ rethrowAsHandlerError(error);
167
+ }
124
168
  return {
125
169
  subscribed: true
126
170
  };
127
171
  },
172
+ 'hub/topic/fetch': async (ctx)=>{
173
+ const { topicID, after, limit } = ctx.param;
174
+ // The subscriber is the authenticated caller, taken from the verified issuer of the signed
175
+ // message — never a wire field, or any member could read a topic's log by naming another.
176
+ const subscriberDID = getClientDID(ctx);
177
+ try {
178
+ const result = await store.fetchTopic({
179
+ subscriberDID,
180
+ topicID,
181
+ after,
182
+ limit
183
+ });
184
+ return {
185
+ messages: result.messages.map((message)=>({
186
+ sequenceID: message.sequenceID,
187
+ senderDID: message.senderDID,
188
+ topicID: message.topicID,
189
+ payload: toB64(message.payload)
190
+ })),
191
+ head: result.head,
192
+ oldest: result.oldest
193
+ };
194
+ } catch (error) {
195
+ rethrowAsHandlerError(error);
196
+ }
197
+ },
128
198
  'hub/unsubscribe': async (ctx)=>{
129
199
  const { topicID } = ctx.param;
130
200
  const clientDID = getClientDID(ctx);
package/lib/hub.d.ts CHANGED
@@ -13,7 +13,11 @@ export declare const DEFAULT_HUB_ACCESS_RULES: AccessRules;
13
13
  export type HubPurgeOptions = {
14
14
  /** Interval between purge runs in milliseconds. Default: 3600000 (1 hour) */
15
15
  interval?: number;
16
- /** Age in seconds after which unacked stored messages are purged. Default: 604800 (7 days) */
16
+ /**
17
+ * The hub's default retention in seconds: the age bound applied to a topic no subscriber asked
18
+ * to keep for longer. Default: 604800 (7 days). The maximum a subscriber may ask for is the
19
+ * store's, since the store is the thing that refuses the subscribe.
20
+ */
17
21
  olderThan?: number;
18
22
  };
19
23
  export type CreateHubParams = {
@@ -1,12 +1,34 @@
1
1
  import type { HubStore } from '@kumiai/hub-protocol';
2
+ export type MemoryStoreRetention = {
3
+ /** Floor, in seconds, on how long a topic's frames are kept. Default 0. */
4
+ default?: number;
5
+ /**
6
+ * Ceiling, in seconds, on what a subscriber may request. Above it: RetentionExceededError.
7
+ * Default {@link DEFAULT_MAX_RETENTION} (30 days), finite by design — an unbounded ceiling lets
8
+ * any subscriber pin a topic's frames forever with `subscribe({ retention: 2**31 })`. A host
9
+ * wanting no ceiling sets `Number.POSITIVE_INFINITY` explicitly.
10
+ */
11
+ max?: number;
12
+ };
2
13
  export type MemoryStoreOptions = {
3
- /** Per-topic max retained messages; oldest are trimmed beyond this. Default 1000. */
14
+ /** Per-topic max retained entries; oldest are trimmed beyond this. Default 1000. */
4
15
  maxDepth?: number;
16
+ retention?: MemoryStoreRetention;
5
17
  };
6
18
  /**
7
- * In-memory implementation of HubStore for testing and development.
19
+ * In-memory HubStore for testing and development.
20
+ *
21
+ * Retention is a class (per publish) and a duration (per subscribe):
22
+ * - `'mailbox'` (default) is delivery-derived: readers are known at publish time, so the last ack
23
+ * frees the frame, and a publish nobody is subscribed to is dropped outright.
24
+ * - `'log'` is not: a subscriber that must read a frame may not exist when it is published, so no
25
+ * refcount over current subscribers can free it. It is appended whether or not anyone is
26
+ * subscribed, and only `trim` or the age bound removes it.
27
+ *
28
+ * The store is told the class; it never infers it, and never reads a payload.
8
29
  *
9
- * Single message copy + per-subscriber delivery index + refcount GC. Recipients
10
- * are resolved from the subscription table at publish time, never passed in.
30
+ * Both classes are age-bounded: frames live for the longest retention any subscriber asked for,
31
+ * floored at the hub's default. For mailbox that bound sits alongside the ack GC, ack usually
32
+ * winning.
11
33
  */
12
34
  export declare function createMemoryStore(options?: MemoryStoreOptions): HubStore;
@@ -1,137 +1,234 @@
1
+ import { HeadMismatchError, NotSubscribedError, RetentionExceededError } from '@kumiai/hub-protocol';
1
2
  import { EventEmitter } from '@sozai/event';
2
3
  const DEFAULT_MAX_DEPTH = 1000;
4
+ /** 30 days, in seconds. A finite default ceiling on requested retention. */ const DEFAULT_MAX_RETENTION = 2_592_000;
3
5
  function formatSequenceID(counter) {
4
6
  return String(counter).padStart(12, '0');
5
7
  }
6
8
  /**
7
- * In-memory implementation of HubStore for testing and development.
9
+ * In-memory HubStore for testing and development.
8
10
  *
9
- * Single message copy + per-subscriber delivery index + refcount GC. Recipients
10
- * are resolved from the subscription table at publish time, never passed in.
11
+ * Retention is a class (per publish) and a duration (per subscribe):
12
+ * - `'mailbox'` (default) is delivery-derived: readers are known at publish time, so the last ack
13
+ * frees the frame, and a publish nobody is subscribed to is dropped outright.
14
+ * - `'log'` is not: a subscriber that must read a frame may not exist when it is published, so no
15
+ * refcount over current subscribers can free it. It is appended whether or not anyone is
16
+ * subscribed, and only `trim` or the age bound removes it.
17
+ *
18
+ * The store is told the class; it never infers it, and never reads a payload.
19
+ *
20
+ * Both classes are age-bounded: frames live for the longest retention any subscriber asked for,
21
+ * floored at the hub's default. For mailbox that bound sits alongside the ack GC, ack usually
22
+ * winning.
11
23
  */ export function createMemoryStore(options = {}) {
12
24
  const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
25
+ const defaultRetention = options.retention?.default ?? 0;
26
+ const maxRetention = options.retention?.max ?? DEFAULT_MAX_RETENTION;
13
27
  let counter = 0;
14
- const messages = new Map();
28
+ const entries = new Map();
29
+ const topicLogs = new Map();
30
+ const heads = new Map();
15
31
  const deliveries = new Map();
16
- const subscriptions = new Map();
17
- const topicMessages = new Map();
32
+ /**
33
+ * publishID -> the sequenceID it was accepted as. Not a log entry and not reachable from one: no
34
+ * deleter takes a publishID, so `removeEntry`, `trim` and `purge` cannot touch this map even by
35
+ * mistake. Retained indefinitely — it is the only thing that lets a peer replaying its journal
36
+ * learn that a commit it never saw acknowledged had in fact landed.
37
+ */ const publishRecords = new Map();
38
+ /** Per topic: the subscribers, each with the retention it asked for. */ const subscriptions = new Map();
18
39
  const keyPackages = new Map();
19
40
  const events = new EventEmitter();
20
- // Remove a message entirely: every recipient delivery list, the topic log,
21
- // and the message record.
22
- function deleteMessage(sequenceID) {
23
- const record = messages.get(sequenceID);
24
- if (record == null) return;
25
- for (const recipient of record.recipients){
26
- const list = deliveries.get(recipient);
41
+ // The age bound for a topic: the longest retention any of its subscribers asked for, floored
42
+ // by the hub's default and by the caller's own bound.
43
+ function retentionOf(topicID, floor) {
44
+ let retention = Math.max(floor, defaultRetention);
45
+ const subscribers = subscriptions.get(topicID);
46
+ if (subscribers != null) {
47
+ for (const requested of subscribers.values()){
48
+ if (requested > retention) retention = requested;
49
+ }
50
+ }
51
+ return retention;
52
+ }
53
+ // The only removal path for an entry. Pending deliveries of it go with it: they reference an
54
+ // entry that no longer exists, so they can no longer be pushed. `heads` is never touched — an
55
+ // empty log still has a head.
56
+ function removeEntry(sequenceID) {
57
+ const entry = entries.get(sequenceID);
58
+ if (entry == null) return;
59
+ for (const recipientDID of entry.pendingFor){
60
+ const list = deliveries.get(recipientDID);
27
61
  if (list != null) {
28
62
  const index = list.indexOf(sequenceID);
29
63
  if (index !== -1) list.splice(index, 1);
30
64
  }
31
65
  }
32
- const topicLog = topicMessages.get(record.topicID);
33
- if (topicLog != null) {
34
- const index = topicLog.indexOf(sequenceID);
35
- if (index !== -1) topicLog.splice(index, 1);
36
- if (topicLog.length === 0) topicMessages.delete(record.topicID);
66
+ const log = topicLogs.get(entry.topicID);
67
+ if (log != null) {
68
+ const index = log.indexOf(sequenceID);
69
+ if (index !== -1) log.splice(index, 1);
70
+ if (log.length === 0) topicLogs.delete(entry.topicID);
37
71
  }
38
- messages.delete(sequenceID);
72
+ entries.delete(sequenceID);
39
73
  }
40
- // Drop one subscriber's delivery of a message; GC the message when its last
41
- // recipient is gone (refcount → 0).
42
- function removeDelivery(recipientDID, sequenceID) {
74
+ // Drop one recipient's pending delivery. A mailbox frame whose last delivery is gone has been
75
+ // read by everyone who was ever going to read it, so it goes with it. A log frame does not: the
76
+ // subscriber that needs it may not exist yet.
77
+ function dropDelivery(recipientDID, sequenceID) {
43
78
  const list = deliveries.get(recipientDID);
44
79
  if (list != null) {
45
80
  const index = list.indexOf(sequenceID);
46
81
  if (index !== -1) list.splice(index, 1);
47
82
  }
48
- const record = messages.get(sequenceID);
49
- if (record != null) {
50
- record.recipients.delete(recipientDID);
51
- if (record.recipients.size === 0) {
52
- deleteMessage(sequenceID);
53
- }
83
+ const entry = entries.get(sequenceID);
84
+ if (entry == null) return;
85
+ entry.pendingFor.delete(recipientDID);
86
+ if (entry.retain === 'mailbox' && entry.pendingFor.size === 0) {
87
+ removeEntry(sequenceID);
54
88
  }
55
89
  }
56
90
  return {
57
91
  events,
58
92
  async publish (params) {
93
+ const retain = params.retain ?? 'mailbox';
94
+ // The dedup check comes BEFORE the CAS; the order is load-bearing. A replay carries the
95
+ // already-accepted publishID and the journalled expectedHead, which the accepted publish
96
+ // itself made stale — comparing first would raise HeadMismatchError and tell the caller its
97
+ // commit was lost when it landed. The returned sequenceID may name an already-trimmed frame,
98
+ // which is correct: a replay asks "did my publish land?", not "give me my frame".
99
+ if (params.publishID != null) {
100
+ const accepted = publishRecords.get(params.publishID);
101
+ if (accepted !== undefined) {
102
+ return {
103
+ sequenceID: accepted,
104
+ deduped: true
105
+ };
106
+ }
107
+ }
108
+ // The CAS, before anything is minted or written. Head comparison, sequence mint, append and
109
+ // head advance are one indivisible step; a loser leaves log, head and sequence untouched (no
110
+ // entry, no delivery, no sequenceID consumed). Absent expectedHead this is skipped — the
111
+ // unconditional fast path every mailbox frame takes.
112
+ if (params.expectedHead !== undefined) {
113
+ const head = heads.get(params.topicID) ?? null;
114
+ if (head !== params.expectedHead) {
115
+ throw new HeadMismatchError(`Publish to ${params.topicID} expected head ${params.expectedHead ?? 'null'}, but the head is ${head ?? 'null'}`);
116
+ }
117
+ }
59
118
  counter++;
60
119
  const sequenceID = formatSequenceID(counter);
61
- // Recipients = current subscribers minus the sender. Zero recipients
62
- // (no subscribers, or only the sender) → drop immediately, store nothing.
63
- const subscribers = subscriptions.get(params.topicID);
120
+ if (params.publishID != null) {
121
+ publishRecords.set(params.publishID, sequenceID);
122
+ }
123
+ // Only a log publish moves the head. A head naming a mailbox frame is a head that the
124
+ // frame's own last ack deletes, leaving readers of the log a head they can never reach.
125
+ if (retain === 'log') {
126
+ heads.set(params.topicID, sequenceID);
127
+ }
128
+ // Recipients = current subscribers minus the sender.
64
129
  const recipients = new Set();
130
+ const subscribers = subscriptions.get(params.topicID);
65
131
  if (subscribers != null) {
66
- for (const did of subscribers){
67
- if (did !== params.senderDID) recipients.add(did);
132
+ for (const subscriberDID of subscribers.keys()){
133
+ if (subscriberDID !== params.senderDID) recipients.add(subscriberDID);
68
134
  }
69
135
  }
70
- if (recipients.size === 0) {
71
- return sequenceID;
136
+ // A mailbox frame with no recipients has already been read by everyone who was going to
137
+ // read it. A log frame is kept regardless: its reader may not exist yet.
138
+ if (retain === 'mailbox' && recipients.size === 0) {
139
+ return {
140
+ sequenceID,
141
+ deduped: false
142
+ };
72
143
  }
73
- const record = {
144
+ const entry = {
74
145
  sequenceID,
75
146
  senderDID: params.senderDID,
76
147
  topicID: params.topicID,
77
148
  payload: params.payload,
78
- recipients,
79
- storedAt: Date.now()
149
+ storedAt: Date.now(),
150
+ retain,
151
+ pendingFor: recipients
80
152
  };
81
- messages.set(sequenceID, record);
82
- for (const recipient of recipients){
83
- let list = deliveries.get(recipient);
153
+ entries.set(sequenceID, entry);
154
+ let log = topicLogs.get(params.topicID);
155
+ if (log == null) {
156
+ log = [];
157
+ topicLogs.set(params.topicID, log);
158
+ }
159
+ log.push(sequenceID);
160
+ for (const recipientDID of recipients){
161
+ let list = deliveries.get(recipientDID);
84
162
  if (list == null) {
85
163
  list = [];
86
- deliveries.set(recipient, list);
164
+ deliveries.set(recipientDID, list);
87
165
  }
88
166
  list.push(sequenceID);
89
167
  }
90
- let topicLog = topicMessages.get(params.topicID);
91
- if (topicLog == null) {
92
- topicLog = [];
93
- topicMessages.set(params.topicID, topicLog);
94
- }
95
- topicLog.push(sequenceID);
96
- // Per-topic max-depth trim: drop oldest beyond the bound.
97
- while(topicLog.length > maxDepth){
98
- deleteMessage(topicLog[0]);
168
+ // Depth bound: a trim like any other (moves oldest, leaves head alone), counting LOG frames
169
+ // only. A mailbox frame is bounded by ack and age, not depth; counting it here would let any
170
+ // member evict the commit log with a mailbox flood. Only a log publish can push the log-class
171
+ // count over the bound, so this is skipped for the mailbox fast path.
172
+ if (retain === 'log') {
173
+ let logDepth = 0;
174
+ for (const id of log){
175
+ if (entries.get(id)?.retain === 'log') logDepth++;
176
+ }
177
+ while(logDepth > maxDepth){
178
+ const oldestLog = log.find((id)=>entries.get(id)?.retain === 'log');
179
+ if (oldestLog == null) break;
180
+ removeEntry(oldestLog);
181
+ logDepth--;
182
+ }
99
183
  }
100
- return sequenceID;
184
+ return {
185
+ sequenceID,
186
+ deduped: false
187
+ };
101
188
  },
102
189
  async fetch (params) {
103
190
  if (params.ack != null && params.ack.length > 0) {
104
191
  for (const sequenceID of params.ack){
105
- removeDelivery(params.recipientDID, sequenceID);
192
+ dropDelivery(params.recipientDID, sequenceID);
106
193
  }
107
194
  }
108
- const recipientDeliveries = deliveries.get(params.recipientDID);
109
- if (recipientDeliveries == null || recipientDeliveries.length === 0) {
195
+ const pending = deliveries.get(params.recipientDID);
196
+ if (pending == null || pending.length === 0) {
110
197
  return {
111
198
  messages: [],
112
199
  cursor: null
113
200
  };
114
201
  }
115
- let startIndex = 0;
116
- if (params.after != null) {
117
- const afterIndex = recipientDeliveries.indexOf(params.after);
118
- if (afterIndex !== -1) {
119
- startIndex = afterIndex + 1;
202
+ // `after` is an exclusive cursor: the first pending delivery with sequenceID strictly greater
203
+ // than it. Pending is in append order (strictly increasing), so this is the position just
204
+ // past `after` whether or not `after` is still pending. Matching on the value (indexOf) would
205
+ // fall back to index 0 once the cursored delivery is acked or aged out between pages of a
206
+ // drain, silently restarting from the top and re-serving frames already handed out. Scanning
207
+ // for `> after` resumes where the cursor pointed even after its own entry is gone.
208
+ let startIndex = pending.length;
209
+ if (params.after == null) {
210
+ startIndex = 0;
211
+ } else {
212
+ for(let index = 0; index < pending.length; index++){
213
+ if (pending[index] > params.after) {
214
+ startIndex = index;
215
+ break;
216
+ }
120
217
  }
121
218
  }
122
- const available = recipientDeliveries.slice(startIndex);
219
+ const available = pending.slice(startIndex);
123
220
  const limit = params.limit ?? available.length;
124
221
  const selected = available.slice(0, limit);
125
222
  const hasMore = available.length > limit;
126
223
  const resultMessages = [];
127
224
  for (const sequenceID of selected){
128
- const record = messages.get(sequenceID);
129
- if (record != null) {
225
+ const entry = entries.get(sequenceID);
226
+ if (entry != null) {
130
227
  resultMessages.push({
131
- sequenceID: record.sequenceID,
132
- senderDID: record.senderDID,
133
- topicID: record.topicID,
134
- payload: record.payload
228
+ sequenceID: entry.sequenceID,
229
+ senderDID: entry.senderDID,
230
+ topicID: entry.topicID,
231
+ payload: entry.payload
135
232
  });
136
233
  }
137
234
  }
@@ -145,20 +242,73 @@ function formatSequenceID(counter) {
145
242
  }
146
243
  return result;
147
244
  },
245
+ async fetchTopic (params) {
246
+ if (!subscriptions.get(params.topicID)?.has(params.subscriberDID)) {
247
+ throw new NotSubscribedError(`${params.subscriberDID} is not a subscriber of ${params.topicID}`);
248
+ }
249
+ // A topic's LOG is its log-class frames and nothing else. A mailbox frame published to a log
250
+ // topic is still delivered (push is untouched) but never enters the log: it does not move the
251
+ // head, so a reader that saw one would hold a cursor naming a position the head can never
252
+ // reach, and every CAS anchored there would lose forever. Only the publisher's own `retain`
253
+ // sets the class.
254
+ const log = (topicLogs.get(params.topicID) ?? []).filter((sequenceID)=>entries.get(sequenceID)?.retain === 'log');
255
+ const after = params.after;
256
+ // Filter to the class BEFORE the limit: a page of mailbox frames must not eat the limit and
257
+ // hand back an empty page while log frames wait — a caller draining until a short page would
258
+ // stop early and strand itself.
259
+ const selected = after == null ? log : log.filter((sequenceID)=>sequenceID > after);
260
+ const limited = params.limit == null ? selected : selected.slice(0, params.limit);
261
+ const messages = [];
262
+ for (const sequenceID of limited){
263
+ const entry = entries.get(sequenceID);
264
+ if (entry != null) {
265
+ messages.push({
266
+ sequenceID: entry.sequenceID,
267
+ senderDID: entry.senderDID,
268
+ topicID: entry.topicID,
269
+ payload: entry.payload
270
+ });
271
+ }
272
+ }
273
+ return {
274
+ messages,
275
+ head: heads.get(params.topicID) ?? null,
276
+ oldest: log.length > 0 ? log[0] : null
277
+ };
278
+ },
279
+ async trim (params) {
280
+ const log = topicLogs.get(params.topicID);
281
+ if (log == null) return;
282
+ for (const sequenceID of [
283
+ ...log
284
+ ]){
285
+ // Log-class frames only. A mailbox frame shares the topic's array but not its log: it is
286
+ // delivery-derived, freed by its last ack or by age, and trim never removes it — trimming
287
+ // the mixed array silently drops pending mail below the bound.
288
+ if (sequenceID < params.before && entries.get(sequenceID)?.retain === 'log') {
289
+ removeEntry(sequenceID);
290
+ }
291
+ }
292
+ },
148
293
  async ack (params) {
149
294
  for (const sequenceID of params.sequenceIDs){
150
- removeDelivery(params.recipientDID, sequenceID);
295
+ dropDelivery(params.recipientDID, sequenceID);
151
296
  }
152
297
  },
153
298
  async purge (params) {
154
- const threshold = Date.now() - params.olderThan * 1000;
299
+ // The age bound, for both classes: the same removal path, the same invariants — head is
300
+ // untouched.
301
+ const now = Date.now();
155
302
  const purgedIDs = [];
156
- for (const [sequenceID, record] of messages){
157
- if (record.storedAt <= threshold) {
303
+ for (const [sequenceID, entry] of entries){
304
+ const retention = retentionOf(entry.topicID, params.olderThan);
305
+ if (entry.storedAt <= now - retention * 1000) {
158
306
  purgedIDs.push(sequenceID);
159
- deleteMessage(sequenceID);
160
307
  }
161
308
  }
309
+ for (const sequenceID of purgedIDs){
310
+ removeEntry(sequenceID);
311
+ }
162
312
  if (purgedIDs.length > 0) {
163
313
  await events.emit('purge', {
164
314
  sequenceIDs: purgedIDs
@@ -166,50 +316,43 @@ function formatSequenceID(counter) {
166
316
  }
167
317
  return purgedIDs;
168
318
  },
169
- async subscribe (subscriberDID, topicID) {
170
- let subs = subscriptions.get(topicID);
171
- if (subs == null) {
172
- subs = new Set();
173
- subscriptions.set(topicID, subs);
319
+ async subscribe (params) {
320
+ const requested = params.retention ?? defaultRetention;
321
+ if (requested > maxRetention) {
322
+ throw new RetentionExceededError(`Requested retention of ${requested}s exceeds the maximum of ${maxRetention}s`);
174
323
  }
175
- subs.add(subscriberDID);
324
+ let subscribers = subscriptions.get(params.topicID);
325
+ if (subscribers == null) {
326
+ subscribers = new Map();
327
+ subscriptions.set(params.topicID, subscribers);
328
+ }
329
+ subscribers.set(params.subscriberDID, requested);
176
330
  },
177
331
  async unsubscribe (subscriberDID, topicID) {
178
- const subs = subscriptions.get(topicID);
179
- if (subs != null) {
180
- subs.delete(subscriberDID);
181
- if (subs.size === 0) {
332
+ const subscribers = subscriptions.get(topicID);
333
+ if (subscribers != null) {
334
+ subscribers.delete(subscriberDID);
335
+ if (subscribers.size === 0) {
182
336
  subscriptions.delete(topicID);
183
337
  }
184
338
  }
185
- // Drop this subscriber's pending deliveries for the topic.
186
- const list = deliveries.get(subscriberDID);
187
- if (list != null) {
339
+ // Drops this subscriber's pending deliveries for the topic — freeing a mailbox frame whose
340
+ // last delivery this was, and leaving a log frame standing.
341
+ const pending = deliveries.get(subscriberDID);
342
+ if (pending != null) {
188
343
  for (const sequenceID of [
189
- ...list
344
+ ...pending
190
345
  ]){
191
- const record = messages.get(sequenceID);
192
- if (record != null && record.topicID === topicID) {
193
- removeDelivery(subscriberDID, sequenceID);
194
- }
195
- }
196
- }
197
- // Last subscriber gone → drop the whole topic log immediately.
198
- if (!subscriptions.has(topicID)) {
199
- const topicLog = topicMessages.get(topicID);
200
- if (topicLog != null) {
201
- for (const sequenceID of [
202
- ...topicLog
203
- ]){
204
- deleteMessage(sequenceID);
346
+ if (entries.get(sequenceID)?.topicID === topicID) {
347
+ dropDelivery(subscriberDID, sequenceID);
205
348
  }
206
349
  }
207
350
  }
208
351
  },
209
352
  async getSubscribers (topicID) {
210
- const subs = subscriptions.get(topicID);
211
- return subs == null ? [] : [
212
- ...subs
353
+ const subscribers = subscriptions.get(topicID);
354
+ return subscribers == null ? [] : [
355
+ ...subscribers.keys()
213
356
  ];
214
357
  },
215
358
  async storeKeyPackage (ownerDID, keyPackage) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kumiai/hub-server",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "description": "Hub server for Enkaku E2EE group message routing",
6
6
  "keywords": [
@@ -25,18 +25,19 @@
25
25
  ],
26
26
  "sideEffects": false,
27
27
  "dependencies": {
28
- "@sozai/codec": "^0.1.0",
29
- "@sozai/event": "^0.1.0",
30
- "@enkaku/protocol": "^0.18.0",
31
- "@enkaku/server": "^0.18.0",
32
- "@sozai/stream": "^0.1.0",
33
- "@kokuin/token": "^0.1.0",
34
- "@kumiai/mls": "^0.1.0",
35
- "@kumiai/hub-protocol": "^0.1.0"
28
+ "@sozai/codec": "^0.2.0",
29
+ "@sozai/event": "^0.1.1",
30
+ "@enkaku/protocol": "^0.19.0",
31
+ "@enkaku/server": "^0.19.0",
32
+ "@sozai/stream": "^0.2.0",
33
+ "@kokuin/token": "^0.2.0",
34
+ "@kumiai/hub-protocol": "^0.3.0",
35
+ "@kumiai/mls": "^0.3.0"
36
36
  },
37
37
  "devDependencies": {
38
- "@enkaku/client": "^0.18.0",
39
- "@enkaku/transport": "^0.18.0"
38
+ "@enkaku/client": "^0.19.0",
39
+ "@enkaku/transport": "^0.19.1",
40
+ "@kumiai/hub-conformance": "^0.3.0"
40
41
  },
41
42
  "scripts": {
42
43
  "build:clean": "del lib",