@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.
- package/lib/api.d.ts +24 -6
- package/lib/api.js +17 -2
- package/lib/definition.js +1 -1
- package/lib/hub-url.d.ts +1 -0
- package/lib/hub-url.js +3 -0
- package/lib/index.d.ts +8 -2
- package/lib/index.js +6 -0
- package/lib/migrations.js +150 -9
- package/lib/ops/app-lane.d.ts +32 -2
- package/lib/ops/app-lane.js +55 -2
- package/lib/ops/app-outbox.d.ts +44 -0
- package/lib/ops/app-outbox.js +236 -0
- package/lib/ops/catalog-offers.d.ts +18 -0
- package/lib/ops/catalog-offers.js +59 -0
- package/lib/ops/catalog-routes.d.ts +17 -0
- package/lib/ops/catalog-routes.js +47 -0
- package/lib/ops/catalog-sync.d.ts +122 -0
- package/lib/ops/catalog-sync.js +251 -0
- package/lib/ops/circles.d.ts +3 -4
- package/lib/ops/circles.js +31 -14
- package/lib/ops/control.d.ts +6 -0
- package/lib/ops/control.js +12 -3
- package/lib/ops/delegations.d.ts +31 -0
- package/lib/ops/delegations.js +71 -0
- package/lib/ops/document-sweep.d.ts +11 -0
- package/lib/ops/document-sweep.js +16 -0
- package/lib/ops/groups.d.ts +70 -22
- package/lib/ops/groups.js +130 -35
- package/lib/ops/hubs.js +8 -4
- package/lib/ops/log-handler-completion.d.ts +20 -0
- package/lib/ops/log-handler-completion.js +41 -0
- package/lib/ops/mls.d.ts +39 -6
- package/lib/ops/mls.js +115 -21
- package/lib/ops/own-agents.d.ts +68 -0
- package/lib/ops/own-agents.js +359 -0
- package/lib/ops/sync.d.ts +16 -0
- package/lib/ops/sync.js +25 -0
- package/lib/owner-classification.d.ts +14 -0
- package/lib/owner-classification.js +18 -0
- package/lib/tables.d.ts +188 -5
- package/package.json +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
|
+
}
|
package/lib/ops/circles.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import type
|
|
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' | '
|
|
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;
|
package/lib/ops/circles.js
CHANGED
|
@@ -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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
}))).
|
|
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
|
-
}))).
|
|
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
|
}
|
package/lib/ops/control.d.ts
CHANGED
|
@@ -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>;
|
package/lib/ops/control.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/lib/ops/delegations.d.ts
CHANGED
|
@@ -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
|
package/lib/ops/delegations.js
CHANGED
|
@@ -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
|
+
}
|