@kubun/store-p2p 0.17.0 → 0.18.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.
Files changed (41) hide show
  1. package/lib/api.d.ts +24 -6
  2. package/lib/api.js +17 -2
  3. package/lib/definition.js +1 -1
  4. package/lib/hub-url.d.ts +1 -0
  5. package/lib/hub-url.js +3 -0
  6. package/lib/index.d.ts +8 -2
  7. package/lib/index.js +6 -0
  8. package/lib/migrations.js +153 -9
  9. package/lib/ops/app-lane.d.ts +32 -2
  10. package/lib/ops/app-lane.js +55 -2
  11. package/lib/ops/app-outbox.d.ts +44 -0
  12. package/lib/ops/app-outbox.js +236 -0
  13. package/lib/ops/catalog-offers.d.ts +18 -0
  14. package/lib/ops/catalog-offers.js +59 -0
  15. package/lib/ops/catalog-routes.d.ts +25 -0
  16. package/lib/ops/catalog-routes.js +60 -0
  17. package/lib/ops/catalog-sync.d.ts +122 -0
  18. package/lib/ops/catalog-sync.js +251 -0
  19. package/lib/ops/circles.d.ts +3 -4
  20. package/lib/ops/circles.js +31 -14
  21. package/lib/ops/control.d.ts +6 -0
  22. package/lib/ops/control.js +12 -3
  23. package/lib/ops/delegations.d.ts +31 -0
  24. package/lib/ops/delegations.js +71 -0
  25. package/lib/ops/document-sweep.d.ts +11 -0
  26. package/lib/ops/document-sweep.js +16 -0
  27. package/lib/ops/groups.d.ts +70 -22
  28. package/lib/ops/groups.js +130 -35
  29. package/lib/ops/hubs.js +8 -4
  30. package/lib/ops/log-handler-completion.d.ts +20 -0
  31. package/lib/ops/log-handler-completion.js +41 -0
  32. package/lib/ops/mls.d.ts +39 -6
  33. package/lib/ops/mls.js +115 -21
  34. package/lib/ops/own-agents.d.ts +68 -0
  35. package/lib/ops/own-agents.js +359 -0
  36. package/lib/ops/sync.d.ts +16 -0
  37. package/lib/ops/sync.js +25 -0
  38. package/lib/owner-classification.d.ts +14 -0
  39. package/lib/owner-classification.js +18 -0
  40. package/lib/tables.d.ts +193 -5
  41. package/package.json +16 -11
@@ -1,12 +1,26 @@
1
- import type { Adapter } from '@kubun/db-adapter';
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. Leaving keeps every row hanging off
21
- * the group, so being invited back rejoins the SAME group rather than forking
22
- * a second row for it.
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
- setGroupStatus(id: string, status: GroupStatus): Promise<void>;
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
- addGroupMember(member: InsertGroupMember): Promise<void>;
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<void>;
55
+ addGroupMembers(members: Array<InsertGroupMember>): Promise<Set<string>>;
34
56
  /**
35
- * Soft-delete a member by tombstoning the row with `hlc`. Delegates to
36
- * `markMemberRemoved`: earliest-wins LWW, ignore-if-absent. The row stays in
37
- * the table (read queries exclude it) so the apply-time check can compare a
38
- * mutation's HLC against the removal HLC.
57
+ * Tombstone the current incarnation and report whether its row matched.
39
58
  */
40
- removeGroupMember(groupID: string, memberDID: string, hlc: string): Promise<void>;
59
+ removeGroupMember(groupID: string, memberDID: string, hlc: string): Promise<boolean>;
41
60
  /**
42
- * Tombstone an existing membership row at `hlc` using earliest-wins LWW: the
43
- * removal sticks only when no removal is recorded yet, or when `hlc` precedes
44
- * the stored removal HLC. A removal for a `(groupID, memberDID)` with no local
45
- * row is a no-op — absence means there is no member to tombstone, and no row
46
- * is created.
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<void>;
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 earliest-wins LWW and ignore-if-absent semantics as
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<void>;
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
- * the only query that sees soft-deleted members. Returns `undefined` when no
60
- * row exists; `{ removed_at_hlc: null }` for an active member; and
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
- await db.updateTable('kubun_p2p_groups').set(values).where('id', '=', id).execute();
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
- await db.deleteFrom('kubun_p2p_groups').where('id', '=', id).execute();
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
- await db.updateTable('kubun_p2p_groups').set({
35
- status
36
- }).where('id', '=', id).execute();
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
- // Re-adding a previously-removed member clears the tombstone and refreshes
62
- // the row's HLC, so a member that left and rejoined is active again.
63
- await db.insertInto('kubun_p2p_group_members').values({
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
- }))).execute();
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
- }))).execute();
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((eb)=>eb.or([
109
- eb('removed_at_hlc', 'is', null),
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((eb)=>eb.or([
124
- eb('removed_at_hlc', 'is', null),
125
- eb('removed_at_hlc', '>', hlc)
126
- ])).execute();
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
- saveMLSState(state: InsertGroupMLSState): Promise<void>;
7
- getMLSState(groupID: string, deviceID: string): Promise<GroupMLSState | undefined>;
8
- deleteMLSState(groupID: string, deviceID: string): Promise<void>;
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
- * **No production caller — a test observer.** It once answered "have I joined
15
- * this group", which it cannot: a removed device still holds state. Kept for
16
- * asserting that a departure left nothing behind.
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>;