Files
signed/docs/repo-updated-timestamp.md
T
reya 42c1229a90 fix: date repository cards from state events, not announcements
The explore list fell back to the announcement created_at because kind 30618 state events were only fetched per repository, on open. Sync them globally and refresh the list on published state events, so a card shows the last push without the user cloning first.

Open repository panels now also react to state events from relays and to locally published state events.
2026-09-25 17:36:35 +07:00

7.3 KiB

Repository card "Updated" timestamps are stale until first open

Feedback under investigation:

My repositories that I updated yesterday show up as being updated like 6 months ago, but then when I open them and they get cloned that timestamp is fixed, which means you're not reading the repository state event, only the date of the repository announcement event and the git history?

Summary

The reviewer is essentially right. The backend has logic to include kind 30618 repository state events in the "last activity" timestamp, but that logic only reads events that are already present in the local nostr database. The app never globally fetches state events for repositories the user has not opened, so for those the timestamp falls back to the announcement's created_at — the repository's creation date. Opening (and cloning) the repository fetches the state event, which is why the timestamp corrects itself afterwards.

The "git history" part of the hypothesis is not accurate for this label: no git-derived timestamp feeds the repository card. The only git-derived times in the UI are commit timestamps in commit lists and diffs, which are unrelated.

This is a data-coverage bug, not a missing computation. The code that aggregates state events is correct; it is simply starved of input on the explore list.

Where the timestamp comes from

The label is rendered in crates/workspace/src/views/repo_list.rs (L209-212):

let activity = last_activity
    .map(relative_time)
    .map(|label| SharedString::from(format!("Updated {label}")))

last_activity is computed in crates/signed_state/src/repos.rs, RepoListStore::run_refresh (L192-235), as a max over three sources:

Source Code Availability
announcement.created_at (kind 30617) L195-198 always; seeded for every listed repo
kind 30618 repository state events L200-213 only events already in the local database
NIP-34 activity (issues, PRs, patches, statuses, comments) L215-235 only local database, windowed to 90 days (ACTIVITY_WINDOW, L13-14)

The critical gap is the global sync scope. RepoListStore::subscribe_remote (crates/signed_state/src/repos.rs L131-139) only syncs:

backend.sync_bootstrap(filters::all_announcements(), cx);  // kind 30617 only
backend.sync_bootstrap(filters::deletions(), cx);

State events are fetched per repository, on open, via RepoStore:

  • repo_filters (crates/signed_state/src/repo.rs L254-270) queries [GitRepoAnnouncement, RepoState] plus activity and deletions.
  • subscribe_remote (L296-306) runs those filters against the bootstrap relays.
  • connect_announced_relays (L272-294) runs a negentropy sync of the same filters against the relays in the announcement's NIP-34 relays tag.
  • Both fire from RepoStore::new's deferred initialization (L93-112).

Publishes only ever create a state event, never re-publish the announcement: sign_state_event in crates/signed_state/src/backend.rs (L1655-1682) and the fan-out at L907-918. A repository created 6 months ago and pushed yesterday therefore has an announcement dated 6 months ago and a state event dated yesterday.

flowchart TD
    A[App start / login] --> B[RepoListStore sync: 30617 announcements + deletions]
    B --> C[Explore card: Updated = announcement created_at]
    C --> D{Repo opened?}
    D -- no --> C
    D -- yes --> E[RepoStore fetches 30617 + 30618 from bootstrap + announced relays]
    E --> F[State event stored in local DB]
    F --> G[BackendEvent NostrUpdate RepoState]
    G --> H[RepoListStore refreshes, last_activity recomputed]
    H --> I[Card shows push date]

Why the report reproduces exactly

  1. The announcement (kind 30617) is addressable and effectively static; pushes replace only the state event (kind 30618).
  2. On a database that has never fetched that repository (fresh install, another device, or wasm where the database is in-memory per session), only the announcement is known, so the card shows the creation date.
  3. Opening the repository triggers the per-repo fetch; the state event lands in the database, and RepoListStore does react to RepoState updates (crates/signed_state/src/repos.rs L69-82), so the card corrects itself.

Secondary issues found in the same area

  1. RepoStore ignores live state events. subscribe_backend (crates/signed_state/src/repo.rs L195-239) matches relay updates via Update.coordinate, which is built from a tags only (crates/signed_nostr/src/update.rs L13-21). A kind 30618 event carries d, not a, and the kind check only accepts GitRepoAnnouncement. A state update arriving from a relay therefore does not refresh an open repository panel's head. RepoListStore is unaffected because its check is kind-based.

  2. Locally published state events refresh nothing. BackendEvent::Published is ignored for RepoState by both stores (crates/signed_state/src/repos.rs L83-92 and crates/signed_state/src/repo.rs L221-231). The comment in crates/signed_state/src/backend.rs L907-909 ("publishing notifies the repository views") is inaccurate: after an in-app push, the explore card stays stale until the next unrelated refresh, sync or restart, even though the event is in the local database.

  3. Usage counts are similarly under-scoped. The Popular ranking (crates/signed_state/src/repos.rs L239-261) counts only issues, PRs and patches present locally, i.e. mostly repositories the user has opened.

  4. The Recent tab is not "recently updated". RepoFilter::Recent (crates/workspace/src/views/repo_list.rs L73-84) truncates the announcement-sorted list (crates/signed_state/src/repos.rs L189-190), so it orders by creation, not by last_activity.

  5. The 90-day activity window is deliberate. Activity-driven bumps decay after ACTIVITY_WINDOW; only state events would bump indefinitely once fetched (L13-14).

Remediation options

  • A. Global state sync (smallest change). — implemented. RepoListStore::subscribe_remote now also syncs filters::all_states() (Filter::new().kind(Kind::RepoState)) alongside the announcement sync. Kind 30618 is addressable, so relays keep one event per repository — volume comparable to announcements. Caveat: coverage depends on whether state events reach the bootstrap relays; if a grasp server is the only holder, those repositories remain unseen.

  • B. Targeted fetch for the user's own repositories. — not implemented. In sync_inbox (crates/signed_state/src/backend.rs L1126-1152), where the user's own repositories' relays are already connected, also sync filters::state(addr) per own repository. This directly addresses "my repositories updated elsewhere" at low cost. Note the existing race: sync_inbox reads the announcement list right after login, which may not be loaded yet.

  • C. Reactivity fixes. — implemented. Kind::RepoState is now included in the BackendEvent::Published refresh condition of both RepoListStore and RepoStore. RepoStore::subscribe_backend also matches relay state updates by owner plus kind — state events carry d, not a, so coordinate matching misses them — and locally published state updates by owner plus the d identifier.

A + C are implemented; B remains available if own-repository freshness on fresh profiles matters.