@kubun/store-p2p 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.
@@ -0,0 +1,222 @@
1
+ import { sql } from 'kysely';
2
+ import { COMMIT_HUB_UNIQUE_INDEX } from '../migrations.js';
3
+ /**
4
+ * Run `write` as a single atomic unit. The store API is built on whatever Kysely
5
+ * instance the provider hands it, which is a transaction-scoped one whenever the
6
+ * caller runs inside an ambient write transaction. Kysely rejects a nested
7
+ * `transaction()` call, and the ambient transaction already provides the
8
+ * atomicity, so in that case the statements run inline; otherwise a transaction
9
+ * is opened around them.
10
+ *
11
+ * Multi-statement invariants (clear-then-set, read-then-insert) must go through
12
+ * this: a crash between two independent statements can leave a group with no
13
+ * commit hub at all, and a device with no commit hub cannot commit.
14
+ */ async function writeAtomically(db, write) {
15
+ if (db.isTransaction) {
16
+ await write(db);
17
+ return;
18
+ }
19
+ await db.transaction().execute(async (tx)=>{
20
+ await write(tx);
21
+ });
22
+ }
23
+ /**
24
+ * Run `run` so that a failed statement inside it can be caught and recovered
25
+ * from. Postgres aborts the WHOLE transaction on any statement error, so a
26
+ * caller inside an ambient transaction that catches one and carries on would
27
+ * otherwise hit `25P02` on everything after it. A savepoint is the portable
28
+ * fence; outside a transaction there is nothing to fence, because the failed
29
+ * unit already rolled itself back.
30
+ */ async function recoverable(db, run) {
31
+ if (!db.isTransaction) {
32
+ return await run();
33
+ }
34
+ await sql`SAVEPOINT kubun_p2p_recoverable`.execute(db);
35
+ try {
36
+ const result = await run();
37
+ await sql`RELEASE SAVEPOINT kubun_p2p_recoverable`.execute(db);
38
+ return result;
39
+ } catch (error) {
40
+ await sql`ROLLBACK TO SAVEPOINT kubun_p2p_recoverable`.execute(db);
41
+ throw error;
42
+ }
43
+ }
44
+ /**
45
+ * Whether `error` is the partial unique index refusing a second commit-hub
46
+ * designation for one group. Matched on the index name, which both drivers put
47
+ * in the message (Postgres also in `constraint_name`) — the error classes and
48
+ * codes themselves have nothing in common across the two.
49
+ */ function isCommitHubRaceLoss(error) {
50
+ if (error == null || typeof error !== 'object') {
51
+ return false;
52
+ }
53
+ const { message, constraint_name: constraint } = error;
54
+ return typeof message === 'string' && message.includes(COMMIT_HUB_UNIQUE_INDEX) || constraint === COMMIT_HUB_UNIQUE_INDEX;
55
+ }
56
+ /**
57
+ * Bind `binding` to its group, claiming the commit-hub designation when
58
+ * `claimDesignation` is set and the group carries none yet. Returns whether a
59
+ * row was actually inserted.
60
+ */ async function bindGroupHub(db, binding, claimDesignation) {
61
+ let inserted = false;
62
+ await writeAtomically(db, async (tx)=>{
63
+ let designate = false;
64
+ if (claimDesignation) {
65
+ const existing = await tx.selectFrom('kubun_p2p_group_hubs').select('hub_id').where('group_id', '=', binding.group_id).where('is_commit_hub', '=', 1).executeTakeFirst();
66
+ designate = existing == null;
67
+ }
68
+ const result = await tx.insertInto('kubun_p2p_group_hubs').values({
69
+ group_id: binding.group_id,
70
+ hub_id: binding.hub_id,
71
+ is_commit_hub: designate ? 1 : 0
72
+ }).onConflict((oc)=>oc.columns([
73
+ 'group_id',
74
+ 'hub_id'
75
+ ]).doNothing()).executeTakeFirst();
76
+ inserted = (result.numInsertedOrUpdatedRows ?? 0n) > 0n;
77
+ });
78
+ return inserted;
79
+ }
80
+ export function createHubOps(db, adapter) {
81
+ return {
82
+ async insertHub (hub) {
83
+ await db.insertInto('kubun_p2p_hubs').values({
84
+ id: hub.id,
85
+ label: hub.label,
86
+ url: hub.url,
87
+ server_did: hub.server_did
88
+ }).execute();
89
+ },
90
+ async getHub (id) {
91
+ return await db.selectFrom('kubun_p2p_hubs').selectAll().where('id', '=', id).executeTakeFirst();
92
+ },
93
+ async getHubByURL (url) {
94
+ return await db.selectFrom('kubun_p2p_hubs').selectAll().where('url', '=', url).executeTakeFirst();
95
+ },
96
+ async updateHub (id, update) {
97
+ const values = {};
98
+ if (update.label !== undefined) values.label = update.label;
99
+ if (update.url !== undefined) values.url = update.url;
100
+ if (update.server_did !== undefined) values.server_did = update.server_did;
101
+ values.updated_at = adapter.encodeTimestamp(new Date());
102
+ await db.updateTable('kubun_p2p_hubs').set(values).where('id', '=', id).execute();
103
+ },
104
+ async deleteHub (id) {
105
+ await db.deleteFrom('kubun_p2p_hubs').where('id', '=', id).execute();
106
+ },
107
+ async listHubs () {
108
+ return await db.selectFrom('kubun_p2p_hubs').selectAll().execute();
109
+ },
110
+ // --- Group-hub binding operations ---
111
+ async insertGroupHub (binding) {
112
+ // The first hub bound to a group becomes its canonical commit hub: a group
113
+ // commits through exactly one hub, and with none yet designated this bind
114
+ // is the deterministic choice. Later binds default to 0, leaving the
115
+ // designation on the first-bound hub until an explicit `setCommitHub`.
116
+ //
117
+ // Running the read and the insert as one unit does NOT settle a race: two
118
+ // concurrent first binds touch different rows, so neither blocks the other
119
+ // and both read "no commit hub". The partial unique index is what arbitrates,
120
+ // by rejecting the second claim — so a rejection here means another bind
121
+ // won the designation, and this one adopts the winner and binds unflagged.
122
+ // Erroring instead would leave the loser unbound over a designation it
123
+ // never needed to own.
124
+ try {
125
+ return await recoverable(db, ()=>bindGroupHub(db, binding, true));
126
+ } catch (error) {
127
+ if (!isCommitHubRaceLoss(error)) {
128
+ throw error;
129
+ }
130
+ return await bindGroupHub(db, binding, false);
131
+ }
132
+ },
133
+ async deleteGroupHub (params) {
134
+ const result = await db.deleteFrom('kubun_p2p_group_hubs').where('group_id', '=', params.groupID).where('hub_id', '=', params.hubID).executeTakeFirst();
135
+ return (result.numDeletedRows ?? 0n) > 0n;
136
+ },
137
+ async listGroupIDsByHubID (hubID) {
138
+ const rows = await db.selectFrom('kubun_p2p_group_hubs').select('group_id').where('hub_id', '=', hubID).execute();
139
+ return rows.map((r)=>r.group_id);
140
+ },
141
+ async listHubsByGroupID (groupID) {
142
+ return await db.selectFrom('kubun_p2p_group_hubs').innerJoin('kubun_p2p_hubs', 'kubun_p2p_hubs.id', 'kubun_p2p_group_hubs.hub_id').selectAll('kubun_p2p_hubs').where('kubun_p2p_group_hubs.group_id', '=', groupID).execute();
143
+ },
144
+ async listGroupsByHubID (hubID) {
145
+ const rows = await db.selectFrom('kubun_p2p_group_hubs').innerJoin('kubun_p2p_groups', 'kubun_p2p_groups.id', 'kubun_p2p_group_hubs.group_id').select([
146
+ 'kubun_p2p_groups.id as id',
147
+ 'kubun_p2p_groups.name as name',
148
+ 'kubun_p2p_groups.description as description',
149
+ 'kubun_p2p_groups.created_by as created_by',
150
+ 'kubun_p2p_groups.status as status',
151
+ 'kubun_p2p_groups.hlc as hlc',
152
+ 'kubun_p2p_groups.created_at as created_at',
153
+ 'kubun_p2p_groups.updated_at as updated_at'
154
+ ]).where('kubun_p2p_group_hubs.hub_id', '=', hubID).execute();
155
+ return rows;
156
+ },
157
+ async deleteGroupHubsByHubID (hubID) {
158
+ await db.deleteFrom('kubun_p2p_group_hubs').where('hub_id', '=', hubID).execute();
159
+ },
160
+ async getCommitHub (groupID) {
161
+ return await db.selectFrom('kubun_p2p_group_hubs').innerJoin('kubun_p2p_hubs', 'kubun_p2p_hubs.id', 'kubun_p2p_group_hubs.hub_id').selectAll('kubun_p2p_hubs').where('kubun_p2p_group_hubs.group_id', '=', groupID).where('kubun_p2p_group_hubs.is_commit_hub', '=', 1).executeTakeFirst();
162
+ },
163
+ async setCommitHub (params) {
164
+ // Clear then set so at most one bound hub per group carries the flag — the
165
+ // single-designation invariant a fork-free commit log depends on. Both
166
+ // statements run as one unit: a crash after the clear would leave the group
167
+ // with no commit hub, and a device with none cannot commit.
168
+ await writeAtomically(db, async (tx)=>{
169
+ // The hub must already be bound. Without this check an unbound hubID
170
+ // clears every flag and sets none — silently leaving the group unable to
171
+ // commit, which reads as a hang rather than an error.
172
+ const bound = await tx.selectFrom('kubun_p2p_group_hubs').select('hub_id').where('group_id', '=', params.groupID).where('hub_id', '=', params.hubID).executeTakeFirst();
173
+ if (bound == null) {
174
+ throw new Error(`Cannot designate commit hub ${params.hubID} for group ${params.groupID}: the hub is not bound to the group`);
175
+ }
176
+ await tx.updateTable('kubun_p2p_group_hubs').set({
177
+ is_commit_hub: 0
178
+ }).where('group_id', '=', params.groupID).execute();
179
+ await tx.updateTable('kubun_p2p_group_hubs').set({
180
+ is_commit_hub: 1
181
+ }).where('group_id', '=', params.groupID).where('hub_id', '=', params.hubID).execute();
182
+ });
183
+ },
184
+ async reconcileCommitHub (groupID, commitHub) {
185
+ // No folded designation: leave the local auto-designated flag untouched.
186
+ if (commitHub == null) {
187
+ return;
188
+ }
189
+ // Match and reflag as one unit: a crash between the clear and the set
190
+ // would leave the group with no commit hub at all.
191
+ await writeAtomically(db, async (tx)=>{
192
+ // The bound hub whose URL matches the designation, if this device has
193
+ // bound it. The join is what makes "bound to THIS group with that URL"
194
+ // the match, rather than any hub row that happens to carry the URL. When
195
+ // both sides carry a server DID it must match too: the same URL served
196
+ // by a different server DID is a different hub identity, and flagging it
197
+ // would designate a peer the group never chose.
198
+ let matchQuery = tx.selectFrom('kubun_p2p_group_hubs').innerJoin('kubun_p2p_hubs', 'kubun_p2p_hubs.id', 'kubun_p2p_group_hubs.hub_id').select('kubun_p2p_group_hubs.hub_id as hub_id').where('kubun_p2p_group_hubs.group_id', '=', groupID).where('kubun_p2p_hubs.url', '=', commitHub.url);
199
+ if (commitHub.serverDID != null) {
200
+ // A bound row with no recorded server DID stays eligible — it has not
201
+ // claimed a conflicting identity, only an unknown one.
202
+ matchQuery = matchQuery.where((eb)=>eb.or([
203
+ eb('kubun_p2p_hubs.server_did', '=', commitHub.serverDID),
204
+ eb('kubun_p2p_hubs.server_did', 'is', null)
205
+ ]));
206
+ }
207
+ const match = await matchQuery.executeTakeFirst();
208
+ // Clear every row first so at most one carries the flag — and so a
209
+ // designated-but-unbound hub leaves the device with NO flagged row rather
210
+ // than a stale non-designated one (that fallback is the fork).
211
+ await tx.updateTable('kubun_p2p_group_hubs').set({
212
+ is_commit_hub: 0
213
+ }).where('group_id', '=', groupID).execute();
214
+ if (match != null) {
215
+ await tx.updateTable('kubun_p2p_group_hubs').set({
216
+ is_commit_hub: 1
217
+ }).where('group_id', '=', groupID).where('hub_id', '=', match.hub_id).execute();
218
+ }
219
+ });
220
+ }
221
+ };
222
+ }
@@ -0,0 +1,21 @@
1
+ import type { Adapter } from '@kubun/db-adapter';
2
+ import type { Kysely } from 'kysely';
3
+ import type { GroupMLSState, InsertGroupMLSState, P2PStoreTables } from '../tables.js';
4
+ /** Per-device MLS state for each group. */
5
+ export type MLSOps = {
6
+ saveMLSState(state: InsertGroupMLSState): Promise<void>;
7
+ getMLSState(groupID: string, deviceID: string): Promise<GroupMLSState | undefined>;
8
+ deleteMLSState(groupID: string, deviceID: string): Promise<void>;
9
+ listMLSStates(groupID: string): Promise<Array<GroupMLSState>>;
10
+ /**
11
+ * The groups this device holds MLS state for, keyed on the same `(group_id,
12
+ * device_id)` pair {@link getMLSState} reads.
13
+ *
14
+ * **No production caller — a test observer.** It once answered "have I joined
15
+ * this group", which it cannot: a removed device still holds state. Kept for
16
+ * asserting that a departure left nothing behind.
17
+ */
18
+ listMLSStateGroupIDs(deviceID: string): Promise<Array<string>>;
19
+ deleteGroupMLSStates(groupID: string): Promise<void>;
20
+ };
21
+ export declare function createMLSOps(db: Kysely<P2PStoreTables>, adapter: Adapter): MLSOps;
package/lib/ops/mls.js ADDED
@@ -0,0 +1,50 @@
1
+ export function createMLSOps(db, adapter) {
2
+ return {
3
+ async saveMLSState (state) {
4
+ await db.insertInto('kubun_p2p_group_mls_state').values({
5
+ group_id: state.group_id,
6
+ device_id: state.device_id,
7
+ mls_state: adapter.encodeBinary(state.mls_state),
8
+ credential: state.credential,
9
+ epoch: state.epoch,
10
+ ledger_entries: state.ledger_entries
11
+ }).onConflict((oc)=>oc.columns([
12
+ 'group_id',
13
+ 'device_id'
14
+ ]).doUpdateSet((eb)=>({
15
+ mls_state: eb.ref('excluded.mls_state'),
16
+ credential: eb.ref('excluded.credential'),
17
+ epoch: eb.ref('excluded.epoch'),
18
+ ledger_entries: eb.ref('excluded.ledger_entries'),
19
+ updated_at: adapter.encodeTimestamp(new Date())
20
+ }))// Monotonic-epoch guard: only overwrite when the incoming epoch is
21
+ // at least the stored one. The MLS epoch never moves backwards, so a
22
+ // write carrying a lower epoch is a stale handle re-persisting an
23
+ // already-superseded ratchet — apply it and we roll the ratchet back.
24
+ // When this predicate is false the existing row is left untouched
25
+ // with no error (a silent no-op); a fresh insert has no conflict and
26
+ // is unaffected. The bound is `<=`, not `<`: decrypting an
27
+ // application message advances the secret-tree generation WITHIN an
28
+ // epoch while `epoch` stays constant, so a legitimate same-epoch
29
+ // re-persist carries a changed `mls_state` blob at the same epoch and
30
+ // must still win — `<` would reject it and wedge decryption.
31
+ .where((eb)=>eb('kubun_p2p_group_mls_state.epoch', '<=', eb.ref('excluded.epoch')))).execute();
32
+ },
33
+ async getMLSState (groupID, deviceID) {
34
+ return await db.selectFrom('kubun_p2p_group_mls_state').selectAll().where('group_id', '=', groupID).where('device_id', '=', deviceID).executeTakeFirst();
35
+ },
36
+ async deleteMLSState (groupID, deviceID) {
37
+ await db.deleteFrom('kubun_p2p_group_mls_state').where('group_id', '=', groupID).where('device_id', '=', deviceID).execute();
38
+ },
39
+ async listMLSStates (groupID) {
40
+ return await db.selectFrom('kubun_p2p_group_mls_state').selectAll().where('group_id', '=', groupID).execute();
41
+ },
42
+ async listMLSStateGroupIDs (deviceID) {
43
+ const rows = await db.selectFrom('kubun_p2p_group_mls_state').select('group_id').where('device_id', '=', deviceID).execute();
44
+ return rows.map((row)=>row.group_id);
45
+ },
46
+ async deleteGroupMLSStates (groupID) {
47
+ await db.deleteFrom('kubun_p2p_group_mls_state').where('group_id', '=', groupID).execute();
48
+ }
49
+ };
50
+ }
@@ -0,0 +1,22 @@
1
+ import type { Adapter } from '@kubun/db-adapter';
2
+ import type { Kysely } from 'kysely';
3
+ import type { InsertSyncPeer, P2PStoreTables, SyncPeer } from '../tables.js';
4
+ /** Sync peers and the broadcast dedup log. */
5
+ export type SyncOps = {
6
+ addSyncPeer(peer: InsertSyncPeer): Promise<void>;
7
+ getSyncPeer(peerDID: string): Promise<SyncPeer | undefined>;
8
+ listSyncPeers(): Promise<Array<SyncPeer>>;
9
+ updateSyncPeer(peerDID: string, update: Partial<InsertSyncPeer>): Promise<void>;
10
+ removeSyncPeer(peerDID: string): Promise<void>;
11
+ recordBroadcast(entry: {
12
+ docID: string;
13
+ version: string;
14
+ mlsGroupID: string;
15
+ }): Promise<boolean>;
16
+ hasBroadcast(entry: {
17
+ docID: string;
18
+ version: string;
19
+ mlsGroupID: string;
20
+ }): Promise<boolean>;
21
+ };
22
+ export declare function createSyncOps(db: Kysely<P2PStoreTables>, adapter: Adapter): SyncOps;
@@ -0,0 +1,50 @@
1
+ export function createSyncOps(db, adapter) {
2
+ return {
3
+ async addSyncPeer (peer) {
4
+ const config = adapter.encodeJSON(peer.config);
5
+ await db.insertInto('kubun_p2p_sync_peers').values({
6
+ ...peer,
7
+ config
8
+ }).onConflict((oc)=>oc.column('peer_did').doUpdateSet({
9
+ endpoint: peer.endpoint,
10
+ config,
11
+ updated_at: peer.updated_at
12
+ })).execute();
13
+ },
14
+ async getSyncPeer (peerDID) {
15
+ return await db.selectFrom('kubun_p2p_sync_peers').selectAll().where('peer_did', '=', peerDID).executeTakeFirst();
16
+ },
17
+ async listSyncPeers () {
18
+ return await db.selectFrom('kubun_p2p_sync_peers').selectAll().execute();
19
+ },
20
+ async updateSyncPeer (peerDID, update) {
21
+ const values = {};
22
+ if (update.endpoint != null) values.endpoint = update.endpoint;
23
+ if (update.config != null) values.config = adapter.encodeJSON(update.config);
24
+ // Through the adapter, like every sibling write: the column is a
25
+ // `timestamptz(3)` on Postgres, which a raw epoch number is not.
26
+ values.updated_at = adapter.encodeTimestamp(new Date());
27
+ await db.updateTable('kubun_p2p_sync_peers').set(values).where('peer_did', '=', peerDID).execute();
28
+ },
29
+ async removeSyncPeer (peerDID) {
30
+ await db.deleteFrom('kubun_p2p_sync_peers').where('peer_did', '=', peerDID).execute();
31
+ },
32
+ // --- Broadcast log operations ---
33
+ async recordBroadcast (entry) {
34
+ const result = await db.insertInto('kubun_p2p_broadcast_log').values({
35
+ doc_id: entry.docID,
36
+ version: entry.version,
37
+ mls_group_id: entry.mlsGroupID
38
+ }).onConflict((oc)=>oc.columns([
39
+ 'doc_id',
40
+ 'version',
41
+ 'mls_group_id'
42
+ ]).doNothing()).executeTakeFirst();
43
+ return (result.numInsertedOrUpdatedRows ?? 0n) > 0n;
44
+ },
45
+ async hasBroadcast (entry) {
46
+ const row = await db.selectFrom('kubun_p2p_broadcast_log').select('doc_id').where('doc_id', '=', entry.docID).where('version', '=', entry.version).where('mls_group_id', '=', entry.mlsGroupID).limit(1).executeTakeFirst();
47
+ return row != null;
48
+ }
49
+ };
50
+ }
package/lib/tables.d.ts CHANGED
@@ -1,15 +1,33 @@
1
1
  import type { CreatedAtColumn, UpdatedAtColumn } from '@kubun/db-adapter';
