@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,90 @@
1
+ import { normalizeDID } from '@kokuin/token';
2
+ /**
3
+ * How long a prepared join request stays usable. The exchange it belongs to is a
4
+ * human round-trip — a payload pasted into a message, answered whenever the other
5
+ * person gets to it — so the window is days, not minutes. Past it the request is
6
+ * abandoned, and an abandoned one is private key material sitting at rest with
7
+ * nothing left to answer it: the row is deleted rather than kept for a reply that
8
+ * is not coming.
9
+ */ const PENDING_JOIN_REQUEST_TTL_MS = 7 * 24 * 60 * 60 * 1000;
10
+ /** The encoded `created_at` a row must be at or after to still be usable. */ function expiredBefore(adapter) {
11
+ return adapter.encodeTimestamp(new Date(Date.now() - PENDING_JOIN_REQUEST_TTL_MS));
12
+ }
13
+ export function createDiscoveryOps(db, adapter) {
14
+ return {
15
+ async upsertPeerAnnouncement (params) {
16
+ const values = {
17
+ group_id: params.groupID,
18
+ // Canonical DID form on every key, as the membership rows use: the row
19
+ // is deleted by DIDs the MLS roster names, and a key written in one form
20
+ // and deleted in another would leave an ex-member's route behind.
21
+ peer_did: normalizeDID(params.peerDID),
22
+ label: params.label,
23
+ availability: params.availability,
24
+ capabilities: adapter.encodeJSON(params.capabilities),
25
+ hlc: params.hlc,
26
+ announced_at: adapter.encodeTimestamp(new Date()),
27
+ announced_epoch: params.epoch
28
+ };
29
+ await db.insertInto('kubun_p2p_peer_announcements').values(values).onConflict((oc)=>oc.columns([
30
+ 'group_id',
31
+ 'peer_did'
32
+ ]).doUpdateSet((eb)=>({
33
+ label: eb.ref('excluded.label'),
34
+ availability: eb.ref('excluded.availability'),
35
+ capabilities: eb.ref('excluded.capabilities'),
36
+ hlc: eb.ref('excluded.hlc'),
37
+ announced_at: eb.ref('excluded.announced_at'),
38
+ announced_epoch: eb.ref('excluded.announced_epoch')
39
+ }))).execute();
40
+ },
41
+ async getPeerAnnouncement (params) {
42
+ return await db.selectFrom('kubun_p2p_peer_announcements').selectAll().where('group_id', '=', params.groupID).where('peer_did', '=', normalizeDID(params.peerDID)).executeTakeFirst();
43
+ },
44
+ async listPeerAnnouncements (groupID) {
45
+ return await db.selectFrom('kubun_p2p_peer_announcements').selectAll().where('group_id', '=', groupID).orderBy('peer_did').execute();
46
+ },
47
+ async deletePeerAnnouncements (params) {
48
+ if (params.peerDIDs.length === 0) {
49
+ return;
50
+ }
51
+ await db.deleteFrom('kubun_p2p_peer_announcements').where('group_id', '=', params.groupID).where('peer_did', 'in', params.peerDIDs.map(normalizeDID)).execute();
52
+ },
53
+ async setLocalPeerProfile (params) {
54
+ const values = {
55
+ peer_did: normalizeDID(params.peerDID),
56
+ label: params.label,
57
+ availability: params.availability,
58
+ capabilities: adapter.encodeJSON(params.capabilities),
59
+ updated_at: adapter.encodeTimestamp(new Date())
60
+ };
61
+ await db.insertInto('kubun_p2p_local_peer_profile').values(values).onConflict((oc)=>oc.column('peer_did').doUpdateSet((eb)=>({
62
+ label: eb.ref('excluded.label'),
63
+ availability: eb.ref('excluded.availability'),
64
+ capabilities: eb.ref('excluded.capabilities'),
65
+ updated_at: eb.ref('excluded.updated_at')
66
+ }))).execute();
67
+ },
68
+ async getLocalPeerProfile (peerDID) {
69
+ return await db.selectFrom('kubun_p2p_local_peer_profile').selectAll().where('peer_did', '=', normalizeDID(peerDID)).executeTakeFirst();
70
+ },
71
+ async putPendingJoinRequest (params) {
72
+ // Swept on write rather than on a timer: preparing a request is the only
73
+ // moment this table grows, so it is the only moment it needs collecting.
74
+ await db.deleteFrom('kubun_p2p_pending_join_requests').where('created_at', '<', expiredBefore(adapter)).execute();
75
+ const values = {
76
+ did: normalizeDID(params.did),
77
+ payload: params.payload
78
+ };
79
+ await db.insertInto('kubun_p2p_pending_join_requests').values(values).onConflict((oc)=>oc.column('did').doUpdateSet((eb)=>({
80
+ payload: eb.ref('excluded.payload')
81
+ }))).execute();
82
+ },
83
+ async getPendingJoinRequest (did) {
84
+ return await db.selectFrom('kubun_p2p_pending_join_requests').selectAll().where('did', '=', normalizeDID(did)).where('created_at', '>=', expiredBefore(adapter)).executeTakeFirst();
85
+ },
86
+ async deletePendingJoinRequest (did) {
87
+ await db.deleteFrom('kubun_p2p_pending_join_requests').where('did', '=', normalizeDID(did)).execute();
88
+ }
89
+ };
90
+ }
@@ -0,0 +1,132 @@
1
+ import type { Adapter } from '@kubun/db-adapter';
2
+ import type { Kysely } from 'kysely';
3
+ import type { Group, GroupMember, GroupStatus, InsertGroup, InsertGroupMember, P2PStoreTables, PaginatedResult, PaginationParams } from '../tables.js';
4
+ /** Groups and their membership rows. */
5
+ export type GroupOps = {
6
+ createGroup(group: InsertGroup): Promise<void>;
7
+ getGroup(id: string): Promise<Group | undefined>;
8
+ updateGroup(id: string, update: Partial<Pick<InsertGroup, 'name' | 'description' | 'hlc'>>): Promise<void>;
9
+ deleteGroup(id: string): Promise<void>;
10
+ /**
11
+ * The groups this device belongs to. A group it has LEFT is excluded — this
12
+ * is the read every listing is built on, and the default has to be the safe
13
+ * one or each new caller has to remember the filter and one eventually will
14
+ * not. Use {@link GroupOps.listAllGroups} where a departed group matters.
15
+ */
16
+ listGroups(): Promise<Array<Group>>;
17
+ /** Every group row, departed ones included. */
18
+ listAllGroups(): Promise<Array<Group>>;
19
+ /**
20
+ * Move a group between joined and left. Leaving keeps every row hanging off
21
+ * the group, so being invited back rejoins the SAME group rather than forking
22
+ * a second row for it.
23
+ */
24
+ setGroupStatus(id: string, status: GroupStatus): Promise<void>;
25
+ listGroupsOffsetPaged(params: PaginationParams): Promise<PaginatedResult<Group>>;
26
+ addGroupMember(member: InsertGroupMember): Promise<void>;
27
+ /**
28
+ * Add (or revive) several members in a single multi-row insert. Same
29
+ * re-add-clears-tombstone semantics as {@link addGroupMember}, applied to every
30
+ * row. A single statement keeps the whole batch atomic and avoids issuing one
31
+ * round-trip per member on a single-connection adapter. A no-op for an empty list.
32
+ */
33
+ addGroupMembers(members: Array<InsertGroupMember>): Promise<void>;
34
+ /**
35
+ * Soft-delete a member by tombstoning the row with `hlc`. Delegates to
36
+ * `markMemberRemoved`: earliest-wins LWW, ignore-if-absent. The row stays in
37
+ * the table (read queries exclude it) so the apply-time check can compare a
38
+ * mutation's HLC against the removal HLC.
39
+ */
40
+ removeGroupMember(groupID: string, memberDID: string, hlc: string): Promise<void>;
41
+ /**
42
+ * Tombstone an existing membership row at `hlc` using earliest-wins LWW: the
43
+ * removal sticks only when no removal is recorded yet, or when `hlc` precedes
44
+ * the stored removal HLC. A removal for a `(groupID, memberDID)` with no local
45
+ * row is a no-op — absence means there is no member to tombstone, and no row
46
+ * is created.
47
+ */
48
+ markMemberRemoved(groupID: string, memberDID: string, hlc: string): Promise<void>;
49
+ /**
50
+ * Tombstone several members of one group at `hlc` in a single statement, with
51
+ * the same earliest-wins LWW and ignore-if-absent semantics as
52
+ * {@link markMemberRemoved} applied per row. One statement instead of one per
53
+ * member, and the whole batch lands or none of it does. A no-op for an empty
54
+ * list.
55
+ */
56
+ markMembersRemoved(groupID: string, memberDIDs: Array<string>, hlc: string): Promise<void>;
57
+ /**
58
+ * Read a membership row's removal state, including tombstoned rows. This is
59
+ * the only query that sees soft-deleted members. Returns `undefined` when no
60
+ * row exists; `{ removed_at_hlc: null }` for an active member; and
61
+ * `{ removed_at_hlc: <hlc> }` for a removed one.
62
+ */
63
+ getMemberRemoval(groupID: string, memberDID: string): Promise<{
64
+ removed_at_hlc: string | null;
65
+ } | undefined>;
66
+ getGroupMember(groupID: string, memberDID: string): Promise<GroupMember | undefined>;
67
+ isGroupMember(groupID: string, memberDID: string): Promise<boolean>;
68
+ /**
69
+ * Whether `did` was a member of `groupID` at the given HLC, ignoring role.
70
+ * Mirrors {@link isGroupAdmin}'s removal-boundary semantics without the
71
+ * `role = 'admin'` filter: with no `atHLC`, tombstoned rows are excluded
72
+ * (`removed_at_hlc IS NULL`); with `atHLC`, a member removed at HLC `R` still
73
+ * counts iff `atHLC < R`. Conjoined with ledger admin authority so an
74
+ * MLS-removed member loses admin regardless of the ledger.
75
+ */
76
+ isGroupMemberAtHLC(groupID: string, did: string, atHLC?: string): Promise<boolean>;
77
+ /**
78
+ * Whether `did` holds an active `admin` role in `groupID`. With no `atHLC`,
79
+ * tombstoned rows are ignored (`removed_at_hlc IS NULL`) — the apply-time
80
+ * admin check. With `atHLC`, an admin removed at HLC `R` still counts iff the
81
+ * supplied HLC strictly precedes `R` (`atHLC < R`): a write claiming a moment
82
+ * before the removal is admitted, one at or after it is denied. This mirrors
83
+ * the membership boundary in `checkMembership` (which denies at
84
+ * `removed_at_hlc <= mutationHLC`), so the two checks agree on the
85
+ * remove-boundary instant.
86
+ */
87
+ isGroupAdmin(groupID: string, did: string, atHLC?: string): Promise<boolean>;
88
+ /**
89
+ * Overlay the role column for an existing member. Update-only: a grant for a
90
+ * DID with no membership row is a no-op, since membership is MLS-native and a
91
+ * non-member can hold no role. Never touches `hlc` or `removed_at_hlc`, so it
92
+ * neither churns membership LWW nor resurrects a tombstone — it is purely the
93
+ * projection target for the admin-roster fold.
94
+ */
95
+ setGroupMemberRole(groupID: string, memberDID: string, role: string): Promise<void>;
96
+ /**
97
+ * Overlay the role column for several members of one group, with the same
98
+ * update-only semantics as {@link setGroupMemberRole} per row. Grouped by role
99
+ * so the write costs one statement per DISTINCT role rather than one per
100
+ * member — a roster reconcile typically moves everyone it touches to one or
101
+ * two roles. A no-op for an empty list.
102
+ */
103
+ setGroupMemberRoles(groupID: string, entries: Array<{
104
+ memberDID: string;
105
+ role: string;
106
+ }>): Promise<void>;
107
+ isMemberOfAnyGroup(viewerDID: string, groupIDs: Array<string>): Promise<boolean>;
108
+ /**
109
+ * WHICH of `groupIDs` the member holds an active row in — {@link isGroupMember}
110
+ * over a set, in one query, for a caller that must know per group rather than
111
+ * "any".
112
+ *
113
+ * Reads the membership rows ONLY. Not interchangeable with
114
+ * {@link getGroupsForMember}, which inner-joins `kubun_p2p_groups` and so
115
+ * answers false for a membership whose group row this device does not hold —
116
+ * an authorization gate must not deny on that.
117
+ */
118
+ listMemberGroupIDs(memberDID: string, groupIDs: Array<string>): Promise<Array<string>>;
119
+ listGroupMembers(groupID: string): Promise<Array<GroupMember>>;
120
+ getGroupsForMember(memberDID: string): Promise<Array<Group>>;
121
+ /**
122
+ * Batch fetch groups for multiple members. Returns a Map keyed by
123
+ * `memberDID`; values are arrays of groups for that member. Members with
124
+ * no groups are absent from the map. Single SQL query joining
125
+ * `group_members` to `groups`. Empty input short-circuits with an empty
126
+ * Map (no SQL).
127
+ *
128
+ * Caller is responsible for deduplicating DIDs.
129
+ */
130
+ getGroupsForMembers(memberDIDs: Array<string>): Promise<Map<string, Array<Group>>>;
131
+ };
132
+ export declare function createGroupOps(db: Kysely<P2PStoreTables>, adapter: Adapter): GroupOps;
@@ -0,0 +1,259 @@
1
+ import { normalizeDID } from '@kokuin/token';
2
+ export function createGroupOps(db, adapter) {
3
+ const ops = {
4
+ async createGroup (group) {
5
+ await db.insertInto('kubun_p2p_groups').values({
6
+ id: group.id,
7
+ name: group.name,
8
+ description: group.description,
9
+ created_by: group.created_by,
10
+ hlc: group.hlc
11
+ }).execute();
12
+ },
13
+ async getGroup (id) {
14
+ return await db.selectFrom('kubun_p2p_groups').selectAll().where('id', '=', id).executeTakeFirst();
15
+ },
16
+ async updateGroup (id, update) {
17
+ const values = {};
18
+ if (update.name != null) values.name = update.name;
19
+ if (update.description != null) values.description = update.description;
20
+ if (update.hlc != null) values.hlc = update.hlc;
21
+ values.updated_at = adapter.encodeTimestamp(new Date());
22
+ await db.updateTable('kubun_p2p_groups').set(values).where('id', '=', id).execute();
23
+ },
24
+ async deleteGroup (id) {
25
+ await db.deleteFrom('kubun_p2p_groups').where('id', '=', id).execute();
26
+ },
27
+ async listGroups () {
28
+ return await db.selectFrom('kubun_p2p_groups').selectAll().where('status', '=', 'joined').execute();
29
+ },
30
+ async listAllGroups () {
31
+ return await db.selectFrom('kubun_p2p_groups').selectAll().execute();
32
+ },
33
+ async setGroupStatus (id, status) {
34
+ await db.updateTable('kubun_p2p_groups').set({
35
+ status
36
+ }).where('id', '=', id).execute();
37
+ },
38
+ async listGroupsOffsetPaged (params) {
39
+ const { limit, offset } = params;
40
+ let query = db.selectFrom('kubun_p2p_groups').selectAll().where('status', '=', 'joined').orderBy('name');
41
+ if (offset != null) {
42
+ query = query.offset(offset);
43
+ }
44
+ if (limit != null) {
45
+ query = query.limit(limit + 1);
46
+ const rows = await query.execute();
47
+ const hasMore = rows.length > limit;
48
+ return {
49
+ entries: hasMore ? rows.slice(0, limit) : rows,
50
+ hasMore
51
+ };
52
+ }
53
+ const rows = await query.execute();
54
+ return {
55
+ entries: rows,
56
+ hasMore: false
57
+ };
58
+ },
59
+ // --- Group member operations ---
60
+ async addGroupMember (member) {
61
+ // Re-adding a previously-removed member clears the tombstone and refreshes
62
+ // the row's HLC, so a member that left and rejoined is active again.
63
+ await db.insertInto('kubun_p2p_group_members').values({
64
+ group_id: member.group_id,
65
+ member_did: normalizeDID(member.member_did),
66
+ role: member.role,
67
+ hlc: member.hlc
68
+ }).onConflict((oc)=>oc.columns([
69
+ 'group_id',
70
+ 'member_did'
71
+ ]).doUpdateSet((eb)=>({
72
+ role: eb.ref('excluded.role'),
73
+ hlc: eb.ref('excluded.hlc'),
74
+ removed_at_hlc: null,
75
+ updated_at: adapter.encodeTimestamp(new Date())
76
+ }))).execute();
77
+ },
78
+ async addGroupMembers (members) {
79
+ if (members.length === 0) {
80
+ return;
81
+ }
82
+ await db.insertInto('kubun_p2p_group_members').values(members.map((member)=>({
83
+ group_id: member.group_id,
84
+ member_did: normalizeDID(member.member_did),
85
+ role: member.role,
86
+ hlc: member.hlc
87
+ }))).onConflict((oc)=>oc.columns([
88
+ 'group_id',
89
+ 'member_did'
90
+ ]).doUpdateSet((eb)=>({
91
+ role: eb.ref('excluded.role'),
92
+ hlc: eb.ref('excluded.hlc'),
93
+ removed_at_hlc: null,
94
+ updated_at: adapter.encodeTimestamp(new Date())
95
+ }))).execute();
96
+ },
97
+ // Earliest-wins LWW on `removed_at_hlc`: the update lands only when the row
98
+ // exists AND it is either not yet tombstoned or the incoming `hlc` precedes
99
+ // the stored removal HLC. A removal for a `(groupID, memberDID)` with no
100
+ // local row is ignored — there is no member to tombstone, and creating a
101
+ // row would leak a phantom member. The string `<` compare mirrors the SQL
102
+ // LWW used by the delegation-token paths: the serialized HLC form is
103
+ // lexicographically ordered, so SQL ordering matches HLC ordering.
104
+ async markMemberRemoved (groupID, memberDID, hlc) {
105
+ await db.updateTable('kubun_p2p_group_members').set({
106
+ removed_at_hlc: hlc,
107
+ updated_at: adapter.encodeTimestamp(new Date())
108
+ }).where('group_id', '=', groupID).where('member_did', '=', normalizeDID(memberDID)).where((eb)=>eb.or([
109
+ eb('removed_at_hlc', 'is', null),
110
+ eb('removed_at_hlc', '>', hlc)
111
+ ])).execute();
112
+ },
113
+ // Same earliest-wins LWW as `markMemberRemoved`, evaluated per row by the
114
+ // `WHERE`: an absent member matches nothing, and a row already tombstoned at
115
+ // an earlier HLC keeps its own removal.
116
+ async markMembersRemoved (groupID, memberDIDs, hlc) {
117
+ if (memberDIDs.length === 0) {
118
+ return;
119
+ }
120
+ await db.updateTable('kubun_p2p_group_members').set({
121
+ removed_at_hlc: hlc,
122
+ updated_at: adapter.encodeTimestamp(new Date())
123
+ }).where('group_id', '=', groupID).where('member_did', 'in', memberDIDs.map(normalizeDID)).where((eb)=>eb.or([
124
+ eb('removed_at_hlc', 'is', null),
125
+ eb('removed_at_hlc', '>', hlc)
126
+ ])).execute();
127
+ },
128
+ async getMemberRemoval (groupID, memberDID) {
129
+ const row = await db.selectFrom('kubun_p2p_group_members').select('removed_at_hlc').where('group_id', '=', groupID).where('member_did', '=', normalizeDID(memberDID)).executeTakeFirst();
130
+ return row == null ? undefined : {
131
+ removed_at_hlc: row.removed_at_hlc
132
+ };
133
+ },
134
+ async removeGroupMember (groupID, memberDID, hlc) {
135
+ await ops.markMemberRemoved(groupID, memberDID, hlc);
136
+ },
137
+ async getGroupMember (groupID, memberDID) {
138
+ return await db.selectFrom('kubun_p2p_group_members').selectAll().where('group_id', '=', groupID).where('member_did', '=', normalizeDID(memberDID)).where('removed_at_hlc', 'is', null).executeTakeFirst();
139
+ },
140
+ async isGroupMember (groupID, memberDID) {
141
+ const row = await db.selectFrom('kubun_p2p_group_members').select('member_did').where('group_id', '=', groupID).where('member_did', '=', normalizeDID(memberDID)).where('removed_at_hlc', 'is', null).executeTakeFirst();
142
+ return row != null;
143
+ },
144
+ async isGroupMemberAtHLC (groupID, did, atHLC) {
145
+ let query = db.selectFrom('kubun_p2p_group_members').select('member_did').where('group_id', '=', groupID).where('member_did', '=', normalizeDID(did));
146
+ if (atHLC == null) {
147
+ query = query.where('removed_at_hlc', 'is', null);
148
+ } else {
149
+ query = query.where((eb)=>eb.or([
150
+ eb('removed_at_hlc', 'is', null),
151
+ eb('removed_at_hlc', '>', atHLC)
152
+ ]));
153
+ }
154
+ const row = await query.limit(1).executeTakeFirst();
155
+ return row != null;
156
+ },
157
+ async isGroupAdmin (groupID, did, atHLC) {
158
+ let query = db.selectFrom('kubun_p2p_group_members').select('member_did').where('group_id', '=', groupID).where('member_did', '=', normalizeDID(did)).where('role', '=', 'admin');
159
+ if (atHLC == null) {
160
+ query = query.where('removed_at_hlc', 'is', null);
161
+ } else {
162
+ // Admit a write whose HLC strictly precedes the removal HLC; deny
163
+ // at-or-after. Complements `checkMembership`'s `removed_at_hlc <= hlc`
164
+ // denial so both agree on the remove-boundary instant.
165
+ query = query.where((eb)=>eb.or([
166
+ eb('removed_at_hlc', 'is', null),
167
+ eb('removed_at_hlc', '>', atHLC)
168
+ ]));
169
+ }
170
+ const row = await query.limit(1).executeTakeFirst();
171
+ return row != null;
172
+ },
173
+ async setGroupMemberRole (groupID, memberDID, role) {
174
+ // Update-only by design: a missing row leaves the table untouched, so a
175
+ // role grant can never conjure a phantom member. hlc/removed_at_hlc are
176
+ // left alone to keep membership LWW and tombstones intact.
177
+ await db.updateTable('kubun_p2p_group_members').set({
178
+ role,
179
+ updated_at: adapter.encodeTimestamp(new Date())
180
+ }).where('group_id', '=', groupID).where('member_did', '=', normalizeDID(memberDID)).execute();
181
+ },
182
+ // Grouped by role, then one update per distinct role. Update-only like the
183
+ // single-member writer, and `hlc`/`removed_at_hlc` are left alone, so a
184
+ // tombstoned row keeps the role it held when it was removed.
185
+ async setGroupMemberRoles (groupID, entries) {
186
+ if (entries.length === 0) {
187
+ return;
188
+ }
189
+ const didsByRole = new Map();
190
+ for (const entry of entries){
191
+ const dids = didsByRole.get(entry.role);
192
+ if (dids == null) {
193
+ didsByRole.set(entry.role, [
194
+ normalizeDID(entry.memberDID)
195
+ ]);
196
+ } else {
197
+ dids.push(normalizeDID(entry.memberDID));
198
+ }
199
+ }
200
+ const now = adapter.encodeTimestamp(new Date());
201
+ for (const [role, dids] of didsByRole){
202
+ await db.updateTable('kubun_p2p_group_members').set({
203
+ role,
204
+ updated_at: now
205
+ }).where('group_id', '=', groupID).where('member_did', 'in', dids).execute();
206
+ }
207
+ },
208
+ async isMemberOfAnyGroup (viewerDID, groupIDs) {
209
+ if (groupIDs.length === 0) return false;
210
+ const row = await db.selectFrom('kubun_p2p_group_members').select('group_id').where('member_did', '=', normalizeDID(viewerDID)).where('group_id', 'in', groupIDs).where('removed_at_hlc', 'is', null).limit(1).executeTakeFirst();
211
+ return row != null;
212
+ },
213
+ // Same predicate as `isGroupMember`, projecting the matching group ids rather
214
+ // than collapsing to one existence answer. No join to `kubun_p2p_groups` —
215
+ // membership stands on its own row.
216
+ async listMemberGroupIDs (memberDID, groupIDs) {
217
+ if (groupIDs.length === 0) return [];
218
+ const rows = await db.selectFrom('kubun_p2p_group_members').select('group_id').where('member_did', '=', normalizeDID(memberDID)).where('group_id', 'in', groupIDs).where('removed_at_hlc', 'is', null).execute();
219
+ return rows.map((row)=>row.group_id);
220
+ },
221
+ async listGroupMembers (groupID) {
222
+ return await db.selectFrom('kubun_p2p_group_members').selectAll().where('group_id', '=', groupID).where('removed_at_hlc', 'is', null).execute();
223
+ },
224
+ async getGroupsForMember (memberDID) {
225
+ return await db.selectFrom('kubun_p2p_group_members').innerJoin('kubun_p2p_groups', 'kubun_p2p_groups.id', 'kubun_p2p_group_members.group_id').selectAll('kubun_p2p_groups').where('kubun_p2p_group_members.member_did', '=', normalizeDID(memberDID)).where('kubun_p2p_group_members.removed_at_hlc', 'is', null).execute();
226
+ },
227
+ // Result Map is keyed by the normalized stored `member_did`; callers must
228
+ // normalize before looking up by key (existing callers iterate `.values()`).
229
+ async getGroupsForMembers (memberDIDs) {
230
+ const result = new Map();
231
+ if (memberDIDs.length === 0) return result;
232
+ const normalizedDIDs = memberDIDs.map(normalizeDID);
233
+ const rows = await db.selectFrom('kubun_p2p_group_members').innerJoin('kubun_p2p_groups', 'kubun_p2p_groups.id', 'kubun_p2p_group_members.group_id').select([
234
+ 'kubun_p2p_group_members.member_did as member_did',
235
+ 'kubun_p2p_groups.id as id',
236
+ 'kubun_p2p_groups.name as name',
237
+ 'kubun_p2p_groups.description as description',
238
+ 'kubun_p2p_groups.created_by as created_by',
239
+ 'kubun_p2p_groups.status as status',
240
+ 'kubun_p2p_groups.hlc as hlc',
241
+ 'kubun_p2p_groups.created_at as created_at',
242
+ 'kubun_p2p_groups.updated_at as updated_at'
243
+ ]).where('kubun_p2p_group_members.member_did', 'in', normalizedDIDs).where('kubun_p2p_group_members.removed_at_hlc', 'is', null).execute();
244
+ for (const row of rows){
245
+ const { member_did, ...group } = row;
246
+ const list = result.get(member_did);
247
+ if (list != null) {
248
+ list.push(group);
249
+ } else {
250
+ result.set(member_did, [
251
+ group
252
+ ]);
253
+ }
254
+ }
255
+ return result;
256
+ }
257
+ };
258
+ return ops;
259
+ }
@@ -0,0 +1,56 @@
1
+ import type { Adapter } from '@kubun/db-adapter';
2
+ import { type Kysely } from 'kysely';
3
+ import type { Group, Hub, InsertGroupHub, InsertHub, P2PStoreTables } from '../tables.js';
4
+ /** Hubs, their group bindings, and the canonical commit-hub designation. */
5
+ export type HubOps = {
6
+ insertHub(hub: InsertHub): Promise<void>;
7
+ getHub(id: string): Promise<Hub | undefined>;
8
+ getHubByURL(url: string): Promise<Hub | undefined>;
9
+ updateHub(id: string, update: Partial<Pick<InsertHub, 'label' | 'url' | 'server_did'>>): Promise<void>;
10
+ deleteHub(id: string): Promise<void>;
11
+ listHubs(): Promise<Array<Hub>>;
12
+ insertGroupHub(binding: InsertGroupHub): Promise<boolean>;
13
+ deleteGroupHub(params: {
14
+ groupID: string;
15
+ hubID: string;
16
+ }): Promise<boolean>;
17
+ listGroupIDsByHubID(hubID: string): Promise<Array<string>>;
18
+ listHubsByGroupID(groupID: string): Promise<Array<Hub>>;
19
+ listGroupsByHubID(hubID: string): Promise<Array<Group>>;
20
+ deleteGroupHubsByHubID(hubID: string): Promise<void>;
21
+ /** The group's designated commit hub (join to the hub row), or `undefined`. */
22
+ getCommitHub(groupID: string): Promise<Hub | undefined>;
23
+ /**
24
+ * Designate `hubID` as the group's sole commit hub, clearing any prior
25
+ * designation first so at most one row per group carries the flag. The hub
26
+ * must already be bound to the group — an unbound `hubID` throws rather than
27
+ * clearing every flag and setting none.
28
+ */
29
+ setCommitHub(params: {
30
+ groupID: string;
31
+ hubID: string;
32
+ }): Promise<void>;
33
+ /**
34
+ * Materialize the group's folded commit-hub designation onto the local
35
+ * `is_commit_hub` flag, so consensus — not each device's first hub-bind —
36
+ * decides which bound hub the group commits through. `commitHub` is the shared
37
+ * hub identity (URL, optional server DID), which the caller derives from the
38
+ * group-settings fold.
39
+ *
40
+ * When `commitHub` is given, set `is_commit_hub = 1` on the bound hub whose URL
41
+ * matches and `0` on every other bound hub of the group. A designation that
42
+ * carries a server DID additionally requires the bound hub's recorded server
43
+ * DID to match or to be absent — the same URL under a different server DID is a
44
+ * different hub identity. When `commitHub` is `undefined`, no-op: the group has folded no designation, so the local
45
+ * auto-designated flag stands. When the designated URL matches NO bound hub —
46
+ * the device has not bound the designated hub — clear the flag on every one of
47
+ * the group's rows: the device has no local commit peer, and keeping a
48
+ * non-designated hub flagged is exactly the fork this designation prevents. The
49
+ * device then cannot commit until it binds the designated hub.
50
+ */
51
+ reconcileCommitHub(groupID: string, commitHub: {
52
+ url: string;
53
+ serverDID?: string;
54
+ } | undefined): Promise<void>;
55
+ };
56
+ export declare function createHubOps(db: Kysely<P2PStoreTables>, adapter: Adapter): HubOps;