@kubun/store-p2p 0.10.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/migrations.js CHANGED
@@ -1 +1,185 @@
1
- export function getP2PMigrations(e){let t=e.types,a=e.functions.now;return{"0-init":{async up(e){await e.schema.createTable("kubun_p2p_groups").ifNotExists().addColumn("id",t.text,e=>e.notNull().primaryKey()).addColumn("name",t.text,e=>e.notNull()).addColumn("description",t.text,e=>e.notNull().defaultTo("")).addColumn("created_by",t.text,e=>e.notNull()).addColumn("hlc",t.text,e=>e.notNull()).addColumn("created_at",t.timestamp,e=>e.defaultTo(a).notNull()).addColumn("updated_at",t.timestamp).execute(),await e.schema.createTable("kubun_p2p_group_members").ifNotExists().addColumn("group_id",t.text,e=>e.notNull()).addColumn("member_did",t.text,e=>e.notNull()).addColumn("role",t.text,e=>e.notNull().defaultTo("member")).addColumn("hlc",t.text,e=>e.notNull()).addColumn("removed_at_hlc",t.text).addColumn("created_at",t.timestamp,e=>e.defaultTo(a).notNull()).addColumn("updated_at",t.timestamp).addPrimaryKeyConstraint("pk_p2p_group_members",["group_id","member_did"]).execute(),await e.schema.createIndex("idx_p2p_group_members_did").ifNotExists().on("kubun_p2p_group_members").column("member_did").execute(),await e.schema.createTable("kubun_p2p_circles").ifNotExists().addColumn("id",t.text,e=>e.notNull().primaryKey()).addColumn("group_id",t.text,e=>e.notNull()).addColumn("name",t.text,e=>e.notNull()).addColumn("description",t.text,e=>e.notNull().defaultTo("")).addColumn("catalog_ids",t.json,e=>e.notNull().defaultTo("[]")).addColumn("hlc",t.text,e=>e.notNull()).addColumn("removed_at_hlc",t.text).addColumn("created_at",t.timestamp,e=>e.defaultTo(a).notNull()).addColumn("updated_at",t.timestamp).execute(),await e.schema.createIndex("idx_p2p_circles_group").ifNotExists().on("kubun_p2p_circles").column("group_id").execute(),await e.schema.createTable("kubun_p2p_circle_members").ifNotExists().addColumn("circle_id",t.text,e=>e.notNull()).addColumn("member_did",t.text,e=>e.notNull()).addColumn("role",t.text,e=>e.notNull().defaultTo("member")).addColumn("hlc",t.text,e=>e.notNull()).addColumn("removed_at_hlc",t.text).addColumn("created_at",t.timestamp,e=>e.defaultTo(a).notNull()).addColumn("updated_at",t.timestamp).addPrimaryKeyConstraint("pk_p2p_circle_members",["circle_id","member_did"]).execute(),await e.schema.createIndex("idx_p2p_circle_members_did").ifNotExists().on("kubun_p2p_circle_members").column("member_did").execute(),await e.schema.createTable("kubun_p2p_delegation_tokens").ifNotExists().addColumn("jti",t.text,e=>e.notNull()).addColumn("grantor",t.text,e=>e.notNull()).addColumn("audience",t.text,e=>e.notNull()).addColumn("token",t.text,e=>e.notNull()).addColumn("resource",t.text,e=>e.notNull()).addColumn("act",t.text,e=>e.notNull()).addColumn("exp","integer",e=>e.notNull()).addColumn("group_id",t.text,e=>e.notNull()).addColumn("hlc",t.text,e=>e.notNull()).addColumn("created_at",t.timestamp,e=>e.defaultTo(a).notNull()).addColumn("updated_at",t.timestamp).addPrimaryKeyConstraint("pk_p2p_delegation_tokens",["grantor","audience","resource"]).execute(),await e.schema.createIndex("idx_p2p_delegation_tokens_grantor_audience").ifNotExists().on("kubun_p2p_delegation_tokens").columns(["grantor","audience"]).execute(),await e.schema.createIndex("idx_p2p_delegation_tokens_audience_exp").ifNotExists().on("kubun_p2p_delegation_tokens").columns(["audience","exp"]).execute(),await e.schema.createIndex("idx_p2p_delegation_tokens_jti").ifNotExists().on("kubun_p2p_delegation_tokens").column("jti").execute(),await e.schema.createTable("kubun_p2p_revoked_capabilities").ifNotExists().addColumn("jti",t.text,e=>e.notNull().primaryKey()).addColumn("revoker_did",t.text,e=>e.notNull()).addColumn("revoked_iat","integer",e=>e.notNull()).addColumn("revocation_token",t.text,e=>e.notNull()).addColumn("verified_at","integer").addColumn("cap_exp","integer").addColumn("group_id",t.text,e=>e.notNull()).addColumn("hlc",t.text,e=>e.notNull()).addColumn("created_at",t.timestamp,e=>e.defaultTo(a).notNull()).addColumn("updated_at",t.timestamp).execute(),await e.schema.createIndex("idx_p2p_revoked_capabilities_verified_cap_exp").ifNotExists().on("kubun_p2p_revoked_capabilities").columns(["verified_at","cap_exp"]).execute(),await e.schema.createTable("kubun_p2p_group_mls_state").ifNotExists().addColumn("group_id",t.text,e=>e.notNull()).addColumn("device_id",t.text,e=>e.notNull()).addColumn("mls_state",t.binary,e=>e.notNull()).addColumn("credential",t.text,e=>e.notNull()).addColumn("epoch","integer",e=>e.notNull()).addColumn("root_capability",t.text,e=>e.notNull().defaultTo("")).addColumn("updated_at",t.timestamp,e=>e.defaultTo(a).notNull()).addPrimaryKeyConstraint("pk_p2p_group_mls_state",["group_id","device_id"]).execute(),await e.schema.createTable("kubun_p2p_sync_peers").ifNotExists().addColumn("id",t.text,e=>e.notNull().primaryKey()).addColumn("peer_did",t.text,e=>e.notNull().unique()).addColumn("endpoint",t.text,e=>e.notNull()).addColumn("mode",t.text,e=>e.notNull()).addColumn("allowed_users",t.json,e=>e.notNull()).addColumn("priority",t.text,e=>e.notNull().defaultTo("0")).addColumn("trust_level",t.text,e=>e.notNull()).addColumn("config",t.json,e=>e.notNull()).addColumn("created_at",t.timestamp,e=>e.defaultTo(a).notNull()).addColumn("updated_at",t.timestamp,e=>e.defaultTo(a).notNull()).execute(),await e.schema.createTable("kubun_p2p_sync_checkpoints").ifNotExists().addColumn("id",t.text,e=>e.notNull().primaryKey()).addColumn("peer_did",t.text,e=>e.notNull()).addColumn("scope",t.text,e=>e.notNull()).addColumn("checkpoint_hlc",t.text,e=>e.notNull()).addColumn("updated_at",t.timestamp,e=>e.defaultTo(a).notNull()).execute(),await e.schema.createTable("kubun_p2p_broadcast_log").ifNotExists().addColumn("doc_id",t.text,e=>e.notNull()).addColumn("version",t.text,e=>e.notNull()).addColumn("mls_group_id",t.text,e=>e.notNull()).addColumn("broadcast_at",t.timestamp,e=>e.defaultTo(a).notNull()).addPrimaryKeyConstraint("pk_p2p_broadcast_log",["doc_id","version","mls_group_id"]).execute(),await e.schema.createIndex("idx_p2p_broadcast_log_doc").ifNotExists().on("kubun_p2p_broadcast_log").column("doc_id").execute(),await e.schema.createTable("kubun_p2p_hubs").ifNotExists().addColumn("id",t.text,e=>e.notNull().primaryKey()).addColumn("label",t.text).addColumn("url",t.text,e=>e.notNull().unique()).addColumn("server_did",t.text).addColumn("created_at",t.timestamp,e=>e.defaultTo(a).notNull()).addColumn("updated_at",t.timestamp).execute(),await e.schema.createTable("kubun_p2p_group_hubs").ifNotExists().addColumn("group_id",t.text,e=>e.notNull()).addColumn("hub_id",t.text,e=>e.notNull()).addColumn("created_at",t.timestamp,e=>e.defaultTo(a).notNull()).addPrimaryKeyConstraint("pk_p2p_group_hubs",["group_id","hub_id"]).execute(),await e.schema.createIndex("idx_p2p_group_hubs_hub").ifNotExists().on("kubun_p2p_group_hubs").column("hub_id").execute()},async down(e){await e.schema.dropTable("kubun_p2p_group_hubs").ifExists().execute(),await e.schema.dropTable("kubun_p2p_hubs").ifExists().execute(),await e.schema.dropTable("kubun_p2p_broadcast_log").ifExists().execute(),await e.schema.dropTable("kubun_p2p_sync_checkpoints").ifExists().execute(),await e.schema.dropTable("kubun_p2p_sync_peers").ifExists().execute(),await e.schema.dropTable("kubun_p2p_group_mls_state").ifExists().execute(),await e.schema.dropTable("kubun_p2p_revoked_capabilities").ifExists().execute(),await e.schema.dropTable("kubun_p2p_delegation_tokens").ifExists().execute(),await e.schema.dropTable("kubun_p2p_circle_members").ifExists().execute(),await e.schema.dropTable("kubun_p2p_circles").ifExists().execute(),await e.schema.dropTable("kubun_p2p_group_members").ifExists().execute(),await e.schema.dropTable("kubun_p2p_groups").ifExists().execute()}}}}
1
+ import { sql } from 'kysely';
2
+ /**
3
+ * Partial unique index keeping one commit-hub designation per group. Named here
4
+ * because the store recognises a lost first-bind race by this name appearing in
5
+ * the driver's constraint-violation error.
6
+ */ export const COMMIT_HUB_UNIQUE_INDEX = 'uq_p2p_group_hubs_commit';
7
+ /**
8
+ * A shipped migration is immutable: once a database has run it, editing it
9
+ * changes nothing on that database and silently diverges the schema from the
10
+ * code. Every schema change after a release goes in a NEW migration.
11
+ *
12
+ * That rule is suspended for this release, deliberately. The eight migrations
13
+ * this store had accumulated are folded into `0-init`, which is now the whole
14
+ * schema in one readable place. It is a BREAKING change — a database created
15
+ * before it will not pick the new shape up and must be recreated — and it costs
16
+ * nothing here only because the release already requires recreating local
17
+ * databases for unrelated reasons.
18
+ *
19
+ * Reinstate the rule the moment this ships: the next schema change is a new
20
+ * migration, and `0-init` is immutable again.
21
+ */ export function getP2PMigrations(ctx) {
22
+ const t = ctx.types;
23
+ const now = ctx.functions.now;
24
+ const init = {
25
+ async up (db) {
26
+ // 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();
30
+ // Group members
31
+ 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
+ .addColumn('hlc', t.text, (col)=>col.notNull())// HLC at which this peer learned the member was removed from the MLS
33
+ // group. NULL means the member is active; a non-null value tombstones
34
+ // the row so read-ACL / sync-scope queries exclude it while the
35
+ // apply-time check can still compare against the removal HLC.
36
+ .addColumn('removed_at_hlc', t.text).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addColumn('updated_at', t.timestamp).addPrimaryKeyConstraint('pk_p2p_group_members', [
37
+ 'group_id',
38
+ 'member_did'
39
+ ]).execute();
40
+ await db.schema.createIndex('idx_p2p_group_members_did').ifNotExists().on('kubun_p2p_group_members').column('member_did').execute();
41
+ // 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
43
+ // open (every group member has implicit access), 0 closed. Integer 0/1
44
+ // mirrors the catalog `active` flag, keeping the read-back value uniform
45
+ // across SQLite and Postgres. A circle is closed until a settings fold
46
+ // projects it open.
47
+ .addColumn('open', 'integer', (col)=>col.notNull().defaultTo(0))// NULL means the circle is active; a non-null value tombstones the row
48
+ // so reads exclude it while the removal HLC stays comparable.
49
+ .addColumn('removed_at_hlc', t.text).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addColumn('updated_at', t.timestamp).execute();
50
+ await db.schema.createIndex('idx_p2p_circles_group').ifNotExists().on('kubun_p2p_circles').column('group_id').execute();
51
+ // Circle members
52
+ await db.schema.createTable('kubun_p2p_circle_members').ifNotExists().addColumn('circle_id', t.text, (col)=>col.notNull()).addColumn('member_did', t.text, (col)=>col.notNull()).addColumn('role', t.text, (col)=>col.notNull().defaultTo('member')).addColumn('hlc', t.text, (col)=>col.notNull())// NULL means the circle member is active; a non-null value tombstones
53
+ // the row so reads exclude it while the removal HLC stays comparable.
54
+ .addColumn('removed_at_hlc', t.text).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addColumn('updated_at', t.timestamp).addPrimaryKeyConstraint('pk_p2p_circle_members', [
55
+ 'circle_id',
56
+ 'member_did'
57
+ ]).execute();
58
+ await db.schema.createIndex('idx_p2p_circle_members_did').ifNotExists().on('kubun_p2p_circle_members').column('member_did').execute();
59
+ // Group-delegation edge. The delegation token and revocation rows live in
60
+ // the core delegation store (group-agnostic); this thin edge records the
61
+ // one group a delegation `jti` was granted in, so the p2p layer can still
62
+ // resolve a cap's group for revoke-broadcast targeting and listing
63
+ // 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();
65
+ // 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', [
67
+ 'group_id',
68
+ 'device_id'
69
+ ]).execute();
70
+ // Sync peers
71
+ await db.schema.createTable('kubun_p2p_sync_peers').ifNotExists().addColumn('id', t.text, (col)=>col.notNull().primaryKey()).addColumn('peer_did', t.text, (col)=>col.notNull().unique()).addColumn('endpoint', t.text, (col)=>col.notNull())// No `trust_level`, `allowed_users`, `priority` or `mode`. They were
72
+ // stored and never read: what a peer may see is decided per document at
73
+ // the far end, and a locally-written row is not a credential. `priority`
74
+ // in particular looked like the ranking knob after ranking had moved to
75
+ // the discovery projection, which is the only thing that knows
76
+ // availability. `mode` accumulated two incompatible vocabularies for a
77
+ // question nothing asked of this row — direction comes from `syncPeer`'s
78
+ // argument, per call.
79
+ .addColumn('config', t.json, (col)=>col.notNull()).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addColumn('updated_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).execute();
80
+ // Sync checkpoints
81
+ await db.schema.createTable('kubun_p2p_sync_checkpoints').ifNotExists().addColumn('id', t.text, (col)=>col.notNull().primaryKey()).addColumn('peer_did', t.text, (col)=>col.notNull()).addColumn('scope', t.text, (col)=>col.notNull()).addColumn('checkpoint_hlc', t.text, (col)=>col.notNull()).addColumn('updated_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).execute();
82
+ // Broadcast log
83
+ await db.schema.createTable('kubun_p2p_broadcast_log').ifNotExists().addColumn('doc_id', t.text, (col)=>col.notNull()).addColumn('version', t.text, (col)=>col.notNull()).addColumn('mls_group_id', t.text, (col)=>col.notNull()).addColumn('broadcast_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addPrimaryKeyConstraint('pk_p2p_broadcast_log', [
84
+ 'doc_id',
85
+ 'version',
86
+ 'mls_group_id'
87
+ ]).execute();
88
+ await db.schema.createIndex('idx_p2p_broadcast_log_doc').ifNotExists().on('kubun_p2p_broadcast_log').column('doc_id').execute();
89
+ // Hubs (device-local, never synced — no hlc column)
90
+ 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
+ // Group hubs (join table, device-local — no hlc column). A group commits
92
+ // through exactly one bound hub; `is_commit_hub` marks which. Integer 0/1
93
+ // mirrors the circle `open` flag so the value reads back uniformly across
94
+ // SQLite and Postgres.
95
+ await db.schema.createTable('kubun_p2p_group_hubs').ifNotExists().addColumn('group_id', t.text, (col)=>col.notNull()).addColumn('hub_id', t.text, (col)=>col.notNull()).addColumn('is_commit_hub', 'integer', (col)=>col.notNull().defaultTo(0)).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addPrimaryKeyConstraint('pk_p2p_group_hubs', [
96
+ 'group_id',
97
+ 'hub_id'
98
+ ]).execute();
99
+ await db.schema.createIndex('idx_p2p_group_hubs_hub').ifNotExists().on('kubun_p2p_group_hubs').column('hub_id').execute();
100
+ // The single designation is a database constraint, not a convention a
101
+ // writer upholds. A transaction alone cannot uphold it: two devices
102
+ // binding their first hub concurrently touch DIFFERENT rows, so under
103
+ // READ COMMITTED neither blocks the other, both read "no commit hub" and
104
+ // both claim it — the group then commits to two logs, which is the fork
105
+ // the designation exists to prevent. Partial unique indexes carry the
106
+ // same meaning on both adapter families (SQLite >= 3.8, Postgres).
107
+ await db.schema.createIndex(COMMIT_HUB_UNIQUE_INDEX).ifNotExists().unique().on('kubun_p2p_group_hubs').column('group_id').where(sql.ref('is_commit_hub'), '=', 1).execute();
108
+ // ControlRequest work queue: the client-visible record of a control-plane
109
+ // request (a ledger write, an invite, a removal) from enqueue to
110
+ // settlement, keyed by the same id the commit journal carries for the
111
+ // attempt. Rows are retained after settling so a client holding the id can
112
+ // read the outcome back, and leave only on an explicit ack. `result` is
113
+ // nullable because most requests produce nothing to hand back; it is
114
+ // written on settle and deleted with the row on ack, so the retention of
115
+ // 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();
117
+ await db.schema.createIndex('idx_p2p_control_requests_group').ifNotExists().on('kubun_p2p_control_requests').columns([
118
+ 'group_id',
119
+ 'enqueued_at'
120
+ ]).execute();
121
+ // CommitJournal store (backs the `@kumiai/rpc` CommitJournal port).
122
+ // `group_id` is the primary key: a single slot per group, so a fresh `put`
123
+ // 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();
125
+ // App-lane anchor + per-topic read positions (back the `@kumiai/rpc`
126
+ // `AnchorStore` and `AppCursorStore` ports). The anchor table is a single
127
+ // slot per group, so a rotation upserts and replaces the whole row;
128
+ // cursors are keyed by (group, topic) so a deleted group takes its cursors
129
+ // 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();
131
+ 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
+ 'group_id',
133
+ 'topic_id'
134
+ ]).execute();
135
+ // The peer-discovery projection: one row per (group, peer) folded from
136
+ // authenticated `peer/announce` frames. `peer_did` is the MLS-authenticated
137
+ // announcer, which is why the primary key is (group, peer) and there is no
138
+ // column for a body-supplied DID: nothing self-asserted may pick the row.
139
+ await db.schema.createTable('kubun_p2p_peer_announcements').ifNotExists().addColumn('group_id', t.text, (col)=>col.notNull()).addColumn('peer_did', t.text, (col)=>col.notNull()).addColumn('label', t.text, (col)=>col.notNull().defaultTo('')).addColumn('availability', t.text, (col)=>col.notNull()).addColumn('capabilities', t.json, (col)=>col.notNull().defaultTo('[]')).addColumn('hlc', t.text, (col)=>col.notNull()).addColumn('announced_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addColumn('announced_epoch', 'integer', (col)=>col.notNull().defaultTo(0)).addPrimaryKeyConstraint('pk_p2p_peer_announcements', [
140
+ 'group_id',
141
+ 'peer_did'
142
+ ]).execute();
143
+ // Answers "which groups have I seen this device in", the read a roster
144
+ // removal and a cross-group route lookup both need.
145
+ await db.schema.createIndex('idx_p2p_peer_announcements_peer').ifNotExists().on('kubun_p2p_peer_announcements').column('peer_did').execute();
146
+ // This device's own advertisement, device-wide. One row, keyed on the
147
+ // device's DID rather than on a fixed sentinel, so a profile written under
148
+ // one identity is never read back under another.
149
+ await db.schema.createTable('kubun_p2p_local_peer_profile').ifNotExists().addColumn('peer_did', t.text, (col)=>col.notNull().primaryKey()).addColumn('label', t.text, (col)=>col.notNull().defaultTo('')).addColumn('availability', t.text, (col)=>col.notNull()).addColumn('capabilities', t.json, (col)=>col.notNull().defaultTo('[]')).addColumn('updated_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).execute();
150
+ // The private half of a join request, held between `prepareJoinRequest`
151
+ // and `completeJoin`. The offline exchange spans a human round-trip, so
152
+ // the two calls can be different processes — a CLI device is always two.
153
+ // Keyed on the requesting DID: a second request replaces the first rather
154
+ // than leaving dead key material behind, and the row is deleted the moment
155
+ // it is consumed.
156
+ await db.schema.createTable('kubun_p2p_pending_join_requests').ifNotExists().addColumn('did', t.text, (col)=>col.notNull().primaryKey())// The caller's own encoding, opaque here: a key package holds BigInts
157
+ // and binary, which plain JSON cannot carry.
158
+ .addColumn('payload', t.text, (col)=>col.notNull()).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).execute();
159
+ },
160
+ async down (db) {
161
+ await db.schema.dropTable('kubun_p2p_pending_join_requests').ifExists().execute();
162
+ await db.schema.dropTable('kubun_p2p_local_peer_profile').ifExists().execute();
163
+ await db.schema.dropTable('kubun_p2p_peer_announcements').ifExists().execute();
164
+ await db.schema.dropTable('kubun_p2p_app_cursor').ifExists().execute();
165
+ await db.schema.dropTable('kubun_p2p_group_anchor').ifExists().execute();
166
+ await db.schema.dropTable('kubun_p2p_commit_journal').ifExists().execute();
167
+ await db.schema.dropTable('kubun_p2p_control_requests').ifExists().execute();
168
+ await db.schema.dropIndex(COMMIT_HUB_UNIQUE_INDEX).ifExists().execute();
169
+ await db.schema.dropTable('kubun_p2p_group_hubs').ifExists().execute();
170
+ await db.schema.dropTable('kubun_p2p_hubs').ifExists().execute();
171
+ await db.schema.dropTable('kubun_p2p_broadcast_log').ifExists().execute();
172
+ await db.schema.dropTable('kubun_p2p_sync_checkpoints').ifExists().execute();
173
+ await db.schema.dropTable('kubun_p2p_sync_peers').ifExists().execute();
174
+ await db.schema.dropTable('kubun_p2p_group_mls_state').ifExists().execute();
175
+ await db.schema.dropTable('kubun_p2p_group_delegation').ifExists().execute();
176
+ await db.schema.dropTable('kubun_p2p_circle_members').ifExists().execute();
177
+ await db.schema.dropTable('kubun_p2p_circles').ifExists().execute();
178
+ await db.schema.dropTable('kubun_p2p_group_members').ifExists().execute();
179
+ await db.schema.dropTable('kubun_p2p_groups').ifExists().execute();
180
+ }
181
+ };
182
+ return {
183
+ '0-init': init
184
+ };
185
+ }
@@ -0,0 +1,36 @@
1
+ import type { Adapter } from '@kubun/db-adapter';
2
+ import type { Kysely } from 'kysely';
3
+ import type { AppCursor, GroupAnchor, P2PStoreTables } from '../tables.js';
4
+ /** App-lane anchors and per-topic read cursors. */
5
+ export type AppLaneOps = {
6
+ /**
7
+ * Write the group's anchor slot, REPLACING whatever it held. Single slot per
8
+ * group: the anchor moves as a pair (secret + the epoch it was exported at),
9
+ * so a rotation overwrites both or neither.
10
+ */
11
+ putGroupAnchor(params: {
12
+ groupID: string;
13
+ secret: Uint8Array;
14
+ epoch: number;
15
+ }): Promise<void>;
16
+ /** The group's anchor, or `undefined` if the group has never stored one. */
17
+ getGroupAnchor(groupID: string): Promise<GroupAnchor | undefined>;
18
+ /**
19
+ * Write this device's read position on one topic, replacing the previous
20
+ * position for that (group, topic). `position` is opaque: stored verbatim.
21
+ */
22
+ putAppCursor(params: {
23
+ groupID: string;
24
+ topicID: string;
25
+ position: string;
26
+ }): Promise<void>;
27
+ /**
28
+ * The read position on one topic, or `undefined` if this device has never
29
+ * processed a frame on it.
30
+ */
31
+ getAppCursor(params: {
32
+ groupID: string;
33
+ topicID: string;
34
+ }): Promise<AppCursor | undefined>;
35
+ };
36
+ export declare function createAppLaneOps(db: Kysely<P2PStoreTables>, adapter: Adapter): AppLaneOps;
@@ -0,0 +1,38 @@
1
+ export function createAppLaneOps(db, adapter) {
2
+ return {
3
+ async putGroupAnchor (params) {
4
+ const values = {
5
+ group_id: params.groupID,
6
+ secret: adapter.encodeBinary(params.secret),
7
+ epoch: params.epoch
8
+ };
9
+ await db.insertInto('kubun_p2p_group_anchor')// Single slot per group: a rotation REPLACES both halves together. The
10
+ // secret and the epoch it was exported at both feed topic derivation,
11
+ // so a row mixing one epoch's secret with another's number would derive
12
+ // a topic no member is on.
13
+ .values(values).onConflict((oc)=>oc.column('group_id').doUpdateSet((eb)=>({
14
+ secret: eb.ref('excluded.secret'),
15
+ epoch: eb.ref('excluded.epoch')
16
+ }))).execute();
17
+ },
18
+ async getGroupAnchor (groupID) {
19
+ return await db.selectFrom('kubun_p2p_group_anchor').selectAll().where('group_id', '=', groupID).executeTakeFirst();
20
+ },
21
+ // --- App-lane cursor operations ---
22
+ async putAppCursor (params) {
23
+ await db.insertInto('kubun_p2p_app_cursor').values({
24
+ group_id: params.groupID,
25
+ topic_id: params.topicID,
26
+ position: params.position
27
+ }).onConflict((oc)=>oc.columns([
28
+ 'group_id',
29
+ 'topic_id'
30
+ ]).doUpdateSet((eb)=>({
31
+ position: eb.ref('excluded.position')
32
+ }))).execute();
33
+ },
34
+ async getAppCursor (params) {
35
+ return await db.selectFrom('kubun_p2p_app_cursor').selectAll().where('group_id', '=', params.groupID).where('topic_id', '=', params.topicID).executeTakeFirst();
36
+ }
37
+ };
38
+ }
@@ -0,0 +1,153 @@
1
+ import type { Adapter } from '@kubun/db-adapter';
2
+ import type { Kysely } from 'kysely';
3
+ import type { Circle, CircleMember, InsertCircle, InsertCircleMember, P2PStoreTables } from '../tables.js';
4
+ /** Circles, their membership rows, and the fold-authoritative projections. */
5
+ export type CircleOps = {
6
+ createCircle(circle: InsertCircle): Promise<void>;
7
+ /**
8
+ * Active-only by default (`removed_at_hlc IS NULL`). Pass
9
+ * `{ includeRemoved: true }` to also see tombstoned circles — used by the
10
+ * apply path so a `circle:create`/`circle:update` can HLC-compare against a
11
+ * tombstoned row and resurrect it, and by the catch-up responder.
12
+ */
13
+ getCircle(id: string, options?: {
14
+ includeRemoved?: boolean;
15
+ }): Promise<Circle | undefined>;
16
+ /**
17
+ * Batch fetch circles by ID. Returns a Map keyed by ID; missing IDs are
18
+ * absent from the map. Single SQL query with `WHERE id IN (...)`. Empty
19
+ * input short-circuits with an empty Map (no SQL).
20
+ *
21
+ * Caller is responsible for deduplicating IDs.
22
+ */
23
+ getCircles(ids: Array<string>): Promise<Map<string, Circle>>;
24
+ updateCircle(id: string, update: Partial<Pick<InsertCircle, 'name' | 'description' | 'catalog_ids' | 'hlc'>>): Promise<void>;
25
+ deleteCircle(id: string): Promise<void>;
26
+ /**
27
+ * Tombstone an existing circle row at `hlc` using earliest-wins LWW: the
28
+ * removal sticks only when no removal is recorded yet, or when `hlc` precedes
29
+ * the stored removal HLC. A removal for a `circleID` with no local row is a
30
+ * no-op — absence means there is no circle to tombstone, and no row is
31
+ * created.
32
+ */
33
+ markCircleRemoved(circleID: string, hlc: string): Promise<void>;
34
+ /**
35
+ * Active-only by default (`removed_at_hlc IS NULL`). Pass
36
+ * `{ includeRemoved: true }` to include tombstoned circles for the catch-up
37
+ * responder.
38
+ */
39
+ listCirclesByGroup(groupID: string, options?: {
40
+ includeRemoved?: boolean;
41
+ }): Promise<Array<Circle>>;
42
+ addCircleMember(member: InsertCircleMember): Promise<void>;
43
+ /**
44
+ * Soft-delete a circle member by tombstoning the row with `hlc`: earliest-wins
45
+ * LWW, ignore-if-absent. The row stays in the table so the removal HLC stays
46
+ * comparable.
47
+ */
48
+ removeCircleMember(circleID: string, memberDID: string, hlc: string): Promise<void>;
49
+ /**
50
+ * The row as stored, INCLUDING a tombstoned one — unlike every sibling reader,
51
+ * whose default is active-only. A revival has to see the row it revives, so the
52
+ * caller decides what removal means and any presence check must read
53
+ * `removed_at_hlc` as well.
54
+ */
55
+ getCircleMember(circleID: string, memberDID: string): Promise<CircleMember | undefined>;
56
+ /**
57
+ * Active-only by default (`removed_at_hlc IS NULL`). Pass
58
+ * `{ includeRemoved: true }` to include tombstoned members for the catch-up
59
+ * responder.
60
+ */
61
+ listCircleMembers(circleID: string, options?: {
62
+ includeRemoved?: boolean;
63
+ }): Promise<Array<CircleMember>>;
64
+ getCirclesForMember(memberDID: string): Promise<Array<Circle>>;
65
+ /**
66
+ * Circle access predicate over `circleIDs`. True when, for any active circle in
67
+ * the set, the member either holds an active explicit `circle_members` row OR
68
+ * the circle is open (`open = 1`) and the member is an active member of the
69
+ * circle's group. Evaluates `open` at CURRENT state, so delisting a circle from
70
+ * the group's `openCircleIDs` revokes implicit access immediately. Empty
71
+ * `circleIDs`, a tombstoned circle, and a tombstoned membership all return
72
+ * false.
73
+ */
74
+ isMemberOfAnyCircle(memberDID: string, circleIDs: Array<string>): Promise<boolean>;
75
+ /**
76
+ * WHICH of `circleIDs` the member reaches, each with its group, under exactly
77
+ * the predicate {@link isMemberOfAnyCircle} tests — same explicit-or-open arms,
78
+ * same current-state `open`, same tombstone gating. One query for the whole set,
79
+ * for a caller that must know per circle rather than "any": asking
80
+ * `isMemberOfAnyCircle` once per id costs a round trip per id, and collapsing
81
+ * the set into one "any" answer loses the per-circle conjunction an
82
+ * authorization rule needs.
83
+ *
84
+ * The group comes back with the id because a caller conjoining circle access
85
+ * with group membership would otherwise re-read each circle to learn its group,
86
+ * and that second read is where a tombstone filter could drift from this one. A
87
+ * tombstoned or unknown id is simply absent. Order is not guaranteed.
88
+ */
89
+ listAccessibleCircles(memberDID: string, circleIDs: Array<string>): Promise<Array<{
90
+ id: string;
91
+ groupID: string;
92
+ }>>;
93
+ /**
94
+ * Sync-owner expansion for a circle. Returns the explicit active members, plus
95
+ * — when the circle is open — every active member of the circle's group. A
96
+ * closed, tombstoned, or unknown circle yields the explicit members only, so a
97
+ * closed circle's owner set is unchanged.
98
+ */
99
+ listCircleOwners(circleID: string): Promise<Array<string>>;
100
+ /**
101
+ * The UNION of {@link listCircleOwners} over several circles: three queries for
102
+ * the whole set instead of three per circle. Callers that only need the merged
103
+ * owner set — sync-scope resolution — get it without a per-circle round trip.
104
+ * Per-circle attribution is deliberately not returned; the open arm makes it
105
+ * ambiguous anyway, since one group member can be an owner via several open
106
+ * circles of the same group. A no-op returning an empty array for an empty
107
+ * list.
108
+ */
109
+ listOwnersForCircles(circleIDs: Array<string>): Promise<Array<string>>;
110
+ /**
111
+ * Reproject the group's current open set onto its circle rows: set `open = 1`
112
+ * for every circle in `openCircleIDs` and `open = 0` for every other circle in
113
+ * the group. Idempotent and current-state authoritative — the caller derives
114
+ * `openCircleIDs` from the settings fold, so this write simply materializes it.
115
+ */
116
+ reconcileCircleOpenFlags(groupID: string, openCircleIDs: Array<string>): Promise<void>;
117
+ /**
118
+ * Fold-authoritative circle projection write: unconditionally set the row for
119
+ * `id` to the supplied derived state. Distinct from the LWW writers —
120
+ * `createCircle` (insert-only), `updateCircle` (update-only, clears the
121
+ * tombstone) and `markCircleRemoved` (earliest-wins LWW). A reproject derives
122
+ * `hlc` from the winning active entry and `removedAtHLC` from the winning
123
+ * tombstone (or null when active), then overwrites every projected column so
124
+ * the row equals exactly what the ledger fold decides regardless of prior
125
+ * contents. A removed circle keeps a tombstone row (`removed_at_hlc` set), not
126
+ * a hard delete, so `includeRemoved` reads and the read-ACL joins keep working.
127
+ */
128
+ reconcileCircleProjection(params: {
129
+ id: string;
130
+ groupID: string;
131
+ name: string;
132
+ description: string;
133
+ catalogIDs: Array<string>;
134
+ hlc: string;
135
+ removedAtHLC: string | null;
136
+ open: boolean;
137
+ }): Promise<void>;
138
+ /**
139
+ * Fold-authoritative circle-member projection write, the member-row analogue
140
+ * of {@link reconcileCircleProjection}: unconditionally upsert the
141
+ * `(circleID, memberDID)` row to the derived `(role, hlc, removedAtHLC)` state.
142
+ * A removed member keeps a tombstone row so the read-ACL joins still gate on
143
+ * the marker.
144
+ */
145
+ reconcileCircleMemberProjection(params: {
146
+ circleID: string;
147
+ memberDID: string;
148
+ role: string;
149
+ hlc: string;
150
+ removedAtHLC: string | null;
151
+ }): Promise<void>;
152
+ };
153
+ export declare function createCircleOps(db: Kysely<P2PStoreTables>, adapter: Adapter): CircleOps;