@kubun/store-p2p 0.11.0 → 0.12.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 +25 -251
- package/lib/api.js +19 -590
- package/lib/index.d.ts +2 -2
- package/lib/migrations.d.ts +21 -0
- package/lib/migrations.js +106 -26
- package/lib/ops/app-lane.d.ts +36 -0
- package/lib/ops/app-lane.js +38 -0
- package/lib/ops/circles.d.ts +153 -0
- package/lib/ops/circles.js +269 -0
- package/lib/ops/control.d.ts +118 -0
- package/lib/ops/control.js +110 -0
- package/lib/ops/delegations.d.ts +25 -0
- package/lib/ops/delegations.js +27 -0
- package/lib/ops/discovery.d.ts +92 -0
- package/lib/ops/discovery.js +90 -0
- package/lib/ops/groups.d.ts +132 -0
- package/lib/ops/groups.js +259 -0
- package/lib/ops/hubs.d.ts +56 -0
- package/lib/ops/hubs.js +222 -0
- package/lib/ops/mls.d.ts +21 -0
- package/lib/ops/mls.js +50 -0
- package/lib/ops/sync.d.ts +22 -0
- package/lib/ops/sync.js +50 -0
- package/lib/tables.d.ts +106 -26
- package/package.json +18 -18
package/lib/ops/hubs.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { sql } from 'kysely';
|
|
2
|
+
import { COMMIT_HUB_UNIQUE_INDEX } from '../migrations.js';
|
|
3
|
+
/**
|
|
4
|
+
* Run `write` as a single atomic unit. The store API is built on whatever Kysely
|
|
5
|
+
* instance the provider hands it, which is a transaction-scoped one whenever the
|
|
6
|
+
* caller runs inside an ambient write transaction. Kysely rejects a nested
|
|
7
|
+
* `transaction()` call, and the ambient transaction already provides the
|
|
8
|
+
* atomicity, so in that case the statements run inline; otherwise a transaction
|
|
9
|
+
* is opened around them.
|
|
10
|
+
*
|
|
11
|
+
* Multi-statement invariants (clear-then-set, read-then-insert) must go through
|
|
12
|
+
* this: a crash between two independent statements can leave a group with no
|
|
13
|
+
* commit hub at all, and a device with no commit hub cannot commit.
|
|
14
|
+
*/ async function writeAtomically(db, write) {
|
|
15
|
+
if (db.isTransaction) {
|
|
16
|
+
await write(db);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
await db.transaction().execute(async (tx)=>{
|
|
20
|
+
await write(tx);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Run `run` so that a failed statement inside it can be caught and recovered
|
|
25
|
+
* from. Postgres aborts the WHOLE transaction on any statement error, so a
|
|
26
|
+
* caller inside an ambient transaction that catches one and carries on would
|
|
27
|
+
* otherwise hit `25P02` on everything after it. A savepoint is the portable
|
|
28
|
+
* fence; outside a transaction there is nothing to fence, because the failed
|
|
29
|
+
* unit already rolled itself back.
|
|
30
|
+
*/ async function recoverable(db, run) {
|
|
31
|
+
if (!db.isTransaction) {
|
|
32
|
+
return await run();
|
|
33
|
+
}
|
|
34
|
+
await sql`SAVEPOINT kubun_p2p_recoverable`.execute(db);
|
|
35
|
+
try {
|
|
36
|
+
const result = await run();
|
|
37
|
+
await sql`RELEASE SAVEPOINT kubun_p2p_recoverable`.execute(db);
|
|
38
|
+
return result;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
await sql`ROLLBACK TO SAVEPOINT kubun_p2p_recoverable`.execute(db);
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Whether `error` is the partial unique index refusing a second commit-hub
|
|
46
|
+
* designation for one group. Matched on the index name, which both drivers put
|
|
47
|
+
* in the message (Postgres also in `constraint_name`) — the error classes and
|
|
48
|
+
* codes themselves have nothing in common across the two.
|
|
49
|
+
*/ function isCommitHubRaceLoss(error) {
|
|
50
|
+
if (error == null || typeof error !== 'object') {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
const { message, constraint_name: constraint } = error;
|
|
54
|
+
return typeof message === 'string' && message.includes(COMMIT_HUB_UNIQUE_INDEX) || constraint === COMMIT_HUB_UNIQUE_INDEX;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Bind `binding` to its group, claiming the commit-hub designation when
|
|
58
|
+
* `claimDesignation` is set and the group carries none yet. Returns whether a
|
|
59
|
+
* row was actually inserted.
|
|
60
|
+
*/ async function bindGroupHub(db, binding, claimDesignation) {
|
|
61
|
+
let inserted = false;
|
|
62
|
+
await writeAtomically(db, async (tx)=>{
|
|
63
|
+
let designate = false;
|
|
64
|
+
if (claimDesignation) {
|
|
65
|
+
const existing = await tx.selectFrom('kubun_p2p_group_hubs').select('hub_id').where('group_id', '=', binding.group_id).where('is_commit_hub', '=', 1).executeTakeFirst();
|
|
66
|
+
designate = existing == null;
|
|
67
|
+
}
|
|
68
|
+
const result = await tx.insertInto('kubun_p2p_group_hubs').values({
|
|
69
|
+
group_id: binding.group_id,
|
|
70
|
+
hub_id: binding.hub_id,
|
|
71
|
+
is_commit_hub: designate ? 1 : 0
|
|
72
|
+
}).onConflict((oc)=>oc.columns([
|
|
73
|
+
'group_id',
|
|
74
|
+
'hub_id'
|
|
75
|
+
]).doNothing()).executeTakeFirst();
|
|
76
|
+
inserted = (result.numInsertedOrUpdatedRows ?? 0n) > 0n;
|
|
77
|
+
});
|
|
78
|
+
return inserted;
|
|
79
|
+
}
|
|
80
|
+
export function createHubOps(db, adapter) {
|
|
81
|
+
return {
|
|
82
|
+
async insertHub (hub) {
|
|
83
|
+
await db.insertInto('kubun_p2p_hubs').values({
|
|
84
|
+
id: hub.id,
|
|
85
|
+
label: hub.label,
|
|
86
|
+
url: hub.url,
|
|
87
|
+
server_did: hub.server_did
|
|
88
|
+
}).execute();
|
|
89
|
+
},
|
|
90
|
+
async getHub (id) {
|
|
91
|
+
return await db.selectFrom('kubun_p2p_hubs').selectAll().where('id', '=', id).executeTakeFirst();
|
|
92
|
+
},
|
|
93
|
+
async getHubByURL (url) {
|
|
94
|
+
return await db.selectFrom('kubun_p2p_hubs').selectAll().where('url', '=', url).executeTakeFirst();
|
|
95
|
+
},
|
|
96
|
+
async updateHub (id, update) {
|
|
97
|
+
const values = {};
|
|
98
|
+
if (update.label !== undefined) values.label = update.label;
|
|
99
|
+
if (update.url !== undefined) values.url = update.url;
|
|
100
|
+
if (update.server_did !== undefined) values.server_did = update.server_did;
|
|
101
|
+
values.updated_at = adapter.encodeTimestamp(new Date());
|
|
102
|
+
await db.updateTable('kubun_p2p_hubs').set(values).where('id', '=', id).execute();
|
|
103
|
+
},
|
|
104
|
+
async deleteHub (id) {
|
|
105
|
+
await db.deleteFrom('kubun_p2p_hubs').where('id', '=', id).execute();
|
|
106
|
+
},
|
|
107
|
+
async listHubs () {
|
|
108
|
+
return await db.selectFrom('kubun_p2p_hubs').selectAll().execute();
|
|
109
|
+
},
|
|
110
|
+
// --- Group-hub binding operations ---
|
|
111
|
+
async insertGroupHub (binding) {
|
|
112
|
+
// The first hub bound to a group becomes its canonical commit hub: a group
|
|
113
|
+
// commits through exactly one hub, and with none yet designated this bind
|
|
114
|
+
// is the deterministic choice. Later binds default to 0, leaving the
|
|
115
|
+
// designation on the first-bound hub until an explicit `setCommitHub`.
|
|
116
|
+
//
|
|
117
|
+
// Running the read and the insert as one unit does NOT settle a race: two
|
|
118
|
+
// concurrent first binds touch different rows, so neither blocks the other
|
|
119
|
+
// and both read "no commit hub". The partial unique index is what arbitrates,
|
|
120
|
+
// by rejecting the second claim — so a rejection here means another bind
|
|
121
|
+
// won the designation, and this one adopts the winner and binds unflagged.
|
|
122
|
+
// Erroring instead would leave the loser unbound over a designation it
|
|
123
|
+
// never needed to own.
|
|
124
|
+
try {
|
|
125
|
+
return await recoverable(db, ()=>bindGroupHub(db, binding, true));
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (!isCommitHubRaceLoss(error)) {
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
return await bindGroupHub(db, binding, false);
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
async deleteGroupHub (params) {
|
|
134
|
+
const result = await db.deleteFrom('kubun_p2p_group_hubs').where('group_id', '=', params.groupID).where('hub_id', '=', params.hubID).executeTakeFirst();
|
|
135
|
+
return (result.numDeletedRows ?? 0n) > 0n;
|
|
136
|
+
},
|
|
137
|
+
async listGroupIDsByHubID (hubID) {
|
|
138
|
+
const rows = await db.selectFrom('kubun_p2p_group_hubs').select('group_id').where('hub_id', '=', hubID).execute();
|
|
139
|
+
return rows.map((r)=>r.group_id);
|
|
140
|
+
},
|
|
141
|
+
async listHubsByGroupID (groupID) {
|
|
142
|
+
return await db.selectFrom('kubun_p2p_group_hubs').innerJoin('kubun_p2p_hubs', 'kubun_p2p_hubs.id', 'kubun_p2p_group_hubs.hub_id').selectAll('kubun_p2p_hubs').where('kubun_p2p_group_hubs.group_id', '=', groupID).execute();
|
|
143
|
+
},
|
|
144
|
+
async listGroupsByHubID (hubID) {
|
|
145
|
+
const rows = await db.selectFrom('kubun_p2p_group_hubs').innerJoin('kubun_p2p_groups', 'kubun_p2p_groups.id', 'kubun_p2p_group_hubs.group_id').select([
|
|
146
|
+
'kubun_p2p_groups.id as id',
|
|
147
|
+
'kubun_p2p_groups.name as name',
|
|
148
|
+
'kubun_p2p_groups.description as description',
|
|
149
|
+
'kubun_p2p_groups.created_by as created_by',
|
|
150
|
+
'kubun_p2p_groups.status as status',
|
|
151
|
+
'kubun_p2p_groups.hlc as hlc',
|
|
152
|
+
'kubun_p2p_groups.created_at as created_at',
|
|
153
|
+
'kubun_p2p_groups.updated_at as updated_at'
|
|
154
|
+
]).where('kubun_p2p_group_hubs.hub_id', '=', hubID).execute();
|
|
155
|
+
return rows;
|
|
156
|
+
},
|
|
157
|
+
async deleteGroupHubsByHubID (hubID) {
|
|
158
|
+
await db.deleteFrom('kubun_p2p_group_hubs').where('hub_id', '=', hubID).execute();
|
|
159
|
+
},
|
|
160
|
+
async getCommitHub (groupID) {
|
|
161
|
+
return await db.selectFrom('kubun_p2p_group_hubs').innerJoin('kubun_p2p_hubs', 'kubun_p2p_hubs.id', 'kubun_p2p_group_hubs.hub_id').selectAll('kubun_p2p_hubs').where('kubun_p2p_group_hubs.group_id', '=', groupID).where('kubun_p2p_group_hubs.is_commit_hub', '=', 1).executeTakeFirst();
|
|
162
|
+
},
|
|
163
|
+
async setCommitHub (params) {
|
|
164
|
+
// Clear then set so at most one bound hub per group carries the flag — the
|
|
165
|
+
// single-designation invariant a fork-free commit log depends on. Both
|
|
166
|
+
// statements run as one unit: a crash after the clear would leave the group
|
|
167
|
+
// with no commit hub, and a device with none cannot commit.
|
|
168
|
+
await writeAtomically(db, async (tx)=>{
|
|
169
|
+
// The hub must already be bound. Without this check an unbound hubID
|
|
170
|
+
// clears every flag and sets none — silently leaving the group unable to
|
|
171
|
+
// commit, which reads as a hang rather than an error.
|
|
172
|
+
const bound = await tx.selectFrom('kubun_p2p_group_hubs').select('hub_id').where('group_id', '=', params.groupID).where('hub_id', '=', params.hubID).executeTakeFirst();
|
|
173
|
+
if (bound == null) {
|
|
174
|
+
throw new Error(`Cannot designate commit hub ${params.hubID} for group ${params.groupID}: the hub is not bound to the group`);
|
|
175
|
+
}
|
|
176
|
+
await tx.updateTable('kubun_p2p_group_hubs').set({
|
|
177
|
+
is_commit_hub: 0
|
|
178
|
+
}).where('group_id', '=', params.groupID).execute();
|
|
179
|
+
await tx.updateTable('kubun_p2p_group_hubs').set({
|
|
180
|
+
is_commit_hub: 1
|
|
181
|
+
}).where('group_id', '=', params.groupID).where('hub_id', '=', params.hubID).execute();
|
|
182
|
+
});
|
|
183
|
+
},
|
|
184
|
+
async reconcileCommitHub (groupID, commitHub) {
|
|
185
|
+
// No folded designation: leave the local auto-designated flag untouched.
|
|
186
|
+
if (commitHub == null) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
// Match and reflag as one unit: a crash between the clear and the set
|
|
190
|
+
// would leave the group with no commit hub at all.
|
|
191
|
+
await writeAtomically(db, async (tx)=>{
|
|
192
|
+
// The bound hub whose URL matches the designation, if this device has
|
|
193
|
+
// bound it. The join is what makes "bound to THIS group with that URL"
|
|
194
|
+
// the match, rather than any hub row that happens to carry the URL. When
|
|
195
|
+
// both sides carry a server DID it must match too: the same URL served
|
|
196
|
+
// by a different server DID is a different hub identity, and flagging it
|
|
197
|
+
// would designate a peer the group never chose.
|
|
198
|
+
let matchQuery = tx.selectFrom('kubun_p2p_group_hubs').innerJoin('kubun_p2p_hubs', 'kubun_p2p_hubs.id', 'kubun_p2p_group_hubs.hub_id').select('kubun_p2p_group_hubs.hub_id as hub_id').where('kubun_p2p_group_hubs.group_id', '=', groupID).where('kubun_p2p_hubs.url', '=', commitHub.url);
|
|
199
|
+
if (commitHub.serverDID != null) {
|
|
200
|
+
// A bound row with no recorded server DID stays eligible — it has not
|
|
201
|
+
// claimed a conflicting identity, only an unknown one.
|
|
202
|
+
matchQuery = matchQuery.where((eb)=>eb.or([
|
|
203
|
+
eb('kubun_p2p_hubs.server_did', '=', commitHub.serverDID),
|
|
204
|
+
eb('kubun_p2p_hubs.server_did', 'is', null)
|
|
205
|
+
]));
|
|
206
|
+
}
|
|
207
|
+
const match = await matchQuery.executeTakeFirst();
|
|
208
|
+
// Clear every row first so at most one carries the flag — and so a
|
|
209
|
+
// designated-but-unbound hub leaves the device with NO flagged row rather
|
|
210
|
+
// than a stale non-designated one (that fallback is the fork).
|
|
211
|
+
await tx.updateTable('kubun_p2p_group_hubs').set({
|
|
212
|
+
is_commit_hub: 0
|
|
213
|
+
}).where('group_id', '=', groupID).execute();
|
|
214
|
+
if (match != null) {
|
|
215
|
+
await tx.updateTable('kubun_p2p_group_hubs').set({
|
|
216
|
+
is_commit_hub: 1
|
|
217
|
+
}).where('group_id', '=', groupID).where('hub_id', '=', match.hub_id).execute();
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
}
|
package/lib/ops/mls.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Adapter } from '@kubun/db-adapter';
|
|
2
|
+
import type { Kysely } from 'kysely';
|
|
3
|
+
import type { GroupMLSState, InsertGroupMLSState, P2PStoreTables } from '../tables.js';
|
|
4
|
+
/** Per-device MLS state for each group. */
|
|
5
|
+
export type MLSOps = {
|
|
6
|
+
saveMLSState(state: InsertGroupMLSState): Promise<void>;
|
|
7
|
+
getMLSState(groupID: string, deviceID: string): Promise<GroupMLSState | undefined>;
|
|
8
|
+
deleteMLSState(groupID: string, deviceID: string): Promise<void>;
|
|
9
|
+
listMLSStates(groupID: string): Promise<Array<GroupMLSState>>;
|
|
10
|
+
/**
|
|
11
|
+
* The groups this device holds MLS state for, keyed on the same `(group_id,
|
|
12
|
+
* device_id)` pair {@link getMLSState} reads.
|
|
13
|
+
*
|
|
14
|
+
* **No production caller — a test observer.** It once answered "have I joined
|
|
15
|
+
* this group", which it cannot: a removed device still holds state. Kept for
|
|
16
|
+
* asserting that a departure left nothing behind.
|
|
17
|
+
*/
|
|
18
|
+
listMLSStateGroupIDs(deviceID: string): Promise<Array<string>>;
|
|
19
|
+
deleteGroupMLSStates(groupID: string): Promise<void>;
|
|
20
|
+
};
|
|
21
|
+
export declare function createMLSOps(db: Kysely<P2PStoreTables>, adapter: Adapter): MLSOps;
|
package/lib/ops/mls.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export function createMLSOps(db, adapter) {
|
|
2
|
+
return {
|
|
3
|
+
async saveMLSState (state) {
|
|
4
|
+
await db.insertInto('kubun_p2p_group_mls_state').values({
|
|
5
|
+
group_id: state.group_id,
|
|
6
|
+
device_id: state.device_id,
|
|
7
|
+
mls_state: adapter.encodeBinary(state.mls_state),
|
|
8
|
+
credential: state.credential,
|
|
9
|
+
epoch: state.epoch,
|
|
10
|
+
ledger_entries: state.ledger_entries
|
|
11
|
+
}).onConflict((oc)=>oc.columns([
|
|
12
|
+
'group_id',
|
|
13
|
+
'device_id'
|
|
14
|
+
]).doUpdateSet((eb)=>({
|
|
15
|
+
mls_state: eb.ref('excluded.mls_state'),
|
|
16
|
+
credential: eb.ref('excluded.credential'),
|
|
17
|
+
epoch: eb.ref('excluded.epoch'),
|
|
18
|
+
ledger_entries: eb.ref('excluded.ledger_entries'),
|
|
19
|
+
updated_at: adapter.encodeTimestamp(new Date())
|
|
20
|
+
}))// Monotonic-epoch guard: only overwrite when the incoming epoch is
|
|
21
|
+
// at least the stored one. The MLS epoch never moves backwards, so a
|
|
22
|
+
// write carrying a lower epoch is a stale handle re-persisting an
|
|
23
|
+
// already-superseded ratchet — apply it and we roll the ratchet back.
|
|
24
|
+
// When this predicate is false the existing row is left untouched
|
|
25
|
+
// with no error (a silent no-op); a fresh insert has no conflict and
|
|
26
|
+
// is unaffected. The bound is `<=`, not `<`: decrypting an
|
|
27
|
+
// application message advances the secret-tree generation WITHIN an
|
|
28
|
+
// epoch while `epoch` stays constant, so a legitimate same-epoch
|
|
29
|
+
// re-persist carries a changed `mls_state` blob at the same epoch and
|
|
30
|
+
// must still win — `<` would reject it and wedge decryption.
|
|
31
|
+
.where((eb)=>eb('kubun_p2p_group_mls_state.epoch', '<=', eb.ref('excluded.epoch')))).execute();
|
|
32
|
+
},
|
|
33
|
+
async getMLSState (groupID, deviceID) {
|
|
34
|
+
return await db.selectFrom('kubun_p2p_group_mls_state').selectAll().where('group_id', '=', groupID).where('device_id', '=', deviceID).executeTakeFirst();
|
|
35
|
+
},
|
|
36
|
+
async deleteMLSState (groupID, deviceID) {
|
|
37
|
+
await db.deleteFrom('kubun_p2p_group_mls_state').where('group_id', '=', groupID).where('device_id', '=', deviceID).execute();
|
|
38
|
+
},
|
|
39
|
+
async listMLSStates (groupID) {
|
|
40
|
+
return await db.selectFrom('kubun_p2p_group_mls_state').selectAll().where('group_id', '=', groupID).execute();
|
|
41
|
+
},
|
|
42
|
+
async listMLSStateGroupIDs (deviceID) {
|
|
43
|
+
const rows = await db.selectFrom('kubun_p2p_group_mls_state').select('group_id').where('device_id', '=', deviceID).execute();
|
|
44
|
+
return rows.map((row)=>row.group_id);
|
|
45
|
+
},
|
|
46
|
+
async deleteGroupMLSStates (groupID) {
|
|
47
|
+
await db.deleteFrom('kubun_p2p_group_mls_state').where('group_id', '=', groupID).execute();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Adapter } from '@kubun/db-adapter';
|
|
2
|
+
import type { Kysely } from 'kysely';
|
|
3
|
+
import type { InsertSyncPeer, P2PStoreTables, SyncPeer } from '../tables.js';
|
|
4
|
+
/** Sync peers and the broadcast dedup log. */
|
|
5
|
+
export type SyncOps = {
|
|
6
|
+
addSyncPeer(peer: InsertSyncPeer): Promise<void>;
|
|
7
|
+
getSyncPeer(peerDID: string): Promise<SyncPeer | undefined>;
|
|
8
|
+
listSyncPeers(): Promise<Array<SyncPeer>>;
|
|
9
|
+
updateSyncPeer(peerDID: string, update: Partial<InsertSyncPeer>): Promise<void>;
|
|
10
|
+
removeSyncPeer(peerDID: string): Promise<void>;
|
|
11
|
+
recordBroadcast(entry: {
|
|
12
|
+
docID: string;
|
|
13
|
+
version: string;
|
|
14
|
+
mlsGroupID: string;
|
|
15
|
+
}): Promise<boolean>;
|
|
16
|
+
hasBroadcast(entry: {
|
|
17
|
+
docID: string;
|
|
18
|
+
version: string;
|
|
19
|
+
mlsGroupID: string;
|
|
20
|
+
}): Promise<boolean>;
|
|
21
|
+
};
|
|
22
|
+
export declare function createSyncOps(db: Kysely<P2PStoreTables>, adapter: Adapter): SyncOps;
|
package/lib/ops/sync.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export function createSyncOps(db, adapter) {
|
|
2
|
+
return {
|
|
3
|
+
async addSyncPeer (peer) {
|
|
4
|
+
const config = adapter.encodeJSON(peer.config);
|
|
5
|
+
await db.insertInto('kubun_p2p_sync_peers').values({
|
|
6
|
+
...peer,
|
|
7
|
+
config
|
|
8
|
+
}).onConflict((oc)=>oc.column('peer_did').doUpdateSet({
|
|
9
|
+
endpoint: peer.endpoint,
|
|
10
|
+
config,
|
|
11
|
+
updated_at: peer.updated_at
|
|
12
|
+
})).execute();
|
|
13
|
+
},
|
|
14
|
+
async getSyncPeer (peerDID) {
|
|
15
|
+
return await db.selectFrom('kubun_p2p_sync_peers').selectAll().where('peer_did', '=', peerDID).executeTakeFirst();
|
|
16
|
+
},
|
|
17
|
+
async listSyncPeers () {
|
|
18
|
+
return await db.selectFrom('kubun_p2p_sync_peers').selectAll().execute();
|
|
19
|
+
},
|
|
20
|
+
async updateSyncPeer (peerDID, update) {
|
|
21
|
+
const values = {};
|
|
22
|
+
if (update.endpoint != null) values.endpoint = update.endpoint;
|
|
23
|
+
if (update.config != null) values.config = adapter.encodeJSON(update.config);
|
|
24
|
+
// Through the adapter, like every sibling write: the column is a
|
|
25
|
+
// `timestamptz(3)` on Postgres, which a raw epoch number is not.
|
|
26
|
+
values.updated_at = adapter.encodeTimestamp(new Date());
|
|
27
|
+
await db.updateTable('kubun_p2p_sync_peers').set(values).where('peer_did', '=', peerDID).execute();
|
|
28
|
+
},
|
|
29
|
+
async removeSyncPeer (peerDID) {
|
|
30
|
+
await db.deleteFrom('kubun_p2p_sync_peers').where('peer_did', '=', peerDID).execute();
|
|
31
|
+
},
|
|
32
|
+
// --- Broadcast log operations ---
|
|
33
|
+
async recordBroadcast (entry) {
|
|
34
|
+
const result = await db.insertInto('kubun_p2p_broadcast_log').values({
|
|
35
|
+
doc_id: entry.docID,
|
|
36
|
+
version: entry.version,
|
|
37
|
+
mls_group_id: entry.mlsGroupID
|
|
38
|
+
}).onConflict((oc)=>oc.columns([
|
|
39
|
+
'doc_id',
|
|
40
|
+
'version',
|
|
41
|
+
'mls_group_id'
|
|
42
|
+
]).doNothing()).executeTakeFirst();
|
|
43
|
+
return (result.numInsertedOrUpdatedRows ?? 0n) > 0n;
|
|
44
|
+
},
|
|
45
|
+
async hasBroadcast (entry) {
|
|
46
|
+
const row = await db.selectFrom('kubun_p2p_broadcast_log').select('doc_id').where('doc_id', '=', entry.docID).where('version', '=', entry.version).where('mls_group_id', '=', entry.mlsGroupID).limit(1).executeTakeFirst();
|
|
47
|
+
return row != null;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
package/lib/tables.d.ts
CHANGED
|
@@ -1,15 +1,33 @@
|
|
|
1
1
|
import type { CreatedAtColumn, UpdatedAtColumn } from '@kubun/db-adapter';
|
|
2
2
|
import type { ColumnType, Insertable, Selectable, Updateable } from 'kysely';
|
|
3
3
|
type DefaultedStringColumn = ColumnType<string, string | undefined, string>;
|
|
4
|
+
type DefaultedNumberColumn = ColumnType<number, number | undefined, number>;
|
|
4
5
|
type NullableStringColumn = ColumnType<string | null, string | null | undefined, string | null>;
|
|
5
6
|
type JSONArrayColumn<T> = ColumnType<Array<T>, Array<T> | undefined, Array<T>>;
|
|
6
7
|
type JSONValueColumn<T> = ColumnType<T, T, T>;
|
|
8
|
+
/**
|
|
9
|
+
* Whether this device belongs to the group right now.
|
|
10
|
+
*
|
|
11
|
+
* Only the two states that have a writer. An `invited` state would have none:
|
|
12
|
+
* a group row is written when a join COMPLETES, so there is no moment at which
|
|
13
|
+
* a row exists and the device has merely been asked.
|
|
14
|
+
*/
|
|
15
|
+
export type GroupStatus = 'joined' | 'left';
|
|
7
16
|
export type GroupTable = {
|
|
8
17
|
id: string;
|
|
9
18
|
name: string;
|
|
10
19
|
description: DefaultedStringColumn;
|
|
11
20
|
created_by: string;
|
|
12
21
|
hlc: string;
|
|
22
|
+
/**
|
|
23
|
+
* Leaving keeps the row: its circles, circle members, peer announcements and
|
|
24
|
+
* hub bindings hang off it, and a device invited back would otherwise have to
|
|
25
|
+
* rebuild state it still holds. The status is what every LISTING reads, while
|
|
26
|
+
* a point lookup by id deliberately still finds a departed group — that is
|
|
27
|
+
* what makes rejoining the same group possible instead of forking a second
|
|
28
|
+
* row for it.
|
|
29
|
+
*/
|
|
30
|
+
status: DefaultedStringColumn;
|
|
13
31
|
created_at: CreatedAtColumn;
|
|
14
32
|
updated_at: UpdatedAtColumn;
|
|
15
33
|
};
|
|
@@ -34,6 +52,7 @@ export type CircleTable = {
|
|
|
34
52
|
catalog_ids: JSONArrayColumn<string>;
|
|
35
53
|
hlc: string;
|
|
36
54
|
removed_at_hlc: NullableStringColumn;
|
|
55
|
+
open: DefaultedNumberColumn;
|
|
37
56
|
created_at: CreatedAtColumn;
|
|
38
57
|
updated_at: UpdatedAtColumn;
|
|
39
58
|
};
|
|
@@ -62,7 +81,13 @@ export type GroupMLSStateTable = {
|
|
|
62
81
|
mls_state: Uint8Array;
|
|
63
82
|
credential: string;
|
|
64
83
|
epoch: number;
|
|
65
|
-
|
|
84
|
+
/**
|
|
85
|
+
* The handle's signed control-ledger tokens, JSON-encoded in enactment order.
|
|
86
|
+
* Replayed into `restoreGroup` so a restored handle folds the same roster it
|
|
87
|
+
* held before: without them the fold reverts to the genesis anchor alone and
|
|
88
|
+
* every admin but the creator loses authority across a restart.
|
|
89
|
+
*/
|
|
90
|
+
ledger_entries: DefaultedStringColumn;
|
|
66
91
|
updated_at: UpdatedAtColumn;
|
|
67
92
|
};
|
|
68
93
|
export type GroupMLSState = Selectable<GroupMLSStateTable>;
|
|
@@ -72,12 +97,6 @@ export type SyncPeerTable = {
|
|
|
72
97
|
id: string;
|
|
73
98
|
peer_did: string;
|
|
74
99
|
endpoint: string;
|
|
75
|
-
mode: string;
|
|
76
|
-
allowed_users: JSONValueColumn<Array<string> | {
|
|
77
|
-
all: boolean;
|
|
78
|
-
}>;
|
|
79
|
-
priority: DefaultedStringColumn;
|
|
80
|
-
trust_level: string;
|
|
81
100
|
config: JSONValueColumn<Record<string, unknown>>;
|
|
82
101
|
created_at: CreatedAtColumn;
|
|
83
102
|
updated_at: UpdatedAtColumn;
|
|
@@ -101,27 +120,45 @@ export type BroadcastLogTable = {
|
|
|
101
120
|
};
|
|
102
121
|
export type BroadcastLogEntry = Selectable<BroadcastLogTable>;
|
|
103
122
|
export type InsertBroadcastLogEntry = Insertable<BroadcastLogTable>;
|
|
104
|
-
export type
|
|
123
|
+
export type ControlRequestTable = {
|
|
124
|
+
id: string;
|
|
105
125
|
group_id: string;
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
126
|
+
kind: string;
|
|
127
|
+
status: DefaultedStringColumn;
|
|
128
|
+
error: NullableStringColumn;
|
|
129
|
+
result: NullableStringColumn;
|
|
130
|
+
enqueued_at: string;
|
|
131
|
+
settled_at: NullableStringColumn;
|
|
132
|
+
};
|
|
133
|
+
export type ControlRequest = Selectable<ControlRequestTable>;
|
|
134
|
+
export type InsertControlRequest = Insertable<ControlRequestTable>;
|
|
135
|
+
export type CommitJournalEntryTable = {
|
|
136
|
+
group_id: string;
|
|
137
|
+
publish_id: string;
|
|
138
|
+
expected_head: string | null;
|
|
139
|
+
epoch: number;
|
|
140
|
+
accepted_as: NullableStringColumn;
|
|
141
|
+
commit: Uint8Array;
|
|
142
|
+
bodies: JSONArrayColumn<string>;
|
|
143
|
+
kind: string;
|
|
144
|
+
journal: Uint8Array;
|
|
145
|
+
};
|
|
146
|
+
export type CommitJournalEntry = Selectable<CommitJournalEntryTable>;
|
|
147
|
+
export type InsertCommitJournalEntry = Insertable<CommitJournalEntryTable>;
|
|
148
|
+
export type GroupAnchorTable = {
|
|
149
|
+
group_id: string;
|
|
150
|
+
secret: Uint8Array;
|
|
151
|
+
epoch: number;
|
|
114
152
|
};
|
|
115
|
-
export type
|
|
116
|
-
export type
|
|
117
|
-
export type
|
|
153
|
+
export type GroupAnchor = Selectable<GroupAnchorTable>;
|
|
154
|
+
export type InsertGroupAnchor = Insertable<GroupAnchorTable>;
|
|
155
|
+
export type AppCursorTable = {
|
|
118
156
|
group_id: string;
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
updated_at: UpdatedAtColumn;
|
|
157
|
+
topic_id: string;
|
|
158
|
+
position: string;
|
|
122
159
|
};
|
|
123
|
-
export type
|
|
124
|
-
export type
|
|
160
|
+
export type AppCursor = Selectable<AppCursorTable>;
|
|
161
|
+
export type InsertAppCursor = Insertable<AppCursorTable>;
|
|
125
162
|
export type HubTable = {
|
|
126
163
|
id: string;
|
|
127
164
|
label: string | null;
|
|
@@ -135,10 +172,48 @@ export type InsertHub = Insertable<HubTable>;
|
|
|
135
172
|
export type GroupHubTable = {
|
|
136
173
|
group_id: string;
|
|
137
174
|
hub_id: string;
|
|
175
|
+
is_commit_hub: DefaultedNumberColumn;
|
|
138
176
|
created_at: CreatedAtColumn;
|
|
139
177
|
};
|
|
140
178
|
export type GroupHub = Selectable<GroupHubTable>;
|
|
141
179
|
export type InsertGroupHub = Insertable<GroupHubTable>;
|
|
180
|
+
/**
|
|
181
|
+
* One advertised capability: an identifier matched EXACTLY on both halves, so a
|
|
182
|
+
* peer answering `kubun/sync-merkle` v1 is not offered a v2 session. `transports`
|
|
183
|
+
* is null for a capability the hub tunnel already addresses, and is where a
|
|
184
|
+
* future direct transport carries its own addressing hint.
|
|
185
|
+
*/
|
|
186
|
+
export type PeerCapabilityRecord = {
|
|
187
|
+
protocol: string;
|
|
188
|
+
version: number;
|
|
189
|
+
transports: Array<string> | null;
|
|
190
|
+
};
|
|
191
|
+
export type PeerAnnouncementTable = {
|
|
192
|
+
group_id: string;
|
|
193
|
+
peer_did: string;
|
|
194
|
+
label: string;
|
|
195
|
+
availability: string;
|
|
196
|
+
capabilities: JSONArrayColumn<PeerCapabilityRecord>;
|
|
197
|
+
hlc: string;
|
|
198
|
+
announced_at: UpdatedAtColumn;
|
|
199
|
+
announced_epoch: DefaultedNumberColumn;
|
|
200
|
+
};
|
|
201
|
+
export type PeerAnnouncement = Selectable<PeerAnnouncementTable>;
|
|
202
|
+
export type InsertPeerAnnouncement = Insertable<PeerAnnouncementTable>;
|
|
203
|
+
export type LocalPeerProfileTable = {
|
|
204
|
+
peer_did: string;
|
|
205
|
+
label: string;
|
|
206
|
+
availability: string;
|
|
207
|
+
capabilities: JSONArrayColumn<PeerCapabilityRecord>;
|
|
208
|
+
updated_at: UpdatedAtColumn;
|
|
209
|
+
};
|
|
210
|
+
export type LocalPeerProfile = Selectable<LocalPeerProfileTable>;
|
|
211
|
+
export type PendingJoinRequestTable = {
|
|
212
|
+
did: string;
|
|
213
|
+
payload: string;
|
|
214
|
+
created_at: CreatedAtColumn;
|
|
215
|
+
};
|
|
216
|
+
export type PendingJoinRequestRow = Selectable<PendingJoinRequestTable>;
|
|
142
217
|
export type PaginationParams = {
|
|
143
218
|
limit?: number;
|
|
144
219
|
offset?: number;
|
|
@@ -157,9 +232,14 @@ export type P2PStoreTables = {
|
|
|
157
232
|
kubun_p2p_sync_peers: SyncPeerTable;
|
|
158
233
|
kubun_p2p_sync_checkpoints: SyncCheckpointTable;
|
|
159
234
|
kubun_p2p_broadcast_log: BroadcastLogTable;
|
|
160
|
-
|
|
161
|
-
|
|
235
|
+
kubun_p2p_control_requests: ControlRequestTable;
|
|
236
|
+
kubun_p2p_commit_journal: CommitJournalEntryTable;
|
|
237
|
+
kubun_p2p_group_anchor: GroupAnchorTable;
|
|
238
|
+
kubun_p2p_app_cursor: AppCursorTable;
|
|
162
239
|
kubun_p2p_hubs: HubTable;
|
|
163
240
|
kubun_p2p_group_hubs: GroupHubTable;
|
|
241
|
+
kubun_p2p_peer_announcements: PeerAnnouncementTable;
|
|
242
|
+
kubun_p2p_local_peer_profile: LocalPeerProfileTable;
|
|
243
|
+
kubun_p2p_pending_join_requests: PendingJoinRequestTable;
|
|
164
244
|
};
|
|
165
245
|
export {};
|
package/package.json
CHANGED
|
@@ -1,41 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/store-p2p",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"license": "see LICENSE.md",
|
|
3
|
+
"version": "0.12.0",
|
|
5
4
|
"keywords": [],
|
|
5
|
+
"license": "see LICENSE.md",
|
|
6
|
+
"sideEffects": false,
|
|
6
7
|
"type": "module",
|
|
7
|
-
"main": "lib/index.js",
|
|
8
|
-
"types": "lib/index.d.ts",
|
|
9
8
|
"exports": {
|
|
10
9
|
".": "./lib/index.js"
|
|
11
10
|
},
|
|
11
|
+
"main": "lib/index.js",
|
|
12
|
+
"types": "lib/index.d.ts",
|
|
12
13
|
"files": [
|
|
13
14
|
"lib/*",
|
|
14
15
|
"LICENSE.md"
|
|
15
16
|
],
|
|
16
|
-
"sideEffects": false,
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@kokuin/capability": "^0.1
|
|
19
|
-
"@kokuin/token": "^0.
|
|
20
|
-
"kysely": "^0.29.
|
|
21
|
-
"@kubun/db": "^0.
|
|
22
|
-
"@kubun/
|
|
23
|
-
"@kubun/
|
|
18
|
+
"@kokuin/capability": "^0.2.1",
|
|
19
|
+
"@kokuin/token": "^0.3.0",
|
|
20
|
+
"kysely": "^0.29.4",
|
|
21
|
+
"@kubun/db": "^0.12.0",
|
|
22
|
+
"@kubun/db-adapter": "^0.12.0",
|
|
23
|
+
"@kubun/logger": "^0.12.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@testcontainers/postgresql": "^12.0
|
|
27
|
-
"@kubun/db-better-sqlite": "^0.
|
|
28
|
-
"@kubun/db-postgres": "^0.
|
|
29
|
-
"@kubun/hlc": "^0.
|
|
26
|
+
"@testcontainers/postgresql": "^12.1.0",
|
|
27
|
+
"@kubun/db-better-sqlite": "^0.12.0",
|
|
28
|
+
"@kubun/db-postgres": "^0.12.0",
|
|
29
|
+
"@kubun/hlc": "^0.12.0"
|
|
30
30
|
},
|
|
31
31
|
"scripts": {
|
|
32
|
+
"build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
|
|
32
33
|
"build:clean": "del lib",
|
|
33
34
|
"build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
|
|
34
35
|
"build:types": "tsc --emitDeclarationOnly --skipLibCheck",
|
|
35
36
|
"build:types:ci": "tsc --emitDeclarationOnly --declarationMap false",
|
|
36
|
-
"
|
|
37
|
+
"test": "pnpm run test:types && pnpm run test:unit",
|
|
37
38
|
"test:types": "tsc --noEmit -p tsconfig.test.json",
|
|
38
|
-
"test:unit": "vitest run"
|
|
39
|
-
"test": "pnpm run test:types && pnpm run test:unit"
|
|
39
|
+
"test:unit": "vitest run"
|
|
40
40
|
}
|
|
41
41
|
}
|