@kubun/store-p2p 0.17.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/lib/api.d.ts +24 -6
  2. package/lib/api.js +17 -2
  3. package/lib/definition.js +1 -1
  4. package/lib/hub-url.d.ts +1 -0
  5. package/lib/hub-url.js +3 -0
  6. package/lib/index.d.ts +8 -2
  7. package/lib/index.js +6 -0
  8. package/lib/migrations.js +153 -9
  9. package/lib/ops/app-lane.d.ts +32 -2
  10. package/lib/ops/app-lane.js +55 -2
  11. package/lib/ops/app-outbox.d.ts +44 -0
  12. package/lib/ops/app-outbox.js +236 -0
  13. package/lib/ops/catalog-offers.d.ts +18 -0
  14. package/lib/ops/catalog-offers.js +59 -0
  15. package/lib/ops/catalog-routes.d.ts +25 -0
  16. package/lib/ops/catalog-routes.js +60 -0
  17. package/lib/ops/catalog-sync.d.ts +122 -0
  18. package/lib/ops/catalog-sync.js +251 -0
  19. package/lib/ops/circles.d.ts +3 -4
  20. package/lib/ops/circles.js +31 -14
  21. package/lib/ops/control.d.ts +6 -0
  22. package/lib/ops/control.js +12 -3
  23. package/lib/ops/delegations.d.ts +31 -0
  24. package/lib/ops/delegations.js +71 -0
  25. package/lib/ops/document-sweep.d.ts +11 -0
  26. package/lib/ops/document-sweep.js +16 -0
  27. package/lib/ops/groups.d.ts +70 -22
  28. package/lib/ops/groups.js +130 -35
  29. package/lib/ops/hubs.js +8 -4
  30. package/lib/ops/log-handler-completion.d.ts +20 -0
  31. package/lib/ops/log-handler-completion.js +41 -0
  32. package/lib/ops/mls.d.ts +39 -6
  33. package/lib/ops/mls.js +115 -21
  34. package/lib/ops/own-agents.d.ts +68 -0
  35. package/lib/ops/own-agents.js +359 -0
  36. package/lib/ops/sync.d.ts +16 -0
  37. package/lib/ops/sync.js +25 -0
  38. package/lib/owner-classification.d.ts +14 -0
  39. package/lib/owner-classification.js +18 -0
  40. package/lib/tables.d.ts +193 -5
  41. package/package.json +16 -11
package/lib/api.d.ts CHANGED
@@ -1,28 +1,46 @@
1
1
  import type { Adapter } from '@kubun/db-adapter';
2
+ import { type Runtime } from '@sozai/runtime';
2
3
  import type { Kysely } from 'kysely';
3
4
  import { type AppLaneOps } from './ops/app-lane.js';
5
+ import { type AppOutboxOps } from './ops/app-outbox.js';
6
+ import { type CatalogOfferOps } from './ops/catalog-offers.js';
7
+ import { type CatalogRouteOps } from './ops/catalog-routes.js';
8
+ import { type CatalogSyncOps } from './ops/catalog-sync.js';
4
9
  import { type CircleOps } from './ops/circles.js';
5
10
  import { type ControlOps } from './ops/control.js';
6
11
  import { type DelegationOps } from './ops/delegations.js';
7
12
  import { type DiscoveryOps } from './ops/discovery.js';
13
+ import { type DocumentSweepOps } from './ops/document-sweep.js';
8
14
  import { type GroupOps } from './ops/groups.js';
9
15
  import { type HubOps } from './ops/hubs.js';
16
+ import { type LogHandlerCompletionOps } from './ops/log-handler-completion.js';
10
17
  import { type MLSOps } from './ops/mls.js';
18
+ import { type OwnAgentsOps } from './ops/own-agents.js';
11
19
  import { type SyncOps } from './ops/sync.js';
12
20
  import type { P2PStoreTables } from './tables.js';
13
- export type { AppLaneOps } from './ops/app-lane.js';
21
+ export type { AppLaneOps, OpenedAppFrameExpectedVersion, OpenedAppFrameRecord, SaveOpenedAppFrameParams, } from './ops/app-lane.js';
22
+ export type { AppOutboxOps } from './ops/app-outbox.js';
23
+ export type { CatalogOffer, CatalogOfferOps } from './ops/catalog-offers.js';
24
+ export type { CatalogRoute, CatalogRouteOps } from './ops/catalog-routes.js';
25
+ export type { CatalogCursorBounds, CatalogCursorState, CatalogSyncOps, DocumentRepairAnswerKind, DocumentRepairRow, DueObservationRepairQuery, ObservationRepairPosition, ObservationRepairSchedule, RecordDocumentRepairParams, } from './ops/catalog-sync.js';
14
26
  export type { CircleOps } from './ops/circles.js';
15
27
  export type { CommitJournalEntryKind, ControlOps, ControlRequestKind, ControlRequestOutcome, } from './ops/control.js';
16
28
  export type { DelegationOps } from './ops/delegations.js';
17
29
  export type { DiscoveryOps, PutPendingJoinRequestParams, SetLocalPeerProfileParams, UpsertPeerAnnouncementParams, } from './ops/discovery.js';
18
- export type { GroupOps } from './ops/groups.js';
30
+ export type { DocumentSweepOps } from './ops/document-sweep.js';
31
+ export type { GetMemberRemovalsParams, GroupOps } from './ops/groups.js';
19
32
  export type { HubOps } from './ops/hubs.js';
20
- export type { MLSOps } from './ops/mls.js';
21
- export type { SyncOps } from './ops/sync.js';
33
+ export type { BeginLogHandlerCompletionParams, LogHandlerCompletionKey, LogHandlerCompletionOps, } from './ops/log-handler-completion.js';
34
+ export type { MLSOps, ReserveSenderGenerationsParams } from './ops/mls.js';
35
+ export type { BindingEvidence, OwnAgentsGrant, OwnAgentsJoinCapability, OwnAgentsOps, OwnAgentsRecord, OwnAgentsRecordKey, } from './ops/own-agents.js';
36
+ export type { PruneBroadcastLogParams, SyncOps } from './ops/sync.js';
22
37
  /**
23
38
  * The store's whole surface, one slice per domain. Each slice module owns its
24
39
  * tables and its own helpers; this intersection is the only coupling between
25
40
  * them, so a caller still sees one flat API.
26
41
  */
