@kubun/store-p2p 0.10.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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;