@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.
@@ -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,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?: 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: 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?: 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
  };
@@ -1 +1,233 @@
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 { 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
+ }
@@ -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
- group_id: string | null;
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
- kubun_hub_group_members: {
22
- group_id: string;
23
- member_did: string;
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): HubStore;
67
+ export declare function createHubStore(params: CreateHubStoreParams): KubunHubStore;
package/lib/hub-store.js CHANGED
@@ -1 +1,503 @@
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 { 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 '@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,68 @@
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
+ 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
+ }
@@ -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,76 @@
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
+ 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.10.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/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
+ "@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.12.23",
26
- "kysely": "^0.29.2",
27
- "@kubun/db-adapter": "^0.10.0",
28
- "@kubun/db-provider": "^0.10.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/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
+ "@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
- "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types"
49
+ "test:types": "tsc --noEmit -p tsconfig.test.json"
44
50
  }
45
51
  }