27
- export type P2PStoreAPI = GroupOps & CircleOps & DelegationOps & MLSOps & SyncOps & ControlOps & AppLaneOps & HubOps & DiscoveryOps;
28
- export declare function createP2PStore(db: Kysely<P2PStoreTables>, adapter: Adapter): P2PStoreAPI;
42
+ export type P2PStoreAPI = GroupOps & CatalogOfferOps & CatalogRouteOps & CatalogSyncOps & CircleOps & DelegationOps & MLSOps & SyncOps & ControlOps & AppLaneOps & AppOutboxOps & LogHandlerCompletionOps & HubOps & DiscoveryOps & DocumentSweepOps & OwnAgentsOps;
43
+ export type CreateP2PStoreOptions = {
44
+ getRandomValues?: Runtime['getRandomValues'];
45
+ };
46
+ export declare function createP2PStore(db: Kysely<P2PStoreTables>, adapter: Adapter, options?: CreateP2PStoreOptions): P2PStoreAPI;
package/lib/api.js CHANGED
@@ -1,22 +1,37 @@
1
+ import { createRuntime } from '@sozai/runtime';
1
2
  import { createAppLaneOps } from './ops/app-lane.js';
3
+ import { createAppOutboxOps } from './ops/app-outbox.js';
4
+ import { createCatalogOfferOps } from './ops/catalog-offers.js';
5
+ import { createCatalogRouteOps } from './ops/catalog-routes.js';
6
+ import { createCatalogSyncOps } from './ops/catalog-sync.js';
2
7
  import { createCircleOps } from './ops/circles.js';
3
8
  import { createControlOps } from './ops/control.js';
4
9
  import { createDelegationOps } from './ops/delegations.js';
5
10
  import { createDiscoveryOps } from './ops/discovery.js';
11
+ import { createDocumentSweepOps } from './ops/document-sweep.js';
6
12
  import { createGroupOps } from './ops/groups.js';
7
13
  import { createHubOps } from './ops/hubs.js';
14
+ import { createLogHandlerCompletionOps } from './ops/log-handler-completion.js';
8
15
  import { createMLSOps } from './ops/mls.js';
16
+ import { createOwnAgentsOps } from './ops/own-agents.js';
9
17
  import { createSyncOps } from './ops/sync.js';
