@kubun/store-p2p 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +150 -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 +17 -0
  16. package/lib/ops/catalog-routes.js +47 -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 +188 -5
  41. package/package.json +18 -13
@@ -0,0 +1,251 @@
1
+ import { AbstractPostgresAdapter } from '@kubun/db-adapter';
2
+ import { sql } from 'kysely';
3
+ /** Listed repair rows one peer may account for in one group; the oldest go first. */ export const MAX_REPAIRS_PER_LISTER = 10_000;
4
+ function scheduleFromRow(row, triedPeers) {
5
+ return {
6
+ documentID: row.document_id,
7
+ fetchHLC: row.fetch_hlc,
8
+ attempts: Number(row.attempts),
9
+ nextAttemptAt: Number(row.next_attempt_at),
10
+ triedPeers
11
+ };
12
+ }
13
+ /** Some adapters parse JSON text on read. */ function parseJSON(value) {
14
+ return typeof value === 'string' ? JSON.parse(value) : value;
15
+ }
16
+ export function createCatalogSyncOps(db, adapter) {
17
+ const triedPeersOf = async (documentIDs, from = db)=>{
18
+ const tried = new Map();
19
+ if (documentIDs.length === 0) return tried;
20
+ const rows = await from.selectFrom('kubun_p2p_observation_repair_peers').selectAll().where('document_id', 'in', [
21
+ ...documentIDs
22
+ ]).orderBy('peer_did').execute();
23
+ for (const row of rows){
24
+ const peers = tried.get(row.document_id) ?? [];
25
+ peers.push(row.peer_did);
26
+ tried.set(row.document_id, peers);
27
+ }
28
+ return tried;
29
+ };
30
+ const readSchedules = async (from, documentIDs)=>{
31
+ const schedules = new Map();
32
+ if (documentIDs.length === 0) return schedules;
33
+ const rows = await from.selectFrom('kubun_p2p_observation_repairs').selectAll().where('document_id', 'in', [
34
+ ...documentIDs
35
+ ]).execute();
36
+ const tried = await triedPeersOf(rows.map((row)=>row.document_id), from);
37
+ for (const row of rows){
38
+ schedules.set(row.document_id, scheduleFromRow(row, tried.get(row.document_id) ?? []));
39
+ }
40
+ return schedules;
41
+ };
42
+ const writeSchedule = async (tx, schedule)=>{
43
+ const triedPeers = [
44
+ ...new Set(schedule.triedPeers)
45
+ ].sort();
46
+ const values = {
47
+ fetch_hlc: schedule.fetchHLC,
48
+ attempts: schedule.attempts,
49
+ next_attempt_at: schedule.nextAttemptAt,
50
+ tried_count: triedPeers.length
51
+ };
52
+ await tx.insertInto('kubun_p2p_observation_repairs').values({
53
+ document_id: schedule.documentID,
54
+ ...values
55
+ }).onConflict((oc)=>oc.column('document_id').doUpdateSet(values)).execute();
56
+ await tx.deleteFrom('kubun_p2p_observation_repair_peers').where('document_id', '=', schedule.documentID).execute();
57
+ if (triedPeers.length > 0) {
58
+ await tx.insertInto('kubun_p2p_observation_repair_peers').values(triedPeers.map((peerDID)=>({
59
+ document_id: schedule.documentID,
60
+ peer_did: peerDID
61
+ }))).execute();
62
+ }
63
+ };
64
+ const inTransaction = async (fn)=>db.isTransaction ? await fn(db) : await db.transaction().execute(fn);
65
+ const ops = {
66
+ async getCatalogCursor (peerDID, groupID) {
67
+ const row = await db.selectFrom('kubun_p2p_catalog_cursors').selectAll().where('peer_did', '=', peerDID).where('group_id', '=', groupID).executeTakeFirst();
68
+ if (row == null) return null;
69
+ return {
70
+ rulesHash: row.rules_hash,
71
+ cursor: row.last_key ?? null,
72
+ token: row.last_token ?? null,
73
+ cycleClear: Number(row.cycle_clear) === 1,
74
+ bounds: parseJSON(row.bounds)
75
+ };
76
+ },
77
+ async setCatalogCursor (peerDID, groupID, state) {
78
+ const values = {
79
+ rules_hash: state.rulesHash,
80
+ last_key: state.cursor,
81
+ last_token: state.token,
82
+ cycle_clear: state.cycleClear ? 1 : 0,
83
+ bounds: JSON.stringify(state.bounds)
84
+ };
85
+ await db.insertInto('kubun_p2p_catalog_cursors').values({
86
+ peer_did: peerDID,
87
+ group_id: groupID,
88
+ ...values
89
+ }).onConflict((oc)=>oc.columns([
90
+ 'peer_did',
91
+ 'group_id'
92
+ ]).doUpdateSet(values)).execute();
93
+ },
94
+ async getDocumentRepairs (groupID, documentIDs) {
95
+ const rows = new Map();
96
+ if (documentIDs.length === 0) return rows;
97
+ const ids = [
98
+ ...documentIDs
99
+ ];
100
+ const [found, answers] = await Promise.all([
101
+ db.selectFrom('kubun_p2p_document_repairs').selectAll().where('group_id', '=', groupID).where('document_id', 'in', ids).execute(),
102
+ db.selectFrom('kubun_p2p_document_repair_answers').selectAll().where('group_id', '=', groupID).where('document_id', 'in', ids).execute()
103
+ ]);
104
+ for (const row of found){
105
+ rows.set(row.document_id, {
106
+ listedBy: row.listed_by ?? null,
107
+ holdingsDigest: row.holdings_digest,
108
+ answers: {},
109
+ createdAt: Number(row.created_at),
110
+ attemptedAt: Number(row.attempted_at)
111
+ });
112
+ }
113
+ for (const answer of answers){
114
+ const row = rows.get(answer.document_id);
115
+ if (row != null) row.answers[answer.peer_did] = answer.answer;
116
+ }
117
+ return rows;
118
+ },
119
+ async recordDocumentRepair (groupID, documentID, params) {
120
+ const existing = (await ops.getDocumentRepairs(groupID, [
121
+ documentID
122
+ ])).get(documentID);
123
+ const listedBy = existing?.listedBy ?? (params.listed ? params.peerDID : null);
124
+ if (params.listed && existing?.listedBy == null) {
125
+ // A peer that lists more than its share pushes out its own oldest rows.
126
+ const counted = await db.selectFrom('kubun_p2p_document_repairs').select((eb)=>eb.fn.countAll().as('count')).where('group_id', '=', groupID).where('listed_by', '=', params.peerDID).executeTakeFirst();
127
+ const excess = Number(counted?.count ?? 0) + 1 - MAX_REPAIRS_PER_LISTER;
128
+ if (excess > 0) {
129
+ const oldest = await db.selectFrom('kubun_p2p_document_repairs').select('document_id').where('group_id', '=', groupID).where('listed_by', '=', params.peerDID).orderBy('attempted_at').orderBy('document_id').limit(excess).execute();
130
+ await ops.resolveDocumentRepairs(groupID, oldest.map((row)=>row.document_id));
131
+ }
132
+ }
133
+ if (existing != null && existing.holdingsDigest !== params.holdingsDigest) {
134
+ await db.deleteFrom('kubun_p2p_document_repair_answers').where('group_id', '=', groupID).where('document_id', '=', documentID).execute();
135
+ }
136
+ const values = {
137
+ listed_by: listedBy,
138
+ holdings_digest: params.holdingsDigest,
139
+ attempted_at: params.attemptedAt
140
+ };
141
+ await db.insertInto('kubun_p2p_document_repairs').values({
142
+ group_id: groupID,
143
+ document_id: documentID,
144
+ created_at: existing?.createdAt ?? params.attemptedAt,
145
+ ...values
146
+ }).onConflict((oc)=>oc.columns([
147
+ 'group_id',
148
+ 'document_id'
149
+ ]).doUpdateSet({
150
+ ...values,
151
+ listed_by: sql`coalesce(kubun_p2p_document_repairs.listed_by, ${listedBy})`
152
+ })).execute();
153
+ if (params.answer == null) {
154
+ await db.deleteFrom('kubun_p2p_document_repair_answers').where('group_id', '=', groupID).where('document_id', '=', documentID).where('peer_did', '=', params.peerDID).execute();
155
+ } else {
156
+ await db.insertInto('kubun_p2p_document_repair_answers').values({
157
+ group_id: groupID,
158
+ document_id: documentID,
159
+ peer_did: params.peerDID,
160
+ answer: params.answer
161
+ }).onConflict((oc)=>oc.columns([
162
+ 'group_id',
163
+ 'document_id',
164
+ 'peer_did'
165
+ ]).doUpdateSet({
166
+ answer: params.answer
167
+ })).execute();
168
+ }
169
+ },
170
+ async resolveDocumentRepairs (groupID, documentIDs) {
171
+ if (documentIDs.length === 0) return;
172
+ for (const table of [
173
+ 'kubun_p2p_document_repair_answers',
174
+ 'kubun_p2p_document_repairs'
175
+ ]){
176
+ await db.deleteFrom(table).where('group_id', '=', groupID).where('document_id', 'in', [
177
+ ...documentIDs
178
+ ]).execute();
179
+ }
180
+ },
181
+ async listDocumentRepairs (groupID, peerDID, limit) {
182
+ const rows = await db.selectFrom('kubun_p2p_document_repairs as r').select('r.document_id').where('r.group_id', '=', groupID).where('r.listed_by', 'is not', null).where(({ not, exists, selectFrom })=>not(exists(selectFrom('kubun_p2p_document_repair_answers as a').select('a.peer_did').whereRef('a.group_id', '=', 'r.group_id').whereRef('a.document_id', '=', 'r.document_id').where('a.peer_did', '=', peerDID)))).orderBy('r.attempted_at').orderBy('r.document_id').limit(limit).execute();
183
+ return rows.map((row)=>row.document_id);
184
+ },
185
+ async pruneDocumentRepairs (groupID, createdBefore) {
186
+ await db.deleteFrom('kubun_p2p_document_repair_answers').where('group_id', '=', groupID).where('document_id', 'in', (eb)=>eb.selectFrom('kubun_p2p_document_repairs').select('document_id').where('group_id', '=', groupID).where('created_at', '<', createdBefore)).execute();
187
+ await db.deleteFrom('kubun_p2p_document_repairs').where('group_id', '=', groupID).where('created_at', '<', createdBefore).execute();
188
+ },
189
+ async getObservationRepairSchedules (documentIDs) {
190
+ return await readSchedules(db, documentIDs);
191
+ },
192
+ async listDueObservationRepairSchedules (query) {
193
+ let select = db.selectFrom('kubun_p2p_observation_repairs as r').selectAll('r').where((eb)=>eb.or([
194
+ eb('r.next_attempt_at', '<=', query.now),
195
+ eb.and([
196
+ eb('r.tried_count', '<', query.maxTriedPeers),
197
+ eb.not(eb.exists(eb.selectFrom('kubun_p2p_observation_repair_peers as p').select('p.peer_did').whereRef('p.document_id', '=', 'r.document_id').where('p.peer_did', '=', query.peerDID)))
198
+ ])
199
+ ]));
200
+ const after = query.after;
201
+ if (after !== null) {
202
+ select = select.where((eb)=>eb.or([
203
+ eb('r.next_attempt_at', '>', after.nextAttemptAt),
204
+ eb.and([
205
+ eb('r.next_attempt_at', '=', after.nextAttemptAt),
206
+ eb('r.document_id', '>', after.documentID)
207
+ ])
208
+ ]));
209
+ }
210
+ const rows = await select.orderBy('r.next_attempt_at').orderBy('r.document_id').limit(query.limit).execute();
211
+ const tried = await triedPeersOf(rows.map((row)=>row.document_id));
212
+ return rows.map((row)=>scheduleFromRow(row, tried.get(row.document_id) ?? []));
213
+ },
214
+ async setObservationRepairSchedule (schedule) {
215
+ await inTransaction(async (tx)=>await writeSchedule(tx, schedule));
216
+ },
217
+ async updateObservationRepairSchedule (documentID, update) {
218
+ return await inTransaction(async (tx)=>{
219
+ // Postgres runs sessions concurrently: serialize updates of one
220
+ // document. SQLite transactions already run one at a time.
221
+ if (adapter instanceof AbstractPostgresAdapter) {
222
+ await sql`select pg_advisory_xact_lock(hashtextextended(${`kubun_p2p_observation_repairs:${documentID}`}, 0))`.execute(tx);
223
+ }
224
+ const current = (await readSchedules(tx, [
225
+ documentID
226
+ ])).get(documentID);
227
+ const next = update(current);
228
+ if (next === null) return current;
229
+ await writeSchedule(tx, next);
230
+ return next;
231
+ });
232
+ },
233
+ async listObservationRepairScheduleIDs ({ after, limit }) {
234
+ let query = db.selectFrom('kubun_p2p_observation_repairs').select('document_id').orderBy('document_id').limit(limit);
235
+ if (after !== null) query = query.where('document_id', '>', after);
236
+ return (await query.execute()).map((row)=>row.document_id);
237
+ },
238
+ async deleteObservationRepairSchedules (documentIDs) {
239
+ if (documentIDs.length === 0) return;
240
+ await inTransaction(async (tx)=>{
241
+ await tx.deleteFrom('kubun_p2p_observation_repair_peers').where('document_id', 'in', [
242
+ ...documentIDs
243
+ ]).execute();
244
+ await tx.deleteFrom('kubun_p2p_observation_repairs').where('document_id', 'in', [
245
+ ...documentIDs
246
+ ]).execute();
247
+ });
248
+ }
249
+ };
250
+ return ops;
251
+ }
@@ -1,5 +1,5 @@
1
- import type { Adapter } from '@kubun/db-adapter';
2
- import type { Kysely } from 'kysely';
1
+ import { type Adapter } from '@kubun/db-adapter';
2
+ import { type Kysely } from 'kysely';
3
3
  import type { Circle, CircleMember, InsertCircle, InsertCircleMember, P2PStoreTables } from '../tables.js';
