@kubun/store-p2p 0.11.0 → 0.12.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.
- package/lib/api.d.ts +25 -251
- package/lib/api.js +19 -590
- package/lib/index.d.ts +2 -2
- package/lib/migrations.d.ts +21 -0
- package/lib/migrations.js +106 -26
- package/lib/ops/app-lane.d.ts +36 -0
- package/lib/ops/app-lane.js +38 -0
- package/lib/ops/circles.d.ts +153 -0
- package/lib/ops/circles.js +269 -0
- package/lib/ops/control.d.ts +118 -0
- package/lib/ops/control.js +110 -0
- package/lib/ops/delegations.d.ts +25 -0
- package/lib/ops/delegations.js +27 -0
- package/lib/ops/discovery.d.ts +92 -0
- package/lib/ops/discovery.js +90 -0
- package/lib/ops/groups.d.ts +132 -0
- package/lib/ops/groups.js +259 -0
- package/lib/ops/hubs.d.ts +56 -0
- package/lib/ops/hubs.js +222 -0
- package/lib/ops/mls.d.ts +21 -0
- package/lib/ops/mls.js +50 -0
- package/lib/ops/sync.d.ts +22 -0
- package/lib/ops/sync.js +50 -0
- package/lib/tables.d.ts +106 -26
- package/package.json +21 -18
package/lib/migrations.js
CHANGED
|
@@ -1,10 +1,32 @@
|
|
|
1
|
-
|
|
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) {
|
|
2
22
|
const t = ctx.types;
|
|
3
23
|
const now = ctx.functions.now;
|
|
4
24
|
const init = {
|
|
5
25
|
async up (db) {
|
|
6
26
|
// Groups
|
|
7
|
-
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())
|
|
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();
|
|
8
30
|
// Group members
|
|
9
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.
|
|
10
32
|
.addColumn('hlc', t.text, (col)=>col.notNull())// HLC at which this peer learned the member was removed from the MLS
|
|
@@ -17,7 +39,12 @@ export function getP2PMigrations(ctx) {
|
|
|
17
39
|
]).execute();
|
|
18
40
|
await db.schema.createIndex('idx_p2p_group_members_did').ifNotExists().on('kubun_p2p_group_members').column('member_did').execute();
|
|
19
41
|
// Circles
|
|
20
|
-
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())//
|
|
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
|
|
21
48
|
// so reads exclude it while the removal HLC stays comparable.
|
|
22
49
|
.addColumn('removed_at_hlc', t.text).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addColumn('updated_at', t.timestamp).execute();
|
|
23
50
|
await db.schema.createIndex('idx_p2p_circles_group').ifNotExists().on('kubun_p2p_circles').column('group_id').execute();
|
|
@@ -36,12 +63,20 @@ export function getP2PMigrations(ctx) {
|
|
|
36
63
|
// enrichment. One group per `jti`, so `jti` is the primary key.
|
|
37
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();
|
|
38
65
|
// Group MLS state
|
|
39
|
-
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('
|
|
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', [
|
|
40
67
|
'group_id',
|
|
41
68
|
'device_id'
|
|
42
69
|
]).execute();
|
|
43
70
|
// Sync peers
|
|
44
|
-
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())
|
|
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();
|
|
45
80
|
// Sync checkpoints
|
|
46
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();
|
|
47
82
|
// Broadcast log
|
|
@@ -53,39 +88,84 @@ export function getP2PMigrations(ctx) {
|
|
|
53
88
|
await db.schema.createIndex('idx_p2p_broadcast_log_doc').ifNotExists().on('kubun_p2p_broadcast_log').column('doc_id').execute();
|
|
54
89
|
// Hubs (device-local, never synced — no hlc column)
|
|
55
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();
|
|
56
|
-
// Group hubs (join table, device-local — no hlc column)
|
|
57
|
-
|
|
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', [
|
|
58
96
|
'group_id',
|
|
59
97
|
'hub_id'
|
|
60
98
|
]).execute();
|
|
61
99
|
await db.schema.createIndex('idx_p2p_group_hubs_hub').ifNotExists().on('kubun_p2p_group_hubs').column('hub_id').execute();
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
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([
|
|
68
118
|
'group_id',
|
|
69
|
-
'
|
|
70
|
-
'entry_id'
|
|
119
|
+
'enqueued_at'
|
|
71
120
|
]).execute();
|
|
72
|
-
|
|
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', [
|
|
73
132
|
'group_id',
|
|
74
|
-
'
|
|
133
|
+
'topic_id'
|
|
75
134
|
]).execute();
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
await db.schema.createTable('kubun_p2p_projection_state').ifNotExists().addColumn('group_id', t.text, (col)=>col.notNull()).addColumn('projection', t.text, (col)=>col.notNull()).addColumn('projected_through_hlc', t.text, (col)=>col.notNull()).addColumn('updated_at', t.timestamp).addPrimaryKeyConstraint('pk_p2p_projection_state', [
|
|
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', [
|
|
82
140
|
'group_id',
|
|
83
|
-
'
|
|
141
|
+
'peer_did'
|
|
84
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();
|
|
85
159
|
},
|
|
86
160
|
async down (db) {
|
|
87
|
-
await db.schema.dropTable('
|
|
88
|
-
await db.schema.dropTable('
|
|
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();
|
|
89
169
|
await db.schema.dropTable('kubun_p2p_group_hubs').ifExists().execute();
|
|
90
170
|
await db.schema.dropTable('kubun_p2p_hubs').ifExists().execute();
|
|
91
171
|
await db.schema.dropTable('kubun_p2p_broadcast_log').ifExists().execute();
|
|
@@ -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;
|