@kubun/hub 0.15.0 → 0.17.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/lib/hub-store.js +27 -32
- package/package.json +16 -16
package/lib/hub-store.js
CHANGED
|
@@ -8,11 +8,10 @@ function toBytes(value) {
|
|
|
8
8
|
/**
|
|
9
9
|
* How long an idempotency record outlives its own publish, in seconds.
|
|
10
10
|
*
|
|
11
|
-
* A year: a
|
|
12
|
-
* exceed any replay a real client could still
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* stops being a coherent claim.
|
|
11
|
+
* A year: past this a repeated publish is treated as new, so the window must
|
|
12
|
+
* exceed any replay a real client could still hold — a parked commit, a device
|
|
13
|
+
* restored from backup, a lane retried after an outage. Not a host-tuned knob;
|
|
14
|
+
* it is the horizon past which "the same publish" stops being coherent.
|
|
16
15
|
*/ const PUBLISH_ID_RETENTION_SECONDS = 365 * 24 * 60 * 60;
|
|
17
16
|
/** Frames deleted per statement — see {@link deleteFrames}. */ const DELETE_CHUNK_SIZE = 500;
|
|
18
17
|
/** Matches `@kumiai/hub-server`'s own defaults — a kubun relay is not a laxer hub. */ const DEFAULT_MAX_KEY_PACKAGES_PER_DID = 100;
|
|
@@ -36,11 +35,10 @@ export function createHubStore(params) {
|
|
|
36
35
|
/**
|
|
37
36
|
* Remove frames and the deliveries pointing at them, a chunk at a time.
|
|
38
37
|
*
|
|
39
|
-
* SQLite binds one parameter per
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* be pushed once its referent is gone.
|
|
38
|
+
* SQLite binds one parameter per `in` element and throws "too many SQL
|
|
39
|
+
* variables" past its limit (32766 here, 999 on older builds), so an unchunked
|
|
40
|
+
* sweep fails precisely when the backlog is large. Deliveries first: a delivery
|
|
41
|
+
* references a frame and cannot be pushed once its referent is gone.
|
|
44
42
|
*/ async function deleteFrames(executor, sequenceIDs) {
|
|
45
43
|
for(let index = 0; index < sequenceIDs.length; index += DELETE_CHUNK_SIZE){
|
|
46
44
|
const chunk = sequenceIDs.slice(index, index + DELETE_CHUNK_SIZE);
|
|
@@ -52,10 +50,9 @@ export function createHubStore(params) {
|
|
|
52
50
|
// global counter. Fixed-width zero-padded so lexicographic order matches
|
|
53
51
|
// numeric order (`"10" > "9"`), and strictly increasing.
|
|
54
52
|
//
|
|
55
|
-
// One row, so every publish on every topic serializes behind it
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
// hold each other's next lock.
|
|
53
|
+
// One row, so every publish on every topic serializes behind it — the cost of
|
|
54
|
+
// a single total order. It deadlocks nothing: every writer takes this row FIRST
|
|
55
|
+
// and per-topic rows after, so no two transactions hold each other's next lock.
|
|
59
56
|
async function nextSequenceID(trx) {
|
|
60
57
|
const row = await trx.updateTable('kubun_hub_sequence').set((eb)=>({
|
|
61
58
|
counter: eb('counter', '+', 1)
|
|
@@ -229,7 +226,7 @@ export function createHubStore(params) {
|
|
|
229
226
|
logPosition: row.sequence_id
|
|
230
227
|
} : {}
|
|
231
228
|
}));
|
|
232
|
-
const cursor = messages.
|
|
229
|
+
const cursor = messages.at(-1)?.sequenceID ?? null;
|
|
233
230
|
return {
|
|
234
231
|
messages,
|
|
235
232
|
cursor,
|
|
@@ -310,22 +307,20 @@ export function createHubStore(params) {
|
|
|
310
307
|
const kysely = await db();
|
|
311
308
|
const now = Date.now();
|
|
312
309
|
const olderThan = purgeParams.olderThan;
|
|
313
|
-
// Idempotency records outlive the frames they dedup, on their own clock
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
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.
|
|
310
|
+
// Idempotency records outlive the frames they dedup, on their own clock: the
|
|
311
|
+
// contract is that no message deleter reaches one for a frame it still
|
|
312
|
+
// guards, NOT that they are kept forever. Every commit publish writes one, so
|
|
313
|
+
// without this the table is the fastest-growing on a long-lived hub. The
|
|
314
|
+
// window far exceeds any plausible replay (see PUBLISH_ID_RETENTION_SECONDS).
|
|
320
315
|
await kysely.deleteFrom('kubun_hub_publish_ids').where('recorded_at', '<=', now - PUBLISH_ID_RETENTION_SECONDS * 1000).execute();
|
|
321
316
|
// Per topic, the effective age bound is the longest retention any CURRENT
|
|
322
317
|
// subscriber asked for, floored at the hub default (and at the sweep's
|
|
323
318
|
// olderThan). Applies to both classes. Never touches the head or a publishID
|
|
324
319
|
// record for a frame it removes.
|
|
325
320
|
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
|
-
//
|
|
328
|
-
//
|
|
321
|
+
// Every topic's longest CURRENT subscriber retention in one grouped read, not
|
|
322
|
+
// one read per topic: the sweep walks every topic, so per-topic reads would be
|
|
323
|
+
// a query count that grows with the hub.
|
|
329
324
|
const retentionRows = await kysely.selectFrom('kubun_hub_subscriptions').select((eb)=>[
|
|
330
325
|
'topic_id',
|
|
331
326
|
eb.fn.max('retention').as('maxRetention')
|
|
@@ -358,7 +353,7 @@ export function createHubStore(params) {
|
|
|
358
353
|
async function deleteExpiredKeyPackages(kysely, ownerDID) {
|
|
359
354
|
await kysely.deleteFrom('kubun_hub_key_packages').where('owner_did', '=', ownerDID).where('not_after', 'is not', null).where('not_after', '<=', nowSeconds()).execute();
|
|
360
355
|
}
|
|
361
|
-
async function storeKeyPackage(ownerDID, keyPackage, notAfter) {
|
|
356
|
+
async function storeKeyPackage({ ownerDID, keyPackage, notAfter }) {
|
|
362
357
|
const kysely = await db();
|
|
363
358
|
// Drop the dead BEFORE charging the cap. The cap rejects rather than evicts, so
|
|
364
359
|
// nothing else would ever remove them: a pool that filled with expired entries
|
|
@@ -374,7 +369,7 @@ export function createHubStore(params) {
|
|
|
374
369
|
not_after: notAfter ?? null
|
|
375
370
|
}).execute();
|
|
376
371
|
}
|
|
377
|
-
async function fetchKeyPackages(ownerDID, count) {
|
|
372
|
+
async function fetchKeyPackages({ ownerDID, count }) {
|
|
378
373
|
const kysely = await db();
|
|
379
374
|
// Expired entries are DELETED first, not filtered after the limit: a run of dead
|
|
380
375
|
// rows would otherwise consume the whole page and return nothing while live
|
|
@@ -386,7 +381,7 @@ export function createHubStore(params) {
|
|
|
386
381
|
const rows = await kysely.deleteFrom('kubun_hub_key_packages').where('id', 'in', kysely.selectFrom('kubun_hub_key_packages').select('id').where('owner_did', '=', ownerDID).orderBy('id', 'asc').limit(limit)).returning('key_package').execute();
|
|
387
382
|
return rows.map((r)=>r.key_package);
|
|
388
383
|
}
|
|
389
|
-
async function countKeyPackages(ownerDID) {
|
|
384
|
+
async function countKeyPackages({ ownerDID }) {
|
|
390
385
|
const kysely = await db();
|
|
391
386
|
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
387
|
eb('not_after', 'is', null),
|
|
@@ -394,7 +389,7 @@ export function createHubStore(params) {
|
|
|
394
389
|
])).executeTakeFirst();
|
|
395
390
|
return Number(row?.count ?? 0);
|
|
396
391
|
}
|
|
397
|
-
async function storeLastResortKeyPackage(ownerDID, keyPackage) {
|
|
392
|
+
async function storeLastResortKeyPackage({ ownerDID, keyPackage }) {
|
|
398
393
|
const kysely = await db();
|
|
399
394
|
await kysely.insertInto('kubun_hub_last_resort_key_packages').values({
|
|
400
395
|
owner_did: ownerDID,
|
|
@@ -403,7 +398,7 @@ export function createHubStore(params) {
|
|
|
403
398
|
key_package: (eb)=>eb.ref('excluded.key_package')
|
|
404
399
|
})).execute();
|
|
405
400
|
}
|
|
406
|
-
async function fetchLastResortKeyPackage(ownerDID) {
|
|
401
|
+
async function fetchLastResortKeyPackage({ ownerDID }) {
|
|
407
402
|
const kysely = await db();
|
|
408
403
|
// Never consumes. Reuse is the point: the package carries MLS's `last_resort`
|
|
409
404
|
// extension, so serving it twice is the availability floor rather than the
|
|
@@ -442,7 +437,7 @@ export function createHubStore(params) {
|
|
|
442
437
|
retention: (eb)=>eb.ref('excluded.retention')
|
|
443
438
|
})).execute();
|
|
444
439
|
}
|
|
445
|
-
async function unsubscribe(subscriberDID, topicID) {
|
|
440
|
+
async function unsubscribe({ subscriberDID, topicID }) {
|
|
446
441
|
const kysely = await db();
|
|
447
442
|
await kysely.deleteFrom('kubun_hub_subscriptions').where('topic_id', '=', topicID).where('subscriber_did', '=', subscriberDID).execute();
|
|
448
443
|
// unsubscribe is a delivery operation, not a trim: it drops this subscriber's
|
|
@@ -460,7 +455,7 @@ export function createHubStore(params) {
|
|
|
460
455
|
await kysely.deleteFrom('kubun_hub_messages').where('sequence_id', 'in', orphaned).where('retain', '=', 'mailbox').execute();
|
|
461
456
|
}
|
|
462
457
|
}
|
|
463
|
-
async function getSubscribers(topicID) {
|
|
458
|
+
async function getSubscribers({ topicID }) {
|
|
464
459
|
const kysely = await db();
|
|
465
460
|
const rows = await kysely.selectFrom('kubun_hub_subscriptions').select('subscriber_did').where('topic_id', '=', topicID).execute();
|
|
466
461
|
return rows.map((r)=>r.subscriber_did);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/hub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"license": "see LICENSE.md",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"type": "module",
|
|
@@ -18,26 +18,26 @@
|
|
|
18
18
|
"@enkaku/server": "^0.21.2",
|
|
19
19
|
"@hono/node-server": "^2.1.1",
|
|
20
20
|
"@kokuin/token": "^0.5.0",
|
|
21
|
-
"@kubun/db-adapter": "^0.
|
|
22
|
-
"@kubun/db-provider": "^0.
|
|
23
|
-
"@kubun/http-util": "^0.
|
|
24
|
-
"@kumiai/hub-protocol": "^0.
|
|
25
|
-
"@kumiai/hub-server": "^0.
|
|
21
|
+
"@kubun/db-adapter": "^0.17.0",
|
|
22
|
+
"@kubun/db-provider": "^0.17.0",
|
|
23
|
+
"@kubun/http-util": "^0.17.0",
|
|
24
|
+
"@kumiai/hub-protocol": "^0.9.0",
|
|
25
|
+
"@kumiai/hub-server": "^0.9.0",
|
|
26
26
|
"@sozai/event": "^0.1.3",
|
|
27
27
|
"get-port": "^7.2.0",
|
|
28
|
-
"hono": "^4.13.
|
|
28
|
+
"hono": "^4.13.7",
|
|
29
29
|
"kysely": "^0.29.5"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@enkaku/client": "^0.21.
|
|
32
|
+
"@enkaku/client": "^0.21.2",
|
|
33
33
|
"@enkaku/http-fetch": "^0.21.0",
|
|
34
|
-
"@enkaku/protocol": "^0.21.
|
|
34
|
+
"@enkaku/protocol": "^0.21.1",
|
|
35
35
|
"@enkaku/transport": "^0.21.0",
|
|
36
|
-
"@kubun/db-better-sqlite": "^0.
|
|
37
|
-
"@kubun/db-node-sqlite": "^0.
|
|
38
|
-
"@kubun/db-postgres": "^0.
|
|
39
|
-
"@kumiai/hub-conformance": "^0.
|
|
40
|
-
"@kumiai/mls": "^0.
|
|
36
|
+
"@kubun/db-better-sqlite": "^0.17.0",
|
|
37
|
+
"@kubun/db-node-sqlite": "^0.17.0",
|
|
38
|
+
"@kubun/db-postgres": "^0.17.0",
|
|
39
|
+
"@kumiai/hub-conformance": "^0.9.0",
|
|
40
|
+
"@kumiai/mls": "^0.9.0",
|
|
41
41
|
"@sozai/async": "^0.2.1",
|
|
42
42
|
"@testcontainers/postgresql": "^12.1.0"
|
|
43
43
|
},
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
"scripts": {
|
|
48
48
|
"build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
|
|
49
49
|
"build:clean": "del lib",
|
|
50
|
-
"build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
|
|
51
|
-
"build:types": "tsc --emitDeclarationOnly --skipLibCheck",
|
|
50
|
+
"build:js": "del 'lib/**/*.js' && swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
|
|
51
|
+
"build:types": "del 'lib/**/*.d.ts' 'lib/**/*.d.ts.map' && tsc --emitDeclarationOnly --skipLibCheck",
|
|
52
52
|
"test:types": "tsc --noEmit -p tsconfig.test.json"
|
|
53
53
|
}
|
|
54
54
|
}
|