@kubun/hub 0.10.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 +12 -6
- package/lib/create-relay.js +233 -1
- package/lib/hub-store.d.ts +43 -6
- package/lib/hub-store.js +503 -1
- package/lib/index.d.ts +9 -9
- package/lib/index.js +42 -1
- package/lib/migrations.js +68 -1
- package/lib/observers.d.ts +13 -5
- package/lib/observers.js +76 -1
- package/package.json +30 -24
package/lib/create-relay.d.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type { Identity } from '@enkaku/token';
|
|
1
|
+
import type { Identity } from '@kokuin/token';
|
|
3
2
|
import type { Adapter } from '@kubun/db-adapter';
|
|
4
3
|
import { type AdapterInput } from '@kubun/db-provider';
|
|
5
4
|
import { Kysely } from 'kysely';
|
|
6
|
-
import { type HubDatabase } from './hub-store.js';
|
|
5
|
+
import { type HubDatabase, type KubunHubStore } from './hub-store.js';
|
|
7
6
|
import { type RelayObservers } from './observers.js';
|
|
8
7
|
export type HubAuthConfig = {
|
|
9
8
|
mode: 'open';
|
|
@@ -15,28 +14,35 @@ export type HubAuthConfig = {
|
|
|
15
14
|
export type RelayRetentionConfig = {
|
|
16
15
|
olderThanSeconds: number;
|
|
17
16
|
intervalSeconds?: number;
|
|
17
|
+
subscriptionOlderThanSeconds?: number;
|
|
18
18
|
};
|
|
19
19
|
export type CreateRelayOptions = {
|
|
20
20
|
db: AdapterInput;
|
|
21
|
-
identity
|
|
21
|
+
identity: Identity;
|
|
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 = {
|
|
28
31
|
fetch: (request: Request) => Promise<Response>;
|
|
29
|
-
hubStore:
|
|
32
|
+
hubStore: KubunHubStore;
|
|
30
33
|
pendingDeliveries: () => Promise<number>;
|
|
31
34
|
dispose: () => Promise<void>;
|
|
32
35
|
};
|
|
33
36
|
export type RelayInternalOptions = {
|
|
34
37
|
kysely: Kysely<HubDatabase>;
|
|
35
38
|
adapter: Adapter;
|
|
36
|
-
identity
|
|
39
|
+
identity: Identity;
|
|
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 +1,233 @@
|
|
|
1
|
-
import{ServerTransport
|
|
1
|
+
import { ServerTransport } from '@enkaku/http-serve';
|
|
2
|
+
import { resolveAdapter } from '@kubun/db-provider';
|
|
3
|
+
import { resolveAllowedOrigin } from '@kubun/http-util';
|
|
4
|
+
import { createHub } from '@kumiai/hub-server';
|
|
5
|
+
import { Kysely } from 'kysely';
|
|
6
|
+
import { Migrator } from 'kysely/migration';
|
|
7
|
+
import { createHubStore } from './hub-store.js';
|
|
8
|
+
import { createHubMigrations } from './migrations.js';
|
|
9
|
+
import { decorateHubStore } from './observers.js';
|
|
10
|
+
const DEFAULT_RETENTION_INTERVAL_SECONDS = 3600;
|
|
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;
|
|
14
|
+
function resolveAccessRules(auth) {
|
|
15
|
+
if (auth == null) return {};
|
|
16
|
+
switch(auth.mode){
|
|
17
|
+
case 'open':
|
|
18
|
+
return {
|
|
19
|
+
'*': {
|
|
20
|
+
allow: true
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
case 'allowlist':
|
|
24
|
+
{
|
|
25
|
+
const { allowedDIDs, isAllowed } = auth;
|
|
26
|
+
if (isAllowed != null) {
|
|
27
|
+
const wrapped = async (ctx)=>{
|
|
28
|
+
const did = ctx.payload.iss;
|
|
29
|
+
if (allowedDIDs?.includes(did)) return true;
|
|
30
|
+
return await isAllowed(did);
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
'*': {
|
|
34
|
+
allow: wrapped
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
'*': {
|
|
40
|
+
allow: allowedDIDs ?? []
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export async function createRelayInternal(options) {
|
|
47
|
+
const { kysely, adapter, observers, ownsKysely } = options;
|
|
48
|
+
const migrationCtx = {
|
|
49
|
+
types: adapter.types,
|
|
50
|
+
functions: adapter.functions
|
|
51
|
+
};
|
|
52
|
+
const migrations = createHubMigrations(migrationCtx);
|
|
53
|
+
const migrationProvider = {
|
|
54
|
+
getMigrations: async ()=>migrations
|
|
55
|
+
};
|
|
56
|
+
const migrator = new Migrator({
|
|
57
|
+
db: kysely,
|
|
58
|
+
provider: migrationProvider,
|
|
59
|
+
migrationTableName: 'kubun_hub_migration',
|
|
60
|
+
migrationLockTableName: 'kubun_hub_migration_lock'
|
|
61
|
+
});
|
|
62
|
+
const { error: migrationError } = await migrator.migrateToLatest();
|
|
63
|
+
if (migrationError != null) {
|
|
64
|
+
throw migrationError;
|
|
65
|
+
}
|
|
66
|
+
const rawStore = createHubStore({
|
|
67
|
+
kysely,
|
|
68
|
+
adapter,
|
|
69
|
+
defaultRetention: options.defaultRetention ?? 0,
|
|
70
|
+
maxRetention: options.maxRetention ?? DEFAULT_MAX_RETENTION_SECONDS,
|
|
71
|
+
maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH
|
|
72
|
+
});
|
|
73
|
+
const hubStore = decorateHubStore(rawStore, observers);
|
|
74
|
+
let unsubscribePurge;
|
|
75
|
+
if (observers?.onPurge != null) {
|
|
76
|
+
const onPurge = observers.onPurge;
|
|
77
|
+
unsubscribePurge = rawStore.events.on('purge', (info)=>{
|
|
78
|
+
try {
|
|
79
|
+
onPurge({
|
|
80
|
+
sequenceCount: info.sequenceIDs.length
|
|
81
|
+
});
|
|
82
|
+
} catch {
|
|
83
|
+
// Observer callbacks are diagnostics — never let them break the relay.
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const allowedOrigin = resolveAllowedOrigin(options.allowedOrigin);
|
|
88
|
+
const transport = new ServerTransport({
|
|
89
|
+
allowedOrigin
|
|
90
|
+
});
|
|
91
|
+
const accessRules = resolveAccessRules(options.auth);
|
|
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,
|
|
98
|
+
identity: options.identity,
|
|
99
|
+
accessRules,
|
|
100
|
+
purge: false
|
|
101
|
+
});
|
|
102
|
+
if (observers?.onAuthRejected != null) {
|
|
103
|
+
const onAuthRejected = observers.onAuthRejected;
|
|
104
|
+
enkakuServer.events.on('handlerError', (info)=>{
|
|
105
|
+
if (info.category !== 'auth') return;
|
|
106
|
+
try {
|
|
107
|
+
onAuthRejected({
|
|
108
|
+
reason: info.error.message
|
|
109
|
+
});
|
|
110
|
+
} catch {
|
|
111
|
+
// Observer callbacks are diagnostics — never let them break the relay.
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
let retentionTimer;
|
|
116
|
+
// The sweep a fired tick is still running, so `dispose` can wait for it.
|
|
117
|
+
let sweep;
|
|
118
|
+
let runSweep;
|
|
119
|
+
if (options.retention != null) {
|
|
120
|
+
const { olderThanSeconds } = options.retention;
|
|
121
|
+
const intervalSeconds = options.retention.intervalSeconds ?? DEFAULT_RETENTION_INTERVAL_SECONDS;
|
|
122
|
+
const intervalMs = Math.max(1, Math.floor(intervalSeconds * 1000));
|
|
123
|
+
// A subscriber must never be reaped while a message it would still receive
|
|
124
|
+
// is retained, so the subscription window is clamped up to the message
|
|
125
|
+
// window.
|
|
126
|
+
const configuredSubscription = options.retention.subscriptionOlderThanSeconds ?? DEFAULT_SUBSCRIPTION_RETENTION_SECONDS;
|
|
127
|
+
const subscriptionOlderThanSeconds = Math.max(configuredSubscription, olderThanSeconds);
|
|
128
|
+
if (configuredSubscription < olderThanSeconds && observers?.onRetentionConfigClamped != null) {
|
|
129
|
+
try {
|
|
130
|
+
observers.onRetentionConfigClamped({
|
|
131
|
+
configuredSubscriptionOlderThanSeconds: configuredSubscription,
|
|
132
|
+
clampedToSeconds: subscriptionOlderThanSeconds
|
|
133
|
+
});
|
|
134
|
+
} catch {
|
|
135
|
+
// Observer callbacks are diagnostics — never let them break the relay.
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
retentionTimer = setInterval(()=>{
|
|
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({
|
|
150
|
+
olderThan: olderThanSeconds
|
|
151
|
+
}).catch((err)=>{
|
|
152
|
+
if (observers?.onError != null) {
|
|
153
|
+
try {
|
|
154
|
+
observers.onError({
|
|
155
|
+
stage: 'purge',
|
|
156
|
+
error: err
|
|
157
|
+
});
|
|
158
|
+
} catch {
|
|
159
|
+
// Observer callbacks are diagnostics — never let them break the relay.
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
await hubStore.purgeSubscribers({
|
|
164
|
+
olderThan: subscriptionOlderThanSeconds
|
|
165
|
+
}).then((purged)=>{
|
|
166
|
+
if (observers?.onSubscriptionPurge != null) {
|
|
167
|
+
try {
|
|
168
|
+
observers.onSubscriptionPurge({
|
|
169
|
+
subscriptionCount: purged.length
|
|
170
|
+
});
|
|
171
|
+
} catch {
|
|
172
|
+
// Observer callbacks are diagnostics — never let them break the relay.
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}).catch((err)=>{
|
|
176
|
+
if (observers?.onError != null) {
|
|
177
|
+
try {
|
|
178
|
+
observers.onError({
|
|
179
|
+
stage: 'subscription-purge',
|
|
180
|
+
error: err
|
|
181
|
+
});
|
|
182
|
+
} catch {
|
|
183
|
+
// Observer callbacks are diagnostics — never let them break the relay.
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
fetch: (request)=>transport.fetch(request),
|
|
191
|
+
hubStore,
|
|
192
|
+
pendingDeliveries: async ()=>{
|
|
193
|
+
const row = await kysely.selectFrom('kubun_hub_deliveries').select(({ fn })=>fn.countAll().as('count')).executeTakeFirstOrThrow();
|
|
194
|
+
return Number(row.count);
|
|
195
|
+
},
|
|
196
|
+
dispose: async ()=>{
|
|
197
|
+
if (retentionTimer != null) {
|
|
198
|
+
clearInterval(retentionTimer);
|
|
199
|
+
retentionTimer = undefined;
|
|
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;
|
|
204
|
+
if (unsubscribePurge != null) {
|
|
205
|
+
unsubscribePurge();
|
|
206
|
+
unsubscribePurge = undefined;
|
|
207
|
+
}
|
|
208
|
+
await enkakuServer.dispose();
|
|
209
|
+
if (ownsKysely) {
|
|
210
|
+
await kysely.destroy();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
export async function createRelay(options) {
|
|
216
|
+
const adapter = resolveAdapter(options.db);
|
|
217
|
+
const kysely = new Kysely({
|
|
218
|
+
dialect: adapter.dialect
|
|
219
|
+
});
|
|
220
|
+
return await createRelayInternal({
|
|
221
|
+
kysely,
|
|
222
|
+
adapter,
|
|
223
|
+
identity: options.identity,
|
|
224
|
+
auth: options.auth,
|
|
225
|
+
allowedOrigin: options.allowedOrigin,
|
|
226
|
+
retention: options.retention,
|
|
227
|
+
defaultRetention: options.defaultRetention,
|
|
228
|
+
maxRetention: options.maxRetention,
|
|
229
|
+
maxDepth: options.maxDepth,
|
|
230
|
+
observers: options.observers,
|
|
231
|
+
ownsKysely: true
|
|
232
|
+
});
|
|
233
|
+
}
|
package/lib/hub-store.d.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import type { HubStore } from '@enkaku/hub-protocol';
|
|
2
1
|
import type { Adapter } from '@kubun/db-adapter';
|
|
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: {
|
|
6
6
|
sequence_id: string;
|
|
7
7
|
sender_did: string;
|
|
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,14 +18,50 @@ 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>;
|
|
20
22
|
};
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
kubun_hub_last_resort_key_packages: {
|
|
24
|
+
owner_did: string;
|
|
25
|
+
key_package: string;
|
|
26
|
+
};
|
|
27
|
+
kubun_hub_subscriptions: {
|
|
28
|
+
topic_id: string;
|
|
29
|
+
subscriber_did: string;
|
|
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;
|
|
24
46
|
};
|
|
25
47
|
};
|
|
26
48
|
export type CreateHubStoreParams = {
|
|
27
49
|
kysely: Kysely<HubDatabase> | Promise<Kysely<HubDatabase>>;
|
|
28
50
|
adapter: Adapter;
|
|
51
|
+
defaultRetention?: number;
|
|
52
|
+
maxRetention?: number;
|
|
53
|
+
maxDepth?: number;
|
|
54
|
+
maxKeyPackagesPerDID?: number;
|
|
55
|
+
maxSubscriptionsPerDID?: number;
|
|
56
|
+
};
|
|
57
|
+
export type PurgeSubscriptionsParams = {
|
|
58
|
+
olderThan: number;
|
|
59
|
+
};
|
|
60
|
+
export type PurgedSubscription = {
|
|
61
|
+
topicID: string;
|
|
62
|
+
subscriberDID: string;
|
|
63
|
+
};
|
|
64
|
+
export type KubunHubStore = HubStore & {
|
|
65
|
+
purgeSubscribers(params: PurgeSubscriptionsParams): Promise<Array<PurgedSubscription>>;
|
|
29
66
|
};
|
|
30
|
-
export declare function createHubStore(params: CreateHubStoreParams):
|
|
67
|
+
export declare function createHubStore(params: CreateHubStoreParams): KubunHubStore;
|
package/lib/hub-store.js
CHANGED
|
@@ -1 +1,503 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { HeadMismatchError, KeyPackageQuotaExceededError, NotSubscribedError, RetentionExceededError, SubscriptionQuotaExceededError } from '@kumiai/hub-protocol';
|
|
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;
|
|
20
|
+
export function createHubStore(params) {
|
|
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;
|
|
27
|
+
// Lazy DB resolution -- accepts Kysely or Promise<Kysely> and caches the resolved instance
|
|
28
|
+
let resolvedDB;
|
|
29
|
+
async function db() {
|
|
30
|
+
if (resolvedDB == null) {
|
|
31
|
+
resolvedDB = await params.kysely;
|
|
32
|
+
}
|
|
33
|
+
return resolvedDB;
|
|
34
|
+
}
|
|
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();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
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
|
+
}
|
|
75
|
+
async function publish(publishParams) {
|
|
76
|
+
const kysely = await db();
|
|
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,
|
|
177
|
+
sequence_id: sequenceID,
|
|
178
|
+
recorded_at: Date.now()
|
|
179
|
+
}).execute();
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
sequenceID,
|
|
183
|
+
deduped: false
|
|
184
|
+
};
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
async function fetch(fetchParams) {
|
|
188
|
+
// Process acks first if provided
|
|
189
|
+
if (fetchParams.ack != null && fetchParams.ack.length > 0) {
|
|
190
|
+
await ack({
|
|
191
|
+
recipientDID: fetchParams.recipientDID,
|
|
192
|
+
sequenceIDs: fetchParams.ack
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
const kysely = await db();
|
|
196
|
+
let query = kysely.selectFrom('kubun_hub_deliveries').innerJoin('kubun_hub_messages', 'kubun_hub_messages.sequence_id', 'kubun_hub_deliveries.sequence_id').select([
|
|
197
|
+
'kubun_hub_messages.sequence_id',
|
|
198
|
+
'kubun_hub_messages.sender_did',
|
|
199
|
+
'kubun_hub_messages.topic_id',
|
|
200
|
+
'kubun_hub_messages.payload',
|
|
201
|
+
'kubun_hub_messages.retain'
|
|
202
|
+
]).where('kubun_hub_deliveries.recipient_did', '=', fetchParams.recipientDID).orderBy('kubun_hub_messages.sequence_id', 'asc');
|
|
203
|
+
if (fetchParams.after != null) {
|
|
204
|
+
query = query.where('kubun_hub_messages.sequence_id', '>', fetchParams.after);
|
|
205
|
+
}
|
|
206
|
+
const limit = fetchParams.limit;
|
|
207
|
+
if (limit != null) {
|
|
208
|
+
query = query.limit(limit + 1); // fetch one extra to determine hasMore
|
|
209
|
+
}
|
|
210
|
+
const rows = await query.execute();
|
|
211
|
+
let hasMore = false;
|
|
212
|
+
let resultRows = rows;
|
|
213
|
+
if (limit != null && rows.length > limit) {
|
|
214
|
+
hasMore = true;
|
|
215
|
+
resultRows = rows.slice(0, limit);
|
|
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.
|
|
223
|
+
const messages = resultRows.map((row)=>({
|
|
224
|
+
sequenceID: row.sequence_id,
|
|
225
|
+
senderDID: row.sender_did,
|
|
226
|
+
topicID: row.topic_id,
|
|
227
|
+
payload: toBytes(row.payload),
|
|
228
|
+
...row.retain === 'log' ? {
|
|
229
|
+
logPosition: row.sequence_id
|
|
230
|
+
} : {}
|
|
231
|
+
}));
|
|
232
|
+
const cursor = messages.length > 0 ? messages[messages.length - 1].sequenceID : null;
|
|
233
|
+
return {
|
|
234
|
+
messages,
|
|
235
|
+
cursor,
|
|
236
|
+
hasMore
|
|
237
|
+
};
|
|
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
|
+
}
|
|
285
|
+
async function ack(ackParams) {
|
|
286
|
+
if (ackParams.sequenceIDs.length === 0) return;
|
|
287
|
+
const kysely = await db();
|
|
288
|
+
// Remove delivery entries for this recipient
|
|
289
|
+
await kysely.deleteFrom('kubun_hub_deliveries').where('recipient_did', '=', ackParams.recipientDID).where('sequence_id', 'in', ackParams.sequenceIDs).execute();
|
|
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.
|
|
292
|
+
const withDeliveries = await kysely.selectFrom('kubun_hub_deliveries').select('sequence_id').where('sequence_id', 'in', ackParams.sequenceIDs).execute();
|
|
293
|
+
const stillDelivered = new Set(withDeliveries.map((r)=>r.sequence_id));
|
|
294
|
+
const fullyAcked = ackParams.sequenceIDs.filter((id)=>!stillDelivered.has(id));
|
|
295
|
+
if (fullyAcked.length > 0) {
|
|
296
|
+
await kysely.deleteFrom('kubun_hub_messages').where('sequence_id', 'in', fullyAcked).where('retain', '=', 'mailbox').execute();
|
|
297
|
+
}
|
|
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
|
+
}
|
|
309
|
+
async function purge(purgeParams) {
|
|
310
|
+
const kysely = await db();
|
|
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);
|
|
349
|
+
await events.emit('purge', {
|
|
350
|
+
sequenceIDs: removed
|
|
351
|
+
});
|
|
352
|
+
return removed;
|
|
353
|
+
}
|
|
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) {
|
|
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
|
+
}
|
|
371
|
+
await kysely.insertInto('kubun_hub_key_packages').values({
|
|
372
|
+
owner_did: ownerDID,
|
|
373
|
+
key_package: keyPackage,
|
|
374
|
+
not_after: notAfter ?? null
|
|
375
|
+
}).execute();
|
|
376
|
+
}
|
|
377
|
+
async function fetchKeyPackages(ownerDID, count) {
|
|
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);
|
|
383
|
+
const limit = count ?? 1;
|
|
384
|
+
// Atomic delete-returning: a non-atomic select+delete pair would let two
|
|
385
|
+
// concurrent fetches return the same key package twice.
|
|
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();
|
|
387
|
+
return rows.map((r)=>r.key_package);
|
|
388
|
+
}
|
|
389
|
+
async function countKeyPackages(ownerDID) {
|
|
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
|
+
}
|
|
429
|
+
// Upsert on the (topic_id, subscriber_did) primary key so re-subscribing an
|
|
430
|
+
// existing subscriber refreshes its activity timestamp and retention rather
|
|
431
|
+
// than failing or duplicating.
|
|
432
|
+
await kysely.insertInto('kubun_hub_subscriptions').values({
|
|
433
|
+
topic_id: topicID,
|
|
434
|
+
subscriber_did: subscriberDID,
|
|
435
|
+
last_active_at: Date.now(),
|
|
436
|
+
retention
|
|
437
|
+
}).onConflict((oc)=>oc.columns([
|
|
438
|
+
'topic_id',
|
|
439
|
+
'subscriber_did'
|
|
440
|
+
]).doUpdateSet({
|
|
441
|
+
last_active_at: (eb)=>eb.ref('excluded.last_active_at'),
|
|
442
|
+
retention: (eb)=>eb.ref('excluded.retention')
|
|
443
|
+
})).execute();
|
|
444
|
+
}
|
|
445
|
+
async function unsubscribe(subscriberDID, topicID) {
|
|
446
|
+
const kysely = await db();
|
|
447
|
+
await kysely.deleteFrom('kubun_hub_subscriptions').where('topic_id', '=', topicID).where('subscriber_did', '=', subscriberDID).execute();
|
|
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.
|
|
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();
|
|
453
|
+
if (pending.length === 0) return;
|
|
454
|
+
const sequenceIDs = pending.map((r)=>r.sequence_id);
|
|
455
|
+
await kysely.deleteFrom('kubun_hub_deliveries').where('recipient_did', '=', subscriberDID).where('sequence_id', 'in', sequenceIDs).execute();
|
|
456
|
+
const remaining = await kysely.selectFrom('kubun_hub_deliveries').select('sequence_id').where('sequence_id', 'in', sequenceIDs).execute();
|
|
457
|
+
const stillDelivered = new Set(remaining.map((r)=>r.sequence_id));
|
|
458
|
+
const orphaned = sequenceIDs.filter((id)=>!stillDelivered.has(id));
|
|
459
|
+
if (orphaned.length > 0) {
|
|
460
|
+
await kysely.deleteFrom('kubun_hub_messages').where('sequence_id', 'in', orphaned).where('retain', '=', 'mailbox').execute();
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
async function getSubscribers(topicID) {
|
|
464
|
+
const kysely = await db();
|
|
465
|
+
const rows = await kysely.selectFrom('kubun_hub_subscriptions').select('subscriber_did').where('topic_id', '=', topicID).execute();
|
|
466
|
+
return rows.map((r)=>r.subscriber_did);
|
|
467
|
+
}
|
|
468
|
+
async function purgeSubscribers(purgeParams) {
|
|
469
|
+
const kysely = await db();
|
|
470
|
+
// Strict `<`: a row refreshed exactly at the boundary survives. Subscription
|
|
471
|
+
// retention runs on a much longer window than message purge.
|
|
472
|
+
const threshold = Date.now() - purgeParams.olderThan * 1000;
|
|
473
|
+
// Atomic delete-and-return so the reported set exactly matches the rows
|
|
474
|
+
// actually removed — a subscriber who rejoins between evaluating and deleting
|
|
475
|
+
// is neither reaped nor over-reported. Mirrors `fetchKeyPackages`.
|
|
476
|
+
const deletedRows = await kysely.deleteFrom('kubun_hub_subscriptions').where('last_active_at', '<', threshold).returning([
|
|
477
|
+
'topic_id',
|
|
478
|
+
'subscriber_did'
|
|
479
|
+
]).execute();
|
|
480
|
+
return deletedRows.map((row)=>({
|
|
481
|
+
topicID: row.topic_id,
|
|
482
|
+
subscriberDID: row.subscriber_did
|
|
483
|
+
}));
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
events,
|
|
487
|
+
publish,
|
|
488
|
+
fetch,
|
|
489
|
+
fetchTopic,
|
|
490
|
+
ack,
|
|
491
|
+
purge,
|
|
492
|
+
trim,
|
|
493
|
+
purgeSubscribers,
|
|
494
|
+
storeKeyPackage,
|
|
495
|
+
fetchKeyPackages,
|
|
496
|
+
countKeyPackages,
|
|
497
|
+
storeLastResortKeyPackage,
|
|
498
|
+
fetchLastResortKeyPackage,
|
|
499
|
+
subscribe,
|
|
500
|
+
unsubscribe,
|
|
501
|
+
getSubscribers
|
|
502
|
+
};
|
|
503
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import type { Identity } from '@
|
|
1
|
+
import type { Identity } from '@kokuin/token';
|
|
2
2
|
import type { Adapter } from '@kubun/db-adapter';
|
|
3
3
|
import type { Kysely } from 'kysely';
|
|
4
4
|
import { type HubAuthConfig, type RelayRetentionConfig } from './create-relay.js';
|
|
5
5
|
import type { HubDatabase } from './hub-store.js';
|
|
6
6
|
import type { RelayObservers } from './observers.js';
|
|
7
7
|
export { type CreateRelayOptions, createRelay, createRelayInternal, type HubAuthConfig, type Relay, type RelayRetentionConfig, } from './create-relay.js';
|
|
8
|
-
export { type CreateHubStoreParams, createHubStore, type HubDatabase } from './hub-store.js';
|
|
8
|
+
export { type CreateHubStoreParams, createHubStore, type HubDatabase, type KubunHubStore, type PurgedSubscription, type PurgeSubscriptionsParams, } from './hub-store.js';
|
|
9
9
|
export { createHubMigrations, type MigrationContext } from './migrations.js';
|
|
10
|
-
export { decorateHubStore, type RelayObserverAckInfo, type RelayObserverAuthRejectedInfo, type RelayObserverErrorInfo, type RelayObserverErrorStage, type RelayObserverFetchInfo, type RelayObserverPurgeInfo, type RelayObserverStoreInfo, type RelayObservers, } from './observers.js';
|
|
10
|
+
export { decorateHubStore, type RelayObserverAckInfo, type RelayObserverAuthRejectedInfo, type RelayObserverErrorInfo, type RelayObserverErrorStage, type RelayObserverFetchInfo, type RelayObserverPurgeInfo, type RelayObserverRetentionConfigClampedInfo, type RelayObserverStoreInfo, type RelayObserverSubscriptionPurgeInfo, type RelayObservers, } from './observers.js';
|
|
11
11
|
export type HubOptions = {
|
|
12
12
|
/** Kysely instance for the hub database. */
|
|
13
13
|
kysely: Kysely<HubDatabase>;
|
|
@@ -18,14 +18,14 @@ export type HubOptions = {
|
|
|
18
18
|
/** Allowed CORS origin(s). Default: ['*']. */
|
|
19
19
|
allowedOrigin?: string | Array<string>;
|
|
20
20
|
/**
|
|
21
|
-
* Hub server identity. Required
|
|
22
|
-
*
|
|
21
|
+
* Hub server identity. Required — the server signs response tokens, derives
|
|
22
|
+
* the sender DID from each verified request, and clients pin its DID for
|
|
23
|
+
* TOFU verification.
|
|
23
24
|
*/
|
|
24
|
-
identity
|
|
25
|
+
identity: Identity;
|
|
25
26
|
/**
|
|
26
|
-
* Authentication configuration
|
|
27
|
-
*
|
|
28
|
-
* provided.
|
|
27
|
+
* Authentication configuration layered over the required identity. When
|
|
28
|
+
* omitted, no access rules are configured beyond signature verification.
|
|
29
29
|
*/
|
|
30
30
|
auth?: HubAuthConfig;
|
|
31
31
|
/** Optional retention scheduler. */
|
package/lib/index.js
CHANGED
|
@@ -1 +1,42 @@
|
|
|
1
|
-
import{serve
|
|
1
|
+
import { serve } from '@hono/node-server';
|
|
2
|
+
import getPort from 'get-port';
|
|
3
|
+
import { Hono } from 'hono';
|
|
4
|
+
import { createRelayInternal } from './create-relay.js';
|
|
5
|
+
export { createRelay, createRelayInternal } from './create-relay.js';
|
|
6
|
+
export { createHubStore } from './hub-store.js';
|
|
7
|
+
export { createHubMigrations } from './migrations.js';
|
|
8
|
+
export { decorateHubStore } from './observers.js';
|
|
9
|
+
export async function createHub(options) {
|
|
10
|
+
const relay = await createRelayInternal({
|
|
11
|
+
kysely: options.kysely,
|
|
12
|
+
adapter: options.adapter,
|
|
13
|
+
identity: options.identity,
|
|
14
|
+
auth: options.auth,
|
|
15
|
+
allowedOrigin: options.allowedOrigin,
|
|
16
|
+
retention: options.retention,
|
|
17
|
+
observers: options.observers,
|
|
18
|
+
ownsKysely: false
|
|
19
|
+
});
|
|
20
|
+
const app = new Hono();
|
|
21
|
+
app.get('/health', (c)=>c.json({
|
|
22
|
+
status: 'ok'
|
|
23
|
+
}));
|
|
24
|
+
app.post('*', async (c)=>relay.fetch(c.req.raw));
|
|
25
|
+
app.options('*', async (c)=>relay.fetch(c.req.raw));
|
|
26
|
+
const port = options.port ?? await getPort();
|
|
27
|
+
const url = `http://localhost:${port}`;
|
|
28
|
+
const httpServer = await new Promise((resolve)=>{
|
|
29
|
+
const server = serve({
|
|
30
|
+
fetch: app.fetch,
|
|
31
|
+
port
|
|
32
|
+
}, ()=>resolve(server));
|
|
33
|
+
});
|
|
34
|
+
return {
|
|
35
|
+
port,
|
|
36
|
+
url,
|
|
37
|
+
dispose: async ()=>{
|
|
38
|
+
await relay.dispose();
|
|
39
|
+
httpServer.close();
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
package/lib/migrations.js
CHANGED
|
@@ -1 +1,68 @@
|
|
|
1
|
-
|
|
1
|
+
import { sql } from 'kysely';
|
|
2
|
+
export function createHubMigrations(ctx) {
|
|
3
|
+
const t = ctx.types;
|
|
4
|
+
return {
|
|
5
|
+
'001_create_hub_tables': {
|
|
6
|
+
async up (db) {
|
|
7
|
+
// Hub messages
|
|
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();
|
|
10
|
+
// Hub deliveries
|
|
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', [
|
|
12
|
+
'sequence_id',
|
|
13
|
+
'recipient_did'
|
|
14
|
+
]).execute();
|
|
15
|
+
await db.schema.createIndex('idx_hub_deliveries_recipient').ifNotExists().on('kubun_hub_deliveries').column('recipient_did').execute();
|
|
16
|
+
// Hub key packages
|
|
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();
|
|
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();
|
|
24
|
+
// Hub subscriptions
|
|
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', [
|
|
28
|
+
'topic_id',
|
|
29
|
+
'subscriber_did'
|
|
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();
|
|
55
|
+
},
|
|
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();
|
|
61
|
+
await db.schema.dropTable('kubun_hub_subscriptions').ifExists().execute();
|
|
62
|
+
await db.schema.dropTable('kubun_hub_key_packages').ifExists().execute();
|
|
63
|
+
await db.schema.dropTable('kubun_hub_deliveries').ifExists().execute();
|
|
64
|
+
await db.schema.dropTable('kubun_hub_messages').ifExists().execute();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
package/lib/observers.d.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { KubunHubStore } from './hub-store.js';
|
|
2
2
|
export type RelayObserverStoreInfo = {
|
|
3
3
|
senderDID: string;
|
|
4
|
-
|
|
5
|
-
recipientCount: number;
|
|
4
|
+
topicID: string;
|
|
6
5
|
sequenceID: string;
|
|
7
6
|
};
|
|
8
7
|
export type RelayObserverFetchInfo = {
|
|
@@ -16,10 +15,17 @@ export type RelayObserverAckInfo = {
|
|
|
16
15
|
export type RelayObserverPurgeInfo = {
|
|
17
16
|
sequenceCount: number;
|
|
18
17
|
};
|
|
18
|
+
export type RelayObserverSubscriptionPurgeInfo = {
|
|
19
|
+
subscriptionCount: number;
|
|
20
|
+
};
|
|
19
21
|
export type RelayObserverAuthRejectedInfo = {
|
|
20
22
|
reason: string;
|
|
21
23
|
};
|
|
22
|
-
export type
|
|
24
|
+
export type RelayObserverRetentionConfigClampedInfo = {
|
|
25
|
+
configuredSubscriptionOlderThanSeconds: number;
|
|
26
|
+
clampedToSeconds: number;
|
|
27
|
+
};
|
|
28
|
+
export type RelayObserverErrorStage = 'store' | 'fetch' | 'ack' | 'purge' | 'subscription-purge';
|
|
23
29
|
export type RelayObserverErrorInfo = {
|
|
24
30
|
stage: RelayObserverErrorStage;
|
|
25
31
|
error: unknown;
|
|
@@ -29,7 +35,9 @@ export type RelayObservers = {
|
|
|
29
35
|
onFetch?: (info: RelayObserverFetchInfo) => void;
|
|
30
36
|
onAck?: (info: RelayObserverAckInfo) => void;
|
|
31
37
|
onPurge?: (info: RelayObserverPurgeInfo) => void;
|
|
38
|
+
onSubscriptionPurge?: (info: RelayObserverSubscriptionPurgeInfo) => void;
|
|
32
39
|
onAuthRejected?: (info: RelayObserverAuthRejectedInfo) => void;
|
|
40
|
+
onRetentionConfigClamped?: (info: RelayObserverRetentionConfigClampedInfo) => void;
|
|
33
41
|
onError?: (info: RelayObserverErrorInfo) => void;
|
|
34
42
|
};
|
|
35
|
-
export declare function decorateHubStore(store:
|
|
43
|
+
export declare function decorateHubStore(store: KubunHubStore, observers?: RelayObservers): KubunHubStore;
|
package/lib/observers.js
CHANGED
|
@@ -1 +1,76 @@
|
|
|
1
|
-
function
|
|
1
|
+
function safeCall(callback, info) {
|
|
2
|
+
if (callback == null) return;
|
|
3
|
+
try {
|
|
4
|
+
callback(info);
|
|
5
|
+
} catch {
|
|
6
|
+
// Observer callbacks are diagnostics — never let them break the relay.
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function decorateHubStore(store, observers) {
|
|
10
|
+
if (observers == null) return store;
|
|
11
|
+
const { onStore, onFetch, onAck, onError } = observers;
|
|
12
|
+
const wrappedStore = {
|
|
13
|
+
events: store.events,
|
|
14
|
+
storeKeyPackage: store.storeKeyPackage.bind(store),
|
|
15
|
+
fetchKeyPackages: store.fetchKeyPackages.bind(store),
|
|
16
|
+
countKeyPackages: store.countKeyPackages.bind(store),
|
|
17
|
+
storeLastResortKeyPackage: store.storeLastResortKeyPackage.bind(store),
|
|
18
|
+
fetchLastResortKeyPackage: store.fetchLastResortKeyPackage.bind(store),
|
|
19
|
+
subscribe: store.subscribe.bind(store),
|
|
20
|
+
unsubscribe: store.unsubscribe.bind(store),
|
|
21
|
+
getSubscribers: store.getSubscribers.bind(store),
|
|
22
|
+
purgeSubscribers: store.purgeSubscribers.bind(store),
|
|
23
|
+
purge: store.purge.bind(store),
|
|
24
|
+
fetchTopic: store.fetchTopic.bind(store),
|
|
25
|
+
trim: store.trim.bind(store),
|
|
26
|
+
async publish (params) {
|
|
27
|
+
try {
|
|
28
|
+
const result = await store.publish(params);
|
|
29
|
+
safeCall(onStore, {
|
|
30
|
+
senderDID: params.senderDID,
|
|
31
|
+
topicID: params.topicID,
|
|
32
|
+
sequenceID: result.sequenceID
|
|
33
|
+
});
|
|
34
|
+
return result;
|
|
35
|
+
} catch (error) {
|
|
36
|
+
safeCall(onError, {
|
|
37
|
+
stage: 'store',
|
|
38
|
+
error
|
|
39
|
+
});
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
async fetch (params) {
|
|
44
|
+
try {
|
|
45
|
+
const result = await store.fetch(params);
|
|
46
|
+
safeCall(onFetch, {
|
|
47
|
+
recipientDID: params.recipientDID,
|
|
48
|
+
messageCount: result.messages.length
|
|
49
|
+
});
|
|
50
|
+
return result;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
safeCall(onError, {
|
|
53
|
+
stage: 'fetch',
|
|
54
|
+
error
|
|
55
|
+
});
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
async ack (params) {
|
|
60
|
+
try {
|
|
61
|
+
await store.ack(params);
|
|
62
|
+
safeCall(onAck, {
|
|
63
|
+
recipientDID: params.recipientDID,
|
|
64
|
+
sequenceCount: params.sequenceIDs.length
|
|
65
|
+
});
|
|
66
|
+
} catch (error) {
|
|
67
|
+
safeCall(onError, {
|
|
68
|
+
stage: 'ack',
|
|
69
|
+
error
|
|
70
|
+
});
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
return wrappedStore;
|
|
76
|
+
}
|
package/package.json
CHANGED
|
@@ -1,45 +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
|
-
"@enkaku/
|
|
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/db-provider": "^0.
|
|
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"
|
|
29
30
|
},
|
|
30
31
|
"devDependencies": {
|
|
31
|
-
"@enkaku/
|
|
32
|
-
"@enkaku/
|
|
33
|
-
"@enkaku/
|
|
34
|
-
"@enkaku/
|
|
35
|
-
"@
|
|
36
|
-
"@
|
|
37
|
-
"@
|
|
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"
|
|
38
43
|
},
|
|
39
44
|
"scripts": {
|
|
45
|
+
"build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
|
|
40
46
|
"build:clean": "del lib",
|
|
41
|
-
"build:js": "swc src -d ./lib --config-file ../../swc.json --strip-leading-paths",
|
|
47
|
+
"build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
|
|
42
48
|
"build:types": "tsc --emitDeclarationOnly --skipLibCheck",
|
|
43
|
-
"
|
|
49
|
+
"test:types": "tsc --noEmit -p tsconfig.test.json"
|
|
44
50
|
}
|
|
45
51
|
}
|