@kubun/store-p2p 0.11.0 → 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.
- package/lib/api.d.ts +25 -251
- package/lib/api.js +19 -590
- package/lib/index.d.ts +2 -2
- package/lib/migrations.d.ts +21 -0
- package/lib/migrations.js +106 -26
- package/lib/ops/app-lane.d.ts +36 -0
- package/lib/ops/app-lane.js +38 -0
- package/lib/ops/circles.d.ts +153 -0
- package/lib/ops/circles.js +269 -0
- package/lib/ops/control.d.ts +118 -0
- package/lib/ops/control.js +110 -0
- package/lib/ops/delegations.d.ts +25 -0
- package/lib/ops/delegations.js +27 -0
- package/lib/ops/discovery.d.ts +92 -0
- package/lib/ops/discovery.js +90 -0
- package/lib/ops/groups.d.ts +132 -0
- package/lib/ops/groups.js +259 -0
- package/lib/ops/hubs.d.ts +56 -0
- package/lib/ops/hubs.js +222 -0
- package/lib/ops/mls.d.ts +21 -0
- package/lib/ops/mls.js +50 -0
- package/lib/ops/sync.d.ts +22 -0
- package/lib/ops/sync.js +50 -0
- package/lib/tables.d.ts +106 -26
- package/package.json +18 -18
|
@@ -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;
|