@kubun/hub 0.11.0 → 0.12.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/create-relay.d.ts +6 -0
- package/lib/create-relay.js +37 -18
- package/lib/hub-store.d.ts +27 -1
- package/lib/hub-store.js +356 -58
- package/lib/migrations.js +40 -3
- package/lib/observers.js +8 -3
- package/package.json +29 -27
package/lib/create-relay.d.ts
CHANGED
|
@@ -22,6 +22,9 @@ export type CreateRelayOptions = {
|
|
|
22
22
|
auth?: HubAuthConfig;
|
|
23
23
|
allowedOrigin?: string | Array<string>;
|
|
24
24
|
retention?: RelayRetentionConfig;
|
|
25
|
+
defaultRetention?: number;
|
|
26
|
+
maxRetention?: number;
|
|
27
|
+
maxDepth?: number;
|
|
25
28
|
observers?: RelayObservers;
|
|
26
29
|
};
|
|
27
30
|
export type Relay = {
|
|
@@ -37,6 +40,9 @@ export type RelayInternalOptions = {
|
|
|
37
40
|
auth?: HubAuthConfig;
|
|
38
41
|
allowedOrigin?: string | Array<string>;
|
|
39
42
|
retention?: RelayRetentionConfig;
|
|
43
|
+
defaultRetention?: number;
|
|
44
|
+
maxRetention?: number;
|
|
45
|
+
maxDepth?: number;
|
|
40
46
|
observers?: RelayObservers;
|
|
41
47
|
ownsKysely: boolean;
|
|
42
48
|
};
|
package/lib/create-relay.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { ServerTransport } from '@enkaku/http-serve';
|
|
2
|
-
import { Server } from '@enkaku/server';
|
|
3
2
|
import { resolveAdapter } from '@kubun/db-provider';
|
|
4
3
|
import { resolveAllowedOrigin } from '@kubun/http-util';
|
|
5
|
-
import {
|
|
4
|
+
import { createHub } from '@kumiai/hub-server';
|
|
6
5
|
import { Kysely } from 'kysely';
|
|
7
6
|
import { Migrator } from 'kysely/migration';
|
|
8
7
|
import { createHubStore } from './hub-store.js';
|
|
@@ -10,6 +9,8 @@ import { createHubMigrations } from './migrations.js';
|
|
|
10
9
|
import { decorateHubStore } from './observers.js';
|
|
11
10
|
const DEFAULT_RETENTION_INTERVAL_SECONDS = 3600;
|
|
12
11
|
const DEFAULT_SUBSCRIPTION_RETENTION_SECONDS = 90 * 24 * 60 * 60;
|
|
12
|
+
const DEFAULT_MAX_RETENTION_SECONDS = 30 * 24 * 60 * 60;
|
|
13
|
+
const DEFAULT_MAX_DEPTH = 1000;
|
|
13
14
|
function resolveAccessRules(auth) {
|
|
14
15
|
if (auth == null) return {};
|
|
15
16
|
switch(auth.mode){
|
|
@@ -62,10 +63,12 @@ export async function createRelayInternal(options) {
|
|
|
62
63
|
if (migrationError != null) {
|
|
63
64
|
throw migrationError;
|
|
64
65
|
}
|
|
65
|
-
const registry = new HubClientRegistry();
|
|
66
66
|
const rawStore = createHubStore({
|
|
67
67
|
kysely,
|
|
68
|
-
adapter
|
|
68
|
+
adapter,
|
|
69
|
+
defaultRetention: options.defaultRetention ?? 0,
|
|
70
|
+
maxRetention: options.maxRetention ?? DEFAULT_MAX_RETENTION_SECONDS,
|
|
71
|
+
maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH
|
|
69
72
|
});
|
|
70
73
|
const hubStore = decorateHubStore(rawStore, observers);
|
|
71
74
|
let unsubscribePurge;
|
|
@@ -81,24 +84,21 @@ export async function createRelayInternal(options) {
|
|
|
81
84
|
}
|
|
82
85
|
});
|
|
83
86
|
}
|
|
84
|
-
const hubHandlers = createHubHandlers({
|
|
85
|
-
registry,
|
|
86
|
-
store: hubStore
|
|
87
|
-
});
|
|
88
87
|
const allowedOrigin = resolveAllowedOrigin(options.allowedOrigin);
|
|
89
88
|
const transport = new ServerTransport({
|
|
90
89
|
allowedOrigin
|
|
91
90
|
});
|
|
92
91
|
const accessRules = resolveAccessRules(options.auth);
|
|
93
|
-
|
|
92
|
+
// `@kumiai/hub-server` assembles the handlers, registry, and Enkaku server for
|
|
93
|
+
// its own protocol. Purging is disabled here because this relay runs its own
|
|
94
|
+
// retention timer below, over the same store, with kubun's clamped windows.
|
|
95
|
+
const { server: enkakuServer } = createHub({
|
|
96
|
+
transport,
|
|
97
|
+
store: hubStore,
|
|
94
98
|
identity: options.identity,
|
|
95
99
|
accessRules,
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
transport
|
|
99
|
-
]
|
|
100
|
-
};
|
|
101
|
-
const enkakuServer = new Server(serverParams);
|
|
100
|
+
purge: false
|
|
101
|
+
});
|
|
102
102
|
if (observers?.onAuthRejected != null) {
|
|
103
103
|
const onAuthRejected = observers.onAuthRejected;
|
|
104
104
|
enkakuServer.events.on('handlerError', (info)=>{
|
|
@@ -113,6 +113,9 @@ export async function createRelayInternal(options) {
|
|
|
113
113
|
});
|
|
114
114
|
}
|
|
115
115
|
let retentionTimer;
|
|
116
|
+
// The sweep a fired tick is still running, so `dispose` can wait for it.
|
|
117
|
+
let sweep;
|
|
118
|
+
let runSweep;
|
|
116
119
|
if (options.retention != null) {
|
|
117
120
|
const { olderThanSeconds } = options.retention;
|
|
118
121
|
const intervalSeconds = options.retention.intervalSeconds ?? DEFAULT_RETENTION_INTERVAL_SECONDS;
|
|
@@ -133,7 +136,17 @@ export async function createRelayInternal(options) {
|
|
|
133
136
|
}
|
|
134
137
|
}
|
|
135
138
|
retentionTimer = setInterval(()=>{
|
|
136
|
-
|
|
139
|
+
// A tick that already fired is live work, not a timer: `purge` is an
|
|
140
|
+
// unbounded per-topic scan plus two bulk deletes, and on Postgres
|
|
141
|
+
// destroying the pool underneath one wedges `close()` forever. `dispose`
|
|
142
|
+
// awaits this handle, so cancelling the timer and draining what it
|
|
143
|
+
// started are two different things and both are needed.
|
|
144
|
+
sweep = runSweep?.().finally(()=>{
|
|
145
|
+
sweep = undefined;
|
|
146
|
+
});
|
|
147
|
+
}, intervalMs);
|
|
148
|
+
runSweep = async ()=>{
|
|
149
|
+
await hubStore.purge({
|
|
137
150
|
olderThan: olderThanSeconds
|
|
138
151
|
}).catch((err)=>{
|
|
139
152
|
if (observers?.onError != null) {
|
|
@@ -147,7 +160,7 @@ export async function createRelayInternal(options) {
|
|
|
147
160
|
}
|
|
148
161
|
}
|
|
149
162
|
});
|
|
150
|
-
|
|
163
|
+
await hubStore.purgeSubscribers({
|
|
151
164
|
olderThan: subscriptionOlderThanSeconds
|
|
152
165
|
}).then((purged)=>{
|
|
153
166
|
if (observers?.onSubscriptionPurge != null) {
|
|
@@ -171,7 +184,7 @@ export async function createRelayInternal(options) {
|
|
|
171
184
|
}
|
|
172
185
|
}
|
|
173
186
|
});
|
|
174
|
-
}
|
|
187
|
+
};
|
|
175
188
|
}
|
|
176
189
|
return {
|
|
177
190
|
fetch: (request)=>transport.fetch(request),
|
|
@@ -185,6 +198,9 @@ export async function createRelayInternal(options) {
|
|
|
185
198
|
clearInterval(retentionTimer);
|
|
186
199
|
retentionTimer = undefined;
|
|
187
200
|
}
|
|
201
|
+
// Drain before the server and the pool go: cancelling the timer stops the
|
|
202
|
+
// NEXT tick and does nothing about the one already running.
|
|
203
|
+
await sweep;
|
|
188
204
|
if (unsubscribePurge != null) {
|
|
189
205
|
unsubscribePurge();
|
|
190
206
|
unsubscribePurge = undefined;
|
|
@@ -208,6 +224,9 @@ export async function createRelay(options) {
|
|
|
208
224
|
auth: options.auth,
|
|
209
225
|
allowedOrigin: options.allowedOrigin,
|
|
210
226
|
retention: options.retention,
|
|
227
|
+
defaultRetention: options.defaultRetention,
|
|
228
|
+
maxRetention: options.maxRetention,
|
|
229
|
+
maxDepth: options.maxDepth,
|
|
211
230
|
observers: options.observers,
|
|
212
231
|
ownsKysely: true
|
|
213
232
|
});
|
package/lib/hub-store.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Adapter } from '@kubun/db-adapter';
|
|
2
|
-
import type
|
|
2
|
+
import { type HubStore } from '@kumiai/hub-protocol';
|
|
3
3
|
import type { ColumnType, Kysely } from 'kysely';
|
|
4
4
|
export type HubDatabase = {
|
|
5
5
|
kubun_hub_messages: {
|
|
@@ -8,6 +8,7 @@ export type HubDatabase = {
|
|
|
8
8
|
topic_id: string;
|
|
9
9
|
payload: Uint8Array;
|
|
10
10
|
stored_at: number;
|
|
11
|
+
retain: string;
|
|
11
12
|
};
|
|
12
13
|
kubun_hub_deliveries: {
|
|
13
14
|
sequence_id: string;
|
|
@@ -17,16 +18,41 @@ export type HubDatabase = {
|
|
|
17
18
|
id: ColumnType<number, number | undefined, never>;
|
|
18
19
|
owner_did: string;
|
|
19
20
|
key_package: string;
|
|
21
|
+
not_after: ColumnType<number | null, number | null | undefined, never>;
|
|
22
|
+
};
|
|
23
|
+
kubun_hub_last_resort_key_packages: {
|
|
24
|
+
owner_did: string;
|
|
25
|
+
key_package: string;
|
|
20
26
|
};
|
|
21
27
|
kubun_hub_subscriptions: {
|
|
22
28
|
topic_id: string;
|
|
23
29
|
subscriber_did: string;
|
|
24
30
|
last_active_at: number;
|
|
31
|
+
retention: ColumnType<number, number | undefined, number>;
|
|
32
|
+
};
|
|
33
|
+
kubun_hub_topics: {
|
|
34
|
+
topic_id: string;
|
|
35
|
+
head: string;
|
|
36
|
+
};
|
|
37
|
+
kubun_hub_sequence: {
|
|
38
|
+
id: number;
|
|
39
|
+
counter: ColumnType<number, number, number>;
|
|
40
|
+
};
|
|
41
|
+
kubun_hub_publish_ids: {
|
|
42
|
+
topic_id: string;
|
|
43
|
+
publish_id: string;
|
|
44
|
+
sequence_id: string;
|
|
45
|
+
recorded_at: number;
|
|
25
46
|
};
|
|
26
47
|
};
|
|
27
48
|
export type CreateHubStoreParams = {
|
|
28
49
|
kysely: Kysely<HubDatabase> | Promise<Kysely<HubDatabase>>;
|
|
29
50
|
adapter: Adapter;
|
|
51
|
+
defaultRetention?: number;
|
|
52
|
+
maxRetention?: number;
|
|
53
|
+
maxDepth?: number;
|
|
54
|
+
maxKeyPackagesPerDID?: number;
|
|
55
|
+
maxSubscriptionsPerDID?: number;
|
|
30
56
|
};
|
|
31
57
|
export type PurgeSubscriptionsParams = {
|
|
32
58
|
olderThan: number;
|
package/lib/hub-store.js
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
|
+
import { HeadMismatchError, KeyPackageQuotaExceededError, NotSubscribedError, RetentionExceededError, SubscriptionQuotaExceededError } from '@kumiai/hub-protocol';
|
|
1
2
|
import { EventEmitter } from '@sozai/event';
|
|
3
|
+
// Normalize a DB binary column (a Buffer under better-sqlite3 / node-postgres)
|
|
4
|
+
// to a plain Uint8Array, so payload equality holds against a Uint8Array caller.
|
|
5
|
+
function toBytes(value) {
|
|
6
|
+
return new Uint8Array(value);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* How long an idempotency record outlives its own publish, in seconds.
|
|
10
|
+
*
|
|
11
|
+
* A year: a publish repeated after this is treated as new, so the window has to
|
|
12
|
+
* exceed any replay a real client could still be holding — a parked commit, a
|
|
13
|
+
* device restored from a backup, a lane retried after an outage. It is not a
|
|
14
|
+
* retention knob a host tunes; it is the horizon past which "the same publish"
|
|
15
|
+
* stops being a coherent claim.
|
|
16
|
+
*/ const PUBLISH_ID_RETENTION_SECONDS = 365 * 24 * 60 * 60;
|
|
17
|
+
/** Frames deleted per statement — see {@link deleteFrames}. */ const DELETE_CHUNK_SIZE = 500;
|
|
18
|
+
/** Matches `@kumiai/hub-server`'s own defaults — a kubun relay is not a laxer hub. */ const DEFAULT_MAX_KEY_PACKAGES_PER_DID = 100;
|
|
19
|
+
const DEFAULT_MAX_SUBSCRIPTIONS_PER_DID = 1000;
|
|
2
20
|
export function createHubStore(params) {
|
|
3
21
|
const { adapter } = params;
|
|
22
|
+
const defaultRetention = params.defaultRetention ?? 0;
|
|
23
|
+
const maxRetention = params.maxRetention;
|
|
24
|
+
const maxDepth = params.maxDepth;
|
|
25
|
+
const maxKeyPackagesPerDID = params.maxKeyPackagesPerDID ?? DEFAULT_MAX_KEY_PACKAGES_PER_DID;
|
|
26
|
+
const maxSubscriptionsPerDID = params.maxSubscriptionsPerDID ?? DEFAULT_MAX_SUBSCRIPTIONS_PER_DID;
|
|
4
27
|
// Lazy DB resolution -- accepts Kysely or Promise<Kysely> and caches the resolved instance
|
|
5
28
|
let resolvedDB;
|
|
6
29
|
async function db() {
|
|
@@ -9,48 +32,157 @@ export function createHubStore(params) {
|
|
|
9
32
|
}
|
|
10
33
|
return resolvedDB;
|
|
11
34
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
35
|
+
const events = new EventEmitter();
|
|
36
|
+
/**
|
|
37
|
+
* Remove frames and the deliveries pointing at them, a chunk at a time.
|
|
38
|
+
*
|
|
39
|
+
* SQLite binds one parameter per element of an `in` list and throws "too many
|
|
40
|
+
* SQL variables" past its limit — 32766 on the build this ships with, 999 on
|
|
41
|
+
* older ones — so an unchunked sweep fails precisely when the backlog it exists
|
|
42
|
+
* to clear is large. Deliveries first: a delivery references a frame and cannot
|
|
43
|
+
* be pushed once its referent is gone.
|
|
44
|
+
*/ async function deleteFrames(executor, sequenceIDs) {
|
|
45
|
+
for(let index = 0; index < sequenceIDs.length; index += DELETE_CHUNK_SIZE){
|
|
46
|
+
const chunk = sequenceIDs.slice(index, index + DELETE_CHUNK_SIZE);
|
|
47
|
+
await executor.deleteFrom('kubun_hub_deliveries').where('sequence_id', 'in', chunk).execute();
|
|
48
|
+
await executor.deleteFrom('kubun_hub_messages').where('sequence_id', 'in', chunk).execute();
|
|
19
49
|
}
|
|
20
|
-
sequenceCounter++;
|
|
21
|
-
return String(sequenceCounter).padStart(12, '0');
|
|
22
50
|
}
|
|
23
|
-
|
|
51
|
+
// Mint the next sequenceID inside the accepting transaction, from a persistent
|
|
52
|
+
// global counter. Fixed-width zero-padded so lexicographic order matches
|
|
53
|
+
// numeric order (`"10" > "9"`), and strictly increasing.
|
|
54
|
+
//
|
|
55
|
+
// One row, so every publish on every topic serializes behind it. That is the
|
|
56
|
+
// cost of a single total order; it deadlocks nothing, because every writer
|
|
57
|
+
// takes this row FIRST and the per-topic rows after it, so no two transactions
|
|
58
|
+
// hold each other's next lock.
|
|
59
|
+
async function nextSequenceID(trx) {
|
|
60
|
+
const row = await trx.updateTable('kubun_hub_sequence').set((eb)=>({
|
|
61
|
+
counter: eb('counter', '+', 1)
|
|
62
|
+
})).where('id', '=', 0).returning('counter').executeTakeFirstOrThrow();
|
|
63
|
+
return String(row.counter).padStart(12, '0');
|
|
64
|
+
}
|
|
65
|
+
// Depth eviction runs inside the publishing transaction. It counts LOG frames
|
|
66
|
+
// only and removes the oldest beyond the bound (with their deliveries). Silent:
|
|
67
|
+
// depth eviction is a synchronous consequence of the caller's own publish.
|
|
68
|
+
async function evictDepth(trx, topicID) {
|
|
69
|
+
if (maxDepth == null) return;
|
|
70
|
+
const logRows = await trx.selectFrom('kubun_hub_messages').select('sequence_id').where('topic_id', '=', topicID).where('retain', '=', 'log').orderBy('sequence_id', 'asc').execute();
|
|
71
|
+
if (logRows.length <= maxDepth) return;
|
|
72
|
+
const evict = logRows.slice(0, logRows.length - maxDepth).map((r)=>r.sequence_id);
|
|
73
|
+
await deleteFrames(trx, evict);
|
|
74
|
+
}
|
|
24
75
|
async function publish(publishParams) {
|
|
25
|
-
const sequenceID = await nextSequenceID();
|
|
26
76
|
const kysely = await db();
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
await trx.
|
|
77
|
+
const retain = publishParams.retain ?? 'mailbox';
|
|
78
|
+
const { senderDID, topicID } = publishParams;
|
|
79
|
+
// The dedup check, the head compare-and-set, the sequence mint, the append,
|
|
80
|
+
// and the head advance all happen in ONE transaction, in that order. A
|
|
81
|
+
// read-then-write CAS split across statements is the exact race the head
|
|
82
|
+
// exists to eliminate.
|
|
83
|
+
return await kysely.transaction().execute(async (trx)=>{
|
|
84
|
+
// 1. Dedup — BEFORE the CAS. A replay carries a stale expectedHead by
|
|
85
|
+
// construction (the publish it replays is what moved the head), so comparing
|
|
86
|
+
// first would raise HeadMismatchError and tell the caller its commit was lost
|
|
87
|
+
// when it landed.
|
|
88
|
+
if (publishParams.publishID != null) {
|
|
89
|
+
const existing = await trx.selectFrom('kubun_hub_publish_ids').select('sequence_id').where('topic_id', '=', topicID).where('publish_id', '=', publishParams.publishID).executeTakeFirst();
|
|
90
|
+
if (existing != null) {
|
|
91
|
+
return {
|
|
92
|
+
sequenceID: existing.sequence_id,
|
|
93
|
+
deduped: true
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// 2. Stored head (null when the topic has never had an accepted log publish).
|
|
98
|
+
const topicRow = await trx.selectFrom('kubun_hub_topics').select('head').where('topic_id', '=', topicID).executeTakeFirst();
|
|
99
|
+
const currentHead = topicRow?.head ?? null;
|
|
100
|
+
// 3. Compare-and-set. On mismatch, throw and store NOTHING (the transaction
|
|
101
|
+
// rolls back — no entry, no delivery, no sequenceID consumed, no event).
|
|
102
|
+
if (publishParams.expectedHead !== undefined && currentHead !== publishParams.expectedHead) {
|
|
103
|
+
throw new HeadMismatchError(`head mismatch: expected ${String(publishParams.expectedHead)}, current ${String(currentHead)}`);
|
|
104
|
+
}
|
|
105
|
+
// 4. Mint the sequenceID.
|
|
106
|
+
const sequenceID = await nextSequenceID(trx);
|
|
107
|
+
// 5. Recipients = current subscribers minus the sender.
|
|
108
|
+
const subscriberRows = await trx.selectFrom('kubun_hub_subscriptions').select('subscriber_did').where('topic_id', '=', topicID).execute();
|
|
109
|
+
const recipients = subscriberRows.map((r)=>r.subscriber_did).filter((did)=>did !== senderDID);
|
|
110
|
+
// 6. Append. A log frame is stored unconditionally (a reader that must read
|
|
111
|
+
// it may not exist yet). A mailbox frame is delivery-derived: with zero
|
|
112
|
+
// recipients there is nothing to deliver, so it is not stored — but the
|
|
113
|
+
// sequenceID is still consumed.
|
|
114
|
+
const storeMessage = retain === 'log' || recipients.length > 0;
|
|
115
|
+
if (storeMessage) {
|
|
116
|
+
await trx.insertInto('kubun_hub_messages').values({
|
|
117
|
+
sequence_id: sequenceID,
|
|
118
|
+
sender_did: senderDID,
|
|
119
|
+
topic_id: topicID,
|
|
120
|
+
payload: adapter.encodeBinary(publishParams.payload),
|
|
121
|
+
stored_at: Date.now(),
|
|
122
|
+
retain
|
|
123
|
+
}).execute();
|
|
124
|
+
// Push is untouched by the class: a log frame is delivered to every current
|
|
125
|
+
// subscriber exactly as a mailbox frame is.
|
|
126
|
+
if (recipients.length > 0) {
|
|
127
|
+
await trx.insertInto('kubun_hub_deliveries').values(recipients.map((did)=>({
|
|
128
|
+
sequence_id: sequenceID,
|
|
129
|
+
recipient_did: did
|
|
130
|
+
}))).execute();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// 7. Advance the stored head — LOG frames only. A mailbox publish never moves
|
|
134
|
+
// the head.
|
|
135
|
+
//
|
|
136
|
+
// The head advance IS the compare-and-set; the earlier head read is only an
|
|
137
|
+
// MVCC snapshot, which two transactions racing at the same head both pass. The
|
|
138
|
+
// arbiter is this CONDITIONAL write, whose row count decides the winner: the
|
|
139
|
+
// loser matches zero rows, throws, and rolls the whole transaction back.
|
|
140
|
+
if (retain === 'log') {
|
|
141
|
+
if (publishParams.expectedHead === undefined) {
|
|
142
|
+
// Unconditional log publish: last-writer-wins on the head pointer. The
|
|
143
|
+
// frame is already appended; only the pointer races, and the caller did
|
|
144
|
+
// not ask for a CAS.
|
|
145
|
+
await trx.insertInto('kubun_hub_topics').values({
|
|
146
|
+
topic_id: topicID,
|
|
147
|
+
head: sequenceID
|
|
148
|
+
}).onConflict((oc)=>oc.column('topic_id').doUpdateSet({
|
|
149
|
+
head: sequenceID
|
|
150
|
+
})).execute();
|
|
151
|
+
} else if (publishParams.expectedHead === null) {
|
|
152
|
+
// Expected "never had a log publish": insert the row, and lose to any
|
|
153
|
+
// concurrent transaction that inserted it first (conflict → 0 rows).
|
|
154
|
+
const inserted = await trx.insertInto('kubun_hub_topics').values({
|
|
155
|
+
topic_id: topicID,
|
|
156
|
+
head: sequenceID
|
|
157
|
+
}).onConflict((oc)=>oc.column('topic_id').doNothing()).executeTakeFirst();
|
|
158
|
+
if (Number(inserted.numInsertedOrUpdatedRows ?? 0n) === 0) {
|
|
159
|
+
throw new HeadMismatchError('head mismatch: expected null, a concurrent publish set the head first');
|
|
160
|
+
}
|
|
161
|
+
} else {
|
|
162
|
+
// Expected a specific head: advance only if it is still that value.
|
|
163
|
+
const updated = await trx.updateTable('kubun_hub_topics').set({
|
|
164
|
+
head: sequenceID
|
|
165
|
+
}).where('topic_id', '=', topicID).where('head', '=', publishParams.expectedHead).executeTakeFirst();
|
|
166
|
+
if (Number(updated.numUpdatedRows ?? 0n) === 0) {
|
|
167
|
+
throw new HeadMismatchError(`head mismatch: expected ${publishParams.expectedHead}, a concurrent publish advanced it`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
await evictDepth(trx, topicID);
|
|
171
|
+
}
|
|
172
|
+
// 8. Record the idempotency key in its own table.
|
|
173
|
+
if (publishParams.publishID != null) {
|
|
174
|
+
await trx.insertInto('kubun_hub_publish_ids').values({
|
|
175
|
+
topic_id: topicID,
|
|
176
|
+
publish_id: publishParams.publishID,
|
|
49
177
|
sequence_id: sequenceID,
|
|
50
|
-
|
|
51
|
-
})
|
|
178
|
+
recorded_at: Date.now()
|
|
179
|
+
}).execute();
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
sequenceID,
|
|
183
|
+
deduped: false
|
|
184
|
+
};
|
|
52
185
|
});
|
|
53
|
-
return sequenceID;
|
|
54
186
|
}
|
|
55
187
|
async function fetch(fetchParams) {
|
|
56
188
|
// Process acks first if provided
|
|
@@ -65,7 +197,8 @@ export function createHubStore(params) {
|
|
|
65
197
|
'kubun_hub_messages.sequence_id',
|
|
66
198
|
'kubun_hub_messages.sender_did',
|
|
67
199
|
'kubun_hub_messages.topic_id',
|
|
68
|
-
'kubun_hub_messages.payload'
|
|
200
|
+
'kubun_hub_messages.payload',
|
|
201
|
+
'kubun_hub_messages.retain'
|
|
69
202
|
]).where('kubun_hub_deliveries.recipient_did', '=', fetchParams.recipientDID).orderBy('kubun_hub_messages.sequence_id', 'asc');
|
|
70
203
|
if (fetchParams.after != null) {
|
|
71
204
|
query = query.where('kubun_hub_messages.sequence_id', '>', fetchParams.after);
|
|
@@ -81,11 +214,20 @@ export function createHubStore(params) {
|
|
|
81
214
|
hasMore = true;
|
|
82
215
|
resultRows = rows.slice(0, limit);
|
|
83
216
|
}
|
|
217
|
+
// One global sequence serves both classes here, so a log frame's own
|
|
218
|
+
// sequenceID IS its position in the topic's log. A delivery position is a
|
|
219
|
+
// different number (it spans every subscribed topic, skips the recipient's own
|
|
220
|
+
// sends, and empties as it is acked), so a recipient cannot derive the log
|
|
221
|
+
// position itself. Omitted entirely for a mailbox frame — a falsy placeholder is
|
|
222
|
+
// a position a cursor would move to, skipping every log frame below it.
|
|
84
223
|
const messages = resultRows.map((row)=>({
|
|
85
224
|
sequenceID: row.sequence_id,
|
|
86
225
|
senderDID: row.sender_did,
|
|
87
226
|
topicID: row.topic_id,
|
|
88
|
-
payload: row.payload
|
|
227
|
+
payload: toBytes(row.payload),
|
|
228
|
+
...row.retain === 'log' ? {
|
|
229
|
+
logPosition: row.sequence_id
|
|
230
|
+
} : {}
|
|
89
231
|
}));
|
|
90
232
|
const cursor = messages.length > 0 ? messages[messages.length - 1].sequenceID : null;
|
|
91
233
|
return {
|
|
@@ -94,68 +236,219 @@ export function createHubStore(params) {
|
|
|
94
236
|
hasMore
|
|
95
237
|
};
|
|
96
238
|
}
|
|
239
|
+
async function fetchTopic(fetchParams) {
|
|
240
|
+
const kysely = await db();
|
|
241
|
+
const { subscriberDID, topicID } = fetchParams;
|
|
242
|
+
// Authorization: the caller must be a current subscriber of the topic.
|
|
243
|
+
const subscription = await kysely.selectFrom('kubun_hub_subscriptions').select('subscriber_did').where('topic_id', '=', topicID).where('subscriber_did', '=', subscriberDID).executeTakeFirst();
|
|
244
|
+
if (subscription == null) {
|
|
245
|
+
throw new NotSubscribedError(`${subscriberDID} is not subscribed to ${topicID}`);
|
|
246
|
+
}
|
|
247
|
+
// Head is stored state — it outlives every frame.
|
|
248
|
+
const topicRow = await kysely.selectFrom('kubun_hub_topics').select('head').where('topic_id', '=', topicID).executeTakeFirst();
|
|
249
|
+
const head = topicRow?.head ?? null;
|
|
250
|
+
// Oldest is the oldest RETAINED log frame, or null if the log is empty.
|
|
251
|
+
const oldestRow = await kysely.selectFrom('kubun_hub_messages').select((eb)=>eb.fn.min('sequence_id').as('oldest')).where('topic_id', '=', topicID).where('retain', '=', 'log').executeTakeFirst();
|
|
252
|
+
const oldest = oldestRow?.oldest ?? null;
|
|
253
|
+
// The log is the topic's log-class frames and NOTHING else — a mailbox frame
|
|
254
|
+
// never appears here. `limit` counts log frames (applied after the class
|
|
255
|
+
// filter), so a page of mailbox frames cannot hand a draining reader an empty
|
|
256
|
+
// page while log frames still wait.
|
|
257
|
+
let query = kysely.selectFrom('kubun_hub_messages').select([
|
|
258
|
+
'sequence_id',
|
|
259
|
+
'sender_did',
|
|
260
|
+
'topic_id',
|
|
261
|
+
'payload'
|
|
262
|
+
]).where('topic_id', '=', topicID).where('retain', '=', 'log').orderBy('sequence_id', 'asc');
|
|
263
|
+
if (fetchParams.after != null) {
|
|
264
|
+
query = query.where('sequence_id', '>', fetchParams.after);
|
|
265
|
+
}
|
|
266
|
+
if (fetchParams.limit != null) {
|
|
267
|
+
query = query.limit(fetchParams.limit);
|
|
268
|
+
}
|
|
269
|
+
const rows = await query.execute();
|
|
270
|
+
// Every row here is log-class by the filter above, so each carries its position
|
|
271
|
+
// unconditionally — the same value a push of that frame reports.
|
|
272
|
+
const messages = rows.map((row)=>({
|
|
273
|
+
sequenceID: row.sequence_id,
|
|
274
|
+
senderDID: row.sender_did,
|
|
275
|
+
topicID: row.topic_id,
|
|
276
|
+
payload: toBytes(row.payload),
|
|
277
|
+
logPosition: row.sequence_id
|
|
278
|
+
}));
|
|
279
|
+
return {
|
|
280
|
+
messages,
|
|
281
|
+
head,
|
|
282
|
+
oldest
|
|
283
|
+
};
|
|
284
|
+
}
|
|
97
285
|
async function ack(ackParams) {
|
|
98
286
|
if (ackParams.sequenceIDs.length === 0) return;
|
|
99
287
|
const kysely = await db();
|
|
100
288
|
// Remove delivery entries for this recipient
|
|
101
289
|
await kysely.deleteFrom('kubun_hub_deliveries').where('recipient_did', '=', ackParams.recipientDID).where('sequence_id', 'in', ackParams.sequenceIDs).execute();
|
|
102
|
-
//
|
|
290
|
+
// GC fully-acked MAILBOX messages only. A log frame is removed by trim/purge
|
|
291
|
+
// alone — ack frees a delivery, never a log entry or the head.
|
|
103
292
|
const withDeliveries = await kysely.selectFrom('kubun_hub_deliveries').select('sequence_id').where('sequence_id', 'in', ackParams.sequenceIDs).execute();
|
|
104
293
|
const stillDelivered = new Set(withDeliveries.map((r)=>r.sequence_id));
|
|
105
294
|
const fullyAcked = ackParams.sequenceIDs.filter((id)=>!stillDelivered.has(id));
|
|
106
295
|
if (fullyAcked.length > 0) {
|
|
107
|
-
await kysely.deleteFrom('kubun_hub_messages').where('sequence_id', 'in', fullyAcked).execute();
|
|
296
|
+
await kysely.deleteFrom('kubun_hub_messages').where('sequence_id', 'in', fullyAcked).where('retain', '=', 'mailbox').execute();
|
|
108
297
|
}
|
|
109
298
|
}
|
|
299
|
+
async function trim(trimParams) {
|
|
300
|
+
const kysely = await db();
|
|
301
|
+
// Remove LOG frames strictly below the bound, and only those. A mailbox frame
|
|
302
|
+
// on the same topic is delivery-derived and untouched. The head (topics table)
|
|
303
|
+
// and the publishID records are never touched.
|
|
304
|
+
const logRows = await kysely.selectFrom('kubun_hub_messages').select('sequence_id').where('topic_id', '=', trimParams.topicID).where('retain', '=', 'log').where('sequence_id', '<', trimParams.before).execute();
|
|
305
|
+
const sequenceIDs = logRows.map((r)=>r.sequence_id);
|
|
306
|
+
if (sequenceIDs.length === 0) return;
|
|
307
|
+
await deleteFrames(kysely, sequenceIDs);
|
|
308
|
+
}
|
|
110
309
|
async function purge(purgeParams) {
|
|
111
310
|
const kysely = await db();
|
|
112
|
-
const
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
311
|
+
const now = Date.now();
|
|
312
|
+
const olderThan = purgeParams.olderThan;
|
|
313
|
+
// Idempotency records outlive the frames they dedup, on their own clock and
|
|
314
|
+
// deliberately: the contract is that no message deleter reaches one for a
|
|
315
|
+
// frame it still guards, NOT that they are kept forever. Every commit
|
|
316
|
+
// publish writes one, so without this the table is the fastest-growing on a
|
|
317
|
+
// long-lived hub, holding rows for frames purged years earlier. The window
|
|
318
|
+
// is far longer than any plausible replay — a publish repeated after it is
|
|
319
|
+
// accepted as new, which is what a fresh publishID would be anyway.
|
|
320
|
+
await kysely.deleteFrom('kubun_hub_publish_ids').where('recorded_at', '<=', now - PUBLISH_ID_RETENTION_SECONDS * 1000).execute();
|
|
321
|
+
// Per topic, the effective age bound is the longest retention any CURRENT
|
|
322
|
+
// subscriber asked for, floored at the hub default (and at the sweep's
|
|
323
|
+
// olderThan). Applies to both classes. Never touches the head or a publishID
|
|
324
|
+
// record for a frame it removes.
|
|
325
|
+
const topicRows = await kysely.selectFrom('kubun_hub_messages').select('topic_id').distinct().execute();
|
|
326
|
+
// Every topic's longest CURRENT subscriber retention in one grouped read
|
|
327
|
+
// rather than one read per topic: the sweep walks every topic the hub holds,
|
|
328
|
+
// so the per-topic version is a query count that grows with the hub.
|
|
329
|
+
const retentionRows = await kysely.selectFrom('kubun_hub_subscriptions').select((eb)=>[
|
|
330
|
+
'topic_id',
|
|
331
|
+
eb.fn.max('retention').as('maxRetention')
|
|
332
|
+
]).groupBy('topic_id').execute();
|
|
333
|
+
const retentionByTopic = new Map(retentionRows.map((row)=>[
|
|
334
|
+
row.topic_id,
|
|
335
|
+
Number(row.maxRetention ?? 0)
|
|
336
|
+
]));
|
|
337
|
+
const removed = [];
|
|
338
|
+
for (const { topic_id } of topicRows){
|
|
339
|
+
const subscriberRetention = retentionByTopic.get(topic_id) ?? 0;
|
|
340
|
+
const effective = Math.max(olderThan, subscriberRetention, defaultRetention);
|
|
341
|
+
const threshold = now - effective * 1000;
|
|
342
|
+
const frames = await kysely.selectFrom('kubun_hub_messages').select('sequence_id').where('topic_id', '=', topic_id).where('stored_at', '<=', threshold).execute();
|
|
343
|
+
for (const frame of frames){
|
|
344
|
+
removed.push(frame.sequence_id);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (removed.length === 0) return [];
|
|
348
|
+
await deleteFrames(kysely, removed);
|
|
118
349
|
await events.emit('purge', {
|
|
119
|
-
sequenceIDs
|
|
350
|
+
sequenceIDs: removed
|
|
120
351
|
});
|
|
121
|
-
return
|
|
352
|
+
return removed;
|
|
122
353
|
}
|
|
123
|
-
|
|
354
|
+
// `not_after` is seconds; every other timestamp here is milliseconds.
|
|
355
|
+
function nowSeconds() {
|
|
356
|
+
return Math.floor(Date.now() / 1000);
|
|
357
|
+
}
|
|
358
|
+
async function deleteExpiredKeyPackages(kysely, ownerDID) {
|
|
359
|
+
await kysely.deleteFrom('kubun_hub_key_packages').where('owner_did', '=', ownerDID).where('not_after', 'is not', null).where('not_after', '<=', nowSeconds()).execute();
|
|
360
|
+
}
|
|
361
|
+
async function storeKeyPackage(ownerDID, keyPackage, notAfter) {
|
|
124
362
|
const kysely = await db();
|
|
363
|
+
// Drop the dead BEFORE charging the cap. The cap rejects rather than evicts, so
|
|
364
|
+
// nothing else would ever remove them: a pool that filled with expired entries
|
|
365
|
+
// could never be replenished and its owner would be permanently unaddable.
|
|
366
|
+
await deleteExpiredKeyPackages(kysely, ownerDID);
|
|
367
|
+
const row = await kysely.selectFrom('kubun_hub_key_packages').select((eb)=>eb.fn.countAll().as('count')).where('owner_did', '=', ownerDID).executeTakeFirst();
|
|
368
|
+
if (Number(row?.count ?? 0) >= maxKeyPackagesPerDID) {
|
|
369
|
+
throw new KeyPackageQuotaExceededError(`DID ${ownerDID} exceeds the maximum of ${maxKeyPackagesPerDID} stored key packages`);
|
|
370
|
+
}
|
|
125
371
|
await kysely.insertInto('kubun_hub_key_packages').values({
|
|
126
372
|
owner_did: ownerDID,
|
|
127
|
-
key_package: keyPackage
|
|
373
|
+
key_package: keyPackage,
|
|
374
|
+
not_after: notAfter ?? null
|
|
128
375
|
}).execute();
|
|
129
376
|
}
|
|
130
377
|
async function fetchKeyPackages(ownerDID, count) {
|
|
131
378
|
const kysely = await db();
|
|
379
|
+
// Expired entries are DELETED first, not filtered after the limit: a run of dead
|
|
380
|
+
// rows would otherwise consume the whole page and return nothing while live
|
|
381
|
+
// packages waited behind them.
|
|
382
|
+
await deleteExpiredKeyPackages(kysely, ownerDID);
|
|
132
383
|
const limit = count ?? 1;
|
|
133
384
|
// Atomic delete-returning: a non-atomic select+delete pair would let two
|
|
134
385
|
// concurrent fetches return the same key package twice.
|
|
135
386
|
const rows = await kysely.deleteFrom('kubun_hub_key_packages').where('id', 'in', kysely.selectFrom('kubun_hub_key_packages').select('id').where('owner_did', '=', ownerDID).orderBy('id', 'asc').limit(limit)).returning('key_package').execute();
|
|
136
387
|
return rows.map((r)=>r.key_package);
|
|
137
388
|
}
|
|
138
|
-
async function
|
|
389
|
+
async function countKeyPackages(ownerDID) {
|
|
139
390
|
const kysely = await db();
|
|
391
|
+
const row = await kysely.selectFrom('kubun_hub_key_packages').select((eb)=>eb.fn.countAll().as('count')).where('owner_did', '=', ownerDID).where((eb)=>eb.or([
|
|
392
|
+
eb('not_after', 'is', null),
|
|
393
|
+
eb('not_after', '>', nowSeconds())
|
|
394
|
+
])).executeTakeFirst();
|
|
395
|
+
return Number(row?.count ?? 0);
|
|
396
|
+
}
|
|
397
|
+
async function storeLastResortKeyPackage(ownerDID, keyPackage) {
|
|
398
|
+
const kysely = await db();
|
|
399
|
+
await kysely.insertInto('kubun_hub_last_resort_key_packages').values({
|
|
400
|
+
owner_did: ownerDID,
|
|
401
|
+
key_package: keyPackage
|
|
402
|
+
}).onConflict((oc)=>oc.column('owner_did').doUpdateSet({
|
|
403
|
+
key_package: (eb)=>eb.ref('excluded.key_package')
|
|
404
|
+
})).execute();
|
|
405
|
+
}
|
|
406
|
+
async function fetchLastResortKeyPackage(ownerDID) {
|
|
407
|
+
const kysely = await db();
|
|
408
|
+
// Never consumes. Reuse is the point: the package carries MLS's `last_resort`
|
|
409
|
+
// extension, so serving it twice is the availability floor rather than the
|
|
410
|
+
// init-key reuse `fetchKeyPackages` exists to prevent.
|
|
411
|
+
const row = await kysely.selectFrom('kubun_hub_last_resort_key_packages').select('key_package').where('owner_did', '=', ownerDID).executeTakeFirst();
|
|
412
|
+
return row?.key_package ?? null;
|
|
413
|
+
}
|
|
414
|
+
async function subscribe(subscribeParams) {
|
|
415
|
+
const { subscriberDID, topicID } = subscribeParams;
|
|
416
|
+
// A retention above the hub maximum is refused, never clamped — a peer that
|
|
417
|
+
// believed it had asked for more would otherwise be stranded.
|
|
418
|
+
if (subscribeParams.retention != null && maxRetention != null && subscribeParams.retention > maxRetention) {
|
|
419
|
+
throw new RetentionExceededError(`requested retention ${subscribeParams.retention} exceeds the hub maximum ${maxRetention}`);
|
|
420
|
+
}
|
|
421
|
+
const retention = subscribeParams.retention ?? defaultRetention;
|
|
422
|
+
const kysely = await db();
|
|
423
|
+
// Charged on NEW rows only: a re-subscribe to a topic this DID already holds is a
|
|
424
|
+
// refresh, and refusing it would strand a subscriber at the cap on every reconnect.
|
|
425
|
+
const held = await kysely.selectFrom('kubun_hub_subscriptions').select('topic_id').where('subscriber_did', '=', subscriberDID).execute();
|
|
426
|
+
if (!held.some((row)=>row.topic_id === topicID) && held.length >= maxSubscriptionsPerDID) {
|
|
427
|
+
throw new SubscriptionQuotaExceededError(`DID ${subscriberDID} exceeds the maximum of ${maxSubscriptionsPerDID} subscriptions`);
|
|
428
|
+
}
|
|
140
429
|
// Upsert on the (topic_id, subscriber_did) primary key so re-subscribing an
|
|
141
|
-
// existing subscriber refreshes its activity timestamp
|
|
142
|
-
// or duplicating.
|
|
430
|
+
// existing subscriber refreshes its activity timestamp and retention rather
|
|
431
|
+
// than failing or duplicating.
|
|
143
432
|
await kysely.insertInto('kubun_hub_subscriptions').values({
|
|
144
433
|
topic_id: topicID,
|
|
145
434
|
subscriber_did: subscriberDID,
|
|
146
|
-
last_active_at: Date.now()
|
|
435
|
+
last_active_at: Date.now(),
|
|
436
|
+
retention
|
|
147
437
|
}).onConflict((oc)=>oc.columns([
|
|
148
438
|
'topic_id',
|
|
149
439
|
'subscriber_did'
|
|
150
440
|
]).doUpdateSet({
|
|
151
|
-
last_active_at: (eb)=>eb.ref('excluded.last_active_at')
|
|
441
|
+
last_active_at: (eb)=>eb.ref('excluded.last_active_at'),
|
|
442
|
+
retention: (eb)=>eb.ref('excluded.retention')
|
|
152
443
|
})).execute();
|
|
153
444
|
}
|
|
154
445
|
async function unsubscribe(subscriberDID, topicID) {
|
|
155
446
|
const kysely = await db();
|
|
156
447
|
await kysely.deleteFrom('kubun_hub_subscriptions').where('topic_id', '=', topicID).where('subscriber_did', '=', subscriberDID).execute();
|
|
157
|
-
//
|
|
158
|
-
//
|
|
448
|
+
// unsubscribe is a delivery operation, not a trim: it drops this subscriber's
|
|
449
|
+
// pending deliveries for the topic and GCs any MAILBOX frame left without a
|
|
450
|
+
// recipient. A log frame (which trim alone may remove) and the head are never
|
|
451
|
+
// touched.
|
|
159
452
|
const pending = await kysely.selectFrom('kubun_hub_deliveries').innerJoin('kubun_hub_messages', 'kubun_hub_messages.sequence_id', 'kubun_hub_deliveries.sequence_id').select('kubun_hub_deliveries.sequence_id').where('kubun_hub_deliveries.recipient_did', '=', subscriberDID).where('kubun_hub_messages.topic_id', '=', topicID).execute();
|
|
160
453
|
if (pending.length === 0) return;
|
|
161
454
|
const sequenceIDs = pending.map((r)=>r.sequence_id);
|
|
@@ -164,7 +457,7 @@ export function createHubStore(params) {
|
|
|
164
457
|
const stillDelivered = new Set(remaining.map((r)=>r.sequence_id));
|
|
165
458
|
const orphaned = sequenceIDs.filter((id)=>!stillDelivered.has(id));
|
|
166
459
|
if (orphaned.length > 0) {
|
|
167
|
-
await kysely.deleteFrom('kubun_hub_messages').where('sequence_id', 'in', orphaned).execute();
|
|
460
|
+
await kysely.deleteFrom('kubun_hub_messages').where('sequence_id', 'in', orphaned).where('retain', '=', 'mailbox').execute();
|
|
168
461
|
}
|
|
169
462
|
}
|
|
170
463
|
async function getSubscribers(topicID) {
|
|
@@ -193,11 +486,16 @@ export function createHubStore(params) {
|
|
|
193
486
|
events,
|
|
194
487
|
publish,
|
|
195
488
|
fetch,
|
|
489
|
+
fetchTopic,
|
|
196
490
|
ack,
|
|
197
491
|
purge,
|
|
492
|
+
trim,
|
|
198
493
|
purgeSubscribers,
|
|
199
494
|
storeKeyPackage,
|
|
200
495
|
fetchKeyPackages,
|
|
496
|
+
countKeyPackages,
|
|
497
|
+
storeLastResortKeyPackage,
|
|
498
|
+
fetchLastResortKeyPackage,
|
|
201
499
|
subscribe,
|
|
202
500
|
unsubscribe,
|
|
203
501
|
getSubscribers
|
package/lib/migrations.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import { sql } from 'kysely';
|
|
1
2
|
export function createHubMigrations(ctx) {
|
|
2
3
|
const t = ctx.types;
|
|
3
4
|
return {
|
|
4
5
|
'001_create_hub_tables': {
|
|
5
6
|
async up (db) {
|
|
6
7
|
// Hub messages
|
|
7
|
-
await db.schema.createTable('kubun_hub_messages').ifNotExists().addColumn('sequence_id', t.text, (col)=>col.notNull().primaryKey()).addColumn('sender_did', t.text, (col)=>col.notNull()).addColumn('topic_id', t.text, (col)=>col.notNull()).addColumn('payload', t.binary, (col)=>col.notNull()).addColumn('stored_at', t.bigint, (col)=>col.notNull()).
|
|
8
|
+
await db.schema.createTable('kubun_hub_messages').ifNotExists().addColumn('sequence_id', t.text, (col)=>col.notNull().primaryKey()).addColumn('sender_did', t.text, (col)=>col.notNull()).addColumn('topic_id', t.text, (col)=>col.notNull()).addColumn('payload', t.binary, (col)=>col.notNull()).addColumn('stored_at', t.bigint, (col)=>col.notNull())// Retention class: 'mailbox' is delivery-derived, 'log' is topic-retained.
|
|
9
|
+
.addColumn('retain', t.text, (col)=>col.notNull().defaultTo('mailbox')).execute();
|
|
8
10
|
// Hub deliveries
|
|
9
11
|
await db.schema.createTable('kubun_hub_deliveries').ifNotExists().addColumn('sequence_id', t.text, (col)=>col.notNull()).addColumn('recipient_did', t.text, (col)=>col.notNull()).addPrimaryKeyConstraint('pk_hub_deliveries', [
|
|
10
12
|
'sequence_id',
|
|
@@ -12,15 +14,50 @@ export function createHubMigrations(ctx) {
|
|
|
12
14
|
]).execute();
|
|
13
15
|
await db.schema.createIndex('idx_hub_deliveries_recipient').ifNotExists().on('kubun_hub_deliveries').column('recipient_did').execute();
|
|
14
16
|
// Hub key packages
|
|
15
|
-
await db.schema.createTable('kubun_hub_key_packages').ifNotExists().addColumn('id', t.serial, (col)=>col.primaryKey()).addColumn('owner_did', t.text, (col)=>col.notNull()).addColumn('key_package', t.text, (col)=>col.notNull()).
|
|
17
|
+
await db.schema.createTable('kubun_hub_key_packages').ifNotExists().addColumn('id', t.serial, (col)=>col.primaryKey()).addColumn('owner_did', t.text, (col)=>col.notNull()).addColumn('key_package', t.text, (col)=>col.notNull())// Expiry in SECONDS, unlike every millisecond column here. Null never expires.
|
|
18
|
+
.addColumn('not_after', t.bigint).execute();
|
|
16
19
|
await db.schema.createIndex('idx_hub_key_packages_owner').ifNotExists().on('kubun_hub_key_packages').column('owner_did').execute();
|
|
20
|
+
// The last-resort slot gets its OWN table so no ordinary-pool query can reach
|
|
21
|
+
// it: it is reusable and uncapped, and both properties invert the pool's. The
|
|
22
|
+
// primary key is what holds "one slot per owner".
|
|
23
|
+
await db.schema.createTable('kubun_hub_last_resort_key_packages').ifNotExists().addColumn('owner_did', t.text, (col)=>col.notNull().primaryKey()).addColumn('key_package', t.text, (col)=>col.notNull()).execute();
|
|
17
24
|
// Hub subscriptions
|
|
18
|
-
await db.schema.createTable('kubun_hub_subscriptions').ifNotExists().addColumn('topic_id', t.text, (col)=>col.notNull()).addColumn('subscriber_did', t.text, (col)=>col.notNull()).addColumn('last_active_at', t.bigint, (col)=>col.notNull().defaultTo(0)).
|
|
25
|
+
await db.schema.createTable('kubun_hub_subscriptions').ifNotExists().addColumn('topic_id', t.text, (col)=>col.notNull()).addColumn('subscriber_did', t.text, (col)=>col.notNull()).addColumn('last_active_at', t.bigint, (col)=>col.notNull().defaultTo(0))// Requested retention (seconds). The topic's age bound is the longest any
|
|
26
|
+
// current subscriber asked for, floored at the hub default.
|
|
27
|
+
.addColumn('retention', t.bigint, (col)=>col.notNull().defaultTo(0)).addPrimaryKeyConstraint('pk_hub_subscriptions', [
|
|
19
28
|
'topic_id',
|
|
20
29
|
'subscriber_did'
|
|
21
30
|
]).execute();
|
|
31
|
+
// Stored per-topic head: the sequenceID of the last accepted log publish.
|
|
32
|
+
// Stored state, NOT a projection — it outlives every frame, so a trim or
|
|
33
|
+
// purge that empties the log leaves it standing.
|
|
34
|
+
await db.schema.createTable('kubun_hub_topics').ifNotExists().addColumn('topic_id', t.text, (col)=>col.notNull().primaryKey()).addColumn('head', t.text, (col)=>col.notNull()).execute();
|
|
35
|
+
// Persistent, global, monotonic sequence counter. Persisted (not derived
|
|
36
|
+
// from surviving frames) so trimming or purging a topic to empty never
|
|
37
|
+
// regresses minting below a head the topic still names. Global so a
|
|
38
|
+
// sequenceID is unique across topics and the mailbox fetch stays globally
|
|
39
|
+
// ordered.
|
|
40
|
+
await db.schema.createTable('kubun_hub_sequence').ifNotExists().addColumn('id', t.bigint, (col)=>col.notNull().primaryKey()).addColumn('counter', t.bigint, (col)=>col.notNull()).execute();
|
|
41
|
+
await sql`INSERT INTO kubun_hub_sequence (id, counter) VALUES (0, 0)`.execute(db);
|
|
42
|
+
// Idempotency records: publishID -> sequenceID. Its OWN table, so trim and
|
|
43
|
+
// purge never remove it; its retention is its own (indefinite). Scoped per
|
|
44
|
+
// topic lane.
|
|
45
|
+
await db.schema.createTable('kubun_hub_publish_ids').ifNotExists().addColumn('topic_id', t.text, (col)=>col.notNull()).addColumn('publish_id', t.text, (col)=>col.notNull()).addColumn('sequence_id', t.text, (col)=>col.notNull())// Every commit publish writes a row here and nothing else ever deletes one,
|
|
46
|
+
// so on a long-lived hub this is the fastest-growing table, outliving frames
|
|
47
|
+
// purged years earlier. The contract only requires that trim and purge never
|
|
48
|
+
// take a record while the FRAME it dedups survives; keeping records forever
|
|
49
|
+
// was never part of it. This column is what lets `purge` sweep them on a
|
|
50
|
+
// window longer than any plausible replay.
|
|
51
|
+
.addColumn('recorded_at', t.bigint, (col)=>col.notNull().defaultTo(0)).addPrimaryKeyConstraint('pk_hub_publish_ids', [
|
|
52
|
+
'topic_id',
|
|
53
|
+
'publish_id'
|
|
54
|
+
]).execute();
|
|
22
55
|
},
|
|
23
56
|
async down (db) {
|
|
57
|
+
await db.schema.dropTable('kubun_hub_publish_ids').ifExists().execute();
|
|
58
|
+
await db.schema.dropTable('kubun_hub_last_resort_key_packages').ifExists().execute();
|
|
59
|
+
await db.schema.dropTable('kubun_hub_sequence').ifExists().execute();
|
|
60
|
+
await db.schema.dropTable('kubun_hub_topics').ifExists().execute();
|
|
24
61
|
await db.schema.dropTable('kubun_hub_subscriptions').ifExists().execute();
|
|
25
62
|
await db.schema.dropTable('kubun_hub_key_packages').ifExists().execute();
|
|
26
63
|
await db.schema.dropTable('kubun_hub_deliveries').ifExists().execute();
|
package/lib/observers.js
CHANGED
|
@@ -13,20 +13,25 @@ export function decorateHubStore(store, observers) {
|
|
|
13
13
|
events: store.events,
|
|
14
14
|
storeKeyPackage: store.storeKeyPackage.bind(store),
|
|
15
15
|
fetchKeyPackages: store.fetchKeyPackages.bind(store),
|
|
16
|
+
countKeyPackages: store.countKeyPackages.bind(store),
|
|
17
|
+
storeLastResortKeyPackage: store.storeLastResortKeyPackage.bind(store),
|
|
18
|
+
fetchLastResortKeyPackage: store.fetchLastResortKeyPackage.bind(store),
|
|
16
19
|
subscribe: store.subscribe.bind(store),
|
|
17
20
|
unsubscribe: store.unsubscribe.bind(store),
|
|
18
21
|
getSubscribers: store.getSubscribers.bind(store),
|
|
19
22
|
purgeSubscribers: store.purgeSubscribers.bind(store),
|
|
20
23
|
purge: store.purge.bind(store),
|
|
24
|
+
fetchTopic: store.fetchTopic.bind(store),
|
|
25
|
+
trim: store.trim.bind(store),
|
|
21
26
|
async publish (params) {
|
|
22
27
|
try {
|
|
23
|
-
const
|
|
28
|
+
const result = await store.publish(params);
|
|
24
29
|
safeCall(onStore, {
|
|
25
30
|
senderDID: params.senderDID,
|
|
26
31
|
topicID: params.topicID,
|
|
27
|
-
sequenceID
|
|
32
|
+
sequenceID: result.sequenceID
|
|
28
33
|
});
|
|
29
|
-
return
|
|
34
|
+
return result;
|
|
30
35
|
} catch (error) {
|
|
31
36
|
safeCall(onError, {
|
|
32
37
|
stage: 'store',
|
package/package.json
CHANGED
|
@@ -1,49 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/hub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"license": "see LICENSE.md",
|
|
5
|
+
"sideEffects": false,
|
|
5
6
|
"type": "module",
|
|
6
|
-
"main": "lib/index.js",
|
|
7
|
-
"types": "lib/index.d.ts",
|
|
8
7
|
"exports": {
|
|
9
8
|
".": "./lib/index.js"
|
|
10
9
|
},
|
|
10
|
+
"main": "lib/index.js",
|
|
11
|
+
"types": "lib/index.d.ts",
|
|
11
12
|
"files": [
|
|
12
13
|
"lib/*",
|
|
13
14
|
"LICENSE.md"
|
|
14
15
|
],
|
|
15
|
-
"sideEffects": false,
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@
|
|
18
|
-
"@enkaku/
|
|
19
|
-
"@
|
|
20
|
-
"@
|
|
21
|
-
"@
|
|
22
|
-
"@
|
|
23
|
-
"@
|
|
17
|
+
"@enkaku/http-serve": "^0.20.0",
|
|
18
|
+
"@enkaku/server": "^0.20.0",
|
|
19
|
+
"@hono/node-server": "^2.1.0",
|
|
20
|
+
"@kokuin/token": "^0.3.0",
|
|
21
|
+
"@kumiai/hub-protocol": "^0.5.0",
|
|
22
|
+
"@kumiai/hub-server": "^0.5.0",
|
|
23
|
+
"@sozai/event": "^0.1.3",
|
|
24
24
|
"get-port": "^7.2.0",
|
|
25
|
-
"hono": "^4.
|
|
26
|
-
"kysely": "^0.29.
|
|
27
|
-
"@kubun/db-adapter": "^0.
|
|
28
|
-
"@kubun/
|
|
29
|
-
"@kubun/
|
|
25
|
+
"hono": "^4.13.0",
|
|
26
|
+
"kysely": "^0.29.4",
|
|
27
|
+
"@kubun/db-adapter": "^0.12.0",
|
|
28
|
+
"@kubun/db-provider": "^0.12.0",
|
|
29
|
+
"@kubun/http-util": "^0.12.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@
|
|
33
|
-
"@enkaku/
|
|
34
|
-
"@
|
|
35
|
-
"@enkaku/
|
|
36
|
-
"@
|
|
37
|
-
"@
|
|
38
|
-
"@
|
|
39
|
-
"@
|
|
40
|
-
"@kubun/db-
|
|
41
|
-
"@kubun/db-node-sqlite": "^0.
|
|
32
|
+
"@enkaku/client": "^0.20.0",
|
|
33
|
+
"@enkaku/http-fetch": "^0.20.0",
|
|
34
|
+
"@enkaku/protocol": "^0.20.0",
|
|
35
|
+
"@enkaku/transport": "^0.20.0",
|
|
36
|
+
"@kumiai/hub-conformance": "^0.5.0",
|
|
37
|
+
"@kumiai/mls": "^0.5.0",
|
|
38
|
+
"@sozai/async": "^0.2.1",
|
|
39
|
+
"@testcontainers/postgresql": "^12.1.0",
|
|
40
|
+
"@kubun/db-better-sqlite": "^0.12.0",
|
|
41
|
+
"@kubun/db-node-sqlite": "^0.12.0",
|
|
42
|
+
"@kubun/db-postgres": "^0.12.0"
|
|
42
43
|
},
|
|
43
44
|
"scripts": {
|
|
45
|
+
"build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
|
|
44
46
|
"build:clean": "del lib",
|
|
45
47
|
"build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
|
|
46
48
|
"build:types": "tsc --emitDeclarationOnly --skipLibCheck",
|
|
47
|
-
"
|
|
49
|
+
"test:types": "tsc --noEmit -p tsconfig.test.json"
|
|
48
50
|
}
|
|
49
51
|
}
|