@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 +88 -18
- package/lib/hub.d.ts +5 -1
- package/lib/memoryStore.d.ts +26 -4
- package/lib/memoryStore.js +246 -103
- package/package.json +12 -11
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 = {
|
package/lib/memoryStore.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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;
|
package/lib/memoryStore.js
CHANGED
|
@@ -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
|
|
9
|
+
* In-memory HubStore for testing and development.
|
|
8
10
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
28
|
+
const entries = new Map();
|
|
29
|
+
const topicLogs = new Map();
|
|
30
|
+
const heads = new Map();
|
|
15
31
|
const deliveries = new Map();
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
//
|
|
21
|
-
// and the
|
|
22
|
-
function
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
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
|
|
33
|
-
if (
|
|
34
|
-
const index =
|
|
35
|
-
if (index !== -1)
|
|
36
|
-
if (
|
|
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
|
-
|
|
72
|
+
entries.delete(sequenceID);
|
|
39
73
|
}
|
|
40
|
-
// Drop one
|
|
41
|
-
//
|
|
42
|
-
|
|
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
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
67
|
-
if (
|
|
132
|
+
for (const subscriberDID of subscribers.keys()){
|
|
133
|
+
if (subscriberDID !== params.senderDID) recipients.add(subscriberDID);
|
|
68
134
|
}
|
|
69
135
|
}
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
144
|
+
const entry = {
|
|
74
145
|
sequenceID,
|
|
75
146
|
senderDID: params.senderDID,
|
|
76
147
|
topicID: params.topicID,
|
|
77
148
|
payload: params.payload,
|
|
78
|
-
|
|
79
|
-
|
|
149
|
+
storedAt: Date.now(),
|
|
150
|
+
retain,
|
|
151
|
+
pendingFor: recipients
|
|
80
152
|
};
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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(
|
|
164
|
+
deliveries.set(recipientDID, list);
|
|
87
165
|
}
|
|
88
166
|
list.push(sequenceID);
|
|
89
167
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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
|
-
|
|
192
|
+
dropDelivery(params.recipientDID, sequenceID);
|
|
106
193
|
}
|
|
107
194
|
}
|
|
108
|
-
const
|
|
109
|
-
if (
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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 =
|
|
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
|
|
129
|
-
if (
|
|
225
|
+
const entry = entries.get(sequenceID);
|
|
226
|
+
if (entry != null) {
|
|
130
227
|
resultMessages.push({
|
|
131
|
-
sequenceID:
|
|
132
|
-
senderDID:
|
|
133
|
-
topicID:
|
|
134
|
-
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
|
-
|
|
295
|
+
dropDelivery(params.recipientDID, sequenceID);
|
|
151
296
|
}
|
|
152
297
|
},
|
|
153
298
|
async purge (params) {
|
|
154
|
-
|
|
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,
|
|
157
|
-
|
|
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 (
|
|
170
|
-
|
|
171
|
-
if (
|
|
172
|
-
|
|
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
|
-
|
|
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
|
|
179
|
-
if (
|
|
180
|
-
|
|
181
|
-
if (
|
|
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
|
-
//
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
...
|
|
344
|
+
...pending
|
|
190
345
|
]){
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
211
|
-
return
|
|
212
|
-
...
|
|
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.
|
|
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.
|
|
29
|
-
"@sozai/event": "^0.1.
|
|
30
|
-
"@enkaku/protocol": "^0.
|
|
31
|
-
"@enkaku/server": "^0.
|
|
32
|
-
"@sozai/stream": "^0.
|
|
33
|
-
"@kokuin/token": "^0.
|
|
34
|
-
"@kumiai/
|
|
35
|
-
"@kumiai/
|
|
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.
|
|
39
|
-
"@enkaku/transport": "^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",
|