@kubun/store-p2p 0.17.0 → 0.18.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.
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 +150 -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 +17 -0
  16. package/lib/ops/catalog-routes.js +47 -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 +188 -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,76 @@ 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
+ // Document-sync progress with one peer in one group: the last resolved
80
+ // document key, whether the current cycle's examined ranges were all clear,
81
+ // the hash of the accepted rule set without absolute bounds, and the
82
+ // absolute bounds in use until the cursor wraps.
83
+ 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.
84
+ .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', [
85
+ 'peer_did',
86
+ 'group_id'
87
+ ]).execute();
88
+ // A document whose difference with some peer is not resolved.
89
+ // `listed_by` is the first peer whose tree listed it, so it is a repair to
90
+ // retry; null when no peer listed it and the row only remembers answers.
91
+ // Rows expire from `created_at`, so none outlives its window whoever
92
+ // listed it.
93
+ 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', [
94
+ 'group_id',
95
+ 'document_id'
96
+ ]).execute();
97
+ await db.schema.createIndex('idx_p2p_document_repairs_lister').ifNotExists().on('kubun_p2p_document_repairs').columns([
98
+ 'group_id',
99
+ 'listed_by',
100
+ 'attempted_at'
101
+ ]).execute();
102
+ await db.schema.createIndex('idx_p2p_document_repairs_created').ifNotExists().on('kubun_p2p_document_repairs').columns([
103
+ 'group_id',
104
+ 'created_at'
105
+ ]).execute();
106
+ // A peer's last answer, "unavailable" or "refused", for a repair row's
107
+ // current holdings. A change of holdings removes the answers.
108
+ 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', [
109
+ 'group_id',
110
+ 'document_id',
111
+ 'peer_did'
112
+ ]).execute();
113
+ // When this device next asks peers for an observed document's value, for
114
+ // the obligation observed at `fetch_hlc`. The connector owns the
115
+ // obligation; a row leaves only when it resolves.
116
+ 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();
117
+ await db.schema.createIndex('idx_p2p_observation_repairs_next').ifNotExists().on('kubun_p2p_observation_repairs').columns([
118
+ 'next_attempt_at',
119
+ 'document_id'
120
+ ]).execute();
121
+ // The peers already asked for an obligation that answered without it.
122
+ 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', [
123
+ 'document_id',
124
+ 'peer_did'
125
+ ]).execute();
126
+ // Newest leave-request stamp surfaced per (group, member), so a replayed
127
+ // or duplicate request is not surfaced again. Separate from the membership
128
+ // row because it must outlive a removal: a re-added member's old requests
129
+ // stay spent.
130
+ 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', [
131
+ 'group_id',
132
+ 'member_did'
133
+ ]).execute();
41
134
  // 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
135
+ 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
136
  // open (every group member has implicit access), 0 closed. Integer 0/1
44
137
  // mirrors the catalog `active` flag, keeping the read-back value uniform
45
138
  // across SQLite and Postgres. A circle is closed until a settings fold
@@ -61,9 +154,19 @@ import { sql } from 'kysely';
61
154
  // one group a delegation `jti` was granted in, so the p2p layer can still
62
155
  // resolve a cap's group for revoke-broadcast targeting and listing
63
156
  // 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();
157
+ 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();
158
+ await db.schema.createIndex('idx_p2p_group_delegation_pending_grant').ifNotExists().on('kubun_p2p_group_delegation').columns([
159
+ 'group_id',
160
+ 'pending_grant_hlc'
161
+ ]).execute();
162
+ await db.schema.createIndex('idx_p2p_group_delegation_pending_revocation').ifNotExists().on('kubun_p2p_group_delegation').columns([
163
+ 'group_id',
164
+ 'pending_revocation_hlc'
165
+ ]).execute();
65
166
  // 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', [
167
+ 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.
168
+ .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.
169
+ .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
170
  'group_id',
68
171
  'device_id'
69
172
  ]).execute();
@@ -86,6 +189,8 @@ import { sql } from 'kysely';
86
189
  'mls_group_id'
87
190
  ]).execute();
88
191
  await db.schema.createIndex('idx_p2p_broadcast_log_doc').ifNotExists().on('kubun_p2p_broadcast_log').column('doc_id').execute();
192
+ // The broadcast log is pruned by age.
193
+ await db.schema.createIndex('idx_p2p_broadcast_log_broadcast_at').ifNotExists().on('kubun_p2p_broadcast_log').column('broadcast_at').execute();
89
194
  // Hubs (device-local, never synced — no hlc column)
90
195
  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
196
  // Group hubs (join table, device-local — no hlc column). A group commits
@@ -113,7 +218,8 @@ import { sql } from 'kysely';
113
218
  // nullable because most requests produce nothing to hand back; it is
114
219
  // written on settle and deleted with the row on ack, so the retention of
115
220
  // 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();
