@kubun/hub 0.11.0 → 0.12.0

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