@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.
Files changed (2) hide show
  1. package/lib/hub-store.js +27 -32
  2. 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 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.
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 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.
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. 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.
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.length > 0 ? messages[messages.length - 1].sequenceID : null;
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 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.
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
- // 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.
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.15.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.15.0",
22
- "@kubun/db-provider": "^0.15.0",
23
- "@kubun/http-util": "^0.15.0",
24
- "@kumiai/hub-protocol": "^0.8.0",
25
- "@kumiai/hub-server": "^0.8.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.5",
28
+ "hono": "^4.13.7",
29
29
  "kysely": "^0.29.5"
30
30
  },
31
31
  "devDependencies": {
32
- "@enkaku/client": "^0.21.1",
32
+ "@enkaku/client": "^0.21.2",
33
33
  "@enkaku/http-fetch": "^0.21.0",
34
- "@enkaku/protocol": "^0.21.0",
34
+ "@enkaku/protocol": "^0.21.1",
35
35
  "@enkaku/transport": "^0.21.0",
36
- "@kubun/db-better-sqlite": "^0.15.0",
37
- "@kubun/db-node-sqlite": "^0.15.0",
38
- "@kubun/db-postgres": "^0.15.0",
39
- "@kumiai/hub-conformance": "^0.8.0",
40
- "@kumiai/mls": "^0.8.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
  }