2
2
  import type { ColumnType, Insertable, Selectable, Updateable } from 'kysely';
3
3
  type DefaultedStringColumn = ColumnType<string, string | undefined, string>;
4
+ type DefaultedNumberColumn = ColumnType<number, number | undefined, number>;
4
5
  type NullableStringColumn = ColumnType<string | null, string | null | undefined, string | null>;
5
6
  type JSONArrayColumn<T> = ColumnType<Array<T>, Array<T> | undefined, Array<T>>;
6
7
  type JSONValueColumn<T> = ColumnType<T, T, T>;
8
+ /**
9
+ * Whether this device belongs to the group right now.
10
+ *
11
+ * Only the two states that have a writer. An `invited` state would have none:
12
+ * a group row is written when a join COMPLETES, so there is no moment at which
13
+ * a row exists and the device has merely been asked.
14
+ */
15
+ export type GroupStatus = 'joined' | 'left';
7
16
  export type GroupTable = {
8
17
  id: string;
9
18
  name: string;
10
19
  description: DefaultedStringColumn;
11
20
  created_by: string;
12
21
  hlc: string;
22
+ /**
23
+ * Leaving keeps the row: its circles, circle members, peer announcements and
24
+ * hub bindings hang off it, and a device invited back would otherwise have to
25
+ * rebuild state it still holds. The status is what every LISTING reads, while
26
+ * a point lookup by id deliberately still finds a departed group — that is
27
+ * what makes rejoining the same group possible instead of forking a second
28
+ * row for it.
29
+ */
30
+ status: DefaultedStringColumn;
13
31
  created_at: CreatedAtColumn;
14
32
  updated_at: UpdatedAtColumn;
15
33
  };