10
- export function createP2PStore(db, adapter) {
18
+ export function createP2PStore(db, adapter, options = {}) {
11
19
  return {
12
20
  ...createGroupOps(db, adapter),
21
+ ...createOwnAgentsOps(db, adapter),
22
+ ...createCatalogOfferOps(db),
23
+ ...createCatalogRouteOps(db),
24
+ ...createCatalogSyncOps(db, adapter),
13
25
  ...createCircleOps(db, adapter),
14
26
  ...createDelegationOps(db),
15
27
  ...createMLSOps(db, adapter),
16
28
  ...createSyncOps(db, adapter),
17
29
  ...createControlOps(db, adapter),
18
30
  ...createAppLaneOps(db, adapter),
31
+ ...createAppOutboxOps(db, adapter, options.getRandomValues ?? createRuntime().getRandomValues),
19
32
  ...createHubOps(db, adapter),
20
- ...createDiscoveryOps(db, adapter)
33
+ ...createLogHandlerCompletionOps(db),
34
+ ...createDiscoveryOps(db, adapter),
35
+ ...createDocumentSweepOps(db)
21
36
  };
22
37
  }
package/lib/definition.js CHANGED
@@ -3,5 +3,5 @@ import { getP2PMigrations } from './migrations.js';
3
3
  export const p2pStoreDefinition = {
4
4
  name: 'p2p',
5
5
  migrations: getP2PMigrations,
6
- createAPI: createP2PStore
6
+ createAPI: (db, adapter)=>createP2PStore(db, adapter)
7
7
  };
@@ -0,0 +1 @@
1
+ export declare function canonicalHubURL(url: string): string;
package/lib/hub-url.js ADDED
@@ -0,0 +1,3 @@
1
+ export function canonicalHubURL(url) {
2
+ return new URL(url).toString();
3
+ }
package/lib/index.d.ts CHANGED
@@ -1,8 +1,14 @@
1
1
  import type { StoreProvider } from '@kubun/db';
2
2
  import type { P2PStoreAPI } from './api.js';
3
- export type { ControlRequestKind, ControlRequestOutcome, P2PStoreAPI, PutPendingJoinRequestParams, SetLocalPeerProfileParams, UpsertPeerAnnouncementParams, } from './api.js';
3
+ export type { BeginLogHandlerCompletionParams, BindingEvidence, CatalogCursorBounds, CatalogCursorState, CatalogOffer, CatalogRoute, CatalogSyncOps, ControlRequestKind, ControlRequestOutcome, CreateP2PStoreOptions, DocumentRepairAnswerKind, DocumentRepairRow, DueObservationRepairQuery, GetMemberRemovalsParams, LogHandlerCompletionKey, LogHandlerCompletionOps, ObservationRepairPosition, ObservationRepairSchedule, OpenedAppFrameExpectedVersion, OpenedAppFrameRecord, OwnAgentsGrant, OwnAgentsJoinCapability, OwnAgentsOps, OwnAgentsRecord, OwnAgentsRecordKey, P2PStoreAPI, PruneBroadcastLogParams, PutPendingJoinRequestParams, RecordDocumentRepairParams, ReserveSenderGenerationsParams, SaveOpenedAppFrameParams, SetLocalPeerProfileParams, UpsertPeerAnnouncementParams, } from './api.js';
4
4
  export { createP2PStore } from './api.js';
5
5
  export { p2pStoreDefinition } from './definition.js';
6
+ export { canonicalHubURL } from './hub-url.js';
7
+ export { AppOutboxDecryptionError, type AppOutboxPortOptions, AppOutboxTransactionError, type ClearAppOutboxRowsOptions, PendingRevocationAdmissionError, UndeliveredRevocationsError, } from './ops/app-outbox.js';
8
+ export { MAX_REPAIRS_PER_LISTER } from './ops/catalog-sync.js';
9
+ export { BroadcastLogPruneParamsError } from './ops/sync.js';
10
+ export { type ClassifyDocumentOwnerParams, classifyDocumentOwner, type DocumentOwnerClass, } from './owner-classification.js';
6
11
  export declare const P2P_STORE: "p2p";
7
12
  export declare function getP2PStore(provider: StoreProvider): Promise<P2PStoreAPI>;
8
- export type { AppCursor, AppCursorTable, Circle, CircleMember, CircleMemberTable, CircleTable, ControlRequest, ControlRequestTable, Group, GroupAnchor, GroupAnchorTable, GroupDelegation, GroupDelegationTable, GroupHub, GroupHubTable, GroupMember, GroupMemberTable, GroupMLSState, GroupMLSStateTable, GroupStatus, GroupTable, Hub, HubTable, InsertCircle, InsertCircleMember, InsertGroup, InsertGroupDelegation, InsertGroupHub, InsertGroupMember, InsertGroupMLSState, InsertHub, InsertSyncCheckpoint, InsertSyncPeer, LocalPeerProfile, LocalPeerProfileTable, P2PStoreTables, PaginatedResult, PaginationParams, PeerAnnouncement, PeerAnnouncementTable, PeerCapabilityRecord, PendingJoinRequestRow, PendingJoinRequestTable, SyncCheckpoint, SyncCheckpointTable, SyncPeer, SyncPeerTable, UpdateGroupMLSState, } from './tables.js';
13
+ export { OwnAgentsGroupNotFoundError, OwnAgentsKindDowngradeError, OwnAgentsRecordError, OwnAgentsTransactionError, ownAgentsRecordLockKey, } from './ops/own-agents.js';
14
+ export type { AppCursor, AppCursorTable, Circle, CircleMember, CircleMemberTable, CircleTable, CommitJournalEntry, ControlRequest, ControlRequestTable, Group, GroupAnchor, GroupAnchorTable, GroupDelegation, GroupDelegationTable, GroupHub, GroupHubTable, GroupKind, GroupLeaveRequest, GroupLeaveRequestTable, GroupMember, GroupMemberTable, GroupMLSState, GroupMLSStateTable, GroupStatus, GroupTable, Hub, HubTable, InsertCircle, InsertCircleMember, InsertGroup, InsertGroupDelegation, InsertGroupHub, InsertGroupMember, InsertGroupMLSState, InsertHub, InsertSyncCheckpoint, InsertSyncPeer, LocalPeerProfile, LocalPeerProfileTable, OwnAgentsGrantKind, OwnAgentsGrantTable, OwnAgentsJoinCapabilityTable, OwnAgentsRecordTable, P2PStoreTables, PaginatedResult, PaginationParams, PeerAnnouncement, PeerAnnouncementTable, PeerCapabilityRecord, PendingAppFrameRow, PendingAppFrameTable, PendingJoinRequestRow, PendingJoinRequestTable, SyncCheckpoint, SyncCheckpointTable, SyncPeer, SyncPeerTable, UpdateGroupMLSState, } from './tables.js';
package/lib/index.js CHANGED
@@ -1,6 +1,12 @@
1
1
  export { createP2PStore } from './api.js';
2
2
  export { p2pStoreDefinition } from './definition.js';
3
+ export { canonicalHubURL } from './hub-url.js';
4
+ export { AppOutboxDecryptionError, AppOutboxTransactionError, PendingRevocationAdmissionError, UndeliveredRevocationsError } from './ops/app-outbox.js';
5
+ export { MAX_REPAIRS_PER_LISTER } from './ops/catalog-sync.js';
6
+ export { BroadcastLogPruneParamsError } from './ops/sync.js';
7
+ export { classifyDocumentOwner } from './owner-classification.js';
3
8
  export const P2P_STORE = 'p2p';
4
9
  export function getP2PStore(provider) {
5
10
  return provider.getStore(P2P_STORE);
6
11
  }
12
+ export { OwnAgentsGroupNotFoundError, OwnAgentsKindDowngradeError, OwnAgentsRecordError, OwnAgentsTransactionError, ownAgentsRecordLockKey } from './ops/own-agents.js';
package/lib/migrations.js CHANGED
@@ -23,10 +23,35 @@ import { sql } from 'kysely';
23
23
  const now = ctx.functions.now;
24
24
  const init = {
25
25
  async up (db) {
26
+ await db.schema.createTable('kubun_p2p_access_default_revocations').ifNotExists().addColumn('controller_did', t.text, (col)=>col.notNull()).addColumn('device_did', t.text, (col)=>col.notNull()).addPrimaryKeyConstraint('pk_p2p_access_default_revocations', [
27
+ 'controller_did',
28
+ 'device_did'
29
+ ]).execute();
30
+ // Every authenticated device binding this device has observed. Append-only:
31
+ // ownership is historical, so revocation, removal or MLS state loss never
32
+ // takes a row away.
33
+ await db.schema.createTable('kubun_p2p_binding_evidence').ifNotExists().addColumn('controller_did', t.text, (col)=>col.notNull()).addColumn('device_did', t.text, (col)=>col.notNull()).addColumn('first_seen_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addPrimaryKeyConstraint('pk_p2p_binding_evidence', [
34
+ 'controller_did',
35
+ 'device_did'
36
+ ]).execute();
37
+ await db.schema.createIndex('idx_p2p_binding_evidence_device').ifNotExists().on('kubun_p2p_binding_evidence').column('device_did').execute();
26
38
  // Groups
27
- await db.schema.createTable('kubun_p2p_groups').ifNotExists().addColumn('id', t.text, (col)=>col.notNull().primaryKey()).addColumn('name', t.text, (col)=>col.notNull()).addColumn('description', t.text, (col)=>col.notNull().defaultTo('')).addColumn('created_by', t.text, (col)=>col.notNull()).addColumn('hlc', t.text, (col)=>col.notNull())// 'joined' | 'left' — see `GroupStatus`. A row is only written on a
28
- // completed join or a local create, so the default is the joined state.
29
- .addColumn('status', t.text, (col)=>col.notNull().defaultTo('joined')).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addColumn('updated_at', t.timestamp).execute();
39
+ await db.schema.createTable('kubun_p2p_groups').ifNotExists().addColumn('id', t.text, (col)=>col.notNull().primaryKey()).addColumn('name', t.text, (col)=>col.notNull()).addColumn('description', t.text, (col)=>col.notNull().defaultTo('')).addColumn('created_by', t.text, (col)=>col.notNull()).addColumn('hlc', t.text, (col)=>col.notNull()).addColumn('kind', t.text, (col)=>col.notNull().defaultTo('standard'))// A completed join or local create starts joined; see `GroupStatus`.
40
+ .addColumn('status', t.text, (col)=>col.notNull().defaultTo('joined'))// Token of the last handle advance whose projections no reconcile has
41
+ // caught up with yet; NULL when they match the current handle.
42
+ .addColumn('projections_dirty', t.text)// Epoch the last successful reconcile caught the projections up to.
43
+ .addColumn('projections_epoch', 'integer').addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addColumn('updated_at', t.timestamp).execute();
44
+ await db.schema.createTable('kubun_p2p_own_agents_records').ifNotExists().addColumn('type', t.text, (col)=>col.notNull()).addColumn('owner', t.text, (col)=>col.notNull()).addColumn('id', t.text, (col)=>col.notNull()).addColumn('hlc', t.text, (col)=>col.notNull()).addColumn('op_jwt', t.text, (col)=>col.notNull()).addColumn('op_hash', t.text, (col)=>col.notNull()).addColumn('payload', t.binary).addColumn('payload_digest', t.text).addColumn('deleted', 'integer', (col)=>col.notNull()).addColumn('meta', t.json, (col)=>col.notNull()).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addPrimaryKeyConstraint('pk_p2p_own_agents_records', [
45
+ 'type',
46
+ 'owner',
47
+ 'id',
48
+ 'op_hash'
49
+ ]).execute();
50
+ await db.schema.createTable('kubun_p2p_own_agents_grants').ifNotExists().addColumn('controller_did', t.text, (col)=>col.notNull()).addColumn('kind', t.text, (col)=>col.notNull()).addColumn('token', t.text, (col)=>col.notNull()).addColumn('issued_at', t.bigint, (col)=>col.notNull()).addColumn('expires_at', t.bigint, (col)=>col.notNull()).addPrimaryKeyConstraint('pk_p2p_own_agents_grants', [
51
+ 'controller_did',
52
+ 'kind'
53
+ ]).execute();
54
+ await db.schema.createTable('kubun_p2p_own_agents_join_capabilities').ifNotExists().addColumn('group_id', t.text, (col)=>col.notNull().primaryKey()).addColumn('recipient_did', t.text, (col)=>col.notNull()).addColumn('capability_id', t.text, (col)=>col.notNull()).addColumn('token', t.text, (col)=>col.notNull()).addColumn('expires_at', t.bigint, (col)=>col.notNull()).addColumn('signer_did', t.text).addColumn('signer_leaf_key', t.binary).addColumn('signer_capability', t.text).addColumn('signer_prefix', t.json).execute();
30
55
  // Group members
31
56
  await db.schema.createTable('kubun_p2p_group_members').ifNotExists().addColumn('group_id', t.text, (col)=>col.notNull()).addColumn('member_did', t.text, (col)=>col.notNull()).addColumn('role', t.text, (col)=>col.notNull().defaultTo('member'))// LWW version key for the row, set on insert and refreshed on re-add.
32
57
  .addColumn('hlc', t.text, (col)=>col.notNull())// HLC at which this peer learned the member was removed from the MLS
@@ -38,8 +63,78 @@ import { sql } from 'kysely';
38
63
  'member_did'
39
64
  ]).execute();