4
4
  /** Circles, their membership rows, and the fold-authoritative projections. */
5
5
  export type CircleOps = {
@@ -21,7 +21,7 @@ export type CircleOps = {
21
21
  * Caller is responsible for deduplicating IDs.
22
22
  */
23
23
  getCircles(ids: Array<string>): Promise<Map<string, Circle>>;
24
- updateCircle(id: string, update: Partial<Pick<InsertCircle, 'name' | 'description' | 'catalog_ids' | 'hlc'>>): Promise<void>;
24
+ updateCircle(id: string, update: Partial<Pick<InsertCircle, 'name' | 'description' | 'hlc'>>): Promise<void>;
25
25
  deleteCircle(id: string): Promise<void>;
26
26
  /**
27
27
  * Tombstone an existing circle row at `hlc` using earliest-wins LWW: the
@@ -130,7 +130,6 @@ export type CircleOps = {
130
130
  groupID: string;
131
131
  name: string;
132
132
  description: string;
133
- catalogIDs: Array<string>;
134
133
  hlc: string;
135
134
  removedAtHLC: string | null;
136
135
  open: boolean;
@@ -1,4 +1,15 @@
1
1
  import { normalizeDID } from '@kokuin/token';
2
+ import { chunked } from '@kubun/db-adapter';
3
+ import { sql } from 'kysely';
4
+ /**
5
+ * True when an upsert's incoming value differs from the stored one, NULL-safe on
6
+ * both adapters. An upsert guarded by it leaves a row that already holds the
7
+ * projected values untouched, `updated_at` included.
8
+ */ function differs(table, column) {
9
+ const stored = sql.ref(`${table}.${column}`);
10
+ const incoming = sql.ref(`excluded.${column}`);
11
+ return sql`(${stored} <> ${incoming} OR (${stored} IS NULL) <> (${incoming} IS NULL))`;
12
+ }
2
13
  /**
3
14
  * A member may access an active circle by either arm: an active explicit
4
15
  * membership row, OR the circle being open (`open = 1`) with the member holding
@@ -27,7 +38,6 @@ export function createCircleOps(db, adapter) {
27
38
  group_id: circle.group_id,
28
39
  name: circle.name,
29
40
  description: circle.description,
30
- catalog_ids: adapter.encodeJSON(circle.catalog_ids),
31
41
  hlc: circle.hlc
32
42
  }).execute();
33
43
  },
@@ -40,10 +50,11 @@ export function createCircleOps(db, adapter) {
40
50
  },
41
51
  async getCircles (ids) {
42
52
  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);
53
+ for (const chunk of chunked(ids)){
54
+ const rows = await db.selectFrom('kubun_p2p_circles').selectAll().where('id', 'in', chunk).execute();
55
+ for (const row of rows){
56
+ result.set(row.id, row);
57
+ }
47
58
  }
48
59
  return result;
49
60
  },
@@ -51,9 +62,6 @@ export function createCircleOps(db, adapter) {
51
62
  const values = {};
52
63
  if (update.name != null) values.name = update.name;
53
64
  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
65
  if (update.hlc != null) {
58
66
  values.hlc = update.hlc;
59
67
  // A create/update that wins the apply-layer HLC check resurrects a
@@ -211,12 +219,12 @@ export function createCircleOps(db, adapter) {
211
219
  await db.updateTable('kubun_p2p_circles').set({
212
220
  open: 1,
213
221
  updated_at: now
214
- }).where('group_id', '=', groupID).where('id', 'in', openCircleIDs).execute();
222
+ }).where('group_id', '=', groupID).where('id', 'in', openCircleIDs).where('open', '<>', 1).execute();
215
223
  }
216
224
  let closeQuery = db.updateTable('kubun_p2p_circles').set({
217
225
  open: 0,
218
226
  updated_at: now
219
- }).where('group_id', '=', groupID);
227
+ }).where('group_id', '=', groupID).where('open', '<>', 0);
220
228
  if (openCircleIDs.length > 0) {
221
229
  closeQuery = closeQuery.where('id', 'not in', openCircleIDs);
222
230
  }
@@ -233,7 +241,6 @@ export function createCircleOps(db, adapter) {
233
241
  group_id: params.groupID,
234
242
  name: params.name,
235
243
  description: params.description,
236
- catalog_ids: adapter.encodeJSON(params.catalogIDs),
237
244
  hlc: params.hlc,
238
245
  removed_at_hlc: params.removedAtHLC,
239
246
  open: params.open ? 1 : 0
@@ -241,12 +248,18 @@ export function createCircleOps(db, adapter) {
241
248
  group_id: eb.ref('excluded.group_id'),
242
249
  name: eb.ref('excluded.name'),
243
250
  description: eb.ref('excluded.description'),
244
- catalog_ids: eb.ref('excluded.catalog_ids'),
245
251
  hlc: eb.ref('excluded.hlc'),
246
252
  removed_at_hlc: eb.ref('excluded.removed_at_hlc'),
247
253
  open: eb.ref('excluded.open'),
248
254
  updated_at: adapter.encodeTimestamp(new Date())
249
- }))).execute();
255
+ })).where((eb)=>eb.or([
256
+ 'group_id',
257
+ 'name',
258
+ 'description',
259
+ 'hlc',
260
+ 'removed_at_hlc',
261
+ 'open'
262
+ ].map((column)=>differs('kubun_p2p_circles', column))))).execute();
250
263
  },
251
264
  async reconcileCircleMemberProjection (params) {
252
265
  await db.insertInto('kubun_p2p_circle_members').values({
@@ -263,7 +276,11 @@ export function createCircleOps(db, adapter) {
263
276
  hlc: eb.ref('excluded.hlc'),
264
277
  removed_at_hlc: eb.ref('excluded.removed_at_hlc'),
265
278
  updated_at: adapter.encodeTimestamp(new Date())
266
- }))).execute();
279
+ })).where((eb)=>eb.or([
280
+ 'role',
281
+ 'hlc',
282
+ 'removed_at_hlc'
283
+ ].map((column)=>differs('kubun_p2p_circle_members', column))))).execute();
267
284
  }
268
285
  };
269
286
  }
@@ -63,6 +63,11 @@ export type ControlOps = {
63
63
  */
64
64
  result?: string;
65
65
  settledAt: string;
66
+ /**
67
+ * The handle epoch of the advance that lands the request, for a `committed`
68
+ * settle written with that advance.
69
+ */
70
+ epoch?: number;
66
71
  }): Promise<void>;