@@ -34,6 +52,7 @@ export type CircleTable = {
34
52
  catalog_ids: JSONArrayColumn<string>;
35
53
  hlc: string;
36
54
  removed_at_hlc: NullableStringColumn;
55
+ open: DefaultedNumberColumn;
37
56
  created_at: CreatedAtColumn;
38
57
  updated_at: UpdatedAtColumn;
39
58
  };
@@ -50,52 +69,25 @@ export type CircleMemberTable = {
50
69
  };
51
70
  export type CircleMember = Selectable<CircleMemberTable>;
52
71
  export type InsertCircleMember = Insertable<CircleMemberTable>;
53
- export type DelegationTokenTable = {
54
- jti: string;
55
- grantor: string;
56
- audience: string;
57
- token: string;
58
- resource: string;
59
- act: string;
60
- exp: number;
72
+ export type GroupDelegationTable = {
61
73
  group_id: string;
62
- hlc: string;
63
- created_at: CreatedAtColumn;
64
- updated_at: UpdatedAtColumn;
65
- };
66
- export type DelegationToken = Selectable<DelegationTokenTable>;
67
- export type InsertDelegationToken = Insertable<DelegationTokenTable>;
68
- export type RevokedCapabilityTable = {
69
74
  jti: string;
70
- revoker_did: string;
71
- revoked_iat: number;
72
- revocation_token: string;
73
- verified_at: number | null;
74
- cap_exp: number | null;
75
- group_id: string;
76
- hlc: string;
77
- created_at: CreatedAtColumn;
78
- updated_at: UpdatedAtColumn;
79
- };
80
- export type RevokedCapability = Selectable<RevokedCapabilityTable>;
81
- export type InsertRevokedCapability = Insertable<RevokedCapabilityTable>;
82
- export type HeldRevocation = {
83
- jti: string;
84
- grantor: string;
85
- audience: string;
86
- revoker_did: string;
87
- revoked_iat: number;
88
- verified_at: number;
89
- cap_exp: number | null;
90
- group_id: string;
91
75
  };
76
+ export type GroupDelegation = Selectable<GroupDelegationTable>;
77
+ export type InsertGroupDelegation = Insertable<GroupDelegationTable>;
92
78
  export type GroupMLSStateTable = {
93
79
  group_id: string;
94
80
  device_id: string;
95
81
  mls_state: Uint8Array;
96
82
  credential: string;
97
83
  epoch: number;
98
- root_capability: DefaultedStringColumn;
84
+ /**
85
+ * The handle's signed control-ledger tokens, JSON-encoded in enactment order.
86
+ * Replayed into `restoreGroup` so a restored handle folds the same roster it
87
+ * held before: without them the fold reverts to the genesis anchor alone and
88
+ * every admin but the creator loses authority across a restart.
89
+ */
90
+ ledger_entries: DefaultedStringColumn;
99
91
  updated_at: UpdatedAtColumn;
100
92
  };
101
93
  export type GroupMLSState = Selectable<GroupMLSStateTable>;
@@ -105,12 +97,6 @@ export type SyncPeerTable = {
105
97
  id: string;
106
98
  peer_did: string;
107
99
  endpoint: string;
108
- mode: string;
109
- allowed_users: JSONValueColumn<Array<string> | {
110
- all: boolean;
111
- }>;
112
- priority: DefaultedStringColumn;
113
- trust_level: string;
114
100
  config: JSONValueColumn<Record<string, unknown>>;
115
101
  created_at: CreatedAtColumn;
116
102
  updated_at: UpdatedAtColumn;
@@ -134,6 +120,45 @@ export type BroadcastLogTable = {
134
120
  };
135
121
  export type BroadcastLogEntry = Selectable<BroadcastLogTable>;
136
122
  export type InsertBroadcastLogEntry = Insertable<BroadcastLogTable>;
123
+ export type ControlRequestTable = {
124
+ id: string;
125
+ group_id: string;
126
+ kind: string;
127
+ status: DefaultedStringColumn;
128
+ error: NullableStringColumn;
129
+ result: NullableStringColumn;
130
+ enqueued_at: string;
131
+ settled_at: NullableStringColumn;
132
+ };
133
+ export type ControlRequest = Selectable<ControlRequestTable>;
134
+ export type InsertControlRequest = Insertable<ControlRequestTable>;
135
+ export type CommitJournalEntryTable = {
136
+ group_id: string;
137
+ publish_id: string;
138
+ expected_head: string | null;
139
+ epoch: number;
140
+ accepted_as: NullableStringColumn;
141
+ commit: Uint8Array;
142
+ bodies: JSONArrayColumn<string>;
143
+ kind: string;
144
+ journal: Uint8Array;
145
+ };
146
+ export type CommitJournalEntry = Selectable<CommitJournalEntryTable>;
147
+ export type InsertCommitJournalEntry = Insertable<CommitJournalEntryTable>;
148
+ export type GroupAnchorTable = {
149
+ group_id: string;
150
+ secret: Uint8Array;
151
+ epoch: number;
152
+ };
153
+ export type GroupAnchor = Selectable<GroupAnchorTable>;
154
+ export type InsertGroupAnchor = Insertable<GroupAnchorTable>;
155
+ export type AppCursorTable = {
156
+ group_id: string;
157
+ topic_id: string;
158
+ position: string;
159
+ };
160
+ export type AppCursor = Selectable<AppCursorTable>;
161
+ export type InsertAppCursor = Insertable<AppCursorTable>;
137
162
  export type HubTable = {
138
163
  id: string;
139
164
  label: string | null;
@@ -147,10 +172,48 @@ export type InsertHub = Insertable<HubTable>;
147
172
  export type GroupHubTable = {
148
173
  group_id: string;
149
174
  hub_id: string;
175
+ is_commit_hub: DefaultedNumberColumn;
150
176
  created_at: CreatedAtColumn;
151
177
  };
152
178
  export type GroupHub = Selectable<GroupHubTable>;
153
179
  export type InsertGroupHub = Insertable<GroupHubTable>;
180
+ /**
181
+ * One advertised capability: an identifier matched EXACTLY on both halves, so a
182
+ * peer answering `kubun/sync-merkle` v1 is not offered a v2 session. `transports`
183
+ * is null for a capability the hub tunnel already addresses, and is where a
184
+ * future direct transport carries its own addressing hint.
185
+ */
186
+ export type PeerCapabilityRecord = {
187
+ protocol: string;
188
+ version: number;
189
+ transports: Array<string> | null;
190
+ };
191
+ export type PeerAnnouncementTable = {
192
+ group_id: string;
193
+ peer_did: string;
194
+ label: string;
195
+ availability: string;
196
+ capabilities: JSONArrayColumn<PeerCapabilityRecord>;
197
+ hlc: string;
198
+ announced_at: UpdatedAtColumn;
199
+ announced_epoch: DefaultedNumberColumn;
200
+ };
201
+ export type PeerAnnouncement = Selectable<PeerAnnouncementTable>;
202
+ export type InsertPeerAnnouncement = Insertable<PeerAnnouncementTable>;
203
+ export type LocalPeerProfileTable = {
204
+ peer_did: string;
205
+ label: string;
206
+ availability: string;
207
+ capabilities: JSONArrayColumn<PeerCapabilityRecord>;
208
+ updated_at: UpdatedAtColumn;
209
+ };
210
+ export type LocalPeerProfile = Selectable<LocalPeerProfileTable>;
211
+ export type PendingJoinRequestTable = {
212
+ did: string;
213
+ payload: string;
214
+ created_at: CreatedAtColumn;
215
+ };
216
+ export type PendingJoinRequestRow = Selectable<PendingJoinRequestTable>;
154
217
  export type PaginationParams = {
155
218
  limit?: number;
156
219
  offset?: number;
@@ -164,13 +227,19 @@ export type P2PStoreTables = {
164
227
  kubun_p2p_group_members: GroupMemberTable;
165
228
  kubun_p2p_circles: CircleTable;
166
229
  kubun_p2p_circle_members: CircleMemberTable;
167
- kubun_p2p_delegation_tokens: DelegationTokenTable;
168
- kubun_p2p_revoked_capabilities: RevokedCapabilityTable;
230
+ kubun_p2p_group_delegation: GroupDelegationTable;
169
231
  kubun_p2p_group_mls_state: GroupMLSStateTable;
170
232
  kubun_p2p_sync_peers: SyncPeerTable;
171
233
  kubun_p2p_sync_checkpoints: SyncCheckpointTable;
172
234
  kubun_p2p_broadcast_log: BroadcastLogTable;
235
+ kubun_p2p_control_requests: ControlRequestTable;
236
+ kubun_p2p_commit_journal: CommitJournalEntryTable;
237
+ kubun_p2p_group_anchor: GroupAnchorTable;
238
+ kubun_p2p_app_cursor: AppCursorTable;
173
239
  kubun_p2p_hubs: HubTable;
174
240
  kubun_p2p_group_hubs: GroupHubTable;
241
+ kubun_p2p_peer_announcements: PeerAnnouncementTable;
242
+ kubun_p2p_local_peer_profile: LocalPeerProfileTable;
243
+ kubun_p2p_pending_join_requests: PendingJoinRequestTable;
175
244
  };
176
245
  export {};
package/lib/tables.js CHANGED
@@ -1 +1,2 @@
1
- export{};
1
+ // --- Aggregate P2PStoreTables type ---
2
+ export { };
package/package.json CHANGED
@@ -1,40 +1,41 @@
1
1
  {
2
2
  "name": "@kubun/store-p2p",
3
- "version": "0.10.1",
4
- "license": "see LICENSE.md",
3
+ "version": "0.12.0",
5
4
  "keywords": [],
5
+ "license": "see LICENSE.md",
6
+ "sideEffects": false,
6
7
  "type": "module",
7
- "main": "lib/index.js",
8
- "types": "lib/index.d.ts",
9
8
  "exports": {
10
9
  ".": "./lib/index.js"
11
10
  },
11
+ "main": "lib/index.js",
12
+ "types": "lib/index.d.ts",
12
13
  "files": [
13
14
  "lib/*",
14
15
  "LICENSE.md"
15
16
  ],
16
- "sideEffects": false,
17
17
  "dependencies": {
18
- "@enkaku/capability": "^0.16.0",
19
- "kysely": "^0.29.2",
20
- "@kubun/db-adapter": "^0.10.0",
21
- "@kubun/db": "^0.10.0",
22
- "@kubun/logger": "^0.10.0"
18
+ "@kokuin/capability": "^0.2.1",
19
+ "@kokuin/token": "^0.3.0",
20
+ "kysely": "^0.29.4",
21
+ "@kubun/db": "^0.12.0",
22
+ "@kubun/db-adapter": "^0.12.0",
23
+ "@kubun/logger": "^0.12.0"
23
24
  },
24
25
  "devDependencies": {
25
- "@testcontainers/postgresql": "^12.0.1",
26
- "@kubun/db-postgres": "^0.10.0",
27
- "@kubun/hlc": "^0.10.1",
28
- "@kubun/db-better-sqlite": "^0.10.0"
26
+ "@testcontainers/postgresql": "^12.1.0",
27
+ "@kubun/db-better-sqlite": "^0.12.0",
28
+ "@kubun/db-postgres": "^0.12.0",
29
+ "@kubun/hlc": "^0.12.0"
29
30
  },
30
31
  "scripts": {
32
+ "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
31
33
  "build:clean": "del lib",
32
- "build:js": "swc src -d ./lib --config-file ../../swc.json --strip-leading-paths",
34
+ "build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
33
35
  "build:types": "tsc --emitDeclarationOnly --skipLibCheck",
34
36
  "build:types:ci": "tsc --emitDeclarationOnly --declarationMap false",
35
- "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
37
+ "test": "pnpm run test:types && pnpm run test:unit",
36
38
  "test:types": "tsc --noEmit -p tsconfig.test.json",
37
- "test:unit": "vitest run",
38
- "test": "pnpm run test:types && pnpm run test:unit"
39
+ "test:unit": "vitest run"
39
40
  }
40
41
  }