@kubun/hub 0.10.0 → 0.11.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.
@@ -1,9 +1,8 @@
1
- import type { HubStore } from '@enkaku/hub-protocol';
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,10 +14,11 @@ 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?: Identity;
21
+ identity: Identity;
22
22
  auth?: HubAuthConfig;
23
23
  allowedOrigin?: string | Array<string>;
24
24
  retention?: RelayRetentionConfig;
@@ -26,14 +26,14 @@ export type CreateRelayOptions = {
26
26
  };
27
27
  export type Relay = {
28
28
  fetch: (request: Request) => Promise<Response>;
29
- hubStore: HubStore;
29
+ hubStore: KubunHubStore;
30
30
  pendingDeliveries: () => Promise<number>;
31
31
  dispose: () => Promise<void>;
32
32
  };
33
33
  export type RelayInternalOptions = {
34
34
  kysely: Kysely<HubDatabase>;
35
35
  adapter: Adapter;
36
- identity?: Identity;
36
+ identity: Identity;
37
37
  auth?: HubAuthConfig;
38
38
  allowedOrigin?: string | Array<string>;
39
39
  retention?: RelayRetentionConfig;
@@ -1 +1,214 @@
1
- import{ServerTransport as e}from"@enkaku/http-server-transport";import{createHandlers as t,HubClientRegistry as r}from"@enkaku/hub-server";import{Server as n}from"@enkaku/server";import{resolveAdapter as o}from"@kubun/db-provider";import{Kysely as i}from"kysely";import{Migrator as a}from"kysely/migration";import{createHubStore as l}from"./hub-store.js";import{createHubMigrations as s}from"./migrations.js";import{decorateHubStore as u}from"./observers.js";export async function createRelayInternal(o){var i;let c,d,{kysely:m,adapter:h,observers:y,ownsKysely:p}=o;if(null!=o.auth&&null==o.identity)throw Error("createRelay: 'identity' is required when 'auth' is set");let f=s({types:h.types,functions:h.functions}),w=new a({db:m,provider:{getMigrations:async()=>f},migrationTableName:"kubun_hub_migration",migrationLockTableName:"kubun_hub_migration_lock"}),{error:g}=await w.migrateToLatest();if(null!=g)throw g;let b=new r,v=l({kysely:m,adapter:h}),k=u(v,y);if(y?.onPurge!=null){let e=y.onPurge;c=v.events.on("purge",t=>{try{e({sequenceCount:t.sequenceIDs.length})}catch{}})}let _=t({registry:b,store:k}),R=new e({allowedOrigin:null==(i=o.allowedOrigin)?["*"]:"string"==typeof i?i.split(",").map(e=>e.trim()):i}),T=function(e){if(null==e)return{};switch(e.mode){case"open":return{"*":{allow:!0}};case"allowlist":{let{allowedDIDs:t,isAllowed:r}=e;if(null!=r)return{"*":{allow:async e=>{let n=e.payload.iss;return!!t?.includes(n)||await r(n)}}};return{"*":{allow:t??[]}}}}}(o.auth),j=new n(o.identity?{identity:o.identity,accessRules:T,handlers:_,transports:[R]}:{handlers:_,transports:[R]});if(y?.onAuthRejected!=null){let e=y.onAuthRejected;j.events.on("handlerError",t=>{if("auth"===t.category)try{e({reason:t.error.message})}catch{}})}if(null!=o.retention){let{olderThanSeconds:e}=o.retention;d=setInterval(()=>{k.purge({olderThan:e}).catch(e=>{if(y?.onError!=null)try{y.onError({stage:"purge",error:e})}catch{}})},Math.max(1,Math.floor(1e3*(o.retention.intervalSeconds??3600))))}return{fetch:e=>R.fetch(e),hubStore:k,pendingDeliveries:async()=>Number((await m.selectFrom("kubun_hub_deliveries").select(({fn:e})=>e.countAll().as("count")).executeTakeFirstOrThrow()).count),dispose:async()=>{null!=d&&(clearInterval(d),d=void 0),null!=c&&(c(),c=void 0),await j.dispose(),p&&await m.destroy()}}}export async function createRelay(e){let t=o(e.db),r=new i({dialect:t.dialect});return await createRelayInternal({kysely:r,adapter:t,identity:e.identity,auth:e.auth,allowedOrigin:e.allowedOrigin,retention:e.retention,observers:e.observers,ownsKysely:!0})}
1
+ import { ServerTransport } from '@enkaku/http-serve';
2
+ import { Server } from '@enkaku/server';
3
+ import { resolveAdapter } from '@kubun/db-provider';
4
+ import { resolveAllowedOrigin } from '@kubun/http-util';
5
+ import { createHandlers as createHubHandlers, HubClientRegistry } from '@kumiai/hub-server';
6
+ import { Kysely } from 'kysely';
7
+ import { Migrator } from 'kysely/migration';
8
+ import { createHubStore } from './hub-store.js';
9
+ import { createHubMigrations } from './migrations.js';
10
+ import { decorateHubStore } from './observers.js';
11
+ const DEFAULT_RETENTION_INTERVAL_SECONDS = 3600;
12
+ const DEFAULT_SUBSCRIPTION_RETENTION_SECONDS = 90 * 24 * 60 * 60;
13
+ function resolveAccessRules(auth) {
14
+ if (auth == null) return {};
15
+ switch(auth.mode){
16
+ case 'open':
17
+ return {
18
+ '*': {
19
+ allow: true
20
+ }
21
+ };
22
+ case 'allowlist':
23
+ {
24
+ const { allowedDIDs, isAllowed } = auth;
25
+ if (isAllowed != null) {
26
+ const wrapped = async (ctx)=>{
27
+ const did = ctx.payload.iss;
28
+ if (allowedDIDs?.includes(did)) return true;
29
+ return await isAllowed(did);
30
+ };
31
+ return {
32
+ '*': {
33
+ allow: wrapped
34
+ }
35
+ };
36
+ }
37
+ return {
38
+ '*': {
39
+ allow: allowedDIDs ?? []
40
+ }
41
+ };
42
+ }
43
+ }
44
+ }
45
+ export async function createRelayInternal(options) {
46
+ const { kysely, adapter, observers, ownsKysely } = options;
47
+ const migrationCtx = {
48
+ types: adapter.types,
49
+ functions: adapter.functions
50
+ };
51
+ const migrations = createHubMigrations(migrationCtx);
52
+ const migrationProvider = {
53
+ getMigrations: async ()=>migrations
54
+ };
55
+ const migrator = new Migrator({
56
+ db: kysely,
57
+ provider: migrationProvider,
58
+ migrationTableName: 'kubun_hub_migration',
59
+ migrationLockTableName: 'kubun_hub_migration_lock'
60
+ });
61
+ const { error: migrationError } = await migrator.migrateToLatest();
62
+ if (migrationError != null) {
63
+ throw migrationError;
64
+ }
65
+ const registry = new HubClientRegistry();
66
+ const rawStore = createHubStore({
67
+ kysely,
68
+ adapter
69
+ });
70
+ const hubStore = decorateHubStore(rawStore, observers);
71
+ let unsubscribePurge;
72
+ if (observers?.onPurge != null) {
73
+ const onPurge = observers.onPurge;
74
+ unsubscribePurge = rawStore.events.on('purge', (info)=>{
75
+ try {
76
+ onPurge({
77
+ sequenceCount: info.sequenceIDs.length
78
+ });
79
+ } catch {
80
+ // Observer callbacks are diagnostics — never let them break the relay.
81
+ }
82
+ });
83
+ }
84
+ const hubHandlers = createHubHandlers({
85
+ registry,
86
+ store: hubStore
87
+ });
88
+ const allowedOrigin = resolveAllowedOrigin(options.allowedOrigin);
89
+ const transport = new ServerTransport({
90
+ allowedOrigin
91
+ });
92
+ const accessRules = resolveAccessRules(options.auth);
93
+ const serverParams = {
94
+ identity: options.identity,
95
+ accessRules,
96
+ handlers: hubHandlers,
97
+ transports: [
98
+ transport
99
+ ]
100
+ };
101
+ const enkakuServer = new Server(serverParams);
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
+ if (options.retention != null) {
117
+ const { olderThanSeconds } = options.retention;
118
+ const intervalSeconds = options.retention.intervalSeconds ?? DEFAULT_RETENTION_INTERVAL_SECONDS;
119
+ const intervalMs = Math.max(1, Math.floor(intervalSeconds * 1000));
120
+ // A subscriber must never be reaped while a message it would still receive
121
+ // is retained, so the subscription window is clamped up to the message
122
+ // window.
123
+ const configuredSubscription = options.retention.subscriptionOlderThanSeconds ?? DEFAULT_SUBSCRIPTION_RETENTION_SECONDS;
124
+ const subscriptionOlderThanSeconds = Math.max(configuredSubscription, olderThanSeconds);
125
+ if (configuredSubscription < olderThanSeconds && observers?.onRetentionConfigClamped != null) {
126
+ try {
127
+ observers.onRetentionConfigClamped({
128
+ configuredSubscriptionOlderThanSeconds: configuredSubscription,
129
+ clampedToSeconds: subscriptionOlderThanSeconds
130
+ });
131
+ } catch {
132
+ // Observer callbacks are diagnostics — never let them break the relay.
133
+ }
134
+ }
135
+ retentionTimer = setInterval(()=>{
136
+ void hubStore.purge({
137
+ olderThan: olderThanSeconds
138
+ }).catch((err)=>{
139
+ if (observers?.onError != null) {
140
+ try {
141
+ observers.onError({
142
+ stage: 'purge',
143
+ error: err
144
+ });
145
+ } catch {
146
+ // Observer callbacks are diagnostics — never let them break the relay.
147
+ }
148
+ }
149
+ });
150
+ void hubStore.purgeSubscribers({
151
+ olderThan: subscriptionOlderThanSeconds
152
+ }).then((purged)=>{
153
+ if (observers?.onSubscriptionPurge != null) {
154
+ try {
155
+ observers.onSubscriptionPurge({
156
+ subscriptionCount: purged.length
157
+ });
158
+ } catch {
159
+ // Observer callbacks are diagnostics — never let them break the relay.
160
+ }
161
+ }
162
+ }).catch((err)=>{
163
+ if (observers?.onError != null) {
164
+ try {
165
+ observers.onError({
166
+ stage: 'subscription-purge',
167
+ error: err
168
+ });
169
+ } catch {
170
+ // Observer callbacks are diagnostics — never let them break the relay.
171
+ }
172
+ }
173
+ });
174
+ }, intervalMs);
175
+ }
176
+ return {
177
+ fetch: (request)=>transport.fetch(request),
178
+ hubStore,
179
+ pendingDeliveries: async ()=>{
180
+ const row = await kysely.selectFrom('kubun_hub_deliveries').select(({ fn })=>fn.countAll().as('count')).executeTakeFirstOrThrow();
181
+ return Number(row.count);
182
+ },
183
+ dispose: async ()=>{
184
+ if (retentionTimer != null) {
185
+ clearInterval(retentionTimer);
186
+ retentionTimer = undefined;
187
+ }
188
+ if (unsubscribePurge != null) {
189
+ unsubscribePurge();
190
+ unsubscribePurge = undefined;
191
+ }
192
+ await enkakuServer.dispose();
193
+ if (ownsKysely) {
194
+ await kysely.destroy();
195
+ }
196
+ }
197
+ };
198
+ }
199
+ export async function createRelay(options) {
200
+ const adapter = resolveAdapter(options.db);
201
+ const kysely = new Kysely({
202
+ dialect: adapter.dialect
203
+ });
204
+ return await createRelayInternal({
205
+ kysely,
206
+ adapter,
207
+ identity: options.identity,
208
+ auth: options.auth,
209
+ allowedOrigin: options.allowedOrigin,
210
+ retention: options.retention,
211
+ observers: options.observers,
212
+ ownsKysely: true
213
+ });
214
+ }
@@ -1,11 +1,11 @@
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
- group_id: string | null;
8
+ topic_id: string;
9
9
  payload: Uint8Array;
10
10
  stored_at: number;
11
11
  };
