@kumiai/hub-store 0.10.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,8 +10,8 @@ server with a custom transport or when you need the store API separately.
10
10
 
11
11
  - `createHubStoreDefinition(options?)` -- the Hozon `StoreDefinition`, named `'kumiai-hub'`
12
12
  (`HUB_STORE`).
13
- - `getHubStore(provider)` -- the `HubStore` from a `HozonDB` or any Hozon `StoreProvider`.
14
- - `HubStoreOptions`.
13
+ - `getHubStore(provider)` -- the `SQLHubStore` from a `HozonDB` or any Hozon `StoreProvider`.
14
+ - `HubStoreOptions`, `HubSubscriptionReaper`, and `SQLHubStore` (`HubStore & HubSubscriptionReaper`).
15
15
 
16
16
  ```ts
17
17
  import { HozonDB } from '@hozon/db'
@@ -28,13 +28,13 @@ definition. Close the `HozonDB` when the host shuts down.
28
28
 
29
29
  ## Options
30
30
 
31
- | Option | Default | Meaning |
32
- |--------|---------|---------|
33
- | `defaultRetention` | `0` | Floor, in seconds, on how long a topic's frames are kept. |
34
- | `maxRetention` | `2592000` (30 days) | Ceiling, in seconds, a subscribe may request. Above it the subscribe is refused. |
35
- | `maxDepth` | `1000` | Retained log frames per topic. The oldest log frames are evicted first. |
36
- | `maxKeyPackagesPerDID` | `100` | Ordinary key packages one DID may hold. An upload past it is refused. |
37
- | `maxSubscriptionsPerDID` | `1000` | Distinct topics one DID may subscribe to. |
31
+ | Option | Default | Meaning |
32
+ | ------------------------ | ------------------- | -------------------------------------------------------------------------------- |
33
+ | `defaultRetention` | `0` | Floor, in seconds, on how long a topic's frames are kept. |
34
+ | `maxRetention` | `2592000` (30 days) | Ceiling, in seconds, a subscribe may request. Above it the subscribe is refused. |
35
+ | `maxDepth` | `1000` | Retained log frames per topic. The oldest log frames are evicted first. |
36
+ | `maxKeyPackagesPerDID` | `100` | Ordinary key packages one DID may hold. An upload past it is refused. |
37
+ | `maxSubscriptionsPerDID` | `1000` | Distinct topics one DID may subscribe to. |
38
38
 
39
39
  Set `maxRetention` or `maxDepth` to `Infinity` to disable that bound explicitly.
40
40
  These defaults match the memory store. Periodic purge applies the age policy.
@@ -49,3 +49,10 @@ head advance in one transaction. The head advance is a conditional write, so two
49
49
  one Postgres database cannot both accept a publish at the same head.
50
50
 
51
51
  Concurrent retries of one publish ID wait for the accepting transaction and return its sequence ID with `deduped: true`.
52
+
53
+ ## Subscription reaping
54
+
55
+ `store.purgeSubscriptions({ olderThan })` removes subscriptions inactive for strictly more than `olderThan` seconds.
56
+ It returns exactly the deleted `{ topicID, subscriberDID }` pairs. A subscription refreshed at the boundary survives.
57
+ The delete and return happen atomically, so a refreshed subscription is neither removed nor reported.
58
+ This operation emits no event. `createHub` calls it after scheduled message purge when the store supports it.
@@ -1,7 +1,6 @@
1
1
  import type { StoreDefinition, StoreProvider } from '@hozon/db';
2
- import type { HubStore } from '@kumiai/hub-protocol';
3
- import { type HubStoreOptions } from './store.js';
2
+ import { type HubStoreOptions, type SQLHubStore } from './store.js';
4
3
  import type { HubTables } from './tables.js';
5
4
  export declare const HUB_STORE = "kumiai-hub";
6
- export declare function createHubStoreDefinition(options?: HubStoreOptions): StoreDefinition<HubTables, HubStore>;
7
- export declare function getHubStore(provider: StoreProvider): Promise<HubStore>;
5
+ export declare function createHubStoreDefinition(options?: HubStoreOptions): StoreDefinition<HubTables, SQLHubStore>;
6
+ export declare function getHubStore(provider: StoreProvider): Promise<SQLHubStore>;
package/lib/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  export { createHubStoreDefinition, getHubStore, HUB_STORE } from './definition.js';
2
- export type { HubStoreOptions } from './store.js';
2
+ export type { HubStoreOptions, HubSubscriptionReaper, SQLHubStore } from './store.js';
package/lib/store.d.ts CHANGED
@@ -28,4 +28,13 @@ export type CreateHubStoreParams = {
28
28
  adapter: Adapter;
29
29
  options?: HubStoreOptions;
30
30
  };
31
- export declare function createHubStore(params: CreateHubStoreParams): HubStore;
31
+ export type HubSubscriptionReaper = {
32
+ purgeSubscriptions(params: {
33
+ olderThan: number;
34
+ }): Promise<Array<{
35
+ topicID: string;
36
+ subscriberDID: string;
37
+ }>>;
38
+ };
39
+ export type SQLHubStore = HubStore & HubSubscriptionReaper;
40
+ export declare function createHubStore(params: CreateHubStoreParams): SQLHubStore;
package/lib/store.js CHANGED
@@ -62,8 +62,9 @@ export function createHubStore(params) {
62
62
  // and only then evicts frames, so a sweep that deleted frames first and wrote the row after
63
63
  // would take the same two locks in the opposite order and deadlock on Postgres.
64
64
  async function lockTopic(trx, topicID) {
65
- await trx.selectFrom('hub_topics').select('topic_id').where('topic_id', '=', topicID)// SQLite has no row locks (and rejects the clause); its one writer already serialises.
66
- .$if(adapter.kind === 'postgres', (qb)=>qb.forUpdate()).execute();
65
+ const row = await trx.selectFrom('hub_topics').select('head').where('topic_id', '=', topicID)// SQLite has no row locks (and rejects the clause); its one writer already serialises.
66
+ .$if(adapter.kind === 'postgres', (qb)=>qb.forUpdate()).executeTakeFirst();
67
+ return row?.head ?? null;
67
68
  }
68
69
  // Mint the next sequenceID inside the accepting transaction, from a persistent global counter.
69
70
  // Fixed-width zero-padded so lexicographic order matches numeric order (`"10" > "9"`).
@@ -331,7 +332,7 @@ export function createHubStore(params) {
331
332
  const effective = Math.max(olderThan, subscriberRetention, defaultRetention);
332
333
  const threshold = now - effective * 1000;
333
334
  const ids = await withStoreTransaction(db, async (trx)=>{
334
- await lockTopic(trx, topic_id);
335
+ const head = await lockTopic(trx, topic_id);
335
336
  // Log removal is prefix-only by position. A clock that moved backwards can leave a due
336
337
  // frame above one that is not; removing it would punch a hole a cursor cannot see.
337
338
  const keptRow = await trx.selectFrom('hub_messages').select((eb)=>eb.fn.min('sequence_id').as('firstKept')).where('topic_id', '=', topic_id).where('retain', '=', 'log').where('stored_at', '>', threshold).executeTakeFirst();
@@ -340,15 +341,20 @@ export function createHubStore(params) {
340
341
  'sequence_id',
341
342
  'retain'
342
343
  ]).where('topic_id', '=', topic_id).where((eb)=>{
344
+ const mailbox = eb.and([
345
+ eb('retain', '=', 'mailbox'),
346
+ eb('stored_at', '<=', threshold)
347
+ ]);
348
+ // A mailbox-only topic has no row to lock. Never admit its first concurrent log.
349
+ if (head == null) return mailbox;
350
+ const log = [
351
+ eb('retain', '=', 'log'),
352
+ eb('sequence_id', '<=', head)
353
+ ];
354
+ if (firstKept != null) log.push(eb('sequence_id', '<', firstKept));
343
355
  return eb.or([
344
- eb.and([
345
- eb('retain', '=', 'mailbox'),
346
- eb('stored_at', '<=', threshold)
347
- ]),
348
- firstKept == null ? eb('retain', '=', 'log') : eb.and([
349
- eb('retain', '=', 'log'),
350
- eb('sequence_id', '<', firstKept)
351
- ])
356
+ mailbox,
357
+ eb.and(log)
352
358
  ]);
353
359
  }).orderBy('sequence_id', 'asc').execute();
354
360
  if (frames.length === 0) return [];
@@ -369,6 +375,17 @@ export function createHubStore(params) {
369
375
  });
370
376
  return removed;
371
377
  }
378
+ async function purgeSubscriptions({ olderThan }) {
379
+ // Strict boundary and atomic return: a refreshed subscriber is neither deleted nor reported.
380
+ const rows = await db.deleteFrom('hub_subscriptions').where('last_active_at', '<', Date.now() - olderThan * 1000).returning([
381
+ 'topic_id',
382
+ 'subscriber_did'
383
+ ]).execute();
384
+ return rows.map((row)=>({
385
+ topicID: row.topic_id,
386
+ subscriberDID: row.subscriber_did
387
+ }));
388
+ }
372
389
  async function deleteExpiredKeyPackages(ownerDID) {
373
390
  await db.deleteFrom('hub_key_packages').where('owner_did', '=', ownerDID).where('not_after', 'is not', null).where('not_after', '<=', nowSeconds()).execute();
374
391
  }
@@ -474,6 +491,7 @@ export function createHubStore(params) {
474
491
  fetchTopic,
475
492
  ack,
476
493
  purge,
494
+ purgeSubscriptions,
477
495
  trim,
478
496
  storeKeyPackage,
479
497
  fetchKeyPackages,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kumiai/hub-store",
3
- "version": "0.10.1",
3
+ "version": "0.12.0",
4
4
  "description": "SQL hub store for Kumiai hubs, backed by Hozon",
5
5
  "keywords": [
6
6
  "hub",
@@ -25,21 +25,21 @@
25
25
  "lib/*"
26
26
  ],
27
27
  "dependencies": {
28
- "@hozon/adapter": "^0.1.0",
29
- "@hozon/db": "^0.2.2",
30
- "@kumiai/hub-protocol": "^0.10.1",
31
- "@sozai/event": "^0.1.5"
28
+ "@hozon/adapter": "^0.2.1",
29
+ "@hozon/db": "^0.5.0",
30
+ "@kumiai/hub-protocol": "^0.12.0",
31
+ "@sozai/event": "^0.1.6"
32
32
  },
33
33
  "devDependencies": {
34
- "@enkaku/client": "^0.21.3",
35
- "@enkaku/protocol": "^0.21.4",
36
- "@enkaku/transport": "^0.21.3",
37
- "@hozon/node-sqlite": "^0.1.0",
38
- "@hozon/postgres": "^0.1.0",
39
- "@kokuin/token": "^0.5.1",
40
- "@kumiai/hub-conformance": "^0.10.1",
41
- "@kumiai/hub-server": "^0.10.3",
42
- "@testcontainers/postgresql": "^12.1.0"
34
+ "@enkaku/client": "^0.22.2",
35
+ "@enkaku/protocol": "^0.22.2",
36
+ "@enkaku/transport": "^0.22.2",
37
+ "@hozon/node-sqlite": "^0.2.0",
38
+ "@hozon/postgres": "^0.2.0",
39
+ "@kokuin/token": "^0.6.2",
40
+ "@kumiai/hub-conformance": "^0.12.0",
41
+ "@kumiai/hub-server": "^0.12.0",
42
+ "@testcontainers/postgresql": "^12.2.1"
43
43
  },
44
44
  "engines": {
45
45
  "node": ">=24"