40
65
  await db.schema.createIndex('idx_p2p_group_members_did').ifNotExists().on('kubun_p2p_group_members').column('member_did').execute();
66
+ await db.schema.createTable('kubun_p2p_catalog_offers').ifNotExists().addColumn('owner', t.text, (col)=>col.notNull()).addColumn('exposure_id', t.text, (col)=>col.notNull()).addColumn('catalog_id', t.text, (col)=>col.notNull()).addColumn('group_id', t.text, (col)=>col.notNull()).addColumn('circle_id', t.text, (col)=>col.notNull()).addPrimaryKeyConstraint('pk_p2p_catalog_offers', [
67
+ 'owner',
68
+ 'exposure_id'
69
+ ]).execute();
70
+ await db.schema.createIndex('idx_p2p_catalog_offers_group_circle').ifNotExists().on('kubun_p2p_catalog_offers').columns([
71
+ 'group_id',
72
+ 'circle_id'
73
+ ]).execute();
74
+ await db.schema.createTable('kubun_p2p_catalog_routes').ifNotExists().addColumn('owner', t.text, (col)=>col.notNull()).addColumn('catalog_id', t.text, (col)=>col.notNull()).addColumn('group_id', t.text, (col)=>col.notNull()).addColumn('pull', 'integer', (col)=>col.notNull().defaultTo(0)).addColumn('push', 'integer', (col)=>col.notNull().defaultTo(0)).addPrimaryKeyConstraint('pk_p2p_catalog_routes', [
75
+ 'owner',
76
+ 'catalog_id',
77
+ 'group_id'
78
+ ]).execute();
79
+ // Groups whose document data this device does not serve; absence means served.
80
+ await db.schema.createTable('kubun_p2p_unserved_groups').ifNotExists().addColumn('group_id', t.text, (col)=>col.notNull().primaryKey()).execute();
81
+ // Document-sync progress with one peer in one group: the last resolved
82
+ // document key, whether the current cycle's examined ranges were all clear,
83
+ // the hash of the accepted rule set without absolute bounds, and the
84
+ // absolute bounds in use until the cursor wraps.
85
+ await db.schema.createTable('kubun_p2p_catalog_cursors').ifNotExists().addColumn('peer_did', t.text, (col)=>col.notNull()).addColumn('group_id', t.text, (col)=>col.notNull()).addColumn('rules_hash', t.text, (col)=>col.notNull()).addColumn('last_key', t.text)// The responder's sealed position for `last_key`; opaque here.
86
+ .addColumn('last_token', t.text).addColumn('cycle_clear', 'integer', (col)=>col.notNull().defaultTo(1)).addColumn('bounds', t.text, (col)=>col.notNull()).addPrimaryKeyConstraint('pk_p2p_catalog_cursors', [
87
+ 'peer_did',
88
+ 'group_id'
89
+ ]).execute();
90
+ // A document whose difference with some peer is not resolved.
91
+ // `listed_by` is the first peer whose tree listed it, so it is a repair to
92
+ // retry; null when no peer listed it and the row only remembers answers.
93
+ // Rows expire from `created_at`, so none outlives its window whoever
94
+ // listed it.
95
+ await db.schema.createTable('kubun_p2p_document_repairs').ifNotExists().addColumn('group_id', t.text, (col)=>col.notNull()).addColumn('document_id', t.text, (col)=>col.notNull()).addColumn('listed_by', t.text).addColumn('holdings_digest', t.text, (col)=>col.notNull()).addColumn('created_at', t.bigint, (col)=>col.notNull()).addColumn('attempted_at', t.bigint, (col)=>col.notNull()).addPrimaryKeyConstraint('pk_p2p_document_repairs', [
96
+ 'group_id',
97
+ 'document_id'
98
+ ]).execute();
99
+ await db.schema.createIndex('idx_p2p_document_repairs_lister').ifNotExists().on('kubun_p2p_document_repairs').columns([
100
+ 'group_id',
101
+ 'listed_by',
102
+ 'attempted_at'
103
+ ]).execute();
104
+ await db.schema.createIndex('idx_p2p_document_repairs_created').ifNotExists().on('kubun_p2p_document_repairs').columns([
105
+ 'group_id',
106
+ 'created_at'
107
+ ]).execute();
108
+ // A peer's last answer, "unavailable" or "refused", for a repair row's
109
+ // current holdings. A change of holdings removes the answers.
110
+ await db.schema.createTable('kubun_p2p_document_repair_answers').ifNotExists().addColumn('group_id', t.text, (col)=>col.notNull()).addColumn('document_id', t.text, (col)=>col.notNull()).addColumn('peer_did', t.text, (col)=>col.notNull()).addColumn('answer', t.text, (col)=>col.notNull()).addPrimaryKeyConstraint('pk_p2p_document_repair_answers', [
111
+ 'group_id',
112
+ 'document_id',
113
+ 'peer_did'
114
+ ]).execute();
115
+ // When this device next asks peers for an observed document's value, for
116
+ // the obligation observed at `fetch_hlc`. The connector owns the
117
+ // obligation; a row leaves only when it resolves.
118
+ await db.schema.createTable('kubun_p2p_observation_repairs').ifNotExists().addColumn('document_id', t.text, (col)=>col.notNull().primaryKey()).addColumn('fetch_hlc', t.text, (col)=>col.notNull()).addColumn('attempts', 'integer', (col)=>col.notNull()).addColumn('next_attempt_at', t.bigint, (col)=>col.notNull()).addColumn('tried_count', 'integer', (col)=>col.notNull()).execute();
119
+ await db.schema.createIndex('idx_p2p_observation_repairs_next').ifNotExists().on('kubun_p2p_observation_repairs').columns([
120
+ 'next_attempt_at',
121
+ 'document_id'
122
+ ]).execute();
123
+ // The peers already asked for an obligation that answered without it.
124
+ await db.schema.createTable('kubun_p2p_observation_repair_peers').ifNotExists().addColumn('document_id', t.text, (col)=>col.notNull()).addColumn('peer_did', t.text, (col)=>col.notNull()).addPrimaryKeyConstraint('pk_p2p_observation_repair_peers', [
125
+ 'document_id',
126
+ 'peer_did'
127
+ ]).execute();
128
+ // Newest leave-request stamp surfaced per (group, member), so a replayed
129
+ // or duplicate request is not surfaced again. Separate from the membership
130
+ // row because it must outlive a removal: a re-added member's old requests
131
+ // stay spent.
132
+ await db.schema.createTable('kubun_p2p_group_leave_requests').ifNotExists().addColumn('group_id', t.text, (col)=>col.notNull()).addColumn('member_did', t.text, (col)=>col.notNull()).addColumn('hlc', t.text, (col)=>col.notNull()).addPrimaryKeyConstraint('pk_p2p_group_leave_requests', [
133
+ 'group_id',
134
+ 'member_did'
135
+ ]).execute();
41
136
  // Circles
