@kubun/store-p2p 0.17.0 → 0.18.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 +24 -6
- package/lib/api.js +17 -2
- package/lib/definition.js +1 -1
- package/lib/hub-url.d.ts +1 -0
- package/lib/hub-url.js +3 -0
- package/lib/index.d.ts +8 -2
- package/lib/index.js +6 -0
- package/lib/migrations.js +150 -9
- package/lib/ops/app-lane.d.ts +32 -2
- package/lib/ops/app-lane.js +55 -2
- package/lib/ops/app-outbox.d.ts +44 -0
- package/lib/ops/app-outbox.js +236 -0
- package/lib/ops/catalog-offers.d.ts +18 -0
- package/lib/ops/catalog-offers.js +59 -0
- package/lib/ops/catalog-routes.d.ts +17 -0
- package/lib/ops/catalog-routes.js +47 -0
- package/lib/ops/catalog-sync.d.ts +122 -0
- package/lib/ops/catalog-sync.js +251 -0
- package/lib/ops/circles.d.ts +3 -4
- package/lib/ops/circles.js +31 -14
- package/lib/ops/control.d.ts +6 -0
- package/lib/ops/control.js +12 -3
- package/lib/ops/delegations.d.ts +31 -0
- package/lib/ops/delegations.js +71 -0
- package/lib/ops/document-sweep.d.ts +11 -0
- package/lib/ops/document-sweep.js +16 -0
- package/lib/ops/groups.d.ts +70 -22
- package/lib/ops/groups.js +130 -35
- package/lib/ops/hubs.js +8 -4
- package/lib/ops/log-handler-completion.d.ts +20 -0
- package/lib/ops/log-handler-completion.js +41 -0
- package/lib/ops/mls.d.ts +39 -6
- package/lib/ops/mls.js +115 -21
- package/lib/ops/own-agents.d.ts +68 -0
- package/lib/ops/own-agents.js +359 -0
- package/lib/ops/sync.d.ts +16 -0
- package/lib/ops/sync.js +25 -0
- package/lib/owner-classification.d.ts +14 -0
- package/lib/owner-classification.js +18 -0
- package/lib/tables.d.ts +188 -5
- package/package.json +16 -11
package/lib/ops/groups.d.ts
CHANGED
|
@@ -1,12 +1,26 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Adapter } from '@kubun/db-adapter';
|
|
2
2
|
import type { Kysely } from 'kysely';
|
|
3
3
|
import type { Group, GroupMember, GroupStatus, InsertGroup, InsertGroupMember, P2PStoreTables, PaginatedResult, PaginationParams } from '../tables.js';
|
|
4
|
+
import { type ClearAppOutboxRowsOptions } from './app-outbox.js';
|
|
5
|
+
export type GetMemberRemovalsParams = {
|
|
6
|
+
groupIDs: Array<string>;
|
|
7
|
+
memberDID: string;
|
|
8
|
+
};
|
|
4
9
|
/** Groups and their membership rows. */
|
|
5
10
|
export type GroupOps = {
|
|
6
11
|
createGroup(group: InsertGroup): Promise<void>;
|
|
7
12
|
getGroup(id: string): Promise<Group | undefined>;
|
|
8
13
|
updateGroup(id: string, update: Partial<Pick<InsertGroup, 'name' | 'description' | 'hlc'>>): Promise<void>;
|
|
9
14
|
deleteGroup(id: string): Promise<void>;
|
|
15
|
+
/** Record that the control projections lag a handle advance named by `token`. */
|
|
16
|
+
markGroupProjectionsDirty(id: string, token: string): Promise<void>;
|
|
17
|
+
/**
|
|
18
|
+
* Clear the dirty mark only if it still holds `token` (`null` matches a clean
|
|
19
|
+
* group), so an advance that landed meanwhile keeps the group dirty, and
|
|
20
|
+
* record the handle epoch the projections now reflect. Reports whether the mark
|
|
21
|
+
* matched.
|
|
22
|
+
*/
|
|
23
|
+
clearGroupProjectionsDirty(id: string, token: string | null, epoch: number): Promise<boolean>;
|
|
10
24
|
/**
|
|
11
25
|
* The groups this device belongs to. A group it has LEFT is excluded — this
|
|
12
26
|
* is the read every listing is built on, and the default has to be the safe
|
|
@@ -14,56 +28,90 @@ export type GroupOps = {
|
|
|
14
28
|
* not. Use {@link GroupOps.listAllGroups} where a departed group matters.
|
|
15
29
|
*/
|
|
16
30
|
listGroups(): Promise<Array<Group>>;
|
|
31
|
+
/** Pending self-removal groups retained for restart recovery. */
|
|
32
|
+
listLeavingGroups(): Promise<Array<Group>>;
|
|
17
33
|
/** Every group row, departed ones included. */
|
|
18
34
|
listAllGroups(): Promise<Array<Group>>;
|
|
19
35
|
/**
|
|
20
|
-
* Move a group between joined and left
|
|
21
|
-
* the
|
|
22
|
-
* a second row for it.
|
|
36
|
+
* Move a group between joined and left, retaining membership history for rejoin.
|
|
37
|
+
* Returns the outbox sequences durably cleared on leave.
|
|
23
38
|
*/
|
|
24
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Setting left clears the encrypted outbox and pending admission markers.
|
|
41
|
+
* `requireRevocationsDelivered` refuses leaving or left, writing nothing,
|
|
42
|
+
* while revocations remain undelivered.
|
|
43
|
+
*/
|
|
44
|
+
setGroupStatus(id: string, status: GroupStatus, options?: ClearAppOutboxRowsOptions): Promise<Array<number>>;
|
|
25
45
|
listGroupsOffsetPaged(params: PaginationParams): Promise<PaginatedResult<Group>>;
|
|
26
|
-
|
|
46
|
+
/** Reports whether the invite inserted or updated its target row. */
|
|
47
|
+
addGroupMember(member: InsertGroupMember): Promise<boolean>;
|
|
27
48
|
/**
|
|
28
49
|
* Add (or revive) several members in a single multi-row insert. Same
|
|
29
50
|
* re-add-clears-tombstone semantics as {@link addGroupMember}, applied to every
|
|
30
51
|
* row. A single statement keeps the whole batch atomic and avoids issuing one
|
|
31
52
|
* round-trip per member on a single-connection adapter. A no-op for an empty list.
|
|
53
|
+
* Returns the normalized DIDs whose rows were inserted or updated.
|
|
32
54
|
*/
|
|
33
|
-
addGroupMembers(members: Array<InsertGroupMember>): Promise<
|
|
55
|
+
addGroupMembers(members: Array<InsertGroupMember>): Promise<Set<string>>;
|
|
34
56
|
/**
|
|
35
|
-
*
|
|
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.
|
|
57
|
+
* Tombstone the current incarnation and report whether its row matched.
|
|
39
58
|
*/
|
|
40
|
-
removeGroupMember(groupID: string, memberDID: string, hlc: string): Promise<
|
|
59
|
+
removeGroupMember(groupID: string, memberDID: string, hlc: string): Promise<boolean>;
|
|
41
60
|
/**
|
|
42
|
-
* Tombstone an
|
|
43
|
-
*
|
|
44
|
-
* the
|
|
45
|
-
* row is a no-op — absence means there is no member to tombstone, and no row
|
|
46
|
-
* is created.
|
|
61
|
+
* Tombstone an active row only when its incarnation HLC precedes `hlc`.
|
|
62
|
+
* An absent row or an existing tombstone is unchanged.
|
|
63
|
+
* Reports whether the active row matched.
|
|
47
64
|
*/
|
|
48
|
-
markMemberRemoved(groupID: string, memberDID: string, hlc: string): Promise<
|
|
65
|
+
markMemberRemoved(groupID: string, memberDID: string, hlc: string): Promise<boolean>;
|
|
49
66
|
/**
|
|
50
67
|
* Tombstone several members of one group at `hlc` in a single statement, with
|
|
51
|
-
* the same
|
|
68
|
+
* the same incarnation guard and ignore-if-absent semantics as
|
|
52
69
|
* {@link markMemberRemoved} applied per row. One statement instead of one per
|
|
53
70
|
* member, and the whole batch lands or none of it does. A no-op for an empty
|
|
54
71
|
* list.
|
|
72
|
+
* Returns the normalized DIDs whose active rows matched.
|
|
55
73
|
*/
|
|
56
|
-
markMembersRemoved(groupID: string, memberDIDs: Array<string>, hlc: string): Promise<
|
|
74
|
+
markMembersRemoved(groupID: string, memberDIDs: Array<string>, hlc: string): Promise<Set<string>>;
|
|
75
|
+
/** Target incarnation and removal stamps, including tombstoned members. */
|
|
76
|
+
listGroupMembershipStamps(groupID: string, memberDIDs: Array<string>): Promise<Array<Pick<GroupMember, 'member_did' | 'hlc' | 'removed_at_hlc'>>>;
|
|
57
77
|
/**
|
|
58
78
|
* Read a membership row's removal state, including tombstoned rows. This is
|
|
59
|
-
*
|
|
60
|
-
*
|
|
79
|
+
* unfiltered by active status. Returns `undefined` when no row exists;
|
|
80
|
+
* `{ removed_at_hlc: null }` for an active member; and
|
|
61
81
|
* `{ removed_at_hlc: <hlc> }` for a removed one.
|
|
62
82
|
*/
|
|
63
83
|
getMemberRemoval(groupID: string, memberDID: string): Promise<{
|
|
64
84
|
removed_at_hlc: string | null;
|
|
65
85
|
} | undefined>;
|
|
86
|
+
/**
|
|
87
|
+
* {@link GroupOps.getMemberRemoval} for one member across groups, keyed by
|
|
88
|
+
* group id. A group with no row for the member is absent from the map.
|
|
89
|
+
*/
|
|
90
|
+
getMemberRemovals(params: GetMemberRemovalsParams): Promise<Map<string, {
|
|
91
|
+
removed_at_hlc: string | null;
|
|
92
|
+
}>>;
|
|
66
93
|
getGroupMember(groupID: string, memberDID: string): Promise<GroupMember | undefined>;
|
|
94
|
+
/**
|
|
95
|
+
* The newest leave-request stamp surfaced for `memberDID` in `groupID`, or
|
|
96
|
+
* `undefined` if none ever was. Kept across a removal and re-add.
|
|
97
|
+
*/
|
|
98
|
+
getGroupLeaveRequestHLC(groupID: string, memberDID: string): Promise<string | undefined>;
|
|
99
|
+
/**
|
|
100
|
+
* Set the (group, member) leave-request stamp to `hlc`, but only if the row
|
|
101
|
+
* still holds `expected` — `undefined` meaning no row yet. Answers whether it
|
|
102
|
+
* wrote.
|
|
103
|
+
*
|
|
104
|
+
* A compare-and-set, not an ordering: the caller decides that `hlc` is newer
|
|
105
|
+
* by parsing both stamps, since text ordering is collation-dependent on
|
|
106
|
+
* Postgres. This only guards against a concurrent apply having moved the row
|
|
107
|
+
* between the caller's read and this write.
|
|
108
|
+
*/
|
|
109
|
+
swapGroupLeaveRequestHLC(params: {
|
|
110
|
+
groupID: string;
|
|
111
|
+
memberDID: string;
|
|
112
|
+
expected: string | undefined;
|
|
113
|
+
hlc: string;
|
|
114
|
+
}): Promise<boolean>;
|
|
67
115
|
isGroupMember(groupID: string, memberDID: string): Promise<boolean>;
|
|
68
116
|
/**
|
|
69
117
|
* Whether `did` was a member of `groupID` at the given HLC, ignoring role.
|
package/lib/ops/groups.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { normalizeDID } from '@kokuin/token';
|
|
2
|
+
import { chunked } from '@kubun/db-adapter';
|
|
3
|
+
import { clearAppOutboxRows, lockAppOutboxForErasure } from './app-outbox.js';
|
|
2
4
|
export function createGroupOps(db, adapter) {
|
|
3
5
|
const ops = {
|
|
4
6
|
async createGroup (group) {
|
|
@@ -18,22 +20,89 @@ export function createGroupOps(db, adapter) {
|
|
|
18
20
|
if (update.name != null) values.name = update.name;
|
|
19
21
|
if (update.description != null) values.description = update.description;
|
|
20
22
|
if (update.hlc != null) values.hlc = update.hlc;
|
|
23
|
+
const changed = Object.entries(values);
|
|
21
24
|
values.updated_at = adapter.encodeTimestamp(new Date());
|
|
22
|
-
|
|
25
|
+
let query = db.updateTable('kubun_p2p_groups').set(values).where('id', '=', id);
|
|
26
|
+
// Rewriting the values a row already holds is not a change: a reproject
|
|
27
|
+
// that agrees with the stored row leaves it, `updated_at` included, alone.
|
|
28
|
+
if (changed.length > 0) {
|
|
29
|
+
query = query.where((eb)=>eb.or(changed.map(([column, value])=>eb(column, '<>', value))));
|
|
30
|
+
}
|
|
31
|
+
await query.execute();
|
|
32
|
+
},
|
|
33
|
+
async markGroupProjectionsDirty (id, token) {
|
|
34
|
+
await db.updateTable('kubun_p2p_groups').set({
|
|
35
|
+
projections_dirty: token
|
|
36
|
+
}).where('id', '=', id).execute();
|
|
37
|
+
},
|
|
38
|
+
async clearGroupProjectionsDirty (id, token, epoch) {
|
|
39
|
+
const result = await db.updateTable('kubun_p2p_groups').set({
|
|
40
|
+
projections_dirty: null,
|
|
41
|
+
projections_epoch: epoch
|
|
42
|
+
}).where('id', '=', id).where('projections_dirty', token == null ? 'is' : '=', token).executeTakeFirst();
|
|
43
|
+
return (result.numUpdatedRows ?? 0n) > 0n;
|
|
44
|
+
},
|
|
45
|
+
async getGroupLeaveRequestHLC (groupID, memberDID) {
|
|
46
|
+
const row = await db.selectFrom('kubun_p2p_group_leave_requests').select('hlc').where('group_id', '=', groupID).where('member_did', '=', normalizeDID(memberDID)).executeTakeFirst();
|
|
47
|
+
return row?.hlc;
|
|
48
|
+
},
|
|
49
|
+
async swapGroupLeaveRequestHLC (params) {
|
|
50
|
+
const memberDID = normalizeDID(params.memberDID);
|
|
51
|
+
if (params.expected == null) {
|
|
52
|
+
const inserted = await db.insertInto('kubun_p2p_group_leave_requests').values({
|
|
53
|
+
group_id: params.groupID,
|
|
54
|
+
member_did: memberDID,
|
|
55
|
+
hlc: params.hlc
|
|
56
|
+
}).onConflict((oc)=>oc.columns([
|
|
57
|
+
'group_id',
|
|
58
|
+
'member_did'
|
|
59
|
+
]).doNothing()).executeTakeFirst();
|
|
60
|
+
return (inserted.numInsertedOrUpdatedRows ?? 0n) > 0n;
|
|
61
|
+
}
|
|
62
|
+
const updated = await db.updateTable('kubun_p2p_group_leave_requests').set({
|
|
63
|
+
hlc: params.hlc
|
|
64
|
+
}).where('group_id', '=', params.groupID).where('member_did', '=', memberDID).where('hlc', '=', params.expected).executeTakeFirst();
|
|
65
|
+
return (updated.numUpdatedRows ?? 0n) > 0n;
|
|
23
66
|
},
|
|
24
67
|
async deleteGroup (id) {
|
|
25
|
-
|
|
68
|
+
const remove = async (trx)=>{
|
|
69
|
+
await clearAppOutboxRows(trx, id);
|
|
70
|
+
await trx.deleteFrom('kubun_p2p_log_handler_completion').where('group_id', '=', id).execute();
|
|
71
|
+
await trx.deleteFrom('kubun_p2p_catalog_offers').where('group_id', '=', id).execute();
|
|
72
|
+
await trx.deleteFrom('kubun_p2p_catalog_routes').where('group_id', '=', id).execute();
|
|
73
|
+
await trx.deleteFrom('kubun_p2p_groups').where('id', '=', id).execute();
|
|
74
|
+
};
|
|
75
|
+
// A caller already inside a provider transaction owns atomicity; opening
|
|
76
|
+
// a nested one is unsupported.
|
|
77
|
+
if (db.isTransaction) await remove(db);
|
|
78
|
+
else await db.transaction().execute(remove);
|
|
26
79
|
},
|
|
27
80
|
async listGroups () {
|
|
28
81
|
return await db.selectFrom('kubun_p2p_groups').selectAll().where('status', '=', 'joined').execute();
|
|
29
82
|
},
|
|
83
|
+
async listLeavingGroups () {
|
|
84
|
+
return await db.selectFrom('kubun_p2p_groups').selectAll().where('status', '=', 'leaving').execute();
|
|
85
|
+
},
|
|
30
86
|
async listAllGroups () {
|
|
31
87
|
return await db.selectFrom('kubun_p2p_groups').selectAll().execute();
|
|
32
88
|
},
|
|
33
|
-
async setGroupStatus (id, status) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
89
|
+
async setGroupStatus (id, status, options) {
|
|
90
|
+
const update = async (tx)=>{
|
|
91
|
+
let cleared = [];
|
|
92
|
+
if (status === 'leaving' && options?.requireRevocationsDelivered === true) {
|
|
93
|
+
await lockAppOutboxForErasure(tx, id, options);
|
|
94
|
+
}
|
|
95
|
+
if (status === 'left') {
|
|
96
|
+
cleared = await clearAppOutboxRows(tx, id, options);
|
|
97
|
+
await tx.deleteFrom('kubun_p2p_log_handler_completion').where('group_id', '=', id).execute();
|
|
98
|
+
}
|
|
99
|
+
await tx.updateTable('kubun_p2p_groups').set({
|
|
100
|
+
status
|
|
101
|
+
}).where('id', '=', id).execute();
|
|
102
|
+
return cleared;
|
|
103
|
+
};
|
|
104
|
+
if (db.isTransaction) return await update(db);
|
|
105
|
+
return await db.transaction().execute(update);
|
|
37
106
|
},
|
|
38
107
|
async listGroupsOffsetPaged (params) {
|
|
39
108
|
const { limit, offset } = params;
|
|
@@ -58,9 +127,7 @@ export function createGroupOps(db, adapter) {
|
|
|
58
127
|
},
|
|
59
128
|
// --- Group member operations ---
|
|
60
129
|
async addGroupMember (member) {
|
|
61
|
-
|
|
62
|
-
// the row's HLC, so a member that left and rejoined is active again.
|
|
63
|
-
await db.insertInto('kubun_p2p_group_members').values({
|
|
130
|
+
const row = await db.insertInto('kubun_p2p_group_members').values({
|
|
64
131
|
group_id: member.group_id,
|
|
65
132
|
member_did: normalizeDID(member.member_did),
|
|
66
133
|
role: member.role,
|
|
@@ -73,13 +140,20 @@ export function createGroupOps(db, adapter) {
|
|
|
73
140
|
hlc: eb.ref('excluded.hlc'),
|
|
74
141
|
removed_at_hlc: null,
|
|
75
142
|
updated_at: adapter.encodeTimestamp(new Date())
|
|
76
|
-
}))).
|
|
143
|
+
})).where((eb)=>eb.or([
|
|
144
|
+
eb.and([
|
|
145
|
+
eb('kubun_p2p_group_members.removed_at_hlc', 'is', null),
|
|
146
|
+
eb('kubun_p2p_group_members.hlc', '<', eb.ref('excluded.hlc'))
|
|
147
|
+
]),
|
|
148
|
+
eb('kubun_p2p_group_members.removed_at_hlc', '<', eb.ref('excluded.hlc'))
|
|
149
|
+
]))).returning('member_did').executeTakeFirst();
|
|
150
|
+
return row != null;
|
|
77
151
|
},
|
|
78
152
|
async addGroupMembers (members) {
|
|
79
153
|
if (members.length === 0) {
|
|
80
|
-
return;
|
|
154
|
+
return new Set();
|
|
81
155
|
}
|
|
82
|
-
await db.insertInto('kubun_p2p_group_members').values(members.map((member)=>({
|
|
156
|
+
const rows = await db.insertInto('kubun_p2p_group_members').values(members.map((member)=>({
|
|
83
157
|
group_id: member.group_id,
|
|
84
158
|
member_did: normalizeDID(member.member_did),
|
|
85
159
|
role: member.role,
|
|
@@ -92,38 +166,39 @@ export function createGroupOps(db, adapter) {
|
|
|
92
166
|
hlc: eb.ref('excluded.hlc'),
|
|
93
167
|
removed_at_hlc: null,
|
|
94
168
|
updated_at: adapter.encodeTimestamp(new Date())
|
|
95
|
-
}))).
|
|
169
|
+
})).where((eb)=>eb.or([
|
|
170
|
+
eb.and([
|
|
171
|
+
eb('kubun_p2p_group_members.removed_at_hlc', 'is', null),
|
|
172
|
+
eb('kubun_p2p_group_members.hlc', '<', eb.ref('excluded.hlc'))
|
|
173
|
+
]),
|
|
174
|
+
eb('kubun_p2p_group_members.removed_at_hlc', '<', eb.ref('excluded.hlc'))
|
|
175
|
+
]))).returning('member_did').execute();
|
|
176
|
+
return new Set(rows.map((row)=>row.member_did));
|
|
96
177
|
},
|
|
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
178
|
async markMemberRemoved (groupID, memberDID, hlc) {
|
|
105
|
-
await db.updateTable('kubun_p2p_group_members').set({
|
|
179
|
+
const row = await db.updateTable('kubun_p2p_group_members').set({
|
|
106
180
|
removed_at_hlc: hlc,
|
|
107
181
|
updated_at: adapter.encodeTimestamp(new Date())
|
|
108
|
-
}).where('group_id', '=', groupID).where('member_did', '=', normalizeDID(memberDID)).where((
|
|
109
|
-
|
|
110
|
-
eb('removed_at_hlc', '>', hlc)
|
|
111
|
-
])).execute();
|
|
182
|
+
}).where('group_id', '=', groupID).where('member_did', '=', normalizeDID(memberDID)).where('removed_at_hlc', 'is', null).where('hlc', '<', hlc).returning('member_did').executeTakeFirst();
|
|
183
|
+
return row != null;
|
|
112
184
|
},
|
|
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
185
|
async markMembersRemoved (groupID, memberDIDs, hlc) {
|
|
117
186
|
if (memberDIDs.length === 0) {
|
|
118
|
-
return;
|
|
187
|
+
return new Set();
|
|
119
188
|
}
|
|
120
|
-
await db.updateTable('kubun_p2p_group_members').set({
|
|
189
|
+
const rows = await db.updateTable('kubun_p2p_group_members').set({
|
|
121
190
|
removed_at_hlc: hlc,
|
|
122
191
|
updated_at: adapter.encodeTimestamp(new Date())
|
|
123
|
-
}).where('group_id', '=', groupID).where('member_did', 'in', memberDIDs.map(normalizeDID)).where((
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
192
|
+
}).where('group_id', '=', groupID).where('member_did', 'in', memberDIDs.map(normalizeDID)).where('removed_at_hlc', 'is', null).where('hlc', '<', hlc).returning('member_did').execute();
|
|
193
|
+
return new Set(rows.map((row)=>row.member_did));
|
|
194
|
+
},
|
|
195
|
+
async listGroupMembershipStamps (groupID, memberDIDs) {
|
|
196
|
+
if (memberDIDs.length === 0) return [];
|
|
197
|
+
return await db.selectFrom('kubun_p2p_group_members').select([
|
|
198
|
+
'member_did',
|
|
199
|
+
'hlc',
|
|
200
|
+
'removed_at_hlc'
|
|
201
|
+
]).where('group_id', '=', groupID).where('member_did', 'in', memberDIDs.map(normalizeDID)).execute();
|
|
127
202
|
},
|
|
128
203
|
async getMemberRemoval (groupID, memberDID) {
|
|
129
204
|
const row = await db.selectFrom('kubun_p2p_group_members').select('removed_at_hlc').where('group_id', '=', groupID).where('member_did', '=', normalizeDID(memberDID)).executeTakeFirst();
|
|
@@ -131,8 +206,25 @@ export function createGroupOps(db, adapter) {
|
|
|
131
206
|
removed_at_hlc: row.removed_at_hlc
|
|
132
207
|
};
|
|
133
208
|
},
|
|
209
|
+
async getMemberRemovals (params) {
|
|
210
|
+
const result = new Map();
|
|
211
|
+
const groupIDs = [
|
|
212
|
+
...new Set(params.groupIDs)
|
|
213
|
+
];
|
|
214
|
+
const memberDID = normalizeDID(params.memberDID);
|
|
215
|
+
for (const chunk of chunked(groupIDs)){
|
|
216
|
+
const rows = await db.selectFrom('kubun_p2p_group_members').select([
|
|
217
|
+
'group_id',
|
|
218
|
+
'removed_at_hlc'
|
|
219
|
+
]).where('group_id', 'in', chunk).where('member_did', '=', memberDID).execute();
|
|
220
|
+
for (const row of rows)result.set(row.group_id, {
|
|
221
|
+
removed_at_hlc: row.removed_at_hlc
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
return result;
|
|
225
|
+
},
|
|
134
226
|
async removeGroupMember (groupID, memberDID, hlc) {
|
|
135
|
-
await ops.markMemberRemoved(groupID, memberDID, hlc);
|
|
227
|
+
return await ops.markMemberRemoved(groupID, memberDID, hlc);
|
|
136
228
|
},
|
|
137
229
|
async getGroupMember (groupID, memberDID) {
|
|
138
230
|
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();
|
|
@@ -237,6 +329,9 @@ export function createGroupOps(db, adapter) {
|
|
|
237
329
|
'kubun_p2p_groups.description as description',
|
|
238
330
|
'kubun_p2p_groups.created_by as created_by',
|
|
239
331
|
'kubun_p2p_groups.status as status',
|
|
332
|
+
'kubun_p2p_groups.kind as kind',
|
|
333
|
+
'kubun_p2p_groups.projections_dirty as projections_dirty',
|
|
334
|
+
'kubun_p2p_groups.projections_epoch as projections_epoch',
|
|
240
335
|
'kubun_p2p_groups.hlc as hlc',
|
|
241
336
|
'kubun_p2p_groups.created_at as created_at',
|
|
242
337
|
'kubun_p2p_groups.updated_at as updated_at'
|
package/lib/ops/hubs.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { sql } from 'kysely';
|
|
2
|
+
import { canonicalHubURL } from '../hub-url.js';
|
|
2
3
|
import { COMMIT_HUB_UNIQUE_INDEX } from '../migrations.js';
|
|
3
4
|
/**
|
|
4
5
|
* Run `write` as a single atomic unit. The store API is built on whatever Kysely
|
|
@@ -83,7 +84,7 @@ export function createHubOps(db, adapter) {
|
|
|
83
84
|
await db.insertInto('kubun_p2p_hubs').values({
|
|
84
85
|
id: hub.id,
|
|
85
86
|
label: hub.label,
|
|
86
|
-
url: hub.url,
|
|
87
|
+
url: canonicalHubURL(hub.url),
|
|
87
88
|
server_did: hub.server_did
|
|
88
89
|
}).execute();
|
|
89
90
|
},
|
|
@@ -91,12 +92,12 @@ export function createHubOps(db, adapter) {
|
|
|
91
92
|
return await db.selectFrom('kubun_p2p_hubs').selectAll().where('id', '=', id).executeTakeFirst();
|
|
92
93
|
},
|
|
93
94
|
async getHubByURL (url) {
|
|
94
|
-
return await db.selectFrom('kubun_p2p_hubs').selectAll().where('url', '=', url).executeTakeFirst();
|
|
95
|
+
return await db.selectFrom('kubun_p2p_hubs').selectAll().where('url', '=', canonicalHubURL(url)).executeTakeFirst();
|
|
95
96
|
},
|
|
96
97
|
async updateHub (id, update) {
|
|
97
98
|
const values = {};
|
|
98
99
|
if (update.label !== undefined) values.label = update.label;
|
|
99
|
-
if (update.url !== undefined) values.url = update.url;
|
|
100
|
+
if (update.url !== undefined) values.url = canonicalHubURL(update.url);
|
|
100
101
|
if (update.server_did !== undefined) values.server_did = update.server_did;
|
|
101
102
|
values.updated_at = adapter.encodeTimestamp(new Date());
|
|
102
103
|
await db.updateTable('kubun_p2p_hubs').set(values).where('id', '=', id).execute();
|
|
@@ -148,6 +149,9 @@ export function createHubOps(db, adapter) {
|
|
|
148
149
|
'kubun_p2p_groups.description as description',
|
|
149
150
|
'kubun_p2p_groups.created_by as created_by',
|
|
150
151
|
'kubun_p2p_groups.status as status',
|
|
152
|
+
'kubun_p2p_groups.kind as kind',
|
|
153
|
+
'kubun_p2p_groups.projections_dirty as projections_dirty',
|
|
154
|
+
'kubun_p2p_groups.projections_epoch as projections_epoch',
|
|
151
155
|
'kubun_p2p_groups.hlc as hlc',
|
|
152
156
|
'kubun_p2p_groups.created_at as created_at',
|
|
153
157
|
'kubun_p2p_groups.updated_at as updated_at'
|
|
@@ -195,7 +199,7 @@ export function createHubOps(db, adapter) {
|
|
|
195
199
|
// both sides carry a server DID it must match too: the same URL served
|
|
196
200
|
// by a different server DID is a different hub identity, and flagging it
|
|
197
201
|
// 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);
|
|
202
|
+
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', '=', canonicalHubURL(commitHub.url));
|
|
199
203
|
if (commitHub.serverDID != null) {
|
|
200
204
|
// A bound row with no recorded server DID stays eligible — it has not
|
|
201
205
|
// claimed a conflicting identity, only an unknown one.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Kysely } from 'kysely';
|
|
2
|
+
import type { P2PStoreTables } from '../tables.js';
|
|
3
|
+
export type LogHandlerCompletionKey = {
|
|
4
|
+
groupID: string;
|
|
5
|
+
procedure: string;
|
|
6
|
+
/** Verified issuer plus token identity, or the MLS-authenticated announcer. */
|
|
7
|
+
entity: string;
|
|
8
|
+
hlc: string;
|
|
9
|
+
};
|
|
10
|
+
export type LogHandlerCompletionOps = {
|
|
11
|
+
/** Retain the first attempt's notification decision before writing handler state. */
|
|
12
|
+
beginLogHandlerCompletion(params: BeginLogHandlerCompletionParams): Promise<boolean>;
|
|
13
|
+
hasLogHandlerCompleted(key: LogHandlerCompletionKey): Promise<boolean>;
|
|
14
|
+
markLogHandlerCompleted(key: LogHandlerCompletionKey): Promise<void>;
|
|
15
|
+
};
|
|
16
|
+
export type BeginLogHandlerCompletionParams = {
|
|
17
|
+
key: LogHandlerCompletionKey;
|
|
18
|
+
notify: boolean;
|
|
19
|
+
};
|
|
20
|
+
export declare function createLogHandlerCompletionOps(db: Kysely<P2PStoreTables>): LogHandlerCompletionOps;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export function createLogHandlerCompletionOps(db) {
|
|
2
|
+
return {
|
|
3
|
+
async beginLogHandlerCompletion ({ key, notify }) {
|
|
4
|
+
const row = await db.insertInto('kubun_p2p_log_handler_completion').values({
|
|
5
|
+
group_id: key.groupID,
|
|
6
|
+
procedure: key.procedure,
|
|
7
|
+
entity: key.entity,
|
|
8
|
+
hlc: key.hlc,
|
|
9
|
+
notify: notify ? 1 : 0
|
|
10
|
+
}).onConflict((oc)=>oc.columns([
|
|
11
|
+
'group_id',
|
|
12
|
+
'procedure',
|
|
13
|
+
'entity',
|
|
14
|
+
'hlc'
|
|
15
|
+
]).doUpdateSet({
|
|
16
|
+
hlc: key.hlc
|
|
17
|
+
})).returning('notify').executeTakeFirstOrThrow();
|
|
18
|
+
return row.notify === 1;
|
|
19
|
+
},
|
|
20
|
+
async hasLogHandlerCompleted (key) {
|
|
21
|
+
const row = await db.selectFrom('kubun_p2p_log_handler_completion').select('hlc').where('group_id', '=', key.groupID).where('procedure', '=', key.procedure).where('entity', '=', key.entity).where('hlc', '=', key.hlc).where('completed', '=', 1).executeTakeFirst();
|
|
22
|
+
return row != null;
|
|
23
|
+
},
|
|
24
|
+
async markLogHandlerCompleted (key) {
|
|
25
|
+
await db.insertInto('kubun_p2p_log_handler_completion').values({
|
|
26
|
+
group_id: key.groupID,
|
|
27
|
+
procedure: key.procedure,
|
|
28
|
+
entity: key.entity,
|
|
29
|
+
hlc: key.hlc,
|
|
30
|
+
completed: 1
|
|
31
|
+
}).onConflict((oc)=>oc.columns([
|
|
32
|
+
'group_id',
|
|
33
|
+
'procedure',
|
|
34
|
+
'entity',
|
|
35
|
+
'hlc'
|
|
36
|
+
]).doUpdateSet({
|
|
37
|
+
completed: 1
|
|
38
|
+
})).execute();
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
package/lib/ops/mls.d.ts
CHANGED
|
@@ -1,19 +1,52 @@
|
|
|
1
1
|
import type { Adapter } from '@kubun/db-adapter';
|
|
2
2
|
import type { Kysely } from 'kysely';
|
|
3
3
|
import type { GroupMLSState, InsertGroupMLSState, P2PStoreTables } from '../tables.js';
|
|
4
|
+
export type ReserveSenderGenerationsParams = {
|
|
5
|
+
groupID: string;
|
|
6
|
+
deviceID: string;
|
|
7
|
+
epoch: number;
|
|
8
|
+
/** The sender generation encryption has reached. */
|
|
9
|
+
current: number;
|
|
10
|
+
/** How many generations past `current` to reserve. */
|
|
11
|
+
size: number;
|
|
12
|
+
/** The high-water mark the caller last saw; a different stored mark returns `null`. */
|
|
13
|
+
expectedHWM: number;
|
|
14
|
+
/**
|
|
15
|
+
* The caller's state, stored in place when the row is still at `stateVersion` and the
|
|
16
|
+
* reservation made here is still the latest. Its sender generation must not exceed
|
|
17
|
+
* `current`, so it never leads the hwm restore fast-forwards to. The version does not
|
|
18
|
+
* move, so no other holder of the row loses its CAS; restore then fast-forwards at most
|
|
19
|
+
* one lease instead of every lease since the last save.
|
|
20
|
+
*/
|
|
21
|
+
senderState?: {
|
|
22
|
+
mlsState: Uint8Array;
|
|
23
|
+
stateVersion: number;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
4
26
|
/** Per-device MLS state for each group. */
|
|
5
27
|
export type MLSOps = {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
28
|
+
/** True when this version was stored; false when a newer state already won. */
|
|
29
|
+
saveMLSState(state: InsertGroupMLSState, expected?: {
|
|
30
|
+
epoch: number;
|
|
31
|
+
stateVersion: number;
|
|
32
|
+
} | null): Promise<boolean>;
|
|
33
|
+
/** Atomically reserve sender generations before encryption can use them. */
|
|
34
|
+
reserveSenderGenerations(params: ReserveSenderGenerationsParams): Promise<number | null>;
|
|
35
|
+
getMLSState(groupID: string, deviceID: string, options?: {
|
|
36
|
+
includeDeleted?: boolean;
|
|
37
|
+
}): Promise<GroupMLSState | undefined>;
|
|
38
|
+
deleteMLSState(groupID: string, deviceID: string, expected?: {
|
|
39
|
+
epoch: number;
|
|
40
|
+
stateVersion: number;
|
|
41
|
+
}): Promise<boolean>;
|
|
9
42
|
listMLSStates(groupID: string): Promise<Array<GroupMLSState>>;
|
|
10
43
|
/**
|
|
11
44
|
* The groups this device holds MLS state for, keyed on the same `(group_id,
|
|
12
45
|
* device_id)` pair {@link getMLSState} reads.
|
|
13
46
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
47
|
+
* It does not answer "have I joined this group": a removed device still holds
|
|
48
|
+
* state. Binding evidence backfill wants exactly that held state, joined or not;
|
|
49
|
+
* tests use it to assert that a departure left nothing behind.
|
|
17
50
|
*/
|
|
18
51
|
listMLSStateGroupIDs(deviceID: string): Promise<Array<string>>;
|
|
19
52
|
deleteGroupMLSStates(groupID: string): Promise<void>;
|