@kubun/store-p2p 0.19.0 → 0.20.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 +5 -2
- package/lib/api.js +10 -6
- package/lib/definition.d.ts +6 -1
- package/lib/definition.js +37 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/lib/migrations.js +90 -90
- package/lib/ops/app-lane.d.ts +1 -1
- package/lib/ops/app-lane.js +18 -12
- package/lib/ops/app-outbox.d.ts +4 -2
- package/lib/ops/app-outbox.js +33 -33
- package/lib/ops/catalog-offers.d.ts +1 -1
- package/lib/ops/catalog-offers.js +9 -10
- package/lib/ops/catalog-routes.js +9 -9
- package/lib/ops/catalog-sync.d.ts +2 -1
- package/lib/ops/catalog-sync.js +39 -42
- package/lib/ops/circles.js +33 -33
- package/lib/ops/control.d.ts +1 -1
- package/lib/ops/control.js +12 -10
- package/lib/ops/delegations.d.ts +2 -1
- package/lib/ops/delegations.js +17 -25
- package/lib/ops/discovery.d.ts +1 -1
- package/lib/ops/discovery.js +23 -10
- package/lib/ops/document-sweep.js +5 -5
- package/lib/ops/groups.d.ts +3 -2
- package/lib/ops/groups.js +60 -62
- package/lib/ops/hubs.js +38 -57
- package/lib/ops/log-handler-completion.js +3 -3
- package/lib/ops/mls.d.ts +1 -1
- package/lib/ops/mls.js +36 -26
- package/lib/ops/own-agents.d.ts +3 -3
- package/lib/ops/own-agents.js +128 -72
- package/lib/ops/sync.d.ts +1 -1
- package/lib/ops/sync.js +17 -8
- package/lib/scope.d.ts +8 -0
- package/lib/scope.js +8 -0
- package/lib/tables.d.ts +52 -42
- package/package.json +14 -13
package/lib/ops/app-lane.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { withStoreTransaction } from '@kubun/db';
|
|
2
|
+
import { decodeJSON } from '@kubun/db-adapter';
|
|
1
3
|
import { sortPendingAppFrames } from '@kumiai/rpc';
|
|
2
4
|
import { createMLSOps } from './mls.js';
|
|
3
5
|
export function createAppLaneOps(db, adapter) {
|
|
@@ -5,7 +7,7 @@ export function createAppLaneOps(db, adapter) {
|
|
|
5
7
|
async saveOpenedAppFrame ({ state, expected, record }) {
|
|
6
8
|
const save = async (tx)=>{
|
|
7
9
|
if (!await createMLSOps(tx, adapter).saveMLSState(state, expected)) return false;
|
|
8
|
-
await tx.insertInto('
|
|
10
|
+
await tx.insertInto('p2p_app_pending').values({
|
|
9
11
|
group_id: state.group_id,
|
|
10
12
|
device_id: state.device_id,
|
|
11
13
|
frame_id: record.frameID,
|
|
@@ -22,10 +24,10 @@ export function createAppLaneOps(db, adapter) {
|
|
|
22
24
|
]).doNothing()).execute();
|
|
23
25
|
return true;
|
|
24
26
|
};
|
|
25
|
-
return
|
|
27
|
+
return await withStoreTransaction(db, save);
|
|
26
28
|
},
|
|
27
29
|
async listPendingAppFrames (groupID, deviceID) {
|
|
28
|
-
const rows = await db.selectFrom('
|
|
30
|
+
const rows = await db.selectFrom('p2p_app_pending').selectAll().where('group_id', '=', groupID).where('device_id', '=', deviceID).execute();
|
|
29
31
|
const sorted = sortPendingAppFrames(rows.map((row)=>({
|
|
30
32
|
frame: {
|
|
31
33
|
id: row.frame_id,
|
|
@@ -41,15 +43,15 @@ export function createAppLaneOps(db, adapter) {
|
|
|
41
43
|
return sorted.map(({ row })=>row);
|
|
42
44
|
},
|
|
43
45
|
async completePendingAppFrame (groupID, deviceID, frameID) {
|
|
44
|
-
await db.deleteFrom('
|
|
46
|
+
await db.deleteFrom('p2p_app_pending').where('group_id', '=', groupID).where('device_id', '=', deviceID).where('frame_id', '=', frameID).execute();
|
|
45
47
|
},
|
|
46
48
|
async deleteGroupAppLane (groupID, deviceID) {
|
|
47
49
|
const remove = async (tx)=>{
|
|
48
|
-
await tx.deleteFrom('
|
|
49
|
-
await tx.deleteFrom('
|
|
50
|
-
await tx.deleteFrom('
|
|
50
|
+
await tx.deleteFrom('p2p_app_pending').where('group_id', '=', groupID).where('device_id', '=', deviceID).execute();
|
|
51
|
+
await tx.deleteFrom('p2p_group_anchor').where('group_id', '=', groupID).execute();
|
|
52
|
+
await tx.deleteFrom('p2p_app_cursor').where('group_id', '=', groupID).execute();
|
|
51
53
|
};
|
|
52
|
-
return
|
|
54
|
+
return await withStoreTransaction(db, remove);
|
|
53
55
|
},
|
|
54
56
|
async putGroupAnchor (params) {
|
|
55
57
|
const values = {
|
|
@@ -58,7 +60,7 @@ export function createAppLaneOps(db, adapter) {
|
|
|
58
60
|
epoch: params.epoch,
|
|
59
61
|
pending: params.pending == null ? null : adapter.encodeJSON(params.pending)
|
|
60
62
|
};
|
|
61
|
-
await db.insertInto('
|
|
63
|
+
await db.insertInto('p2p_group_anchor')// Single slot per group: a rotation REPLACES both halves together. The
|
|
62
64
|
// secret and the epoch it was exported at both feed topic derivation,
|
|
63
65
|
// so a row mixing one epoch's secret with another's number would derive
|
|
64
66
|
// a topic no member is on.
|
|
@@ -69,11 +71,15 @@ export function createAppLaneOps(db, adapter) {
|
|
|
69
71
|
}))).execute();
|
|
70
72
|
},
|
|
71
73
|
async getGroupAnchor (groupID) {
|
|
72
|
-
|
|
74
|
+
const row = await db.selectFrom('p2p_group_anchor').selectAll().where('group_id', '=', groupID).executeTakeFirst();
|
|
75
|
+
return row == null ? undefined : {
|
|
76
|
+
...row,
|
|
77
|
+
pending: decodeJSON(adapter.kind, row.pending)
|
|
78
|
+
};
|
|
73
79
|
},
|
|
74
80
|
// --- App-lane cursor operations ---
|
|
75
81
|
async putAppCursor (params) {
|
|
76
|
-
await db.insertInto('
|
|
82
|
+
await db.insertInto('p2p_app_cursor').values({
|
|
77
83
|
group_id: params.groupID,
|
|
78
84
|
topic_id: params.topicID,
|
|
79
85
|
position: params.position
|
|
@@ -85,7 +91,7 @@ export function createAppLaneOps(db, adapter) {
|
|
|
85
91
|
}))).execute();
|
|
86
92
|
},
|
|
87
93
|
async getAppCursor (params) {
|
|
88
|
-
return await db.selectFrom('
|
|
94
|
+
return await db.selectFrom('p2p_app_cursor').selectAll().where('group_id', '=', params.groupID).where('topic_id', '=', params.topicID).executeTakeFirst();
|
|
89
95
|
}
|
|
90
96
|
};
|
|
91
97
|
}
|
package/lib/ops/app-outbox.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { StoreAPIContext } from '@kubun/db';
|
|
2
|
+
import { type Adapter } from '@kubun/db-adapter';
|
|
2
3
|
import { type AppOutbox } from '@kumiai/rpc';
|
|
3
4
|
import type { Runtime } from '@sozai/runtime';
|
|
4
5
|
import type { Kysely } from 'kysely';
|
|
@@ -41,4 +42,5 @@ export type AppOutboxOps = {
|
|
|
41
42
|
export declare function lockAppOutboxForErasure(db: Kysely<P2PStoreTables>, groupID: string, options?: ClearAppOutboxRowsOptions): Promise<void>;
|
|
42
43
|
/** The caller's transaction also covers group leave or deletion. */
|
|
43
44
|
export declare function clearAppOutboxRows(db: Kysely<P2PStoreTables>, groupID: string, options?: ClearAppOutboxRowsOptions): Promise<Array<number>>;
|
|
44
|
-
export declare function
|
|
45
|
+
export declare function withAppOutboxLock<R>(context: StoreAPIContext, groupID: string, run: (tx: Kysely<P2PStoreTables>) => Promise<R>): Promise<R>;
|
|
46
|
+
export declare function createAppOutboxOps(db: Kysely<P2PStoreTables>, adapter: Adapter, getRandomValues: Runtime['getRandomValues'], context: StoreAPIContext): AppOutboxOps;
|
package/lib/ops/app-outbox.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { encodeMultibase, multihashSHA256 } from '@kokuin/token';
|
|
2
|
+
import { decodeJSON } from '@kubun/db-adapter';
|
|
2
3
|
import { AppEntryTooLargeError, AppOutboxFullError, MAX_APP_ENTRY_BYTES, SendNotAdmissibleError } from '@kumiai/rpc';
|
|
3
4
|
import { gcm } from '@noble/ciphers/aes.js';
|
|
5
|
+
import { withP2PKeyLocks } from '../scope.js';
|
|
4
6
|
const KEY_LENGTH = 32;
|
|
5
7
|
const IV_LENGTH = 12;
|
|
6
8
|
export class AppOutboxDecryptionError extends Error {
|
|
@@ -55,18 +57,12 @@ function associatedData(groupID, entry) {
|
|
|
55
57
|
]));
|
|
56
58
|
}
|
|
57
59
|
async function hasUndeliveredRevocations(db, groupID) {
|
|
58
|
-
const marker = await db.selectFrom('
|
|
60
|
+
const marker = await db.selectFrom('p2p_group_delegation').select('jti').where('group_id', '=', groupID).where('pending_revocation_hlc', 'is not', null).limit(1).executeTakeFirst();
|
|
59
61
|
if (marker != null) return true;
|
|
60
|
-
const queued = await db.selectFrom('
|
|
62
|
+
const queued = await db.selectFrom('p2p_app_outbox').select('seq').where('group_id', '=', groupID).where('protocol', '=', 'control').where('prc', '=', 'control/delegationRevoke').limit(1).executeTakeFirst();
|
|
61
63
|
return queued != null;
|
|
62
64
|
}
|
|
63
65
|
/** 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
66
|
// Under the erasure lock: a revocation committed after the leaver's delivery
|
|
71
67
|
// check is either seen here or blocks until this transaction ends.
|
|
72
68
|
if (options?.requireRevocationsDelivered === true && await hasUndeliveredRevocations(db, groupID)) {
|
|
@@ -75,23 +71,29 @@ async function hasUndeliveredRevocations(db, groupID) {
|
|
|
75
71
|
}
|
|
76
72
|
/** The caller's transaction also covers group leave or deletion. */ export async function clearAppOutboxRows(db, groupID, options) {
|
|
77
73
|
await lockAppOutboxForErasure(db, groupID, options);
|
|
78
|
-
await db.updateTable('
|
|
74
|
+
await db.updateTable('p2p_group_delegation').set({
|
|
79
75
|
pending_grant_hlc: null,
|
|
80
76
|
pending_grant_digest: null,
|
|
81
77
|
pending_revocation_hlc: null,
|
|
82
78
|
pending_revocation_digest: null
|
|
83
79
|
}).where('group_id', '=', groupID).execute();
|
|
84
|
-
await db.deleteFrom('
|
|
85
|
-
const deleted = await db.deleteFrom('
|
|
80
|
+
await db.deleteFrom('p2p_app_outbox_state').where('group_id', '=', groupID).execute();
|
|
81
|
+
const deleted = await db.deleteFrom('p2p_app_outbox').where('group_id', '=', groupID).returning('seq').execute();
|
|
86
82
|
return deleted.map((entry)=>entry.seq).sort((a, b)=>a - b);
|
|
87
83
|
}
|
|
88
|
-
export function
|
|
84
|
+
export async function withAppOutboxLock(context, groupID, run) {
|
|
85
|
+
return await withP2PKeyLocks(context, 'p2p_app_outbox_state', [
|
|
86
|
+
[
|
|
87
|
+
groupID
|
|
88
|
+
]
|
|
89
|
+
], run);
|
|
90
|
+
}
|
|
91
|
+
export function createAppOutboxOps(db, adapter, getRandomValues, context) {
|
|
89
92
|
const clearAppOutbox = async (groupID)=>{
|
|
90
93
|
const clear = async (tx)=>{
|
|
91
94
|
await clearAppOutboxRows(tx, groupID);
|
|
92
95
|
};
|
|
93
|
-
|
|
94
|
-
else await db.transaction().execute(clear);
|
|
96
|
+
await withAppOutboxLock(context, groupID, clear);
|
|
95
97
|
};
|
|
96
98
|
return {
|
|
97
99
|
clearAppOutbox,
|
|
@@ -107,30 +109,28 @@ export function createAppOutboxOps(db, adapter, getRandomValues) {
|
|
|
107
109
|
}
|
|
108
110
|
const admission = pendingDelegationAdmission(entry);
|
|
109
111
|
const put = async (tx)=>{
|
|
110
|
-
|
|
111
|
-
const state = await tx.insertInto('kubun_p2p_app_outbox_state').values({
|
|
112
|
+
await tx.insertInto('p2p_app_outbox_state').values({
|
|
112
113
|
group_id: groupID
|
|
113
|
-
}).onConflict((oc)=>oc.column('group_id').
|
|
114
|
-
|
|
115
|
-
})).returning('key').executeTakeFirstOrThrow();
|
|
114
|
+
}).onConflict((oc)=>oc.column('group_id').doNothing()).execute();
|
|
115
|
+
const state = await tx.selectFrom('p2p_app_outbox_state').select('key').where('group_id', '=', groupID).executeTakeFirstOrThrow();
|
|
116
116
|
// Check after acquiring the erasure lock: a put resumed after removal
|
|
117
117
|
// must not recreate the destroyed key or an encrypted outbox row.
|
|
118
|
-
const group = await tx.selectFrom('
|
|
118
|
+
const group = await tx.selectFrom('p2p_groups').select('status').where('id', '=', groupID).executeTakeFirst();
|
|
119
119
|
if (group?.status === 'left' || admission != null && group?.status !== 'joined') {
|
|
120
120
|
throw new SendNotAdmissibleError('lapsed');
|
|
121
121
|
}
|
|
122
122
|
if (admission?.kind !== 'revocation') {
|
|
123
|
-
const accepted = await tx.selectFrom('
|
|
123
|
+
const accepted = await tx.selectFrom('p2p_app_outbox').select('seq').where('group_id', '=', groupID).where('seq', '=', entry.seq).executeTakeFirst();
|
|
124
124
|
// Accepted entries must stay writable so draining can release capacity.
|
|
125
125
|
if (accepted == null) {
|
|
126
|
-
const pendingRevocation = await tx.selectFrom('
|
|
126
|
+
const pendingRevocation = await tx.selectFrom('p2p_group_delegation').select('jti').where('group_id', '=', groupID).where('pending_revocation_hlc', 'is not', null).limit(1).executeTakeFirst();
|
|
127
127
|
if (pendingRevocation != null && admission?.kind === 'grant') {
|
|
128
128
|
throw new PendingRevocationAdmissionError('Pending revocations must be admitted before new grants');
|
|
129
129
|
}
|
|
130
130
|
if (pendingRevocation != null && options?.limit != null) {
|
|
131
131
|
// Other retained traffic leaves the last slot to revocations, so a
|
|
132
132
|
// revocation that cannot be admitted never holds presence back.
|
|
133
|
-
const queued = await tx.selectFrom('
|
|
133
|
+
const queued = await tx.selectFrom('p2p_app_outbox').select((eb)=>eb.fn.countAll().as('count')).where('group_id', '=', groupID).executeTakeFirstOrThrow();
|
|
134
134
|
if (Number(queued.count) >= options.limit - 1) {
|
|
135
135
|
throw new AppOutboxFullError('App outbox slot reserved for revocations');
|
|
136
136
|
}
|
|
@@ -140,13 +140,13 @@ export function createAppOutboxOps(db, adapter, getRandomValues) {
|
|
|
140
140
|
let key = state.key;
|
|
141
141
|
if (key == null) {
|
|
142
142
|
key = getRandomValues(new Uint8Array(KEY_LENGTH));
|
|
143
|
-
await tx.updateTable('
|
|
143
|
+
await tx.updateTable('p2p_app_outbox_state').set({
|
|
144
144
|
key: adapter.encodeBinary(key)
|
|
145
145
|
}).where('group_id', '=', groupID).execute();
|
|
146
146
|
}
|
|
147
147
|
const iv = getRandomValues(new Uint8Array(IV_LENGTH));
|
|
148
148
|
const data = gcm(key, iv, associatedData(groupID, entry)).encrypt(entry.data);
|
|
149
|
-
await tx.insertInto('
|
|
149
|
+
await tx.insertInto('p2p_app_outbox').values({
|
|
150
150
|
group_id: groupID,
|
|
151
151
|
seq: entry.seq,
|
|
152
152
|
protocol: entry.protocol,
|
|
@@ -168,7 +168,7 @@ export function createAppOutboxOps(db, adapter, getRandomValues) {
|
|
|
168
168
|
const hlcColumn = admission.kind === 'grant' ? 'pending_grant_hlc' : 'pending_revocation_hlc';
|
|
169
169
|
const digestColumn = admission.kind === 'grant' ? 'pending_grant_digest' : 'pending_revocation_digest';
|
|
170
170
|
// Admission and marker clear commit together, matching the exact stored token.
|
|
171
|
-
await tx.updateTable('
|
|
171
|
+
await tx.updateTable('p2p_group_delegation').set(admission.kind === 'grant' ? {
|
|
172
172
|
pending_grant_hlc: null,
|
|
173
173
|
pending_grant_digest: null
|
|
174
174
|
} : {
|
|
@@ -177,10 +177,10 @@ export function createAppOutboxOps(db, adapter, getRandomValues) {
|
|
|
177
177
|
}).where('group_id', '=', groupID).where(hlcColumn, '=', admission.hlc).where(digestColumn, '=', admission.digest).execute();
|
|
178
178
|
}
|
|
179
179
|
};
|
|
180
|
-
await
|
|
180
|
+
await withAppOutboxLock(context, groupID, put);
|
|
181
181
|
},
|
|
182
182
|
async list () {
|
|
183
|
-
const rows = await db.selectFrom('
|
|
183
|
+
const rows = await db.selectFrom('p2p_app_outbox as entry').leftJoin('p2p_app_outbox_state as state', 'state.group_id', 'entry.group_id').select([
|
|
184
184
|
'entry.seq',
|
|
185
185
|
'entry.protocol',
|
|
186
186
|
'entry.prc',
|
|
@@ -194,7 +194,7 @@ export function createAppOutboxOps(db, adapter, getRandomValues) {
|
|
|
194
194
|
seq: row.seq,
|
|
195
195
|
protocol: row.protocol,
|
|
196
196
|
prc: row.prc,
|
|
197
|
-
lastAttempt: row.last_attempt
|
|
197
|
+
lastAttempt: decodeJSON(adapter.kind, row.last_attempt)
|
|
198
198
|
};
|
|
199
199
|
if (row.key == null) {
|
|
200
200
|
throw new AppOutboxDecryptionError(`App outbox key is absent for group ${groupID}`);
|
|
@@ -213,17 +213,17 @@ export function createAppOutboxOps(db, adapter, getRandomValues) {
|
|
|
213
213
|
});
|
|
214
214
|
},
|
|
215
215
|
async remove (seq) {
|
|
216
|
-
await db.deleteFrom('
|
|
216
|
+
await db.deleteFrom('p2p_app_outbox').where('group_id', '=', groupID).where('seq', '=', seq).execute();
|
|
217
217
|
},
|
|
218
218
|
clear: ()=>clearAppOutbox(groupID),
|
|
219
219
|
async getCommitCursor () {
|
|
220
|
-
const state = await db.selectFrom('
|
|
221
|
-
return state?.commit_cursor
|
|
220
|
+
const state = await db.selectFrom('p2p_app_outbox_state').select('commit_cursor').where('group_id', '=', groupID).executeTakeFirst();
|
|
221
|
+
return decodeJSON(adapter.kind, state?.commit_cursor);
|
|
222
222
|
},
|
|
223
223
|
async putCommitCursor (cursor) {
|
|
224
224
|
// A write after clear can recreate a key-less row with a stale cursor.
|
|
225
225
|
// Kumiai resets that cursor when rejoin changes the epoch.
|
|
226
|
-
await db.insertInto('
|
|
226
|
+
await db.insertInto('p2p_app_outbox_state').values({
|
|
227
227
|
group_id: groupID,
|
|
228
228
|
commit_cursor: cursor == null ? null : adapter.encodeJSON(cursor)
|
|
229
229
|
}).onConflict((oc)=>oc.column('group_id').doUpdateSet((eb)=>({
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { withStoreTransaction } from '@kubun/db';
|
|
2
2
|
export function createCatalogOfferOps(db) {
|
|
3
3
|
const project = (row)=>({
|
|
4
4
|
owner: row.owner,
|
|
@@ -9,7 +9,7 @@ export function createCatalogOfferOps(db) {
|
|
|
9
9
|
});
|
|
10
10
|
return {
|
|
11
11
|
async putCatalogOffer (offer) {
|
|
12
|
-
await db.insertInto('
|
|
12
|
+
await db.insertInto('p2p_catalog_offers').values({
|
|
13
13
|
owner: offer.owner,
|
|
14
14
|
exposure_id: offer.exposureID,
|
|
15
15
|
catalog_id: offer.catalogID,
|
|
@@ -25,20 +25,20 @@ export function createCatalogOfferOps(db) {
|
|
|
25
25
|
})).execute();
|
|
26
26
|
},
|
|
27
27
|
async removeCatalogOffer (owner, exposureID) {
|
|
28
|
-
await db.deleteFrom('
|
|
28
|
+
await db.deleteFrom('p2p_catalog_offers').where('owner', '=', owner).where('exposure_id', '=', exposureID).execute();
|
|
29
29
|
},
|
|
30
30
|
async removeCatalogOffersForCatalog (owner, catalogID) {
|
|
31
|
-
await db.deleteFrom('
|
|
31
|
+
await db.deleteFrom('p2p_catalog_offers').where('owner', '=', owner).where('catalog_id', '=', catalogID).execute();
|
|
32
32
|
},
|
|
33
33
|
async allCatalogOffers () {
|
|
34
|
-
return (await db.selectFrom('
|
|
34
|
+
return (await db.selectFrom('p2p_catalog_offers').selectAll().execute()).map(project);
|
|
35
35
|
},
|
|
36
36
|
async replaceCatalogOffers (groupID, offers) {
|
|
37
37
|
if (offers.some((offer)=>offer.groupID !== groupID)) throw new Error('Catalog offer group does not match replacement scope');
|
|
38
38
|
const replace = async (tx)=>{
|
|
39
|
-
await tx.deleteFrom('
|
|
39
|
+
await tx.deleteFrom('p2p_catalog_offers').where('group_id', '=', groupID).execute();
|
|
40
40
|
if (offers.length === 0) return;
|
|
41
|
-
await tx.insertInto('
|
|
41
|
+
await tx.insertInto('p2p_catalog_offers').values(offers.map((offer)=>({
|
|
42
42
|
owner: offer.owner,
|
|
43
43
|
exposure_id: offer.exposureID,
|
|
44
44
|
catalog_id: offer.catalogID,
|
|
@@ -46,11 +46,10 @@ export function createCatalogOfferOps(db) {
|
|
|
46
46
|
circle_id: offer.circleID
|
|
47
47
|
}))).execute();
|
|
48
48
|
};
|
|
49
|
-
|
|
50
|
-
else await db.transaction().execute(replace);
|
|
49
|
+
await withStoreTransaction(db, replace);
|
|
51
50
|
},
|
|
52
51
|
async activeCatalogOffers (groupID, circleID) {
|
|
53
|
-
let query = db.selectFrom('
|
|
52
|
+
let query = db.selectFrom('p2p_catalog_offers').selectAll().where('group_id', '=', groupID);
|
|
54
53
|
if (circleID !== null) query = query.where('circle_id', '=', circleID);
|
|
55
54
|
const rows = await query.orderBy('owner').orderBy('exposure_id').execute();
|
|
56
55
|
return rows.map(project);
|
|
@@ -9,10 +9,10 @@ export function createCatalogRouteOps(db) {
|
|
|
9
9
|
return {
|
|
10
10
|
async setCatalogRoute (owner, catalogID, groupID, pull, push) {
|
|
11
11
|
if (!pull && !push) {
|
|
12
|
-
await db.deleteFrom('
|
|
12
|
+
await db.deleteFrom('p2p_catalog_routes').where('owner', '=', owner).where('catalog_id', '=', catalogID).where('group_id', '=', groupID).execute();
|
|
13
13
|
return;
|
|
14
14
|
}
|
|
15
|
-
await db.insertInto('
|
|
15
|
+
await db.insertInto('p2p_catalog_routes').values({
|
|
16
16
|
owner,
|
|
17
17
|
catalog_id: catalogID,
|
|
18
18
|
group_id: groupID,
|
|
@@ -28,33 +28,33 @@ export function createCatalogRouteOps(db) {
|
|
|
28
28
|
})).execute();
|
|
29
29
|
},
|
|
30
30
|
async getGroupServing ({ groupID }) {
|
|
31
|
-
const row = await db.selectFrom('
|
|
31
|
+
const row = await db.selectFrom('p2p_unserved_groups').select('group_id').where('group_id', '=', groupID).executeTakeFirst();
|
|
32
32
|
return row == null;
|
|
33
33
|
},
|
|
34
34
|
async setGroupServing ({ groupID, enabled }) {
|
|
35
35
|
if (enabled) {
|
|
36
|
-
await db.deleteFrom('
|
|
36
|
+
await db.deleteFrom('p2p_unserved_groups').where('group_id', '=', groupID).execute();
|
|
37
37
|
return;
|
|
38
38
|
}
|
|
39
|
-
await db.insertInto('
|
|
39
|
+
await db.insertInto('p2p_unserved_groups').values({
|
|
40
40
|
group_id: groupID
|
|
41
41
|
}).onConflict((oc)=>oc.column('group_id').doNothing()).execute();
|
|
42
42
|
},
|
|
43
43
|
async catalogRoutes (owner, catalogID) {
|
|
44
|
-
const rows = await db.selectFrom('
|
|
44
|
+
const rows = await db.selectFrom('p2p_catalog_routes').selectAll().where('owner', '=', owner).where('catalog_id', '=', catalogID).orderBy('group_id').execute();
|
|
45
45
|
return rows.map(project);
|
|
46
46
|
},
|
|
47
47
|
async allCatalogRoutes (groupID) {
|
|
48
|
-
let query = db.selectFrom('
|
|
48
|
+
let query = db.selectFrom('p2p_catalog_routes').selectAll();
|
|
49
49
|
if (groupID != null) query = query.where('group_id', '=', groupID);
|
|
50
50
|
const rows = await query.orderBy('owner').orderBy('catalog_id').orderBy('group_id').execute();
|
|
51
51
|
return rows.map(project);
|
|
52
52
|
},
|
|
53
53
|
async deleteCatalogRoutes (owner, catalogID) {
|
|
54
|
-
await db.deleteFrom('
|
|
54
|
+
await db.deleteFrom('p2p_catalog_routes').where('owner', '=', owner).where('catalog_id', '=', catalogID).execute();
|
|
55
55
|
},
|
|
56
56
|
async deleteGroupCatalogRoutes (groupID) {
|
|
57
|
-
await db.deleteFrom('
|
|
57
|
+
await db.deleteFrom('p2p_catalog_routes').where('group_id', '=', groupID).execute();
|
|
58
58
|
}
|
|
59
59
|
};
|
|
60
60
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { StoreAPIContext } from '@kubun/db';
|
|
1
2
|
import { type Adapter } from '@kubun/db-adapter';
|
|
2
3
|
import type { Kysely } from 'kysely';
|
|
3
4
|
import type { P2PStoreTables } from '../tables.js';
|
|
@@ -119,4 +120,4 @@ export type CatalogSyncOps = {
|
|
|
119
120
|
limit: number;
|
|
120
121
|
}): Promise<Array<string>>;
|
|
121
122
|
};
|
|
122
|
-
export declare function createCatalogSyncOps(db: Kysely<P2PStoreTables>, adapter: Adapter): CatalogSyncOps;
|
|
123
|
+
export declare function createCatalogSyncOps(db: Kysely<P2PStoreTables>, adapter: Adapter, context: StoreAPIContext): CatalogSyncOps;
|
package/lib/ops/catalog-sync.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { withStoreTransaction } from '@kubun/db';
|
|
2
|
+
import { decodeJSON } from '@kubun/db-adapter';
|
|
2
3
|
import { sql } from 'kysely';
|
|
4
|
+
import { withP2PKeyLocks } from '../scope.js';
|
|
3
5
|
/** Listed repair rows one peer may account for in one group; the oldest go first. */ export const MAX_REPAIRS_PER_LISTER = 10_000;
|
|
4
6
|
function scheduleFromRow(row, triedPeers) {
|
|
5
7
|
return {
|
|
@@ -10,14 +12,11 @@ function scheduleFromRow(row, triedPeers) {
|
|
|
10
12
|
triedPeers
|
|
11
13
|
};
|
|
12
14
|
}
|
|
13
|
-
|
|
14
|
-
return typeof value === 'string' ? JSON.parse(value) : value;
|
|
15
|
-
}
|
|
16
|
-
export function createCatalogSyncOps(db, adapter) {
|
|
15
|
+
export function createCatalogSyncOps(db, adapter, context) {
|
|
17
16
|
const triedPeersOf = async (documentIDs, from = db)=>{
|
|
18
17
|
const tried = new Map();
|
|
19
18
|
if (documentIDs.length === 0) return tried;
|
|
20
|
-
const rows = await from.selectFrom('
|
|
19
|
+
const rows = await from.selectFrom('p2p_observation_repair_peers').selectAll().where('document_id', 'in', [
|
|
21
20
|
...documentIDs
|
|
22
21
|
]).orderBy('peer_did').execute();
|
|
23
22
|
for (const row of rows){
|
|
@@ -30,7 +29,7 @@ export function createCatalogSyncOps(db, adapter) {
|
|
|
30
29
|
const readSchedules = async (from, documentIDs)=>{
|
|
31
30
|
const schedules = new Map();
|
|
32
31
|
if (documentIDs.length === 0) return schedules;
|
|
33
|
-
const rows = await from.selectFrom('
|
|
32
|
+
const rows = await from.selectFrom('p2p_observation_repairs').selectAll().where('document_id', 'in', [
|
|
34
33
|
...documentIDs
|
|
35
34
|
]).execute();
|
|
36
35
|
const tried = await triedPeersOf(rows.map((row)=>row.document_id), from);
|
|
@@ -49,29 +48,28 @@ export function createCatalogSyncOps(db, adapter) {
|
|
|
49
48
|
next_attempt_at: schedule.nextAttemptAt,
|
|
50
49
|
tried_count: triedPeers.length
|
|
51
50
|
};
|
|
52
|
-
await tx.insertInto('
|
|
51
|
+
await tx.insertInto('p2p_observation_repairs').values({
|
|
53
52
|
document_id: schedule.documentID,
|
|
54
53
|
...values
|
|
55
54
|
}).onConflict((oc)=>oc.column('document_id').doUpdateSet(values)).execute();
|
|
56
|
-
await tx.deleteFrom('
|
|
55
|
+
await tx.deleteFrom('p2p_observation_repair_peers').where('document_id', '=', schedule.documentID).execute();
|
|
57
56
|
if (triedPeers.length > 0) {
|
|
58
|
-
await tx.insertInto('
|
|
57
|
+
await tx.insertInto('p2p_observation_repair_peers').values(triedPeers.map((peerDID)=>({
|
|
59
58
|
document_id: schedule.documentID,
|
|
60
59
|
peer_did: peerDID
|
|
61
60
|
}))).execute();
|
|
62
61
|
}
|
|
63
62
|
};
|
|
64
|
-
const inTransaction = async (fn)=>db.isTransaction ? await fn(db) : await db.transaction().execute(fn);
|
|
65
63
|
const ops = {
|
|
66
64
|
async getCatalogCursor (peerDID, groupID) {
|
|
67
|
-
const row = await db.selectFrom('
|
|
65
|
+
const row = await db.selectFrom('p2p_catalog_cursors').selectAll().where('peer_did', '=', peerDID).where('group_id', '=', groupID).executeTakeFirst();
|
|
68
66
|
if (row == null) return null;
|
|
69
67
|
return {
|
|
70
68
|
rulesHash: row.rules_hash,
|
|
71
69
|
cursor: row.last_key ?? null,
|
|
72
70
|
token: row.last_token ?? null,
|
|
73
71
|
cycleClear: Number(row.cycle_clear) === 1,
|
|
74
|
-
bounds:
|
|
72
|
+
bounds: decodeJSON(adapter.kind, row.bounds)
|
|
75
73
|
};
|
|
76
74
|
},
|
|
77
75
|
async setCatalogCursor (peerDID, groupID, state) {
|
|
@@ -80,9 +78,9 @@ export function createCatalogSyncOps(db, adapter) {
|
|
|
80
78
|
last_key: state.cursor,
|
|
81
79
|
last_token: state.token,
|
|
82
80
|
cycle_clear: state.cycleClear ? 1 : 0,
|
|
83
|
-
bounds:
|
|
81
|
+
bounds: adapter.encodeJSON(state.bounds)
|
|
84
82
|
};
|
|
85
|
-
await db.insertInto('
|
|
83
|
+
await db.insertInto('p2p_catalog_cursors').values({
|
|
86
84
|
peer_did: peerDID,
|
|
87
85
|
group_id: groupID,
|
|
88
86
|
...values
|
|
@@ -98,8 +96,8 @@ export function createCatalogSyncOps(db, adapter) {
|
|
|
98
96
|
...documentIDs
|
|
99
97
|
];
|
|
100
98
|
const [found, answers] = await Promise.all([
|
|
101
|
-
db.selectFrom('
|
|
102
|
-
db.selectFrom('
|
|
99
|
+
db.selectFrom('p2p_document_repairs').selectAll().where('group_id', '=', groupID).where('document_id', 'in', ids).execute(),
|
|
100
|
+
db.selectFrom('p2p_document_repair_answers').selectAll().where('group_id', '=', groupID).where('document_id', 'in', ids).execute()
|
|
103
101
|
]);
|
|
104
102
|
for (const row of found){
|
|
105
103
|
rows.set(row.document_id, {
|
|
@@ -123,22 +121,22 @@ export function createCatalogSyncOps(db, adapter) {
|
|
|
123
121
|
const listedBy = existing?.listedBy ?? (params.listed ? params.peerDID : null);
|
|
124
122
|
if (params.listed && existing?.listedBy == null) {
|
|
125
123
|
// A peer that lists more than its share pushes out its own oldest rows.
|
|
126
|
-
const counted = await db.selectFrom('
|
|
124
|
+
const counted = await db.selectFrom('p2p_document_repairs').select((eb)=>eb.fn.countAll().as('count')).where('group_id', '=', groupID).where('listed_by', '=', params.peerDID).executeTakeFirst();
|
|
127
125
|
const excess = Number(counted?.count ?? 0) + 1 - MAX_REPAIRS_PER_LISTER;
|
|
128
126
|
if (excess > 0) {
|
|
129
|
-
const oldest = await db.selectFrom('
|
|
127
|
+
const oldest = await db.selectFrom('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
128
|
await ops.resolveDocumentRepairs(groupID, oldest.map((row)=>row.document_id));
|
|
131
129
|
}
|
|
132
130
|
}
|
|
133
131
|
if (existing != null && existing.holdingsDigest !== params.holdingsDigest) {
|
|
134
|
-
await db.deleteFrom('
|
|
132
|
+
await db.deleteFrom('p2p_document_repair_answers').where('group_id', '=', groupID).where('document_id', '=', documentID).execute();
|
|
135
133
|
}
|
|
136
134
|
const values = {
|
|
137
135
|
listed_by: listedBy,
|
|
138
136
|
holdings_digest: params.holdingsDigest,
|
|
139
137
|
attempted_at: params.attemptedAt
|
|
140
138
|
};
|
|
141
|
-
await db.insertInto('
|
|
139
|
+
await db.insertInto('p2p_document_repairs').values({
|
|
142
140
|
group_id: groupID,
|
|
143
141
|
document_id: documentID,
|
|
144
142
|
created_at: existing?.createdAt ?? params.attemptedAt,
|
|
@@ -148,12 +146,12 @@ export function createCatalogSyncOps(db, adapter) {
|
|
|
148
146
|
'document_id'
|
|
149
147
|
]).doUpdateSet({
|
|
150
148
|
...values,
|
|
151
|
-
listed_by: sql`coalesce(
|
|
149
|
+
listed_by: sql`coalesce(${sql.ref('p2p_document_repairs.listed_by')}, ${listedBy})`
|
|
152
150
|
})).execute();
|
|
153
151
|
if (params.answer == null) {
|
|
154
|
-
await db.deleteFrom('
|
|
152
|
+
await db.deleteFrom('p2p_document_repair_answers').where('group_id', '=', groupID).where('document_id', '=', documentID).where('peer_did', '=', params.peerDID).execute();
|
|
155
153
|
} else {
|
|
156
|
-
await db.insertInto('
|
|
154
|
+
await db.insertInto('p2p_document_repair_answers').values({
|
|
157
155
|
group_id: groupID,
|
|
158
156
|
document_id: documentID,
|
|
159
157
|
peer_did: params.peerDID,
|
|
@@ -170,8 +168,8 @@ export function createCatalogSyncOps(db, adapter) {
|
|
|
170
168
|
async resolveDocumentRepairs (groupID, documentIDs) {
|
|
171
169
|
if (documentIDs.length === 0) return;
|
|
172
170
|
for (const table of [
|
|
173
|
-
'
|
|
174
|
-
'
|
|
171
|
+
'p2p_document_repair_answers',
|
|
172
|
+
'p2p_document_repairs'
|
|
175
173
|
]){
|
|
176
174
|
await db.deleteFrom(table).where('group_id', '=', groupID).where('document_id', 'in', [
|
|
177
175
|
...documentIDs
|
|
@@ -179,22 +177,22 @@ export function createCatalogSyncOps(db, adapter) {
|
|
|
179
177
|
}
|
|
180
178
|
},
|
|
181
179
|
async listDocumentRepairs (groupID, peerDID, limit) {
|
|
182
|
-
const rows = await db.selectFrom('
|
|
180
|
+
const rows = await db.selectFrom('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('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
181
|
return rows.map((row)=>row.document_id);
|
|
184
182
|
},
|
|
185
183
|
async pruneDocumentRepairs (groupID, createdBefore) {
|
|
186
|
-
await db.deleteFrom('
|
|
187
|
-
await db.deleteFrom('
|
|
184
|
+
await db.deleteFrom('p2p_document_repair_answers').where('group_id', '=', groupID).where('document_id', 'in', (eb)=>eb.selectFrom('p2p_document_repairs').select('document_id').where('group_id', '=', groupID).where('created_at', '<', createdBefore)).execute();
|
|
185
|
+
await db.deleteFrom('p2p_document_repairs').where('group_id', '=', groupID).where('created_at', '<', createdBefore).execute();
|
|
188
186
|
},
|
|
189
187
|
async getObservationRepairSchedules (documentIDs) {
|
|
190
188
|
return await readSchedules(db, documentIDs);
|
|
191
189
|
},
|
|
192
190
|
async listDueObservationRepairSchedules (query) {
|
|
193
|
-
let select = db.selectFrom('
|
|
191
|
+
let select = db.selectFrom('p2p_observation_repairs as r').selectAll('r').where((eb)=>eb.or([
|
|
194
192
|
eb('r.next_attempt_at', '<=', query.now),
|
|
195
193
|
eb.and([
|
|
196
194
|
eb('r.tried_count', '<', query.maxTriedPeers),
|
|
197
|
-
eb.not(eb.exists(eb.selectFrom('
|
|
195
|
+
eb.not(eb.exists(eb.selectFrom('p2p_observation_repair_peers as p').select('p.peer_did').whereRef('p.document_id', '=', 'r.document_id').where('p.peer_did', '=', query.peerDID)))
|
|
198
196
|
])
|
|
199
197
|
]));
|
|
200
198
|
const after = query.after;
|
|
@@ -212,15 +210,14 @@ export function createCatalogSyncOps(db, adapter) {
|
|
|
212
210
|
return rows.map((row)=>scheduleFromRow(row, tried.get(row.document_id) ?? []));
|
|
213
211
|
},
|
|
214
212
|
async setObservationRepairSchedule (schedule) {
|
|
215
|
-
await
|
|
213
|
+
await withStoreTransaction(db, async (tx)=>await writeSchedule(tx, schedule));
|
|
216
214
|
},
|
|
217
215
|
async updateObservationRepairSchedule (documentID, update) {
|
|
218
|
-
return await
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
}
|
|
216
|
+
return await withP2PKeyLocks(context, 'p2p_observation_repairs', [
|
|
217
|
+
[
|
|
218
|
+
documentID
|
|
219
|
+
]
|
|
220
|
+
], async (tx)=>{
|
|
224
221
|
const current = (await readSchedules(tx, [
|
|
225
222
|
documentID
|
|
226
223
|
])).get(documentID);
|
|
@@ -231,17 +228,17 @@ export function createCatalogSyncOps(db, adapter) {
|
|
|
231
228
|
});
|
|
232
229
|
},
|
|
233
230
|
async listObservationRepairScheduleIDs ({ after, limit }) {
|
|
234
|
-
let query = db.selectFrom('
|
|
231
|
+
let query = db.selectFrom('p2p_observation_repairs').select('document_id').orderBy('document_id').limit(limit);
|
|
235
232
|
if (after !== null) query = query.where('document_id', '>', after);
|
|
236
233
|
return (await query.execute()).map((row)=>row.document_id);
|
|
237
234
|
},
|
|
238
235
|
async deleteObservationRepairSchedules (documentIDs) {
|
|
239
236
|
if (documentIDs.length === 0) return;
|
|
240
|
-
await
|
|
241
|
-
await tx.deleteFrom('
|
|
237
|
+
await withStoreTransaction(db, async (tx)=>{
|
|
238
|
+
await tx.deleteFrom('p2p_observation_repair_peers').where('document_id', 'in', [
|
|
242
239
|
...documentIDs
|
|
243
240
|
]).execute();
|
|
244
|
-
await tx.deleteFrom('
|
|
241
|
+
await tx.deleteFrom('p2p_observation_repairs').where('document_id', 'in', [
|
|
245
242
|
...documentIDs
|
|
246
243
|
]).execute();
|
|
247
244
|
});
|