@kumiai/hub-store 0.12.0 → 0.13.1

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
@@ -11,7 +11,7 @@ server with a custom transport or when you need the store API separately.
11
11
  - `createHubStoreDefinition(options?)` -- the Hozon `StoreDefinition`, named `'kumiai-hub'`
12
12
  (`HUB_STORE`).
13
13
  - `getHubStore(provider)` -- the `SQLHubStore` from a `HozonDB` or any Hozon `StoreProvider`.
14
- - `HubStoreOptions`, `HubSubscriptionReaper`, and `SQLHubStore` (`HubStore & HubSubscriptionReaper`).
14
+ - `HubStoreOptions`, `HubSubscriptionReaper`, and `SQLHubStore` (the hub contract, subscription reaping, occupancy and persistent store identity).
15
15
 
16
16
  ```ts
17
17
  import { HozonDB } from '@hozon/db'
@@ -56,3 +56,21 @@ Concurrent retries of one publish ID wait for the accepting transaction and retu
56
56
  It returns exactly the deleted `{ topicID, subscriberDID }` pairs. A subscription refreshed at the boundary survives.
57
57
  The delete and return happen atomically, so a refreshed subscription is neither removed nor reported.
58
58
  This operation emits no event. `createHub` calls it after scheduled message purge when the store supports it.
59
+
60
+ ## Attribution migration and occupancy
61
+
62
+ Migration `1-attribution` adds `payload_size`, nullable `account`, `actor` and `via` columns, plus the `hub_meta` table.
63
+ It backfills payload sizes and persists a generated `storeID` in `hub_meta`.
64
+ Existing message, retention and delivery semantics remain unchanged.
65
+
66
+ `publish({ ..., attribution? })` accepts `{ account, actor, via: 'direct' | 'delegated' | 'group' }`.
67
+ Without attribution, these columns remain null.
68
+ Legacy and unattributed rows fall back to `sender_did` for both account and actor, with `via: 'direct'`.
69
+
70
+ `store.occupancy()` returns retained payload bytes and frame counts grouped by account, actor and via.
71
+ Each physical frame counts once, regardless of recipient count.
72
+ Ack, trim, purge and unsubscribe affect occupancy through their existing frame deletion rules.
73
+ `store.storeID()` returns the persistent identifier shared by processes using the same store.
74
+
75
+ Publish results include `outcome` and `storedBytes`.
76
+ Only `outcome: 'inserted'` contributes payload bytes. Deduplicated and no-recipient publishes report zero stored bytes.
package/lib/migrations.js CHANGED
@@ -1,7 +1,39 @@
1
+ import { sql } from '@hozon/db';
1
2
  export function hubStoreMigrations(ctx) {
2
3
  const t = ctx.types;
3
4
  const p = ctx.tablePrefix;
4
5
  return {
6
+ '1-attribution': {
7
+ async up (db) {
8
+ await db.schema.alterTable('hub_messages').addColumn('payload_size', 'integer', (col)=>col.notNull().defaultTo(0)).execute();
9
+ for (const column of [
10
+ 'account',
11
+ 'actor',
12
+ 'via'
13
+ ]){
14
+ await db.schema.alterTable('hub_messages').addColumn(column, t.text).execute();
15
+ }
16
+ await db.updateTable('hub_messages').set({
17
+ payload_size: sql`length(${sql.ref('payload')})`
18
+ }).execute();
19
+ await db.schema.createTable('hub_meta').addColumn('key', t.text, (col)=>col.notNull().primaryKey()).addColumn('value', t.text, (col)=>col.notNull()).execute();
20
+ await db.insertInto('hub_meta').values({
21
+ key: 'storeID',
22
+ value: crypto.randomUUID()
23
+ }).execute();
24
+ },
25
+ async down (db) {
26
+ await db.schema.dropTable('hub_meta').execute();
27
+ for (const column of [
28
+ 'via',
29
+ 'actor',
30
+ 'account',
31
+ 'payload_size'
32
+ ]){
33
+ await db.schema.alterTable('hub_messages').dropColumn(column).execute();
34
+ }
35
+ }
36
+ },
5
37
  '0-init': {
6
38
  async up (db) {
7
39
  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();
package/lib/store.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Adapter } from '@hozon/adapter';
2
- import { type Kysely } from '@hozon/db';
3
- import { type HubStore } from '@kumiai/hub-protocol';
2
+ import type { Kysely } from '@hozon/db';
3
+ import type { HubStore, OccupancyRow } from '@kumiai/hub-protocol';
4
4
  import type { HubTables } from './tables.js';
5
5
  export type HubStoreOptions = {
6
6
  /** Hub default retention in seconds: the floor for a topic's age bound. Default 0. */
@@ -36,5 +36,8 @@ export type HubSubscriptionReaper = {
36
36
  subscriberDID: string;
37
37
  }>>;
38
38
  };
39
- export type SQLHubStore = HubStore & HubSubscriptionReaper;
39
+ export type SQLHubStore = HubStore & HubSubscriptionReaper & {
40
+ occupancy(): Promise<Array<OccupancyRow>>;
41
+ storeID(): Promise<string>;
42
+ };
40
43
  export declare function createHubStore(params: CreateHubStoreParams): SQLHubStore;
package/lib/store.js CHANGED
@@ -1,4 +1,4 @@
1
- import { withStoreTransaction } from '@hozon/db';
1
+ import { sql, withStoreTransaction } from '@hozon/db';
2
2
  import { HeadMismatchError, KeyPackageQuotaExceededError, NotSubscribedError, RetentionExceededError, SubscriptionQuotaExceededError } from '@kumiai/hub-protocol';
3
3
  import { EventEmitter } from '@sozai/event';
4
4
  /**
@@ -110,7 +110,9 @@ export function createHubStore(params) {
110
110
  if (existing.sequence_id == null) throw new Error('Incomplete publish-id record');
111
111
  return {
112
112
  sequenceID: existing.sequence_id,
113
- deduped: true
113
+ deduped: true,
114
+ outcome: 'deduplicated',
115
+ storedBytes: 0
114
116
  };
115
117
  }
116
118
  }
@@ -128,12 +130,20 @@ export function createHubStore(params) {
128
130
  const recipients = subscriberRows.map((r)=>r.subscriber_did).filter((did)=>did !== senderDID);
129
131
  // 6. Append. A log frame is stored unconditionally (its reader may not exist yet). A mailbox
130
132
  // frame with zero recipients is not stored, but the sequenceID is still consumed.
131
- if (retain === 'log' || recipients.length > 0) {
133
+ let storedBytes = 0;
134
+ const inserted = retain === 'log' || recipients.length > 0;
135
+ if (inserted) {
136
+ const payload = adapter.encodeBinary(publishParams.payload);
137
+ storedBytes = payload.byteLength;
132
138
  await trx.insertInto('hub_messages').values({
133
139
  sequence_id: sequenceID,
134
140
  sender_did: senderDID,
135
141
  topic_id: topicID,
136
- payload: adapter.encodeBinary(publishParams.payload),
142
+ payload,
143
+ payload_size: storedBytes,
144
+ account: publishParams.attribution?.account ?? null,
145
+ actor: publishParams.attribution?.actor ?? null,
146
+ via: publishParams.attribution?.via ?? null,
137
147
  stored_at: Date.now(),
138
148
  retain
139
149
  }).execute();
@@ -185,7 +195,9 @@ export function createHubStore(params) {
185
195
  }
186
196
  return {
187
197
  sequenceID,
188
- deduped: false
198
+ deduped: false,
199
+ outcome: inserted ? 'inserted' : 'no-recipients',
200
+ storedBytes
189
201
  };
190
202
  });
191
203
  }
@@ -484,7 +496,35 @@ export function createHubStore(params) {
484
496
  const rows = await db.selectFrom('hub_subscriptions').select('subscriber_did').where('topic_id', '=', topicID).execute();
485
497
  return rows.map((r)=>r.subscriber_did);
486
498
  }
499
+ async function occupancy() {
500
+ const rows = await db.selectFrom('hub_messages').select((eb)=>{
501
+ return [
502
+ eb.fn.coalesce('account', 'sender_did').as('account'),
503
+ eb.fn.coalesce('actor', 'sender_did').as('actor'),
504
+ eb.fn.coalesce(eb.ref('via'), sql`'direct'`).as('via'),
505
+ eb.fn.sum('payload_size').as('bytes'),
506
+ eb.fn.countAll().as('frames')
507
+ ];
508
+ }).groupBy((eb)=>{
509
+ return [
510
+ eb.fn.coalesce('account', 'sender_did'),
511
+ eb.fn.coalesce('actor', 'sender_did'),
512
+ eb.fn.coalesce(eb.ref('via'), sql`'direct'`)
513
+ ];
514
+ }).execute();
515
+ return rows.map((row)=>({
516
+ ...row,
517
+ bytes: Number(row.bytes),
518
+ frames: Number(row.frames)
519
+ }));
520
+ }
521
+ async function storeID() {
522
+ const row = await db.selectFrom('hub_meta').select('value').where('key', '=', 'storeID').executeTakeFirstOrThrow();
523
+ return row.value;
524
+ }
487
525
  return {
526
+ occupancy,
527
+ storeID,
488
528
  events,
489
529
  publish,
490
530
  fetch,
package/lib/tables.d.ts CHANGED
@@ -1,9 +1,18 @@
1
1
  import type { ColumnType } from '@hozon/db';
2
+ import type { HubAttribution } from '@kumiai/hub-protocol';
2
3
  export type HubTables = {
4
+ hub_meta: {
5
+ key: string;
6
+ value: string;
7
+ };
3
8
  hub_messages: {
4
9
  sequence_id: string;
5
10
  sender_did: string;
6
11
  topic_id: string;
12
+ payload_size: ColumnType<number, number | undefined, number>;
13
+ account: ColumnType<string | null, string | null | undefined, string | null>;
14
+ actor: ColumnType<string | null, string | null | undefined, string | null>;
15
+ via: ColumnType<HubAttribution['via'] | null, HubAttribution['via'] | null | undefined, HubAttribution['via'] | null>;
7
16
  payload: Uint8Array;
8
17
  stored_at: number;
9
18
  retain: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kumiai/hub-store",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
4
4
  "description": "SQL hub store for Kumiai hubs, backed by Hozon",
5
5
  "keywords": [
6
6
  "hub",
@@ -25,20 +25,20 @@
25
25
  "lib/*"
26
26
  ],
27
27
  "dependencies": {
28
- "@hozon/adapter": "^0.2.1",
29
- "@hozon/db": "^0.5.0",
30
- "@kumiai/hub-protocol": "^0.12.0",
28
+ "@hozon/adapter": "^0.3.0",
29
+ "@hozon/db": "^0.5.1",
30
+ "@kumiai/hub-protocol": "^0.13.1",
31
31
  "@sozai/event": "^0.1.6"
32
32
  },
33
33
  "devDependencies": {
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",
34
+ "@enkaku/client": "^0.23.1",
35
+ "@enkaku/protocol": "^0.23.1",
36
+ "@enkaku/transport": "^0.23.1",
37
+ "@hozon/node-sqlite": "^0.3.0",
38
+ "@hozon/postgres": "^0.2.1",
39
+ "@kokuin/token": "^0.7.0",
40
+ "@kumiai/hub-conformance": "^0.13.0",
41
+ "@kumiai/hub-server": "^0.13.1",
42
42
  "@testcontainers/postgresql": "^12.2.1"
43
43
  },
44
44
  "engines": {