@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.
- 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 +153 -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 +25 -0
- package/lib/ops/catalog-routes.js +60 -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 +193 -5
- package/package.json +16 -11
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { encodeMultibase, multihashSHA256 } from '@kokuin/token';
|
|
2
|
+
import { AppEntryTooLargeError, AppOutboxFullError, MAX_APP_ENTRY_BYTES, SendNotAdmissibleError } from '@kumiai/rpc';
|
|
3
|
+
import { gcm } from '@noble/ciphers/aes.js';
|
|
4
|
+
const KEY_LENGTH = 32;
|
|
5
|
+
const IV_LENGTH = 12;
|
|
6
|
+
export class AppOutboxDecryptionError extends Error {
|
|
7
|
+
name = 'AppOutboxDecryptionError';
|
|
8
|
+
}
|
|
9
|
+
export class AppOutboxTransactionError extends Error {
|
|
10
|
+
name = 'AppOutboxTransactionError';
|
|
11
|
+
}
|
|
12
|
+
export class PendingRevocationAdmissionError extends Error {
|
|
13
|
+
name = 'PendingRevocationAdmissionError';
|
|
14
|
+
}
|
|
15
|
+
/** A voluntary leave would erase revocations the hub has not accepted yet. */ export class UndeliveredRevocationsError extends Error {
|
|
16
|
+
name = 'UndeliveredRevocationsError';
|
|
17
|
+
}
|
|
18
|
+
function isRecord(value) {
|
|
19
|
+
return value != null && typeof value === 'object' && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
function pendingDelegationAdmission(entry) {
|
|
22
|
+
if (entry.protocol !== 'control' || entry.prc !== 'control/delegationShare' && entry.prc !== 'control/delegationRevoke') {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const envelope = JSON.parse(new TextDecoder().decode(entry.data));
|
|
27
|
+
if (!isRecord(envelope) || envelope.v !== 2 || !isRecord(envelope.payload)) return null;
|
|
28
|
+
const payload = envelope.payload;
|
|
29
|
+
if (payload.typ !== 'event' || payload.prc !== entry.prc || !isRecord(payload.data)) return null;
|
|
30
|
+
const data = payload.data;
|
|
31
|
+
if (typeof data.token !== 'string' || data.token.length === 0 || typeof data.hlc !== 'string' || data.hlc.length === 0) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
kind: entry.prc === 'control/delegationShare' ? 'grant' : 'revocation',
|
|
36
|
+
hlc: data.hlc,
|
|
37
|
+
digest: encodeMultibase(multihashSHA256(new TextEncoder().encode(data.token)))
|
|
38
|
+
};
|
|
39
|
+
} catch {
|
|
40
|
+
// Generic outbox payloads remain valid even when they are not delegation envelopes.
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function associatedData(groupID, entry) {
|
|
45
|
+
// Fixed field order survives JSONB's reordering of lastAttempt keys.
|
|
46
|
+
return new TextEncoder().encode(JSON.stringify([
|
|
47
|
+
'kubun/app-outbox/v1',
|
|
48
|
+
groupID,
|
|
49
|
+
entry.seq,
|
|
50
|
+
entry.protocol,
|
|
51
|
+
entry.prc,
|
|
52
|
+
entry.lastAttempt?.epoch ?? null,
|
|
53
|
+
entry.lastAttempt?.floor ?? null,
|
|
54
|
+
entry.lastAttempt?.attempts ?? null
|
|
55
|
+
]));
|
|
56
|
+
}
|
|
57
|
+
async function hasUndeliveredRevocations(db, groupID) {
|
|
58
|
+
const marker = await db.selectFrom('kubun_p2p_group_delegation').select('jti').where('group_id', '=', groupID).where('pending_revocation_hlc', 'is not', null).limit(1).executeTakeFirst();
|
|
59
|
+
if (marker != null) return true;
|
|
60
|
+
const queued = await db.selectFrom('kubun_p2p_app_outbox').select('seq').where('group_id', '=', groupID).where('protocol', '=', 'control').where('prc', '=', 'control/delegationRevoke').limit(1).executeTakeFirst();
|
|
61
|
+
return queued != null;
|
|
62
|
+
}
|
|
63
|
+
/** Coordinate status transitions with revocation admission and outbox erasure. */ export async function lockAppOutboxForErasure(db, groupID, options) {
|
|
64
|
+
// Lock first-write creation too, even when the group has never held a key.
|
|
65
|
+
await db.insertInto('kubun_p2p_app_outbox_state').values({
|
|
66
|
+
group_id: groupID
|
|
67
|
+
}).onConflict((oc)=>oc.column('group_id').doUpdateSet({
|
|
68
|
+
group_id: groupID
|
|
69
|
+
})).execute();
|
|
70
|
+
// Under the erasure lock: a revocation committed after the leaver's delivery
|
|
71
|
+
// check is either seen here or blocks until this transaction ends.
|
|
72
|
+
if (options?.requireRevocationsDelivered === true && await hasUndeliveredRevocations(db, groupID)) {
|
|
73
|
+
throw new UndeliveredRevocationsError(`Group ${groupID} has capability revocations the hub has not accepted yet`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** The caller's transaction also covers group leave or deletion. */ export async function clearAppOutboxRows(db, groupID, options) {
|
|
77
|
+
await lockAppOutboxForErasure(db, groupID, options);
|
|
78
|
+
await db.updateTable('kubun_p2p_group_delegation').set({
|
|
79
|
+
pending_grant_hlc: null,
|
|
80
|
+
pending_grant_digest: null,
|
|
81
|
+
pending_revocation_hlc: null,
|
|
82
|
+
pending_revocation_digest: null
|
|
83
|
+
}).where('group_id', '=', groupID).execute();
|
|
84
|
+
await db.deleteFrom('kubun_p2p_app_outbox_state').where('group_id', '=', groupID).execute();
|
|
85
|
+
const deleted = await db.deleteFrom('kubun_p2p_app_outbox').where('group_id', '=', groupID).returning('seq').execute();
|
|
86
|
+
return deleted.map((entry)=>entry.seq).sort((a, b)=>a - b);
|
|
87
|
+
}
|
|
88
|
+
export function createAppOutboxOps(db, adapter, getRandomValues) {
|
|
89
|
+
const clearAppOutbox = async (groupID)=>{
|
|
90
|
+
const clear = async (tx)=>{
|
|
91
|
+
await clearAppOutboxRows(tx, groupID);
|
|
92
|
+
};
|
|
93
|
+
if (db.isTransaction) await clear(db);
|
|
94
|
+
else await db.transaction().execute(clear);
|
|
95
|
+
};
|
|
96
|
+
return {
|
|
97
|
+
clearAppOutbox,
|
|
98
|
+
hasUndeliveredRevocations: (groupID)=>hasUndeliveredRevocations(db, groupID),
|
|
99
|
+
appOutboxFor (groupID, options) {
|
|
100
|
+
if (db.isTransaction) {
|
|
101
|
+
throw new AppOutboxTransactionError('App outbox ports require a top-level store');
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
async put (entry) {
|
|
105
|
+
if (entry.data.byteLength > MAX_APP_ENTRY_BYTES) {
|
|
106
|
+
throw new AppEntryTooLargeError('App entry exceeds the plaintext bound');
|
|
107
|
+
}
|
|
108
|
+
const admission = pendingDelegationAdmission(entry);
|
|
109
|
+
const put = async (tx)=>{
|
|
110
|
+
// The upsert locks the key row against concurrent crypto-erasure.
|
|
111
|
+
const state = await tx.insertInto('kubun_p2p_app_outbox_state').values({
|
|
112
|
+
group_id: groupID
|
|
113
|
+
}).onConflict((oc)=>oc.column('group_id').doUpdateSet({
|
|
114
|
+
group_id: groupID
|
|
115
|
+
})).returning('key').executeTakeFirstOrThrow();
|
|
116
|
+
// Check after acquiring the erasure lock: a put resumed after removal
|
|
117
|
+
// must not recreate the destroyed key or an encrypted outbox row.
|
|
118
|
+
const group = await tx.selectFrom('kubun_p2p_groups').select('status').where('id', '=', groupID).executeTakeFirst();
|
|
119
|
+
if (group?.status === 'left' || admission != null && group?.status !== 'joined') {
|
|
120
|
+
throw new SendNotAdmissibleError('lapsed');
|
|
121
|
+
}
|
|
122
|
+
if (admission?.kind !== 'revocation') {
|
|
123
|
+
const accepted = await tx.selectFrom('kubun_p2p_app_outbox').select('seq').where('group_id', '=', groupID).where('seq', '=', entry.seq).executeTakeFirst();
|
|
124
|
+
// Accepted entries must stay writable so draining can release capacity.
|
|
125
|
+
if (accepted == null) {
|
|
126
|
+
const pendingRevocation = await tx.selectFrom('kubun_p2p_group_delegation').select('jti').where('group_id', '=', groupID).where('pending_revocation_hlc', 'is not', null).limit(1).executeTakeFirst();
|
|
127
|
+
if (pendingRevocation != null && admission?.kind === 'grant') {
|
|
128
|
+
throw new PendingRevocationAdmissionError('Pending revocations must be admitted before new grants');
|
|
129
|
+
}
|
|
130
|
+
if (pendingRevocation != null && options?.limit != null) {
|
|
131
|
+
// Other retained traffic leaves the last slot to revocations, so a
|
|
132
|
+
// revocation that cannot be admitted never holds presence back.
|
|
133
|
+
const queued = await tx.selectFrom('kubun_p2p_app_outbox').select((eb)=>eb.fn.countAll().as('count')).where('group_id', '=', groupID).executeTakeFirstOrThrow();
|
|
134
|
+
if (Number(queued.count) >= options.limit - 1) {
|
|
135
|
+
throw new AppOutboxFullError('App outbox slot reserved for revocations');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
let key = state.key;
|
|
141
|
+
if (key == null) {
|
|
142
|
+
key = getRandomValues(new Uint8Array(KEY_LENGTH));
|
|
143
|
+
await tx.updateTable('kubun_p2p_app_outbox_state').set({
|
|
144
|
+
key: adapter.encodeBinary(key)
|
|
145
|
+
}).where('group_id', '=', groupID).execute();
|
|
146
|
+
}
|
|
147
|
+
const iv = getRandomValues(new Uint8Array(IV_LENGTH));
|
|
148
|
+
const data = gcm(key, iv, associatedData(groupID, entry)).encrypt(entry.data);
|
|
149
|
+
await tx.insertInto('kubun_p2p_app_outbox').values({
|
|
150
|
+
group_id: groupID,
|
|
151
|
+
seq: entry.seq,
|
|
152
|
+
protocol: entry.protocol,
|
|
153
|
+
prc: entry.prc,
|
|
154
|
+
data: adapter.encodeBinary(data),
|
|
155
|
+
iv: adapter.encodeBinary(iv),
|
|
156
|
+
last_attempt: entry.lastAttempt == null ? null : adapter.encodeJSON(entry.lastAttempt)
|
|
157
|
+
}).onConflict((oc)=>oc.columns([
|
|
158
|
+
'group_id',
|
|
159
|
+
'seq'
|
|
160
|
+
]).doUpdateSet((eb)=>({
|
|
161
|
+
protocol: eb.ref('excluded.protocol'),
|
|
162
|
+
prc: eb.ref('excluded.prc'),
|
|
163
|
+
data: eb.ref('excluded.data'),
|
|
164
|
+
iv: eb.ref('excluded.iv'),
|
|
165
|
+
last_attempt: eb.ref('excluded.last_attempt')
|
|
166
|
+
}))).execute();
|
|
167
|
+
if (admission != null) {
|
|
168
|
+
const hlcColumn = admission.kind === 'grant' ? 'pending_grant_hlc' : 'pending_revocation_hlc';
|
|
169
|
+
const digestColumn = admission.kind === 'grant' ? 'pending_grant_digest' : 'pending_revocation_digest';
|
|
170
|
+
// Admission and marker clear commit together, matching the exact stored token.
|
|
171
|
+
await tx.updateTable('kubun_p2p_group_delegation').set(admission.kind === 'grant' ? {
|
|
172
|
+
pending_grant_hlc: null,
|
|
173
|
+
pending_grant_digest: null
|
|
174
|
+
} : {
|
|
175
|
+
pending_revocation_hlc: null,
|
|
176
|
+
pending_revocation_digest: null
|
|
177
|
+
}).where('group_id', '=', groupID).where(hlcColumn, '=', admission.hlc).where(digestColumn, '=', admission.digest).execute();
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
await db.transaction().execute(put);
|
|
181
|
+
},
|
|
182
|
+
async list () {
|
|
183
|
+
const rows = await db.selectFrom('kubun_p2p_app_outbox as entry').leftJoin('kubun_p2p_app_outbox_state as state', 'state.group_id', 'entry.group_id').select([
|
|
184
|
+
'entry.seq',
|
|
185
|
+
'entry.protocol',
|
|
186
|
+
'entry.prc',
|
|
187
|
+
'entry.data',
|
|
188
|
+
'entry.iv',
|
|
189
|
+
'entry.last_attempt',
|
|
190
|
+
'state.key'
|
|
191
|
+
]).where('entry.group_id', '=', groupID).orderBy('entry.seq').execute();
|
|
192
|
+
return rows.map((row)=>{
|
|
193
|
+
const entry = {
|
|
194
|
+
seq: row.seq,
|
|
195
|
+
protocol: row.protocol,
|
|
196
|
+
prc: row.prc,
|
|
197
|
+
lastAttempt: row.last_attempt
|
|
198
|
+
};
|
|
199
|
+
if (row.key == null) {
|
|
200
|
+
throw new AppOutboxDecryptionError(`App outbox key is absent for group ${groupID}`);
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
const data = gcm(row.key, row.iv, associatedData(groupID, entry)).decrypt(row.data);
|
|
204
|
+
return {
|
|
205
|
+
...entry,
|
|
206
|
+
data
|
|
207
|
+
};
|
|
208
|
+
} catch (cause) {
|
|
209
|
+
throw new AppOutboxDecryptionError(`App outbox entry ${row.seq} failed authentication for group ${groupID}`, {
|
|
210
|
+
cause
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
},
|
|
215
|
+
async remove (seq) {
|
|
216
|
+
await db.deleteFrom('kubun_p2p_app_outbox').where('group_id', '=', groupID).where('seq', '=', seq).execute();
|
|
217
|
+
},
|
|
218
|
+
clear: ()=>clearAppOutbox(groupID),
|
|
219
|
+
async getCommitCursor () {
|
|
220
|
+
const state = await db.selectFrom('kubun_p2p_app_outbox_state').select('commit_cursor').where('group_id', '=', groupID).executeTakeFirst();
|
|
221
|
+
return state?.commit_cursor ?? null;
|
|
222
|
+
},
|
|
223
|
+
async putCommitCursor (cursor) {
|
|
224
|
+
// A write after clear can recreate a key-less row with a stale cursor.
|
|
225
|
+
// Kumiai resets that cursor when rejoin changes the epoch.
|
|
226
|
+
await db.insertInto('kubun_p2p_app_outbox_state').values({
|
|
227
|
+
group_id: groupID,
|
|
228
|
+
commit_cursor: cursor == null ? null : adapter.encodeJSON(cursor)
|
|
229
|
+
}).onConflict((oc)=>oc.column('group_id').doUpdateSet((eb)=>({
|
|
230
|
+
commit_cursor: eb.ref('excluded.commit_cursor')
|
|
231
|
+
}))).execute();
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Kysely } from 'kysely';
|
|
2
|
+
import type { P2PStoreTables } from '../tables.js';
|
|
3
|
+
export type CatalogOffer = {
|
|
4
|
+
owner: string;
|
|
5
|
+
exposureID: string;
|
|
6
|
+
catalogID: string;
|
|
7
|
+
groupID: string;
|
|
8
|
+
circleID: string;
|
|
9
|
+
};
|
|
10
|
+
export type CatalogOfferOps = {
|
|
11
|
+
putCatalogOffer(offer: CatalogOffer): Promise<void>;
|
|
12
|
+
removeCatalogOffer(owner: string, exposureID: string): Promise<void>;
|
|
13
|
+
removeCatalogOffersForCatalog(owner: string, catalogID: string): Promise<void>;
|
|
14
|
+
replaceCatalogOffers(groupID: string, offers: ReadonlyArray<CatalogOffer>): Promise<void>;
|
|
15
|
+
activeCatalogOffers(groupID: string, circleID: string | null): Promise<Array<CatalogOffer>>;
|
|
16
|
+
allCatalogOffers(): Promise<Array<CatalogOffer>>;
|
|
17
|
+
};
|
|
18
|
+
export declare function createCatalogOfferOps(db: Kysely<P2PStoreTables>): CatalogOfferOps;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Transaction } from 'kysely';
|
|
2
|
+
export function createCatalogOfferOps(db) {
|
|
3
|
+
const project = (row)=>({
|
|
4
|
+
owner: row.owner,
|
|
5
|
+
exposureID: row.exposure_id,
|
|
6
|
+
catalogID: row.catalog_id,
|
|
7
|
+
groupID: row.group_id,
|
|
8
|
+
circleID: row.circle_id
|
|
9
|
+
});
|
|
10
|
+
return {
|
|
11
|
+
async putCatalogOffer (offer) {
|
|
12
|
+
await db.insertInto('kubun_p2p_catalog_offers').values({
|
|
13
|
+
owner: offer.owner,
|
|
14
|
+
exposure_id: offer.exposureID,
|
|
15
|
+
catalog_id: offer.catalogID,
|
|
16
|
+
group_id: offer.groupID,
|
|
17
|
+
circle_id: offer.circleID
|
|
18
|
+
}).onConflict((oc)=>oc.columns([
|
|
19
|
+
'owner',
|
|
20
|
+
'exposure_id'
|
|
21
|
+
]).doUpdateSet({
|
|
22
|
+
catalog_id: offer.catalogID,
|
|
23
|
+
group_id: offer.groupID,
|
|
24
|
+
circle_id: offer.circleID
|
|
25
|
+
})).execute();
|
|
26
|
+
},
|
|
27
|
+
async removeCatalogOffer (owner, exposureID) {
|
|
28
|
+
await db.deleteFrom('kubun_p2p_catalog_offers').where('owner', '=', owner).where('exposure_id', '=', exposureID).execute();
|
|
29
|
+
},
|
|
30
|
+
async removeCatalogOffersForCatalog (owner, catalogID) {
|
|
31
|
+
await db.deleteFrom('kubun_p2p_catalog_offers').where('owner', '=', owner).where('catalog_id', '=', catalogID).execute();
|
|
32
|
+
},
|
|
33
|
+
async allCatalogOffers () {
|
|
34
|
+
return (await db.selectFrom('kubun_p2p_catalog_offers').selectAll().execute()).map(project);
|
|
35
|
+
},
|
|
36
|
+
async replaceCatalogOffers (groupID, offers) {
|
|
37
|
+
if (offers.some((offer)=>offer.groupID !== groupID)) throw new Error('Catalog offer group does not match replacement scope');
|
|
38
|
+
const replace = async (tx)=>{
|
|
39
|
+
await tx.deleteFrom('kubun_p2p_catalog_offers').where('group_id', '=', groupID).execute();
|
|
40
|
+
if (offers.length === 0) return;
|
|
41
|
+
await tx.insertInto('kubun_p2p_catalog_offers').values(offers.map((offer)=>({
|
|
42
|
+
owner: offer.owner,
|
|
43
|
+
exposure_id: offer.exposureID,
|
|
44
|
+
catalog_id: offer.catalogID,
|
|
45
|
+
group_id: offer.groupID,
|
|
46
|
+
circle_id: offer.circleID
|
|
47
|
+
}))).execute();
|
|
48
|
+
};
|
|
49
|
+
if (db instanceof Transaction) await replace(db);
|
|
50
|
+
else await db.transaction().execute(replace);
|
|
51
|
+
},
|
|
52
|
+
async activeCatalogOffers (groupID, circleID) {
|
|
53
|
+
let query = db.selectFrom('kubun_p2p_catalog_offers').selectAll().where('group_id', '=', groupID);
|
|
54
|
+
if (circleID !== null) query = query.where('circle_id', '=', circleID);
|
|
55
|
+
const rows = await query.orderBy('owner').orderBy('exposure_id').execute();
|
|
56
|
+
return rows.map(project);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Kysely } from 'kysely';
|
|
2
|
+
import type { P2PStoreTables } from '../tables.js';
|
|
3
|
+
export type CatalogRoute = {
|
|
4
|
+
owner: string;
|
|
5
|
+
catalogID: string;
|
|
6
|
+
groupID: string;
|
|
7
|
+
pull: boolean;
|
|
8
|
+
push: boolean;
|
|
9
|
+
};
|
|
10
|
+
export type CatalogRouteOps = {
|
|
11
|
+
setCatalogRoute(owner: string, catalogID: string, groupID: string, pull: boolean, push: boolean): Promise<void>;
|
|
12
|
+
catalogRoutes(owner: string, catalogID: string): Promise<Array<CatalogRoute>>;
|
|
13
|
+
allCatalogRoutes(groupID?: string): Promise<Array<CatalogRoute>>;
|
|
14
|
+
deleteCatalogRoutes(owner: string, catalogID: string): Promise<void>;
|
|
15
|
+
deleteGroupCatalogRoutes(groupID: string): Promise<void>;
|
|
16
|
+
/** Whether this device serves the group's document data; true unless turned off. */
|
|
17
|
+
getGroupServing(params: {
|
|
18
|
+
groupID: string;
|
|
19
|
+
}): Promise<boolean>;
|
|
20
|
+
setGroupServing(params: {
|
|
21
|
+
groupID: string;
|
|
22
|
+
enabled: boolean;
|
|
23
|
+
}): Promise<void>;
|
|
24
|
+
};
|
|
25
|
+
export declare function createCatalogRouteOps(db: Kysely<P2PStoreTables>): CatalogRouteOps;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export function createCatalogRouteOps(db) {
|
|
2
|
+
const project = (row)=>({
|
|
3
|
+
owner: row.owner,
|
|
4
|
+
catalogID: row.catalog_id,
|
|
5
|
+
groupID: row.group_id,
|
|
6
|
+
pull: Number(row.pull) === 1,
|
|
7
|
+
push: Number(row.push) === 1
|
|
8
|
+
});
|
|
9
|
+
return {
|
|
10
|
+
async setCatalogRoute (owner, catalogID, groupID, pull, push) {
|
|
11
|
+
if (!pull && !push) {
|
|
12
|
+
await db.deleteFrom('kubun_p2p_catalog_routes').where('owner', '=', owner).where('catalog_id', '=', catalogID).where('group_id', '=', groupID).execute();
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
await db.insertInto('kubun_p2p_catalog_routes').values({
|
|
16
|
+
owner,
|
|
17
|
+
catalog_id: catalogID,
|
|
18
|
+
group_id: groupID,
|
|
19
|
+
pull: Number(pull),
|
|
20
|
+
push: Number(push)
|
|
21
|
+
}).onConflict((oc)=>oc.columns([
|
|
22
|
+
'owner',
|
|
23
|
+
'catalog_id',
|
|
24
|
+
'group_id'
|
|
25
|
+
]).doUpdateSet({
|
|
26
|
+
pull: Number(pull),
|
|
27
|
+
push: Number(push)
|
|
28
|
+
})).execute();
|
|
29
|
+
},
|
|
30
|
+
async getGroupServing ({ groupID }) {
|
|
31
|
+
const row = await db.selectFrom('kubun_p2p_unserved_groups').select('group_id').where('group_id', '=', groupID).executeTakeFirst();
|
|
32
|
+
return row == null;
|
|
33
|
+
},
|
|
34
|
+
async setGroupServing ({ groupID, enabled }) {
|
|
35
|
+
if (enabled) {
|
|
36
|
+
await db.deleteFrom('kubun_p2p_unserved_groups').where('group_id', '=', groupID).execute();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
await db.insertInto('kubun_p2p_unserved_groups').values({
|
|
40
|
+
group_id: groupID
|
|
41
|
+
}).onConflict((oc)=>oc.column('group_id').doNothing()).execute();
|
|
42
|
+
},
|
|
43
|
+
async catalogRoutes (owner, catalogID) {
|
|
44
|
+
const rows = await db.selectFrom('kubun_p2p_catalog_routes').selectAll().where('owner', '=', owner).where('catalog_id', '=', catalogID).orderBy('group_id').execute();
|
|
45
|
+
return rows.map(project);
|
|
46
|
+
},
|
|
47
|
+
async allCatalogRoutes (groupID) {
|
|
48
|
+
let query = db.selectFrom('kubun_p2p_catalog_routes').selectAll();
|
|
49
|
+
if (groupID != null) query = query.where('group_id', '=', groupID);
|
|
50
|
+
const rows = await query.orderBy('owner').orderBy('catalog_id').orderBy('group_id').execute();
|
|
51
|
+
return rows.map(project);
|
|
52
|
+
},
|
|
53
|
+
async deleteCatalogRoutes (owner, catalogID) {
|
|
54
|
+
await db.deleteFrom('kubun_p2p_catalog_routes').where('owner', '=', owner).where('catalog_id', '=', catalogID).execute();
|
|
55
|
+
},
|
|
56
|
+
async deleteGroupCatalogRoutes (groupID) {
|
|
57
|
+
await db.deleteFrom('kubun_p2p_catalog_routes').where('group_id', '=', groupID).execute();
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { type Adapter } from '@kubun/db-adapter';
|
|
2
|
+
import type { Kysely } from 'kysely';
|
|
3
|
+
import type { P2PStoreTables } from '../tables.js';
|
|
4
|
+
/** Absolute window bounds of one concrete rule. */
|
|
5
|
+
export type CatalogCursorBounds = {
|
|
6
|
+
from: number | null;
|
|
7
|
+
to: number | null;
|
|
8
|
+
/** Bound-free slot of the offered rule this accepted window came from. */
|
|
9
|
+
offeredSlot?: string;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Document-sync progress with one peer in one group. `rulesHash` is the hash of
|
|
13
|
+
* the accepted rule set without absolute bounds, together with the
|
|
14
|
+
* authorization it was accepted under; `bounds` holds the absolute bounds in
|
|
15
|
+
* use for each rule, keyed by the rule's bound-free key, until the cursor
|
|
16
|
+
* wraps. `cursor` is the last resolved document key, null at the start of a
|
|
17
|
+
* cycle. `cycleClear` is false once a session in the cycle left something
|
|
18
|
+
* authorized unfetched.
|
|
19
|
+
*/
|
|
20
|
+
export type CatalogCursorState = {
|
|
21
|
+
rulesHash: string;
|
|
22
|
+
cursor: string | null;
|
|
23
|
+
/** The responder's sealed position matching `cursor`; null at a cycle start. */
|
|
24
|
+
token: string | null;
|
|
25
|
+
cycleClear: boolean;
|
|
26
|
+
bounds: Record<string, CatalogCursorBounds>;
|
|
27
|
+
};
|
|
28
|
+
/** A peer's last answer for a document that left nothing to fetch from it. */
|
|
29
|
+
export type DocumentRepairAnswerKind = 'unavailable' | 'refused';
|
|
30
|
+
/** Listed repair rows one peer may account for in one group; the oldest go first. */
|
|
31
|
+
export declare const MAX_REPAIRS_PER_LISTER = 10000;
|
|
32
|
+
/** What this device remembers of one document's difference in one group. */
|
|
33
|
+
export type DocumentRepairRow = {
|
|
34
|
+
/** The first peer whose tree listed the document; null when none did. */
|
|
35
|
+
listedBy: string | null;
|
|
36
|
+
holdingsDigest: string;
|
|
37
|
+
answers: Record<string, DocumentRepairAnswerKind>;
|
|
38
|
+
createdAt: number;
|
|
39
|
+
attemptedAt: number;
|
|
40
|
+
};
|
|
41
|
+
export type RecordDocumentRepairParams = {
|
|
42
|
+
/** `peerDID`'s tree listed the document in this attempt. */
|
|
43
|
+
listed: boolean;
|
|
44
|
+
holdingsDigest: string;
|
|
45
|
+
peerDID: string;
|
|
46
|
+
/** Null when the answer left work pending, or is about to be applied. */
|
|
47
|
+
answer: DocumentRepairAnswerKind | null;
|
|
48
|
+
attemptedAt: number;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* When this device next asks peers for an observed document's value: the
|
|
52
|
+
* obligation it is for, how many attempts failed so far, the earliest time of
|
|
53
|
+
* the next one and the peers already asked that answered without the value.
|
|
54
|
+
*/
|
|
55
|
+
export type ObservationRepairSchedule = {
|
|
56
|
+
documentID: string;
|
|
57
|
+
/** Fetch time of the observation the obligation is for; a newer one starts afresh. */
|
|
58
|
+
fetchHLC: string;
|
|
59
|
+
attempts: number;
|
|
60
|
+
nextAttemptAt: number;
|
|
61
|
+
triedPeers: Array<string>;
|
|
62
|
+
};
|
|
63
|
+
/** Position of a schedule in due order. */
|
|
64
|
+
export type ObservationRepairPosition = {
|
|
65
|
+
nextAttemptAt: number;
|
|
66
|
+
documentID: string;
|
|
67
|
+
};
|
|
68
|
+
export type DueObservationRepairQuery = {
|
|
69
|
+
now: number;
|
|
70
|
+
/** A schedule that has not tried this peer is due for it before its time. */
|
|
71
|
+
peerDID: string;
|
|
72
|
+
/** A schedule whose tried set is this full no longer asks untried peers early. */
|
|
73
|
+
maxTriedPeers: number;
|
|
74
|
+
after: ObservationRepairPosition | null;
|
|
75
|
+
limit: number;
|
|
76
|
+
};
|
|
77
|
+
/** Durable session cursors, document repair rows and observation repair schedules. */
|
|
78
|
+
export type CatalogSyncOps = {
|
|
79
|
+
getCatalogCursor(peerDID: string, groupID: string): Promise<CatalogCursorState | null>;
|
|
80
|
+
setCatalogCursor(peerDID: string, groupID: string, state: CatalogCursorState): Promise<void>;
|
|
81
|
+
/** Rows for the given documents. The caller bounds the list. */
|
|
82
|
+
getDocumentRepairs(groupID: string, documentIDs: ReadonlyArray<string>): Promise<Map<string, DocumentRepairRow>>;
|
|
83
|
+
/**
|
|
84
|
+
* Record an attempt. A listed row keeps its first lister; new holdings forget
|
|
85
|
+
* the answers given for the old ones. A peer accounts for at most
|
|
86
|
+
* {@link MAX_REPAIRS_PER_LISTER} listed rows: its oldest go when it lists more.
|
|
87
|
+
*/
|
|
88
|
+
recordDocumentRepair(groupID: string, documentID: string, params: RecordDocumentRepairParams): Promise<void>;
|
|
89
|
+
/** Remove rows the caller resolved. The caller bounds the list. */
|
|
90
|
+
resolveDocumentRepairs(groupID: string, documentIDs: ReadonlyArray<string>): Promise<void>;
|
|
91
|
+
/**
|
|
92
|
+
* Listed rows `peerDID` has not answered for the current holdings, least
|
|
93
|
+
* recently attempted first, so no row starves the others.
|
|
94
|
+
*/
|
|
95
|
+
listDocumentRepairs(groupID: string, peerDID: string, limit: number): Promise<Array<string>>;
|
|
96
|
+
/** Remove rows created before `createdBefore`, whoever listed them. */
|
|
97
|
+
pruneDocumentRepairs(groupID: string, createdBefore: number): Promise<void>;
|
|
98
|
+
/** Schedules for the given documents. The caller bounds the list. */
|
|
99
|
+
getObservationRepairSchedules(documentIDs: ReadonlyArray<string>): Promise<Map<string, ObservationRepairSchedule>>;
|
|
100
|
+
/**
|
|
101
|
+
* Schedules due now or not yet tried with the peer, in due order after a
|
|
102
|
+
* position, at most `limit`.
|
|
103
|
+
*/
|
|
104
|
+
listDueObservationRepairSchedules(query: DueObservationRepairQuery): Promise<Array<ObservationRepairSchedule>>;
|
|
105
|
+
/** Replace a schedule, its tried peers included. */
|
|
106
|
+
setObservationRepairSchedule(schedule: ObservationRepairSchedule): Promise<void>;
|
|
107
|
+
/**
|
|
108
|
+
* Read, change and write one schedule as a unit, so concurrent updates never
|
|
109
|
+
* lose a tried peer or an attempt. `update` receives the stored schedule and
|
|
110
|
+
* returns the one to store, or null to leave it. Resolves to the schedule
|
|
111
|
+
* stored afterwards.
|
|
112
|
+
*/
|
|
113
|
+
updateObservationRepairSchedule(documentID: string, update: (current: ObservationRepairSchedule | undefined) => ObservationRepairSchedule | null): Promise<ObservationRepairSchedule | undefined>;
|
|
114
|
+
/** Remove a schedule whose obligation resolved. */
|
|
115
|
+
deleteObservationRepairSchedules(documentIDs: ReadonlyArray<string>): Promise<void>;
|
|
116
|
+
/** The documents with a schedule, in ID order after `after`, at most `limit`. */
|
|
117
|
+
listObservationRepairScheduleIDs(page: {
|
|
118
|
+
after: string | null;
|
|
119
|
+
limit: number;
|
|
120
|
+
}): Promise<Array<string>>;
|
|
121
|
+
};
|
|
122
|
+
export declare function createCatalogSyncOps(db: Kysely<P2PStoreTables>, adapter: Adapter): CatalogSyncOps;
|