@kubun/store-p2p 0.18.1 → 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.
@@ -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('kubun_p2p_app_pending').values({
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 db.isTransaction ? await save(db) : await db.transaction().execute(save);
27
+ return await withStoreTransaction(db, save);
26
28
  },
27
29
  async listPendingAppFrames (groupID, deviceID) {
28
- const rows = await db.selectFrom('kubun_p2p_app_pending').selectAll().where('group_id', '=', groupID).where('device_id', '=', deviceID).execute();
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('kubun_p2p_app_pending').where('group_id', '=', groupID).where('device_id', '=', deviceID).where('frame_id', '=', frameID).execute();
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('kubun_p2p_app_pending').where('group_id', '=', groupID).where('device_id', '=', deviceID).execute();
49
- await tx.deleteFrom('kubun_p2p_group_anchor').where('group_id', '=', groupID).execute();
50
- await tx.deleteFrom('kubun_p2p_app_cursor').where('group_id', '=', groupID).execute();
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 db.isTransaction ? await remove(db) : await db.transaction().execute(remove);
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('kubun_p2p_group_anchor')// Single slot per group: a rotation REPLACES both halves together. The
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
- return await db.selectFrom('kubun_p2p_group_anchor').selectAll().where('group_id', '=', groupID).executeTakeFirst();
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('kubun_p2p_app_cursor').values({
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('kubun_p2p_app_cursor').selectAll().where('group_id', '=', params.groupID).where('topic_id', '=', params.topicID).executeTakeFirst();
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
  }
@@ -1,4 +1,5 @@
1
- import type { Adapter } from '@kubun/db-adapter';
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 createAppOutboxOps(db: Kysely<P2PStoreTables>, adapter: Adapter, getRandomValues: Runtime['getRandomValues']): AppOutboxOps;
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;
@@ -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('kubun_p2p_group_delegation').select('jti').where('group_id', '=', groupID).where('pending_revocation_hlc', 'is not', null).limit(1).executeTakeFirst();
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('kubun_p2p_app_outbox').select('seq').where('group_id', '=', groupID).where('protocol', '=', 'control').where('prc', '=', 'control/delegationRevoke').limit(1).executeTakeFirst();
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('kubun_p2p_group_delegation').set({
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('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();
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 createAppOutboxOps(db, adapter, getRandomValues) {
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
- if (db.isTransaction) await clear(db);
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
- // The upsert locks the key row against concurrent crypto-erasure.
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').doUpdateSet({
114
- group_id: groupID
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('kubun_p2p_groups').select('status').where('id', '=', groupID).executeTakeFirst();
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('kubun_p2p_app_outbox').select('seq').where('group_id', '=', groupID).where('seq', '=', entry.seq).executeTakeFirst();
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('kubun_p2p_group_delegation').select('jti').where('group_id', '=', groupID).where('pending_revocation_hlc', 'is not', null).limit(1).executeTakeFirst();
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('kubun_p2p_app_outbox').select((eb)=>eb.fn.countAll().as('count')).where('group_id', '=', groupID).executeTakeFirstOrThrow();
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('kubun_p2p_app_outbox_state').set({
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('kubun_p2p_app_outbox').values({
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('kubun_p2p_group_delegation').set(admission.kind === 'grant' ? {
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 db.transaction().execute(put);
180
+ await withAppOutboxLock(context, groupID, put);
181
181
  },
182
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([
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('kubun_p2p_app_outbox').where('group_id', '=', groupID).where('seq', '=', seq).execute();
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('kubun_p2p_app_outbox_state').select('commit_cursor').where('group_id', '=', groupID).executeTakeFirst();
221
- return state?.commit_cursor ?? null;
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('kubun_p2p_app_outbox_state').values({
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 { type Kysely } from 'kysely';
1
+ import type { Kysely } from 'kysely';
2
2
  import type { P2PStoreTables } from '../tables.js';
3
3
  export type CatalogOffer = {
4
4
  owner: string;
@@ -1,4 +1,4 @@
1
- import { Transaction } from 'kysely';
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('kubun_p2p_catalog_offers').values({
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('kubun_p2p_catalog_offers').where('owner', '=', owner).where('exposure_id', '=', exposureID).execute();
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('kubun_p2p_catalog_offers').where('owner', '=', owner).where('catalog_id', '=', catalogID).execute();
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('kubun_p2p_catalog_offers').selectAll().execute()).map(project);
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('kubun_p2p_catalog_offers').where('group_id', '=', groupID).execute();
39
+ await tx.deleteFrom('p2p_catalog_offers').where('group_id', '=', groupID).execute();
40
40
  if (offers.length === 0) return;
41
- await tx.insertInto('kubun_p2p_catalog_offers').values(offers.map((offer)=>({
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
- if (db instanceof Transaction) await replace(db);
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('kubun_p2p_catalog_offers').selectAll().where('group_id', '=', groupID);
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('kubun_p2p_catalog_routes').where('owner', '=', owner).where('catalog_id', '=', catalogID).where('group_id', '=', groupID).execute();
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('kubun_p2p_catalog_routes').values({
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('kubun_p2p_unserved_groups').select('group_id').where('group_id', '=', groupID).executeTakeFirst();
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('kubun_p2p_unserved_groups').where('group_id', '=', groupID).execute();
36
+ await db.deleteFrom('p2p_unserved_groups').where('group_id', '=', groupID).execute();
37
37
  return;
38
38
  }
39
- await db.insertInto('kubun_p2p_unserved_groups').values({
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('kubun_p2p_catalog_routes').selectAll().where('owner', '=', owner).where('catalog_id', '=', catalogID).orderBy('group_id').execute();
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('kubun_p2p_catalog_routes').selectAll();
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('kubun_p2p_catalog_routes').where('owner', '=', owner).where('catalog_id', '=', catalogID).execute();
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('kubun_p2p_catalog_routes').where('group_id', '=', groupID).execute();
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;
@@ -1,5 +1,7 @@
1
- import { AbstractPostgresAdapter } from '@kubun/db-adapter';
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
- /** Some adapters parse JSON text on read. */ function parseJSON(value) {
14
- return typeof value === 'string' ? JSON.parse(value) : value;
15
- }
16
- export function createCatalogSyncOps(db, adapter) {
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('kubun_p2p_observation_repair_peers').selectAll().where('document_id', 'in', [
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('kubun_p2p_observation_repairs').selectAll().where('document_id', 'in', [
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('kubun_p2p_observation_repairs').values({
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('kubun_p2p_observation_repair_peers').where('document_id', '=', schedule.documentID).execute();
55
+ await tx.deleteFrom('p2p_observation_repair_peers').where('document_id', '=', schedule.documentID).execute();
57
56
  if (triedPeers.length > 0) {
58
- await tx.insertInto('kubun_p2p_observation_repair_peers').values(triedPeers.map((peerDID)=>({
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('kubun_p2p_catalog_cursors').selectAll().where('peer_did', '=', peerDID).where('group_id', '=', groupID).executeTakeFirst();
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: parseJSON(row.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: JSON.stringify(state.bounds)
81
+ bounds: adapter.encodeJSON(state.bounds)
84
82
  };
85
- await db.insertInto('kubun_p2p_catalog_cursors').values({
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('kubun_p2p_document_repairs').selectAll().where('group_id', '=', groupID).where('document_id', 'in', ids).execute(),
102
- db.selectFrom('kubun_p2p_document_repair_answers').selectAll().where('group_id', '=', groupID).where('document_id', 'in', ids).execute()
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('kubun_p2p_document_repairs').select((eb)=>eb.fn.countAll().as('count')).where('group_id', '=', groupID).where('listed_by', '=', params.peerDID).executeTakeFirst();
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('kubun_p2p_document_repairs').select('document_id').where('group_id', '=', groupID).where('listed_by', '=', params.peerDID).orderBy('attempted_at').orderBy('document_id').limit(excess).execute();
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('kubun_p2p_document_repair_answers').where('group_id', '=', groupID).where('document_id', '=', documentID).execute();
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('kubun_p2p_document_repairs').values({
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(kubun_p2p_document_repairs.listed_by, ${listedBy})`
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('kubun_p2p_document_repair_answers').where('group_id', '=', groupID).where('document_id', '=', documentID).where('peer_did', '=', params.peerDID).execute();
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('kubun_p2p_document_repair_answers').values({
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
- 'kubun_p2p_document_repair_answers',
174
- 'kubun_p2p_document_repairs'
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('kubun_p2p_document_repairs as r').select('r.document_id').where('r.group_id', '=', groupID).where('r.listed_by', 'is not', null).where(({ not, exists, selectFrom })=>not(exists(selectFrom('kubun_p2p_document_repair_answers as a').select('a.peer_did').whereRef('a.group_id', '=', 'r.group_id').whereRef('a.document_id', '=', 'r.document_id').where('a.peer_did', '=', peerDID)))).orderBy('r.attempted_at').orderBy('r.document_id').limit(limit).execute();
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('kubun_p2p_document_repair_answers').where('group_id', '=', groupID).where('document_id', 'in', (eb)=>eb.selectFrom('kubun_p2p_document_repairs').select('document_id').where('group_id', '=', groupID).where('created_at', '<', createdBefore)).execute();
187
- await db.deleteFrom('kubun_p2p_document_repairs').where('group_id', '=', groupID).where('created_at', '<', createdBefore).execute();
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('kubun_p2p_observation_repairs as r').selectAll('r').where((eb)=>eb.or([
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('kubun_p2p_observation_repair_peers as p').select('p.peer_did').whereRef('p.document_id', '=', 'r.document_id').where('p.peer_did', '=', query.peerDID)))
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 inTransaction(async (tx)=>await writeSchedule(tx, schedule));
213
+ await withStoreTransaction(db, async (tx)=>await writeSchedule(tx, schedule));
216
214
  },
217
215
  async updateObservationRepairSchedule (documentID, update) {
218
- return await inTransaction(async (tx)=>{
219
- // Postgres runs sessions concurrently: serialize updates of one
220
- // document. SQLite transactions already run one at a time.
221
- if (adapter instanceof AbstractPostgresAdapter) {
222
- await sql`select pg_advisory_xact_lock(hashtextextended(${`kubun_p2p_observation_repairs:${documentID}`}, 0))`.execute(tx);
223
- }
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('kubun_p2p_observation_repairs').select('document_id').orderBy('document_id').limit(limit);
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 inTransaction(async (tx)=>{
241
- await tx.deleteFrom('kubun_p2p_observation_repair_peers').where('document_id', 'in', [
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('kubun_p2p_observation_repairs').where('document_id', 'in', [
241
+ await tx.deleteFrom('p2p_observation_repairs').where('document_id', 'in', [
245
242
  ...documentIDs
246
243
  ]).execute();
247
244
  });