This commit is contained in:
2026-09-19 08:01:55 +07:00
parent aa3bd71351
commit 907347d002
14 changed files with 264 additions and 277 deletions
+33 -19
View File
@@ -251,25 +251,36 @@ Validation: `cargo test -p concord` — 46 passed, 0 failed (the 80-blob
the base64 record). `cargo clippy -p concord --all-targets` and
`cargo fmt -p concord --check` are clean.
### Phase 4 — duplication and hygiene (independent, low risk)
### Phase 4 — duplication and hygiene (independent, low risk) — DONE
1. Add `store::load_states(client)` and delete the app-side state-document scan
(`crates/community/src/sync.rs:100-137`).
2. Export the `concord/` state prefix from concord; delete the app-side copies
(`store.rs:26`, `sync.rs:15-16`).
3. Collapse the duplicated tag parsers (`cord03.rs:614-654` vs
`guestbook.rs:496-530`) and the identical `ChatError`/`GuestbookError`
enums.
4. Remove never-varied parameters where the change is local: `banned_at` from
`complete_memberlist` (doc admits "empty today"), `cache_rumor -> Result<()>`
once nothing reads the bool, `snapshot_authority`/`ephemeral`/`query_rumors
(until)` if no scheduled flow needs them.
5. Tighten visibility of internal-only `pub` items in `cord04`
(`edition_hash`, `fold`, `FoldResult`, `bootstrap_head`, `HeadSelection`,
`parse_banlist`, `Role::parse`, `Grant::parse`).
6. Fix doc drift: `backfill` arity (`docs/concord-usage.md:212`), `save_state`
parameter (`:487`), `init` signature (`:431-432`), and refresh the "Not wired
up yet" section (`:528-545`) once Phase 2 lands.
1. DONE — `store::load_states(client)` added (with a direct `store` test), the
app-side state-document scan in `sync::load` is gone.
2. DONE — `store::STATE_PREFIX` is public; the app-side `concord/` literals are
gone, and subscription ids reuse the exported prefix.
3. DONE — the shared rumor tag readers and error live in a new `cords::rumor`
module (`RumorError`, `tag`, `required`, `value`, `pubkey`,
`optional_citation`), re-exported as `cord03::ChatError` and
`cord02::guestbook::GuestbookError`. `cord06` keeps its own narrower
`RekeyError`, which the plan scoped out.
4. RETAINED — none of the "never-varied parameters" were removed. Each is
load-bearing for a flow the fold or a writer already implements (D1):
- `complete_memberlist`'s `banned_at` is read by the fold and is exercised
with a non-empty map by `join_leave_kick_and_snapshot_converge_to_one_memberlist`;
`docs/concord-usage.md` already promises to fill it once the banlist head's
timestamp is plumbed through.
- `cache_rumor -> Result<bool>` is read by `backfill` to drop expired rumors.
- `coalesce`'s `snapshot_authority` gates which snapshots apply; passing
`None` today is a policy, not a dead parameter.
- `seal_rumor(ephemeral)` and the `until` cursors on `backfill`/`query_rumors`
select protocol modes and paging.
5. DONE — tightened `cord04` visibility: `edition_hash`, `fold`, `FoldResult`,
`bootstrap_head`, `parse_banlist`, `Role::parse` and `Grant::parse` are no
longer `pub`. `HeadSelection` stays `pub` because the public `fold_head`
returns it.
6. DONE — doc drift fixed: the store takes `&Client` throughout (including
`load_state`/`load_states`/`query_rumors`, not just the writers), `backfill`
arity, `set_pin_list`'s missing `.await`, the GPUI `init` signature and
registry names, and the "Not wired up yet" registry bullet.
---
@@ -284,7 +295,7 @@ Per D1 these stay, but they should be understood as unwired, not live:
| `cord04::pins` | ~550 | none |
| `cord03` write path + `fold` + `plane_keys` | ~340 | only `open` / `expiration_of` |
| guestbook / list write paths | ~240 | `open`, `coalesce`, `complete_memberlist`, `is_live` |
| `store` paging / purge / query / load_state | ~180 | `cache_rumor`, `save_state` |
| `store` paging / purge / query / load_state(s) | ~180 | `cache_rumor`, `save_state`, `load_states` |
Truly unreferenced even by tests (safe candidates, but kept per D1):
`CommunityInvite::expired`, `GroupKey::pk_hex`, `From<[u8; 32]>` impls,
@@ -310,6 +321,9 @@ Truly unreferenced even by tests (safe candidates, but kept per D1):
- Phase 2 adds the app-level test: seed a `CommunityState` via
`store::save_state`, drive `CommunityRegistry`, assert a subscription is made
and an inbound wrap folds into the community.
- Phase 4: `cargo test -p concord -p community` (47 + 1 passed),
`cargo clippy -p concord -p community --all-targets`, and
`cargo fmt -p concord -p community --check` are all clean.
## 6. Immediate unblock
+25 -22
View File
@@ -73,7 +73,7 @@ let editions: Vec<ParsedEdition> = minted
.collect::<Result<_, _>>()?;
let mut state = CommunityState::from_genesis(&minted, &editions, added_at_ms)?;
save_state(database, &state).await?;
save_state(&client, &state).await?;
```
Put the community's relay list into `state.relays` and add those relays to the
@@ -192,7 +192,7 @@ for wrap in &wraps {
let Ok((opened, rumor)) = cord03::open(wrap, group, &channel, *epoch) else {
continue;
};
store::cache_rumor(database, &channel, &opened).await?;
store::cache_rumor(&client, &channel, &opened).await?;
rumors.push(rumor);
}
@@ -209,13 +209,13 @@ let messages = fold(&rumors, Timestamp::now(), |actor, citation, author| {
Relay history pages through the local cache:
```rust
let page = store::backfill(client, database, &channel, &held, until, 50).await?;
let cached = store::query_rumors(database, &channel, None, 50).await?;
let page = store::backfill(client, &channel, &held, until, 50).await?;
let cached = store::query_rumors(&client, &channel, None, 50).await?;
```
`backfill` walks newest-first across every held epoch, caches what it opens, and
stops on a short page. `query_rumors` is the read path when the group keys are
gone. Run `store::purge_expired(database, &channel, now)` on the same cadence as
gone. Run `store::purge_expired(client, &channel, now)` on the same cadence as
any other local sweep — the timer is cooperative, so the local store is the
artifact that has to forget.
@@ -275,7 +275,7 @@ let head_content = control.pin_content(&community_id, &channel).unwrap_or("");
let read = cord04::pins::read_list(head_content, |epoch| channel_group_key(&root, &channel, epoch).ok());
let content = cord04::pins::publishable(&read, channel_is_private, &plane, epoch)?;
let (wrap, _) = writer.set_pin_list(
&my_keys, &community_id, &channel, &content, head, citation, now_secs)?;
&my_keys, &community_id, &channel, &content, head, citation, now_secs).await?;
```
Reading is verification: `read_list` decodes either content form (public, or
@@ -428,7 +428,8 @@ the NIP-44 size cap, both protocol constants.
## GPUI integration
`crates/concord` stays GPUI-free. The UI layer adds a registry global and one
`crates/concord` stays GPUI-free; the registry and sync engine live in
`crates/community`. That layer adds a registry global and one
entity per community, and moves every decrypt, verification, fold and I/O off
the foreground thread.
@@ -437,13 +438,13 @@ the foreground thread.
Same shape as `ChatRegistry`:
```rust
pub fn init(window: &mut Window, cx: &mut App) {
ConcordRegistry::set_global(cx.new(|cx| ConcordRegistry::new(window, cx)), cx);
pub fn init(cx: &mut App) {
CommunityRegistry::set_global(cx.new(CommunityRegistry::new), cx);
}
impl ConcordRegistry {
impl CommunityRegistry {
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalConcordRegistry>().0.clone()
cx.global::<GlobalCommunityRegistry>().0.clone()
}
}
```
@@ -452,8 +453,8 @@ Call it after `cord03::init` in `desktop/src/main.rs` and `web/src/lib.rs`, and
subscribe to `NostrRegistry` for `SignerChanged` so the communities reset with
the account.
- `ConcordRegistry` holds `communities: Vec<Entity<Community>>`, an index by
`CommunityId`, and `tasks: SmallVec<[Task<Result<(), Error>>; 2]>`.
- `CommunityRegistry` holds `communities: Vec<Entity<Community>>`, an index by
`CommunityId`, and `tasks: SmallVec<[Task<Result<()>>; 2]>`.
- `Community` owns one `CommunityState`, the last `ControlFold`, the member list
and the channel list. Views render `Entity<Community>`; no protocol state
lives in a view.
@@ -468,7 +469,7 @@ A background task never touches an entity. It sends results through a bounded
```rust
let (signal_tx, signal_rx) = flume::bounded::<Signal>(256);
let database = client.database().clone();
let client = client.clone();
// Background: open, verify, fold — no entities.
self.ingress = Some(cx.background_spawn(async move {
@@ -477,7 +478,7 @@ self.ingress = Some(cx.background_spawn(async move {
continue;
};
let (opened, rumor) = cord03::open(wrap, &plane.group, &plane.channel, plane.epoch)?;
store::cache_rumor(database.as_ref(), &plane.channel, &opened).await?;
store::cache_rumor(&client, &plane.channel, &opened).await?;
signal_tx.send_async(Signal::Chat { channel: plane.channel, rumor }).await?;
}
Ok(())
@@ -492,8 +493,8 @@ self.consumer = Some(cx.spawn(async move |this, cx| {
}));
```
- `client.database()` is a `&Arc<dyn NostrDatabase>` and `store::save_state`
wants `&dyn NostrDatabase`, so clone the `Arc` and pass `database.as_ref()`.
- Every store function takes the `&Client` and reaches the database through
`client.database()`, so clone the `Client` into the background task.
- Keep long-lived tasks in fields — dropping a `Task` cancels it. Assign `None`
to an `Option<Task<_>>` before respawning it; a signer change replaces both
the listener and the consumer.
@@ -537,11 +538,13 @@ client.subscribe(filter).with_id(sub_id).await?;
## Not wired up yet
- **No registry and no sync engine.** `crates/concord` has no subscriptions, no
`init`, and no `Entity<Community>`; the UI owns subscribing, routing a wrap to
the plane whose address it carries, and rebuilding a subscription when a plane's
address changes (join, channel added, rekey folded). GPUI integration above is
the shape to build, not code that exists.
- **`crates/concord` stays protocol-only; the registry lives in
`crates/community`.** `concord` has no subscriptions, no `init`, and no
`Entity<Community>`; `community::CommunityRegistry` owns one `Entity<Community>`
per state document, subscribes when a community's plane set changes, and
re-folds on an inbound wrap. Nothing observes `CommunityEvent` yet, and
`CommunityRegistry::create` persists the genesis locally without publishing it
to the metadata's relays.
- **Account-key writers take any signer, not `&Keys`.** `genesis`,
`ControlWriter`, the guestbook and chat `seal_rumor`s, the `list` builders, and
the `cord05` invite writers (`build_direct_invite` / `unwrap_direct_invite`,