67
72
  /**
68
73
  * The settled request's artifact, or `null` when it produced none or the row
@@ -93,6 +98,7 @@ export type ControlOps = {
93
98
  bodies: Array<string>;
94
99
  kind: CommitJournalEntryKind;
95
100
  journal: Uint8Array;
101
+ holdsLogSends?: boolean;
96
102
  }): Promise<void>;
97
103
  /** The group's slot, or `undefined` if empty. */
98
104
  getCommitJournalEntry(groupID: string): Promise<CommitJournalEntry | undefined>;
@@ -38,6 +38,9 @@ export function createControlOps(db, adapter) {
38
38
  status: params.outcome,
39
39
  settled_at: params.settledAt
40
40
  };
41
+ if (params.epoch !== undefined) {
42
+ update.settled_epoch = params.epoch;
43
+ }
41
44
  if (params.error !== undefined) {
42
45
  update.error = params.error;
43
46
  }
@@ -75,7 +78,8 @@ export function createControlOps(db, adapter) {
75
78
  commit: adapter.encodeBinary(params.commit),
76
79
  bodies: adapter.encodeJSON(params.bodies),
77
80
  kind: params.kind,
78
- journal: adapter.encodeBinary(params.journal)
81
+ journal: adapter.encodeBinary(params.journal),
82
+ holds_log_sends: params.holdsLogSends === true ? 1 : 0
79
83
  };