42
- await db.schema.createTable('kubun_p2p_circles').ifNotExists().addColumn('id', t.text, (col)=>col.notNull().primaryKey()).addColumn('group_id', t.text, (col)=>col.notNull()).addColumn('name', t.text, (col)=>col.notNull()).addColumn('description', t.text, (col)=>col.notNull().defaultTo('')).addColumn('catalog_ids', t.json, (col)=>col.notNull().defaultTo('[]')).addColumn('hlc', t.text, (col)=>col.notNull())// Projection of the group's current `openCircleIDs`: 1 marks the circle
137
+ await db.schema.createTable('kubun_p2p_circles').ifNotExists().addColumn('id', t.text, (col)=>col.notNull().primaryKey()).addColumn('group_id', t.text, (col)=>col.notNull()).addColumn('name', t.text, (col)=>col.notNull()).addColumn('description', t.text, (col)=>col.notNull().defaultTo('')).addColumn('hlc', t.text, (col)=>col.notNull())// Projection of the group's current `openCircleIDs`: 1 marks the circle
43
138
  // open (every group member has implicit access), 0 closed. Integer 0/1
44
139
  // mirrors the catalog `active` flag, keeping the read-back value uniform
45
140
  // across SQLite and Postgres. A circle is closed until a settings fold
@@ -61,9 +156,19 @@ import { sql } from 'kysely';
61
156
  // one group a delegation `jti` was granted in, so the p2p layer can still
62
157
  // resolve a cap's group for revoke-broadcast targeting and listing
63
158
  // enrichment. One group per `jti`, so `jti` is the primary key.
64
- await db.schema.createTable('kubun_p2p_group_delegation').ifNotExists().addColumn('group_id', t.text, (col)=>col.notNull()).addColumn('jti', t.text, (col)=>col.notNull().primaryKey()).execute();
159
+ await db.schema.createTable('kubun_p2p_group_delegation').ifNotExists().addColumn('group_id', t.text, (col)=>col.notNull()).addColumn('jti', t.text, (col)=>col.notNull().primaryKey()).addColumn('pending_grant_hlc', t.text).addColumn('pending_grant_digest', t.text).addColumn('pending_revocation_hlc', t.text).addColumn('pending_revocation_digest', t.text).execute();
160
+ await db.schema.createIndex('idx_p2p_group_delegation_pending_grant').ifNotExists().on('kubun_p2p_group_delegation').columns([
161
+ 'group_id',
162
+ 'pending_grant_hlc'
163
+ ]).execute();
164
+ await db.schema.createIndex('idx_p2p_group_delegation_pending_revocation').ifNotExists().on('kubun_p2p_group_delegation').columns([
165
+ 'group_id',
166
+ 'pending_revocation_hlc'
167
+ ]).execute();
65
168
  // Group MLS state
66
- await db.schema.createTable('kubun_p2p_group_mls_state').ifNotExists().addColumn('group_id', t.text, (col)=>col.notNull()).addColumn('device_id', t.text, (col)=>col.notNull()).addColumn('mls_state', t.binary, (col)=>col.notNull()).addColumn('credential', t.text, (col)=>col.notNull()).addColumn('epoch', 'integer', (col)=>col.notNull()).addColumn('ledger_entries', t.text, (col)=>col.notNull().defaultTo('[]')).addColumn('updated_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addPrimaryKeyConstraint('pk_p2p_group_mls_state', [
169
+ await db.schema.createTable('kubun_p2p_group_mls_state').ifNotExists().addColumn('group_id', t.text, (col)=>col.notNull()).addColumn('device_id', t.text, (col)=>col.notNull()).addColumn('mls_state', t.binary, (col)=>col.notNull()).addColumn('credential', t.text, (col)=>col.notNull()).addColumn('epoch', 'integer', (col)=>col.notNull())// Orders in-place secret-tree mutations within one epoch.
170
+ .addColumn('state_version', 'integer', (col)=>col.notNull().defaultTo(0)).addColumn('is_deleted', 'integer', (col)=>col.notNull().defaultTo(0))// Highest sender generation reserved at this epoch; restore skips past it.
171
+ .addColumn('sender_generation_hwm', 'integer', (col)=>col.notNull().defaultTo(0)).addColumn('ledger_entries', t.text, (col)=>col.notNull().defaultTo('[]')).addColumn('updated_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addPrimaryKeyConstraint('pk_p2p_group_mls_state', [
67
172
  'group_id',
68
173
  'device_id'
69
174
  ]).execute();