221
+ 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.
222
+ .addColumn('settled_epoch', 'integer').execute();
117
223
  await db.schema.createIndex('idx_p2p_control_requests_group').ifNotExists().on('kubun_p2p_control_requests').columns([
118
224
  'group_id',
119
225
  'enqueued_at'
@@ -121,17 +227,35 @@ import { sql } from 'kysely';
121
227
  // CommitJournal store (backs the `@kumiai/rpc` CommitJournal port).
122
228
  // `group_id` is the primary key: a single slot per group, so a fresh `put`
123
229
  // 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();
230
+ 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
231
  // App-lane anchor + per-topic read positions (back the `@kumiai/rpc`
126
232
  // `AnchorStore` and `AppCursorStore` ports). The anchor table is a single
127
233
  // slot per group, so a rotation upserts and replaces the whole row;
128
234
  // cursors are keyed by (group, topic) so a deleted group takes its cursors
129
235
  // 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();
236
+ 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();
237
+ 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();
238
+ 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', [
239
+ 'group_id',
240
+ 'seq'
241
+ ]).execute();
131
242
  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
243
  'group_id',
133
244
  'topic_id'
134
245
  ]).execute();
246
+ // Decrypted app frames awaiting application. The frame ID is stable across
247
+ // redelivery, so the first staged record wins until it is completed.
248
+ 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', [
249
+ 'group_id',
250
+ 'device_id',
251
+ 'frame_id'
252
+ ]).execute();
253
+ 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', [
254
+ 'group_id',
255
+ 'procedure',
256
+ 'entity',
257
+ 'hlc'
258
+ ]).execute();
135
259
  // The peer-discovery projection: one row per (group, peer) folded from
136
260
  // authenticated `peer/announce` frames. `peer_did` is the MLS-authenticated
137
261
  // announcer, which is why the primary key is (group, peer) and there is no
@@ -158,10 +282,26 @@ import { sql } from 'kysely';
158
282
  .addColumn('payload', t.text, (col)=>col.notNull()).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).execute();
159
283
  },
160
284
  async down (db) {
285
+ await db.schema.dropTable('kubun_p2p_access_default_revocations').ifExists().execute();
286
+ await db.schema.dropTable('kubun_p2p_binding_evidence').ifExists().execute();
287
+ await db.schema.dropTable('kubun_p2p_own_agents_join_capabilities').ifExists().execute();
288
+ await db.schema.dropTable('kubun_p2p_own_agents_grants').ifExists().execute();
289
+ await db.schema.dropTable('kubun_p2p_own_agents_records').ifExists().execute();
290
+ await db.schema.dropTable('kubun_p2p_observation_repair_peers').ifExists().execute();
291
+ await db.schema.dropTable('kubun_p2p_observation_repairs').ifExists().execute();
292
+ await db.schema.dropTable('kubun_p2p_document_repair_answers').ifExists().execute();
293
+ await db.schema.dropTable('kubun_p2p_document_repairs').ifExists().execute();
294
+ await db.schema.dropTable('kubun_p2p_catalog_cursors').ifExists().execute();
295
+ await db.schema.dropTable('kubun_p2p_catalog_routes').ifExists().execute();
296
+ await db.schema.dropTable('kubun_p2p_catalog_offers').ifExists().execute();
161
297
  await db.schema.dropTable('kubun_p2p_pending_join_requests').ifExists().execute();
162
298
  await db.schema.dropTable('kubun_p2p_local_peer_profile').ifExists().execute();
163
299
  await db.schema.dropTable('kubun_p2p_peer_announcements').ifExists().execute();
300
+ await db.schema.dropTable('kubun_p2p_app_pending').ifExists().execute();
301
+ await db.schema.dropTable('kubun_p2p_log_handler_completion').ifExists().execute();
164
302
  await db.schema.dropTable('kubun_p2p_app_cursor').ifExists().execute();
303
+ await db.schema.dropTable('kubun_p2p_app_outbox').ifExists().execute();
304
+ await db.schema.dropTable('kubun_p2p_app_outbox_state').ifExists().execute();
165
305
  await db.schema.dropTable('kubun_p2p_group_anchor').ifExists().execute();
166
306
  await db.schema.dropTable('kubun_p2p_commit_journal').ifExists().execute();
167
307
  await db.schema.dropTable('kubun_p2p_control_requests').ifExists().execute();
@@ -175,6 +315,7 @@ import { sql } from 'kysely';
175
315
  await db.schema.dropTable('kubun_p2p_group_delegation').ifExists().execute();
176
316
  await db.schema.dropTable('kubun_p2p_circle_members').ifExists().execute();
177
317
  await db.schema.dropTable('kubun_p2p_circles').ifExists().execute();
318
+ await db.schema.dropTable('kubun_p2p_group_leave_requests').ifExists().execute();
178
319
  await db.schema.dropTable('kubun_p2p_group_members').ifExists().execute();
179
320
  await db.schema.dropTable('kubun_p2p_groups').ifExists().execute();
180
321
  }
@@ -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;