80
84
  await db.insertInto('kubun_p2p_commit_journal').values(values)// Single slot per group: a fresh `put` REPLACES the whole row,
81
85
  // whatever it held (including a stale `accepted_as` from a prior
@@ -88,11 +92,16 @@ export function createControlOps(db, adapter) {
88
92
  commit: eb.ref('excluded.commit'),
89
93
  bodies: eb.ref('excluded.bodies'),
90
94
  kind: eb.ref('excluded.kind'),
91
- journal: eb.ref('excluded.journal')
95
+ journal: eb.ref('excluded.journal'),
96
+ holds_log_sends: eb.ref('excluded.holds_log_sends')
92
97
  }))).execute();
93
98
  },
94
99
  async getCommitJournalEntry (groupID) {
95
- return await db.selectFrom('kubun_p2p_commit_journal').selectAll().where('group_id', '=', groupID).executeTakeFirst();
100
+ const row = await db.selectFrom('kubun_p2p_commit_journal').selectAll().where('group_id', '=', groupID).executeTakeFirst();
101
+ return row == null ? undefined : {
102
+ ...row,
103
+ holds_log_sends: row.holds_log_sends === 1
104
+ };
96
105
  },
97
106
  // A no-op if the slot no longer holds `publishID` (already cleared, or
98
107
  // replaced by a later `put`) — the `WHERE` simply matches no row.
