@kumiai/hub-store 0.0.0-stage → 0.10.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 +50 -2
- package/lib/definition.d.ts +7 -0
- package/lib/definition.js +17 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.js +1 -0
- package/lib/migrations.d.ts +2 -0
- package/lib/migrations.js +59 -0
- package/lib/store.d.ts +31 -0
- package/lib/store.js +487 -0
- package/lib/tables.d.ts +46 -0
- package/lib/tables.js +1 -0
- package/package.json +56 -3
package/README.md
CHANGED
|
@@ -1,3 +1,51 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @kumiai/hub-store
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A SQL `HubStore` for Kumiai hubs, registered as a Hozon store. It runs on any Hozon adapter
|
|
4
|
+
(node:sqlite, Postgres) and passes the `@kumiai/hub-conformance` suites on both.
|
|
5
|
+
|
|
6
|
+
Use `@kumiai/hub-http` to host a hub over HTTP. Use this package directly when composing a hub
|
|
7
|
+
server with a custom transport or when you need the store API separately.
|
|
8
|
+
|
|
9
|
+
## Exports
|
|
10
|
+
|
|
11
|
+
- `createHubStoreDefinition(options?)` -- the Hozon `StoreDefinition`, named `'kumiai-hub'`
|
|
12
|
+
(`HUB_STORE`).
|
|
13
|
+
- `getHubStore(provider)` -- the `HubStore` from a `HozonDB` or any Hozon `StoreProvider`.
|
|
14
|
+
- `HubStoreOptions`.
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { HozonDB } from '@hozon/db'
|
|
18
|
+
import { NodeSQLiteAdapter } from '@hozon/node-sqlite'
|
|
19
|
+
import { createHubStoreDefinition, getHubStore } from '@kumiai/hub-store'
|
|
20
|
+
|
|
21
|
+
const db = new HozonDB({ adapter: new NodeSQLiteAdapter({ database: 'hub.db' }) })
|
|
22
|
+
db.register(createHubStoreDefinition({ maxRetention: 30 * 24 * 60 * 60, maxDepth: 1000 }))
|
|
23
|
+
const store = await getHubStore(db)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The database applies the store migrations when `getHubStore` first resolves the registered
|
|
27
|
+
definition. Close the `HozonDB` when the host shuts down.
|
|
28
|
+
|
|
29
|
+
## Options
|
|
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. |
|
|
38
|
+
|
|
39
|
+
Set `maxRetention` or `maxDepth` to `Infinity` to disable that bound explicitly.
|
|
40
|
+
These defaults match the memory store. Periodic purge applies the age policy.
|
|
41
|
+
|
|
42
|
+
## Storage
|
|
43
|
+
|
|
44
|
+
The migrations name tables without a prefix (`hub_messages`, `hub_topics`, ...). `HozonDB` applies
|
|
45
|
+
its `tablePrefix`, so several hubs can share one database under different prefixes.
|
|
46
|
+
|
|
47
|
+
`publish` claims the publish ID, then runs the head compare-and-set, sequence mint, append and the
|
|
48
|
+
head advance in one transaction. The head advance is a conditional write, so two hub processes on
|
|
49
|
+
one Postgres database cannot both accept a publish at the same head.
|
|
50
|
+
|
|
51
|
+
Concurrent retries of one publish ID wait for the accepting transaction and return its sequence ID with `deduped: true`.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { StoreDefinition, StoreProvider } from '@hozon/db';
|
|
2
|
+
import type { HubStore } from '@kumiai/hub-protocol';
|
|
3
|
+
import { type HubStoreOptions } from './store.js';
|
|
4
|
+
import type { HubTables } from './tables.js';
|
|
5
|
+
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>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { hubStoreMigrations } from './migrations.js';
|
|
2
|
+
import { createHubStore } from './store.js';
|
|
3
|
+
export const HUB_STORE = 'kumiai-hub';
|
|
4
|
+
export function createHubStoreDefinition(options = {}) {
|
|
5
|
+
return {
|
|
6
|
+
name: HUB_STORE,
|
|
7
|
+
migrations: hubStoreMigrations,
|
|
8
|
+
createAPI: (db, adapter)=>createHubStore({
|
|
9
|
+
db,
|
|
10
|
+
adapter,
|
|
11
|
+
options
|
|
12
|
+
})
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export async function getHubStore(provider) {
|
|
16
|
+
return await provider.getStore(HUB_STORE);
|
|
17
|
+
}
|
package/lib/index.d.ts
ADDED
package/lib/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createHubStoreDefinition, getHubStore, HUB_STORE } from './definition.js';
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export function hubStoreMigrations(ctx) {
|
|
2
|
+
const t = ctx.types;
|
|
3
|
+
const p = ctx.tablePrefix;
|
|
4
|
+
return {
|
|
5
|
+
'0-init': {
|
|
6
|
+
async up (db) {
|
|
7
|
+
await db.schema.createTable('hub_messages').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()).addColumn('retain', t.text, (col)=>col.notNull().defaultTo('mailbox')).execute();
|
|
8
|
+
await db.schema.createIndex(`${p}_hub_messages_topic`).on('hub_messages').columns([
|
|
9
|
+
'topic_id',
|
|
10
|
+
'retain',
|
|
11
|
+
'sequence_id'
|
|
12
|
+
]).execute();
|
|
13
|
+
await db.schema.createTable('hub_deliveries').addColumn('sequence_id', t.text, (col)=>col.notNull()).addColumn('recipient_did', t.text, (col)=>col.notNull()).addPrimaryKeyConstraint(`${p}_hub_deliveries_pk`, [
|
|
14
|
+
'sequence_id',
|
|
15
|
+
'recipient_did'
|
|
16
|
+
]).execute();
|
|
17
|
+
await db.schema.createIndex(`${p}_hub_deliveries_recipient`).on('hub_deliveries').column('recipient_did').execute();
|
|
18
|
+
await db.schema.createTable('hub_key_packages').addColumn('id', t.serial, (col)=>col.primaryKey()).addColumn('owner_did', t.text, (col)=>col.notNull()).addColumn('key_package', t.text, (col)=>col.notNull()).addColumn('not_after', t.bigint).execute();
|
|
19
|
+
await db.schema.createIndex(`${p}_hub_key_packages_owner`).on('hub_key_packages').column('owner_did').execute();
|
|
20
|
+
// The primary key is what holds "one last-resort slot per owner".
|
|
21
|
+
await db.schema.createTable('hub_last_resort_key_packages').addColumn('owner_did', t.text, (col)=>col.notNull().primaryKey()).addColumn('key_package', t.text, (col)=>col.notNull()).execute();
|
|
22
|
+
await db.schema.createTable('hub_subscriptions').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)).addColumn('retention', t.bigint, (col)=>col.notNull().defaultTo(0)).addPrimaryKeyConstraint(`${p}_hub_subscriptions_pk`, [
|
|
23
|
+
'topic_id',
|
|
24
|
+
'subscriber_did'
|
|
25
|
+
]).execute();
|
|
26
|
+
await db.schema.createIndex(`${p}_hub_subscriptions_subscriber`).on('hub_subscriptions').columns([
|
|
27
|
+
'subscriber_did'
|
|
28
|
+
]).execute();
|
|
29
|
+
await db.schema.createTable('hub_topics').addColumn('topic_id', t.text, (col)=>col.notNull().primaryKey()).addColumn('head', t.text, (col)=>col.notNull()).addColumn('removed_through', t.text).execute();
|
|
30
|
+
// Persisted, not derived from surviving frames, so emptying a topic never regresses
|
|
31
|
+
// minting below a head the topic still names. Global, so a sequenceID is unique across
|
|
32
|
+
// topics and the mailbox fetch stays globally ordered.
|
|
33
|
+
await db.schema.createTable('hub_sequence').addColumn('id', t.bigint, (col)=>col.notNull().primaryKey()).addColumn('counter', t.bigint, (col)=>col.notNull()).execute();
|
|
34
|
+
// Through the query builder, not raw SQL, so the table prefix applies.
|
|
35
|
+
await db.insertInto('hub_sequence').values({
|
|
36
|
+
id: 0,
|
|
37
|
+
counter: 0
|
|
38
|
+
}).execute();
|
|
39
|
+
await db.schema.createTable('hub_publish_ids').addColumn('topic_id', t.text, (col)=>col.notNull()).addColumn('publish_id', t.text, (col)=>col.notNull()).addColumn('sequence_id', t.text).addColumn('recorded_at', t.bigint, (col)=>col.notNull().defaultTo(0)).addPrimaryKeyConstraint(`${p}_hub_publish_ids_pk`, [
|
|
40
|
+
'topic_id',
|
|
41
|
+
'publish_id'
|
|
42
|
+
]).execute();
|
|
43
|
+
await db.schema.createIndex(`${p}_hub_publish_ids_recorded`).on('hub_publish_ids').columns([
|
|
44
|
+
'recorded_at'
|
|
45
|
+
]).execute();
|
|
46
|
+
},
|
|
47
|
+
async down (db) {
|
|
48
|
+
await db.schema.dropTable('hub_publish_ids').execute();
|
|
49
|
+
await db.schema.dropTable('hub_sequence').execute();
|
|
50
|
+
await db.schema.dropTable('hub_topics').execute();
|
|
51
|
+
await db.schema.dropTable('hub_subscriptions').execute();
|
|
52
|
+
await db.schema.dropTable('hub_last_resort_key_packages').execute();
|
|
53
|
+
await db.schema.dropTable('hub_key_packages').execute();
|
|
54
|
+
await db.schema.dropTable('hub_deliveries').execute();
|
|
55
|
+
await db.schema.dropTable('hub_messages').execute();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
package/lib/store.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Adapter } from '@hozon/adapter';
|
|
2
|
+
import { type Kysely } from '@hozon/db';
|
|
3
|
+
import { type HubStore } from '@kumiai/hub-protocol';
|
|
4
|
+
import type { HubTables } from './tables.js';
|
|
5
|
+
export type HubStoreOptions = {
|
|
6
|
+
/** Hub default retention in seconds: the floor for a topic's age bound. Default 0. */
|
|
7
|
+
defaultRetention?: number;
|
|
8
|
+
/**
|
|
9
|
+
* Maximum retention in seconds a subscribe may request. A request above it is refused with
|
|
10
|
+
* `RetentionExceededError`, never clamped. Default 30 days; Infinity disables the maximum.
|
|
11
|
+
*/
|
|
12
|
+
maxRetention?: number;
|
|
13
|
+
/**
|
|
14
|
+
* Maximum retained LOG frames per topic, oldest evicted first. Counts log frames only, so a
|
|
15
|
+
* mailbox flood cannot evict the commit log. Default 1000; Infinity disables the bound.
|
|
16
|
+
*/
|
|
17
|
+
maxDepth?: number;
|
|
18
|
+
/**
|
|
19
|
+
* Maximum ordinary key packages one DID may hold. An upload past it is rejected, never evicted:
|
|
20
|
+
* eviction would let a flood push a victim's live packages out. Default 100.
|
|
21
|
+
*/
|
|
22
|
+
maxKeyPackagesPerDID?: number;
|
|
23
|
+
/** Maximum distinct topics one DID may subscribe to. Default 1000. */
|
|
24
|
+
maxSubscriptionsPerDID?: number;
|
|
25
|
+
};
|
|
26
|
+
export type CreateHubStoreParams = {
|
|
27
|
+
db: Kysely<HubTables>;
|
|
28
|
+
adapter: Adapter;
|
|
29
|
+
options?: HubStoreOptions;
|
|
30
|
+
};
|
|
31
|
+
export declare function createHubStore(params: CreateHubStoreParams): HubStore;
|
package/lib/store.js
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
import { withStoreTransaction } from '@hozon/db';
|
|
2
|
+
import { HeadMismatchError, KeyPackageQuotaExceededError, NotSubscribedError, RetentionExceededError, SubscriptionQuotaExceededError } from '@kumiai/hub-protocol';
|
|
3
|
+
import { EventEmitter } from '@sozai/event';
|
|
4
|
+
/**
|
|
5
|
+
* How long an idempotency record outlives its own publish, in seconds.
|
|
6
|
+
*
|
|
7
|
+
* A year: past this a repeated publish is treated as new, so the window must exceed any replay a
|
|
8
|
+
* real client could still hold -- a parked commit, a device restored from backup, a lane retried
|
|
9
|
+
* after an outage. Not a host-tuned knob.
|
|
10
|
+
*/ const PUBLISH_ID_RETENTION_SECONDS = 365 * 24 * 60 * 60;
|
|
11
|
+
/** Frames deleted per statement: see `deleteFrames`. */ const DELETE_CHUNK_SIZE = 500;
|
|
12
|
+
const DEFAULT_MAX_KEY_PACKAGES_PER_DID = 100;
|
|
13
|
+
const DEFAULT_MAX_SUBSCRIPTIONS_PER_DID = 1000;
|
|
14
|
+
// A DB binary column (a Buffer under node-postgres-style drivers) as a plain Uint8Array, so payload
|
|
15
|
+
// equality holds against a Uint8Array caller.
|
|
16
|
+
function toBytes(value) {
|
|
17
|
+
return new Uint8Array(value);
|
|
18
|
+
}
|
|
19
|
+
// `not_after` is seconds; every other timestamp here is milliseconds.
|
|
20
|
+
function nowSeconds() {
|
|
21
|
+
return Math.floor(Date.now() / 1000);
|
|
22
|
+
}
|
|
23
|
+
export function createHubStore(params) {
|
|
24
|
+
const { db, adapter, options = {} } = params;
|
|
25
|
+
const defaultRetention = options.defaultRetention ?? 0;
|
|
26
|
+
const maxRetention = options.maxRetention ?? 30 * 24 * 60 * 60;
|
|
27
|
+
const maxDepth = options.maxDepth ?? 1000;
|
|
28
|
+
const maxKeyPackagesPerDID = options.maxKeyPackagesPerDID ?? DEFAULT_MAX_KEY_PACKAGES_PER_DID;
|
|
29
|
+
const maxSubscriptionsPerDID = options.maxSubscriptionsPerDID ?? DEFAULT_MAX_SUBSCRIPTIONS_PER_DID;
|
|
30
|
+
const events = new EventEmitter();
|
|
31
|
+
// One snapshot for every read of a `fetchTopic`: under Postgres' default READ COMMITTED each
|
|
32
|
+
// statement would see its own, and head, oldest, gap and messages could disagree.
|
|
33
|
+
function readSnapshot(fn) {
|
|
34
|
+
return db.isTransaction ? fn(db) : db.transaction().setIsolationLevel('repeatable read').execute(fn);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Remove frames and the deliveries pointing at them, a chunk at a time.
|
|
38
|
+
*
|
|
39
|
+
* SQLite binds one parameter per `in` element and throws "too many SQL variables" past its
|
|
40
|
+
* limit, so an unchunked sweep fails precisely when the backlog is large. Deliveries first: a
|
|
41
|
+
* delivery references a frame and cannot be pushed once its referent is gone.
|
|
42
|
+
*/ async function deleteFrames(executor, sequenceIDs) {
|
|
43
|
+
for(let index = 0; index < sequenceIDs.length; index += DELETE_CHUNK_SIZE){
|
|
44
|
+
const chunk = sequenceIDs.slice(index, index + DELETE_CHUNK_SIZE);
|
|
45
|
+
await executor.deleteFrom('hub_deliveries').where('sequence_id', 'in', chunk).execute();
|
|
46
|
+
await executor.deleteFrom('hub_messages').where('sequence_id', 'in', chunk).execute();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// Every log removal (trim, depth, age) advances the topic's removal watermark, which is what
|
|
50
|
+
// `fetchTopic` reports a gap from. Monotonic: it never moves back.
|
|
51
|
+
async function advanceRemovedThrough(executor, topicID, sequenceID) {
|
|
52
|
+
await executor.updateTable('hub_topics').set({
|
|
53
|
+
removed_through: sequenceID
|
|
54
|
+
}).where('topic_id', '=', topicID).where((eb)=>{
|
|
55
|
+
return eb.or([
|
|
56
|
+
eb('removed_through', 'is', null),
|
|
57
|
+
eb('removed_through', '<', sequenceID)
|
|
58
|
+
]);
|
|
59
|
+
}).execute();
|
|
60
|
+
}
|
|
61
|
+
// Take the topic's row lock before touching its frames. `publish` locks the row (head write)
|
|
62
|
+
// and only then evicts frames, so a sweep that deleted frames first and wrote the row after
|
|
63
|
+
// would take the same two locks in the opposite order and deadlock on Postgres.
|
|
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();
|
|
67
|
+
}
|
|
68
|
+
// Mint the next sequenceID inside the accepting transaction, from a persistent global counter.
|
|
69
|
+
// Fixed-width zero-padded so lexicographic order matches numeric order (`"10" > "9"`).
|
|
70
|
+
//
|
|
71
|
+
// One row, so every publish on every topic serialises behind it: the cost of a single total
|
|
72
|
+
// order. It deadlocks nothing: writers claim publish IDs first, then this row, then per-topic rows.
|
|
73
|
+
async function nextSequenceID(trx) {
|
|
74
|
+
const row = await trx.updateTable('hub_sequence').set((eb)=>({
|
|
75
|
+
counter: eb('counter', '+', 1)
|
|
76
|
+
})).where('id', '=', 0).returning('counter').executeTakeFirstOrThrow();
|
|
77
|
+
return String(row.counter).padStart(12, '0');
|
|
78
|
+
}
|
|
79
|
+
// Depth eviction runs inside the publishing transaction. Counts LOG frames only. Silent: it is a
|
|
80
|
+
// synchronous consequence of the caller's own publish.
|
|
81
|
+
async function evictDepth(trx, topicID) {
|
|
82
|
+
if (maxDepth === Number.POSITIVE_INFINITY) return;
|
|
83
|
+
const logRows = await trx.selectFrom('hub_messages').select('sequence_id').where('topic_id', '=', topicID).where('retain', '=', 'log').orderBy('sequence_id', 'asc').execute();
|
|
84
|
+
if (logRows.length <= maxDepth) return;
|
|
85
|
+
const evict = logRows.slice(0, logRows.length - maxDepth).map((r)=>r.sequence_id);
|
|
86
|
+
await deleteFrames(trx, evict);
|
|
87
|
+
await advanceRemovedThrough(trx, topicID, evict[evict.length - 1]);
|
|
88
|
+
}
|
|
89
|
+
async function publish(publishParams) {
|
|
90
|
+
const retain = publishParams.retain ?? 'mailbox';
|
|
91
|
+
const { senderDID, topicID } = publishParams;
|
|
92
|
+
// The dedup check, the head compare-and-set, the sequence mint, the append, and the head
|
|
93
|
+
// advance all happen in ONE transaction, in that order. A read-then-write CAS split across
|
|
94
|
+
// statements is the exact race the head exists to eliminate.
|
|
95
|
+
return await withStoreTransaction(db, async (trx)=>{
|
|
96
|
+
// Claim before minting or CAS: a concurrent retry waits for the holder to commit.
|
|
97
|
+
if (publishParams.publishID != null) {
|
|
98
|
+
const claimed = await trx.insertInto('hub_publish_ids').values({
|
|
99
|
+
topic_id: topicID,
|
|
100
|
+
publish_id: publishParams.publishID,
|
|
101
|
+
sequence_id: null,
|
|
102
|
+
recorded_at: Date.now()
|
|
103
|
+
}).onConflict((oc)=>oc.columns([
|
|
104
|
+
'topic_id',
|
|
105
|
+
'publish_id'
|
|
106
|
+
]).doNothing()).executeTakeFirst();
|
|
107
|
+
if (Number(claimed.numInsertedOrUpdatedRows ?? 0n) === 0) {
|
|
108
|
+
const existing = await trx.selectFrom('hub_publish_ids').select('sequence_id').where('topic_id', '=', topicID).where('publish_id', '=', publishParams.publishID).executeTakeFirstOrThrow();
|
|
109
|
+
if (existing.sequence_id == null) throw new Error('Incomplete publish-id record');
|
|
110
|
+
return {
|
|
111
|
+
sequenceID: existing.sequence_id,
|
|
112
|
+
deduped: true
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// 2. Stored head (null when the topic has never had an accepted log publish).
|
|
117
|
+
const topicRow = await trx.selectFrom('hub_topics').select('head').where('topic_id', '=', topicID).executeTakeFirst();
|
|
118
|
+
const currentHead = topicRow?.head ?? null;
|
|
119
|
+
// 3. Compare-and-set. On mismatch, throw and store NOTHING: the transaction rolls back.
|
|
120
|
+
if (publishParams.expectedHead !== undefined && currentHead !== publishParams.expectedHead) {
|
|
121
|
+
throw new HeadMismatchError(`head mismatch: expected ${String(publishParams.expectedHead)}, current ${String(currentHead)}`);
|
|
122
|
+
}
|
|
123
|
+
// 4. Mint the sequenceID.
|
|
124
|
+
const sequenceID = await nextSequenceID(trx);
|
|
125
|
+
// 5. Recipients = current subscribers minus the sender.
|
|
126
|
+
const subscriberRows = await trx.selectFrom('hub_subscriptions').select('subscriber_did').where('topic_id', '=', topicID).execute();
|
|
127
|
+
const recipients = subscriberRows.map((r)=>r.subscriber_did).filter((did)=>did !== senderDID);
|
|
128
|
+
// 6. Append. A log frame is stored unconditionally (its reader may not exist yet). A mailbox
|
|
129
|
+
// frame with zero recipients is not stored, but the sequenceID is still consumed.
|
|
130
|
+
if (retain === 'log' || recipients.length > 0) {
|
|
131
|
+
await trx.insertInto('hub_messages').values({
|
|
132
|
+
sequence_id: sequenceID,
|
|
133
|
+
sender_did: senderDID,
|
|
134
|
+
topic_id: topicID,
|
|
135
|
+
payload: adapter.encodeBinary(publishParams.payload),
|
|
136
|
+
stored_at: Date.now(),
|
|
137
|
+
retain
|
|
138
|
+
}).execute();
|
|
139
|
+
if (recipients.length > 0) {
|
|
140
|
+
await trx.insertInto('hub_deliveries').values(recipients.map((did)=>({
|
|
141
|
+
sequence_id: sequenceID,
|
|
142
|
+
recipient_did: did
|
|
143
|
+
}))).execute();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// 7. Advance the stored head, LOG frames only.
|
|
147
|
+
//
|
|
148
|
+
// The earlier head read is only an MVCC snapshot, which two transactions racing at the same
|
|
149
|
+
// head both pass. The arbiter is this CONDITIONAL write: the loser matches zero rows, throws,
|
|
150
|
+
// and rolls the whole transaction back.
|
|
151
|
+
if (retain === 'log') {
|
|
152
|
+
if (publishParams.expectedHead === undefined) {
|
|
153
|
+
// Unconditional: last-writer-wins on the head pointer.
|
|
154
|
+
await trx.insertInto('hub_topics').values({
|
|
155
|
+
topic_id: topicID,
|
|
156
|
+
head: sequenceID
|
|
157
|
+
}).onConflict((oc)=>oc.column('topic_id').doUpdateSet({
|
|
158
|
+
head: sequenceID
|
|
159
|
+
})).execute();
|
|
160
|
+
} else if (publishParams.expectedHead === null) {
|
|
161
|
+
// Lose to any concurrent transaction that inserted the row first (conflict: 0 rows).
|
|
162
|
+
const inserted = await trx.insertInto('hub_topics').values({
|
|
163
|
+
topic_id: topicID,
|
|
164
|
+
head: sequenceID
|
|
165
|
+
}).onConflict((oc)=>oc.column('topic_id').doNothing()).executeTakeFirst();
|
|
166
|
+
if (Number(inserted.numInsertedOrUpdatedRows ?? 0n) === 0) {
|
|
167
|
+
throw new HeadMismatchError('head mismatch: expected null, a concurrent publish set the head first');
|
|
168
|
+
}
|
|
169
|
+
} else {
|
|
170
|
+
const updated = await trx.updateTable('hub_topics').set({
|
|
171
|
+
head: sequenceID
|
|
172
|
+
}).where('topic_id', '=', topicID).where('head', '=', publishParams.expectedHead).executeTakeFirst();
|
|
173
|
+
if (Number(updated.numUpdatedRows ?? 0n) === 0) {
|
|
174
|
+
throw new HeadMismatchError(`head mismatch: expected ${publishParams.expectedHead}, a concurrent publish advanced it`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
await evictDepth(trx, topicID);
|
|
178
|
+
}
|
|
179
|
+
// Complete the claim before committing; failed publishes roll the claim back.
|
|
180
|
+
if (publishParams.publishID != null) {
|
|
181
|
+
await trx.updateTable('hub_publish_ids').set({
|
|
182
|
+
sequence_id: sequenceID
|
|
183
|
+
}).where('topic_id', '=', topicID).where('publish_id', '=', publishParams.publishID).execute();
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
sequenceID,
|
|
187
|
+
deduped: false
|
|
188
|
+
};
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
async function fetch(fetchParams) {
|
|
192
|
+
if (fetchParams.ack != null && fetchParams.ack.length > 0) {
|
|
193
|
+
await ack({
|
|
194
|
+
recipientDID: fetchParams.recipientDID,
|
|
195
|
+
sequenceIDs: fetchParams.ack
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
let query = db.selectFrom('hub_deliveries').innerJoin('hub_messages', 'hub_messages.sequence_id', 'hub_deliveries.sequence_id').select([
|
|
199
|
+
'hub_messages.sequence_id',
|
|
200
|
+
'hub_messages.sender_did',
|
|
201
|
+
'hub_messages.topic_id',
|
|
202
|
+
'hub_messages.payload',
|
|
203
|
+
'hub_messages.retain'
|
|
204
|
+
]).where('hub_deliveries.recipient_did', '=', fetchParams.recipientDID).orderBy('hub_messages.sequence_id', 'asc');
|
|
205
|
+
if (fetchParams.after != null) {
|
|
206
|
+
query = query.where('hub_messages.sequence_id', '>', fetchParams.after);
|
|
207
|
+
}
|
|
208
|
+
const limit = fetchParams.limit;
|
|
209
|
+
if (limit != null) {
|
|
210
|
+
// One extra row decides `hasMore`.
|
|
211
|
+
query = query.limit(limit + 1);
|
|
212
|
+
}
|
|
213
|
+
const rows = await query.execute();
|
|
214
|
+
let hasMore = false;
|
|
215
|
+
let resultRows = rows;
|
|
216
|
+
if (limit != null && rows.length > limit) {
|
|
217
|
+
hasMore = true;
|
|
218
|
+
resultRows = rows.slice(0, limit);
|
|
219
|
+
}
|
|
220
|
+
// One global sequence serves both classes, so a log frame's own sequenceID IS its log
|
|
221
|
+
// position. Omitted for a mailbox frame: a falsy placeholder is a position a cursor would move
|
|
222
|
+
// 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.at(-1)?.sequenceID ?? null;
|
|
233
|
+
return {
|
|
234
|
+
messages,
|
|
235
|
+
cursor,
|
|
236
|
+
hasMore
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
async function fetchTopic(fetchParams) {
|
|
240
|
+
const { subscriberDID, topicID, after } = fetchParams;
|
|
241
|
+
return await readSnapshot(async (trx)=>{
|
|
242
|
+
const subscription = await trx.selectFrom('hub_subscriptions').select('subscriber_did').where('topic_id', '=', topicID).where('subscriber_did', '=', subscriberDID).executeTakeFirst();
|
|
243
|
+
if (subscription == null) {
|
|
244
|
+
throw new NotSubscribedError(`${subscriberDID} is not subscribed to ${topicID}`);
|
|
245
|
+
}
|
|
246
|
+
const topicRow = await trx.selectFrom('hub_topics').select([
|
|
247
|
+
'head',
|
|
248
|
+
'removed_through'
|
|
249
|
+
]).where('topic_id', '=', topicID).executeTakeFirst();
|
|
250
|
+
const head = topicRow?.head ?? null;
|
|
251
|
+
const removedThrough = topicRow?.removed_through ?? null;
|
|
252
|
+
const oldestRow = await trx.selectFrom('hub_messages').select((eb)=>eb.fn.min('sequence_id').as('oldest')).where('topic_id', '=', topicID).where('retain', '=', 'log').executeTakeFirst();
|
|
253
|
+
const oldest = oldestRow?.oldest ?? null;
|
|
254
|
+
// The log is the topic's log-class frames and NOTHING else. `limit` applies after the class
|
|
255
|
+
// filter, so a page of mailbox frames cannot hand a draining reader an empty page.
|
|
256
|
+
let query = trx.selectFrom('hub_messages').select([
|
|
257
|
+
'sequence_id',
|
|
258
|
+
'sender_did',
|
|
259
|
+
'topic_id',
|
|
260
|
+
'payload'
|
|
261
|
+
]).where('topic_id', '=', topicID).where('retain', '=', 'log').orderBy('sequence_id', 'asc');
|
|
262
|
+
if (after != null) {
|
|
263
|
+
query = query.where('sequence_id', '>', after);
|
|
264
|
+
}
|
|
265
|
+
if (fetchParams.limit != null) {
|
|
266
|
+
query = query.limit(fetchParams.limit);
|
|
267
|
+
}
|
|
268
|
+
const rows = await query.execute();
|
|
269
|
+
const messages = rows.map((row)=>({
|
|
270
|
+
sequenceID: row.sequence_id,
|
|
271
|
+
senderDID: row.sender_did,
|
|
272
|
+
topicID: row.topic_id,
|
|
273
|
+
payload: toBytes(row.payload),
|
|
274
|
+
logPosition: row.sequence_id
|
|
275
|
+
}));
|
|
276
|
+
// Removal is prefix-only, so a watermark past the cursor means frames the reader never saw
|
|
277
|
+
// are gone.
|
|
278
|
+
const gap = removedThrough != null && (after == null || removedThrough > after);
|
|
279
|
+
return {
|
|
280
|
+
messages,
|
|
281
|
+
head,
|
|
282
|
+
oldest,
|
|
283
|
+
gap
|
|
284
|
+
};
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
async function ack(ackParams) {
|
|
288
|
+
if (ackParams.sequenceIDs.length === 0) return;
|
|
289
|
+
await db.deleteFrom('hub_deliveries').where('recipient_did', '=', ackParams.recipientDID).where('sequence_id', 'in', ackParams.sequenceIDs).execute();
|
|
290
|
+
// GC fully-acked MAILBOX frames only. Ack frees a delivery, never a log entry or the head.
|
|
291
|
+
const withDeliveries = await db.selectFrom('hub_deliveries').select('sequence_id').where('sequence_id', 'in', ackParams.sequenceIDs).execute();
|
|
292
|
+
const stillDelivered = new Set(withDeliveries.map((r)=>r.sequence_id));
|
|
293
|
+
const fullyAcked = ackParams.sequenceIDs.filter((id)=>!stillDelivered.has(id));
|
|
294
|
+
if (fullyAcked.length > 0) {
|
|
295
|
+
await db.deleteFrom('hub_messages').where('sequence_id', 'in', fullyAcked).where('retain', '=', 'mailbox').execute();
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
async function trim(trimParams) {
|
|
299
|
+
// LOG frames strictly below the bound, and only those. The head and the publishID records are
|
|
300
|
+
// never touched.
|
|
301
|
+
await withStoreTransaction(db, async (trx)=>{
|
|
302
|
+
await lockTopic(trx, trimParams.topicID);
|
|
303
|
+
const logRows = await trx.selectFrom('hub_messages').select('sequence_id').where('topic_id', '=', trimParams.topicID).where('retain', '=', 'log').where('sequence_id', '<', trimParams.before).orderBy('sequence_id', 'asc').execute();
|
|
304
|
+
const sequenceIDs = logRows.map((r)=>r.sequence_id);
|
|
305
|
+
if (sequenceIDs.length === 0) return;
|
|
306
|
+
await deleteFrames(trx, sequenceIDs);
|
|
307
|
+
await advanceRemovedThrough(trx, trimParams.topicID, sequenceIDs.at(-1));
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
async function purge(purgeParams) {
|
|
311
|
+
const now = Date.now();
|
|
312
|
+
const olderThan = purgeParams.olderThan;
|
|
313
|
+
// Idempotency records outlive the frames they dedup, on their own clock. Without this sweep
|
|
314
|
+
// the table is the fastest-growing on a long-lived hub.
|
|
315
|
+
await db.deleteFrom('hub_publish_ids').where('recorded_at', '<=', now - PUBLISH_ID_RETENTION_SECONDS * 1000).execute();
|
|
316
|
+
const topicRows = await db.selectFrom('hub_messages').select('topic_id').distinct().orderBy('topic_id', 'asc').execute();
|
|
317
|
+
// Every topic's longest CURRENT subscriber retention in one grouped read.
|
|
318
|
+
const retentionRows = await db.selectFrom('hub_subscriptions').select((eb)=>[
|
|
319
|
+
'topic_id',
|
|
320
|
+
eb.fn.max('retention').as('maxRetention')
|
|
321
|
+
]).groupBy('topic_id').execute();
|
|
322
|
+
const retentionByTopic = new Map(retentionRows.map((row)=>[
|
|
323
|
+
row.topic_id,
|
|
324
|
+
Number(row.maxRetention ?? 0)
|
|
325
|
+
]));
|
|
326
|
+
// One transaction per topic: frames and that topic's watermark commit together, and row locks
|
|
327
|
+
// are held for one topic at a time, in topic_id order.
|
|
328
|
+
const removed = [];
|
|
329
|
+
for (const { topic_id } of topicRows){
|
|
330
|
+
const subscriberRetention = retentionByTopic.get(topic_id) ?? 0;
|
|
331
|
+
const effective = Math.max(olderThan, subscriberRetention, defaultRetention);
|
|
332
|
+
const threshold = now - effective * 1000;
|
|
333
|
+
const ids = await withStoreTransaction(db, async (trx)=>{
|
|
334
|
+
await lockTopic(trx, topic_id);
|
|
335
|
+
// Log removal is prefix-only by position. A clock that moved backwards can leave a due
|
|
336
|
+
// frame above one that is not; removing it would punch a hole a cursor cannot see.
|
|
337
|
+
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();
|
|
338
|
+
const firstKept = keptRow?.firstKept ?? null;
|
|
339
|
+
const frames = await trx.selectFrom('hub_messages').select([
|
|
340
|
+
'sequence_id',
|
|
341
|
+
'retain'
|
|
342
|
+
]).where('topic_id', '=', topic_id).where((eb)=>{
|
|
343
|
+
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
|
+
])
|
|
352
|
+
]);
|
|
353
|
+
}).orderBy('sequence_id', 'asc').execute();
|
|
354
|
+
if (frames.length === 0) return [];
|
|
355
|
+
const frameIDs = frames.map((frame)=>frame.sequence_id);
|
|
356
|
+
await deleteFrames(trx, frameIDs);
|
|
357
|
+
const lastLog = frames.findLast((frame)=>frame.retain === 'log');
|
|
358
|
+
if (lastLog != null) {
|
|
359
|
+
await advanceRemovedThrough(trx, topic_id, lastLog.sequence_id);
|
|
360
|
+
}
|
|
361
|
+
return frameIDs;
|
|
362
|
+
});
|
|
363
|
+
removed.push(...ids);
|
|
364
|
+
}
|
|
365
|
+
removed.sort();
|
|
366
|
+
if (removed.length === 0) return [];
|
|
367
|
+
await events.emit('purge', {
|
|
368
|
+
sequenceIDs: removed
|
|
369
|
+
});
|
|
370
|
+
return removed;
|
|
371
|
+
}
|
|
372
|
+
async function deleteExpiredKeyPackages(ownerDID) {
|
|
373
|
+
await db.deleteFrom('hub_key_packages').where('owner_did', '=', ownerDID).where('not_after', 'is not', null).where('not_after', '<=', nowSeconds()).execute();
|
|
374
|
+
}
|
|
375
|
+
async function storeKeyPackage({ ownerDID, keyPackage, notAfter }) {
|
|
376
|
+
// Drop the dead BEFORE charging the cap. The cap rejects rather than evicts, so nothing else
|
|
377
|
+
// would ever remove them, and a pool full of expired entries could never be replenished.
|
|
378
|
+
await deleteExpiredKeyPackages(ownerDID);
|
|
379
|
+
const row = await db.selectFrom('hub_key_packages').select((eb)=>eb.fn.countAll().as('count')).where('owner_did', '=', ownerDID).executeTakeFirst();
|
|
380
|
+
if (Number(row?.count ?? 0) >= maxKeyPackagesPerDID) {
|
|
381
|
+
throw new KeyPackageQuotaExceededError(`DID ${ownerDID} exceeds the maximum of ${maxKeyPackagesPerDID} stored key packages`);
|
|
382
|
+
}
|
|
383
|
+
await db.insertInto('hub_key_packages').values({
|
|
384
|
+
owner_did: ownerDID,
|
|
385
|
+
key_package: keyPackage,
|
|
386
|
+
not_after: notAfter ?? null
|
|
387
|
+
}).execute();
|
|
388
|
+
}
|
|
389
|
+
async function fetchKeyPackages({ ownerDID, count }) {
|
|
390
|
+
// Expired entries are DELETED first, not filtered after the limit: a run of dead rows would
|
|
391
|
+
// otherwise consume the whole page while live packages waited behind them.
|
|
392
|
+
await deleteExpiredKeyPackages(ownerDID);
|
|
393
|
+
// Atomic delete-returning: a select+delete pair would let two concurrent fetches return the
|
|
394
|
+
// same key package twice.
|
|
395
|
+
const rows = await db.deleteFrom('hub_key_packages').where('id', 'in', db.selectFrom('hub_key_packages').select('id').where('owner_did', '=', ownerDID).orderBy('id', 'asc').limit(count ?? 1)).returning('key_package').execute();
|
|
396
|
+
return rows.map((r)=>r.key_package);
|
|
397
|
+
}
|
|
398
|
+
async function countKeyPackages({ ownerDID }) {
|
|
399
|
+
const row = await db.selectFrom('hub_key_packages').select((eb)=>eb.fn.countAll().as('count')).where('owner_did', '=', ownerDID).where((eb)=>eb.or([
|
|
400
|
+
eb('not_after', 'is', null),
|
|
401
|
+
eb('not_after', '>', nowSeconds())
|
|
402
|
+
])).executeTakeFirst();
|
|
403
|
+
return Number(row?.count ?? 0);
|
|
404
|
+
}
|
|
405
|
+
async function storeLastResortKeyPackage({ ownerDID, keyPackage }) {
|
|
406
|
+
await db.insertInto('hub_last_resort_key_packages').values({
|
|
407
|
+
owner_did: ownerDID,
|
|
408
|
+
key_package: keyPackage
|
|
409
|
+
}).onConflict((oc)=>{
|
|
410
|
+
return oc.column('owner_did').doUpdateSet({
|
|
411
|
+
key_package: (eb)=>eb.ref('excluded.key_package')
|
|
412
|
+
});
|
|
413
|
+
}).execute();
|
|
414
|
+
}
|
|
415
|
+
async function fetchLastResortKeyPackage({ ownerDID }) {
|
|
416
|
+
// Never consumes: the package carries MLS's `last_resort` extension, so serving it twice is
|
|
417
|
+
// the availability floor rather than the init-key reuse `fetchKeyPackages` prevents.
|
|
418
|
+
const row = await db.selectFrom('hub_last_resort_key_packages').select('key_package').where('owner_did', '=', ownerDID).executeTakeFirst();
|
|
419
|
+
return row?.key_package ?? null;
|
|
420
|
+
}
|
|
421
|
+
async function subscribe(subscribeParams) {
|
|
422
|
+
const { subscriberDID, topicID } = subscribeParams;
|
|
423
|
+
// Refused, never clamped: a peer that believed it had asked for more would be stranded.
|
|
424
|
+
if (subscribeParams.retention != null && maxRetention != null && subscribeParams.retention > maxRetention) {
|
|
425
|
+
throw new RetentionExceededError(`requested retention ${subscribeParams.retention} exceeds the hub maximum ${maxRetention}`);
|
|
426
|
+
}
|
|
427
|
+
const retention = subscribeParams.retention ?? defaultRetention;
|
|
428
|
+
// Charged on NEW rows only: refusing a refresh would strand a subscriber at the cap on every
|
|
429
|
+
// reconnect.
|
|
430
|
+
const held = await db.selectFrom('hub_subscriptions').select('topic_id').where('subscriber_did', '=', subscriberDID).execute();
|
|
431
|
+
if (!held.some((row)=>row.topic_id === topicID) && held.length >= maxSubscriptionsPerDID) {
|
|
432
|
+
throw new SubscriptionQuotaExceededError(`DID ${subscriberDID} exceeds the maximum of ${maxSubscriptionsPerDID} subscriptions`);
|
|
433
|
+
}
|
|
434
|
+
// Upsert, so re-subscribing refreshes the activity timestamp and retention rather than failing
|
|
435
|
+
// or duplicating.
|
|
436
|
+
await db.insertInto('hub_subscriptions').values({
|
|
437
|
+
topic_id: topicID,
|
|
438
|
+
subscriber_did: subscriberDID,
|
|
439
|
+
last_active_at: Date.now(),
|
|
440
|
+
retention
|
|
441
|
+
}).onConflict((oc)=>{
|
|
442
|
+
return oc.columns([
|
|
443
|
+
'topic_id',
|
|
444
|
+
'subscriber_did'
|
|
445
|
+
]).doUpdateSet({
|
|
446
|
+
last_active_at: (eb)=>eb.ref('excluded.last_active_at'),
|
|
447
|
+
retention: (eb)=>eb.ref('excluded.retention')
|
|
448
|
+
});
|
|
449
|
+
}).execute();
|
|
450
|
+
}
|
|
451
|
+
async function unsubscribe({ subscriberDID, topicID }) {
|
|
452
|
+
await db.deleteFrom('hub_subscriptions').where('topic_id', '=', topicID).where('subscriber_did', '=', subscriberDID).execute();
|
|
453
|
+
// A delivery operation, not a trim: drop this subscriber's pending deliveries for the topic and
|
|
454
|
+
// GC any MAILBOX frame left without a recipient. Log frames and the head are never touched.
|
|
455
|
+
const pending = await db.selectFrom('hub_deliveries').innerJoin('hub_messages', 'hub_messages.sequence_id', 'hub_deliveries.sequence_id').select('hub_deliveries.sequence_id').where('hub_deliveries.recipient_did', '=', subscriberDID).where('hub_messages.topic_id', '=', topicID).execute();
|
|
456
|
+
if (pending.length === 0) return;
|
|
457
|
+
const sequenceIDs = pending.map((r)=>r.sequence_id);
|
|
458
|
+
await db.deleteFrom('hub_deliveries').where('recipient_did', '=', subscriberDID).where('sequence_id', 'in', sequenceIDs).execute();
|
|
459
|
+
const remaining = await db.selectFrom('hub_deliveries').select('sequence_id').where('sequence_id', 'in', sequenceIDs).execute();
|
|
460
|
+
const stillDelivered = new Set(remaining.map((r)=>r.sequence_id));
|
|
461
|
+
const orphaned = sequenceIDs.filter((id)=>!stillDelivered.has(id));
|
|
462
|
+
if (orphaned.length > 0) {
|
|
463
|
+
await db.deleteFrom('hub_messages').where('sequence_id', 'in', orphaned).where('retain', '=', 'mailbox').execute();
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
async function getSubscribers({ topicID }) {
|
|
467
|
+
const rows = await db.selectFrom('hub_subscriptions').select('subscriber_did').where('topic_id', '=', topicID).execute();
|
|
468
|
+
return rows.map((r)=>r.subscriber_did);
|
|
469
|
+
}
|
|
470
|
+
return {
|
|
471
|
+
events,
|
|
472
|
+
publish,
|
|
473
|
+
fetch,
|
|
474
|
+
fetchTopic,
|
|
475
|
+
ack,
|
|
476
|
+
purge,
|
|
477
|
+
trim,
|
|
478
|
+
storeKeyPackage,
|
|
479
|
+
fetchKeyPackages,
|
|
480
|
+
countKeyPackages,
|
|
481
|
+
storeLastResortKeyPackage,
|
|
482
|
+
fetchLastResortKeyPackage,
|
|
483
|
+
subscribe,
|
|
484
|
+
unsubscribe,
|
|
485
|
+
getSubscribers
|
|
486
|
+
};
|
|
487
|
+
}
|
package/lib/tables.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ColumnType } from '@hozon/db';
|
|
2
|
+
export type HubTables = {
|
|
3
|
+
hub_messages: {
|
|
4
|
+
sequence_id: string;
|
|
5
|
+
sender_did: string;
|
|
6
|
+
topic_id: string;
|
|
7
|
+
payload: Uint8Array;
|
|
8
|
+
stored_at: number;
|
|
9
|
+
retain: string;
|
|
10
|
+
};
|
|
11
|
+
hub_deliveries: {
|
|
12
|
+
sequence_id: string;
|
|
13
|
+
recipient_did: string;
|
|
14
|
+
};
|
|
15
|
+
hub_key_packages: {
|
|
16
|
+
id: ColumnType<number, number | undefined, never>;
|
|
17
|
+
owner_did: string;
|
|
18
|
+
key_package: string;
|
|
19
|
+
not_after: ColumnType<number | null, number | null | undefined, never>;
|
|
20
|
+
};
|
|
21
|
+
hub_last_resort_key_packages: {
|
|
22
|
+
owner_did: string;
|
|
23
|
+
key_package: string;
|
|
24
|
+
};
|
|
25
|
+
hub_subscriptions: {
|
|
26
|
+
topic_id: string;
|
|
27
|
+
subscriber_did: string;
|
|
28
|
+
last_active_at: number;
|
|
29
|
+
retention: ColumnType<number, number | undefined, number>;
|
|
30
|
+
};
|
|
31
|
+
hub_topics: {
|
|
32
|
+
topic_id: string;
|
|
33
|
+
head: string;
|
|
34
|
+
removed_through: ColumnType<string | null, string | null | undefined, string | null>;
|
|
35
|
+
};
|
|
36
|
+
hub_sequence: {
|
|
37
|
+
id: number;
|
|
38
|
+
counter: number;
|
|
39
|
+
};
|
|
40
|
+
hub_publish_ids: {
|
|
41
|
+
topic_id: string;
|
|
42
|
+
publish_id: string;
|
|
43
|
+
sequence_id: string | null;
|
|
44
|
+
recorded_at: number;
|
|
45
|
+
};
|
|
46
|
+
};
|
package/lib/tables.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
package/package.json
CHANGED
|
@@ -1,6 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kumiai/hub-store",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"description": "SQL hub store for Kumiai hubs, backed by Hozon",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"hub",
|
|
7
|
+
"store",
|
|
8
|
+
"sql",
|
|
9
|
+
"hozon"
|
|
10
|
+
],
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/TairuFramework/kumiai",
|
|
14
|
+
"directory": "packages/hub-store"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"sideEffects": false,
|
|
18
|
+
"type": "module",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": "./lib/index.js"
|
|
21
|
+
},
|
|
22
|
+
"main": "lib/index.js",
|
|
23
|
+
"types": "lib/index.d.ts",
|
|
24
|
+
"files": [
|
|
25
|
+
"lib/*"
|
|
26
|
+
],
|
|
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"
|
|
32
|
+
},
|
|
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.0",
|
|
40
|
+
"@kumiai/hub-conformance": "^0.10.1",
|
|
41
|
+
"@kumiai/hub-server": "^0.10.2",
|
|
42
|
+
"@testcontainers/postgresql": "^12.1.0"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=24"
|
|
46
|
+
},
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
|
|
52
|
+
"build:clean": "del lib",
|
|
53
|
+
"build:js": "del 'lib/**/*.js' && swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
|
|
54
|
+
"build:types": "del 'lib/**/*.d.ts' 'lib/**/*.d.ts.map' && tsc --emitDeclarationOnly --skipLibCheck",
|
|
55
|
+
"test": "pnpm run test:types && pnpm run test:unit",
|
|
56
|
+
"test:types": "tsc --noEmit --skipLibCheck -p tsconfig.test.json",
|
|
57
|
+
"test:unit": "vitest run"
|
|
58
|
+
}
|
|
6
59
|
}
|