@@ -86,6 +191,8 @@ import { sql } from 'kysely';
86
191
  'mls_group_id'
87
192
  ]).execute();
88
193
  await db.schema.createIndex('idx_p2p_broadcast_log_doc').ifNotExists().on('kubun_p2p_broadcast_log').column('doc_id').execute();
194
+ // The broadcast log is pruned by age.
195
+ await db.schema.createIndex('idx_p2p_broadcast_log_broadcast_at').ifNotExists().on('kubun_p2p_broadcast_log').column('broadcast_at').execute();
89
196
  // Hubs (device-local, never synced — no hlc column)
90
197
  await db.schema.createTable('kubun_p2p_hubs').ifNotExists().addColumn('id', t.text, (col)=>col.notNull().primaryKey()).addColumn('label', t.text).addColumn('url', t.text, (col)=>col.notNull().unique()).addColumn('server_did', t.text).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addColumn('updated_at', t.timestamp).execute();
91
198
  // Group hubs (join table, device-local — no hlc column). A group commits
@@ -113,7 +220,8 @@ import { sql } from 'kysely';
113
220
  // nullable because most requests produce nothing to hand back; it is
114
221
  // written on settle and deleted with the row on ack, so the retention of
115
222
  // whatever it holds is bounded by the request's own lifetime.
116
- await db.schema.createTable('kubun_p2p_control_requests').ifNotExists().addColumn('id', t.text, (col)=>col.notNull().primaryKey()).addColumn('group_id', t.text, (col)=>col.notNull()).addColumn('kind', t.text, (col)=>col.notNull()).addColumn('status', t.text, (col)=>col.notNull().defaultTo('pending')).addColumn('error', t.text).addColumn('result', t.text).addColumn('enqueued_at', t.text, (col)=>col.notNull()).addColumn('settled_at', t.text).execute();
223
+ await db.schema.createTable('kubun_p2p_control_requests').ifNotExists().addColumn('id', t.text, (col)=>col.notNull().primaryKey()).addColumn('group_id', t.text, (col)=>col.notNull()).addColumn('kind', t.text, (col)=>col.notNull()).addColumn('status', t.text, (col)=>col.notNull().defaultTo('pending')).addColumn('error', t.text).addColumn('result', t.text).addColumn('enqueued_at', t.text, (col)=>col.notNull()).addColumn('settled_at', t.text)// Epoch of the advance that settled the request committed.
224
+ .addColumn('settled_epoch', 'integer').execute();
117
225
  await db.schema.createIndex('idx_p2p_control_requests_group').ifNotExists().on('kubun_p2p_control_requests').columns([
118
226
  'group_id',
119
227
  'enqueued_at'
@@ -121,17 +229,35 @@ import { sql } from 'kysely';
121
229
  // CommitJournal store (backs the `@kumiai/rpc` CommitJournal port).
122
230
  // `group_id` is the primary key: a single slot per group, so a fresh `put`
123
231
  // upserts/replaces the whole row.
124
- await db.schema.createTable('kubun_p2p_commit_journal').ifNotExists().addColumn('group_id', t.text, (col)=>col.notNull().primaryKey()).addColumn('publish_id', t.text, (col)=>col.notNull()).addColumn('expected_head', t.text).addColumn('epoch', t.bigint, (col)=>col.notNull()).addColumn('accepted_as', t.text).addColumn('commit', t.binary, (col)=>col.notNull()).addColumn('bodies', t.json, (col)=>col.notNull()).addColumn('kind', t.text, (col)=>col.notNull()).addColumn('journal', t.binary, (col)=>col.notNull()).execute();
232
+ await db.schema.createTable('kubun_p2p_commit_journal').ifNotExists().addColumn('group_id', t.text, (col)=>col.notNull().primaryKey()).addColumn('publish_id', t.text, (col)=>col.notNull()).addColumn('expected_head', t.text).addColumn('epoch', t.bigint, (col)=>col.notNull()).addColumn('accepted_as', t.text).addColumn('commit', t.binary, (col)=>col.notNull()).addColumn('bodies', t.json, (col)=>col.notNull()).addColumn('kind', t.text, (col)=>col.notNull()).addColumn('journal', t.binary, (col)=>col.notNull()).addColumn('holds_log_sends', 'integer', (col)=>col.notNull().defaultTo(0)).execute();
125
233
  // App-lane anchor + per-topic read positions (back the `@kumiai/rpc`
126
234
  // `AnchorStore` and `AppCursorStore` ports). The anchor table is a single
127
235
  // slot per group, so a rotation upserts and replaces the whole row;
128
236
  // cursors are keyed by (group, topic) so a deleted group takes its cursors
129
237
  // with it.
130
- await db.schema.createTable('kubun_p2p_group_anchor').ifNotExists().addColumn('group_id', t.text, (col)=>col.notNull().primaryKey()).addColumn('secret', t.binary, (col)=>col.notNull()).addColumn('epoch', t.bigint, (col)=>col.notNull()).execute();
238
+ await db.schema.createTable('kubun_p2p_group_anchor').ifNotExists().addColumn('group_id', t.text, (col)=>col.notNull().primaryKey()).addColumn('secret', t.binary, (col)=>col.notNull()).addColumn('epoch', t.bigint, (col)=>col.notNull()).addColumn('pending', t.json).execute();
239
+ await db.schema.createTable('kubun_p2p_app_outbox_state').ifNotExists().addColumn('group_id', t.text, (col)=>col.notNull().primaryKey()).addColumn('key', t.binary).addColumn('commit_cursor', t.json).execute();
240
+ await db.schema.createTable('kubun_p2p_app_outbox').ifNotExists().addColumn('group_id', t.text, (col)=>col.notNull()).addColumn('seq', t.bigint, (col)=>col.notNull()).addColumn('protocol', t.text, (col)=>col.notNull()).addColumn('prc', t.text, (col)=>col.notNull()).addColumn('data', t.binary, (col)=>col.notNull()).addColumn('iv', t.binary, (col)=>col.notNull()).addColumn('last_attempt', t.json).addPrimaryKeyConstraint('pk_p2p_app_outbox', [
241
+ 'group_id',
242
+ 'seq'
243
+ ]).execute();
131
244
  await db.schema.createTable('kubun_p2p_app_cursor').ifNotExists().addColumn('group_id', t.text, (col)=>col.notNull()).addColumn('topic_id', t.text, (col)=>col.notNull()).addColumn('position', t.text, (col)=>col.notNull()).addPrimaryKeyConstraint('pk_p2p_app_cursor', [
132
245
  'group_id',
133
246
  'topic_id'
134
247
  ]).execute();
248
+ // Decrypted app frames awaiting application. The frame ID is stable across
249
+ // redelivery, so the first staged record wins until it is completed.
250
+ await db.schema.createTable('kubun_p2p_app_pending').ifNotExists().addColumn('group_id', t.text, (col)=>col.notNull()).addColumn('device_id', t.text, (col)=>col.notNull()).addColumn('frame_id', t.text, (col)=>col.notNull()).addColumn('topic_id', t.text, (col)=>col.notNull()).addColumn('protocol', t.text, (col)=>col.notNull()).addColumn('segment', 'integer', (col)=>col.notNull()).addColumn('position', t.text, (col)=>col.notNull()).addColumn('payload', t.binary, (col)=>col.notNull()).addColumn('sender_did', t.text, (col)=>col.notNull()).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addPrimaryKeyConstraint('pk_p2p_app_pending', [
251
+ 'group_id',
252
+ 'device_id',
253
+ 'frame_id'
254
+ ]).execute();
255
+ await db.schema.createTable('kubun_p2p_log_handler_completion').ifNotExists().addColumn('group_id', t.text, (col)=>col.notNull()).addColumn('procedure', t.text, (col)=>col.notNull()).addColumn('entity', t.text, (col)=>col.notNull()).addColumn('hlc', t.text, (col)=>col.notNull()).addColumn('notify', 'integer', (col)=>col.notNull().defaultTo(0)).addColumn('completed', 'integer', (col)=>col.notNull().defaultTo(0)).addPrimaryKeyConstraint('pk_p2p_log_handler_completion', [
256
+ 'group_id',
257
+ 'procedure',
258
+ 'entity',
259
+ 'hlc'
260
+ ]).execute();
135
261
  // The peer-discovery projection: one row per (group, peer) folded from
136
262
  // authenticated `peer/announce` frames. `peer_did` is the MLS-authenticated
137
263
  // announcer, which is why the primary key is (group, peer) and there is no
@@ -158,10 +284,27 @@ import { sql } from 'kysely';
158
284
  .addColumn('payload', t.text, (col)=>col.notNull()).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).execute();
159
285
  },