@@ -18,13 +18,24 @@ export type HubDatabase = {
18
18
  owner_did: string;
19
19
  key_package: string;
20
20
  };
21
- kubun_hub_group_members: {
22
- group_id: string;
23
- member_did: string;
21
+ kubun_hub_subscriptions: {
22
+ topic_id: string;
23
+ subscriber_did: string;
24
+ last_active_at: number;
24
25
  };
25
26
  };
26
27
  export type CreateHubStoreParams = {
27
28
  kysely: Kysely<HubDatabase> | Promise<Kysely<HubDatabase>>;
28
29
  adapter: Adapter;
29
30
  };
30
- export declare function createHubStore(params: CreateHubStoreParams): HubStore;
31
+ export type PurgeSubscriptionsParams = {
32
+ olderThan: number;
33
+ };
34
+ export type PurgedSubscription = {
35
+ topicID: string;
36
+ subscriberDID: string;
37
+ };
38
+ export type KubunHubStore = HubStore & {
39
+ purgeSubscribers(params: PurgeSubscriptionsParams): Promise<Array<PurgedSubscription>>;
40
+ };
41
+ export declare function createHubStore(params: CreateHubStoreParams): KubunHubStore;
package/lib/hub-store.js CHANGED
@@ -1 +1,205 @@
1
- import{EventEmitter as e}from"@enkaku/event";export function createHubStore(u){let n,t,{adapter:a}=u;async function i(){return null==n&&(n=await u.kysely),n}async function s(){let e=await i();if(null==t){let u=await e.selectFrom("kubun_hub_messages").select(e.fn.max("sequence_id").as("max_id")).executeTakeFirst();t=u?.max_id!=null?Number.parseInt(u.max_id,10):0}return String(++t).padStart(12,"0")}let r=new e;async function _(e){if(0===e.sequenceIDs.length)return;let u=await i();await u.deleteFrom("kubun_hub_deliveries").where("recipient_did","=",e.recipientDID).where("sequence_id","in",e.sequenceIDs).execute();let n=new Set((await u.selectFrom("kubun_hub_deliveries").select("sequence_id").where("sequence_id","in",e.sequenceIDs).execute()).map(e=>e.sequence_id)),t=e.sequenceIDs.filter(e=>!n.has(e));t.length>0&&await u.deleteFrom("kubun_hub_messages").where("sequence_id","in",t).execute()}async function c(e){let u=await i(),n=Date.now()-1e3*e.olderThan,t=(await u.selectFrom("kubun_hub_messages").select("sequence_id").where("stored_at","<=",n).execute()).map(e=>e.sequence_id);return 0===t.length?[]:(await u.deleteFrom("kubun_hub_deliveries").where("sequence_id","in",t).execute(),await u.deleteFrom("kubun_hub_messages").where("sequence_id","in",t).execute(),await r.emit("purge",{sequenceIDs:t}),t)}return{events:r,store:async function(e){let u=await s(),n=Date.now(),t=await i();return await t.insertInto("kubun_hub_messages").values({sequence_id:u,sender_did:e.senderDID,group_id:e.groupID??null,payload:a.encodeBinary(e.payload),stored_at:n}).execute(),e.recipients.length>0&&await t.insertInto("kubun_hub_deliveries").values(e.recipients.map(e=>({sequence_id:u,recipient_did:e}))).execute(),u},fetch:async function(e){null!=e.ack&&e.ack.length>0&&await _({recipientDID:e.recipientDID,sequenceIDs:e.ack});let u=(await i()).selectFrom("kubun_hub_deliveries").innerJoin("kubun_hub_messages","kubun_hub_messages.sequence_id","kubun_hub_deliveries.sequence_id").select(["kubun_hub_messages.sequence_id","kubun_hub_messages.sender_did","kubun_hub_messages.group_id","kubun_hub_messages.payload"]).where("kubun_hub_deliveries.recipient_did","=",e.recipientDID).orderBy("kubun_hub_messages.sequence_id","asc");null!=e.after&&(u=u.where("kubun_hub_messages.sequence_id",">",e.after));let n=e.limit;null!=n&&(u=u.limit(n+1));let t=await u.execute(),a=!1,s=t;null!=n&&t.length>n&&(a=!0,s=t.slice(0,n));let r=s.map(e=>({sequenceID:e.sequence_id,senderDID:e.sender_did,groupID:e.group_id??void 0,payload:e.payload})),c=r.length>0?r[r.length-1].sequenceID:null;return{messages:r,cursor:c,hasMore:a}},ack:_,purge:c,storeKeyPackage:async function(e,u){let n=await i();await n.insertInto("kubun_hub_key_packages").values({owner_did:e,key_package:u}).execute()},fetchKeyPackages:async function(e,u){let n=await i();return(await n.deleteFrom("kubun_hub_key_packages").where("id","in",n.selectFrom("kubun_hub_key_packages").select("id").where("owner_did","=",e).orderBy("id","asc").limit(u??1)).returning("key_package").execute()).map(e=>e.key_package)},setGroupMembers:async function(e,u){let n=await i();await n.deleteFrom("kubun_hub_group_members").where("group_id","=",e).execute(),u.length>0&&await n.insertInto("kubun_hub_group_members").values(u.map(u=>({group_id:e,member_did:u}))).execute()},getGroupMembers:async function(e){let u=await i();return(await u.selectFrom("kubun_hub_group_members").select("member_did").where("group_id","=",e).execute()).map(e=>e.member_did)}}}
1
+ import { EventEmitter } from '@sozai/event';
2
+ export function createHubStore(params) {
3
+ const { adapter } = params;
4
+ // Lazy DB resolution -- accepts Kysely or Promise<Kysely> and caches the resolved instance
5
+ let resolvedDB;
6
+ async function db() {
7
+ if (resolvedDB == null) {
8
+ resolvedDB = await params.kysely;
9
+ }
10
+ return resolvedDB;
11
+ }
12
+ // Lazy sequence counter -- initialized from DB on first publish() call
13
+ let sequenceCounter;
14
+ async function nextSequenceID() {
15
+ const kysely = await db();
16
+ if (sequenceCounter == null) {
17
+ const maxRow = await kysely.selectFrom('kubun_hub_messages').select(kysely.fn.max('sequence_id').as('max_id')).executeTakeFirst();
18
+ sequenceCounter = maxRow?.max_id != null ? Number.parseInt(maxRow.max_id, 10) : 0;
19
+ }
20
+ sequenceCounter++;
21
+ return String(sequenceCounter).padStart(12, '0');
22
+ }
23
+ const events = new EventEmitter();
24
+ async function publish(publishParams) {
25
+ const sequenceID = await nextSequenceID();
26
+ const kysely = await db();
27
+ // Recipients = current subscribers minus the sender. Zero recipients (no
28
+ // subscribers, or only the sender) means there is nothing to deliver, so the
29
+ // sequence is consumed but no message or delivery rows are stored.
30
+ const subscribers = await getSubscribers(publishParams.topicID);
31
+ const recipients = subscribers.filter((did)=>did !== publishParams.senderDID);
32
+ if (recipients.length === 0) {
33
+ return sequenceID;
34
+ }
35
+ const storedAt = Date.now();
36
+ // The message row and its delivery rows are written in one transaction: a
37
+ // partial write (message stored, deliveries failed) would orphan a message
38
+ // with zero deliveries — never fetched, never acked, never GC'd by `ack`,
39
+ // reclaimed only by the age-based purge.
40
+ await kysely.transaction().execute(async (trx)=>{
41
+ await trx.insertInto('kubun_hub_messages').values({
42
+ sequence_id: sequenceID,
43
+ sender_did: publishParams.senderDID,
44
+ topic_id: publishParams.topicID,
45
+ payload: adapter.encodeBinary(publishParams.payload),
46
+ stored_at: storedAt
47
+ }).execute();
48
+ await trx.insertInto('kubun_hub_deliveries').values(recipients.map((did)=>({
49
+ sequence_id: sequenceID,
50
+ recipient_did: did
51
+ }))).execute();
52
+ });
53
+ return sequenceID;
54
+ }
55
+ async function fetch(fetchParams) {
56
+ // Process acks first if provided
57
+ if (fetchParams.ack != null && fetchParams.ack.length > 0) {
58
+ await ack({
59
+ recipientDID: fetchParams.recipientDID,
60
+ sequenceIDs: fetchParams.ack
61
+ });
62
+ }
63
+ const kysely = await db();
64
+ let query = kysely.selectFrom('kubun_hub_deliveries').innerJoin('kubun_hub_messages', 'kubun_hub_messages.sequence_id', 'kubun_hub_deliveries.sequence_id').select([
65
+ 'kubun_hub_messages.sequence_id',
66
+ 'kubun_hub_messages.sender_did',
67
+ 'kubun_hub_messages.topic_id',
68
+ 'kubun_hub_messages.payload'
69
+ ]).where('kubun_hub_deliveries.recipient_did', '=', fetchParams.recipientDID).orderBy('kubun_hub_messages.sequence_id', 'asc');
70
+ if (fetchParams.after != null) {
71
+ query = query.where('kubun_hub_messages.sequence_id', '>', fetchParams.after);
72
+ }
73
+ const limit = fetchParams.limit;
74
+ if (limit != null) {
75
+ query = query.limit(limit + 1); // fetch one extra to determine hasMore
76
+ }
77
+ const rows = await query.execute();
78
+ let hasMore = false;
79
+ let resultRows = rows;
80
+ if (limit != null && rows.length > limit) {
81
+ hasMore = true;
82
+ resultRows = rows.slice(0, limit);
83
+ }
84
+ const messages = resultRows.map((row)=>({
85
+ sequenceID: row.sequence_id,
86
+ senderDID: row.sender_did,
87
+ topicID: row.topic_id,
88
+ payload: row.payload
89
+ }));
90
+ const cursor = messages.length > 0 ? messages[messages.length - 1].sequenceID : null;
91
+ return {
92
+ messages,
93
+ cursor,
94
+ hasMore
95
+ };
96
+ }
97
+ async function ack(ackParams) {
98
+ if (ackParams.sequenceIDs.length === 0) return;
99
+ const kysely = await db();
100
+ // Remove delivery entries for this recipient
101
+ await kysely.deleteFrom('kubun_hub_deliveries').where('recipient_did', '=', ackParams.recipientDID).where('sequence_id', 'in', ackParams.sequenceIDs).execute();
102
+ // Clean up messages with no remaining deliveries (all recipients acked)
103
+ const withDeliveries = await kysely.selectFrom('kubun_hub_deliveries').select('sequence_id').where('sequence_id', 'in', ackParams.sequenceIDs).execute();
104
+ const stillDelivered = new Set(withDeliveries.map((r)=>r.sequence_id));
105
+ const fullyAcked = ackParams.sequenceIDs.filter((id)=>!stillDelivered.has(id));
106
+ if (fullyAcked.length > 0) {
107
+ await kysely.deleteFrom('kubun_hub_messages').where('sequence_id', 'in', fullyAcked).execute();
108
+ }
109
+ }
110
+ async function purge(purgeParams) {
111
+ const kysely = await db();
112
+ const threshold = Date.now() - purgeParams.olderThan * 1000;
113
+ const oldMessages = await kysely.selectFrom('kubun_hub_messages').select('sequence_id').where('stored_at', '<=', threshold).execute();
114
+ const sequenceIDs = oldMessages.map((m)=>m.sequence_id);
115
+ if (sequenceIDs.length === 0) return [];
116
+ await kysely.deleteFrom('kubun_hub_deliveries').where('sequence_id', 'in', sequenceIDs).execute();
117
+ await kysely.deleteFrom('kubun_hub_messages').where('sequence_id', 'in', sequenceIDs).execute();
118
+ await events.emit('purge', {
119
+ sequenceIDs
120
+ });
121
+ return sequenceIDs;
122
+ }
123
+ async function storeKeyPackage(ownerDID, keyPackage) {
124
+ const kysely = await db();
125
+ await kysely.insertInto('kubun_hub_key_packages').values({
126
+ owner_did: ownerDID,
127
+ key_package: keyPackage
128
+ }).execute();
129
+ }
130
+ async function fetchKeyPackages(ownerDID, count) {
131
+ const kysely = await db();
132
+ const limit = count ?? 1;
133
+ // Atomic delete-returning: a non-atomic select+delete pair would let two
134
+ // concurrent fetches return the same key package twice.
135
+ 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
+ return rows.map((r)=>r.key_package);
137
+ }
138
+ async function subscribe(subscriberDID, topicID) {
139
+ const kysely = await db();
140
+ // Upsert on the (topic_id, subscriber_did) primary key so re-subscribing an
141
+ // existing subscriber refreshes its activity timestamp rather than failing
142
+ // or duplicating.
143
+ await kysely.insertInto('kubun_hub_subscriptions').values({
144
+ topic_id: topicID,
145
+ subscriber_did: subscriberDID,
146
+ last_active_at: Date.now()
147
+ }).onConflict((oc)=>oc.columns([
148
+ 'topic_id',
149
+ 'subscriber_did'
150
+ ]).doUpdateSet({
151
+ last_active_at: (eb)=>eb.ref('excluded.last_active_at')
152
+ })).execute();
153
+ }
154
+ async function unsubscribe(subscriberDID, topicID) {
155
+ const kysely = await db();
156
+ await kysely.deleteFrom('kubun_hub_subscriptions').where('topic_id', '=', topicID).where('subscriber_did', '=', subscriberDID).execute();
157
+ // Drop this subscriber's pending deliveries for the topic, then GC any
158
+ // message left without a recipient (refcount → 0), mirroring ack cleanup.
159
+ 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
+ if (pending.length === 0) return;
161
+ const sequenceIDs = pending.map((r)=>r.sequence_id);
162
+ await kysely.deleteFrom('kubun_hub_deliveries').where('recipient_did', '=', subscriberDID).where('sequence_id', 'in', sequenceIDs).execute();
163
+ const remaining = await kysely.selectFrom('kubun_hub_deliveries').select('sequence_id').where('sequence_id', 'in', sequenceIDs).execute();
164
+ const stillDelivered = new Set(remaining.map((r)=>r.sequence_id));
165
+ const orphaned = sequenceIDs.filter((id)=>!stillDelivered.has(id));
166
+ if (orphaned.length > 0) {
167
+ await kysely.deleteFrom('kubun_hub_messages').where('sequence_id', 'in', orphaned).execute();
168
+ }
169
+ }
170
+ async function getSubscribers(topicID) {
171
+ const kysely = await db();
172
+ const rows = await kysely.selectFrom('kubun_hub_subscriptions').select('subscriber_did').where('topic_id', '=', topicID).execute();
173
+ return rows.map((r)=>r.subscriber_did);
174
+ }
175
+ async function purgeSubscribers(purgeParams) {
176
+ const kysely = await db();
177
+ // Strict `<`: a row refreshed exactly at the boundary survives. Subscription
178
+ // retention runs on a much longer window than message purge.
179
+ const threshold = Date.now() - purgeParams.olderThan * 1000;
180
+ // Atomic delete-and-return so the reported set exactly matches the rows
181
+ // actually removed — a subscriber who rejoins between evaluating and deleting
182
+ // is neither reaped nor over-reported. Mirrors `fetchKeyPackages`.
183
+ const deletedRows = await kysely.deleteFrom('kubun_hub_subscriptions').where('last_active_at', '<', threshold).returning([
184
+ 'topic_id',
185
+ 'subscriber_did'
186
+ ]).execute();
187
+ return deletedRows.map((row)=>({
188
+ topicID: row.topic_id,
189
+ subscriberDID: row.subscriber_did
190
+ }));
191
+ }
192
+ return {
193
+ events,
194
+ publish,
195
+ fetch,
196
+ ack,
197
+ purge,
198
+ purgeSubscribers,
199
+ storeKeyPackage,
200
+ fetchKeyPackages,
201
+ subscribe,
202
+ unsubscribe,
203
+ getSubscribers
204
+ };
205
+ }
package/lib/index.d.ts CHANGED
@@ -1,13 +1,13 @@
1
- import type { Identity } from '@enkaku/token';
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 when `auth` is provided — the server
22
- * signs response tokens and clients pin its DID for TOFU verification.
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?: Identity;
25
+ identity: Identity;
25
26
  /**
26
- * Authentication configuration. When omitted, the hub runs without
27
- * access control (back-compat). When provided, `identity` must also be
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 as e}from"@hono/node-server";import t from"get-port";import{Hono as r}from"hono";import{createRelayInternal as o}from"./create-relay.js";export{createRelay,createRelayInternal}from"./create-relay.js";export{createHubStore}from"./hub-store.js";export{createHubMigrations}from"./migrations.js";export{decorateHubStore}from"./observers.js";export async function createHub(a){let s=await o({kysely:a.kysely,adapter:a.adapter,identity:a.identity,auth:a.auth,allowedOrigin:a.allowedOrigin,retention:a.retention,observers:a.observers,ownsKysely:!1}),i=new r;i.get("/health",e=>e.json({status:"ok"})),i.post("*",async e=>s.fetch(e.req.raw)),i.options("*",async e=>s.fetch(e.req.raw));let n=a.port??await t(),c=`http://localhost:${n}`,l=await new Promise(t=>{let r=e({fetch:i.fetch,port:n},()=>t(r))});return{port:n,url:c,dispose:async()=>{await s.dispose(),l.close()}}}
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,31 @@
1
- export function createHubMigrations(e){let t=e.types;return{"001_create_hub_tables":{async up(e){await e.schema.createTable("kubun_hub_messages").ifNotExists().addColumn("sequence_id",t.text,e=>e.notNull().primaryKey()).addColumn("sender_did",t.text,e=>e.notNull()).addColumn("group_id",t.text).addColumn("payload",t.binary,e=>e.notNull()).addColumn("stored_at","integer",e=>e.notNull()).execute(),await e.schema.createTable("kubun_hub_deliveries").ifNotExists().addColumn("sequence_id",t.text,e=>e.notNull()).addColumn("recipient_did",t.text,e=>e.notNull()).addPrimaryKeyConstraint("pk_hub_deliveries",["sequence_id","recipient_did"]).execute(),await e.schema.createIndex("idx_hub_deliveries_recipient").ifNotExists().on("kubun_hub_deliveries").column("recipient_did").execute(),await e.schema.createTable("kubun_hub_key_packages").ifNotExists().addColumn("id",t.serial,e=>e.primaryKey()).addColumn("owner_did",t.text,e=>e.notNull()).addColumn("key_package",t.text,e=>e.notNull()).execute(),await e.schema.createIndex("idx_hub_key_packages_owner").ifNotExists().on("kubun_hub_key_packages").column("owner_did").execute(),await e.schema.createTable("kubun_hub_group_members").ifNotExists().addColumn("group_id",t.text,e=>e.notNull()).addColumn("member_did",t.text,e=>e.notNull()).addPrimaryKeyConstraint("pk_hub_group_members",["group_id","member_did"]).execute()},async down(e){await e.schema.dropTable("kubun_hub_group_members").ifExists().execute(),await e.schema.dropTable("kubun_hub_key_packages").ifExists().execute(),await e.schema.dropTable("kubun_hub_deliveries").ifExists().execute(),await e.schema.dropTable("kubun_hub_messages").ifExists().execute()}}}}
1
+ export function createHubMigrations(ctx) {
2
+ const t = ctx.types;
3
+ return {
4
+ '001_create_hub_tables': {
5
+ async up (db) {
6
+ // 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()).execute();
8
+ // Hub deliveries
9
+ 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
+ 'sequence_id',
11
+ 'recipient_did'
12
+ ]).execute();
13
+ await db.schema.createIndex('idx_hub_deliveries_recipient').ifNotExists().on('kubun_hub_deliveries').column('recipient_did').execute();
14
+ // 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()).execute();
16
+ await db.schema.createIndex('idx_hub_key_packages_owner').ifNotExists().on('kubun_hub_key_packages').column('owner_did').execute();
17
+ // 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)).addPrimaryKeyConstraint('pk_hub_subscriptions', [
19
+ 'topic_id',
20
+ 'subscriber_did'
21
+ ]).execute();
22
+ },
23
+ async down (db) {
24
+ await db.schema.dropTable('kubun_hub_subscriptions').ifExists().execute();
25
+ await db.schema.dropTable('kubun_hub_key_packages').ifExists().execute();
26
+ await db.schema.dropTable('kubun_hub_deliveries').ifExists().execute();
27
+ await db.schema.dropTable('kubun_hub_messages').ifExists().execute();
28
+ }
29
+ }
30
+ };
31
+ }
@@ -1,8 +1,7 @@
1
- import type { HubStore } from '@enkaku/hub-protocol';
1
+ import type { KubunHubStore } from './hub-store.js';
2
2
  export type RelayObserverStoreInfo = {
3
3
  senderDID: string;
4
- groupID?: string;
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 RelayObserverErrorStage = 'store' | 'fetch' | 'ack' | 'purge';
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: HubStore, observers?: RelayObservers): HubStore;
43
+ export declare function decorateHubStore(store: KubunHubStore, observers?: RelayObservers): KubunHubStore;
package/lib/observers.js CHANGED
@@ -1 +1,71 @@
1
- function e(e,t){if(null!=e)try{e(t)}catch{}}export function decorateHubStore(t,r){if(null==r)return t;let{onStore:n,onFetch:c,onAck:s,onError:a}=r;return{events:t.events,storeKeyPackage:t.storeKeyPackage.bind(t),fetchKeyPackages:t.fetchKeyPackages.bind(t),setGroupMembers:t.setGroupMembers.bind(t),getGroupMembers:t.getGroupMembers.bind(t),purge:t.purge.bind(t),async store(r){try{let c=await t.store(r);return e(n,{senderDID:r.senderDID,groupID:r.groupID,recipientCount:r.recipients.length,sequenceID:c}),c}catch(t){throw e(a,{stage:"store",error:t}),t}},async fetch(r){try{let n=await t.fetch(r);return e(c,{recipientDID:r.recipientDID,messageCount:n.messages.length}),n}catch(t){throw e(a,{stage:"fetch",error:t}),t}},async ack(r){try{await t.ack(r),e(s,{recipientDID:r.recipientDID,sequenceCount:r.sequenceIDs.length})}catch(t){throw e(a,{stage:"ack",error:t}),t}}}}
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
+ subscribe: store.subscribe.bind(store),
17
+ unsubscribe: store.unsubscribe.bind(store),
18
+ getSubscribers: store.getSubscribers.bind(store),
19
+ purgeSubscribers: store.purgeSubscribers.bind(store),
20
+ purge: store.purge.bind(store),
21
+ async publish (params) {
22
+ try {
23
+ const sequenceID = await store.publish(params);
24
+ safeCall(onStore, {
25
+ senderDID: params.senderDID,
26
+ topicID: params.topicID,
27
+ sequenceID
28
+ });
29
+ return sequenceID;
30
+ } catch (error) {
31
+ safeCall(onError, {
32
+ stage: 'store',
33
+ error
34
+ });
35
+ throw error;
36
+ }
37
+ },
38
+ async fetch (params) {
39
+ try {
40
+ const result = await store.fetch(params);
41
+ safeCall(onFetch, {
42
+ recipientDID: params.recipientDID,
43
+ messageCount: result.messages.length
44
+ });
45
+ return result;
46
+ } catch (error) {
47
+ safeCall(onError, {
48
+ stage: 'fetch',
49
+ error
50
+ });
51
+ throw error;
52
+ }
53
+ },
54
+ async ack (params) {
55
+ try {
56
+ await store.ack(params);
57
+ safeCall(onAck, {
58
+ recipientDID: params.recipientDID,
59
+ sequenceCount: params.sequenceIDs.length
60
+ });
61
+ } catch (error) {
62
+ safeCall(onError, {
63
+ stage: 'ack',
64
+ error
65
+ });
66
+ throw error;
67
+ }
68
+ }
69
+ };
70
+ return wrappedStore;
71
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/hub",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "license": "see LICENSE.md",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -14,31 +14,35 @@
14
14
  ],
15
15
  "sideEffects": false,
16
16
  "dependencies": {
17
- "@enkaku/event": "^0.16.0",
18
- "@enkaku/http-server-transport": "^0.16.0",
19
- "@enkaku/hub-protocol": "^0.16.0",
20
- "@enkaku/hub-server": "^0.16.0",
21
- "@enkaku/server": "^0.16.0",
22
- "@enkaku/token": "^0.16.0",
23
- "@hono/node-server": "^2.0.4",
17
+ "@sozai/event": "^0.1.0",
18
+ "@enkaku/http-serve": "^0.18.1",
19
+ "@kumiai/hub-protocol": "^0.1.0",
20
+ "@kumiai/hub-server": "^0.1.0",
21
+ "@enkaku/server": "^0.18.1",
22
+ "@kokuin/token": "^0.1.1",
23
+ "@hono/node-server": "^2.0.8",
24
24
  "get-port": "^7.2.0",
25
- "hono": "^4.12.23",
25
+ "hono": "^4.12.27",
26
26
  "kysely": "^0.29.2",
27
- "@kubun/db-adapter": "^0.10.0",
28
- "@kubun/db-provider": "^0.10.0"
27
+ "@kubun/db-adapter": "^0.11.0",
28
+ "@kubun/http-util": "^0.11.0",
29
+ "@kubun/db-provider": "^0.11.0"
29
30
  },
30
31
  "devDependencies": {
31
- "@enkaku/async": "^0.16.0",
32
- "@enkaku/client": "^0.16.0",
33
- "@enkaku/http-client-transport": "^0.16.0",
34
- "@enkaku/protocol": "^0.16.0",
35
- "@enkaku/transport": "^0.16.0",
36
- "@kubun/db-node-sqlite": "^0.10.0",
37
- "@kubun/db-better-sqlite": "^0.10.0"
32
+ "@sozai/async": "^0.1.0",
33
+ "@enkaku/client": "^0.18.1",
34
+ "@kumiai/mls": "^0.1.0",
35
+ "@enkaku/http-fetch": "^0.18.1",
36
+ "@enkaku/protocol": "^0.18.1",
37
+ "@enkaku/transport": "^0.18.1",
38
+ "@testcontainers/postgresql": "^12.0.4",
39
+ "@kubun/db-better-sqlite": "^0.11.0",
40
+ "@kubun/db-postgres": "^0.11.0",
41
+ "@kubun/db-node-sqlite": "^0.11.0"
38
42
  },
39
43
  "scripts": {
40
44
  "build:clean": "del lib",
41
- "build:js": "swc src -d ./lib --config-file ../../swc.json --strip-leading-paths",
45
+ "build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
42
46
  "build:types": "tsc --emitDeclarationOnly --skipLibCheck",
43
47
  "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types"
44
48
  }