@@ -9,6 +9,37 @@ export type DelegationOps = {
9
9
  * carries the group association the p2p layer needs.
10
10
  */
11
11
  addGroupDelegation(edge: InsertGroupDelegation): Promise<void>;
12
+ /** Hold the outbox erasure lock and read membership; replacement must call inside its transaction. */
13
+ lockJoinedGroupForDelegation(groupID: string): Promise<boolean>;
14
+ /** Mark a locally stored token for outbox admission while its group is joined. */
15
+ markDelegationPending(params: {
16
+ groupID: string;
17
+ jti: string;
18
+ kind: 'grant' | 'revocation';
19
+ hlc: string;
20
+ token: string;
21
+ }): Promise<void>;
22
+ /** Whether this exact locally stored token still awaits admission in a joined group. */
23
+ isDelegationPending(params: {
24
+ groupID: string;
25
+ jti: string;
26
+ kind: 'grant' | 'revocation';
27
+ hlc: string;
28
+ token: string;
29
+ }): Promise<boolean>;
30
+ /** Joined-group pending admissions, oldest first with stable JTI ordering. */
31
+ listPendingDelegations(groupID: string, kind: 'grant' | 'revocation'): Promise<Array<{
32
+ jti: string;
33
+ hlc: string;
34
+ }>>;
35
+ /** Clear one observed marker without removing a newer pending version. */
36
+ clearPendingDelegation(params: {
37
+ groupID: string;
38
+ jti: string;
39
+ kind: 'grant' | 'revocation';
40
+ hlc: string;
41
+ }): Promise<void>;
42
+ clearPendingDelegations(groupID: string): Promise<void>;
12
43
  /**
13
44
  * Resolve the group a delegation `jti` was granted in, or `null` when no edge
14
45
  * exists. Used to target revoke broadcasts and to enrich revocation listings
@@ -1,11 +1,82 @@
1
+ import { encodeMultibase, multihashSHA256 } from '@kokuin/token';
1
2
  export function createDelegationOps(db) {
3
+ const lockJoinedGroup = async (tx, groupID)=>{
4
+ await tx.insertInto('kubun_p2p_app_outbox_state').values({
5
+ group_id: groupID
6
+ }).onConflict((oc)=>oc.column('group_id').doUpdateSet({
7
+ group_id: groupID
8
+ })).execute();
9
+ const group = await tx.selectFrom('kubun_p2p_groups').select('status').where('id', '=', groupID).executeTakeFirst();
10
+ return group?.status === 'joined';
11
+ };
2
12
  return {
13
+ async lockJoinedGroupForDelegation (groupID) {
14
+ if (db.isTransaction) return await lockJoinedGroup(db, groupID);
15
+ return await db.transaction().execute((tx)=>lockJoinedGroup(tx, groupID));
16
+ },
3
17
  async addGroupDelegation (edge) {
4
18
  await db.insertInto('kubun_p2p_group_delegation').values({
5
19
  group_id: edge.group_id,
6
20
  jti: edge.jti
7
21
  }).onConflict((oc)=>oc.column('jti').doNothing()).execute();
8
22
  },
23
+ async markDelegationPending (params) {
24
+ const mark = async (tx)=>{
25
+ // Read membership under the same lock as replacement and departure.
26
+ if (!await lockJoinedGroup(tx, params.groupID)) return;
27
+ const digest = encodeMultibase(multihashSHA256(new TextEncoder().encode(params.token)));
28
+ await tx.updateTable('kubun_p2p_group_delegation').set(params.kind === 'grant' ? {
29
+ pending_grant_hlc: params.hlc,
30
+ pending_grant_digest: digest
31
+ } : {
32
+ pending_revocation_hlc: params.hlc,
33
+ pending_revocation_digest: digest
34
+ }).where('group_id', '=', params.groupID).where('jti', '=', params.jti).execute();
35
+ };
36
+ if (db.isTransaction) await mark(db);
37
+ else await db.transaction().execute(mark);
38
+ },
39
+ async isDelegationPending (params) {
40
+ const hlcColumn = params.kind === 'grant' ? 'pending_grant_hlc' : 'pending_revocation_hlc';
41
+ const digestColumn = params.kind === 'grant' ? 'pending_grant_digest' : 'pending_revocation_digest';
42
+ const digest = encodeMultibase(multihashSHA256(new TextEncoder().encode(params.token)));
43
+ const row = await db.selectFrom('kubun_p2p_group_delegation as delegation').innerJoin('kubun_p2p_groups as group', 'group.id', 'delegation.group_id').select('delegation.jti').where('delegation.group_id', '=', params.groupID).where('delegation.jti', '=', params.jti).where('group.status', '=', 'joined').where(`delegation.${hlcColumn}`, '=', params.hlc).where(`delegation.${digestColumn}`, '=', digest).executeTakeFirst();
44
+ return row != null;
45
+ },
46
+ async listPendingDelegations (groupID, kind) {
47
+ const column = kind === 'grant' ? 'pending_grant_hlc' : 'pending_revocation_hlc';
48
+ return await db.selectFrom('kubun_p2p_group_delegation as delegation').innerJoin('kubun_p2p_groups as group', 'group.id', 'delegation.group_id').select([
49
+ 'delegation.jti',
50
+ `delegation.${column} as hlc`
51
+ ]).where('delegation.group_id', '=', groupID).where('group.status', '=', 'joined').where(`delegation.${column}`, 'is not', null).orderBy(`delegation.${column}`).orderBy('delegation.jti').$narrowType().execute();
52
+ },
53
+ async clearPendingDelegation (params) {
54
+ const clear = async (tx)=>{
55
+ await tx.insertInto('kubun_p2p_app_outbox_state').values({
56
+ group_id: params.groupID
57
+ }).onConflict((oc)=>oc.column('group_id').doUpdateSet({
58
+ group_id: params.groupID
59
+ })).execute();
60
+ const hlcColumn = params.kind === 'grant' ? 'pending_grant_hlc' : 'pending_revocation_hlc';
61
+ await tx.updateTable('kubun_p2p_group_delegation').set(params.kind === 'grant' ? {
62
+ pending_grant_hlc: null,
63
+ pending_grant_digest: null
64
+ } : {
65
+ pending_revocation_hlc: null,
66
+ pending_revocation_digest: null
67
+ }).where('group_id', '=', params.groupID).where('jti', '=', params.jti).where(hlcColumn, '=', params.hlc).execute();
68
+ };
69
+ if (db.isTransaction) await clear(db);
70
+ else await db.transaction().execute(clear);
71
+ },
72
+ async clearPendingDelegations (groupID) {
73
+ await db.updateTable('kubun_p2p_group_delegation').set({
74
+ pending_grant_hlc: null,
75
+ pending_grant_digest: null,
76
+ pending_revocation_hlc: null,
77
+ pending_revocation_digest: null
78
+ }).where('group_id', '=', groupID).execute();
79
+ },
9
80
  async getGroupIDByJTI (jti) {
10
81
  const row = await db.selectFrom('kubun_p2p_group_delegation').select('group_id').where('jti', '=', jti).executeTakeFirst();
11
82
  return row?.group_id ?? null;
@@ -0,0 +1,11 @@
1
+ import type { Kysely } from 'kysely';
2
+ import type { P2PStoreTables } from '../tables.js';
3
+ /** A swept document's per-document rows. */
4
+ export type DocumentSweepOps = {
5
+ /**
6
+ * Delete the document's repair rows in every group, their answers, its observation repair
7
+ * schedule and tried peers, and its broadcast dedup log. Call inside the sweeping transaction.
8
+ */
9
+ sweepDocumentRows(documentID: string): Promise<void>;
10
+ };
11
+ export declare function createDocumentSweepOps(db: Kysely<P2PStoreTables>): DocumentSweepOps;
@@ -0,0 +1,16 @@
1
+ export function createDocumentSweepOps(db) {
2
+ return {
3
+ async sweepDocumentRows (documentID) {
4
+ for (const table of [
5
+ 'kubun_p2p_document_repair_answers',
6
+ 'kubun_p2p_document_repairs',
7
+ 'kubun_p2p_observation_repair_peers',
8
+ 'kubun_p2p_observation_repairs'
9
+ ]){
10
+ await db.deleteFrom(table).where('document_id', '=', documentID).execute();
11
+ }
12
+ // Dedups sends only: a version sent again after a restore is a no-op where it was applied.
13
+ await db.deleteFrom('kubun_p2p_broadcast_log').where('doc_id', '=', documentID).execute();
14
+ }
15
+ };
16
+ }