160
286
  async down (db) {
287
+ await db.schema.dropTable('kubun_p2p_access_default_revocations').ifExists().execute();
288
+ await db.schema.dropTable('kubun_p2p_binding_evidence').ifExists().execute();
289
+ await db.schema.dropTable('kubun_p2p_own_agents_join_capabilities').ifExists().execute();
290
+ await db.schema.dropTable('kubun_p2p_own_agents_grants').ifExists().execute();
291
+ await db.schema.dropTable('kubun_p2p_own_agents_records').ifExists().execute();
292
+ await db.schema.dropTable('kubun_p2p_observation_repair_peers').ifExists().execute();
293
+ await db.schema.dropTable('kubun_p2p_observation_repairs').ifExists().execute();
294
+ await db.schema.dropTable('kubun_p2p_document_repair_answers').ifExists().execute();
295
+ await db.schema.dropTable('kubun_p2p_document_repairs').ifExists().execute();
296
+ await db.schema.dropTable('kubun_p2p_catalog_cursors').ifExists().execute();
297
+ await db.schema.dropTable('kubun_p2p_catalog_routes').ifExists().execute();
298
+ await db.schema.dropTable('kubun_p2p_unserved_groups').ifExists().execute();
299
+ await db.schema.dropTable('kubun_p2p_catalog_offers').ifExists().execute();
161
300
  await db.schema.dropTable('kubun_p2p_pending_join_requests').ifExists().execute();
162
301
  await db.schema.dropTable('kubun_p2p_local_peer_profile').ifExists().execute();
163
302
  await db.schema.dropTable('kubun_p2p_peer_announcements').ifExists().execute();
303
+ await db.schema.dropTable('kubun_p2p_app_pending').ifExists().execute();
304
+ await db.schema.dropTable('kubun_p2p_log_handler_completion').ifExists().execute();
164
305
  await db.schema.dropTable('kubun_p2p_app_cursor').ifExists().execute();
306
+ await db.schema.dropTable('kubun_p2p_app_outbox').ifExists().execute();
307
+ await db.schema.dropTable('kubun_p2p_app_outbox_state').ifExists().execute();
165
308
  await db.schema.dropTable('kubun_p2p_group_anchor').ifExists().execute();
166
309
  await db.schema.dropTable('kubun_p2p_commit_journal').ifExists().execute();
167
310
  await db.schema.dropTable('kubun_p2p_control_requests').ifExists().execute();
@@ -175,6 +318,7 @@ import { sql } from 'kysely';
175
318
  await db.schema.dropTable('kubun_p2p_group_delegation').ifExists().execute();
176
319
  await db.schema.dropTable('kubun_p2p_circle_members').ifExists().execute();
177
320
  await db.schema.dropTable('kubun_p2p_circles').ifExists().execute();
321
+ await db.schema.dropTable('kubun_p2p_group_leave_requests').ifExists().execute();
178
322
  await db.schema.dropTable('kubun_p2p_group_members').ifExists().execute();
179
323
  await db.schema.dropTable('kubun_p2p_groups').ifExists().execute();
180
324
  }
@@ -1,8 +1,37 @@
1
1
  import type { Adapter } from '@kubun/db-adapter';
2
+ import { type AnchorSlot } from '@kumiai/rpc';
2
3
  import type { Kysely } from 'kysely';
