@kubun/store-p2p 0.11.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +21 -18
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { normalizeDID } from '@kokuin/token';
|
|
2
|
+
/**
|
|
3
|
+
* A member may access an active circle by either arm: an active explicit
|
|
4
|
+
* membership row, OR the circle being open (`open = 1`) with the member holding
|
|
5
|
+
* an active row in the circle's group. Both arms gate on `removed_at_hlc` — a
|
|
6
|
+
* tombstoned membership or group membership grants nothing — and `open` is read
|
|
7
|
+
* at current state, so closing the circle revokes the open arm immediately.
|
|
8
|
+
*
|
|
9
|
+
* Every circle reader shares this one predicate: an open circle carries no
|
|
10
|
+
* membership rows, so a reader resolving access from `kubun_p2p_circle_members`
|
|
11
|
+
* alone would see an empty set for a member whose access is purely implicit.
|
|
12
|
+
* Callers must still filter tombstoned circles themselves.
|
|
13
|
+
*/ function circleAccessPredicate(eb, normalizedDID) {
|
|
14
|
+
return eb.or([
|
|
15
|
+
eb.exists(eb.selectFrom('kubun_p2p_circle_members').select('kubun_p2p_circle_members.circle_id').whereRef('kubun_p2p_circle_members.circle_id', '=', 'kubun_p2p_circles.id').where('kubun_p2p_circle_members.member_did', '=', normalizedDID).where('kubun_p2p_circle_members.removed_at_hlc', 'is', null)),
|
|
16
|
+
eb.and([
|
|
17
|
+
eb('kubun_p2p_circles.open', '=', 1),
|
|
18
|
+
eb.exists(eb.selectFrom('kubun_p2p_group_members').select('kubun_p2p_group_members.member_did').whereRef('kubun_p2p_group_members.group_id', '=', 'kubun_p2p_circles.group_id').where('kubun_p2p_group_members.member_did', '=', normalizedDID).where('kubun_p2p_group_members.removed_at_hlc', 'is', null))
|
|
19
|
+
])
|
|
20
|
+
]);
|
|
21
|
+
}
|
|
22
|
+
export function createCircleOps(db, adapter) {
|
|
23
|
+
return {
|
|
24
|
+
async createCircle (circle) {
|
|
25
|
+
await db.insertInto('kubun_p2p_circles').values({
|
|
26
|
+
id: circle.id,
|
|
27
|
+
group_id: circle.group_id,
|
|
28
|
+
name: circle.name,
|
|
29
|
+
description: circle.description,
|
|
30
|
+
catalog_ids: adapter.encodeJSON(circle.catalog_ids),
|
|
31
|
+
hlc: circle.hlc
|
|
32
|
+
}).execute();
|
|
33
|
+
},
|
|
34
|
+
async getCircle (id, options) {
|
|
35
|
+
let query = db.selectFrom('kubun_p2p_circles').selectAll().where('id', '=', id);
|
|
36
|
+
if (!options?.includeRemoved) {
|
|
37
|
+
query = query.where('removed_at_hlc', 'is', null);
|
|
38
|
+
}
|
|
39
|
+
return await query.executeTakeFirst();
|
|
40
|
+
},
|
|
41
|
+
async getCircles (ids) {
|
|
42
|
+
const result = new Map();
|
|
43
|
+
if (ids.length === 0) return result;
|
|
44
|
+
const rows = await db.selectFrom('kubun_p2p_circles').selectAll().where('id', 'in', ids).execute();
|
|
45
|
+
for (const row of rows){
|
|
46
|
+
result.set(row.id, row);
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
},
|
|
50
|
+
async updateCircle (id, update) {
|
|
51
|
+
const values = {};
|
|
52
|
+
if (update.name != null) values.name = update.name;
|
|
53
|
+
if (update.description != null) values.description = update.description;
|
|
54
|
+
if (update.catalog_ids != null) {
|
|
55
|
+
values.catalog_ids = adapter.encodeJSON(update.catalog_ids);
|
|
56
|
+
}
|
|
57
|
+
if (update.hlc != null) {
|
|
58
|
+
values.hlc = update.hlc;
|
|
59
|
+
// A create/update that wins the apply-layer HLC check resurrects a
|
|
60
|
+
// tombstoned circle: clear the removal alongside the HLC refresh.
|
|
61
|
+
values.removed_at_hlc = null;
|
|
62
|
+
}
|
|
63
|
+
values.updated_at = adapter.encodeTimestamp(new Date());
|
|
64
|
+
await db.updateTable('kubun_p2p_circles').set(values).where('id', '=', id).execute();
|
|
65
|
+
},
|
|
66
|
+
async deleteCircle (id) {
|
|
67
|
+
await db.deleteFrom('kubun_p2p_circles').where('id', '=', id).execute();
|
|
68
|
+
},
|
|
69
|
+
// Earliest-wins LWW on `removed_at_hlc`: the update lands only when the row
|
|
70
|
+
// exists AND it is either not yet tombstoned or the incoming `hlc` precedes
|
|
71
|
+
// the stored removal HLC. A removal for a `circleID` with no local row is
|
|
72
|
+
// ignored — there is no circle to tombstone, and creating a row would leak
|
|
73
|
+
// a phantom circle.
|
|
74
|
+
async markCircleRemoved (circleID, hlc) {
|
|
75
|
+
await db.updateTable('kubun_p2p_circles').set({
|
|
76
|
+
removed_at_hlc: hlc,
|
|
77
|
+
updated_at: adapter.encodeTimestamp(new Date())
|
|
78
|
+
}).where('id', '=', circleID).where((eb)=>eb.or([
|
|
79
|
+
eb('removed_at_hlc', 'is', null),
|
|
80
|
+
eb('removed_at_hlc', '>', hlc)
|
|
81
|
+
])).execute();
|
|
82
|
+
},
|
|
83
|
+
async listCirclesByGroup (groupID, options) {
|
|
84
|
+
let query = db.selectFrom('kubun_p2p_circles').selectAll().where('group_id', '=', groupID);
|
|
85
|
+
if (!options?.includeRemoved) {
|
|
86
|
+
query = query.where('removed_at_hlc', 'is', null);
|
|
87
|
+
}
|
|
88
|
+
return await query.execute();
|
|
89
|
+
},
|
|
90
|
+
async addCircleMember (member) {
|
|
91
|
+
// Re-adding a previously-removed member clears the tombstone and refreshes
|
|
92
|
+
// the row's HLC, so a member that left and rejoined is active again.
|
|
93
|
+
await db.insertInto('kubun_p2p_circle_members').values({
|
|
94
|
+
circle_id: member.circle_id,
|
|
95
|
+
member_did: normalizeDID(member.member_did),
|
|
96
|
+
role: member.role,
|
|
97
|
+
hlc: member.hlc
|
|
98
|
+
}).onConflict((oc)=>oc.columns([
|
|
99
|
+
'circle_id',
|
|
100
|
+
'member_did'
|
|
101
|
+
]).doUpdateSet((eb)=>({
|
|
102
|
+
role: eb.ref('excluded.role'),
|
|
103
|
+
hlc: eb.ref('excluded.hlc'),
|
|
104
|
+
removed_at_hlc: null,
|
|
105
|
+
updated_at: adapter.encodeTimestamp(new Date())
|
|
106
|
+
}))).execute();
|
|
107
|
+
},
|
|
108
|
+
// Earliest-wins LWW on `removed_at_hlc`, mirroring `markMemberRemoved`: the
|
|
109
|
+
// update lands only when the row exists AND it is either not yet tombstoned
|
|
110
|
+
// or the incoming `hlc` precedes the stored removal HLC. A removal for a
|
|
111
|
+
// `(circleID, memberDID)` with no local row is ignored — no phantom row is
|
|
112
|
+
// created.
|
|
113
|
+
async removeCircleMember (circleID, memberDID, hlc) {
|
|
114
|
+
await db.updateTable('kubun_p2p_circle_members').set({
|
|
115
|
+
removed_at_hlc: hlc,
|
|
116
|
+
updated_at: adapter.encodeTimestamp(new Date())
|
|
117
|
+
}).where('circle_id', '=', circleID).where('member_did', '=', normalizeDID(memberDID)).where((eb)=>eb.or([
|
|
118
|
+
eb('removed_at_hlc', 'is', null),
|
|
119
|
+
eb('removed_at_hlc', '>', hlc)
|
|
120
|
+
])).execute();
|
|
121
|
+
},
|
|
122
|
+
async getCircleMember (circleID, memberDID) {
|
|
123
|
+
return await db.selectFrom('kubun_p2p_circle_members').selectAll().where('circle_id', '=', circleID).where('member_did', '=', normalizeDID(memberDID)).executeTakeFirst();
|
|
124
|
+
},
|
|
125
|
+
async listCircleMembers (circleID, options) {
|
|
126
|
+
let query = db.selectFrom('kubun_p2p_circle_members').selectAll().where('circle_id', '=', circleID);
|
|
127
|
+
if (!options?.includeRemoved) {
|
|
128
|
+
query = query.where('removed_at_hlc', 'is', null);
|
|
129
|
+
}
|
|
130
|
+
return await query.execute();
|
|
131
|
+
},
|
|
132
|
+
// Read-ACL / sync-scope / write-perimeter source: every active circle the
|
|
133
|
+
// member can reach, explicitly or through the open arm.
|
|
134
|
+
async getCirclesForMember (memberDID) {
|
|
135
|
+
const normalized = normalizeDID(memberDID);
|
|
136
|
+
return await db.selectFrom('kubun_p2p_circles').selectAll('kubun_p2p_circles').where('kubun_p2p_circles.removed_at_hlc', 'is', null).where((eb)=>circleAccessPredicate(eb, normalized)).execute();
|
|
137
|
+
},
|
|
138
|
+
// Read-ACL / sync-scope source: does the member reach any of these circles?
|
|
139
|
+
async isMemberOfAnyCircle (memberDID, circleIDs) {
|
|
140
|
+
if (circleIDs.length === 0) return false;
|
|
141
|
+
const normalized = normalizeDID(memberDID);
|
|
142
|
+
const row = await db.selectFrom('kubun_p2p_circles').select('kubun_p2p_circles.id').where('kubun_p2p_circles.id', 'in', circleIDs).where('kubun_p2p_circles.removed_at_hlc', 'is', null).where((eb)=>circleAccessPredicate(eb, normalized)).limit(1).executeTakeFirst();
|
|
143
|
+
return row != null;
|
|
144
|
+
},
|
|
145
|
+
// Same predicate as `isMemberOfAnyCircle`, projecting the matching rows
|
|
146
|
+
// instead of collapsing to a single existence answer.
|
|
147
|
+
async listAccessibleCircles (memberDID, circleIDs) {
|
|
148
|
+
if (circleIDs.length === 0) return [];
|
|
149
|
+
const normalized = normalizeDID(memberDID);
|
|
150
|
+
const rows = await db.selectFrom('kubun_p2p_circles').select([
|
|
151
|
+
'kubun_p2p_circles.id',
|
|
152
|
+
'kubun_p2p_circles.group_id'
|
|
153
|
+
]).where('kubun_p2p_circles.id', 'in', circleIDs).where('kubun_p2p_circles.removed_at_hlc', 'is', null).where((eb)=>circleAccessPredicate(eb, normalized)).execute();
|
|
154
|
+
return rows.map((row)=>({
|
|
155
|
+
id: row.id,
|
|
156
|
+
groupID: row.group_id
|
|
157
|
+
}));
|
|
158
|
+
},
|
|
159
|
+
// Sync-owner expansion: explicit active members always, plus the group's
|
|
160
|
+
// active members when the circle is open. A closed, tombstoned, or unknown
|
|
161
|
+
// circle contributes only its explicit members, so the closed-circle owner
|
|
162
|
+
// set is unchanged.
|
|
163
|
+
async listCircleOwners (circleID) {
|
|
164
|
+
// The explicit rows and the circle row are independent, so both go out at
|
|
165
|
+
// once; only the group read depends on what the circle says.
|
|
166
|
+
const [explicit, circle] = await Promise.all([
|
|
167
|
+
db.selectFrom('kubun_p2p_circle_members').select('member_did').where('circle_id', '=', circleID).where('removed_at_hlc', 'is', null).execute(),
|
|
168
|
+
db.selectFrom('kubun_p2p_circles').select([
|
|
169
|
+
'group_id',
|
|
170
|
+
'open'
|
|
171
|
+
]).where('id', '=', circleID).where('removed_at_hlc', 'is', null).executeTakeFirst()
|
|
172
|
+
]);
|
|
173
|
+
const owners = new Set(explicit.map((row)=>row.member_did));
|
|
174
|
+
if (circle != null && circle.open === 1) {
|
|
175
|
+
const groupMembers = await db.selectFrom('kubun_p2p_group_members').select('member_did').where('group_id', '=', circle.group_id).where('removed_at_hlc', 'is', null).execute();
|
|
176
|
+
for (const row of groupMembers){
|
|
177
|
+
owners.add(row.member_did);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return Array.from(owners);
|
|
181
|
+
},
|
|
182
|
+
// Union of `listCircleOwners` over the set, in three queries: the explicit
|
|
183
|
+
// rows of every circle, the open ones among them, then the members of those
|
|
184
|
+
// circles' groups. A closed circle still contributes only its explicit
|
|
185
|
+
// members, since only the open ids feed the group read.
|
|
186
|
+
async listOwnersForCircles (circleIDs) {
|
|
187
|
+
if (circleIDs.length === 0) return [];
|
|
188
|
+
// First two are independent and go out together; the third needs the open
|
|
189
|
+
// circles' groups, so it waits.
|
|
190
|
+
const [explicit, openCircles] = await Promise.all([
|
|
191
|
+
db.selectFrom('kubun_p2p_circle_members').select('member_did').where('circle_id', 'in', circleIDs).where('removed_at_hlc', 'is', null).execute(),
|
|
192
|
+
db.selectFrom('kubun_p2p_circles').select('group_id').where('id', 'in', circleIDs).where('removed_at_hlc', 'is', null).where('open', '=', 1).execute()
|
|
193
|
+
]);
|
|
194
|
+
const owners = new Set(explicit.map((row)=>row.member_did));
|
|
195
|
+
const groupIDs = Array.from(new Set(openCircles.map((row)=>row.group_id)));
|
|
196
|
+
if (groupIDs.length > 0) {
|
|
197
|
+
const groupMembers = await db.selectFrom('kubun_p2p_group_members').select('member_did').where('group_id', 'in', groupIDs).where('removed_at_hlc', 'is', null).execute();
|
|
198
|
+
for (const row of groupMembers){
|
|
199
|
+
owners.add(row.member_did);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return Array.from(owners);
|
|
203
|
+
},
|
|
204
|
+
// Materialize the group's current open set onto its circle rows: the listed
|
|
205
|
+
// circles become open, every other circle in the group closed. The caller
|
|
206
|
+
// derives `openCircleIDs` from the settings fold, so this write is the
|
|
207
|
+
// current-state projection with no HLC arbitration of its own.
|
|
208
|
+
async reconcileCircleOpenFlags (groupID, openCircleIDs) {
|
|
209
|
+
const now = adapter.encodeTimestamp(new Date());
|
|
210
|
+
if (openCircleIDs.length > 0) {
|
|
211
|
+
await db.updateTable('kubun_p2p_circles').set({
|
|
212
|
+
open: 1,
|
|
213
|
+
updated_at: now
|
|
214
|
+
}).where('group_id', '=', groupID).where('id', 'in', openCircleIDs).execute();
|
|
215
|
+
}
|
|
216
|
+
let closeQuery = db.updateTable('kubun_p2p_circles').set({
|
|
217
|
+
open: 0,
|
|
218
|
+
updated_at: now
|
|
219
|
+
}).where('group_id', '=', groupID);
|
|
220
|
+
if (openCircleIDs.length > 0) {
|
|
221
|
+
closeQuery = closeQuery.where('id', 'not in', openCircleIDs);
|
|
222
|
+
}
|
|
223
|
+
await closeQuery.execute();
|
|
224
|
+
},
|
|
225
|
+
// Unconditional fold-authoritative upsert: every projected column is set
|
|
226
|
+
// from the supplied derived state, including `removed_at_hlc` (a value
|
|
227
|
+
// tombstones, null marks active). No HLC guard — the reproject already
|
|
228
|
+
// resolved the winner via the ledger fold, so the store write must not
|
|
229
|
+
// re-arbitrate.
|
|
230
|
+
async reconcileCircleProjection (params) {
|
|
231
|
+
await db.insertInto('kubun_p2p_circles').values({
|
|
232
|
+
id: params.id,
|
|
233
|
+
group_id: params.groupID,
|
|
234
|
+
name: params.name,
|
|
235
|
+
description: params.description,
|
|
236
|
+
catalog_ids: adapter.encodeJSON(params.catalogIDs),
|
|
237
|
+
hlc: params.hlc,
|
|
238
|
+
removed_at_hlc: params.removedAtHLC,
|
|
239
|
+
open: params.open ? 1 : 0
|
|
240
|
+
}).onConflict((oc)=>oc.column('id').doUpdateSet((eb)=>({
|
|
241
|
+
group_id: eb.ref('excluded.group_id'),
|
|
242
|
+
name: eb.ref('excluded.name'),
|
|
243
|
+
description: eb.ref('excluded.description'),
|
|
244
|
+
catalog_ids: eb.ref('excluded.catalog_ids'),
|
|
245
|
+
hlc: eb.ref('excluded.hlc'),
|
|
246
|
+
removed_at_hlc: eb.ref('excluded.removed_at_hlc'),
|
|
247
|
+
open: eb.ref('excluded.open'),
|
|
248
|
+
updated_at: adapter.encodeTimestamp(new Date())
|
|
249
|
+
}))).execute();
|
|
250
|
+
},
|
|
251
|
+
async reconcileCircleMemberProjection (params) {
|
|
252
|
+
await db.insertInto('kubun_p2p_circle_members').values({
|
|
253
|
+
circle_id: params.circleID,
|
|
254
|
+
member_did: normalizeDID(params.memberDID),
|
|
255
|
+
role: params.role,
|
|
256
|
+
hlc: params.hlc,
|
|
257
|
+
removed_at_hlc: params.removedAtHLC
|
|
258
|
+
}).onConflict((oc)=>oc.columns([
|
|
259
|
+
'circle_id',
|
|
260
|
+
'member_did'
|
|
261
|
+
]).doUpdateSet((eb)=>({
|
|
262
|
+
role: eb.ref('excluded.role'),
|
|
263
|
+
hlc: eb.ref('excluded.hlc'),
|
|
264
|
+
removed_at_hlc: eb.ref('excluded.removed_at_hlc'),
|
|
265
|
+
updated_at: adapter.encodeTimestamp(new Date())
|
|
266
|
+
}))).execute();
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { Adapter } from '@kubun/db-adapter';
|
|
2
|
+
import type { Kysely } from 'kysely';
|
|
3
|
+
import type { CommitJournalEntry, ControlRequest, P2PStoreTables } from '../tables.js';
|
|
4
|
+
export type ControlRequestKind = 'ledger' | 'invite' | 'remove';
|
|
5
|
+
/**
|
|
6
|
+
* How a control request ended. `no_op` is terminal like the rest: the request
|
|
7
|
+
* asked for a state that already held, so no commit was ever driven — which is
|
|
8
|
+
* why it is not `committed`, a status that asserts a hub accepted a commit.
|
|
9
|
+
*/
|
|
10
|
+
export type ControlRequestOutcome = 'committed' | 'rejected' | 'lost' | 'no_op';
|
|
11
|
+
export type CommitJournalEntryKind = 'ledger' | 'invite' | 'remove';
|
|
12
|
+
/** The control-request work queue and the single-slot commit journal. */
|
|
13
|
+
export type ControlOps = {
|
|
14
|
+
/**
|
|
15
|
+
* Enqueue a `pending` ControlRequest. `id` is the same id the CommitJournal
|
|
16
|
+
* carries for this attempt. `enqueuedAt` is caller-supplied (the store never
|
|
17
|
+
* calls `Date.now()`) so enqueue stays deterministic and testable.
|
|
18
|
+
*/
|
|
19
|
+
enqueueControlRequest(params: {
|
|
20
|
+
id: string;
|
|
21
|
+
groupID: string;
|
|
22
|
+
kind: ControlRequestKind;
|
|
23
|
+
enqueuedAt: string;
|
|
24
|
+
}): Promise<void>;
|
|
25
|
+
/** A single request by id, regardless of status, or `undefined` if absent (already acked or never enqueued). */
|
|
26
|
+
getControlRequest(id: string): Promise<ControlRequest | undefined>;
|
|
27
|
+
/**
|
|
28
|
+
* The live queue: every `pending` row plus every settled row the client has
|
|
29
|
+
* not acked yet, whatever its outcome, optionally scoped to `groupID`,
|
|
30
|
+
* ordered by `enqueuedAt`. This is a work queue, not a history; the control
|
|
31
|
+
* ledger is the history.
|
|
32
|
+
*/
|
|
33
|
+
getControlRequests(groupID?: string): Promise<Array<ControlRequest>>;
|
|
34
|
+
/**
|
|
35
|
+
* Settle a request: update `status`/`error`/`result`/`settledAt` in place and
|
|
36
|
+
* RETAIN the row, whatever the outcome. Retention is uniform because a client
|
|
37
|
+
* holding the request's id must be able to read its outcome back — including
|
|
38
|
+
* a success — and the settled event itself is only a live subscription, so a
|
|
39
|
+
* client reloading at the moment a request settles would otherwise miss it.
|
|
40
|
+
* The row leaves only via `ackControlRequest`. Settling an id that no longer
|
|
41
|
+
* has a row (already acked) is a no-op.
|
|
42
|
+
*
|
|
43
|
+
* A settled row is settled. `pending` is the only non-terminal status, so the
|
|
44
|
+
* accepted transitions are out of `pending` and a re-settle to the SAME
|
|
45
|
+
* outcome — the latter because adopting a commit journal is contractually
|
|
46
|
+
* idempotent and re-settles an already-`committed` row with the same payload.
|
|
47
|
+
* Anything else (a `committed` request reported `rejected` by a later failure
|
|
48
|
+
* on an unrelated path) is REFUSED silently: every caller is a producer
|
|
49
|
+
* already handling a failure, so throwing would replace the real error with a
|
|
50
|
+
* bookkeeping one. Callers needing the outcome read the row back.
|
|
51
|
+
*/
|
|
52
|
+
settleControlRequest(params: {
|
|
53
|
+
id: string;
|
|
54
|
+
outcome: ControlRequestOutcome;
|
|
55
|
+
/** Absent leaves any stored error untouched — absent is not "clear it". */
|
|
56
|
+
error?: string;
|
|
57
|
+
/**
|
|
58
|
+
* The artifact this request produced, for the kinds that produce one. Set in
|
|
59
|
+
* the SAME update as the status, so a reader that learns the request settled
|
|
60
|
+
* can never find the result missing. Absent leaves any stored artifact
|
|
61
|
+
* untouched: a re-settle carrying no result must not null out the payload an
|
|
62
|
+
* earlier one stored.
|
|
63
|
+
*/
|
|
64
|
+
result?: string;
|
|
65
|
+
settledAt: string;
|
|
66
|
+
}): Promise<void>;
|
|
67
|
+
/**
|
|
68
|
+
* The settled request's artifact, or `null` when it produced none or the row
|
|
69
|
+
* is gone. Read separately from the row so the result — which can carry
|
|
70
|
+
* material a settled-request listing has no business handing out — is only
|
|
71
|
+
* ever fetched by a caller that asks for it by id.
|
|
72
|
+
*/
|
|
73
|
+
getControlRequestResult(id: string): Promise<string | null>;
|
|
74
|
+
/**
|
|
75
|
+
* Acknowledge a settled row, deleting it — the app's way of saying "I have
|
|
76
|
+
* observed this outcome." Acking a `pending` (or already-absent) row is a
|
|
77
|
+
* no-op, never an error. The row's `result` goes with it.
|
|
78
|
+
*/
|
|
79
|
+
ackControlRequest(id: string): Promise<void>;
|
|
80
|
+
/**
|
|
81
|
+
* Write the group's slot, REPLACING whatever it held. Single slot per
|
|
82
|
+
* group: at most one pending commit at a time. `acceptedAs` is absent by
|
|
83
|
+
* default (a fresh `put` precedes the publish, before any acceptance is
|
|
84
|
+
* known); pass it only when re-persisting an already-accepted entry.
|
|
85
|
+
*/
|
|
86
|
+
putCommitJournalEntry(params: {
|
|
87
|
+
groupID: string;
|
|
88
|
+
publishID: string;
|
|
89
|
+
expectedHead: string | null;
|
|
90
|
+
epoch: number;
|
|
91
|
+
acceptedAs?: string;
|
|
92
|
+
commit: Uint8Array;
|
|
93
|
+
bodies: Array<string>;
|
|
94
|
+
kind: CommitJournalEntryKind;
|
|
95
|
+
journal: Uint8Array;
|
|
96
|
+
}): Promise<void>;
|
|
97
|
+
/** The group's slot, or `undefined` if empty. */
|
|
98
|
+
getCommitJournalEntry(groupID: string): Promise<CommitJournalEntry | undefined>;
|
|
99
|
+
/**
|
|
100
|
+
* Record that the hub accepted `publishID` as `sequenceID`. A no-op if the
|
|
101
|
+
* group's slot no longer holds `publishID` — never a write over a
|
|
102
|
+
* different pending commit.
|
|
103
|
+
*/
|
|
104
|
+
markCommitJournalAccepted(params: {
|
|
105
|
+
groupID: string;
|
|
106
|
+
publishID: string;
|
|
107
|
+
sequenceID: string;
|
|
108
|
+
}): Promise<void>;
|
|
109
|
+
/**
|
|
110
|
+
* Delete the group's slot ONLY if it holds `publishID`. A mismatched
|
|
111
|
+
* `publishID` is a no-op.
|
|
112
|
+
*/
|
|
113
|
+
clearCommitJournalEntry(params: {
|
|
114
|
+
groupID: string;
|
|
115
|
+
publishID: string;
|
|
116
|
+
}): Promise<void>;
|
|
117
|
+
};
|
|
118
|
+
export declare function createControlOps(db: Kysely<P2PStoreTables>, adapter: Adapter): ControlOps;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
export function createControlOps(db, adapter) {
|
|
2
|
+
return {
|
|
3
|
+
async enqueueControlRequest (params) {
|
|
4
|
+
await db.insertInto('kubun_p2p_control_requests').values({
|
|
5
|
+
id: params.id,
|
|
6
|
+
group_id: params.groupID,
|
|
7
|
+
kind: params.kind,
|
|
8
|
+
enqueued_at: params.enqueuedAt
|
|
9
|
+
}).execute();
|
|
10
|
+
},
|
|
11
|
+
async getControlRequest (id) {
|
|
12
|
+
return await db.selectFrom('kubun_p2p_control_requests').selectAll().where('id', '=', id).executeTakeFirst();
|
|
13
|
+
},
|
|
14
|
+
// Every row still in the table is either `pending` or a settled row the
|
|
15
|
+
// client has not acked yet — no status filter is needed beyond the optional
|
|
16
|
+
// group scope.
|
|
17
|
+
async getControlRequests (groupID) {
|
|
18
|
+
// `enqueued_at` is a caller-supplied string, so equal stamps are routine —
|
|
19
|
+
// two requests enqueued in one transaction share one. The id breaks the tie
|
|
20
|
+
// so a page is a stable order rather than the storage engine's whim.
|
|
21
|
+
let query = db.selectFrom('kubun_p2p_control_requests').selectAll().orderBy('enqueued_at').orderBy('id');
|
|
22
|
+
if (groupID != null) {
|
|
23
|
+
query = query.where('group_id', '=', groupID);
|
|
24
|
+
}
|
|
25
|
+
return await query.execute();
|
|
26
|
+
},
|
|
27
|
+
async settleControlRequest (params) {
|
|
28
|
+
// Every outcome retains the row: a client that holds the request's id must
|
|
29
|
+
// be able to read the outcome back, and it cannot do that if a success
|
|
30
|
+
// vanishes underneath it. The row leaves only when the client acks it.
|
|
31
|
+
// A no-op if the row is already gone (already acked) — the `WHERE id = ?`
|
|
32
|
+
// simply matches nothing.
|
|
33
|
+
//
|
|
34
|
+
// Absent `error`/`result` mean "leave it alone", never "clear it": an
|
|
35
|
+
// idempotent re-settle carries only the status it already knows, and
|
|
36
|
+
// nulling the stored artifact would strip a landed invite of its Welcome.
|
|
37
|
+
const update = {
|
|
38
|
+
status: params.outcome,
|
|
39
|
+
settled_at: params.settledAt
|
|
40
|
+
};
|
|
41
|
+
if (params.error !== undefined) {
|
|
42
|
+
update.error = params.error;
|
|
43
|
+
}
|
|
44
|
+
if (params.result !== undefined) {
|
|
45
|
+
update.result = params.result;
|
|
46
|
+
}
|
|
47
|
+
await db.updateTable('kubun_p2p_control_requests').set(update)// The terminal-state guard, in the WHERE so it is decided by the same
|
|
48
|
+
// statement that writes: only a `pending` row, or a re-settle to the
|
|
49
|
+
// outcome already stored, matches. A different outcome over a terminal
|
|
50
|
+
// row matches nothing and the settle is silently dropped — the stored
|
|
51
|
+
// outcome is the true one, and this is reached from error paths where a
|
|
52
|
+
// throw would mask the failure that got us here.
|
|
53
|
+
.where('id', '=', params.id).where((eb)=>eb.or([
|
|
54
|
+
eb('status', '=', 'pending'),
|
|
55
|
+
eb('status', '=', params.outcome)
|
|
56
|
+
])).execute();
|
|
57
|
+
},
|
|
58
|
+
async getControlRequestResult (id) {
|
|
59
|
+
const row = await db.selectFrom('kubun_p2p_control_requests').select('result').where('id', '=', id).executeTakeFirst();
|
|
60
|
+
return row?.result ?? null;
|
|
61
|
+
},
|
|
62
|
+
// Only a settled row is deleted; a `pending` row (or an already-absent id)
|
|
63
|
+
// matches no row and the delete is a silent no-op.
|
|
64
|
+
async ackControlRequest (id) {
|
|
65
|
+
await db.deleteFrom('kubun_p2p_control_requests').where('id', '=', id).where('status', '!=', 'pending').execute();
|
|
66
|
+
},
|
|
67
|
+
// --- Commit journal operations ---
|
|
68
|
+
async putCommitJournalEntry (params) {
|
|
69
|
+
const values = {
|
|
70
|
+
group_id: params.groupID,
|
|
71
|
+
publish_id: params.publishID,
|
|
72
|
+
expected_head: params.expectedHead,
|
|
73
|
+
epoch: params.epoch,
|
|
74
|
+
accepted_as: params.acceptedAs ?? null,
|
|
75
|
+
commit: adapter.encodeBinary(params.commit),
|
|
76
|
+
bodies: adapter.encodeJSON(params.bodies),
|
|
77
|
+
kind: params.kind,
|
|
78
|
+
journal: adapter.encodeBinary(params.journal)
|
|
79
|
+
};
|
|
80
|
+
await db.insertInto('kubun_p2p_commit_journal').values(values)// Single slot per group: a fresh `put` REPLACES the whole row,
|
|
81
|
+
// whatever it held (including a stale `accepted_as` from a prior
|
|
82
|
+
// commit's acceptance) — never a merge.
|
|
83
|
+
.onConflict((oc)=>oc.column('group_id').doUpdateSet((eb)=>({
|
|
84
|
+
publish_id: eb.ref('excluded.publish_id'),
|
|
85
|
+
expected_head: eb.ref('excluded.expected_head'),
|
|
86
|
+
epoch: eb.ref('excluded.epoch'),
|
|
87
|
+
accepted_as: eb.ref('excluded.accepted_as'),
|
|
88
|
+
commit: eb.ref('excluded.commit'),
|
|
89
|
+
bodies: eb.ref('excluded.bodies'),
|
|
90
|
+
kind: eb.ref('excluded.kind'),
|
|
91
|
+
journal: eb.ref('excluded.journal')
|
|
92
|
+
}))).execute();
|
|
93
|
+
},
|
|
94
|
+
async getCommitJournalEntry (groupID) {
|
|
95
|
+
return await db.selectFrom('kubun_p2p_commit_journal').selectAll().where('group_id', '=', groupID).executeTakeFirst();
|
|
96
|
+
},
|
|
97
|
+
// A no-op if the slot no longer holds `publishID` (already cleared, or
|
|
98
|
+
// replaced by a later `put`) — the `WHERE` simply matches no row.
|
|
99
|
+
async markCommitJournalAccepted (params) {
|
|
100
|
+
await db.updateTable('kubun_p2p_commit_journal').set({
|
|
101
|
+
accepted_as: params.sequenceID
|
|
102
|
+
}).where('group_id', '=', params.groupID).where('publish_id', '=', params.publishID).execute();
|
|
103
|
+
},
|
|
104
|
+
// A no-op if the slot no longer holds `publishID` — never deletes a
|
|
105
|
+
// different pending commit.
|
|
106
|
+
async clearCommitJournalEntry (params) {
|
|
107
|
+
await db.deleteFrom('kubun_p2p_commit_journal').where('group_id', '=', params.groupID).where('publish_id', '=', params.publishID).execute();
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Kysely } from 'kysely';
|
|
2
|
+
import type { InsertGroupDelegation, P2PStoreTables } from '../tables.js';
|
|
3
|
+
/** The `jti` -> group edges the p2p layer keeps for delegation tokens. */
|
|
4
|
+
export type DelegationOps = {
|
|
5
|
+
/**
|
|
6
|
+
* Record the group a delegation `jti` was granted in. Idempotent on `jti`
|
|
7
|
+
* (the primary key): a re-record for a `jti` already present is a no-op. The
|
|
8
|
+
* delegation token itself lives in the core delegation store; this edge only
|
|
9
|
+
* carries the group association the p2p layer needs.
|
|
10
|
+
*/
|
|
11
|
+
addGroupDelegation(edge: InsertGroupDelegation): Promise<void>;
|
|
12
|
+
/**
|
|
13
|
+
* Resolve the group a delegation `jti` was granted in, or `null` when no edge
|
|
14
|
+
* exists. Used to target revoke broadcasts and to enrich revocation listings
|
|
15
|
+
* with their originating group.
|
|
16
|
+
*/
|
|
17
|
+
getGroupIDByJTI(jti: string): Promise<string | null>;
|
|
18
|
+
/**
|
|
19
|
+
* Batch variant of {@link getGroupIDByJTI}: resolve the group each `jti` was
|
|
20
|
+
* granted in, returning a `Map` keyed by `jti`. A `jti` with no edge is absent
|
|
21
|
+
* from the map. Used to enrich revocation listings without an N+1 lookup.
|
|
22
|
+
*/
|
|
23
|
+
getGroupIDsByJTIs(jtis: Array<string>): Promise<Map<string, string>>;
|
|
24
|
+
};
|
|
25
|
+
export declare function createDelegationOps(db: Kysely<P2PStoreTables>): DelegationOps;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export function createDelegationOps(db) {
|
|
2
|
+
return {
|
|
3
|
+
async addGroupDelegation (edge) {
|
|
4
|
+
await db.insertInto('kubun_p2p_group_delegation').values({
|
|
5
|
+
group_id: edge.group_id,
|
|
6
|
+
jti: edge.jti
|
|
7
|
+
}).onConflict((oc)=>oc.column('jti').doNothing()).execute();
|
|
8
|
+
},
|
|
9
|
+
async getGroupIDByJTI (jti) {
|
|
10
|
+
const row = await db.selectFrom('kubun_p2p_group_delegation').select('group_id').where('jti', '=', jti).executeTakeFirst();
|
|
11
|
+
return row?.group_id ?? null;
|
|
12
|
+
},
|
|
13
|
+
async getGroupIDsByJTIs (jtis) {
|
|
14
|
+
if (jtis.length === 0) {
|
|
15
|
+
return new Map();
|
|
16
|
+
}
|
|
17
|
+
const rows = await db.selectFrom('kubun_p2p_group_delegation').select([
|
|
18
|
+
'jti',
|
|
19
|
+
'group_id'
|
|
20
|
+
]).where('jti', 'in', jtis).execute();
|
|
21
|
+
return new Map(rows.map((row)=>[
|
|
22
|
+
row.jti,
|
|
23
|
+
row.group_id
|
|
24
|
+
]));
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { Adapter } from '@kubun/db-adapter';
|
|
2
|
+
import type { Kysely } from 'kysely';
|
|
3
|
+
import type { LocalPeerProfile, P2PStoreTables, PeerAnnouncement, PeerCapabilityRecord, PendingJoinRequestRow } from '../tables.js';
|
|
4
|
+
export type PutPendingJoinRequestParams = {
|
|
5
|
+
/** The DID that emitted the request, and the only one that can answer it. */
|
|
6
|
+
did: string;
|
|
7
|
+
/** The caller-encoded key package bundle. Never inspected here. */
|
|
8
|
+
payload: string;
|
|
9
|
+
};
|
|
10
|
+
export type UpsertPeerAnnouncementParams = {
|
|
11
|
+
groupID: string;
|
|
12
|
+
/**
|
|
13
|
+
* The MLS-authenticated announcer. The lane's own attribution, never a value
|
|
14
|
+
* the announce body carried — it carries none.
|
|
15
|
+
*/
|
|
16
|
+
peerDID: string;
|
|
17
|
+
label: string;
|
|
18
|
+
availability: string;
|
|
19
|
+
capabilities: Array<PeerCapabilityRecord>;
|
|
20
|
+
/** The announcer's own stamp, kept so a later frame can be compared against it. */
|
|
21
|
+
hlc: string;
|
|
22
|
+
/** The group epoch this device held while applying the announcement. */
|
|
23
|
+
epoch: number;
|
|
24
|
+
};
|
|
25
|
+
export type SetLocalPeerProfileParams = {
|
|
26
|
+
/** This device's own DID. */
|
|
27
|
+
peerDID: string;
|
|
28
|
+
label: string;
|
|
29
|
+
availability: string;
|
|
30
|
+
capabilities: Array<PeerCapabilityRecord>;
|
|
31
|
+
};
|
|
32
|
+
/** The peer-discovery projection: what each co-member's device last advertised. */
|
|
33
|
+
export type DiscoveryOps = {
|
|
34
|
+
/**
|
|
35
|
+
* Write one (group, peer) row, REPLACING whatever it held. Dumb by design: the
|
|
36
|
+
* LWW comparison belongs to the apply path, beside the frame's authentication,
|
|
37
|
+
* exactly as the access-default rules do — a store that silently declined a
|
|
38
|
+
* write would leave the caller unable to tell "older" from "stored".
|
|
39
|
+
*/
|
|
40
|
+
upsertPeerAnnouncement(params: UpsertPeerAnnouncementParams): Promise<void>;
|
|
41
|
+
/** One peer's row in one group, or `undefined` if it has never announced. */
|
|
42
|
+
getPeerAnnouncement(params: {
|
|
43
|
+
groupID: string;
|
|
44
|
+
peerDID: string;
|
|
45
|
+
}): Promise<PeerAnnouncement | undefined>;
|
|
46
|
+
/**
|
|
47
|
+
* Every row of one group, ordered by peer DID. A caller asserting that some
|
|
48
|
+
* peer is ABSENT must read the whole set: a point lookup answers only about the
|
|
49
|
+
* DID it was handed, and the row a mis-attributed write leaves behind is under
|
|
50
|
+
* a different one.
|
|
51
|
+
*/
|
|
52
|
+
listPeerAnnouncements(groupID: string): Promise<Array<PeerAnnouncement>>;
|
|
53
|
+
/**
|
|
54
|
+
* Drop the rows of one or more peers in one group, in a single statement.
|
|
55
|
+
*
|
|
56
|
+
* A HARD delete, unlike the membership tombstone it accompanies: a membership
|
|
57
|
+
* row is kept so an apply can compare a mutation's HLC against the removal,
|
|
58
|
+
* while an advertisement carries no authority to compare against and only
|
|
59
|
+
* answers "who can I reach now". Silence is the one thing that never deletes —
|
|
60
|
+
* a quiet device goes stale in place; a removed one goes away.
|
|
61
|
+
*
|
|
62
|
+
* A no-op for an empty list.
|
|
63
|
+
*/
|
|
64
|
+
deletePeerAnnouncements(params: {
|
|
65
|
+
groupID: string;
|
|
66
|
+
peerDIDs: Array<string>;
|
|
67
|
+
}): Promise<void>;
|
|
68
|
+
/**
|
|
69
|
+
* Record what this device is, replacing whatever it said before. Device-wide:
|
|
70
|
+
* an announce trigger for a group this device has never announced in has no
|
|
71
|
+
* other source to read.
|
|
72
|
+
*/
|
|
73
|
+
setLocalPeerProfile(params: SetLocalPeerProfileParams): Promise<void>;
|
|
74
|
+
/** What this device last declared itself to be, or `undefined` if it never has. */
|
|
75
|
+
getLocalPeerProfile(peerDID: string): Promise<LocalPeerProfile | undefined>;
|
|
76
|
+
/**
|
|
77
|
+
* Hold the private half of a join request until the invite answering it
|
|
78
|
+
* arrives. Replaces any earlier request under the same DID — the older key
|
|
79
|
+
* material can no longer be answered, so keeping it would only leave a second
|
|
80
|
+
* secret at rest.
|
|
81
|
+
*/
|
|
82
|
+
putPendingJoinRequest(params: PutPendingJoinRequestParams): Promise<void>;
|
|
83
|
+
/**
|
|
84
|
+
* The held request for a DID, or `undefined` once consumed, never made, or
|
|
85
|
+
* older than the exchange window — an expired bundle would build a leaf from
|
|
86
|
+
* key material the group has had no reason to expect for a week.
|
|
87
|
+
*/
|
|
88
|
+
getPendingJoinRequest(did: string): Promise<PendingJoinRequestRow | undefined>;
|
|
89
|
+
/** Drop the held request. Called the moment it is used, not on a timer. */
|
|
90
|
+
deletePendingJoinRequest(did: string): Promise<void>;
|
|
91
|
+
};
|
|
92
|
+
export declare function createDiscoveryOps(db: Kysely<P2PStoreTables>, adapter: Adapter): DiscoveryOps;
|