3
- import type { AppCursor, GroupAnchor, P2PStoreTables } from '../tables.js';
4
- /** App-lane anchors and per-topic read cursors. */
4
+ import type { AppCursor, GroupAnchor, InsertGroupMLSState, P2PStoreTables, PendingAppFrameRow } from '../tables.js';
5
+ export type OpenedAppFrameExpectedVersion = {
6
+ epoch: number;
7
+ stateVersion: number;
8
+ };
9
+ export type OpenedAppFrameRecord = {
10
+ frameID: string;
11
+ topicID: string;
12
+ protocol: string;
13
+ segment: number;
14
+ position: string;
15
+ payload: Uint8Array;
16
+ senderDID: string;
17
+ };
18
+ export type SaveOpenedAppFrameParams = {
19
+ state: InsertGroupMLSState;
20
+ expected: OpenedAppFrameExpectedVersion;
21
+ record: OpenedAppFrameRecord;
22
+ };
23
+ /** App-lane anchors, read cursors, and pending opened frames. */
5
24
  export type AppLaneOps = {
25
+ /** Commit the MLS CAS and pending insert together. */
26
+ saveOpenedAppFrame(params: SaveOpenedAppFrameParams): Promise<boolean>;
27
+ listPendingAppFrames(groupID: string, deviceID: string): Promise<Array<PendingAppFrameRow>>;
28
+ completePendingAppFrame(groupID: string, deviceID: string, frameID: string): Promise<void>;
29
+ /**
30
+ * Erase one membership's app-lane state: this device's pending frames, the
31
+ * group's anchor and its read cursors. A later membership must start from no
32
+ * anchor, which the peer reads as first boot and seeds from its live handle.
33
+ */
34
+ deleteGroupAppLane(groupID: string, deviceID: string): Promise<void>;
6
35
  /**
7
36
  * Write the group's anchor slot, REPLACING whatever it held. Single slot per
8
37
  * group: the anchor moves as a pair (secret + the epoch it was exported at),
@@ -12,6 +41,7 @@ export type AppLaneOps = {
12
41
  groupID: string;
13
42
  secret: Uint8Array;
14
43
  epoch: number;
44
+ pending?: AnchorSlot['pending'];
15
45
  }): Promise<void>;
16
46
  /** The group's anchor, or `undefined` if the group has never stored one. */
17
47
  getGroupAnchor(groupID: string): Promise<GroupAnchor | undefined>;
@@ -1,10 +1,62 @@
1
+ import { sortPendingAppFrames } from '@kumiai/rpc';
2
+ import { createMLSOps } from './mls.js';
1
3
  export function createAppLaneOps(db, adapter) {
2
4
  return {
5
+ async saveOpenedAppFrame ({ state, expected, record }) {
6
+ const save = async (tx)=>{
7
+ if (!await createMLSOps(tx, adapter).saveMLSState(state, expected)) return false;
8
+ await tx.insertInto('kubun_p2p_app_pending').values({
9
+ group_id: state.group_id,
10
+ device_id: state.device_id,
11
+ frame_id: record.frameID,
12
+ topic_id: record.topicID,
13
+ protocol: record.protocol,
14
+ segment: record.segment,
15
+ position: record.position,
16
+ payload: adapter.encodeBinary(record.payload),
17
+ sender_did: record.senderDID
18
+ }).onConflict((oc)=>oc.columns([
19
+ 'group_id',
20
+ 'device_id',
21
+ 'frame_id'
22
+ ]).doNothing()).execute();
23
+ return true;
24
+ };
25
+ return db.isTransaction ? await save(db) : await db.transaction().execute(save);
26
+ },
27
+ async listPendingAppFrames (groupID, deviceID) {
28
+ const rows = await db.selectFrom('kubun_p2p_app_pending').selectAll().where('group_id', '=', groupID).where('device_id', '=', deviceID).execute();
29
+ const sorted = sortPendingAppFrames(rows.map((row)=>({
30
+ frame: {
31
+ id: row.frame_id,
32
+ topicID: row.topic_id,
33
+ protocol: row.protocol,
34
+ segment: row.segment,
35
+ position: row.position
36
+ },
37
+ payload: row.payload,
38
+ senderDID: row.sender_did,
39
+ row
40
+ })));
41
+ return sorted.map(({ row })=>row);
42
+ },
43
+ 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();
45
+ },
46
+ async deleteGroupAppLane (groupID, deviceID) {
47
+ 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();
51
+ };
52
+ return db.isTransaction ? await remove(db) : await db.transaction().execute(remove);
53
+ },
3
54
  async putGroupAnchor (params) {
4
55
  const values = {
5
56
  group_id: params.groupID,
6
57
  secret: adapter.encodeBinary(params.secret),
7
- epoch: params.epoch
58
+ epoch: params.epoch,
59
+ pending: params.pending == null ? null : adapter.encodeJSON(params.pending)
8
60
  };
9
61
  await db.insertInto('kubun_p2p_group_anchor')// Single slot per group: a rotation REPLACES both halves together. The
10
62
  // secret and the epoch it was exported at both feed topic derivation,
@@ -12,7 +64,8 @@ export function createAppLaneOps(db, adapter) {
12
64
  // a topic no member is on.
13
65
  .values(values).onConflict((oc)=>oc.column('group_id').doUpdateSet((eb)=>({
14
66
  secret: eb.ref('excluded.secret'),
15
- epoch: eb.ref('excluded.epoch')
67
+ epoch: eb.ref('excluded.epoch'),
68
+ pending: eb.ref('excluded.pending')
16
69
  }))).execute();
17
70
  },
18
71
  async getGroupAnchor (groupID) {
@@ -0,0 +1,44 @@
1
+ import type { Adapter } from '@kubun/db-adapter';
2
+ import { type AppOutbox } from '@kumiai/rpc';
3
+ import type { Runtime } from '@sozai/runtime';
4
+ import type { Kysely } from 'kysely';
5
+ import type { P2PStoreTables } from '../tables.js';
6
+ export declare class AppOutboxDecryptionError extends Error {
7
+ name: string;
8
+ }
9
+ export declare class AppOutboxTransactionError extends Error {
10
+ name: string;
11
+ }
12
+ export declare class PendingRevocationAdmissionError extends Error {
13
+ name: string;
14
+ }
15
+ /** A voluntary leave would erase revocations the hub has not accepted yet. */
16
+ export declare class UndeliveredRevocationsError extends Error {
17
+ name: string;
18
+ }
19
+ export type ClearAppOutboxRowsOptions = {
20
+ /** Refuse, before writing anything, while revocations remain undelivered. */
21
+ requireRevocationsDelivered?: boolean;
22
+ };
23
+ /** Port options matching the peer that owns the outbox. */
24
+ export type AppOutboxPortOptions = {
25
+ /**
26
+ * The peer's entry limit. While a revocation is pending, entries other than
27
+ * revocations stop one short of it, keeping the last slot for revocations.
28
+ */
29
+ limit?: number;
30
+ };
31
+ export type AppOutboxOps = {
32
+ appOutboxFor(groupID: string, options?: AppOutboxPortOptions): AppOutbox;
33
+ clearAppOutbox(groupID: string): Promise<void>;
34
+ /**
35
+ * Whether the group still holds a revocation marker or a queued revocation entry
36
+ * the hub has not yet accepted.
37
+ */
38
+ hasUndeliveredRevocations(groupID: string): Promise<boolean>;
39
+ };
40
+ /** Coordinate status transitions with revocation admission and outbox erasure. */
41
+ export declare function lockAppOutboxForErasure(db: Kysely<P2PStoreTables>, groupID: string, options?: ClearAppOutboxRowsOptions): Promise<void>;
42
+ /** The caller's transaction also covers group leave or deletion. */
43
+ 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;