Compare commits

..
Author SHA1 Message Date
Vitor PamplonaandClaude Opus 5 bb95cad98b Audit fixes: one clock per record, no shared mutable list, pin the file format
Deep-audit pass over the branch. Nothing here changes what a band claims;
these are the defects that pass tests and bite later.

- record() read the clock twice PER KIND. A 40-kind map took 80 readings,
  and worse, a span's floor and ceiling were judged against two different
  instants — so a span could be accepted at one end and rejected at the
  other on a clock tick. One read, one instant, for the whole call. The
  aggregate path had the same double read and now shares it.

- legs() handed the SAME MutableList instance to every Filter in a group,
  publishing its accumulator through a public return value. Filters are
  treated as immutable everywhere else; this keeps that true by
  construction rather than by nobody having tried yet.

- The state file's round trip was asserted only for the fields, never for
  the behaviour. Three tests now pin it: per-kind spans survive a restart
  AND still narrow per kind afterwards; the ALL_KINDS sentinel survives
  its negative key through toString/toInt; and a pre-split file (min/max,
  no spans) loads as the claim it always was. Plus the rollback contract
  — `min`/`max` must remain the OUTER edges, since a binary from before
  per-kind spans reads those and would otherwise skip ground it has not
  covered.

Checked and found sound, recorded so the next reader need not re-derive
it: ConcurrentMap.snapshot() copies, so export() cannot be mutated under
a writer; Band is immutable (widen() copies its map), so a shared Band
across threads is safe; merge() keeps old.fullAt, preserving the
re-walk clock across widening; and coveringWindow does NOT regress —
a paged band gave >1 leg before this change too, and a reconciled band
still collapses to one leg and narrows the shared snapshot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 17:09:45 +00:00
Vitor PamplonaandClaude Opus 5 74145ee8f3 Code-review fixes: two ways per-kind spans could be recorded and not used
Both found in the review pass over the previous commit, both the same
shape — a band written that no lookup can reach, or reaches wrongly.

- Spans for kinds the filter never named were stored as given. Inert for
  legs(), which only looks up the filter's own kinds, but NOT for
  Band.minCreatedAt — and that is what SyncCoverageFile writes as its
  rollback-compat `min`/`max`. A relay answering with more than it was
  asked for (or a caller whose containment check runs against a
  different filter than the band is keyed by) would push that floor
  below anything the filter's kinds support, so a binary from before
  per-kind spans would read the file and over-claim. The fix, undone
  through the compatibility path it added.

- observedByKind on a filter that names NO kinds was stored per kind,
  while legs() for such a filter reads only ALL_KINDS. The band was
  recorded, persisted, and never consulted: a resume that silently did
  not resume. Collapsed to the union, which is the only claim a
  kind-less filter can make.

Why these were not in the initial diff: both live where the new per-kind
path meets an OLD assumption — that record()'s input is already scoped
to the filter, and that a band's keys are always the filter's kinds.
Neither held once callers began supplying the map themselves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:08:56 +00:00
Vitor PamplonaandClaude 42a91ffb79 SyncCoverage: one band interval cannot speak for several kinds
A band held ONE created_at interval per (relay, filter). For a filter
naming several kinds that is a claim no walk can support: ask for
`kinds: [0, 30382]`, find profiles going back years and score cards only
from last month, and the band records 2020..now for the pair. The next
run then skips that whole interior for BOTH — so score cards written
inside it are never asked for again, and nothing anywhere says so. A
long-lived kind vouched for a short-lived one.

Band.spans is now per kind. Each carries only the evidence actually
collected for it, so the profile kind keeps its wide interval and the
score kind keeps its narrow one, and legs() re-opens the interior for
the second while still skipping it for the first.

Three things keep the cost of that where it was:

- legs() REGROUPS kinds by the windows they want. Identical coverage —
  the common case, and the only case until they diverge — collapses back
  into one ask, so a filter that produced two legs still produces two
  rather than two per kind. Only a kind whose evidence genuinely differs
  earns its own.
- A finished reconcile needs no per-kind evidence and is given none:
  negentropy compares the filter's whole id set in one pass, so it
  covers every kind in the filter or none. Only the PAGED path changed.
- Filters naming no kinds keep a single span under ALL_KINDS, which is
  the same claim as before, correctly scoped to the case where it is the
  only claim available.

record() takes observedByKind, and SyncCoverage.observe() accumulates it
as events arrive — replacing the pair of hand-rolled vars each caller
kept, and moving the per-event isPlausible guard in with it. A paged
walk over a MULTI-kind filter that supplies none earns no band at all,
loudly, once: attributing one interval to every kind is exactly the
over-claim this removes, and a band that over-claims skips events
silently, which is worse than re-reading them. Single-kind filters are
untouched — there the aggregate always was the per-kind answer.

The state file gains a per-kind `spans` object and keeps `min`/`max` as
the outer edges, so a rollback to a binary from before this reads the
file and behaves as it always did. A file written BEFORE this loads its
one interval under ALL_KINDS — the old, wider claim, kept rather than
discarded because discarding it would re-download every upstream's
corpus once on upgrade. The first per-kind walk replaces it.

All 26 existing SyncCoverage tests pass unchanged, which is the evidence
that single-kind behaviour did not move. The five new ones were checked
against the pre-fix rule reinstated in place: the two behavioural ones
fail there and pass here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:02:26 +00:00
Vitor PamplonaandGitHub e822911d09 Merge pull request #3860 from vitorpamplona/claude/bridge-relay-url-parsing-okxtyw
Fix URL detection to exclude quotes from parsed URLs
2026-08-05 10:44:28 -04:00
Claude 5c56444054 fix: strip the whole punctuation tail from a detected url
Audit follow-up to the quote fix. The path, query and fragment readers only
stop on a space, and readEnd dropped a single trailing delimiter, so a quoted
link that closed a sentence kept its quote:

  He linked "https://example.com/some/path".  ->  https://example.com/some/path"
  (see "https://example.com/some/path")       ->  https://example.com/some/path"

readEnd now strips the tail in a loop. The balance check runs on every round,
so a url that legitimately ends in a matched closer still stops the strip:
`[link](…/Bitcoin_(disambiguation)).` keeps `(disambiguation)` and drops the
`).` that belongs to the sentence.

Differential run over a 4000-string corpus against the previous commit: 8 rows
change, every one of them the removal of extra trailing punctuation. No url is
gained, lost or truncated mid-string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKCAegYMF9V9Nb8FHcMvT7
2026-08-05 14:28:24 +00:00
Claude dc45477deb fix: don't glue quotes onto detected urls
A bare host wrapped in quotes ("relay.momostr.pink") was detected with the
opening quote attached, so the rendered link read `"relay.momostr.pink` and
pointed at a host that does not exist. The mirror case was also wrong: a
quoted url with a path/query/fragment kept the closing quote, because those
readers only stop on a space.

Quotes are not host characters, so they now end the current token exactly
like a space does in readDefault (covering the leading quote and a quote
glued to a previous word, e.g. `href="www.google.com"`), and they were added
to CANNOT_BEGIN_URLS_WITH / CANNOT_END_URLS_WITH so a trailing quote read as
part of a path, query or fragment is stripped on readEnd. The set covers the
ascii quotes plus the typographic family, including the guillemets below the
international-character threshold that the ascii boundary rule never cut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKCAegYMF9V9Nb8FHcMvT7
2026-08-05 14:08:01 +00:00
Vitor PamplonaandClaude Opus 5 f9bef87160 style(desktop): import Compose symbols in NotificationSettingsScreen
Follow-up to 8f8713d8 (nostr proposal 259a0bb1). CLAUDE.md forbids
fully-qualified class names inline in function bodies; the merged
proposal introduced one (androidx.compose.runtime.LaunchedEffect) and
the file already carried four more that predate it. Import them all and
reference them by simple name: LaunchedEffect, snapshotFlow,
rememberCoroutineScope, LocalWindowInfo.

Also rewrites two comments the proposal added:
- the auto-enable comment was written in the first person and described
  the author's own earlier mistake; restate it as what the code does and
  which two paths it covers.
- the "Turn on desktop notifications" comment claimed the button renders
  only when the user explicitly disabled notifications, but the guard is
  `!enabled` alone. Describe the actual condition and why it is enough.

Drops a redundant `enabled = true` on that OutlinedButton (the default).

No behaviour change. :desktopApp:compileKotlin and :commons:jvmTest green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 01:49:30 -04:00
Vitor PamplonaandGitHub a58289b62c Merge pull request #3859 from vitorpamplona/claude/yggdrasil-ipv6-compat-03mctx
Support IPv6 overlay relays (Yggdrasil) with RFC 5952 canonicalization
2026-08-05 00:36:13 -04:00
Claude 70d51cc98b fix(relay): parse the authority instead of substring-matching the url
Audit of the IPv6 work found a family of bugs in isLocalHost/isOnion, most
predating this branch, all with one root cause: the predicates ran `contains`
over the whole url rather than parsing the authority. These decide whether a
relay is exempt from Tor, and relay urls arrive from other people (NIP-65
lists, relay hints, r tags), so they are attacker-controlled input.

- A path could impersonate the host. `wss://evil.example.com/127.0.0.1`
  answered isLocalHost() == true, so any relay list could hand the app a url
  that silently dropped its own Tor routing. The IPv6 lookup added earlier on
  this branch had the same flaw via `/[fd00::1]`, and IPv6 canonicalization
  could rewrite a path outright, corrupting the url.

- `.onion:8080` never matched the `.onion/` test, so an onion relay on an
  explicit port was not treated as onion at all: never forced onto Tor, and its
  hostname went to the clearnet DNS resolver. The fully-qualified `.onion.`
  spelling missed the same way.

- Host tests were case-sensitive, but fix() asks them before the RFC 3986 pass
  folds case, so LOCALHOST:8080 and ABC.ONION:8080 were handed a wss:// scheme
  neither host can serve.

- Private IPv4 was substring-matched, which missed 10.0.0.5, 172.16.3.4 and
  127.1.2.3 — a LAN relay got wss:// and was dialed through Tor — while
  matching 192.168.evil.com and 127.0.0.1.evil.com, registrable domains that
  could therefore exempt themselves from Tor. Same for notlocalhost.example.com
  against `contains("localhost")`.

- A `://` inside a path was read as a scheme separator, so
  `relay.com/x://127.0.0.1` read its path as the authority.

Fixes: a shared hostStart/hostEnd/hostEndWithoutPort trio bounds every test to
the authority, strips :port and trailing dots and validates the scheme; private
ranges are parsed via a new Ipv4 util rather than substring-matched;
comparisons are case-insensitive per RFC 4343; NormalizedRelayUrl.isOnion()
delegates instead of keeping a second, weaker copy of the test.

No performance regression: the old form ran six full-string scans, the new one
bounds its work to the authority and rejects a DNS host from an IP parse on one
character. Ipv6.isLiteral gained a two-colon gate so the schemeless host:port
case answers without allocating the parser's buffer.

Ipv6 is now pinned by a differential test: 4000 random addresses round-trip
against java.net.InetAddress in both directions, and the canonical form is
asserted equal to OkHttp's host for the same address, so the relay identity the
app stores provably matches the host it dials.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQr8CDsznzCRUeB5tS8VYk
2026-08-05 04:22:59 +00:00
Claude 067d68b89c feat(relay): canonicalize IPv6 relay urls and support overlay meshes
Closes the four gaps the previous commit characterized for relays on an
Yggdrasil overlay, where every relay is an IPv6 literal in 0200::/7 served
over plain ws:// (no DNS, no CA-issuable certificate).

New quartz/utils/Ipv6.kt: pure-Kotlin literal parsing, RFC 5952 canonical
formatting and range classification. No java.net, so it works on every KMP
target.

- Canonicalize the bracketed host in RelayUrlNormalizer.norm(). RFC 4291 lets
  one address be spelled many ways and the RFC 3986 pass only folded hex case,
  so two spellings survived as two NormalizedRelayUrl values for one host —
  and that value keys the connection pool, the relay-list sets, the NIP-11
  cache and the per-relay stats, so the app dialed one relay twice. The
  canonical form matches what OkHttp renders when it dials; the tests assert
  that agreement differentially. Relay lists rehydrate through normalizeOrNull,
  so stored entries fold on load and no migration is needed.

- Add isOverlayNetwork() for 0200::/7 and default those relays to ws://:
  nothing can issue a certificate for the range, so wss:// could only fail its
  handshake, and the overlay already encrypts end to end.

- Teach isLocalHost() the IPv6 twins of the literals it already knew — ::1,
  fc00::/7 and fe80::/10 — so a relay on one skips TLS and Tor and stays out of
  published relay lists, as its IPv4 equivalent already did.

- Never route an overlay relay through Tor: the range is unroutable there, so
  proxying guaranteed failure rather than privacy. TorRelayEvaluation covers
  both the Android and desktop relay paths; RoleBasedHttpClientBuilder covers
  non-relay HTTP.

- Bracket a bare IPv6 literal automatically (what yggdrasilctl getSelf prints),
  but only when the whole string parses as an address, so host:port and
  addressable pointers still fall through. RelayUrlEditField now shows an error
  instead of no-opping, fixing the silent Add button for all invalid input.

Mesh relays are still published in NIP-65 and offered by the outbox model; the
plan doc explains why that is left as a maintainer's call, and records that no
live socket test was possible here (the container has no IPv6 stack).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQr8CDsznzCRUeB5tS8VYk
2026-08-04 22:28:07 +00:00
Claude 129401bdaf test(relay): characterize Yggdrasil/IPv6 relay handling
Assesses how the app fares when relays live on an Yggdrasil overlay, where
every relay is a bracketed IPv6 literal in 0200::/7 served over plain ws://
(no DNS, no CA-issuable certificate).

The happy path works: a hand-typed ws://[...]:port normalizes, survives the
RFC 3986 pass and is dialed by OkHttp; nothing in the stack is IPv4-only and
cleartext is already permitted globally.

Four gaps are pinned by the new characterization tests:

1. RelayUrlNormalizer folds hex case but not zero-compression, so two legal
   spellings of one address yield two NormalizedRelayUrl values while OkHttp
   collapses them to one host — duplicate sockets, REQs and stat entries.
2. isLocalHost() does not know 0200::/7, so a schemeless literal defaults to
   wss:// and can only fail its TLS handshake.
3. An unbracketed literal (what yggdrasilctl getSelf prints) is rejected, and
   RelayUrlEditField.submitRelay has no else branch — the Add button silently
   does nothing.
4. TorRelayEvaluation classifies mesh relays as "new", so with Tor on they are
   dialed through the SOCKS proxy, which cannot route 0200::/7.

No behavior is changed. quartz/plans/2026-08-04-yggdrasil-ipv6-relays.md records
the full assessment, the NIP-65/outbox propagation consequences of publishing a
key-derived mesh address, and what could not be verified here (the analysis
container has no IPv6 stack, so nothing below the socket was exercised).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQr8CDsznzCRUeB5tS8VYk
2026-08-04 21:36:22 +00:00
Vitor PamplonaandClaude Opus 5 8f8713d825 Merge PR: fix(desktop): auto-enable master notif switch when OS permission already granted
Merges nostr proposal 259a0bb1 into main:
- fix(desktop): auto-enable master notif switch when OS permission already granted

Follow-up to e9475dd0, which only flipped the master notification switch on
the NotRequested -> Granted path. Adds NotificationSettings.wasExplicitlyDisabled()
(backed by a new java.util.prefs "explicitly_disabled" key) so the Settings
screen can tell "off because it defaults off on first launch" from "off
because the user turned it off", and a LaunchedEffect that auto-enables the
switch in the former case when the OS permission is Granted or NotApplicable.
Adds a "Turn on desktop notifications" recovery button for the deliberate
opt-out path.

Note: on Windows/Linux permissionState is NotApplicable from startup, so the
master switch now auto-enables the first time the user opens Notification
Settings, overriding the off-by-default first-launch state.

Verified before merge: :commons:jvmTest (4 new hermetic tests) and
:desktopApp:compileKotlin both green on top of current main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 17:34:02 -04:00
Vitor PamplonaandClaude Opus 5 21b02d780f Merge PR: ci: publish linux-arm64 desktop, amy, and geode release assets
Merges nostr proposal 1d98285c into main:
- ci: publish linux-arm64 desktop, amy, and geode release assets

Adds ubuntu-24.04-arm legs to the build-desktop, build-cli and build-geode
release matrices so aarch64 Linux users get .deb/.rpm/.AppImage/.flatpak/
.tar.gz for the desktop app plus amy and geode bundles. Parametrizes the
appimagetool fetch, the portable archive names and the Flatpak bundle name
by arch, computes the AppImage multiarch lib path from uname -m at launch,
and extends the desktop release-deb smoke test to arm64.

Verified before merge: the appimagetool 1.9.0 aarch64 SHA256 pin matches
the upstream release, and secp256k1-kmp-jni-jvm-linux ships a
linux-aarch64 libsecp256k1-jni.so so signing works on arm64.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 17:32:30 -04:00
Vitor PamplonaandGitHub d3bd7a45b9 Merge pull request #3852 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-04 11:56:22 -04:00
vitorpamplonaandgithub-actions[bot] a5d2d153c8 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-04 15:40:56 +00:00
Vitor PamplonaandGitHub 3a702add57 Merge pull request #3857 from vitorpamplona/claude/relay-url-normalizer-mggnkh
NIP-66 relay monitoring: streaming probes, read/write checks, URL fixes
2026-08-04 11:38:07 -04:00
Claude 5ec35c7772 feat(quartz): default the read test to kind 0, limit 1
A kind-0, limit-1 REQ works everywhere: purpose relays (purplepag.es)
reject kind-less filters outright, and practically every relay stores
some profile. Verified against production — purplepag.es's read side now
measures instead of going unobserved. Pass a different kinds list to
probe a specific shelf, or null for a kind-less query on relays known to
allow one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK
2026-08-04 15:35:22 +00:00
Claude 9a4f6c6cdd feat(quartz): optional kinds on the read-test filter (production finding)
Verified the new probe surface end-to-end against production relays
(probeFlow streaming, readWriteCheck, signed 30166 templates). One
compatibility finding: purpose relays like purplepag.es reject any REQ
that names no kind ('blocked: filters must specify at least one kind'),
leaving their read side unobserved. readTestFilter/readWriteCheck now
take an optional kinds list for those; the default stays kind-less
because naming kinds also narrows the query on every other relay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK
2026-08-04 15:26:54 +00:00
Claude c767298368 fix(quartz): audit fixes — foreign-OK confirmation bug, normalizer hot-path allocation
Audit findings across the branch, each verified with a failing test or
measurement before the fix:

- publishAndCollectResults counted an OK from a relay OUTSIDE relayList
  (same event id — a probe-wave straggler, or any republish of the same
  event to a different relay set) toward its confirmation window, ending
  the wait loop early and misreporting still-pending listed relays as
  NO_RESPONSE. The OK branch now carries the same relayList guard the
  onCannotConnect/onDisconnected branches always had. Regression test
  proves the failure without the guard. readWriteCheck additionally
  varies the probe event content per wave so wave N's confirmation window
  can never match wave N-1's event id at all.

- RelayUrlNormalizer.fix() called trimEnd('%','2','0') unconditionally,
  allocating a full string copy for ANY url merely ending in '%', '2' or
  '0' — which includes every relay port ending in zero (wss://host:3030).
  Now gated on endsWith("%20"), keeping the hot path allocation-free;
  semantics unchanged (test pins both the trim and the untouched-port
  cases).

- amy relay probe --file: unreadable file is now a clean bad_args error
  instead of a stack trace, and skipped onion urls are counted and
  reported (file_onion_skipped) instead of vanishing from the tally.

- probeFlow KDoc now states that a slow collector eats into the current
  wave's absolute deadline (answers are still recorded; silent relays get
  less listening time), not just that it delays the next wave.

Verified non-issues: androidx.collection LruCache is internally locked
(safe for CachedNip11Fetcher/normalizer concurrency); probeWave's
per-terminal emission cannot lose or double-emit verdicts (remaining-set
guard, data maps read at emission time); existing publish callers all
benefit from the OK guard rather than depending on the old behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK
2026-08-04 15:14:17 +00:00
Vitor PamplonaandGitHub ad3ce4d1a1 Merge pull request #3856 from vitorpamplona/fix/filters-changed-compares-only-first-filter
fix(relay): compare every filter in FiltersChanged, not just the first
2026-08-04 11:11:53 -04:00
Vitor PamplonaandClaude Opus 5 0d0117061a fix(relay): compare every filter in FiltersChanged, not just the first
`needsToResendRequest(List, List)` used a non-local `return` inside
`forEachIndexed`, so the loop always returned on iteration 0 and only
`filters[0]` was ever compared. A subscription whose first filter happened
to be unchanged reported "no resend needed" however much the rest had
changed, leaving the relay serving a stale filter set and the app silently
missing events. Only the size check offered any protection, so the bug was
invisible whenever the filter count stayed constant.

Replaces the loop with an indexed scan over all filters, which also drops
the lambda allocation and matches the hot-path style in this package.

Adds FiltersChangedTest. 3 of its 9 cases fail on the unfixed code — all of
them changes beyond index 0 — while the other 6 pass both before and after,
pinning the blast radius to exactly the buggy behaviour. Coverage includes
the deliberate `since`-moves-forward exemption, which must not trigger a
resend on any index.

Note for reviewers: PoolRequests.kt:490 and :528 use this inverted as a
"same as last" refusal check, so those become stricter — filter sets that
differ only beyond index 0 were previously treated as identical and will
now correctly be treated as changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:05:45 -04:00
Claude fd5bd994a1 feat(quartz): read+write relay checks — observed facts only, no NIP-11 claims
RelayProber.readWriteCheck(relays, signer) is the deeper check pair for
relays already proven live (warm sockets from a probe that just ran):

- READ: a real limit-1 REQ the relay must query its store for, timed
  REQ→first answer (honest rtt-read on an open socket).
- WRITE: one ephemeral RelayProbeWriteTest event signed by the monitor
  key, timed publish→OK (honest rtt-write). An OK false is a measured
  policy answer, kept with its NIP-01 machine-readable reason; only
  silence leaves the write side unobserved (writeAccepted = null).

publishAndCollectResults now stamps each OK with its elapsedMs (a
rejection is still a round trip; -1 when the relay never answered), so
any caller gets write latency for free.

toDiscoveryEventTemplate(readWrite = ...) folds the pair into the 30166
template: rtt-read/rtt-write when measured, R auth / R pow when the
write was refused with auth-required:/pow:. NIP-11-derived tags (N
supported NIPs, k kinds, T type) are deliberately NOT emitted — those
are relay self-claims, and publishing them under a monitor signature
without per-NIP compliance tests would launder claims into
measurements. Per-NIP/per-kind compliance suites can come later as
opt-in checks; open/read/write is the default surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK
2026-08-04 14:41:15 +00:00
Claude a7eec1d605 feat(quartz): NIP-66 check options — read-test filters, write-test event, cached NIP-11 fetcher
RelayProber.probe()/probeFlow() take a filters option choosing the check:
LIVENESS_FILTERS (default, impossible-id REQ — EOSE proves liveness with no
payload) or readTestFilter(limit = 1) — a REQ the relay must actually work
for, querying and streaming real events, making Verdict.rttEoseMs a genuine
read test.

RelayProbeWriteTest.build() creates the write-check event: ephemeral kind
20166 (never stored by compliant relays) carrying a NIP-40 expiration tag
60s out as belt-and-braces for relays that store unknown ephemeral kinds.
Publish it under the monitor key, time the OK for rtt-write, map rejection
prefixes to R requirement tags — an OK false still proves the write path.

Nip11Fetcher is the missing fetch seam for relay information documents,
mirroring Nip05Fetcher: the interface lives in commonMain,
OkHttpNip11Fetcher (jvmAndroid) does the Accept: application/nostr+json
GET, and CachedNip11Fetcher wraps any implementation with a TTL cache —
successes trusted for a day, failures remembered for five minutes so a
census doesn't hammer hosts that just refused.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK
2026-08-04 14:18:05 +00:00
Claude d9a58950ec feat(quartz): stream NIP-66 probe verdicts and expose them as signable 30166 templates
RelayProber.probeFlow(urls) is a cold Flow that emits each relay's Verdict
the moment the relay resolves (EOSE, CLOSED or connect failure) instead of
at the end of the whole census; only silent relays wait for their wave's
deadline. probeWave now resolves verdicts per-terminal, so the batch
probe() shares the same path.

Verdict.toDiscoveryEventTemplate() renders a verdict as an UNSIGNED
kind:30166 template (d = normalized url, n network type, rtt-open when
reachable, R auth when the probe hit a NIP-42 auth-required CLOSED) so an
external consumer signs with its own monitor key:

    prober.probeFlow(urls).map { it.toDiscoveryEventTemplate() }
        .collect { publish(signer.sign(it)) }

rtt-eose is deliberately never published as rtt-read: it is measured from
the wave start (dial + TLS + queueing + read), and aggregators rank on
rtt values. The RelayObserver/RelayMonitor path supplies honest
rtt-read/rtt-write from real traffic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK
2026-08-04 13:57:00 +00:00
Claude ba7cc72dd2 feat(cli): amy relay probe --file to census external relay-url candidates
Feeds a file of raw candidate urls (one per line) through the same
RelayUrlNormalizer the app uses, then probes the surviving clearnet set
alongside the store's known universe. Rejected and onion counts are
reported (file_urls/file_normalized/file_rejected in the JSON output),
and results land in the NIP-66 kind:30166 reachability cache keyed by
the normalized url as d-tag, as usual.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK
2026-08-04 13:23:16 +00:00
Claude 156176f5fe fix(quartz): stop RelayUrlNormalizer from accepting urls that can never be relays
Validated against a 45k-entry corpus of relay-url hints exported from real
events (317k tag occurrences). The normalizer was converting ~30k distinct
https:// urls with paths (Mastodon/bridge actor urls from proxy tags, web
pages, images) into wss:// addresses that can never answer, wasting
connection attempts and relay-pool slots.

- http(s) → ws(s) scheme swap now only applies to bare hosts
  (host[:port] plus optional trailing slash); an http url with a path,
  query or fragment is a web resource, not a mistyped relay.
- Authority validation for all schemes: rejects empty hosts, userinfo
  (@), percent-encoding and commas in the host, and paths that start
  with // (the signature of a second pasted url, e.g. wss://https//host).
- Interior whitespace and backslashes reject the whole string (multiple
  urls or prose in one field).
- Zero-width characters (U+200B..D, U+2060, BOM) are stripped instead of
  corrupting the parse (wss://\u200Bnos.lol previously normalized to the
  scheme-less //nos.lol/).
- Schemeless candidates must look like host[:port] (single colon, numeric
  port), rejecting addressable pointers (31990:pubkey:dtag) and bare
  scheme leftovers (wss:) before the expensive RFC 3986 parse.
- Protocol-relative //host/ inputs normalize as wss:// instead of
  resolving to https://.
- normalizeOrNull now double-checks the parser output still starts with
  ws(s):// and rejects otherwise.

Corpus impact: 30,014 garbage urls (30,333 events) now rejected, 0 real
relays lost (all 15,162 kept urls normalize byte-identically), 6 broken
outputs fixed. fix() itself stays allocation-free on the happy path
(~357ns vs ~318ns per call on the garbage-heavy corpus).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK
2026-08-04 13:23:06 +00:00
Vitor PamplonaandGitHub 1a25557bdb Merge pull request #3855 from vitorpamplona/sync-accessories-from-vespa-relay
quartz/geode: sync accessories from vespa-relay, InsertOutcome.Failed contract, mirror resume coverage
2026-08-04 01:58:29 -04:00
Claude b48b87a60b Merge remote-tracking branch 'origin/main' into sync-accessories-from-vespa-relay
# Conflicts:
#	quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/IngestQueue.kt
#	quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt
#	quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt
2026-08-04 05:44:17 +00:00
Claude cdbd550405 Fix audit findings across the sync accessories and their consumers
quartz:
- SQLiteEventStore: classify per-row savepoint errors — policy refusals
  (blocked:/constraint/not allowed) stay Rejected, everything else is now
  Failed, so disk-full no longer masquerades as 2M duplicate rejections
- IEventStore.batchInsert default: rethrow CancellationException and map
  unknown throws to Failed (re-offering a duplicate is idempotent;
  dropping a good event on a transient store error is not)
- IngestQueue: rethrow CancellationException instead of stamping a
  cancelled batch Failed and continuing
- HostStrikes: make the eviction verdict exactly-once under concurrency
  (deadHosts.add is the atomic gate) and re-check produced before
  publishing
- SyncCoverage: bound the identity fingerprint cache (a caller minting
  fresh Filter instances per cycle could grow it forever); legs() gains a
  floor parameter so a complete band re-opens its older span when the
  caller's window deepens; coveringWindow no longer treats a fully
  covered relay as needing the whole filter
- PagingWindowProgress: accept single-second windows (a band's re-read
  edge leg is exactly that shape)

geode:
- MirrorWorker: cap reconciledThrough at the leg's own ceiling — the
  older leg of a resumed catch-up no longer stamps the band complete
  through 'now' before the newer leg has run (silent event loss for up
  to fullResyncSeconds if that leg failed)
- MirrorWorker: run negentropy and the paged fallback by hand instead of
  negentropySyncOrFetch: drops the O(delivered-ids) dedup set from the
  mirror path, and a fallback resets the observed span so a band never
  claims interior ranges only a half-finished reconcile scattered over
- MirrorWorker: clamp a paged band's ceiling to the snapshot instant so
  one future-dated event cannot suppress the next boot's newer leg
- MirrorWorker.close(): join the workers (bounded) so the final coverage
  flush carries the last records
- Main: gate the coverage file on the store actually being persistent —
  database.file with in_memory=true (the default) persisted bands over a
  volatile store, and the next boot skipped the backfill over an empty
  database; honor --db overrides
- SyncCoverageFile: request ATOMIC_MOVE explicitly; fix the restore/dirty
  comment
- Import summary now prints the failed count; document
  mirror_sync_state_file in config.example.toml
2026-08-04 05:22:40 +00:00
Vitor PamplonaandGitHub c029b271e7 Merge pull request #3854 from vitorpamplona/claude/nip50-grammar-extractor-rejections
nip50Search: search grammar + weighted field extraction; nip01Core: shared store-semantics rules
2026-08-04 01:06:36 -04:00
Claude 4081ef1681 nip01Core: one owner rule, one supersession rule, one tag-name rule
Three semantics rules each existed as multiple independent copies:

- Event.owner() (gift-wrap recipient controls the wrap, else the
  author — NIP-09/62 authority) was derived inline in
  EventIndexesModule and again in EventStoreProjection.ownerOf.
- The NIP-01 replaceable tiebreak (newest created_at, ties to the
  lexically smallest id) lived in EventStoreProjection.supersedes,
  in SQL, and downstream.
- "Indexable tag name" was spelled `length == 1` in four places,
  which admits "5" and "#" — names the NIP-01 #x filter space
  (single a-zA-Z letters) cannot address, letting stores disagree
  about which tags filters reach. isIndexableTagName encodes the
  NIP-01 rule; converging FilterIndex and the SQLite
  IndexingStrategy on it deliberately tightens single-char
  non-letter tag names out of the index.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfwV3xgMSmfxy16ujGPxGW
2026-08-04 05:00:40 +00:00
Claude 804b850095 Audit fixes: empty exclusions, missed fallback, one vocabulary
An all-dash search token ("--") stripped to an empty exclusion that
toSearchString/stripExtensions round-tripped into a REQUIRED "-" term
reaching SQLite FTS; it is now dropped at parse. IngestQueue's
batchInsert fallback was the one site still hand-writing "insert
failed" (unprefixed) while every other path emits
RejectionReason.INSERT_FAILED. RejectionReason no longer duplicates
the NIP-01 prefixes MachineReadablePrefix already owns, and the
expiration trigger now rejects with the same words as the Kotlin
pre-check instead of its own spelling. Tests pin the "--" drop,
consecutive quoted spans, text after a closing quote, the extractor's
fallback tier, blank-content normalization, and the
unparseable-buzz-content hashtag seam; extractor KDoc now states
where the trimmed/non-empty and never-empty-Profile guarantees
actually live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfwV3xgMSmfxy16ujGPxGW
2026-08-04 05:00:40 +00:00
Claude 42fc73f6cb nip50Search: per-kind weighted search-field extraction
SearchableEvent.indexableContent() flattens a kind's searchable text
into one blob, so a weighted full-text backend (title above summary
above body) had to re-derive the decomposition itself — and drift
every time a kind's parsing changed here. SearchFieldExtractor now
lives beside the kinds it decomposes: title-like accessors primary,
summary/description secondary, body tertiary, kind-0-shaped metadata
in profile roles, with an indexableContent() fallback so every
searchable kind, current or future, is covered.

IndexableFields is a sealed shape — Profile or Tiered — so a kind
cannot mix identity fields with content tiers, and each shape
declares its own website role (a profile's homepage; a content kind's
affiliation URLs). Multi-valued roles are carried UNJOINED, as lists:
hashtag and location tags ride raw beside the tiers (filled by the
one tiers() funnel every content branch uses, so no branch can forget
them and profile shapes never see them), and separator or weighting
choices — hashtags at summary weight, "\n" vs " ", arrays vs joined
columns — belong to the backend, not the library. Empty extractions
always normalize to None.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfwV3xgMSmfxy16ujGPxGW
2026-08-04 04:38:36 +00:00
Claude 19d8e3069d Align the sync accessories with quartz vocabulary; geode catch-up resumes
Renames from review: SyncBands -> SyncCoverage ("sync" reads negentropy-ish
in quartz, and coverage is the role — the bands are the records), and
PagingProgress moves into relay.client.paging as PagingWindowProgress,
beside RelayLoadingCursors and RelayPagingProgress, with its docs swept from
"walk" dialect to quartz's pagination vocabulary and cross-references
delineating the three: cursors are in-memory positions for demand-driven UI
paging, the window progress is fraction/ETA for a bulk pagination over a
known window, coverage is persistent intervals that license skipping work.

geode adopts both halves of the new contract. MirrorWorker counts
InsertOutcome.Failed in its own `failed` counter instead of folding it into
`rejected`, and the down catch-up gains resume memory: SyncCoverageFile
persists SyncCoverage next to the event database (admin state-file
convention, temp-file + atomic move, daemon flush), and runCatchUpDown asks
only for the legs outside the covered band. Bands are keyed on the stable
scoped filter — never the boot window, whose since/until change every start
— and clamped to the window, which only slides forward, so an old band can
never license skipping a range an earlier boot could not ask about. A clean
reconcile records completeness through its snapshot instant; a paged
fallback earns only the span it saw. For an upstream without NIP-77 this
turns the every-boot full re-download of the backfill window into a
resumed walk.

Off unless wired: MirrorWorker's coverage parameter defaults to null and
in-memory stores keep no state file, so existing tests and setups are
unchanged. Full :quartz:jvmTest and :geode:test pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y4Pi9YYMhdzTFxRiV2jF9R
2026-08-04 04:24:19 +00:00
Vitor PamplonaandGitHub 9231195890 Merge pull request #3853 from vitorpamplona/claude/quartz-codebase-skills-mrzz5e
docs: Add three store-implementer skills (event-store-semantics, nip85, searchable-events)
2026-08-04 00:12:36 -04:00
Claude 564cafedc4 Replace insertBisecting with a Failed outcome on the batchInsert contract
Bisecting existed to reconstruct per-event attribution after a store threw a
batch-wide exception. Better to never lose the attribution: batchInsert's
contract now requires per-row isolation, with a third outcome telling whose
fault a miss was. Rejected is the EVENT's fault (duplicate, expired, invalid,
blocked) and is final; Failed is the STORE's fault (schema drift, a failed
feed, a resource error) — the event was good, it is lost unless re-offered,
and a rising Failed count means the store is broken rather than that
upstreams send junk. Throwing is reserved for failures with no per-event
answer (engine unreachable, transaction never started), readable as "nothing
in this batch was written".

Consumers updated: RelaySession maps Failed to OK false with NIP-01's
"error:" prefix; IngestQueue converts a thrown batch and a missing outcome to
Failed instead of Rejected; NdjsonImportExport counts failed apart from
rejected; geode's MirrorWorker logs store failures at warn instead of
folding them into debug-level rejections. BisectingInsert and its test are
removed — with attribution guaranteed by the contract, retry-by-splitting
has nothing left to do.

Full :quartz:jvmTest passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y4Pi9YYMhdzTFxRiV2jF9R
2026-08-04 03:53:39 +00:00
Claude e54a546d10 SearchQuery: parse quoted phrases and -word exclusions
NIP-50 search strings in the wild carry Google-style syntax the
extension tokenizer could not see: "exact phrase" requirements,
-"phrase" and -word exclusions. SearchQuery now lifts quoted spans
BEFORE the extension pass — the order is load-bearing: the extension
pass is quote-blind, so a span ending in an extension-shaped token
would lose its closing quote, and lifting first also lets quotes
protect extension-shaped tokens ("include:spam" is a phrase, not an
extension). toSearchString() and stripExtensions() reassemble the
full grammar; existing parses are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfwV3xgMSmfxy16ujGPxGW
2026-08-04 03:48:04 +00:00
Claude b46063713e Centralize the insert-rejection vocabulary in RejectionReason
Every store spelled its rejection reasons inline — the expired-event
string was duplicated four times across ObservableEventStore and
SQLiteEventStore. RejectionReason now carries the NIP-01 OK
machine-readable prefixes plus the standard store reasons, so
InsertOutcome.Rejected tallies and OK-frame building see one
vocabulary no matter which store produced the outcome.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfwV3xgMSmfxy16ujGPxGW
2026-08-04 03:48:04 +00:00
Claude d54e54b48a Add battle-tested sync accessories from vespa-relay's mirror
Four pieces extracted from a production Nostr mirror (NosFabrica/vespa-relay),
generalized to quartz's multiplatform primitives, with their test suites:

- nip01Core.store.insertBisecting: batchInsert fails as a unit, so one bad
  event costs its whole batch (999 good events per bad one at a 1000-event
  batch). Bisecting isolates the offender in ~2*log2(n) extra writes, and a
  fixed write budget keeps a store-wide failure (full disk, dead engine) from
  turning one failed write into ~2n.

- accessories.SyncBands: resume memory for fetchAllPages. Remembers the
  created_at band covered per (relay, filter) and asks only for the legs
  outside it, with inclusive edges so a page boundary cannot strand a run of
  same-second events. A finished negentropy reconcile records completeness
  through its start instant; a periodic full re-walk keeps stale claims from
  narrowing forever. Persistence is the caller's, via export/restore and an
  onChange hook.

- accessories.PagingProgress: progress for paged walks measured on the time
  axis, the only axis whose end is known in advance - count-based percentages
  degenerate to downloaded/downloaded = 100%. Needs no COUNT support.

- nip66RelayMonitor.reachability.HostStrikes: per-authority strike counting
  for outbox-scale fan-outs, where a filtering relay mints one url per user
  and per-url counters never converge. Ever-delivered overrides eviction in
  both race orders, and eviction surfaces exactly once for publishing.
  reachability.Unreachability (jvmAndroid): which failures may be published
  as a signed NIP-66 unreachable record - connection-level only, so a relay
  that answered the handshake and hung up mid-page is never libelled, and a
  caller's own bug is never the relay's fault.

57 tests pass on the JVM target; the common code uses quartz's ConcurrentMap,
ConcurrentSet and TimeUtils only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y4Pi9YYMhdzTFxRiV2jF9R
2026-08-04 03:33:48 +00:00
Claude 2b2e47256b docs: add nip85-trusted-assertions and searchable-events skills
Completes the store-implementer skill set requested by the vespa-eventstore
consumer: the NIP-85 trust-assertion model (kind map, tag vocabulary, value
semantics, authorization conventions) and the NIP-50 indexing surface (the
SearchableEvent contract plus an exhaustive kind -> indexableContent table
external search engines can diff at version bumps).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADBfjWhsXyHZb7ea2eeBhJ
2026-08-04 03:04:17 +00:00
Claude 7b29f57526 docs: add event-store-semantics skill (IEventStore/SQLite store contract)
Documents the store's observable behavior as named rules (STORE-F/W/D/C/S/N)
so external IEventStore implementations can review pin bumps and annotate
divergences against a stated contract instead of reverse-engineering
QueryBuilder. Requested by the vespa-eventstore consumer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADBfjWhsXyHZb7ea2eeBhJ
2026-08-04 02:55:14 +00:00
mstrofnone a110ce0a30 ci: publish linux-arm64 desktop, amy, and geode release assets
Amethyst v1.13.1 (and every prior release) shipped only linux-x64 desktop
binaries — .deb, .rpm, .AppImage, .flatpak, .tar.gz. Same for the amy
CLI and geode relay. Users on aarch64 hardware (Pinebook, Ampere
Altra, Raspberry Pi 4/5, AWS Graviton, arm64 servers, arm64 Chromebooks
running crostini, etc.) can't install any of them.

This teaches the release matrix about arm64:

- Add `ubuntu-24.04-arm` legs to build-desktop, build-cli, and
  build-geode. This is a standard, free public-repo GitHub-hosted
  runner (4 CPU / 16 GB / 14 GB SSD / arm64) since early 2025. No
  cross-compilation: jpackage / jlink / Compose Multiplatform 1.11
  all produce host-native artifacts.

- Fetch the matching `appimagetool-<arch>.AppImage` from the same
  1.9.0 release with an arch-specific SHA256 pin. `APPIMAGETOOL_URL`
  becomes `APPIMAGETOOL_VERSION` + per-arch SHA256 env vars.

- Parametrize the portable tarball/zip filename by `matrix.arch`
  (`amethyst-desktop-<ver>-linux-arm64.tar.gz` is now produced).

- Parametrize the Flatpak bundle filename and rewrite the manifest's
  `GST_PLUGIN_SYSTEM_PATH` from `x86_64-linux-gnu` to
  `aarch64-linux-gnu` on the arm64 leg. The Flathub-submission manifest
  (`desktopApp/packaging/flatpak/flathub/`) still gates on
  `only-arches: x86_64` — flipping that to include aarch64 is a
  follow-up once a Flathub aarch64 build has been validated end-to-end.

- Make the `createReleaseAppImage` gradle task pick its host arch from
  `System.getProperty("os.arch")` (amd64/x86_64 → `x86_64`, aarch64/
  arm64 → `aarch64`). Same task, same command, drives both legs.

- Fix `desktopApp/packaging/appimage/AppRun` to compute the multiarch
  library path from `uname -m` at launch time instead of hard-coding
  `x86_64-linux-gnu`. One script works in both AppImages on the target
  machine.

- Extend the desktop smoke test to run the release .deb build + launch
  probe on `ubuntu-24.04-arm` too, so arch-specific ProGuard/jlink
  breakage (missing native lib, arch-specific reflection root) is
  caught at PR time.

- Update BUILDING.md and scripts/asset-name.sh docs with the new
  arm64 asset names.

Follow-up assets published for the next tag push (v1.13.2+):

- amethyst-desktop-<ver>-linux-arm64.{deb,rpm,AppImage,flatpak,tar.gz}
- amy-<ver>-linux-arm64.{deb,rpm,tar.gz}
- geode-<ver>-linux-arm64.{deb,rpm,tar.gz}

Verification (local, before submitting):

- `python3 -c 'import yaml; yaml.safe_load(open(".github/workflows/create-release.yml"))'` — parses clean
- `bash -n scripts/asset-name.sh desktopApp/packaging/appimage/AppRun` — parses clean
- `actionlint` — reports only pre-existing shellcheck style hints; no new errors
- Confirmed `linuxdeploy-aarch64.AppImage` and
  `appimagetool-aarch64.AppImage` exist under the same pinned release
  tags used for x86_64; SHA256 recorded from a fresh download.

Not addressed (out of scope for this PR):

- Homebrew / winget bump workflows (`bump-homebrew*.yml`,
  `bump-winget.yml`) — those consume the assets by name; the new arm64
  filenames don't change any x86_64 name they already reference.
- Android arm64 continues to ship as before (already had it).
2026-08-04 10:51:16 +10:00
Vitor PamplonaandGitHub ba78eb599e Merge pull request #3851 from vitorpamplona/fix/relay-subscription-lock-convoy
fix(relay): stripe the subscription-state lock per relay to end an ANR convoy
2026-08-03 19:38:06 -04:00
Vitor PamplonaandClaude Opus 5 5896ae5eff fix(relay): stripe the subscription-state lock per relay to end an ANR convoy
A production ANR on a Pixel 8 (Amethyst 1.13.1, anr_2026-08-03-12-55-26-256)
showed the app burning 596% CPU — 6 of 9 cores — with the main thread stuck in
WaitingForGcToComplete. 37 of 52 runnable DefaultDispatcher workers sat at ONE
program point inside PoolRequests.onIncomingMessage and 12 more at one point in
syncState, all state=R, while the single thread actually holding the lock was
itself parked in GC.

Root cause: RequestSubscriptionState.withLock was a raw busy-wait
(`while (lock.exchange(true)) { while (lock.load()) {} }`) with no yield or
backoff, and being `inline` it disappeared into its callers' frames. The lock is
per subId, but one subId spans every relay it runs on — 191 live sockets on that
device — so dozens of relay-dispatch threads piled onto a single AtomicBoolean.
Spinning is only correct when the holder cannot be descheduled; on Android it
always can.

The fix stripes the lock per (subId, relay) rather than making waiting cheaper.
All 11 withLock bodies in PoolRequests are already scoped to exactly one relay,
and every field of RequestSubscriptionState is keyed by relay, so the sharing was
purely an artifact of mutableMapOf not being thread-safe. State moves into a
ConcurrentMap<T, RelayState>; locks live in a fixed 32-entry stripe array that is
never mutated, so lock identity stays stable — if locks lived inside the map
values, a thread holding one while another dropped and re-created that entry
would leave both inside the critical section excluding nothing.

A suspending Mutex was measured and rejected: it needs 262 method overrides and
110 call sites to become suspend, and ran at 0.35-0.63x the current throughput.

Measured (LockDesignComparisonBenchmark, 191 relays / 64 dispatcher threads):
  striped vs per-sub lock  1.5-2.8x throughput, bystander p50 halved
On device (SM-T220, playBenchmark, same account, n=3 per design):
  DefaultDispatcher CPU  -35% mean / -30% median vs the spin lock,
  with non-overlapping ranges; GC -18%
Plus 10 min of driven UI stress (feed, profiles, chat, notifications,
communities): no ANRs, no crashes, thread pools stable.

Also here:
- PlatformLock: new expect/actual parking lock (ReentrantLock on jvmAndroid,
  NSRecursiveLock on Apple, spin only on linuxX64 which is a CI target). quartz
  cannot use commons' equivalent KmpLock because commons depends on quartz.
- LiveNegentropyIndex had the identical busy-wait with a full list SORT inside
  the critical section; switched to PlatformLock.
- ConcurrentMap.remove (+ tests), with a caution that a removable value must not
  own a lock callers acquire.
- SpinLockConvoyBenchmark: regression guard asserting contended waiters PARK
  rather than spin (fails-before / passes-after). Pure benchmarks are gated
  behind -PprodRelayBench=1, so CI cost is 0.3s rather than 51.5s.

Analysis and measurements: quartz/plans/2026-08-03-poolrequests-lock-contention.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 19:19:56 -04:00
Vitor PamplonaandGitHub fdc68e801a Merge pull request #3849 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-03 00:40:58 -04:00
vitorpamplonaandgithub-actions[bot] 30742ab352 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-03 04:35:55 +00:00
Vitor PamplonaandGitHub 1622bd7109 Merge pull request #3850 from vitorpamplona/claude/fetchallpages-timeoutms-d30cl1
Standardize timeout semantics to idle windows across all accessory APIs
2026-08-03 00:33:06 -04:00
Claude 64073f8fa9 perf: drop per-event iterator alloc; harden count/fetchFirst drain loops
Audit follow-up on the timeout work.

- fetchAllPages matched each event with `for ((i, f) in activeFilters)`.
  That destructuring form allocates an Iterator on every event, on the
  relay's reader thread, for the whole download -- millions of short-lived
  objects in a bulk walk. Switched to an indexed loop, which is why quartz
  uses the fast* operators elsewhere in hot event paths (those only cover
  Array, so a List needs the index form).

- count() and fetchFirst() drain their channels in an inner loop that only
  suspends when the channel is empty, so a backlog was consumed with no
  cancellation check: neither the idle window expiring nor the caller
  giving up could interrupt it mid-drain. Added an explicit ensureActive(),
  matching the check fetchAllPages already does per page.

- count() could also lose a result that arrived after the last window
  closed but before unsubscribe, understating the returned map. Added the
  post-loop tryReceive drain that fetchAllWithHooks and fetchFirst have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZe1Pq2ejoHehdufhPDRgb
2026-08-03 04:28:18 +00:00
Claude 3ef61bafcb refactor: rename accessory timeoutMs to idleTimeoutMs
Every wait in the accessories package is an idle window measured from the
relay's most recent progress, so the parameter now says so. The name is
the contract: a caller reading timeoutMs reasonably expects a deadline,
which is exactly the misreading that made fetchAllPages' hard per-page
cap look correct for so long.

Renamed across the public surface -- fetchAll (7 overloads),
fetchAllWithHooks, fetchAllPages (2), fetchAllPagesFromPool,
fetchAllPagesFromPoolWithHooks, fetchFirst, count (2), countMerged -- plus
the quartz wrappers whose own parameter is a pure pass-through of that
window (KeyPackageFetcher, RecipientRelayFetcher, FollowerCrawler.Config)
and every call site across commons, cli, desktopApp and amethyst.

Deliberately NOT renamed, because these are genuine wall-clock bounds and
the differing name is the tell:
  - publishAndConfirm's timeoutInSeconds -- one fixed window to collect
    the OKs, a bounded confirmation round-trip rather than a stream.
  - GrapeRankCrawler.Config.timeoutMs -- a hard per-drain gate
    (withTimeoutOrNull(config.timeoutMs)) that also drives parking.
  - Context.awaitReply's timeoutMs, Context.syncIncoming, and the
    non-accessory app-layer helpers (RelayProber, FeedMetadataCoordinator,
    RelayAuthPromptBus).

This is a source-breaking change for named-argument callers of quartz.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZe1Pq2ejoHehdufhPDRgb
2026-08-03 04:16:31 +00:00
Claude ba35a87a7c refactor: drop the ceiling params; bound waits by progress instead
Follow-up audit on the timeout normalization. Three findings.

1. The maxTotalMs I added to fetchFirst and multi-relay count was the
   wrong shape twice over. A hard wall-clock bound already composes at the
   call site -- withTimeoutOrNull(ms) { fetchFirst(...) } -- so putting it
   in the signature duplicates what the caller has for free. And it was
   papering over the real defect: repeat chatter from a relay already
   accounted for (a CLOSED/reconnect loop, a duplicate COUNT) was treated
   as activity and restarted the idle window, so a flapping relay could
   hold the call open indefinitely. Both now reset the window only on
   genuine progress -- an event, or the first terminal signal from a relay
   still being waited on -- which is the rule the negentropy watchdog
   already applies to NOTICE/CLOSED chatter, and which makes both calls
   self-bounding at one window per relay. Ceiling params removed; the
   overflow guard they needed goes with them.

2. fetchFirst could drop a match: an event landing after the last terminal
   signal but before unsubscribe was left unread in the channel and the
   fetch reported nothing found. Added the post-loop drain that
   fetchAllWithHooks already does. Covered by a test.

3. fetchAllPages published its per-page counters across threads without a
   barrier on the idle path. received/delivered/pageMinTs/idsAtPageMin are
   written on the relay reader thread and read by the driver once the wait
   ends; the EOSE path gets happens-before from the channel, the idle path
   had none, so the driver could read a stale pageMinTs (ending the walk
   early) or an unsafely published idsAtPageMin. The volatile IdleClock
   bump now runs in a finally, so it covers every event including the
   early-returning duplicate and orders after the counters.

Also folded the single-relay count channel close into its finally, so a
throw mid-wait cleans up like every sibling accessory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZe1Pq2ejoHehdufhPDRgb
2026-08-03 03:26:58 +00:00
Claude ac6034f764 refactor: drop fetchAllPages' maxPageMs ceiling
The per-page wall-clock ceiling added alongside the idle-window change did
not do what it claimed. fetchAllPages waits inside a while(true): when a
page's wait ends, the loop advances the until cursor and issues the next
REQ rather than returning, so a ceiling bounds one page, never the call.
Measured against a relay trickling events forever with an unbounded
filter, maxPageMs=400 produced 8 REQs and no return -- the walk ran until
the caller cancelled, exactly as it would with no ceiling at all.

It also made truncation unsafe. Cutting a page mid-stream advances until
to the oldest event received so far, which only preserves the set if the
relay streams strictly newest-first -- NIP-01 recommends that but does not
require it -- so a ceiling firing on an out-of-order relay can skip the
not-yet-sent events above that cursor. That is the same class of silent
gap the idle window was introduced to close.

What actually bounds a paged download is the filter's limit (already the
documented way) or cancelling the caller, which the ensureActive() at the
top of each page honors. Removed from fetchAllPages, fetchAllPagesFromPool
and fetchAllPagesFromPoolWithHooks; a regression test pins the real
contract so nobody re-adds a ceiling believing it caps the walk.

maxTotalMs stays on fetchAll/fetchAllWithHooks, fetchFirst and multi-relay
count: those wait in a single loop and return, so there the ceiling
genuinely ends the call (each is covered by a test asserting it does).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZe1Pq2ejoHehdufhPDRgb
2026-08-03 03:13:53 +00:00
Claude 53be8d1755 fix: harden accessory timeout ceilings and count() listener cleanup
Audit follow-ups on the idle-window normalization:

- maxTotalMs/maxPageMs defaults are timeoutMs * 10, which silently
  overflows to a negative Long for an effectively-infinite idle window
  (Long.MAX_VALUE * 10 wraps to -10). withTimeoutOrNull then expired
  immediately, inverting 'wait forever' into 'never wait'. All three
  ceilings (fetchAllPages, fetchFirst, fetchAllWithHooks) now treat a
  non-positive ceiling as uncapped, matching the idle window's <= 0
  convention. Regression-tested via fetchFirst.
- count(filters) leaked its RelayConnectionListener and left COUNT subs
  open if the caller cancelled mid-wait or a listener threw: the cleanup
  ran as straight-line code with no try/finally, unlike every sibling
  accessory. Wrapped so unsubscribe + removeConnectionListener + channel
  close always run.
- New FetchFirstIdleTimeoutTest pins fetchFirst's idle-window semantics
  (signals restart the window, silence costs exactly one window, the
  ceiling stops endless terminal chatter, overflow means uncapped).
- README: note publishAndConfirm's fixed window as the deliberate
  exception, and correct the fetchAllPagesFromPool row, which claimed
  cross-relay dedup the function explicitly does not do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZe1Pq2ejoHehdufhPDRgb
2026-08-03 02:48:15 +00:00
Claude 431ad153bc fix: normalize accessory timeouts to idle windows (time since last relay message)
fetchAllPages waited for each page's EOSE under a hard wall-clock
withTimeoutOrNull, unlike fetchAll / fetchAllWithHooks / the negentropy
sync, whose timeouts are idle windows reset by every arriving message.
A slow-but-streaming page could be silently truncated, and a relay
slower than timeoutMs to first byte was mistaken for a drained set.

- Extract IdleClock + receiveWithinIdle out of NostrClientNegentropySyncExt
  into a shared internal IdleWatchdog.kt and document the package-wide
  convention in the accessories README.
- fetchAllPages (+ pool/hooks variants): the per-page timeout is now an
  idle window bumped by every event; a maxPageMs ceiling (10x, matching
  fetchAllWithHooks' maxTotalMs) backstops a never-EOSE trickle. A
  non-positive ceiling means uncapped, mirroring the idle <= 0 convention.
- fetchFirst: the wait restarts on every arriving signal instead of one
  hard deadline across all relays; maxTotalMs ceiling added.
- count (multi-relay): each arriving COUNT result restarts the window;
  maxTotalMs ceiling added. Single-relay count is trivially idle already.
- NegentropyStoreSync page fallback clamps a disabled watchdog (0) to
  DEFAULT_DOWNLOAD_IDLE_MS like fetchByIds, instead of paging unbounded.
- New NostrClientFetchAllPagesIdleTimeoutTest pins the semantics
  (verified to fail against the old hard-deadline wait).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZe1Pq2ejoHehdufhPDRgb
2026-08-03 02:35:10 +00:00
Vitor PamplonaandGitHub bf41e75b78 Merge pull request #3848 from vitorpamplona/fix/count-ignores-default-limit
Stop COUNT from inheriting the default page size
2026-08-02 13:15:43 -04:00
Vitor PamplonaandClaude Opus 5 a76a1911ea Stop COUNT from inheriting the default page size
A relay holding 12,289,614 profiles answers

  ["COUNT","c1",{"kinds":[0]}]  ->  {"count":500}

LimitsPolicy ran the same clampLimits over CountCmd as over ReqCmd, so
an unbounded COUNT was given RelayLimits.defaultLimit and the store then
counted at most that many.

defaultLimit answers "how many events should a REQ return when the client
names none". A COUNT returns no events, so the question has no meaning
for it, and applying the answer anyway turns every unbounded COUNT into
min(matches, defaultLimit).

The failure is quiet, which is what let it survive: 500 is a plausible
number, and the kinds under the default answered correctly. On the relay
that surfaced it, kinds 1 and 10040 were right while 0, 10002 and 30382
were all exactly 500.

maxLimit still applies — a client asking to count at most N is asking
something a relay may bound. Only the invented default is dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 10:14:07 -04:00
Vitor PamplonaandGitHub e10f76665f Merge pull request #3846 from vitorpamplona/claude/30382-rank-follower-providers-j2cnb9
fix: request follower-count provider's 30382 cards in UserCardsSubAssembler
2026-08-01 21:17:36 -04:00
Claude 6cd91bb058 fix: request follower-count provider's 30382 cards in UserCardsSubAssembler
updateFilter only added the rank provider to the trusted-author set, so
when the follower-count provider differed (different pubkey and/or
relay), its kind:30382 cards were never requested from the relay.
followerCountStrFlow then filtered for a signer whose cards never
arrived and rendered "--" forever.

Add liveUserFollowerCount (with its relayUrl) into the same mapOfSet
block, symmetrically with liveUserRankProvider.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7AA1AxqA6StnPVdjGDcwv
2026-08-02 00:43:27 +00:00
Vitor PamplonaandGitHub 7922f9a4d1 Merge pull request #3845 from vitorpamplona/claude/explained-filter-test-warnings-clpcv8
Test: Type ExplainedFilter.copy() result as base Filter
2026-08-01 14:08:56 -04:00
Claude 0cc3f72f55 fix: resolve always-true is-check and redundant cast warnings in ExplainedFilterTest
Declaring 'advanced' as the base Filter type keeps the copy() regression
guard as a genuine runtime assertion instead of a compile-time triviality,
which is what the compiler was warning about.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012KG9YkeFp6zyth5J364DLF
2026-08-01 18:03:03 +00:00
Vitor PamplonaandGitHub 31c3eaf2b3 Merge pull request #3844 from vitorpamplona/claude/code-quality-class-decoupling-rrt199
refactor: decouple LocalCache and Account god classes (behavior-preserving)
2026-08-01 13:31:51 -04:00
Vitor PamplonaandGitHub e0ea365368 Merge pull request #3843 from vitorpamplona/fix/rtt-open-from-handshake
rtt-open is the transport's handshake, not our own queueing
2026-08-01 13:22:11 -04:00
Vitor PamplonaandGitHub dc1f0dd995 Merge pull request #3842 from vitorpamplona/claude/liveventstore-searchextensions-3ya4ir
Add StoreQueryContext for observer-relative ranking in stores
2026-08-01 13:03:08 -04:00
Vitor PamplonaandClaude Opus 5 7d91931b75 rtt-open is the transport's handshake, not our own queueing
It was measured from onConnecting to onConnected, which includes the time
the call sat in the client's dispatcher queue. Under a 16,507-relay
fan-out that queue dominates everything else: published records showed a
median rtt-open of 33.5 SECONDS and a max of 90, against a true minimum
of 140ms.

That is the field aggregators rank relays by, so it was worse than
publishing nothing — a signed claim that healthy relays are slow, when
the slowness was ours.

pingMillis already carried the right number and was being handed to the
listener unused: BasicOkHttpWebSocket computes it as
receivedResponseAtMillis - sentRequestAtMillis, so it starts when the
upgrade request actually goes out and excludes everything before it.

Zero or negative means the transport could not time the handshake, and
then no timing is published rather than a fabricated one — the same rule
the rest of this class already followed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 12:59:22 -04:00
Claude f2e7a07a97 fix(relay): make the per-connection auth set a copy-on-write snapshot
Audit finding on the StoreQueryContext seam: RelaySession kept
authenticatedUsers as a plain mutable LinkedHashSet and handed out a
live view through RequestContext. A store (or EventSource) reading the
set during a long REQ replay — exactly what StoreQueryContext invites —
could race a concurrent NIP-42 AUTH commit on another coroutine:
iteration vs. add on an unsynchronized set is a
ConcurrentModificationException or a torn read.

The engine now swaps an immutable Set behind a @Volatile field on each
AUTH (the only writer), so every read is a consistent snapshot and
holding one across a replay is safe. AUTH is rare; the copy is off the
hot path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hH4RY2AUwfMZ54RkMiT45
2026-08-01 16:58:36 +00:00
Claude 2e90cc24ba feat(quartz): move NIP-50 extension handling into the stores; add IEventStore seams
The IEventStore contract now defines the seams a store implementation
needs instead of having the relay layer decide for it:

- NIP-50 search extensions: LiveEventStore no longer strips key:value
  tokens before the store sees them. Filter.search reaches every store
  verbatim, and each implementation decides which extensions it
  supports — the store, not the middleware, is the only component that
  knows whether sort:rank is a directive or noise. The built-in SQLite
  and filesystem stores strip at their own boundary (their FTS engines
  would otherwise error on / literally match the tokens), preserving
  the NIP-50 'ignore unsupported extensions' behavior end to end.
  Extension-aware stores need no side channel to recover the raw
  string anymore. Fs delete now checks emptiness on the stripped
  filter so an extensions-only search cannot wipe the store.

- Caller identity: new StoreQueryContext coroutine-context element,
  installed by LiveEventStore around every REQ/COUNT store call when
  the connection has NIP-42-authenticated pubkeys. Observer-relative
  stores (web-of-trust ranking, for-you relevance) read it off the
  coroutine context; ranking context only, never match-set changes.

- Negentropy liveness: snapshotIdsForNegentropy gains an optional
  onProgress hook so mirrors syncing huge corpora get a running count
  through the interface type instead of a concrete-class overload.
  SQLite reports from its row loop; the interface default streams and
  reports too.

- FtsReindexProgress.cursor documented as opaque and store-defined so
  resumable-reindex callers never assume id semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hH4RY2AUwfMZ54RkMiT45
2026-08-01 16:44:31 +00:00
mandClaude ebd9a163bc fix(desktop): auto-enable master notif switch when OS permission already granted
Follow-up to e9475dd079. That commit fixed the case where the user
clicked the "Enable OS notifications" button on a fresh install
(permission NotRequested \u2192 Granted) but the master toggle stayed off.
It missed the two closely-related cases the user was still hitting on
v1.13.1:

1. Permission was already granted from a previous session or install
   (e.g. an earlier v1.13.0 build, or the user allowed it via
   System Settings \u2192 Notifications directly). In this state,
   permissionState == Granted, so the "Enable OS notifications"
   button never renders \u2014 the button label promised the whole
   handshake but the code path that flipped the master switch only
   ran under NotRequested.
2. On Windows/Linux `permissionState` defaults to `NotApplicable`
   from the moment the app starts. The master switch is off by
   default (first-launch UX choice) and nothing ever flips it, so
   the auto-dispatcher stayed muted forever unless the user found
   the switch manually.

Fix:

- Add `NotificationSettings.wasExplicitlyDisabled()` so the Settings
  screen can distinguish "master switch is off because it defaults
  off on first launch" (auto-enable is fine) from "master switch is
  off because the user turned it off" (leave alone). Backed by a
  new java.util.prefs key `explicitly_disabled` that flips true on
  `setEnabled(false)` and gets cleared on `setEnabled(true)`.
- In `NotificationSettingsScreen`, a `LaunchedEffect(permissionState,
  enabled)` observes when the OS permission is Granted OR NotApplicable
  and the master switch is off. If the user has never explicitly
  turned it off, it auto-flips on \u2014 matching the "Enable OS
  notifications" contract for the paths the previous fix missed.
- Also render a "Turn on desktop notifications" button in the
  Granted branch when the user has explicitly turned notifications
  off. That's the recovery path for users who deliberately opted
  out and later want to opt back in without hunting for the
  master switch two rows away.

Behaviour on the fresh-install macOS path (permission NotRequested)
is unchanged \u2014 that path still runs the `requestPermission()`
flow inside the button's onClick, and the auto-enable happens via
the same LaunchedEffect once permissionState flips to Granted.

Tests (jvmTest, hermetic \u2014 UUID-scoped prefs nodes so tests never
share state or pollute real user prefs):

  PreferencesNotificationSettingsExplicitDisableTest:
    - fresh install defaults to not-explicitly-disabled
    - turning off marks explicitly disabled
    - turning on clears the explicit-disable flag
    - flag persists across new instances on the same prefs node

\ud83e\udd16 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 16:32:11 +10:00
155 changed files with 11632 additions and 673 deletions
+27
View File
@@ -85,3 +85,30 @@ skills verified clean):
`ParseReturn.entity` (the `Nip19Parser.Return.*` sealed class never existed);
Event Store section corrected from "Android only" to commonMain/all platforms
with the real `store.sqlite.EventStore` import and suspend generic `query<T>`.
## Phase 4 (2026-08): Store-implementer skills (external consumer request)
Three skills added at the request of an external Quartz consumer
(vespa-eventstore — a server-side `IEventStore` on Vespa that asserts result
parity against the SQLite store in CI). All three document the
**store/relay-implementer's perspective**, which `quartz-integration` and
`nostr-expert` (client-side) did not cover. Requirements doc: the skill-requests
file reviewed 2026-08-04; the requester's items #4 (storage-lifecycle-nips) was
folded into `event-store-semantics` per their own recommendation, and #5
(relay-server/geode policies) was declined as not currently needed.
- **`event-store-semantics/`** — the `IEventStore`/SQLite-store behavioral
contract as named rules (STORE-Fxx/Wxx/Dxx/Cxx/Sxx/Nxx) with a semantics
changelog for pin-bump review. Written from `QueryBuilder`,
`MergeQueryExecutor`, the seven `*Module.kt` files, and `IEventStore` KDoc.
- **`nip85-trusted-assertions/`** — the NIP-85 model (10040/30382/30383/30384/
30385), full tag vocabulary with value semantics, authorization conventions,
worked JSON examples, stability notes.
- **`searchable-events/`** — the `SearchableEvent` contract + maintenance
mandate, with `references/searchable-kinds.md` holding the exhaustive
kind → class → `indexableContent()` table (126 classes / 129 kinds) that
external search engines diff at version bumps.
Follow-ups suggested but not implemented: a shared JSON test-vector corpus for
filter semantics (testFixtures both the SQLite tests and external parity suites
could run), and a snapshot test pinning the searchable-kind set.
@@ -0,0 +1,325 @@
---
name: event-store-semantics
description: The authoritative behavioral contract of Quartz's event stores — `IEventStore` and its reference SQLite implementation (`nip01Core/store/sqlite/`). Use when implementing or asserting parity with a Quartz event store (external engines like Vespa, the filesystem store, geode), answering filter-semantics questions (since/until inclusivity, tag OR/AND, multi-filter limits, ordering tiebreaks), or working on the write-path rules for replaceable/addressable supersession, NIP-09 deletions, NIP-40 expiration, NIP-62 vanish, NIP-45 counts, or NIP-50 search inside the store. Every behavior has a named rule id (STORE-Fxx/Wxx/Dxx/Sxx/Cxx) so downstream implementations can annotate divergences precisely.
---
# Event Store Semantics — the `IEventStore` / SQLite-store contract
The SQLite `EventStore` (`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/`)
is the de-facto **reference implementation** of what a Quartz event store must do. Other
implementations — the in-repo filesystem store (`nip01Core/store/fs/`, held to parity by
`quartz/src/jvmTest/.../store/fs/FsParityTest.kt`) and external engines (e.g. a Vespa-backed
store) — reimplement its *observable behavior* and assert parity in CI. This skill states that
behavior as **named, numbered decisions** so a parity divergence becomes a lookup, not an
archaeology session through `QueryBuilder`/`MergeQueryExecutor`.
Every rule below was verified against the code as of this skill's last update. When you change
store behavior, **update the rule here in the same PR** and add a line to the
[Semantics changelog](#semantics-changelog) — downstream implementations pin Quartz by commit and
review pin bumps against this file.
## Key files
| Concern | File |
|---|---|
| Public contract (KDoc is normative) | `nip01Core/store/IEventStore.kt` |
| High-level store (owns pool + planner) | `sqlite/EventStore.kt`, `sqlite/SQLiteEventStore.kt` |
| Filter → SQL, ordering, limits, counts | `sqlite/QueryBuilder.kt` |
| k-way merge fast path (feed shapes) | `sqlite/MergeQueryExecutor.kt` |
| Schema, tag hashing, immutability | `sqlite/EventIndexesModule.kt`, `sqlite/TagNameValueHasher.kt`, `sqlite/SeedModule.kt` |
| Replaceable / addressable supersession | `sqlite/ReplaceableModule.kt`, `sqlite/AddressableModule.kt` |
| NIP-09 / NIP-40 / NIP-62 / ephemeral | `sqlite/DeletionRequestModule.kt`, `sqlite/ExpirationModule.kt`, `sqlite/RightToVanishModule.kt`, `sqlite/EphemeralModule.kt` |
| NIP-50 FTS | `sqlite/FullTextSearchModule.kt` (see also the `searchable-events` skill) |
| Index/feature toggles | `sqlite/IndexingStrategy.kt` (client default) and geode's `RelayIndexingStrategy.kt` (relay preset) |
| Operational README | `sqlite/README.md` (concurrency, pragmas, maintenance) |
Executable spec: the test suites in
`quartz/src/commonTest/.../store/sqlite/` (`BasicTest`, `ReplaceableTest`, `AddressableTest`,
`DeletionTest`, `ExpirationTest`, `RightToVanishTest`, `SearchTest`, `SearchRelevanceOrderTest`,
`MergeQueryCorrectnessTest`, `TagMergeCorrectnessTest`, `QueryAssemblerTest`,
`SnapshotIdsForNegentropyTest`, `FilterMatcherTest`, …). If a rule here ever contradicts a test,
the test wins — and this file has a bug to fix.
## Kind classes (used throughout)
- **Replaceable**: kind `0`, kind `3`, and `10000 ≤ kind < 20000`.
- **Ephemeral**: `20000 ≤ kind < 30000`.
- **Addressable**: `30000 ≤ kind < 40000`.
- Everything else is a regular event.
---
## Filter matching (STORE-F)
**STORE-F01 — `since`/`until` are both inclusive.** `since` compiles to
`created_at >= ?`, `until` to `created_at <= ?` (`QueryBuilder` uses
`greaterThanOrEquals`/`lessThanOrEquals` everywhere). An event with
`created_at == since == until` matches.
**STORE-F02 — `ids` and `authors` are exact-match only.** They compile to `=`/`IN` against the
full 64-char hex columns. **NIP-01 prefix matching is NOT supported** anywhere in the store.
(`Filter`'s constructor logs an error for non-64-char ids/authors but still sends them; they
simply never match.)
**STORE-F03 — tag filter combination.** Within one tag name, values are **OR**
(`tag_hash IN (…)`). Across different tag names in the same filter, conditions are **AND**
(each extra name becomes another `event_tags` self-join). `tagsAll` (NIP-91 `&x` syntax) demands
**every listed value** be present on the event — one join + equality per value — and composes by
AND with any plain `tags` in the same filter.
**STORE-F04 — only single-letter tag names are indexed (by default).**
`DefaultIndexingStrategy.shouldIndex` indexes a tag iff `tag.size >= 2 && tag[0].length == 1`.
A filter on a multi-letter tag name (`#title`, `#alt`) matches **nothing** in the SQLite store.
Deployments can widen `shouldIndex`, but the stock contract is single-letter-only.
**STORE-F05 — `d` is special-cased out of the tag index.** `#d` values are matched against the
`event_headers.d_tag` column, not `event_tags` (`Filter.toFilterWithDTags()`). Consequences:
`#d` works on addressable events (which populate `d_tag`); when all `kinds` are addressable the
query adds `kind >= 30000 AND kind < 40000` to pin the addressable index. **Only use `#d` via
plain `tags`.** A `#d` under `tagsAll` is handled inconsistently: on the simple (no other
tags/search) path it degrades to OR semantics (`toFilterWithDTags` folds it into `dTags`), and
when `tags["d"]` is also present it is dropped entirely; on the tag-join path it is ignored.
(An event has one d-tag, so AND-across-values could never match anyway.)
**STORE-F06 — tag and author matching in the tag path is hash-based.** `event_tags` stores a
64-bit MurmurHash3 of `(tag name, value)` keyed by a per-database random seed (`SeedModule`,
`TagNameValueHasher`); the p/e/a-owner columns are hashes too. There is **no post-verification**
of hash matches, so a hash collision would return a false positive. Probability is negligible in
practice but nonzero — a parity harness comparing against an exact-match engine should know this
is the one place the reference can (theoretically) over-match.
**STORE-F07 — multiple filters are a union with dedup; `limit` is per-filter.** Each filter
becomes its own row-id subquery with its **own** `ORDER BY … LIMIT`; branches are combined with
SQL `UNION` (dedup by row). There is **no global limit** — a 3-filter query with limits
10/20/30 can return up to 60 events, presented in one merged `created_at DESC` ordering. NIP-45
counts and negentropy snapshots dedup the same way (`SELECT DISTINCT` / `UNION`).
**STORE-F08 — result ordering.** Non-search queries order `created_at DESC`. The `id ASC`
tiebreak on equal `created_at` is applied **only when
`IndexingStrategy.useAndIndexIdOnOrderBy = true`** — which is `false` in the client default
**and** in geode's relay preset. So by default, same-second ordering is unspecified (SQLite
returns them in storage order). Any newest-N is valid; a parity suite must not assert
same-`created_at` order unless it configures the flag. One extra caveat with the flag ON: the
`MergeQueryExecutor` tag-stream path still yields same-second ties in rowid order (its cursors
run off `event_tags`, which has no id column) — a valid newest-N that may differ byte-for-byte
from the single-SQL ordering.
**STORE-F09 — the merge fast path returns the same *set*.** Single-filter queries of the shape
"authors (+kinds) + limit" or "one `#x` IN-list (+kinds) + limit" (≤2048 streams) route through
`MergeQueryExecutor`, a k-way newest-first merge over per-(kind,author) / per-(tag-value,kind)
index cursors with dedup by id on the tag shape. This is an optimization, not a semantics
change — `MergeQueryCorrectnessTest`/`TagMergeCorrectnessTest` assert set-equality with the
single-SQL plan (ordering caveat per STORE-F08).
**STORE-F10 — empty filter.** `query(Filter())` / `count(Filter())` match **everything**
(`Filter.isEmpty()` → the "everything" query). `delete(Filter())` is deliberately asymmetric:
it deletes **nothing** and returns 0, so a stray empty filter can't wipe the store (documented
on `QueryBuilder.delete`).
**STORE-F11 — empty lists (`kinds = emptyList()` etc.) are a client error with inconsistent
handling; don't rely on either outcome.** On the single-filter simple path an empty list
renders as `1 = 0` → matches nothing. But `Filter.isEmpty()` treats empty lists the same as
`null`, so on the multi-filter union path such a filter contributes no subquery — and a list of
*only* empty-list filters degrades to the match-everything query. Known quirk; treat
empty-list filters as invalid input rather than replicating this shape.
**STORE-F12 — `limit` edge cases.** `limit = 0` compiles to `LIMIT 0` → zero rows.
`limit = null` means unbounded. Negative limits are not defended against (don't send them).
**STORE-F13 — the in-memory matcher is a separate (simpler) implementation.**
`Filter.match(event)` (`FilterMatcher`) is used for live-stream matching, not storage queries;
it checks ids/authors/kinds/tags/tagsAll/since/until but not `search` or `limit`. Parity work
targets the SQL semantics above, not `FilterMatcher`.
---
## Write path (STORE-W)
Inserts run every module in one transaction: header+tags → NIP-09 side effects → expiration
row → FTS row → vanish side effects. A trigger `RAISE(ABORT, …)` rejects the whole row with the
messages quoted below (they surface as the NIP-01 `OK false` reason).
**STORE-W01 — replaceable supersession.** Unique index on `(kind, pubkey)` for replaceable
kinds. A `BEFORE INSERT` trigger deletes any stored version that is *older* — meaning
`created_at` smaller, **or equal `created_at` with lexicographically larger id** (NIP-01
lowest-id-wins). Inserting a version that is *not* newer under that ordering leaves the stored
row in place and fails the unique index → rejected (`UNIQUE constraint failed`). Net contract:
exactly one version stored; newest wins; ties broken by lowest id; older re-inserts blocked.
**STORE-W02 — addressable supersession.** Same as W01 with unique index
`(kind, pubkey, d_tag)` over `30000 ≤ kind < 40000`. Nuance: `d_tag` is populated from the
*parsed* event class (`AddressableEvent.dTag()`); an addressable-range kind whose class doesn't
parse as `AddressableEvent` stores `d_tag NULL`, and SQLite treats NULLs as distinct in unique
indexes — such events don't supersede each other. An event with no `d` tag parses as `dTag() = ""`
(empty string), which *does* dedupe normally.
**STORE-W03 — ephemeral events are never stored but are acked as accepted.**
`insert()` returns silently and `batchInsert` reports `Accepted` for `20000 ≤ kind < 30000`
without writing (the live relay stream still broadcasts them). A DB-level backstop trigger
(`blocked: cannot store ephemeral events`) rejects any that sneak past the app-level check.
**STORE-W04 — expired events are rejected at insert.** App-level check
(`event.isExpired()`) plus a trigger on the expiration-row insert
(`blocked: this event is expired` when `expiration <= unixepoch()`). Single-event `insert`
**throws**; `batchInsert` returns `Rejected`.
**STORE-W05 — expiry is enforced at insert and by sweep, NOT at query time.** Events with a
future `expiration` store a row in `event_expirations`. Nothing filters them out of queries
after the timestamp passes: **a query between expiry and the next `deleteExpiredEvents()` sweep
returns the expired event.** Operators run the sweep periodically (README recommends ~15 min).
Re-inserting an already-expired event after the sweep is rejected per W04.
**STORE-W06 — GiftWrap ownership is the recipient.** For kind 1059 the store computes
`pubkey_owner_hash` from the `p`-tag recipient (falling back to the random signer key if
absent). All owner-scoped machinery — NIP-09 re-insert blocking, NIP-62 vanish deletion and
blocking — operates on that owner hash, so **a user's deletions/vanish remove giftwraps
addressed to them**, even though the wrap's `pubkey` is a one-time key. (Consequently GiftWraps
are also excluded from `authorsMissingOutbox()`.)
**STORE-W07 — immutability.** `event_headers`/`event_tags` rows are never updated
(`BEFORE UPDATE` triggers abort). All supersession is delete + insert; `event_tags`,
`event_expirations`, `event_vanish`, and the FTS row follow the header by
`ON DELETE CASCADE` / trigger.
**STORE-W08 — batch insert.** One outer transaction, one SAVEPOINT per row: a bad row rolls
back alone and reports `Rejected(reason)`; the rest commit. If the **outer commit** fails, every
entry is treated as `Rejected` (the `IEventStore.batchInsert` contract). Outcomes are returned
in input order; OK frames pair by event id, not order.
---
## Deletion lifecycle — NIP-09 / NIP-62 (STORE-D)
**STORE-D01 — delete by id.** A kind-5's `e` tags delete stored events with those ids **whose
owner is the kind-5's author** (`pubkey_owner_hash` match — recipient for giftwraps per W06).
The id path has **no timestamp condition**: it deletes the target regardless of the relative
`created_at` values.
**STORE-D02 — delete by address.** A kind-5's `a` tags delete events at that
`(kind, pubkey, d_tag)` coordinate with `created_at <= deletion.created_at`**inclusive**; a
version newer than the deletion survives. Only coordinates whose pubkey equals the kind-5's
author are honored. Replaceable coordinates (`kind:pubkey:` with no d-tag) get the same
`created_at <=` treatment against `(kind, pubkey)`.
**STORE-D03 — cross-author kind-5s are stored but inert.** A deletion naming someone else's
events is inserted like any regular event (it may be useful to other relays/clients) but its
delete pass removes zero rows and creates no blocking.
**STORE-D04 — re-insert blocking.** A `BEFORE INSERT` trigger rejects
(`blocked: a deletion event exists`) any event whose id (`e`-hash) **or** address (`a`-hash) is
named by a stored kind-5 from the same owner with `deletion.created_at >= event.created_at`.
Note the asymmetry with D01: a *backdated* id-deletion (older `created_at` than its target)
still deletes on arrival, but would not block a later re-insert.
**STORE-D05 — a kind-5 CAN delete another kind-5, and doing so un-blocks its targets.**
Nothing excludes kind 5 from the id path (D01). Deleting a deletion removes its tombstone rows
from `event_tags`, so events it had deleted become re-insertable. **Status: known quirk, not a
considered decision.** NIP-09 leaves it open; at least one external implementation
(vespa-eventstore) deliberately diverges by treating deletion-of-a-deletion as a no-op, which is
the safer reading (tombstones shouldn't be revocable). If you change this, update this rule and
the changelog — parity suites key off it.
**STORE-D06 — NIP-62 vanish is relay-scoped.** A kind-62 only cascades when
`shouldVanishFrom(relay)` — its `relay` tags name this store's `relay` URL or `ALL_RELAYS`.
(A store constructed with `relay = null` matches only `ALL_RELAYS` requests.) Out-of-scope
vanish events are stored as regular events with no side effects.
**STORE-D07 — vanish scope and horizon.** An in-scope vanish deletes every event whose
**owner** (W06) is the vanishing pubkey with `created_at < vanish.created_at` (strict — the
vanish event itself survives), and blocks inserts of owned events with
`created_at <= vanish.created_at` (`blocked: a request to vanish event exists`; note blocking is
inclusive where deletion is strict). Newer vanish requests supersede older ones per pubkey
(unique on `pubkey_hash`).
**STORE-D08 — manual deletes.** `delete(id)` removes one row unconditionally (no blocking
created). `delete(filter)` deletes matching rows honoring per-filter limits, with the F10
empty-filter no-op guard. Neither creates re-insert blocking — only stored kind-5/kind-62
events do that.
---
## NIP-45 count (STORE-C)
**STORE-C01 — count = size of the deduped match set, honoring per-filter limits.** Single
filter: `COUNT(*)` over that filter's row-id subquery (including its `LIMIT`, so
`count(Filter(kinds=…, limit=10))` is at most 10). Multiple filters: branches are `UNION`ed
(dedup) **before** counting — an event matching several filters counts once. FTS-off + search
term → 0 (F-series search rules apply).
---
## NIP-50 search inside the store (STORE-S)
The indexing surface (which kinds are searchable, what text they contribute) is the
`searchable-events` skill; these rules are the store's query-side contract.
**STORE-S01 — extension stripping at the store boundary.** Every filter-accepting method runs
`strippingSearchExtensions()`: NIP-50 `key:value` tokens (`include:spam`, `domain:…`, …) are
removed before FTS. Unsupported extensions are **ignored, never matched as literal text and
never match-nothing** — an extensions-only search collapses to an unconstrained query. Stores
that *do* implement extensions receive the raw string through the relay layer and parse it with
`nip50Search.SearchQuery.parse` (see the `IEventStore` KDoc).
**STORE-S02 — relevance ordering.** Search results order by FTS5 `bm25` rank (best match
first), with `created_at DESC` only as tiebreak; the `LIMIT` keeps the most *relevant* N, not
the newest N. A multi-filter REQ is relevance-ordered only when **every** filter carries a
search term (best/min rank per event across branches); mixing search and non-search filters
falls back to `created_at DESC`.
**STORE-S03 — search combines by AND with the structural parts** (ids/authors/kinds/tags/
since/until) of the same filter — an FTS `MATCH` join on top of the normal conditions.
**STORE-S04 — search grammar is SQLite FTS5 `MATCH`.** The raw (post-strip) string is passed to
FTS5, so implicit-AND terms, `"phrase queries"`, `OR`, and `prefix*` follow FTS5 semantics.
Tokenization details live in `FullTextSearchModule` (see `searchable-events`).
**STORE-S05 — FTS off.** With `IndexingStrategy.indexFullTextSearch = false`: a filter with a
non-empty search term matches **nothing** (query/count/delete alike); an empty-string search
imposes no constraint. Everything else is unchanged.
**STORE-S06 — deferred FTS.** Relays may set `deferFullTextSearchIndexing = true` (geode does):
tokenization moves off the insert path to a watermark-driven catch-up
(`needsFtsCatchUp`/`ftsCatchUp`), and search queries drain the backlog first — so NIP-50
results are exactly as fresh as the synchronous path.
---
## Negentropy / NIP-77 (STORE-N)
**STORE-N01 —** `snapshotIdsForNegentropy(filters)` returns `(created_at, id)` pairs under the
**same filter semantics as `query`** (per-filter limits included, multi-filter dedup), order
unspecified (negentropy re-sorts). `maxEntries` returns up to `maxEntries + 1` as an overflow
sentinel. `liveNegentropySnapshot` serves full-corpus NEG-OPENs from an in-memory index when
`maintainLiveNegentropyIndex` is on; the delta plumbing in `SQLiteEventStore` keeps it exact
across replaceable displacement, kind-5s, and vanish (invalidate-and-rebuild for the
non-itemizable cases).
---
## Configuration presets
- **Client default** (`DefaultIndexingStrategy()`): FTS on (synchronous), optional indexes off,
`useAndIndexIdOnOrderBy` off, no live negentropy index.
- **Relay preset** (geode's `relayIndexingStrategy()`): adds created_at-alone, pubkey-alone and
tag+kind+pubkey indexes, defers FTS, maintains the live negentropy index — still leaves
`useAndIndexIdOnOrderBy` off.
- Flag-gated indexes are runtime config, not schema: flipping one on an existing DB builds the
index on next open (`ensureOptionalIndexes`), no migration.
## For parity implementers
- Treat the rule ids above as the vocabulary for divergence notes
(e.g. "diverges from STORE-D05: we no-op deletion-of-a-deletion").
- The commonTest suites are the executable spec; `FsParityTest` shows the in-repo pattern for
holding a second engine to it.
- Remember F06 (hash-based tag matching) and F08 (unordered same-second ties by default) when
diffing results byte-for-byte — both are places where a "divergence" may be the reference's
own slack, not your bug.
## Semantics changelog
Add one line per behavior change, newest first: `YYYY-MM-DD <short sha> <rule id> — what changed`.
- 2026-08-04 (baseline) — rules F01F13, W01W08, D01D08, C01, S01S06, N01 written from the
code at the time this skill was introduced. Changes before this date are not itemized;
archaeology starts at `git log` on `nip01Core/store/`.
@@ -0,0 +1,232 @@
---
name: nip85-trusted-assertions
description: The NIP-85 trusted-assertions model in Quartz (`nip85TrustedAssertions/`) — kind 10040 trust-provider lists, kind 30382 contact cards / user assertions, 30383 event assertions, 30384 addressable assertions, 30385 external-id assertions. Use when building or parsing these events, working with the typed tags (RankTag, HopsTag, FollowerCountTag, ServiceProviderTag/ServiceType, …), wiring a consumer that resolves a 10040 provider entry to the 30382s it signs, ranking on assertion values, or touching the GrapeRank publisher, contact-card nicknames, or the trust projection of an external store.
---
# NIP-85 Trusted Assertions — the Quartz model
Package: `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/`.
NIP-85 is still an evolving spec; **this package is the operative definition** of what
Amethyst-family software writes and reads. This skill states the model (who signs what about
whom), the exact kind/d-tag/tag vocabulary, and what consumers may — and may not — assume.
## The model in one paragraph
An **assertion is signed by the asserting party** (a trust provider service, or the user
themself) **about a subject named in the d-tag**. All assertion kinds are addressable, so
"latest card by provider P about subject S" is just the addressable coordinate
`(kind, P, S)` and supersession is standard NIP-01 latest-wins. Discovery is the observer's
**kind 10040 list**: each entry says *"for metric M on kind K, I trust provider P — fetch
their assertions at relay R"*. Quartz enforces none of this cryptographically beyond normal
event signatures; the 10040→assertion link is **consumer-side convention** (see
"Authorization" below).
## Kind map
| Kind | Class | Kind class | d-tag = the subject | Content |
|---|---|---|---|---|
| 10040 | `list/TrustProviderListEvent` | replaceable | *(none — always `""`)* | NIP-44 private provider entries (optional) |
| 30382 | `users/ContactCardEvent` | addressable | **target user's pubkey** (hex) | NIP-44 private tags (petname/summary/emoji) |
| 30383 | `events/EventAssertionEvent` | addressable | **target event id** (hex) | `""` |
| 30384 | `addressables/AddressableAssertionEvent` | addressable | **target coordinate** `kind:pubkey:dtag` | `""` |
| 30385 | `externalIds/ExternalIdAssertionEvent` | addressable | **external identifier** (e.g. `isbn:978-0-13-468599-1`) | `""` |
Addresses: `ContactCardEvent.createAddress(owner, target)``Address(30382, owner, target)`
(owner = signer, target = subject). `TrustProviderListEvent.createAddress(pubKey)` uses
`FIXED_D_TAG = ""`. `AssertionEventTest.eventKindsAreCorrect` pins all five numbers.
`ContactCardEvent` is also a `SearchableEvent` — it indexes only the **public** petname/summary
tags plus topics; the encrypted card content is intentionally never indexed.
## The 10040 provider entry (`ServiceProviderTag` / `ServiceType`)
There is **no fixed tag name**: `tag[0]` *is* the service string.
```json
["30382:rank", "<provider pubkey, 64 hex>", "wss://nip85.brainstorm.world"]
```
- `ServiceType(kind, type)` parses/renders `"<kind>:<type>"` — kind must be an int, the first
`:` splits, colons in the remainder stay in `type`. `ServiceType.isOfKind` is the
allocation-free prefix check.
- `ServiceProviderTag.parse` requires ≥3 elements, non-empty service, 64-char pubkey
(length-only check), and a **normalizable relay URL** (`RelayUrlNormalizer.normalizeOrNull`) —
entries failing any check are silently dropped, which is what keeps foreign tags like
`["client","nostria"]` out (regression-tested in `ServiceTypeParserTest`).
- Entries may be **public** (tag array) or **private** (NIP-44 content); `create`/`add` take
`isPrivate`. `remove` always needs decryption and strips from both sides by parsed-value
equality.
- `object ProviderTypes` (`list/tags/ServiceType.kt`) enumerates the *known* service types —
`30382:rank`, `30382:followers`, `30382:first_created_at`, per-metric `30383:*`/`30384:*`/
`30385:*`, etc. It is an **open vocabulary**: real 10040s in the wild (see the fiatjaf →
brainstorm fixture in `commonTest/.../nip85TrustedAssertions/ServiceParser.kt`) carry types
Quartz doesn't enumerate (`30382:personalizedGrapeRank_influence`, `30382:hops`,
`30382:verifiedFollowersCount`, …). Parse any `kind:type`; special-case only what you rank on.
## Authorization — what a consumer may assume
- **A 30382 (or 30383/…) is meaningful to an observer only if its author is listed in the
observer's 10040 for a matching service type.** Quartz does not enforce this; the consuming
code does. The in-repo pattern is `commons/.../model/nip85TrustedAssertions/UserCardsCache.kt`:
`rankFlow(trustProviderList)` picks the received card whose **author pubkey equals the
provider entry's pubkey** and reads `rank()` from it. Assertions from unlisted signers are
simply ignored for trust purposes (they may still be stored; dropping them — as an external
store's orphan sweep does — is a legitimate storage policy, not a protocol rule).
- What an entry authorizes is scoped by its `ServiceType`: `30382:rank` authorizes that
provider's user-rank cards, nothing else. Amethyst models this as one provider slot per
metric (`liveUserRankProvider`, `liveUserFollowerCount` in
`amethyst/.../model/trustedAssertions/TrustProviderListState.kt`).
- **Multi-provider combination is unprescribed.** When two listed providers assert different
ranks, there is no spec'd merge; Amethyst avoids the question by selecting one provider per
metric slot. Consumers choose their own policy — document it.
- The relay URL in the entry is a **fetch hint, and it is honored**:
`amethyst/.../UserCardsSubAssembler.kt` subscribes for cards at the provider's declared relay
(`kinds=[30382], authors=[provider], #d=[targets]`).
### The dual use of kind 30382
The same kind serves two roles, distinguished **by author**:
1. **Provider WoT cards** — signed by a trust provider; public metric tags (`rank`,
`followers`, `hops`, …); this is what 10040 discovery points at.
2. **The account's own contact cards (nicknames, NIP-81-style)** — signed by the account,
one per target user. The petname, summary, and their NIP-30 emoji mappings **always live in
the NIP-44 encrypted content, never in public tags** (`ContactCardEvent.build`/
`updatePetNameAndSummary` strip stray public copies; asserted by `ContactCardPetNameTest`).
`commons/.../ContactCardsState.kt` keys everything on `author == account` and ignores
provider cards.
## Tag vocabulary and value semantics
All tag classes share one shape: `TAG_NAME` + `parse(tag)` (null on wrong name/empty/non-numeric
value — a bad tag is *dropped*, never an error) + `assemble(value)``[name, value.toString()]`.
**A missing tag means "unknown" (`null` accessor), never zero.** There is deliberately no range
validation (rank isn't clamped, hours aren't checked against 023, counts may be negative) —
consumers must defend.
**On 30382** (`users/tags/`, accessors on `ContactCardEvent` and as `TagArray` extensions in
`users/TagArrayExt.kt` so they also work on decrypted private arrays):
| Tag name | Accessor | Type | Semantics |
|---|---|---|---|
| `rank` | `rank()` | Int | Provider-relative score; higher is better. GrapeRank publishes `round(score × 100)` (so 0100 in practice), but nothing enforces a scale — treat it as comparable only *within one provider*. |
| `followers` | `followerCount()` | Int | Follower count as the provider computes it (cumulative, provider-defined). |
| `hops` | `hops()` | Int | Shortest follow-path length **from the observer the provider computed for** to the subject (1 = directly followed). Mirrors Brainstorm GrapeRank's `hops`. The only tag with KDoc. |
| `first_created_at` | `firstCreatedAt()` | Long | Unix seconds of subject's earliest known event. |
| `post_cnt` / `reply_cnt` / `reactions_cnt` | `postCount()` etc. | Int | Activity counts. |
| `zap_amt_recd` / `zap_amt_sent` | `zapAmountReceived()`/`…Sent()` | Long | Sats. |
| `zap_cnt_recd` / `zap_cnt_sent` | `zapCountReceived()`/`…Sent()` | Int | Counts. |
| `zap_avg_amt_day_recd` / `zap_avg_amt_day_sent` | `zapAvgAmountDay…()` | Long | Sats/day averages. |
| `reports_cnt_recd` / `reports_cnt_sent` | `reportsCount…()` | Int | NIP-56 report counts. |
| `t` (repeatable) | `topics()` | List\<String> | Subject's topics/interests. |
| `active_hours_start` / `active_hours_end` | `activeHours…()` | Int | Hour-of-day; **no timezone is specified in code** — treat as provider-defined (UTC in practice) and unclamped. |
| `petname` / `summary` | `petName()`/`summary()` | String | Nickname fields — conventionally private (see dual use above). |
**On 30383/30384** (`tags/`, shared): `rank`, `comment_cnt`, `quote_cnt`, `repost_cnt`,
`reaction_cnt`, `zap_cnt` (Int) and `zap_amount` (Long, sats).
**On 30385**: only `rank`, `comment_cnt`, `reaction_cnt`.
## Building and parsing (use the typed helpers, not raw `arrayOf`)
```kotlin
// Provider list: declare a rank provider (this is what `amy graperank register` does)
val tag = ServiceProviderTag(ProviderTypes.rank, providerPubkeyHex, relayUrl)
val list = TrustProviderListEvent.create(tag, isPrivate = false, signer)
// or append to an existing one:
val updated = TrustProviderListEvent.add(existing, tag, isPrivate = false, signer)
val providers: List<ServiceProviderTag> = updated.serviceProviders() // public
val private = updated.privateTags(signer)?.serviceProviders() // private side
// Provider-style contact card (public metrics) — the GrapeRankPublisher pattern:
val card = ContactCardEvent.create(
targetUser = subjectPubkey,
signer = providerSigner,
publicInitializer = {
rank(87)
followers(1234)
hops(2)
},
)
card.aboutUser() // d-tag → subject pubkey
card.rank() // 87
// Event assertion: unsigned template only (30383/84/85 have build(), no create())
val template = EventAssertionEvent.build(targetEventId) {
rank(12)
reactionCount(40)
zapAmount(2100)
}
val signed = signer.sign(template)
```
## Worked end-to-end example
Observer `O` trusts provider `P` for user ranks (kind 10040, replaceable, by `O`):
```json
{ "kind": 10040, "pubkey": "<O>",
"tags": [
["30382:rank", "<P>", "wss://nip85.brainstorm.world"],
["30382:followers", "<P>", "wss://nip85.brainstorm.world"]
],
"content": "" }
```
Provider `P` asserts about subject `S` (kind 30382, addressable at `30382:<P>:<S>`):
```json
{ "kind": 30382, "pubkey": "<P>",
"tags": [
["d", "<S>"],
["rank", "87"], ["followers", "1234"], ["hops", "2"]
],
"content": "" }
```
`P` asserts about an event `E` (kind 30383, addressable at `30383:<P>:<E>`):
```json
{ "kind": 30383, "pubkey": "<P>",
"tags": [["d", "<E>"], ["rank", "12"], ["reaction_cnt", "40"], ["zap_amount", "2100"]],
"content": "" }
```
Consumption chain: read `O`'s 10040 → entry matching `ServiceType(30382, "rank")` → subscribe
`{kinds:[30382], authors:["<P>"], "#d":["<S>", …]}` at the hinted relay → newest card per
address wins → `rank()`.
Literal fixtures: `quartz/src/commonTest/.../nip85TrustedAssertions/ServiceParser.kt` (a real
10040 — fiatjaf's, pointing at the Brainstorm provider) and `AssertionEventTest.kt` (all four
assertion kinds with every tag populated).
## Freshness / supersession
Assertions are addressable: **latest per `(kind, author, d-tag)` wins**; there is no expiry tag
convention and **no prescribed refresh cadence** — staleness policy is the consumer's.
Writers should avoid churn: `GrapeRankPublisher` re-signs a card only when
`(rank, followers, hops)` actually changed, and retracts with a NIP-09 kind-5 carrying the
card's `a`-tag (`30382:<provider>:<target>`).
## Stability notes (as of 2026-08)
- **Settled** (shipped consumers on both ends): the kind map; `ServiceProviderTag` entry shape;
`rank`/`followers`/`hops` on 30382; petname/summary-in-encrypted-content; 10040 relay-hint
consumption.
- **Written but lightly consumed** (parse, but gate ranking features carefully): the activity/
zap/report count tags, `active_hours_*` (no timezone semantics), 30383/30384/30385 (builders +
tests exist; no in-repo publisher yet).
- **Known warts**: `ServiceProviderTag.assemble(id: ServiceProviderTag)` infers `Array<Any>`
dead code, don't use it; `SummaryTag.assemble(ip:)`/`ActiveHours*Tag.assemble(count:)` params
are misnamed; the tests live under `commonTest/.../experimental/nip85TrustedAssertions/`
(stale path); `TrustProviderListEvent` extends the addressable base, so a stray on-wire `d`
tag is reflected by `dTag()` even though the convention is `""`.
## Where it's consumed (reading list)
- **Publisher**: `quartz/.../experimental/graperank/GrapeRankPublisher.kt` (canonical 30382
writer), `cli/.../graperank/` (`amy graperank register|unregister|providers|publish`).
- **Client model**: `commons/.../model/nip85TrustedAssertions/` (`ContactCardsState`,
`UserCardsCache`, `ContactCardDecryptionCache`, `TrustProviderListDecryptionCache`),
`amethyst/.../model/trustedAssertions/TrustProviderListState.kt`.
- **Relay plumbing**: `commons/.../relayClient/assemblers/ContactCardFilters.kt`,
`amethyst/.../reqCommand/user/watchers/UserCardsSubAssembler.kt`.
+117
View File
@@ -0,0 +1,117 @@
---
name: searchable-events
description: The NIP-50 indexing surface of Quartz — the `SearchableEvent` interface, which event kinds are searchable, exactly what text each kind's `indexableContent()` contributes, how the SQLite/filesystem stores consume it, and the NIP-50 `SearchQuery` extension grammar plus `SearchRelayListEvent` (kind 10007). Use when making a kind searchable, changing what a kind indexes, diffing the searchable set at a Quartz version bump (external search engines mirror this table), debugging why an event is or isn't found by search, or working with search extensions (`include:spam`, `domain:`, …).
---
# Searchable Events — the NIP-50 indexing surface
## The contract
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableEvent.kt`:
```kotlin
interface SearchableEvent {
fun indexableContent(): String
}
```
One method; marker and extractor in one. An event kind is searchable **iff** its event class
implements this interface **and** the class is wired into `EventFactory` (the stores probe
searchability by kind through `EventFactory.create` — an unwired implementor is invisible).
Rules every implementation follows (keep them when adding one):
- **Plain text out.** Return the human-meaningful fields joined with `"\n"` (a handful of
metadata-ish kinds use `" "`); no markup stripping is performed — markdown/asciidoc content
goes in raw, JSON-content kinds (kind 0 metadata, marketplace stalls, channel info) **parse
first and join the extracted fields**, never the raw JSON.
- **Never throw, never null.** There is no defensive wrapper at any call site; a throw aborts
the insert transaction. Parsed-JSON implementations use `?.let { … } ?: ""`.
- **Only public data.** Encrypted content stays out (e.g. kind 30382 contact cards index only
the public petname/summary/topics, never the NIP-44 payload).
- Typical shapes: `content` alone (~33 kinds); `listOfNotNull(title(), content)`;
`listOfNotNull(title(), summary(), content)`; lists index `title() + description()`.
## The full kind table
**`references/searchable-kinds.md`** in this skill holds the authoritative table — every
implementor with its kind number, class, and the exact `indexableContent()` expression
(126 concrete classes / 129 kind values as of 2026-08). Diff that file at a version bump to
answer "did the searchable set or any kind's indexed text change?".
Notables that surprise people:
- **Kind 9735 (zap receipt) indexes the embedded zap request's content**
(`zapRequest?.content.orEmpty()`) — receipts are searchable by the zapper's comment.
- **Kind 0 / 31990** index many profile fields space-joined (name, about, nip05, lud16,
website, picture URL, …).
- **Kind 30063 is claimed twice** (`ReleaseArtifactSetEvent` in nip51Lists and the experimental
`SoftwareReleaseEvent`); `EventFactory` resolves 30063 to `ReleaseArtifactSetEvent`, so
`title()\ndescription()` is what actually gets indexed — `SoftwareReleaseEvent.indexableContent()`
is dead on the store path.
- Poll kinds (1068, 6969) append each option label on its own line.
## MANDATORY maintenance when you touch this surface
Adding `SearchableEvent` to a kind, removing it, or changing any `indexableContent()` body:
1. **Update `references/searchable-kinds.md`** in the same PR (external search engines — e.g.
the Vespa-backed store's `SearchExtractors` — mirror this table at pin bumps; a silent
change ships them stale search results).
2. **Remember existing databases don't reindex themselves.** Old rows keep their old (or
missing) FTS text until `IEventStore.reindexFullTextSearch()` runs — the KDoc on that method
is the contract. App-side, schedule the resumable overload after shipping such a change.
3. New implementors must be **registered in `EventFactory`** or the reindex scan and kind
pre-filter (`FullTextSearchModule.isSearchableKind`) will never see them.
Eligibility policy: a kind becomes searchable when it carries human-authored, human-meaningful
text (titles, bodies, names, descriptions). Pure-machine kinds (reactions, follow lists, zaps
minus their comment, relay lists) stay out to keep the index small.
## How the stores consume it
**SQLite** (`nip01Core/store/sqlite/FullTextSearchModule.kt`):
`CREATE VIRTUAL TABLE event_fts USING fts5(content, content='', contentless_delete=1)`
contentless, `rowid` = `event_headers.row_id`, an `AFTER DELETE` trigger keeps it in sync. On
insert (when FTS is on and not deferred): `if (event is SearchableEvent)` → bind
`event.indexableContent()` — the only method ever called. Tokenization is entirely SQLite's
default FTS5 `unicode61`; queries are always a bound `event_fts MATCH ?` (never concatenated),
ordered by bm25 `rank` then `created_at DESC`. Query-side semantics (relevance ordering,
extension stripping, FTS-off behavior, deferred catch-up) are rules STORE-S01…S06 in the
`event-store-semantics` skill.
**Filesystem store** (`jvmMain/.../store/fs/FsIndexer.kt` + `FsSearchTokenizer.kt`): tokenizes
`indexableContent()` itself, approximating `unicode61` (split on non-letter/digit, lowercase);
the same tokenizer runs on queries so drift cancels.
## NIP-50 client side
**`SearchQuery`** (`nip50Search/SearchQuery.kt`) — typed parse of the `search` filter string
into `terms` + `extensions`. A whitespace token is an extension iff it looks like
`lowercasekey:value` (the value not starting with `//`, so URLs stay free text); duplicate keys
keep the last; unknown extensions are preserved (`extension(key)`). Typed accessors:
`includeSpam`, `domain`, `language`, `sentiment`, `nsfw`. `stripExtensions()` /
`Filter.strippingSearchExtensions()` is the bridge the built-in stores use — unsupported
extensions are **ignored** (NIP-50), so an extensions-only search collapses to an unconstrained
query, never match-nothing. A server-side store that implements its own extensions
(`observer:`, `sort:rank`, …) receives the raw string (see the `IEventStore` KDoc) and should
parse with `SearchQuery.parse` so its syntax stays compatible with what clients send.
**`SearchRelayListEvent`** — **kind 10007**, the user's search-relay list (NIP-51-style, public
tags + NIP-44 private tags; *not* a `SearchableEvent` itself). Client consumption:
`commons/.../actions/SearchActions.kt`, bootstrap defaults in
`commons/.../account/AccountBootstrapEvents.kt`.
Don't confuse it with `commons/.../commons/search/SearchQuery.kt` — an app-level local-feed
query model (authors/kinds/hashtags/or-terms), unrelated to the NIP-50 wire string.
## Tests (executable spec)
- `commonTest/.../nip50Search/SearchQueryTest.kt` — the extension grammar, token by token.
- `commonTest/.../store/sqlite/SearchTest.kt` — per-kind indexing (kind 0 profile fields,
40/41 channel JSON, 31924/30617), extension-token ignoring, reindex/resumable-reindex,
FTS cleanup on replaceable rotation.
- `commonTest/.../store/sqlite/SearchRelevanceOrderTest.kt` — bm25-before-recency ordering,
limit-after-score, multi-filter rank union.
- `commonTest/.../store/sqlite/NoFullTextSearchTest.kt` — FTS-off contract.
- `jvmTest/.../store/fs/FsSearchTest.kt` — tokenizer parity for the filesystem store.
@@ -0,0 +1,166 @@
# Searchable kinds — the authoritative implementor table
Every concrete `SearchableEvent` implementor in Quartz, with the exact `indexableContent()`
expression. **Update this file in the same PR as any change to the searchable set or to an
`indexableContent()` body** (see SKILL.md). Verified against the code 2026-08-04.
Counts: 126 concrete classes covering 129 kind values (`GitStatusEvent` spans 4 kinds;
kind 30063 has a collision — see the footnote). File paths are under
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/`.
Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
| Kind | Class | Package | `indexableContent()` |
|---|---|---|---|
| 0 | MetadataEvent | nip01Core/metadata | `contactMetaData()?.let { listOfNotNull(it.name, it.displayName, it.about, it.nip05, it.lud06, it.lud16, it.website, it.picture, it.banner).joinToString(" ") } ?: ""` (SP) |
| 1 | TextNoteEvent | nip10Notes | `listOfNotNull(subject(), content)` NL |
| 9 | ChatEvent | nipC7Chats | `content` |
| 11 | ThreadEvent | nip7DThreads | `listOfNotNull(title(), content)` NL |
| 14 | ChatMessageEvent | nip17Dm/messages | `content` |
| 20 | PictureEvent | nip68Picture | `listOfNotNull(title(), content)` NL |
| 21 | VideoNormalEvent | nip71Video | inherited `RegularVideoEvent`: `listOfNotNull(title(), content)` NL |
| 22 | VideoShortEvent | nip71Video | inherited `RegularVideoEvent`: `listOfNotNull(title(), content)` NL |
| 24 | PublicMessageEvent | nipA4PublicMessages | `content` |
| 40 | ChannelCreateEvent | nip28PublicChat/admin | `channelInfo().let { listOfNotNull(it.name, it.about, it.picture).joinToString(" ") }` (SP) |
| 41 | ChannelMetadataEvent | nip28PublicChat/admin | same as kind 40 (SP) |
| 42 | ChannelMessageEvent | nip28PublicChat/message | `content` |
| 54 | PodcastEpisodeEvent | nipF4Podcasts/episode | `listOfNotNull(title(), description(), content)` NL |
| 1010 | TextNoteModificationEvent | experimental/edits | `listOfNotNull(content, summary())` NL (content first) |
| 1063 | FileHeaderEvent | nip94FileMetadata | `listOfNotNull(summary(), content)` NL |
| 1065 | FileStorageHeaderEvent | experimental/nip95/header | `listOfNotNull(summary())` NL |
| 1068 | PollEvent | nip88Polls/poll | `buildString { append(content); options().forEach { append('\n').append(it.label) } }` |
| 1111 | CommentEvent | nip22Comments | `(listOf(content) + tags.hashtags())` NL |
| 1163 | ProfileGalleryEntryEvent | experimental/profileGallery | `listOfNotNull(summary())` NL |
| 1301 | WorkoutRecordEvent | experimental/fitness/workout | `listOfNotNull(title(), content)` NL |
| 1311 | LiveActivitiesChatMessageEvent | nip53LiveActivities/chat | `(listOf(content) + tags.hashtags())` NL |
| 1312 | LiveActivitiesRaidEvent | nip53LiveActivities/raid | `content` |
| 1313 | LiveActivitiesClipEvent | nip53LiveActivities/clip | `listOfNotNull(title(), content)` NL |
| 1315 | RoadEventReportEvent | experimental/roadstr/report | `content` |
| 1337 | CodeSnippetEvent | nipC0CodeSnippets | `listOfNotNull(snippetName(), snippetDescription(), content)` NL |
| 1617 | GitPatchEvent | nip34Git/patch | `content` |
| 1618 | GitPullRequestEvent | nip34Git/pr | `listOfNotNull(subject(), content)` NL |
| 1621 | GitIssueEvent | nip34Git/issue | `listOfNotNull(subject(), content)` NL |
| 1622 | GitReplyEvent | nip34Git/reply | `content` |
| 16301633 | GitStatusEvent | nip34Git/status | `content` (open/applied/closed/draft) |
| 1808 | AudioHeaderEvent | experimental/audio/header | `content` |
| 1985 | LabelEvent | nip32Labeling | `(listOf(content) + labels().map { it.label }).filter { it.isNotEmpty() }` NL |
| 2003 | TorrentEvent | nip35Torrents | `listOfNotNull(title(), content)` NL |
| 2004 | TorrentCommentEvent | nip35Torrents | `content` |
| 2473 | BirdDetectionEvent | experimental/birdstar | `listOfNotNull(summary(), speciesName())` NL |
| 3302 | ConcordChatEditEvent | concord/cord03Channels | `content` |
| 5050 | NIP90TextGenerationRequestEvent | nip90Dvms/textGeneration | `inputs().filter { it.type == "prompt" \|\| it.type == "text" }.joinToString(" ") { it.value }` (SP) |
| 5100 | NIP90ImageGenerationRequestEvent | nip90Dvms/imageGeneration | `listOfNotNull(prompt(), negativePrompt()).joinToString(" ")` (SP) |
| 5129 | NappletSnapshotEvent | nip5dNapplets | `listOfNotNull(title(), description())` NL |
| 5250 | NIP90TextToSpeechRequestEvent | nip90Dvms/textToSpeech | `text() ?: ""` |
| 5302 | NIP90ContentSearchRequestEvent | nip90Dvms/contentSearch | `searchQuery() ?: ""` |
| 5303 | NIP90PeopleSearchRequestEvent | nip90Dvms/peopleSearch | `searchQuery() ?: ""` |
| 6969 | ZapPollEvent | experimental/zapPolls | `buildString { append(content); pollOptionsArray().forEach { append('\n').append(it.descriptor) } }` |
| 8333 | OnchainZapEvent | nipBCOnchainZaps/zap | `content` |
| 9002 | EditMetadataEvent | nip29RelayGroups/moderation | `(listOfNotNull(name(), about()) + hashtags())` NL |
| 9041 | GoalEvent | nip75ZapGoals | `listOfNotNull(summary(), content)` NL |
| 9321 | NutzapEvent | nip61Nutzaps/nutzap | `content` |
| 9734 | LnZapRequestEvent | nip57Zaps | `content` |
| 9735 | LnZapEvent | nip57Zaps | `zapRequest?.content.orEmpty()` — indexes the **embedded 9734's** content |
| 9736 | Bolt12ZapEvent | nipB1Bolt12Zaps/zap | `content` |
| 9737 | Bolt12ZapIntentEvent | nipB1Bolt12Zaps/intent | `content` |
| 9802 | HighlightEvent | nip84Highlights | `listOfNotNull(comment(), context(), content)` NL |
| 10003 | BookmarkListEvent | nip51Lists/bookmarkList | `listOfNotNull(title())` NL |
| 10100 | AgentProfileEvent | buzz/agentProfiles | `profileOrNull()?.let { listOfNotNull(it.name, it.displayName).joinToString("\n") } ?: ""` |
| 10154 | PodcastMetadataEvent | nipF4Podcasts/metadata | `listOfNotNull(title(), description())` NL |
| 11871 | AttestorProficiencyEvent | experimental/attestations/proficiency | `listOfNotNull(description())` NL |
| 12473 | BirdexEvent | experimental/birdstar | `(listOfNotNull(summary()) + speciesNames())` NL |
| 15128 | RootSiteEvent | nip5aStaticWebsites | `listOfNotNull(title(), description())` NL |
| 15129 | RootNappletEvent | nip5dNapplets | `listOfNotNull(title(), description())` NL |
| 30000 | PeopleListEvent | nip51Lists/peopleList | `listOfNotNull(titleOrName(), description())` NL |
| 30001 | OldBookmarkListEvent | nip51Lists/bookmarkList | `listOfNotNull(title())` NL |
| 30002 | RelaySetEvent | nip51Lists/relaySets | `listOfNotNull(title(), description())` NL |
| 30003 | LabeledBookmarkListEvent | nip51Lists/labeledBookmarkList | `listOfNotNull(titleOrName(), description())` NL |
| 30004 | ArticleCurationSetEvent | nip51Lists/articleCurationSet | `listOfNotNull(title(), description())` NL |
| 30005 | VideoCurationSetEvent | nip51Lists/videoCurationSet | `listOfNotNull(title(), description())` NL |
| 30006 | PictureCurationSetEvent | nip51Lists/pictureCurationSet | `listOfNotNull(title(), description())` NL |
| 30009 | BadgeDefinitionEvent | nip58Badges/definition | `listOfNotNull(name(), description(), content)` NL |
| 30015 | InterestSetEvent | nip51Lists/interestSet | `(listOfNotNull(title(), description()) + publicHashtags())` NL |
| 30017 | StallEvent | nip15Marketplace/stall | `stallData()?.let { listOfNotNull(it.name, it.description).joinToString("\n") } ?: ""` |
| 30018 | ProductEvent | nip15Marketplace/product | `productData()?.let { (listOfNotNull(it.name, it.description) + categories()).joinToString("\n") } ?: ""` |
| 30019 | MarketplaceEvent | nip15Marketplace/marketplace | `marketplaceData()?.let { listOfNotNull(it.name, it.about).joinToString("\n") } ?: ""` |
| 30020 | AuctionEvent | nip15Marketplace/auction | `auctionData()?.let { (listOfNotNull(it.name, it.description) + tags.hashtags()).joinToString("\n") } ?: ""` |
| 30023 | LongTextNoteEvent | nip23LongContent | `listOfNotNull(title(), summary(), content)` NL |
| 30030 | EmojiPackEvent | nip30CustomEmoji/pack | `listOfNotNull(titleOrName(), description(), content)` NL |
| 30054 | Podcasting20EpisodeEvent | nipXXPodcasting20/episode | `(listOfNotNull(title(), description(), content) + topics())` NL |
| 30055 | Podcasting20TrailerEvent | nipXXPodcasting20/trailer | `listOfNotNull(title(), content)` NL |
| 30063 | ReleaseArtifactSetEvent † | nip51Lists/releaseArtifactSet | `listOfNotNull(title(), description())` NL |
| 30175 | PersonaEvent | buzz/apPersonas | `personaOrNull()?.let { listOfNotNull(it.displayName, it.systemPrompt).joinToString("\n") } ?: ""` |
| 30176 | TeamEvent | buzz/teams | `teamOrNull()?.let { listOfNotNull(it.name, it.description, it.instructions).joinToString("\n") } ?: ""` |
| 30177 | ManagedAgentEvent | buzz/managedAgents | `agentOrNull()?.let { listOfNotNull(it.name, it.systemPrompt).joinToString("\n") } ?: ""` |
| 30267 | AppCurationSetEvent | nip51Lists/appCurationSet | `listOfNotNull(title(), description())` NL |
| 30296 | InteractiveStoryPrologueEvent | experimental/interactiveStories | inherited base: `listOfNotNull(title(), summary(), content)` NL |
| 30297 | InteractiveStorySceneEvent | experimental/interactiveStories | inherited base: `listOfNotNull(title(), summary(), content)` NL |
| 30311 | LiveActivitiesEvent | nip53LiveActivities/streaming | `listOfNotNull(title(), summary(), content)` NL |
| 30312 | MeetingSpaceEvent | nip53LiveActivities/meetingSpaces | `listOfNotNull(room(), summary(), content)` NL |
| 30313 | MeetingRoomEvent | nip53LiveActivities/meetingSpaces | `listOfNotNull(title(), summary())` NL |
| 30315 | StatusEvent | nip38UserStatus | `content` |
| 30382 | ContactCardEvent | nip85TrustedAssertions/users | `(listOfNotNull(petName(), summary()) + topics())` NL — public tags only, never the NIP-44 content |
| 30402 | ClassifiedsEvent | nip99Classifieds | `listOfNotNull(title(), summary(), content)` NL |
| 30617 | GitRepositoryEvent | nip34Git/repository | `listOfNotNull(name(), description(), content)` NL |
| 30620 | WorkflowDefEvent | buzz/workflow | `listOfNotNull(name(), content)` NL |
| 30817 | NipTextEvent | experimental/nipsOnNostr | `listOfNotNull(title(), content)` NL |
| 30818 | WikiNoteEvent | nip54Wiki | `listOfNotNull(title(), summary(), content)` NL |
| 31337 | AudioTrackEvent | experimental/audio/track | `listOfNotNull(subject())` NL |
| 31871 | AttestationEvent | experimental/attestations/attestation | `content` |
| 31872 | AttestationRequestEvent | experimental/attestations/request | `content` |
| 31873 | AttestorRecommendationEvent | experimental/attestations/recommendation | `listOfNotNull(description())` NL |
| 31890 | FeedDefinitionEvent | feedDefinition | `title().orEmpty()` |
| 31922 | CalendarDateSlotEvent | nip52Calendar/appt/day | `listOfNotNull(title(), summary(), content)` NL |
| 31923 | CalendarTimeSlotEvent | nip52Calendar/appt/time | `listOfNotNull(title(), summary(), content)` NL |
| 31924 | CalendarEvent | nip52Calendar/calendar | `listOfNotNull(title(), content)` NL |
| 31925 | CalendarRSVPEvent | nip52Calendar/rsvp | `content` |
| 31990 | AppDefinitionEvent | nip89AppHandlers/definition | `appMetaData()?.let { listOfNotNull(it.name, it.username, it.displayName, it.about, it.nip05, it.lud06, it.lud16, it.website, it.picture, it.banner, it.image).joinToString(" ") } ?: ""` (SP) |
| 32267 | SoftwareApplicationEvent | experimental/nip82SoftwareApps/application | `listOfNotNull(name(), summary(), content)` NL |
| 33401 | ExerciseTemplateEvent | experimental/fitness/workout | `listOfNotNull(title(), content)` NL |
| 33863 | FundraiserEvent | experimental/agora | `listOfNotNull(title(), content)` NL |
| 34139 | MusicPlaylistEvent | experimental/music/playlist | `listOfNotNull(title(), description(), content)` NL |
| 34235 | VideoHorizontalEvent | nip71Video | inherited `AddressableVideoEvent`: `listOfNotNull(title(), content)` NL |
| 34236 | VideoVerticalEvent | nip71Video | inherited `AddressableVideoEvent`: `listOfNotNull(title(), content)` NL |
| 34550 | CommunityDefinitionEvent | nip72ModCommunities/definition | `listOfNotNull(name(), description(), rules(), content)` NL |
| 35128 | NamedSiteEvent | nip5aStaticWebsites | `listOfNotNull(title(), description())` NL |
| 35129 | NamedNappletEvent | nip5dNapplets | `listOfNotNull(title(), description())` NL |
| 36787 | MusicTrackEvent | experimental/music/track | `listOfNotNull(title(), artist(), album(), content)` NL |
| 38000 | MintRecommendationEvent | nip87Ecash/recommendation | `content` |
| 38192 | Ps1SaveEvent | experimental/ps1saves | `listOfNotNull(summary(), saveTitle(), region(), filename())` NL |
| 38383 | P2POrderEvent | nip69P2pOrderEvents | `(listOfNotNull(makerName(), currency()) + paymentMethods().orEmpty()).joinToString(" ")` (SP) |
| 39000 | GroupMetadataEvent | nip29RelayGroups/metadata | `listOfNotNull(name(), about())` NL |
| 39089 | FollowListEvent | nip51Lists/followList | `listOfNotNull(title(), description())` NL |
| 39092 | MediaStarterPackEvent | nip51Lists/mediaStarterPack | `listOfNotNull(title(), description())` NL |
| 39701 | WebBookmarkEvent | nipB0WebBookmarks | `listOfNotNull(title(), description())` NL |
| 40002 | StreamMessageV2Event | buzz/stream | `content` |
| 40100 | CanvasEvent | buzz/stream | `content` |
| 45001 | ForumPostEvent | buzz/forum | `content` |
| 45003 | ForumCommentEvent | buzz/forum | `content` |
| 48106 | HuddleGuidelinesEvent | buzz/huddles | `content` |
**Kind 30063 collision:** `experimental/nip82SoftwareApps/release/SoftwareReleaseEvent` also
declares `KIND = 30063` and implements `SearchableEvent` (`content`), but `EventFactory` maps
30063 to `ReleaseArtifactSetEvent`, so on every store path kind 30063 indexes
`title()\ndescription()`. If the factory mapping ever changes, this table changes with it.
## Abstract bases (no kind of their own)
| Base class | Body | Concrete kinds |
|---|---|---|
| `InteractiveStoryBaseEvent` | `listOfNotNull(title(), summary(), content)` NL | 30296, 30297 |
| `AddressableVideoEvent` | `listOfNotNull(title(), content)` NL | 34235, 34236 |
| `RegularVideoEvent` | `listOfNotNull(title(), content)` NL | 21, 22 |
## How to regenerate / verify this table
```bash
# All implementor files:
grep -rln "override fun indexableContent" quartz/src/commonMain
# For each, pair the KIND constant with the indexableContent() body.
# Searchability on the store path additionally requires EventFactory registration:
grep -n "<ClassName>" quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt
```
A CI-diffable snapshot test (assert the set of kinds whose `EventFactory` product implements
`SearchableEvent` against a checked-in list) would make this table impossible to go stale —
suggested follow-up, not yet implemented.
+50 -18
View File
@@ -26,8 +26,12 @@ env:
# bundle deps — that fights jpackage's self-contained JRE (libjvm.so has
# $ORIGIN RPATH so ldd can't resolve it standalone). appimagetool only
# embeds the AppDir as-is, which is what we actually want.
APPIMAGETOOL_URL: https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage
APPIMAGETOOL_SHA256: 46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1
#
# Both arch binaries come from the same appimagetool release so their SHA256
# values move in lockstep on version bumps.
APPIMAGETOOL_VERSION: '1.9.0'
APPIMAGETOOL_SHA256_X86_64: 46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1
APPIMAGETOOL_SHA256_AARCH64: 04f45ea45b5aa07bb2b071aed9dbf7a5185d3953b11b47358c1311f11ea94a96
jobs:
# ---------------------------------------------------------------------------
@@ -38,11 +42,17 @@ jobs:
strategy:
fail-fast: false
matrix:
# Linux legs run on x64 and arm64 GitHub-hosted runners (the
# ubuntu-24.04-arm label is a standard free public-repo runner as of
# early 2025). jpackage / jlink / Compose Multiplatform 1.11 all
# produce host-native artifacts — no cross-compilation needed.
include:
- { os: macos-14, arch: arm64, family: macos, tasks: "packageReleaseDmg" }
- { os: windows-latest, arch: x64, family: windows, tasks: "packageReleaseMsi createReleaseDistributable" }
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" }
- { os: ubuntu-latest, arch: x64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" }
- { os: macos-14, arch: arm64, family: macos, tasks: "packageReleaseDmg" }
- { os: windows-latest, arch: x64, family: windows, tasks: "packageReleaseMsi createReleaseDistributable" }
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" }
- { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" }
- { os: ubuntu-latest, arch: x64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" }
- { os: ubuntu-24.04-arm, arch: arm64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" }
runs-on: ${{ matrix.os }}
timeout-minutes: 60 # linux-portable leg also downloads the freedesktop runtime + builds the Flatpak bundle
defaults:
@@ -93,13 +103,21 @@ jobs:
set -euo pipefail
# appimagetool 1.9.0 validates the .desktop file via desktop-file-validate.
sudo apt-get update && sudo apt-get install -y desktop-file-utils
curl -fsSL --retry 3 "$APPIMAGETOOL_URL" -o desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
actual=$(sha256sum desktopApp/packaging/appimage/appimagetool-x86_64.AppImage | awk '{print $1}')
if [[ "$actual" != "$APPIMAGETOOL_SHA256" ]]; then
echo "::error::appimagetool SHA256 mismatch. Expected $APPIMAGETOOL_SHA256, got $actual"
# Map runner arch → upstream AppImage suffix (x86_64 / aarch64).
case "${{ matrix.arch }}" in
x64) TOOL_ARCH=x86_64 ; EXPECTED_SHA="$APPIMAGETOOL_SHA256_X86_64" ;;
arm64) TOOL_ARCH=aarch64; EXPECTED_SHA="$APPIMAGETOOL_SHA256_AARCH64" ;;
*) echo "::error::unsupported arch for AppImage: ${{ matrix.arch }}"; exit 1 ;;
esac
URL="https://github.com/AppImage/appimagetool/releases/download/${APPIMAGETOOL_VERSION}/appimagetool-${TOOL_ARCH}.AppImage"
DEST="desktopApp/packaging/appimage/appimagetool-${TOOL_ARCH}.AppImage"
curl -fsSL --retry 3 "$URL" -o "$DEST"
actual=$(sha256sum "$DEST" | awk '{print $1}')
if [[ "$actual" != "$EXPECTED_SHA" ]]; then
echo "::error::appimagetool SHA256 mismatch for $TOOL_ARCH. Expected $EXPECTED_SHA, got $actual"
exit 1
fi
chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
chmod +x "$DEST"
# Flatpak tooling + the freedesktop runtime/sdk the manifest pins
# (runtime-version is greped from the manifest so this never drifts).
@@ -208,12 +226,13 @@ jobs:
run: |
set -euo pipefail
VER="${{ steps.ver.outputs.version }}"
ARCH="${{ matrix.arch }}"
APP="desktopApp/build/compose/binaries/main-release/app"
mkdir -p desktopApp/build/portable
if [[ "${{ matrix.family }}" == "windows" ]]; then
( cd "$APP" && 7z a -tzip "../../../../portable/amethyst-desktop-${VER}-windows-x64.zip" Amethyst/ )
( cd "$APP" && 7z a -tzip "../../../../portable/amethyst-desktop-${VER}-windows-${ARCH}.zip" Amethyst/ )
else
( cd "$APP" && tar czf "../../../../portable/amethyst-desktop-${VER}-linux-x64.tar.gz" Amethyst/ )
( cd "$APP" && tar czf "../../../../portable/amethyst-desktop-${VER}-linux-${ARCH}.tar.gz" Amethyst/ )
fi
# Flatpak bundle: wraps the same createReleaseDistributable tree the
@@ -230,6 +249,17 @@ jobs:
PKG="desktopApp/packaging/flatpak"
APP_ID="com.vitorpamplona.amethyst.Desktop"
OUT="desktopApp/build/flatpak"
# AppImage-style arch names for the bundle filename.
case "${{ matrix.arch }}" in
x64) BUNDLE_ARCH=x86_64 ; GST_TRIPLET=x86_64-linux-gnu ;;
arm64) BUNDLE_ARCH=aarch64 ; GST_TRIPLET=aarch64-linux-gnu ;;
*) echo "::error::unsupported arch for Flatpak: ${{ matrix.arch }}"; exit 1 ;;
esac
# Rewrite the arch-specific GStreamer plugin path in the manifest
# (checked-in default is x86_64-linux-gnu). Idempotent — the sed only
# matches the original triplet.
sed -i "s|/usr/lib/x86_64-linux-gnu/gstreamer-1.0|/usr/lib/${GST_TRIPLET}/gstreamer-1.0|g" \
"${PKG}/${APP_ID}.yml"
# Inject the AppStream <release> entry for this build (the checked-in
# metainfo deliberately carries none — CI is the source of truth).
sed -i "s|<releases>|<releases>\n <release version=\"${VER}\" date=\"$(date -u +%F)\" />|" \
@@ -241,7 +271,7 @@ jobs:
"${OUT}/build-dir" \
"${PKG}/${APP_ID}.yml"
flatpak build-bundle "${OUT}/repo" \
"${OUT}/Amethyst-${VER}-x86_64.flatpak" \
"${OUT}/Amethyst-${VER}-${BUNDLE_ARCH}.flatpak" \
"$APP_ID" \
--runtime-repo=https://dl.flathub.org/repo/flathub.flatpakrepo
ls -la "$OUT"
@@ -325,8 +355,9 @@ jobs:
fail-fast: false
matrix:
include:
- { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" }
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" }
- { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" }
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" }
- { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" }
runs-on: ${{ matrix.os }}
timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image
defaults:
@@ -574,8 +605,9 @@ jobs:
fail-fast: false
matrix:
include:
- { os: macos-14, arch: arm64, family: macos, tasks: "geodeImage" }
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" }
- { os: macos-14, arch: arm64, family: macos, tasks: "geodeImage" }
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" }
- { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" }
runs-on: ${{ matrix.os }}
timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image
defaults:
+11 -2
View File
@@ -49,9 +49,17 @@ jobs:
# package, installs it, and verifies the process stays alive for 10s.
# Catches ProGuard stripping (JNI, reflection), missing jlink modules
# (java.management, java.prefs), and native lib bundling issues.
#
# Runs on both x64 and arm64 hosted runners so release-time arm64 breakage
# (e.g. ProGuard rules missing an arch-specific reflection root) is caught
# at PR time instead of on the tag build.
# -------------------------------------------------------------------------
release-deb-launch:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, ubuntu-24.04-arm]
runs-on: ${{ matrix.os }}
timeout-minutes: 45
steps:
- name: Checkout code
@@ -139,5 +147,6 @@ jobs:
if: always()
uses: actions/upload-artifact@v7
with:
name: Release DEB (smoke-tested)
# Artifact names must be unique across a run — disambiguate per arch.
name: Release DEB (smoke-tested, ${{ matrix.os }})
path: desktopApp/build/compose/binaries/main-release/deb/*.deb
+8 -5
View File
@@ -57,9 +57,12 @@ Install appimagetool locally (CI fetches its own — SHA-verified):
# Debian/Ubuntu — appimagetool calls desktop-file-validate on the .desktop entry
sudo apt-get install -y desktop-file-utils
curl -fsSL -o desktopApp/packaging/appimage/appimagetool-x86_64.AppImage \
https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage
chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
# createReleaseAppImage picks appimagetool-<arch>.AppImage matching the JVM's
# os.arch — fetch the one for your host (x86_64 on Intel/AMD, aarch64 on ARM).
ARCH="$(uname -m)"
curl -fsSL -o "desktopApp/packaging/appimage/appimagetool-${ARCH}.AppImage" \
"https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-${ARCH}.AppImage"
chmod +x "desktopApp/packaging/appimage/appimagetool-${ARCH}.AppImage"
```
---
@@ -110,8 +113,8 @@ are **not** required to build Amethyst from the committed sources.
| Windows MSI | `./gradlew :desktopApp:packageReleaseMsi` | `desktopApp/build/compose/binaries/main-release/msi/Amethyst-*.msi` |
| Linux `.deb` | `./gradlew :desktopApp:packageReleaseDeb` | `desktopApp/build/compose/binaries/main-release/deb/amethyst_*.deb` |
| Linux `.rpm` | `./gradlew :desktopApp:packageReleaseRpm` | `desktopApp/build/compose/binaries/main-release/rpm/amethyst-*.rpm` |
| Linux AppImage | `./gradlew :desktopApp:createReleaseAppImage` | `desktopApp/build/appimage/Amethyst-*-x86_64.AppImage` |
| Linux Flatpak | `flatpak-builder` over `createReleaseDistributable` output — see [`desktopApp/packaging/flatpak/README.md`](desktopApp/packaging/flatpak/README.md) | `desktopApp/build/flatpak/Amethyst-*-x86_64.flatpak` (CI) |
| Linux AppImage | `./gradlew :desktopApp:createReleaseAppImage` | `desktopApp/build/appimage/Amethyst-*-<arch>.AppImage` (x86_64 or aarch64, from host) |
| Linux Flatpak | `flatpak-builder` over `createReleaseDistributable` output — see [`desktopApp/packaging/flatpak/README.md`](desktopApp/packaging/flatpak/README.md) | `desktopApp/build/flatpak/Amethyst-*-<arch>.flatpak` (CI; x86_64 or aarch64) |
| Windows `.zip` portable | See below (inline `7z`) | — |
| Linux `.tar.gz` portable | See below (inline `tar`) | — |
@@ -971,7 +971,7 @@ class AccountConcordActions(
val filter = Filter(kinds = listOf(ConcordCommunityListEvent.KIND), authors = listOf(account.signer.pubKey))
// Stock relays like relay.ditto.pub can be slow (~1020s to first response), so give
// the fetch a generous window to drain every relay before we pick the newest copy.
val events = account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, timeoutMs = 30_000L)
val events = account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, idleTimeoutMs = 30_000L)
val newest = events.filterIsInstance<ConcordCommunityListEvent>().maxByOrNull { it.createdAt }
val entryCount = newest?.let { runCatching { it.decrypt(account.signer).size }.getOrElse { -1 } } ?: 0
Log.d(
@@ -1015,7 +1015,7 @@ class AccountConcordActions(
}
if (filters.isEmpty()) return
val byRelay = filters.groupBy { it.relay }.mapValues { (_, group) -> group.map { it.filter } }
account.client.fetchAll(filters = byRelay, timeoutMs = 20_000L)
account.client.fetchAll(filters = byRelay, idleTimeoutMs = 20_000L)
}
/**
@@ -133,7 +133,7 @@ class AccountRelayGroupActions(
if (channelId == null && results.values.any { !it.accepted && it.message.contains("auth-required", ignoreCase = true) }) {
account.client.fetchAllWithHooks(
filters = mapOf(relay to listOf(Filter(kinds = listOf(DmOpenEvent.KIND), limit = 1))),
timeoutMs = 8_000,
idleTimeoutMs = 8_000,
pendingOnAuthRequired = true,
) { _, _ -> false }
results = account.client.publishAndCollectResults(signed, setOf(relay))
@@ -67,7 +67,9 @@ class RoleBasedHttpClientBuilder(
normalizedUrl: String,
final: Boolean,
): Boolean =
if (RelayUrlNormalizer.isLocalHost(normalizedUrl)) {
if (RelayUrlNormalizer.isLocalHost(normalizedUrl) || RelayUrlNormalizer.isOverlayNetwork(normalizedUrl)) {
// Overlay-mesh hosts (0200::/7) are reachable only through the local mesh
// interface — Tor cannot route the range, so proxying only breaks the fetch.
false
} else if (RelayUrlNormalizer.isOnion(normalizedUrl)) {
true
@@ -113,7 +115,7 @@ class RoleBasedHttpClientBuilder(
isOnionRelaysActive: Boolean,
final: Boolean,
): Boolean =
if (RelayUrlNormalizer.isLocalHost(normalizedUrl)) {
if (RelayUrlNormalizer.isLocalHost(normalizedUrl) || RelayUrlNormalizer.isOverlayNetwork(normalizedUrl)) {
false
} else if (RelayUrlNormalizer.isOnion(normalizedUrl)) {
isOnionRelaysActive
@@ -255,7 +255,7 @@ class AccountNappletGateways(
emptyList()
} else {
runCatching {
account.client.fetchAll(filters = relays.associateWith { filters }, timeoutMs = QUERY_TIMEOUT.inWholeMilliseconds)
account.client.fetchAll(filters = relays.associateWith { filters }, idleTimeoutMs = QUERY_TIMEOUT.inWholeMilliseconds)
}.getOrDefault(emptyList())
}
val fromCache = filters.flatMap { filter -> account.cache.filter(filter).mapNotNull { it.event } }
@@ -144,7 +144,7 @@ class NappletResourceFetcher(
val relays = account.homeRelays.flow.value
if (relays.isEmpty()) return null
return runCatching {
account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, timeoutMs = NOSTR_FETCH_TIMEOUT_MS)
account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, idleTimeoutMs = NOSTR_FETCH_TIMEOUT_MS)
}.getOrDefault(emptyList())
.maxByOrNull { it.createdAt }
}
@@ -84,9 +84,14 @@ class UserCardsSubAssembler(
add(it, account.userProfile().pubkeyHex)
}
}
accounts.map { it.trustProviderList.liveUserRankProvider.value }.forEach { account ->
if (account != null) {
add(account.relayUrl, account.pubkey)
accounts.map { it.trustProviderList.liveUserRankProvider.value }.forEach { provider ->
if (provider != null) {
add(provider.relayUrl, provider.pubkey)
}
}
accounts.map { it.trustProviderList.liveUserFollowerCount.value }.forEach { provider ->
if (provider != null) {
add(provider.relayUrl, provider.pubkey)
}
}
}
@@ -153,7 +153,7 @@ class AgentConsoleViewModel : ViewModel() {
// (pendingOnAuthRequired) so it authenticates on the `auth-required` CLOSED and retries.
account.client.fetchAllWithHooks(
filters = relays.associateWith { filters },
timeoutMs = 8_000,
idleTimeoutMs = 8_000,
pendingOnAuthRequired = true,
) { _, _ -> false }
}
@@ -97,7 +97,7 @@ private suspend fun runBuzzDmDiscovery(
// rather than returning empty.
account.client.fetchAllWithHooks(
filters = relays.associateWith { discoveryFilters },
timeoutMs = 8_000,
idleTimeoutMs = 8_000,
pendingOnAuthRequired = true,
) { relay, event ->
(event as? MemberAddedNotificationEvent)?.let { recordDiscovery(me, it, relay) }
@@ -135,7 +135,7 @@ private suspend fun fetchDmMetadata(
.groupBy({ it.value }, { it.key })
.mapValues { (_, ids) -> listOf(Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to ids))) }
if (byRelay.isEmpty()) return
account.client.fetchAllWithHooks(filters = byRelay, timeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false }
account.client.fetchAllWithHooks(filters = byRelay, idleTimeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false }
}
/**
@@ -200,7 +200,7 @@ class BuzzDmListViewModel : ViewModel() {
)
account.client.fetchAllWithHooks(
filters = relays.associateWith { filters },
timeoutMs = 8_000,
idleTimeoutMs = 8_000,
pendingOnAuthRequired = true,
) { relay, event ->
(event as? MemberAddedNotificationEvent)?.channel()?.let { memberChannels[it] = relay }
@@ -215,7 +215,7 @@ class BuzzDmListViewModel : ViewModel() {
.groupBy({ it.value }, { it.key })
.mapValues { (_, ids) -> listOf(Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to ids))) }
if (byRelay.isEmpty()) return
account.client.fetchAllWithHooks(filters = byRelay, timeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false }
account.client.fetchAllWithHooks(filters = byRelay, idleTimeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false }
}
/**
@@ -149,7 +149,7 @@ class BuzzRelayImportViewModel : ViewModel() {
),
),
),
timeoutMs = 8_000,
idleTimeoutMs = 8_000,
pendingOnAuthRequired = true,
) { _, event ->
(event as? MemberAddedNotificationEvent)?.channel()?.let { channelIds.add(it) }
@@ -172,7 +172,7 @@ class BuzzRelayImportViewModel : ViewModel() {
Filter(kinds = listOf(SystemMessageEvent.KIND), tags = mapOf("h" to channelIds.toList())),
),
),
timeoutMs = 8_000,
idleTimeoutMs = 8_000,
pendingOnAuthRequired = true,
) { _, _ -> false }
}
@@ -170,6 +170,7 @@ fun RelayUrlEditField(
nav: INav,
) {
var url by remember { mutableStateOf("") }
var isInvalid by remember { mutableStateOf(false) }
fun submitRelay() {
if (url.isNotBlank()) {
@@ -177,7 +178,13 @@ fun RelayUrlEditField(
if (relay != null) {
onNewRelay(relay)
url = ""
isInvalid = false
relaySuggestions.reset()
} else {
// Without this the Add button is a silent no-op, which reads as a broken button.
// Bare IPv6 literals are the common way to land here: an overlay-mesh address
// pasted straight out of `yggdrasilctl getSelf` needs brackets to carry a port.
isInvalid = true
}
}
}
@@ -189,8 +196,23 @@ fun RelayUrlEditField(
value = url,
onValueChange = {
url = it
isInvalid = false
relaySuggestions.processInput(it)
},
isError = isInvalid,
// Null, not an empty lambda: a non-null slot reserves its line height even when it
// draws nothing, which would pad the field permanently for every user.
supportingText =
if (isInvalid) {
{
Text(
text = stringRes(R.string.relay_url_not_valid),
color = MaterialTheme.colorScheme.error,
)
}
} else {
null
},
placeholder = {
Text(
text = "server.com",
@@ -502,7 +502,7 @@ class EventSync(
try {
client.fetchAllPagesFromPool(
filters = perRelayFilters,
timeoutMs = RELAY_TIMEOUT_MS,
idleTimeoutMs = RELAY_TIMEOUT_MS,
maxConcurrentRelays = MAX_CONCURRENT_RELAYS,
onNewPage = { until, sourceRelay ->
_liveActivity.value.runningRelays[sourceRelay]
@@ -199,7 +199,7 @@ class CashuWalletDiscovery(
fetchAllPages(
relay = relay,
filters = filters,
timeoutMs = RELAY_TIMEOUT_MS,
idleTimeoutMs = RELAY_TIMEOUT_MS,
onEvent = onEvent,
)
}.onFailure {
@@ -332,7 +332,7 @@
<string name="concord_leave_title">क्या समुदाय छोडें।</string>
<string name="concord_leave_message">क्या %1$s छोडें। इसे हटाया जाएगा इस लेखा की सूची से तथा आपके यन्त्रों पर समचरणीकरण रुक जाएगा। समुदाय को सूचित नहीं किया जाएगा। तथा आपको उसके सदस्य कार्यसूची से हटाया नहीं जाएगा। सन्देश जिनका आप अरहस्यीकरण नहीं कर सकेंगे सम्भाव्यतः पुनःप्राप्तव्य नहीं होंगे। तथा आप केवल नए आमन्त्रण के साथ लौट सकेंगे।</string>
<string name="concord_leave_owner_warning">आपने इस समुदाय को बनाया। छोड जाने से यह मिटेगा नहीं। किसी अन्य के हाथ सौंपा नहीं जाएगा। परन्तु स्वत्वधारी कुंचिका जो आपकी सूची में हैं वह हटाया जाएगा। आप आगे से इसका प्रबन्धन नहीं कर पाएँगे।</string>
<string name="concord_edit_relays_desc">जहाँ इस समुदाय के रहस्यीकृत पत्रों का प्रकाशन तथा पठन किया जाता है।</string>
<string name="concord_edit_relays_desc">जहाँ इस समुदाय के रहस्यीकृत समतलों का प्रकाशन तथा पठन किया जाता है।</string>
<string name="concord_dissolved_read_only">इस समुदाय को विघटित किया गया है तथा अब पठनेवशक्य है। आप इसका इतिहास पढ सकते हैं परन्तु कोई नए सन्देश नहीं भेज सकते।</string>
<string name="concord_typing_one">%1$s टंकण मध्य…</string>
<string name="concord_typing_two">%1$s तथा %2$s टंकण मध्य…</string>
@@ -1233,7 +1233,7 @@
<string name="nest_participant_unmute">मौन हटाएँ</string>
<string name="nest_force_mute_note">सम्भाव्यतः उन ग्राहकों द्वारा उपेक्षित जो आज्ञा का सम्मान नहीं करते।</string>
<string name="nest_confirm_kick_title">क्या शाला से निष्कासित करें।</string>
<string name="nest_confirm_kick_body">%1$s को ध्वनि तल से हटाए जाएँगे तथा सहभागी सूची से भी। वे पुनः जुड सकते हैं यदि वे शाला योजक प्राप्त कर लें।</string>
<string name="nest_confirm_kick_body">%1$s को ध्वनि समतल से हटाए जाएँगे तथा सहभागी सूची से भी। वे पुनः जुड सकते हैं यदि वे शाला योजक प्राप्त कर लें।</string>
<string name="nest_confirm_kick_confirm">पदप्रहार</string>
<string name="nest_confirm_force_mute_title">क्या वक्ता को मौन करें।</string>
<string name="nest_confirm_force_mute_body">%1$s के ग्राहक को अपना ध्वनिग्राहक मौन करने का अनुरोध करता है। कुछ ग्राहक इस आदेश की उपेक्षा कर सकते हैं।</string>
@@ -1942,14 +1942,95 @@
</plurals>
<!-- Expanded-only breakdown of the always-on notification. Counts overlap: one relay commonly
serves several jobs at once, so these deliberately sum to more than the relay count. -->
<plurals name="relay_purpose_line">
<item quantity="one">%1$s \u00b7 %2$d पुनःप्रसारक</item>
<item quantity="other">%1$s \u00b7 %2$d पुनःप्रसारक</item>
</plurals>
<string name="relay_purpose_browsing">जालभ्रमण</string>
<string name="relay_purpose_media">ध्वनिचित्राभिलेख</string>
<string name="relay_purpose_tags">विषयसूचक</string>
<string name="relay_purpose_topics">विषय सूची</string>
<string name="relay_purpose_thread">वार्तालाप</string>
<string name="relay_purpose_search">खोज</string>
<string name="relay_purpose_referenced">लुप्त घटनाओं को ढूँढें</string>
<string name="relay_purpose_engagement">घटना अवलोकन</string>
<string name="relay_explain_referenced">घटनाओं को विभेदक अनुसार ले आता है जिसका उल्लेख आपके पटल पर अमुक करता है पर जिसकी प्राप्ती अभी नहीं हुई। एक उद्धरण अथवा एक प्रत्युत्तर का पूर्वपत्र अथवा एक सूत्र का मूल।</string>
<string name="relay_explain_engagement">घटनाओं का अवलोकन करता है जो वर्तमान में प्रदर्शित हो रहे हैं नए प्रत्युत्तर प्रतिक्रियाएँ उद्धरण ज्साप तथा वृत्तान्तों के लिए जिससे गिनतियों का नवीकरण होता है जब आप पढ रहे हैं।</string>
<string name="relay_purpose_add_ons">संलग्न</string>
<string name="relay_purpose_relay_info">पुनःप्रसारक जानकारी</string>
<string name="relay_purpose_other">अन्य</string>
<!-- Activity labels, used where the app's own noun for the data type already means something
else to the user: "Outbox Relays" is their own relay list in settings, and "Profile" is
their profile screen. Naming the job avoids an active misreading. -->
<string name="relay_purpose_relay_list_finder">पुनःप्रसारक सूची खोजकर्ता</string>
<string name="relay_purpose_observing_profiles">परिचय अवलोकन</string>
<string name="relay_purpose_your_account">लेखा जानकारी</string>
<string name="relay_purpose_home_feed">मुख्य सूचनावली</string>
<string name="relay_purpose_relay_groups">पुनःप्रसारक समूह</string>
<plurals name="active_subs_groups">
<item quantity="one">%1$d समूह</item>
<item quantity="other">%1$d समूह</item>
</plurals>
<string name="relay_purpose_ephemeral_chats">अस्थायी चर्चाएँ</string>
<string name="relay_purpose_geohash_chats">स्थानीय चर्चाएँ</string>
<string name="relay_purpose_live_chat">वर्तमानप्रवाह चर्चा</string>
<string name="relay_explain_relay_groups">निप॰२९ समूह जिनसे आप जुडे हैं। प्रत्येक समूह एक जालावास पुनःप्रसारक में रहता है। इसलिए क्रमक प्रत्येक पुनःप्रसारक से संयोजन करता है जो आपके किसी समूह का जालावास है।</string>
<string name="relay_explain_ephemeral_chats">चर्चाशालाएँ जो कोई इतिहास नहीं रखते। सन्देश केवल तब तक रहते हैं जब तक आप संयोजित हैं। इसलिए ये ग्राहकता बनाए रखते हैं कुछ भी प्राप्त होने के लिए।</string>
<string name="relay_explain_geohash_chats">स्थान आधारित शालाएँ उन क्षेत्रों के लिए जिनका आप अनुगमन करते हैं। पृष्ट उन पुनःप्रसारको से जो इनके जालावास हैं।</string>
<string name="relay_explain_live_chat">चर्चा तथा ज्साप उद्देश्य जो वर्तमानप्रवाहों से संलग्न हैं जिन्हें आप खोले हुए हैं अथवा अनुगमन करते हैं।</string>
<string name="relay_purpose_dm_inbox">सीधासन्देश आगतपेटिका</string>
<string name="relay_purpose_your_wallet">धनकोष</string>
<string name="relay_purpose_nutzap_inbox">नटज्साप आगतपेटिका</string>
<string name="relay_purpose_mint_directory">टकसाल निर्देशिका</string>
<string name="relay_purpose_nwc">धनकोष संयोजन</string>
<string name="relay_purpose_community_chats">समुदाय चर्चाएँ</string>
<string name="relay_purpose_community_feeds">समुदाय सूचनावलियाँ</string>
<!-- How each subscription actually works, shown on the Active Subscriptions screen.
Describe the real strategy, not the intent — these are read by people trying to explain
a relay count they think is too high. -->
<string name="relay_explain_notifications">आपके आगतपेटिका पुनःप्रसारक तथा कुछ अल्पमात्रा परिभ्रमणवर्ती दृष्टान्त पुनःप्रसारक जिनपर आपके अनुचरित पत्र प्रकाशित करते हैं। यदि कोई उल्लेख अन्यत्र भेजा गया।</string>
<string name="relay_explain_direct_messages">आपके सीधासन्देश पुनःप्रसारक। जहाँ उपहारकोषयुक्त सन्देश भेजे जाते हैं।</string>
<string name="relay_explain_public_chats">मुख्य पुनःप्रसारक प्रत्येक चर्चा का जिन्हें आप खोल रखे हैं अथवा जिनसे आप जुड चुके हैं।</string>
<string name="relay_explain_community_chats">पुनःप्रसारक जिनपर प्रत्येक समुदाय अपने समतलों को प्रकाशित करते हैं।</string>
<string name="relay_explain_encrypted_groups">समूह सन्देश तथा कुंचिकापेटलियाँ। प्रत्येक समूह के पुनःप्रसारकों पर।</string>
<string name="relay_explain_live_rooms">शाला के पुनःप्रसारक। जब वह खुला हो।</string>
<string name="relay_explain_account_data">आपके अपने परिचय तथा स्थापना विकल्प तथा पाण्डुलिपियाँ। आपके मुख्य पुनःप्रसारकों पर।</string>
<string name="relay_explain_profiles">वर्तमानतः पटल पर लोगों के परिचय।</string>
<string name="relay_explain_relay_lists">खोजता है किन पुनःप्रसारकों पर प्रत्येक व्यक्ति प्रकाशन करता है। जिससे कि उनके पत्र सम्यक स्थल से प्राप्प हो।</string>
<string name="relay_explain_follows">अनुचरण सूचियाँ। आपकी सूचनावली तथा आपका विश्वासजाल का निर्माण के लिए उपयुक्त।</string>
<string name="relay_explain_moderation">वृत्तान्त जो आपके अनुचरितों ने लिखा वर्तमानतः आपके पटल पर दिखनेवाले परिचयों के विषय में। पृष्ट प्रत्येक पुनःप्रसारक से जिनपर वे पत्र प्रकाशन करते हैं।</string>
<string name="relay_purpose_reports_from_follows">अनुचरित से वृत्तान्त</string>
<string name="relay_explain_wallet">आपके अपने धनकोष घटनाएँ। पुनःपठित उन पुनःप्रसारकों से जिनपर आपने उनके प्रकाशन किए।</string>
<string name="relay_explain_nutzap_inbox">सुनता है आपके नटज्साप पुनःप्रसारकों पर तथा आपके आगतपेटिका तथा सीधासन्देश पुनःप्रसारकों पर। जिससे कि कोई भी भुगतान छूट ना जाए।</string>
<string name="relay_explain_mint_directory">पुनःप्रसारकों का वीक्षण करता है यह देखने के लिए कि कौनसे टकसाल हैं तथा लोग किनकी अनुशम्सा करते हैं।</string>
<string name="relay_explain_nwc">आपके संयोजित धनकोष से सूचनाएँ।</string>
<string name="active_subs_title">सक्रिय पुनःप्रसारक ग्राहकताएँ</string>
<!-- Two countable nouns, so two plurals composed at the call site rather than one string with
two %d in it: filter and relay decline independently in Slavic/Baltic/Semitic languages. -->
<plurals name="active_subs_filters">
<item quantity="one">%1$d छलनी</item>
<item quantity="other">%1$d छलनियाँ</item>
</plurals>
<plurals name="active_subs_relays">
<item quantity="one">%1$d पुनःप्रसारक</item>
<item quantity="other">%1$d पुनःप्रसारक</item>
</plurals>
<plurals name="active_subs_untagged">
<item quantity="one">%1$d छलनी आरोपित नहीं अब तक</item>
<item quantity="other">%1$d छलनियाँ आरोपित नहीं अब तक</item>
</plurals>
<string name="active_subs_pair">%1$s \u00b7 %2$s</string>
<string name="active_subs_unattributed">किसी लेखा प्रति आरोपित नहीं</string>
<string name="active_subs_no_entity">सभी</string>
<string name="active_subs_scope_global">सभी</string>
<string name="active_subs_scope_follows">आपके द्वारा अनुचरित लोग</string>
<string name="active_subs_scope_authors">चयनित लोगों की सूची</string>
<string name="active_subs_scope_muted">मौनकृत लोग</string>
<string name="active_subs_scope_all_communities">आपके समुदाय</string>
<string name="active_subs_scope_algo">एक प्रिय कलनविधि सूचनावली</string>
<string name="active_subs_share">%1$dप्रतिशतप्रतिशत सब में से</string>
<string name="active_subs_search_keywords">ग्राहकताएँ छलनियाँ पुनःप्रसारक अनुरोध अनु॰ संयोजन क्यों निदानतन्त्र</string>
<string name="relay_explain_home">आपके अनुचरितों के पत्र। पठित उन पुनःप्रसारकों से जिनपर उनमें से प्रत्येक प्रकाशन करते हैं।</string>
<string name="always_on_notif_connecting">आगतपेटिका पुनःप्रसारकों के साथ संयोजन किया जा रहा है \u2026</string>
<string name="always_on_notif_setting_title">सदैव सक्रिय सूचना सेवा</string>
<string name="always_on_notif_setting_description">अनवरत संयोजन बनाए रखता है आपके आगतपेटिका पुनःप्रसारकों के साथ तत्काल सूचना वितरण के लिए। एक स्थायी सूचना दिखाता है। विद्युत्कोष का अधिक उपयोग करता है पर निश्चित करता है कि आप कभी भी सन्देश नहीं खोएँगे।</string>
File diff suppressed because it is too large Load Diff
+1
View File
@@ -214,6 +214,7 @@
<string name="connection_success_rate_description">Percentage of successful connections to the relay</string>
<string name="search_and_add_a_user">Search and add user</string>
<string name="add_a_relay">Add a Relay</string>
<string name="relay_url_not_valid">Not a valid relay address. Use a host name, or an IP address in brackets (for example [201:d0e:9ba5:8bbc::1]:8080).</string>
<string name="my_name">My @tag name</string>
<string name="display_name">Display Name</string>
<string name="my_display_name">My display name</string>
@@ -153,12 +153,12 @@ class AmethystAppFunctions {
// Quartz's INostrClient.fetchAll handles subscribe → drain on
// EOSE/closed/cannot-connect → unsubscribe → dedup by id → sort
// newest-first. Wraps everything in a withTimeoutOrNull(timeoutMs)
// newest-first. Wraps everything in a withTimeoutOrNull(idleTimeoutMs)
// so a slow relay can't stall the dispatch.
val events =
client.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
)
val candidates =
@@ -397,7 +397,7 @@ class AmethystAppFunctions {
return Amethyst.instance.client
.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
).mapNotNull { it as? TextNoteEvent }
.take(limit)
}
@@ -449,7 +449,7 @@ class AmethystAppFunctions {
val events =
client.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
)
val hits =
@@ -521,7 +521,7 @@ class AmethystAppFunctions {
client
.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
).mapNotNull { it as? MetadataEvent }
.filter { it.pubKey == pubkey }
.maxByOrNull { it.createdAt }
@@ -569,7 +569,7 @@ class AmethystAppFunctions {
val events =
client.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
)
val hits =
@@ -643,7 +643,7 @@ class AmethystAppFunctions {
val events =
client.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
)
val hits =
@@ -686,7 +686,7 @@ class AmethystAppFunctions {
val events =
client.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
)
val hits =
@@ -733,7 +733,7 @@ class AmethystAppFunctions {
val events =
client.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
)
val hits =
@@ -785,7 +785,7 @@ class AmethystAppFunctions {
val events =
client.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
)
val receipts = events.mapNotNull { it as? LnZapEvent }
@@ -880,7 +880,7 @@ class AmethystAppFunctions {
client
.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
).mapNotNull { it as? GiftWrapEvent }
val seen = HashSet<HexKey>()
@@ -947,7 +947,7 @@ class AmethystAppFunctions {
val events =
client.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
)
val hits =
@@ -991,7 +991,7 @@ class AmethystAppFunctions {
val events =
client.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
)
val streams =
@@ -1691,7 +1691,7 @@ class AmethystAppFunctions {
return client
.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
).mapNotNull { it as? MetadataEvent }
.maxByOrNull { it.createdAt }
?.contactMetaData()
@@ -2027,7 +2027,7 @@ class AmethystAppFunctions {
val events =
client.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
)
val hits =
@@ -547,7 +547,7 @@ class Context(
if (needAuth.isEmpty()) break
// A cheap REQ whose only purpose is to force the AUTH handshake to completion.
val warmFilter = listOf(Filter(kinds = listOf(event.kind), limit = 1))
drain(needAuth.associateWith { warmFilter }, timeoutMs = 8_000, pendingOnAuthRequired = true)
drain(needAuth.associateWith { warmFilter }, idleTimeoutMs = 8_000, pendingOnAuthRequired = true)
results = results + client.publishAndCollectResults(event, needAuth, timeoutSecs)
attempt++
}
@@ -565,7 +565,7 @@ class Context(
* When [deadOut] is provided, every relay that reported it could not be
* connected to (`onCannotConnect`) is added to it, so callers can prune
* proven-dead relays from future routing instead of paying the full
* [timeoutMs] on them again. Slow-but-connected relays are NOT reported —
* [idleTimeoutMs] on them again. Slow-but-connected relays are NOT reported —
* only hard connect failures, so a temporarily-busy relay isn't discarded.
*
* With [pendingOnAuthRequired], a relay that refuses the REQ with an
@@ -573,24 +573,24 @@ class Context(
* NIP-42 responder answers the challenge and the client re-fires this same
* subscription (`syncFilters`), so the post-auth events are collected instead of
* returning empty. If auth never satisfies it, the relay simply falls through to
* the [timeoutMs]. Needed for Concord planes, whose kind-1059 wraps are served
* the [idleTimeoutMs]. Needed for Concord planes, whose kind-1059 wraps are served
* only to a connection authenticated as the derived stream key.
*/
suspend fun drain(
filters: Map<NormalizedRelayUrl, List<Filter>>,
timeoutMs: Long = 8_000,
idleTimeoutMs: Long = 8_000,
diagnoseSlow: Boolean = false,
deadOut: MutableMap<NormalizedRelayUrl, DrainFailure>? = null,
pendingOnAuthRequired: Boolean = false,
): List<Pair<NormalizedRelayUrl, Event>> =
client.fetchAllWithHooks(
filters = filters,
timeoutMs = timeoutMs,
idleTimeoutMs = idleTimeoutMs,
pendingOnAuthRequired = pendingOnAuthRequired,
deadOut = deadOut,
onTimeout =
if (diagnoseSlow) {
{ stalled, doneReasons, collected -> logSlowDrain(timeoutMs, stalled, doneReasons, collected) }
{ stalled, doneReasons, collected -> logSlowDrain(idleTimeoutMs, stalled, doneReasons, collected) }
} else {
null
},
@@ -604,7 +604,7 @@ class Context(
* "relay is slow" and "we never connected" are easy to tell apart.
*/
private fun logSlowDrain(
timeoutMs: Long,
idleTimeoutMs: Long,
stalled: Set<NormalizedRelayUrl>,
doneReasons: Map<NormalizedRelayUrl, String>,
collected: List<Pair<NormalizedRelayUrl, Event>>,
@@ -615,7 +615,7 @@ class Context(
val slowDetail = stalled.take(12).joinToString(", ") { "${it.url}(${eventsPer[it] ?: 0}ev)" }
val cannotDetail = cannot.entries.take(8).joinToString(", ") { "${it.key.url}=${it.value.removePrefix("cannot:").take(40)}" }
System.err.println(
"[drain] timeout ${timeoutMs}ms: ${stalled.size} slow(no EOSE), ${cannot.size} cannot-connect, ${closed.size} closed" +
"[drain] timeout ${idleTimeoutMs}ms: ${stalled.size} slow(no EOSE), ${cannot.size} cannot-connect, ${closed.size} closed" +
(if (slowDetail.isNotEmpty()) " | slow: $slowDetail" else "") +
(if (cannotDetail.isNotEmpty()) " | cannot: $cannotDetail" else ""),
)
@@ -641,12 +641,12 @@ class Context(
*/
suspend fun drainAllPages(
filters: Map<NormalizedRelayUrl, List<Filter>>,
timeoutMs: Long = 30_000,
idleTimeoutMs: Long = 30_000,
maxConcurrentRelays: Int = 8,
): List<Pair<NormalizedRelayUrl, Event>> =
client.fetchAllPagesFromPoolWithHooks(
filters = filters,
timeoutMs = timeoutMs,
idleTimeoutMs = idleTimeoutMs,
maxConcurrentRelays = maxConcurrentRelays,
) { _, event -> verifyAndStore(event) }
@@ -112,7 +112,7 @@ object AwaitCommands {
val event =
ctx.client.fetchFirst(
filters = relays.associateWith { listOf(filter) },
timeoutMs = 3_000,
idleTimeoutMs = 3_000,
)
if (event is KeyPackageEvent) {
Output.emit(
@@ -357,7 +357,7 @@ object DmCommands {
.groupBy { it.relay }
.mapValues { (_, v) -> v.map { it.filter } }
val raw = ctx.drain(filters, timeoutMs = timeoutSecs * 1000)
val raw = ctx.drain(filters, idleTimeoutMs = timeoutSecs * 1000)
val messages = decryptDms(ctx, raw, peerHex)
val out =
@@ -415,7 +415,7 @@ object DmCommands {
.groupBy { it.relay }
.mapValues { (_, v) -> v.map { it.filter } }
val raw = ctx.drain(filters, timeoutMs = 3_000)
val raw = ctx.drain(filters, idleTimeoutMs = 3_000)
val messages = decryptDms(ctx, raw, peerHex)
// Match against the text body for kind:14 and against the URL
// for kind:15 — both are exposed as `searchText` so callers
@@ -103,7 +103,7 @@ object GitReadCommands {
ctx
.drainAllPages(
relays.associateWith { listOf(Filter(kinds = listOf(itemKind), tags = mapOf("a" to listOf(repoAddress)), limit = limit)) },
timeoutMs = READ_TIMEOUT_MS,
idleTimeoutMs = READ_TIMEOUT_MS,
).asSequence()
.map { it.second }
.filter { it.kind == itemKind }
@@ -160,7 +160,7 @@ object GitReadCommands {
relays.associateWith {
listOf(Filter(kinds = STATUS_KINDS + listOf(CommentEvent.KIND, GitReplyEvent.KIND), tags = mapOf("e" to listOf(id))))
},
timeoutMs = READ_TIMEOUT_MS,
idleTimeoutMs = READ_TIMEOUT_MS,
).map { it.second }
.distinctBy { it.id }
@@ -208,7 +208,7 @@ object GitReadCommands {
ctx
.drainAllPages(
relays.associateWith { listOf(Filter(kinds = STATUS_KINDS, tags = mapOf("e" to chunk))) },
timeoutMs = READ_TIMEOUT_MS,
idleTimeoutMs = READ_TIMEOUT_MS,
).map { it.second }
}.filterIsInstance<GitStatusEvent>()
.distinctBy { it.id }
@@ -97,7 +97,7 @@ object GroupAddMemberCommand {
client = ctx.client,
targetPubKey = pub,
relays = kpRelays,
timeoutMs = 10_000,
idleTimeoutMs = 10_000,
)
if (kpEvent == null) {
report.add(mapOf("pubkey" to pub, "status" to "no_key_package"))
@@ -99,7 +99,7 @@ object KeyPackageCommands {
client = ctx.client,
targetPubKey = targetHex,
relays = relays,
timeoutMs = 10_000,
idleTimeoutMs = 10_000,
)
if (event == null) {
return Output.error("not_found", "no KeyPackage for $targetHex on ${relays.size} relay(s)")
@@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
@@ -50,6 +51,7 @@ import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
import com.vitorpamplona.quartz.nip66RelayMonitor.reachability.RelayProber
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
/**
* `amy relay …` — manage every relay list this account maintains, mirroring
@@ -112,10 +114,12 @@ object RelayCommands {
| relay info URL fetch + print a relay's NIP-11 info document (stateless)
| relay probe [--timeout SECS] relay census: mass-connect every relay the store
| [--concurrency N] knows and record live/dead + measured rtt-open
| into the reachability cache (NIP-66 kind:30166),
| [--file PATH] into the reachability cache (NIP-66 kind:30166),
| so reachability-aware commands (graperank crawl/
| refresh) skip dead relays and wait once
| (--timeout: per wave, default 15s)
| (--timeout: per wave, default 15s; --file: also
| probe candidate urls, one per line, each run
| through the relay url normalizer first)
""".trimMargin()
// ------------------------------------------------------------------
@@ -337,12 +341,41 @@ object RelayCommands {
// Relays dialed at once; --relay-concurrency accepted as the alias the
// graperank verbs spell it with.
val waveSize = args.intFlag("concurrency", args.intFlag("relay-concurrency", Context.defaultPreconnectCap))
// Optional external candidate list: one raw url per line, run through the
// same RelayUrlNormalizer the app uses, so a probe doubles as a census of
// how a corpus of relay hints normalizes (rejects are counted, not dialed).
val fromFile = args.flag("file")
args.rejectUnknown()
var fileRaw = 0
var fileRejected = 0
var fileOnion = 0
val fileRelays = HashSet<NormalizedRelayUrl>()
if (fromFile != null) {
val candidates = File(fromFile)
if (!candidates.canRead()) return Output.error("bad_args", "cannot read --file $fromFile")
candidates.forEachLine { line ->
if (line.isBlank()) return@forEachLine
fileRaw++
val normalized = line.normalizeRelayUrlOrNull()
if (normalized == null) {
fileRejected++
} else if (RelayUrlNormalizer.isOnion(normalized.url)) {
fileOnion++
} else {
fileRelays.add(normalized)
}
}
System.err.println(
"[relay-probe] $fromFile: $fileRaw urls → ${fileRelays.size} unique clearnet relays " +
"($fileRejected rejected by the normalizer, $fileOnion onion skipped)",
)
}
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val cached = ctx.reachability.snapshot()
val universe = RelayProber.knownRelayUniverse(ctx.store) + cached.live + cached.dead
val universe = RelayProber.knownRelayUniverse(ctx.store) + cached.live + cached.dead + fileRelays
if (universe.isEmpty()) {
Output.emit(
linkedMapOf<String, Any?>(
@@ -381,6 +414,10 @@ object RelayCommands {
Output.emit(
linkedMapOf<String, Any?>(
"probed" to result.verdicts.size,
"file_urls" to (if (fromFile != null) fileRaw else null),
"file_normalized" to (if (fromFile != null) fileRelays.size else null),
"file_rejected" to (if (fromFile != null) fileRejected else null),
"file_onion_skipped" to (if (fromFile != null) fileOnion else null),
"reachable" to result.reachable.size,
"dead" to result.dead.size,
"closed_by_policy" to authWalled,
@@ -287,7 +287,7 @@ object GrapeRankCrawl {
// Default null → pull EVERY follower each relay holds; --max
// N caps the total per relay for a quick spot check.
maxPerRelay = args.flag("max")?.toIntOrNull(),
timeoutMs = args.timeoutMs(15),
idleTimeoutMs = args.timeoutMs(15),
maxConcurrentRelays = relayConcurrency,
insertBatchSize = args.intFlag(FLAG_INSERT_BATCH, INSERT_BATCH_DEFAULT),
),
@@ -1,17 +1,95 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Login & Auth -->
<string name="login_title">Üdvözöljük az Amethystben</string>
<string name="login_subtitle">Jelentkezzen be Nostr a-fiókjába</string>
<string name="login_subtitle_desktop">Asztali Nostr-kliens</string>
<string name="login_card_title">Jelentkezzen be Nostr-a kulcsával</string>
<string name="login_card_subtitle">nsec a teljes hozzáféréshez, bunker:// a távoli aláíróhoz, vagy npub a csak olvasható módhoz</string>
<string name="login_with_key">Bejelentkezés kulccsal</string>
<string name="login_button">Bejelentkezés</string>
<string name="login_generate_new">Új kulcs előállítása</string>
<string name="login_generate_button">Új előállítása</string>
<string name="login_key_hint">Adja meg a privát kulcsát (nsec) vagy a nyilvános kulcsát (npub)</string>
<string name="login_key_label">nsec, bunker:// vagy npub</string>
<string name="login_key_placeholder">nsec1… / bunker://… / npub1…</string>
<string name="login_show_key">Kulcs megjelenítése</string>
<string name="login_hide_key">Kulcs elrejtése</string>
<!-- New Key Warning -->
<string name="new_key_warning_title">FONTOS: Mentse el a kulcsait!</string>
<string name="new_key_warning_message">A titkos kulcsa (nsec) az EGYETLEN módja annak, hogy hozzáférjen a fiókjához. Ha elveszíti, akkor a fiókja végleg elvész. Mentse el egy biztonságos helyre!</string>
<string name="new_key_public_label">Nyilvános kulcs (megosztható):</string>
<string name="new_key_secret_label">Titkos kulcs (SOHA ne ossza meg!):</string>
<string name="new_key_continue_button">Elmentettem a kulcsaimat, folytatás</string>
<!-- Common Actions -->
<string name="action_copy">Másolás</string>
<string name="action_paste">Beillesztés</string>
<string name="action_cancel">Mégse</string>
<string name="action_ok">OK</string>
<string name="action_save">Mentés</string>
<string name="action_delete">Törlés</string>
<string name="action_share">Megosztás</string>
<!-- Errors -->
<string name="error_invalid_key">Érvénytelen kulcsformátum. Ellenőrizze, és próbálja újra.</string>
<string name="error_network">Hálózati hiba. Ellenőrizze a kapcsolatot.</string>
<string name="error_generic">Hiba történt. Próbálja újra.</string>
<!-- Loading & Empty States -->
<string name="action_refresh">Frissítés</string>
<string name="action_try_again">Próbálja újra</string>
<string name="feed_empty">A hírfolyam üres</string>
<string name="error_loading_feed">Hiba történt a hírfolyam betöltésekor: %s</string>
<!-- Placeholder Screens -->
<string name="screen_search_title">Keresés</string>
<string name="screen_search_description">Keressen felhasználókat, bejegyzéseket és kulcsszavakat.</string>
<string name="screen_messages_title">Üzenetek</string>
<string name="screen_messages_description">Az Ön titkosított közvetlen üzenetei itt fognak megjelenni.</string>
<string name="screen_notifications_title">Értesítések</string>
<string name="screen_notifications_description">Az említések, válaszok és reakciók itt fognak megjelenni.</string>
<!-- Accessibility -->
<string name="accessibility_user_avatar">Felhasználó profilképe</string>
<string name="accessibility_navigate">Navigáció</string>
<!-- Relay history paging (shared feed markers + status card) -->
<string name="chats_history_loading_label">Betöltés:</string>
<string name="chats_history_fully_loaded_label">Teljesen betöltve:</string>
<string name="chats_history_fully_loaded">(teljesen betöltve)</string>
<string name="chats_history_by_relay">Előzmények átjátszónként</string>
<string name="chats_history_stalled_retry">Újra megpróbálja, amint újra megnyitja ezt a képernyőt</string>
<string name="chats_history_older">%1$s korábbi üzenet</string>
<string name="chats_history_all_caught_up">Naprakész</string>
<string name="chats_history_reached_start">Elérte a(z) %1$s üzeneteinek elejét</string>
<string name="chats_history_subtitle">%1$s · %2$s · betöltve ekkortól: %3$s</string>
<string name="chats_history_subtitle_no_date">%1$s · %2$s</string>
<string name="chats_history_waiting">várakozás erre: %1$s</string>
<string name="chats_history_incomplete">Néhány átjátszó nem válaszolt</string>
<string name="chats_history_incomplete_sub">%1$s nem érhető el · koppintson a részletekért</string>
<string name="chats_history_relays_title">%1$s · előzmények átjátszónként</string>
<string name="chats_history_relay_since">ekkortól: %1$s</string>
<string name="action_dismiss">Eltüntetés</string>
<plurals name="chats_history_relays">
<item quantity="one">%1$d átjátszó</item>
<item quantity="other">%1$d relé</item>
</plurals>
<!-- Notes & Replies -->
<string name="replying_to">válasz neki: </string>
<!-- Static sites (NIP-5A) & napplets (NIP-5D) feed card -->
<string name="nsite_title">nOldal: %1$s</string>
<string name="napplet_card_title">nKisalkalmazás: %1$s</string>
<string name="napplet_card_kind">nKisalkalmazás</string>
<string name="nsite_website_kind">nOldal</string>
<string name="napplet_card_permissions">Amihez hozzáférhet</string>
<string name="nsite_root_site">Gyökéroldal</string>
<string name="nsite_source">Forrás:</string>
<string name="nsite_servers">Kiszolgálók:</string>
<string name="nsite_open">Megnyitás</string>
<!-- Custom emoji suggestions (NIP-30) -->
<string name="use_direct_url">Közvetlen webcím használata</string>
<!-- Nicknames (NIP-85 contact cards) -->
<string name="nickname_dialog_title">Becenév</string>
<string name="nickname_dialog_explainer">Ez jelenik meg Önnek ezen felhasználó neve helyett az alkalmazásban bárhol. Titkosítva tárolódik el a kapcsolatkártyájára: csak Ön olvashatja. Írjon be kettőspontot (:) az egyéni emodzsik használatához.</string>
<string name="nickname_label">Becenév</string>
<string name="nickname_summary_label">Privát megjegyzés erről a felhasználóról</string>
<string name="nickname_save">Mentés</string>
<string name="nickname_cancel">Mégse</string>
<string name="git_status_open">Nyitva</string>
<string name="git_status_merged">Beolvasztva</string>
<string name="git_status_closed">Lezárva</string>
@@ -53,6 +131,7 @@
<string name="road_event_traffic_jam">Forgalmi dugó</string>
<string name="road_event_unknown">Útesemény</string>
<string name="podcast_value_zap_split_hint">Az erre küldött Zapek megoszlanak a következők között:</string>
<string name="podcast_value_split_percent">%1$d%%</string>
<string name="podcast_value_for_value">Értéket az értékért</string>
<string name="relay_monitor_rtt_open">Megnyitás</string>
<string name="relay_monitor_rtt_read">Olvasás </string>
@@ -61,6 +140,7 @@
<string name="relay_monitor_relay_type">Típus</string>
<string name="relay_monitor_requirements">Követelmények</string>
<string name="relay_monitor_supported_nips">Támogatott NIP-ek</string>
<string name="relay_monitor_ms">%1$d ms</string>
<string name="relay_discovery_accepted_kinds">Elfogadott típusok</string>
<string name="relay_discovery_geohash">Helyszín</string>
<string name="calendar_rsvp_going">Ott leszek</string>
@@ -81,6 +81,16 @@ interface NotificationSettings {
fun setEnabled(v: Boolean)
/**
* True iff the user has taken an explicit action to disable
* notifications (i.e. flipped the master switch OFF at some point).
* Used by the Settings screen to distinguish "master switch is off
* because it defaults to off on first launch" from "master switch
* is off because the user asked for it to be off". Only the former
* gets auto-enabled when the OS permission check passes.
*/
fun wasExplicitlyDisabled(): Boolean
fun setKindToggle(
kind: NotifKind,
v: Boolean,
@@ -147,7 +147,7 @@ class DmInboxRelayResolver(
if (writeRelays.isNotEmpty()) {
relays =
RecipientRelayFetcher
.fetchRelayLists(unauthenticatedClient, pubkey, writeRelays, timeoutMs = 5_000L)
.fetchRelayLists(unauthenticatedClient, pubkey, writeRelays, idleTimeoutMs = 5_000L)
.dmInbox
}
}
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.tor
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOverlayNetwork
class TorRelayEvaluation(
val torSettings: TorRelaySettings,
@@ -36,6 +37,11 @@ class TorRelayEvaluation(
} else {
if (relay.isLocalHost()) {
false
} else if (relay.isOverlayNetwork()) {
// An overlay-mesh relay (0200::/7, e.g. Yggdrasil) is reachable only through the
// local mesh interface: Tor cannot route the range at all, so proxying it would
// guarantee failure rather than privacy. The overlay already encrypts end to end.
false
} else if (relay.isOnion()) {
// .onion is only reachable over Tor regardless of any other classification.
torSettings.onionRelaysViaTor
@@ -0,0 +1,76 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.richtext
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* A quoted host name (`this bridge-relay "relay.momostr.pink" doesn't appear`) used to be
* detected with the opening quote glued onto it, so the rendered link read
* `"relay.momostr.pink` and pointed at a host that doesn't exist. Quotes are not host
* characters, so they must be left in the surrounding text on both sides.
*/
class RichTextParserQuotedUrlTest {
private fun segmentsOf(text: String) =
RichTextParser()
.parseText(text, EmptyTagList, null)
.paragraphs
.flatMap { it.words }
@Test
fun quotedSchemelessUrlKeepsQuotesOutOfTheLink() {
val segments =
segmentsOf(
"It seems like this bridge-relay \"relay.momostr.pink\" doesn't appear in the feed",
).filterIsInstance<SchemelessUrlSegment>()
assertEquals(listOf("relay.momostr.pink"), segments.map { it.segmentText })
}
@Test
fun quotedUrlWithSchemeKeepsQuotesOutOfTheLink() {
val segments =
segmentsOf(
"the docs are at \"https://example.com/some/page?a=b\" if you need them",
).filterIsInstance<LinkSegment>()
assertEquals(listOf("https://example.com/some/page?a=b"), segments.map { it.segmentText })
}
@Test
fun quotedRelayUrlKeepsQuotesOutOfTheLink() {
val segments =
segmentsOf("add \"wss://relay.momostr.pink\" to your list")
.filterIsInstance<RelayUrlSegment>()
assertEquals(listOf("wss://relay.momostr.pink"), segments.map { it.segmentText })
}
@Test
fun apostrophesInProseDontCreateLinks() {
val segments = segmentsOf("it doesn't appear until you go into the authors' accounts")
assertEquals(emptyList(), segments.filterIsInstance<SchemelessUrlSegment>())
assertEquals(emptyList(), segments.filterIsInstance<LinkSegment>())
}
}
@@ -349,6 +349,34 @@ class UrlParserTest {
Urls(withScheme = setOf("https://test.com")),
)
@Test
fun testQuotedRelayName() =
test(
"It seems like this bridge-relay \"relay.momostr.pink\" doesn't appear in the feed at all",
Urls(withoutScheme = setOf("relay.momostr.pink")),
)
@Test
fun testSingleQuotedRelayName() =
test(
"It seems like this bridge-relay 'relay.momostr.pink' doesn't appear in the feed at all",
Urls(withoutScheme = setOf("relay.momostr.pink")),
)
@Test
fun testQuotedUrlWithScheme() =
test(
"the docs are at \"https://example.com/some/page?a=b\" if you need them",
Urls(withScheme = setOf("https://example.com/some/page?a=b")),
)
@Test
fun testQuotedRelayUrl() =
test(
"add \"wss://relay.momostr.pink\" to your list",
Urls(relayUrls = setOf("wss://relay.momostr.pink")),
)
@Test
fun testBlossom() {
val blossom = "blossom:b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf?xs=cdn.satellite.earth"
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.tor
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* An overlay-mesh relay (`0200::/7`, e.g. Yggdrasil) must never be dialed through the Tor SOCKS
* proxy: Tor cannot route the range, so proxying guarantees failure rather than privacy. The
* overlay already encrypts end to end and authenticates the peer by its key-derived address.
*/
class YggdrasilTorRoutingTest {
private val yggdrasilRelay = NormalizedRelayUrl("ws://[201:d0e:9ba5:8bbc::1]:8080/")
private val yggdrasilSubnetRelay = NormalizedRelayUrl("ws://[300:1b5d:d0e9:ba58::1]:4848/")
private val lanRelay = NormalizedRelayUrl("ws://192.168.1.100:8080/")
private val ulaRelay = NormalizedRelayUrl("ws://[fd12:3456::1]:8080/")
private val clearnetIpv6Relay = NormalizedRelayUrl("wss://[2001:db8::1]:8080/")
private fun evaluation(newViaTor: Boolean) =
TorRelayEvaluation(
torSettings =
TorRelaySettings(
torType = TorType.INTERNAL,
onionRelaysViaTor = true,
dmRelaysViaTor = true,
newRelaysViaTor = newViaTor,
trustedRelaysViaTor = false,
moneyOperationsViaTor = false,
),
trustedRelayList = emptySet(),
dmRelayList = emptySet(),
)
@Test
fun overlayRelaysAreNeverTorifiedEvenWhenNewRelaysViaTorIsOn() {
val eval = evaluation(newViaTor = true)
assertFalse(eval.useTor(yggdrasilRelay), "0200::/8 node address must not be proxied")
assertFalse(eval.useTor(yggdrasilSubnetRelay), "0300::/8 subnet address must not be proxied")
assertFalse(eval.useTor(lanRelay), "LAN relay stays off Tor")
assertFalse(eval.useTor(ulaRelay), "IPv6 unique local address stays off Tor")
}
@Test
fun clearnetIpv6RelaysStillFollowTheTorSetting() {
// The overlay exemption must not leak into ordinary IPv6 relays.
assertTrue(evaluation(newViaTor = true).useTor(clearnetIpv6Relay))
assertFalse(evaluation(newViaTor = false).useTor(clearnetIpv6Relay))
}
}
@@ -53,8 +53,20 @@ class PreferencesNotificationSettings(
override fun setEnabled(v: Boolean) {
_enabled.value = v
prefs.putBoolean(KEY_ENABLED, v)
// Track explicit user intent so the Settings screen can auto-enable
// on next visit for users who never touched the switch, while
// respecting users who deliberately turned it off. Only false
// → "explicit disable"; going from off to on clears the flag so
// subsequent auto-enable heuristics work normally.
if (v) {
prefs.remove(KEY_EXPLICITLY_DISABLED)
} else {
prefs.putBoolean(KEY_EXPLICITLY_DISABLED, true)
}
}
override fun wasExplicitlyDisabled(): Boolean = prefs.getBoolean(KEY_EXPLICITLY_DISABLED, false)
override fun setKindToggle(
kind: NotifKind,
v: Boolean,
@@ -92,6 +104,7 @@ class PreferencesNotificationSettings(
companion object {
const val NODE = "com/vitorpamplona/amethyst/notifications"
private const val KEY_ENABLED = "enabled"
private const val KEY_EXPLICITLY_DISABLED = "explicitly_disabled"
private const val KEY_DND_UNTIL = "dnd_until"
private const val KEY_PREVIEW = "preview_in_toast"
@@ -0,0 +1,86 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.moderation.notifications
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.UUID
import java.util.prefs.Preferences
/**
* Pins the semantics of the new [NotificationSettings.wasExplicitlyDisabled]
* flag added to unblock the "Enable OS notifications button doesn't work on
* desktop" bug's second failure mode.
*
* The Settings screen auto-enables the master notifications switch when it
* detects that the OS permission is fine and the switch is off. That's the
* common path for users who click the "Enable OS notifications" button and
* expect it to fully take effect (button label promise). But it MUST NOT
* override users who deliberately turned notifications off. The
* [wasExplicitlyDisabled] flag is how we distinguish the two.
*/
class PreferencesNotificationSettingsExplicitDisableTest {
private fun freshNode(): Preferences {
// Use a UUID-scoped node so tests never share state and never
// pollute the real user prefs on the machine running CI/dev builds.
return Preferences.userRoot().node("amethyst-test-" + UUID.randomUUID())
}
@Test
fun `fresh install defaults to not-explicitly-disabled`() {
val settings = PreferencesNotificationSettings(freshNode())
assertFalse(
"First launch must not look like a deliberate opt-out; otherwise auto-enable stays off forever",
settings.wasExplicitlyDisabled(),
)
}
@Test
fun `turning off marks explicitly disabled`() {
val prefs = freshNode()
val settings = PreferencesNotificationSettings(prefs)
settings.setEnabled(false)
assertTrue(settings.wasExplicitlyDisabled())
}
@Test
fun `turning on clears the explicit-disable flag`() {
val prefs = freshNode()
val settings = PreferencesNotificationSettings(prefs)
settings.setEnabled(false)
assertTrue(settings.wasExplicitlyDisabled())
settings.setEnabled(true)
assertFalse(
"Toggling back on must clear the flag so subsequent OFF->auto-enable cycles work",
settings.wasExplicitlyDisabled(),
)
}
@Test
fun `flag persists across new instances on the same prefs node`() {
val prefs = freshNode()
PreferencesNotificationSettings(prefs).setEnabled(false)
// Second instance opens the same node \u2014 flag must survive process restart.
val reopened = PreferencesNotificationSettings(prefs)
assertTrue(reopened.wasExplicitlyDisabled())
}
}
@@ -108,7 +108,9 @@ class ExplainedFilterTest {
*/
@Test
fun `copy preserves the purpose`() {
val advanced = explained().copy(since = 1_785_379_272)
// Typed as the base Filter so the is-check below stays a runtime assertion — with the
// override's covariant return type inferred, the compiler would prove it true statically.
val advanced: Filter = explained().copy(since = 1_785_379_272)
assertTrue("copy() must stay an ExplainedFilter", advanced is ExplainedFilter)
assertEquals(SubPurpose.NOTIFICATIONS, advanced.purposeOrNull())
+16 -4
View File
@@ -246,18 +246,30 @@ compose.desktop {
// - amethyst.png 512x512 icon
//
// appimagetool binary is fetched by CI (SHA-verified) into
// desktopApp/packaging/appimage/ as appimagetool-x86_64.AppImage.
// desktopApp/packaging/appimage/ as appimagetool-<arch>.AppImage.
// The arch is selected at task-execution time from the host JVM's os.arch, so
// the same task builds the correct AppImage on both x86_64 and aarch64 hosts.
// BUILDING.md documents local-dev fetch.
val createReleaseAppImage by tasks.registering(Exec::class) {
group = "compose desktop"
description = "Package createReleaseDistributable output into a Linux AppImage via appimagetool."
dependsOn("createReleaseDistributable")
// AppImage's ARCH env accepts the Linux kernel arch names: x86_64 / aarch64
// / armhf / i686. jpackage produces host-native binaries, so mirror the
// host JVM arch. Do not read the property inside doFirst — it needs to be
// resolved at configuration time so outputs.file() below is stable.
val hostArch = when (val a = System.getProperty("os.arch").lowercase()) {
"amd64", "x86_64" -> "x86_64"
"aarch64", "arm64" -> "aarch64"
else -> a
}
val distDir = layout.buildDirectory.dir("compose/binaries/main-release/app/Amethyst")
val appDir = layout.buildDirectory.dir("appimage/Amethyst.AppDir")
val outFile = layout.buildDirectory.file("appimage/Amethyst-$appVersion-x86_64.AppImage")
val outFile = layout.buildDirectory.file("appimage/Amethyst-$appVersion-$hostArch.AppImage")
val toolRoot = layout.projectDirectory.dir("packaging/appimage")
val appimagetool = toolRoot.file("appimagetool-x86_64.AppImage")
val appimagetool = toolRoot.file("appimagetool-$hostArch.AppImage")
inputs.dir(distDir)
inputs.dir(toolRoot)
@@ -292,7 +304,7 @@ val createReleaseAppImage by tasks.registering(Exec::class) {
appDir.get().asFile.absolutePath,
outFile.get().asFile.absolutePath,
)
environment("ARCH", "x86_64")
environment("ARCH", hostArch)
// Bypass FUSE requirement on CI runners (ubuntu-latest lacks libfuse.so.2).
// AppImage standard env var: extracts + runs without mounting.
environment("APPIMAGE_EXTRACT_AND_RUN", "1")
+4 -1
View File
@@ -7,7 +7,10 @@
# (Equivalent packages on Fedora/Arch.)
set -eu
HERE="$(dirname "$(readlink -f "${0}")")"
export LD_LIBRARY_PATH="${HERE}/usr/lib:${HERE}/usr/lib/x86_64-linux-gnu:${LD_LIBRARY_PATH:-}"
# Multiarch lib path is set by the host, not baked at build time — same
# AppRun works in both x86_64 and aarch64 AppImages.
GNU_TRIPLET="$(uname -m)-linux-gnu"
export LD_LIBRARY_PATH="${HERE}/usr/lib:${HERE}/usr/lib/${GNU_TRIPLET}:${LD_LIBRARY_PATH:-}"
export PATH="${HERE}/usr/bin:${PATH}"
export APPDIR="${HERE}"
exec "${HERE}/usr/bin/Amethyst" "$@"
+6 -2
View File
@@ -28,8 +28,12 @@ used two ways:
manifest (archive source pinned to the release tarball URL + sha256, with
`x-checker-data` so Flathub's update bot bumps it), its own metainfo
(carries the permanent `<releases>` history Flathub requires), desktop
entry, icon, and `flathub.json` (`only-arches: x86_64` — we publish no
aarch64 tarball, and jpackage can't cross-compile one)
entry, icon, and `flathub.json` — currently gated to `only-arches:
x86_64` so the Flathub build machinery never tries the aarch64 tarball
before we've validated it end-to-end on Flathub's aarch64 builders. GitHub
releases already ship aarch64 flatpak bundles (built from the same source
tree on `ubuntu-24.04-arm`); flipping `only-arches` to include `aarch64`
is the follow-up once we've smoke-tested a Flathub aarch64 build.
## Local build
@@ -221,7 +221,7 @@ class DesktopRelaySubscriptionsCoordinator(
val events =
client.fetchAll(
filters = indexRelays.associateWith { listOf(filter) },
timeoutMs = 8.seconds.inWholeMilliseconds,
idleTimeoutMs = 8.seconds.inWholeMilliseconds,
)
events.forEach { consumeEvent(it, null) }
}
@@ -39,13 +39,17 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.moderation.notifications.HostOs
import com.vitorpamplona.amethyst.commons.moderation.notifications.NotifKind
@@ -112,7 +116,7 @@ fun NotificationSettingsScreen(onBack: (() -> Unit)? = null) {
dispatcher?.nativeAvailable?.collectAsState()
?: remember { mutableStateOf(false) }
)
val coroutineScope = androidx.compose.runtime.rememberCoroutineScope()
val coroutineScope = rememberCoroutineScope()
var testStatus by remember { mutableStateOf<String?>(null) }
var requestingPermission by remember { mutableStateOf(false) }
var sendingTest by remember { mutableStateOf(false) }
@@ -120,18 +124,36 @@ fun NotificationSettingsScreen(onBack: (() -> Unit)? = null) {
// Re-sync permission state whenever this screen enters composition
// and whenever the window regains focus — user may have toggled
// Amethyst in System Settings → Notifications while we were open.
val windowInfo = androidx.compose.ui.platform.LocalWindowInfo.current
androidx.compose.runtime.LaunchedEffect(dispatcher) {
val windowInfo = LocalWindowInfo.current
LaunchedEffect(dispatcher) {
dispatcher?.refreshPermission()
}
androidx.compose.runtime.LaunchedEffect(dispatcher, windowInfo) {
androidx.compose.runtime
.snapshotFlow { windowInfo.isWindowFocused }
LaunchedEffect(dispatcher, windowInfo) {
snapshotFlow { windowInfo.isWindowFocused }
.collect { focused ->
if (focused) dispatcher?.refreshPermission()
}
}
// Covers the two paths the earlier fix (e9475dd0) missed: the OS
// permission was already granted in a prior session (an older
// build asked, or the user allowed Amethyst in System Settings
// directly), and Windows/Linux, where permissionState is
// NotApplicable from startup. On both, permissionState is not
// NotRequested, so the "Enable OS notifications" button never
// renders, yet the master switch is still OFF from first-launch
// defaults — leaving no affordance that both explains the problem
// and fixes it. Auto-enable when the permission is fine, the
// master switch is off, and the user has never explicitly turned
// it off; [NotificationSettings.wasExplicitlyDisabled] is what
// keeps a deliberate opt-out from being overruled.
LaunchedEffect(permissionState, enabled) {
val allowed = permissionState == PermissionState.Granted || permissionState == PermissionState.NotApplicable
if (allowed && !enabled && !settings.wasExplicitlyDisabled()) {
settings.setEnabled(true)
}
}
PlatformStatusCard(
host = host,
nativeAvailable = nativeAvailable,
@@ -219,6 +241,20 @@ fun NotificationSettingsScreen(onBack: (() -> Unit)? = null) {
}
}
PermissionState.Granted, PermissionState.NotApplicable -> {
// Recovery affordance for the deliberate-opt-out path.
// The LaunchedEffect above already re-enables the
// switch for anyone who never touched it, so in
// practice the only state that still reaches here with
// `enabled == false` is an explicit opt-out. Guarding
// on `!enabled` alone (rather than also calling
// wasExplicitlyDisabled) keeps this a pure Compose
// state read and leaves the button visible for the one
// frame before the effect runs.
if (!enabled) {
OutlinedButton(
onClick = { settings.setEnabled(true) },
) { Text("Turn on desktop notifications") }
}
OutlinedButton(
onClick = {
if (sendingTest) return@OutlinedButton
+13 -10
View File
@@ -84,27 +84,34 @@
"Dutch"
]
},
{
"user": "summoner001",
"languages": [
"Hungarian"
]
},
{
"user": "maxblake2015",
"languages": [
"Polish"
]
},
{
"user": "rajs19420616",
"languages": [
"Hindi"
]
},
{
"user": "vitorpamplona",
"languages": [
"Czech",
"German",
"Polish",
"Portuguese, Brazilian",
"Swedish"
]
},
{
"user": "rajs19420616",
"languages": [
"Hindi"
]
},
{
"user": "greenart7c3",
"languages": []
@@ -265,10 +272,6 @@
"user": "D4rkFIow",
"languages": []
},
{
"user": "summoner001",
"languages": []
},
{
"user": "fiddleway",
"languages": []
+9
View File
@@ -112,6 +112,15 @@ require_auth = false
# the future. Enforced by RejectFutureEventsPolicy.
# reject_future_seconds = 1800
# Path for the JSON file that remembers what the [[mirror]] catch-up
# has already synced (per upstream, per scope), so a restart resumes
# instead of re-downloading each upstream's whole backfill window.
# Defaults to "<database file>.sync-coverage.json" next to the event
# store; only written when the store itself is file-backed (an
# in-memory store keeps no resume state — saved coverage would
# describe events that no longer exist).
# mirror_sync_state_file = "/var/lib/geode/events.db.sync-coverage.json"
[authorization]
# Allow / deny lists. Allow is a permissive ceiling; deny still
# removes specific entries inside it. Enforced by Pubkey/KindAllowDenyPolicy.
@@ -28,6 +28,7 @@ import com.vitorpamplona.geode.config.StaticConfig
import com.vitorpamplona.geode.mirror.MirrorDirection
import com.vitorpamplona.geode.mirror.MirrorUpstream
import com.vitorpamplona.geode.mirror.MirrorWorker
import com.vitorpamplona.geode.mirror.SyncCoverageFile
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -44,6 +45,7 @@ import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip01Core.store.NdjsonImportExport
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import com.vitorpamplona.quartz.nip77Negentropy.NegentropySettings
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -223,7 +225,8 @@ private fun runImport(args: Array<String>) {
}
System.err.println(
"geode import: read=${stats.read} imported=${stats.imported} " +
"rejected=${stats.rejected} invalid-sig=${stats.invalid} malformed=${stats.malformed} " +
"rejected=${stats.rejected} failed=${stats.failed} " +
"invalid-sig=${stats.invalid} malformed=${stats.malformed} " +
"${ctx.dbFile ?: "(in-memory — not persisted; pass --db)"}",
)
} finally {
@@ -411,6 +414,27 @@ private fun serve(args: Array<String>) {
require(upstreams.none { it.url.displayUrl() == advertisedIdentity }) {
"[[mirror]] must not list this relay's own URL ($advertisedUrl)"
}
// Resume state for the mirror catch-up, following the admin state-file
// convention: next to the event database unless configured. Keyed to the
// store's ACTUAL persistence, not to `database.file` being set: a
// volatile store with a persistent coverage file would claim, on the
// next boot, that an empty database already holds the backfill window —
// and the mirror would never fetch it.
val sqliteBackend =
config.database.backend
.trim()
.lowercase() in StoreFactory.SQLITE_BACKEND_KEYWORDS
val persistentLocation =
a.opt("--db") ?: config.database.file?.takeUnless { sqliteBackend && config.database.in_memory }
val syncCoverage =
if (upstreams.isEmpty() || persistentLocation == null) {
if (upstreams.isNotEmpty() && config.options.mirror_sync_state_file != null) {
Log.w("Main") { "mirror_sync_state_file ignored: the event store is in-memory, so saved coverage would outlive the events it describes" }
}
null
} else {
SyncCoverageFile(File(config.options.mirror_sync_state_file ?: "$persistentLocation.sync-coverage.json"))
}
val mirror =
if (upstreams.isEmpty()) {
null
@@ -425,6 +449,9 @@ private fun serve(args: Array<String>) {
// for the historical window, then live REQ tail. Auto-falls back
// to paged REQ for upstreams without NIP-77.
negentropyBackfill = true,
// Without NIP-77 the catch-up is a paged re-download; the
// coverage bands remember what previous boots already walked.
coverage = syncCoverage?.coverage,
).also { it.start() }
}
@@ -462,6 +489,8 @@ private fun serve(args: Array<String>) {
// queue and store beneath them shut down.
runCatching { maintenanceScope.cancel() }
runCatching { mirror?.close() }
// After the mirror, so the final flush carries the last bands.
runCatching { syncCoverage?.close() }
runCatching { server.stop() }
runCatching { relay.close() }
},
@@ -185,6 +185,16 @@ data class StaticConfig(
* that don't offer NIP-50 at all (strfry, for example).
*/
val full_text_search: Boolean = true,
/**
* Where the mirror catch-up's resume state lives: the per-upstream
* `created_at` coverage bands (quartz's `SyncCoverage`). Without it
* every restart re-syncs each upstream's whole backfill window —
* a full re-download for an upstream without NIP-77. Defaults to
* `<database file>.sync-coverage.json` when the store is
* file-backed; an in-memory store keeps no resume state (bands
* only pay off across restarts).
*/
val mirror_sync_state_file: String? = null,
)
/**
@@ -23,8 +23,11 @@ package com.vitorpamplona.geode.mirror
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.NegentropySyncException
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.SyncCoverage
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropyReconcile
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropySyncOrFetch
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropySync
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
@@ -40,12 +43,15 @@ import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.OkHttpClient
import java.time.Duration
import java.util.concurrent.atomic.AtomicLong
@@ -154,11 +160,18 @@ class MirrorWorker(
* historical window before the live REQ tail takes over. The geode binary
* turns this on (see `Main`); it defaults **off** so the many existing
* MirrorWorker tests keep exercising the pure live-REQ path unchanged.
* When on, `negentropySyncOrFetch` automatically falls back to paged REQ
* against an upstream that doesn't speak NIP-77 — so "either mode" is
* transparent and needs no separate toggle.
* When on, the catch-up automatically falls back to paged REQ against an
* upstream that doesn't speak NIP-77 — so "either mode" is transparent
* and needs no separate toggle.
*/
private val negentropyBackfill: Boolean = false,
/**
* Resume memory for the down catch-up, shared across upstreams and — via
* [SyncCoverageFile] — across restarts. Null keeps the old behavior:
* every boot re-syncs the whole backfill window, which for an upstream
* without NIP-77 is a full re-download.
*/
private val coverage: SyncCoverage? = null,
) : AutoCloseable {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -201,6 +214,14 @@ class MirrorWorker(
/** Events the store rejected — mostly duplicate replays after a reconnect. */
val rejected = AtomicLong(0)
/**
* Good events the store could not write ([IEventStore.InsertOutcome.Failed]).
* The store's fault, not the event's, and nothing re-offers them — counted
* apart from [rejected] so a schema drift reads as store damage rather
* than as upstreams sending junk.
*/
val failed = AtomicLong(0)
/**
* Deliveries dropped before ever reaching the store: events outside
* the [MirrorUpstream.filter] scope (an upstream answering outside
@@ -300,6 +321,13 @@ class MirrorWorker(
rejected.incrementAndGet()
Log.d("MirrorWorker") { "rejected ${msg.event.id}: ${outcome.reason}" }
}
is IEventStore.InsertOutcome.Failed -> {
// The store's error, not the event's: the event
// was good and nothing will re-offer it. Louder
// than a rejection on purpose.
failed.incrementAndGet()
Log.w("MirrorWorker") { "store failed ${msg.event.id}: ${outcome.reason}" }
}
}
}
} catch (e: CancellationException) {
@@ -399,9 +427,9 @@ class MirrorWorker(
* **client-paced** so a fast upstream can't overrun the sink: a plain REQ
* backfill of a large set dies here (strfry kills a slow REQ client once its
* unsent-outbound buffer crosses `maxPendingOutboundBytes`), which is exactly
* why this uses negentropy. [INostrClient.negentropySyncOrFetch] falls back
* to paged REQ automatically when the upstream doesn't speak NIP-77, so the
* mirror is compatible with either kind of upstream with no config.
* why this uses negentropy. When the upstream doesn't speak NIP-77 the
* catch-up falls back to paged REQ, so the mirror is compatible with
* either kind of upstream with no config.
*
* A failure here is non-fatal: the live subscription keeps the mirror current
* and the reconnect watermark narrows any residual gap.
@@ -412,16 +440,26 @@ class MirrorWorker(
initialSince: Long,
until: Long,
) {
val catchUpFilter = scopedBase.copy(since = initialSince, until = until)
// Reconcile against what we already hold in this window → download only
// the diff (like `strfry sync`). No store wired → empty local set → the
// whole window is downloaded and the store's unique-id constraint dedups.
val localEntries = store?.snapshotIdsForNegentropy(listOf(catchUpFilter)) ?: emptyList()
// Resume memory: coverage is keyed on the STABLE scoped filter, never
// on the boot window — whose since/until change every start and would
// never match a stored band. The window itself rides along as the
// floor argument, so a band recorded against a shallower window
// re-opens the older span when the operator deepens the backfill.
val legs =
coverage
?.legs(up.url, scopedBase, initialSince)
?.mapNotNull { clampToWindow(it, initialSince, until) }
?: listOf(scopedBase.copy(since = initialSince, until = until))
if (legs.isEmpty()) {
Log.i("MirrorWorker") { "catch-up from ${up.url.url}: window already covered - nothing outside the synced band" }
return
}
// Bounded hand-off → one ingest consumer. `onEvent` can't suspend, so it
// blocks here when the sink falls behind; because negentropySyncOrFetch's
// own delivery pipeline is bounded, that backpressure reaches all the way
// to the upstream — no unbounded buffering (unlike the live-tail path).
// blocks here when the sink falls behind; because negentropySync's own
// delivery pipeline is bounded (and a paged REQ is paced by its pages),
// that backpressure reaches all the way to the upstream — no unbounded
// buffering (unlike the live-tail path).
val handoff = Channel<Event>(capacity = CATCHUP_HANDOFF)
val consumer =
scope.launch {
@@ -431,6 +469,10 @@ class MirrorWorker(
when (outcome) {
IEventStore.InsertOutcome.Accepted -> accepted.incrementAndGet()
is IEventStore.InsertOutcome.Rejected -> rejected.incrementAndGet()
is IEventStore.InsertOutcome.Failed -> {
failed.incrementAndGet()
Log.w("MirrorWorker") { "store failed ${event.id}: ${outcome.reason}" }
}
}
}
} catch (e: CancellationException) {
@@ -443,24 +485,114 @@ class MirrorWorker(
}
try {
val result =
client.negentropySyncOrFetch(
relay = up.url,
filter = catchUpFilter,
localEntries = localEntries,
onEvent = { event ->
// Same containment as the live path: even a trusted
// upstream may only inject events inside the declared scope.
if (up.filter == null || up.filter.match(event)) {
handoff.trySendBlocking(event)
} else {
filtered.incrementAndGet()
var downloaded = 0
var paged = false
for (leg in legs) {
// Reconcile against what we already hold in this leg → download
// only the diff (like `strfry sync`). No store wired → empty
// local set → the whole leg is downloaded and the store's
// unique-id constraint dedups.
val localEntries = store?.snapshotIdsForNegentropy(listOf(leg)) ?: emptyList()
// Coverage is stamped from when the local ids were read — that
// is the state the relay is being compared against.
val syncStartedAt = TimeUtils.now()
var seenMin: Long? = null
var seenMax: Long? = null
// Per KIND as well as in aggregate: one interval for a
// multi-kind filter lets a long-lived kind vouch for a
// short-lived one, and the band then skips the interior for
// both. The aggregate is still tracked because the reconcile
// path records against the leg's floor, not per kind.
val seenByKind = mutableMapOf<Int, SyncCoverage.Span>()
fun observe(event: Event) {
// Same containment as the live path: even a trusted
// upstream may only inject events inside the declared scope.
if (up.filter == null || up.filter.match(event)) {
// Only plausible stamps widen a band — one
// misdated event must not discard the rest.
if (SyncCoverage.isPlausible(event.createdAt)) {
seenMin = minOf(seenMin ?: event.createdAt, event.createdAt)
seenMax = maxOf(seenMax ?: event.createdAt, event.createdAt)
}
},
)
SyncCoverage.observe(seenByKind, event.kind, event.createdAt)
handoff.trySendBlocking(event)
} else {
filtered.incrementAndGet()
}
}
// The two phases run by hand rather than through
// negentropySyncOrFetch, for two reasons. The combinator keeps
// every delivered id for cross-phase dedup — a multi-million-
// event catch-up cannot afford that heap, and the store's
// unique-id constraint dedups anyway. And the fallback must
// reset the observed span: a half-finished reconcile delivers
// events scattered across the whole leg, and a band built from
// that scatter would claim interior ranges nobody walked.
val legPaged =
try {
downloaded +=
client
.negentropySync(
relay = up.url,
filter = leg,
localEntries = localEntries,
onEvent = ::observe,
).downloaded
false
} catch (e: NegentropySyncException) {
seenMin = null
seenMax = null
seenByKind.clear()
// The watchdog matches negentropySync's default rather
// than fetchAllPages' shorter one: a paged catch-up
// sits behind the same slow upstreams.
downloaded += client.fetchAllPages(up.url, listOf(leg), idleTimeoutMs = 120_000L) { observe(it) }
true
}
paged = paged || legPaged
// Recorded per leg, so a failure between legs keeps the ground
// the first one gained — and no more: a reconcile compared only
// its own leg, so completeness reaches the leg's ceiling, never
// "now" while a later leg is still pending. A paged fallback
// earns only the span it actually saw, capped at the snapshot
// instant so one future-dated event cannot lift the band's
// ceiling past what was asked.
if (legPaged) {
coverage?.record(
up.url,
scopedBase,
seenMin,
seenMax?.coerceAtMost(syncStartedAt),
paged = true,
// Capped the same way the aggregate is: one
// future-dated event must not lift a kind's ceiling
// past what was actually asked for.
observedByKind =
seenByKind.mapValues { (_, span) ->
SyncCoverage.Span(span.min, span.max.coerceAtMost(syncStartedAt))
},
)
} else {
val legFloor = leg.since ?: initialSince
coverage?.record(
up.url,
scopedBase,
// The compared range starts at the leg's floor whether
// or not anything was observed there — that is what a
// clean reconcile proves.
observedMin = minOf(seenMin ?: legFloor, legFloor),
observedMax = null,
paged = false,
reconciledThrough = minOf(leg.until ?: syncStartedAt, syncStartedAt),
)
}
}
Log.i("MirrorWorker") {
val how = if (result.pagedFallback) "paged REQ (upstream has no NIP-77)" else "negentropy"
"catch-up from ${up.url.url}: ${result.downloaded} events via $how"
val how = if (paged) "paged REQ (upstream has no NIP-77)" else "negentropy"
val resumed = if (coverage != null && legs.size > 1) " [resumed: ${legs.size} legs outside the synced band]" else ""
"catch-up from ${up.url.url}: $downloaded events via $how$resumed"
}
} catch (e: CancellationException) {
throw e
@@ -671,11 +803,21 @@ class MirrorWorker(
runCatching { client.close() }
inbound.close()
scope.cancel()
// Wait (bounded) for the workers to land: a coverage.record racing
// past the state file's final flush would be recorded and lost.
// Bounded because a worker parked in a blocking hand-off does not
// feel the cancel, and shutdown must not hang on it.
runBlocking {
withTimeoutOrNull(CLOSE_JOIN_MS) { scope.coroutineContext[Job]?.join() }
}
okhttp?.dispatcher?.executorService?.shutdown()
okhttp?.connectionPool?.evictAll()
}
private companion object {
/** How long [close] waits for the worker coroutines to land. */
const val CLOSE_JOIN_MS = 5_000L
/** Matches the Android app's relay-pool WebSocket ping interval. */
const val PING_INTERVAL_SECS = 120L
@@ -701,7 +843,7 @@ class MirrorWorker(
/**
* Depth of the catch-up hand-off between the negentropy download and the
* ingest consumer. Small: negentropySyncOrFetch is already internally
* ingest consumer. Small: the negentropy download is already internally
* backpressured, so this only smooths the seam — the bounded IngestQueue
* behind `server.ingest` is the real limiter.
*/
@@ -720,3 +862,20 @@ class MirrorWorker(
const val UP_SYNC_SETTLE_MS = 1_500L
}
}
/**
* [leg] intersected with the boot window `[since, until]`, or null when the
* band already covers everything this window could ask. Coverage legs come
* off the stable scoped filter and are unbounded on one side; the catch-up
* only ever asks inside its own window.
*/
internal fun clampToWindow(
leg: Filter,
since: Long,
until: Long,
): Filter? {
val newSince = maxOf(leg.since ?: since, since)
val newUntil = minOf(leg.until ?: until, until)
if (newSince > newUntil) return null
return leg.copy(since = newSince, until = newUntil)
}
@@ -0,0 +1,201 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode.mirror
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.SyncCoverage
import com.vitorpamplona.quartz.utils.Log
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import kotlinx.serialization.json.put
import java.io.File
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.StandardCopyOption
/**
* File persistence for [SyncCoverage], so the mirror's catch-up resumes
* across restarts instead of re-syncing each upstream's whole backfill
* window — which, for an upstream without NIP-77, is a full re-download
* every boot.
*
* Same shape as the admin state file: JSON next to the event database,
* written via a temp file and an atomic move so a reader never sees a half
* map. A daemon timer flushes changed state so progress survives a hard
* kill; [close] flushes the rest. A corrupt file starts fresh — the cost of
* losing it is one re-sync, the cost of refusing to start is the relay.
*/
class SyncCoverageFile(
private val file: File,
flushSeconds: Long = DEFAULT_FLUSH_SECONDS,
) : AutoCloseable {
@Volatile private var dirty = false
val coverage = SyncCoverage(onChange = { dirty = true })
private val flusher: Thread
init {
load()
// restore() bypasses onChange, but stay defensive: reopening a file
// must never count as a change, or every boot rewrites it.
dirty = false
flusher =
Thread {
while (!Thread.currentThread().isInterrupted) {
try {
Thread.sleep(flushSeconds * 1000)
} catch (_: InterruptedException) {
return@Thread
}
flush()
}
}.apply {
isDaemon = true
name = "mirror-sync-coverage-flush"
start()
}
}
/** Write the map if anything changed since the last write. */
@Synchronized
fun flush() {
if (!dirty) return
dirty = false
save()
}
override fun close() {
flusher.interrupt()
flush()
}
private fun load() {
if (!file.isFile) return
runCatching {
val root = Json.parseToJsonElement(file.readText()).jsonObject
coverage.restore(
root.mapValues { (_, v) ->
val o = v.jsonObject
SyncCoverage.Band(
spansOf(o),
o["complete"]?.jsonPrimitive?.boolean ?: false,
o["fullAt"]?.jsonPrimitive?.long ?: 0L,
)
},
)
}.onFailure {
Log.w("SyncCoverageFile") { "could not read ${file.path} (${it.message}); starting fresh" }
}
}
/**
* The per-kind spans, or the single pre-split span read as covering every
* kind under [SyncCoverage.ALL_KINDS].
*
* A file written before coverage was tracked per kind carries only
* `min`/`max`, and that is exactly the over-wide claim per-kind spans
* exist to stop — so it is loaded as what it always meant rather than
* discarded, and the first paged walk that reports per kind replaces it.
* Dropping it instead would re-download every upstream's corpus once on
* upgrade, which is the cost bands exist to avoid.
*/
private fun spansOf(o: JsonObject): Map<Int, SyncCoverage.Span> {
o["spans"]?.jsonObject?.let { spans ->
return spans.entries.associate { (kind, v) ->
val span = v.jsonObject
kind.toInt() to SyncCoverage.Span(span.getValue("min").jsonPrimitive.long, span.getValue("max").jsonPrimitive.long)
}
}
return mapOf(
SyncCoverage.ALL_KINDS to
SyncCoverage.Span(o.getValue("min").jsonPrimitive.long, o.getValue("max").jsonPrimitive.long),
)
}
@Synchronized
private fun save() {
runCatching {
val doc =
buildJsonObject {
coverage.export().forEach { (key, band) ->
put(
key,
buildJsonObject {
// min/max are the outer edges across every
// kind, and are written for two readers: a
// human debugging why an upstream re-synced,
// and a ROLLBACK — a binary from before spans
// were per kind reads these and behaves as it
// always did, rather than failing to parse.
put("min", band.minCreatedAt)
put("max", band.maxCreatedAt)
put("complete", band.complete)
put("fullAt", band.fullAt)
put(
"spans",
buildJsonObject {
band.spans.forEach { (kind, span) ->
put(
kind.toString(),
buildJsonObject {
put("min", span.min)
put("max", span.max)
},
)
}
},
)
},
)
}
}
file.parentFile?.mkdirs()
val tmp = File(file.parentFile ?: File("."), "${file.name}.tmp")
tmp.writeText(json.encodeToString(JsonObject.serializer(), doc))
// ATOMIC_MOVE requested explicitly: without it the JVM may
// legally fall back to copy+delete, and a reader could see a
// half map. Same-directory rename, so support is the norm; a
// filesystem that truly can't gets the plain move (and the
// corrupt-file recovery absorbs the residual risk).
try {
Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
} catch (_: AtomicMoveNotSupportedException) {
Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING)
}
}.onFailure {
Log.w("SyncCoverageFile") { "could not write ${file.path}: ${it.message}" }
}
}
companion object {
// Pretty-printed: this file is read by a human debugging why an
// upstream re-synced.
private val json = Json { prettyPrint = true }
// Often enough that a kill costs little, rare enough to be free.
private const val DEFAULT_FLUSH_SECONDS = 30L
}
}
@@ -0,0 +1,262 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode.mirror
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.SyncCoverage
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import kotlinx.serialization.json.put
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The catch-up's resume memory across restarts. Without it every boot
* re-syncs each upstream's whole backfill window — a full re-download for an
* upstream without NIP-77. These pin the restart round-trip and the window
* clamping that keys bands on the stable filter rather than the sliding
* boot window.
*/
class SyncCoverageFileTest {
private val relay = RelayUrlNormalizer.normalize("wss://relay.example")
private val profiles = Filter(kinds = listOf(0))
private fun tempFile(): File {
val f = File.createTempFile("sync-coverage", ".json")
f.delete()
return f
}
@Test
fun `bands survive a restart`() {
val f = tempFile()
SyncCoverageFile(f).use {
it.coverage.record(relay, profiles, 1_700_001_000L, 1_700_002_000L, paged = true)
}
// A fresh instance, as a restart would build.
SyncCoverageFile(f).use { reopened ->
val band = reopened.coverage.band(relay, profiles)!!
assertEquals(1_700_001_000L, band.minCreatedAt)
assertEquals(1_700_002_000L, band.maxCreatedAt)
assertFalse(band.complete)
}
}
@Test
fun `a complete band survives with its completeness`() {
val f = tempFile()
SyncCoverageFile(f).use {
it.coverage.record(relay, profiles, null, null, paged = false, reconciledThrough = 1_700_005_000L)
}
SyncCoverageFile(f).use { reopened ->
assertTrue(reopened.coverage.band(relay, profiles)!!.complete)
}
}
@Test
fun `a corrupt file starts fresh instead of refusing to start`() {
val f = tempFile()
f.writeText("{ not json")
SyncCoverageFile(f).use {
assertNull(it.coverage.band(relay, profiles))
}
}
@Test
fun `recording does not write but closing does`() {
val f = tempFile()
val store = SyncCoverageFile(f)
store.coverage.record(relay, profiles, 1_700_001_000L, 1_700_002_000L, paged = true)
assertFalse(f.isFile, "a record marks dirty; only a flush writes")
store.close()
assertTrue(f.isFile, "close flushes")
}
@Test
fun `reopening without new records does not rewrite the file`() {
val f = tempFile()
SyncCoverageFile(f).use {
it.coverage.record(relay, profiles, 1_700_001_000L, 1_700_002_000L, paged = true)
}
val written = f.lastModified()
SyncCoverageFile(f).close()
assertEquals(written, f.lastModified(), "restoring bands must not mark the store dirty")
}
// ---- the window clamp --------------------------------------------------
@Test
fun `an unbanded filter clamps to exactly the boot window`() {
val leg = clampToWindow(profiles, since = 1_000L, until = 2_000L)!!
assertEquals(1_000L, leg.since)
assertEquals(2_000L, leg.until)
}
@Test
fun `a leg outside the window is dropped rather than inverted`() {
// The band covers past the window's floor: the older leg would ask
// [since..band.min] with since above until — a range nothing can be in.
val olderLeg = profiles.copy(until = 500L)
assertNull(clampToWindow(olderLeg, since = 1_000L, until = 2_000L))
}
@Test
fun `a leg inside the window keeps its own tighter bound`() {
val newerLeg = profiles.copy(since = 1_500L)
val clamped = clampToWindow(newerLeg, since = 1_000L, until = 2_000L)!!
assertEquals(1_500L, clamped.since, "the band's ceiling wins over the window floor")
assertEquals(2_000L, clamped.until)
}
@Test
fun `per-kind spans survive a restart, including the kindless sentinel`() {
// The file is the one place a per-kind band can be silently flattened
// back into the single interval it replaced, so the round trip is
// pinned rather than assumed — negative sentinel key included, since
// ALL_KINDS goes through toString()/toInt() like any other kind.
val mixed = Filter(kinds = listOf(0, 30382))
val anyKind = Filter(authors = listOf("a".repeat(64)))
val f = tempFile()
SyncCoverageFile(f).use {
it.coverage.record(
relay,
mixed,
null,
null,
paged = true,
observedByKind =
mapOf(
0 to SyncCoverage.Span(1_600_000_000L, 1_700_000_000L),
30382 to SyncCoverage.Span(1_690_000_000L, 1_700_000_000L),
),
)
it.coverage.record(
relay,
anyKind,
null,
null,
paged = true,
observedByKind = mapOf(1 to SyncCoverage.Span(1_690_000_000L, 1_700_000_000L)),
)
}
SyncCoverageFile(f).use { reopened ->
val band = reopened.coverage.band(relay, mixed)!!
assertEquals(
mapOf(
0 to SyncCoverage.Span(1_600_000_000L, 1_700_000_000L),
30382 to SyncCoverage.Span(1_690_000_000L, 1_700_000_000L),
),
band.spans,
"each kind keeps its own evidence across the restart",
)
// …and the restored band still narrows per kind, which is the half
// that would go unnoticed if only the fields round-tripped.
val legs = reopened.coverage.legs(relay, mixed)
assertEquals(4, legs.size, "the two kinds want different windows")
assertEquals(
mapOf(SyncCoverage.ALL_KINDS to SyncCoverage.Span(1_690_000_000L, 1_700_000_000L)),
reopened.coverage.band(relay, anyKind)!!.spans,
"the kindless sentinel survives its negative key",
)
}
}
@Test
fun `a file written before per-kind spans loads as the claim it always was`() {
// Only min/max, no `spans` — what every deployed state file holds today.
// Discarding it would re-download each upstream's corpus once on
// upgrade, so it loads under ALL_KINDS and narrows every kind exactly
// as it did before, until the first per-kind walk replaces it.
val mixed = Filter(kinds = listOf(0, 30382))
val f = tempFile()
val key = "${relay.url} ${mixed.toJson()}"
f.writeText(
Json.encodeToString(
JsonObject.serializer(),
buildJsonObject {
put(
key,
buildJsonObject {
put("min", 1_690_000_000L)
put("max", 1_700_000_000L)
put("complete", false)
put("fullAt", TimeUtils.now())
},
)
},
),
)
SyncCoverageFile(f).use { reopened ->
val band = reopened.coverage.band(relay, mixed)!!
assertEquals(mapOf(SyncCoverage.ALL_KINDS to SyncCoverage.Span(1_690_000_000L, 1_700_000_000L)), band.spans)
val legs = reopened.coverage.legs(relay, mixed)
assertEquals(2, legs.size, "one shared pair of legs — the old behaviour, exactly")
assertEquals(listOf(0, 30382), legs[0].kinds)
}
}
@Test
fun `a rolled-back reader still finds the outer edges it understands`() {
// A binary from before per-kind spans reads `min`/`max` and ignores
// `spans`. Those fields must therefore still be written, and must be
// the OUTER edges — anything narrower would make the old reader skip
// ground it has not covered.
val mixed = Filter(kinds = listOf(0, 30382))
val f = tempFile()
SyncCoverageFile(f).use {
it.coverage.record(
relay,
mixed,
null,
null,
paged = true,
observedByKind =
mapOf(
0 to SyncCoverage.Span(1_600_000_000L, 1_695_000_000L),
30382 to SyncCoverage.Span(1_690_000_000L, 1_700_000_000L),
),
)
}
val written =
Json
.parseToJsonElement(f.readText())
.jsonObject.values
.single()
.jsonObject
assertEquals(1_600_000_000L, written.getValue("min").jsonPrimitive.long, "the oldest of any kind")
assertEquals(1_700_000_000L, written.getValue("max").jsonPrimitive.long, "the newest of any kind")
}
}
+19 -9
View File
@@ -259,15 +259,25 @@ q.domain // "example.com"
q.nsfwIncluded // false (NIP-50 default is true when the token is absent)
```
The SQLite store expects `Filter.search` to be plain FTS text: `:` is FTS5
column-filter syntax, so a raw `include:spam` reaching MATCH raises "no such
column: include" instead of matching. Strip the tokens before querying with
`SearchQuery.stripExtensions(raw)` or `filter.strippingSearchExtensions()`
an extensions-only query collapses to an empty search, which imposes no
constraint (NIP-50: unsupported extensions are ignored, not match-nothing).
The storage-backed server path (`NostrServer` / `LiveEventStore`) already does
this, so relays like geode comply out of the box; `EventSource` backends get
the raw string because a real search backend wants the extensions.
`Filter.search` reaches every backend **verbatim**, extension tokens included
— the relay layer never rewrites it. Which extensions are directives and which
are noise is a property of the store, so the `IEventStore` contract puts the
decision there: a store that implements an extension (rank profiles, trust
floors, observer-relative scoring) parses the raw string with
`SearchQuery.parse`; a store that doesn't must ignore the tokens per NIP-50
(not match them as literal text, not return nothing). The built-in SQLite and
filesystem stores do the latter by stripping at their own boundary with
`filter.strippingSearchExtensions()` — FTS5 treats `:` as column-filter syntax,
so a raw `include:spam` reaching MATCH would raise "no such column: include" —
and an extensions-only query collapses to an empty search, which imposes no
constraint. Relays like geode therefore comply out of the box, and
`EventSource` backends likewise get the raw string.
Observer-relative stores (web-of-trust ranking, "for-you" relevance) read the
caller's NIP-42-authenticated pubkeys from the coroutine context via
`StoreQueryContext``LiveEventStore` installs it around every REQ/COUNT store
call for authenticated connections. It is ranking context only: it may reorder
results, never change which events match.
A search/redirector relay is just a custom policy (or, for computed results, a
custom `IEventStore` whose `query` answers the REQ) that reads the parsed query:
@@ -0,0 +1,221 @@
# PoolRequests subscription-state lock: what a suspending Mutex would cost
**Date:** 2026-08-03
**Status:** analysis + measurements; recommendation is NOT to use `Mutex`
**Harness:** `quartz/src/jvmTest/.../prodbench/LockDesignComparisonBenchmark.kt`
(`./gradlew :quartz:jvmTest --tests "*.LockDesignComparisonBenchmark"`)
**Context:** follows the Pixel 8 ANR (`anr_2026-08-03-12-55-26-256`) that replaced
`RequestSubscriptionState`'s busy-wait with a parking `PlatformLock`.
## The question
The parking lock removed the CPU burn, but it still **blocks** rather than suspends.
Relay consumers run as coroutines on `Dispatchers.IO` (limitedParallelism 64) and
production has ~191 live relays, so a blocked waiter occupies one of 64 dispatcher
threads. kotlinx's scheduler treats IO tasks as blocking and grows the pool when they
block — which is why the on-device fix moved CPU but left thread count flat (+7%).
Would `kotlinx.coroutines.sync.Mutex` (a waiter *suspends*, freeing its thread) be
better?
## What Mutex actually costs
The lock sits at the bottom of a call chain that is **entirely non-suspend**:
```
OkHttpWebSocket: scope.launch { for (m in incomingMessages) out.onMessage(m) } <- coroutine
WebSocketListener.onMessage (non-suspend)
BasicRelayClient.MyWebsocketListener.onMessage
RelayPool.onIncomingMessage
NostrClient.onIncomingMessage (RelayConnectionListener)
PoolRequests.onIncomingMessage
RequestSubscriptionState.withLock <- the lock
SubscriptionListener.onEvent (non-suspend, fans out to the app)
```
`Mutex.lock()` is `suspend`, so every frame above it must become `suspend`. Measured
blast radius:
| interface / API | count |
|---|---|
| `override fun onEvent(` | 59 |
| `override fun onEose(` | 52 |
| `override fun onIncomingMessage(` | 35 |
| `override fun onCannotConnect(` | 32 |
| `override fun onClosed(` | 25 |
| `override fun onDisconnected(` | 17 |
| `override fun onConnected(` | 16 |
| `override fun onSent(` | 14 |
| `override fun onConnecting(` | 11 |
| others (`onSubscriptionStarted`, …) | 1 |
| **total overrides to convert** | **262** |
| `.subscribe(` / `.unsubscribe(` call sites | **110** |
Worse than the count: the *entry points* are not all coroutines. `INostrClient` is
non-suspend by design — `subscribe`, `unsubscribe`, `publish`, `syncFilters`,
`connect` — and is called from ViewModels, filter assemblers and Compose effects.
`PoolRequests.addOrUpdate` / `remove` / `sendToRelayIfChanged` reach the lock from
those paths. Making them suspend pushes coroutine scoping into every call site that
today just calls `subscribe(...)` synchronously.
## The cheaper alternative: stripe the lock per relay
**Enabling invariant (verified by reading all 11 `withLock` bodies):** *every*
critical section in `PoolRequests` is scoped to exactly one relay. Each one takes
`url` / `relay` / `relay.url` as its key:
| line | body | key |
|---|---|---|
| 204 | `state.connecting(url)` | url |
| 218 | `state.onOpenReq(relay, cmd.filters)` | relay |
| 228 | `state.onSubscriptionClosed(relay)` | relay |
| 249 | `onNewEvent` / `currentState` / `lastKnownFilterStates` | relay.url |
| 267 | `onEose` + `decideCommandLocked` | relay.url |
| 296 | `onClosed` + `recordRefusalIfStructural` + `decideCommandLocked` | relay.url |
| 325 | `state.disconnected(url)` | url |
| 354 | `isStructurallyRefused` + `onOpenReq` | relay |
| 382 | `lastKnownFilterStates(url)` | url |
| 403 | `decideCommandLocked(state, subId, relay)` | relay |
Every field in `RequestSubscriptionState` is a `Map<T, …>` keyed by relay
(`subStates`, `filterStates`, `lastKnownFilterStates`, `refusedFilters`,
`refusalCounts`). The single cross-relay accessor, `currentFilters()` (no-arg,
returns the whole map), has **zero usages** — dead code, delete it.
So the lock is only shared across relays as an artifact of `mutableMapOf` not being
thread-safe. One lock per `(subId, relay)` is semantically equivalent and drops
contention from ~191 threads to ~12 (that relay's consumer, plus the occasional app
thread in `sendToRelayIfChanged`).
Blast radius: `RequestSubscriptionState` + `PoolRequests` only. Used by 4 production
files (`PoolRequests`, `RelayActiveRequestStates`, `RelayReqRefusals`) and 2 tests.
**No public API change.**
## Measurements
191 relay coroutines on a 64-thread limited-parallelism dispatcher, 3s windows. Each
iteration yields, modelling the real per-message suspension point of
`for (message in incomingMessages)` — without it the tight loops monopolise the
dispatcher and the harness measures itself, not the lock.
`bystander` = a task that never touches the lock, on the same dispatcher. Its latency
answers "is the lock stealing dispatcher threads from unrelated work?"
| subs | design | ops/s | bystander p50 | p99 |
|---|---|---|---|---|
| 1 | PER_SUB_BLOCKING (today) | 548,608 | 196.5µs | 770.9µs |
| 1 | PER_SUB_MUTEX | 190,672 | **1.9µs** | 30.4µs |
| 1 | **STRIPED** | **1,543,037** | 88.8µs | 237.6µs |
| 4 | PER_SUB_BLOCKING | 833,090 | 136.1µs | 507.6µs |
| 4 | PER_SUB_MUTEX | 523,440 | **3.1µs** | 26.0µs |
| 4 | **STRIPED** | **1,499,757** | 92.3µs | 337.5µs |
| 16 | PER_SUB_BLOCKING | 1,198,581 | 94.3µs | 444.3µs |
| 16 | PER_SUB_MUTEX | 749,487 | **5.0µs** | 16.5µs |
| 16 | **STRIPED** | **1,789,509** | 85.7µs | 219.8µs |
Reading:
- **STRIPED gives the most throughput** — 2.8× / 1.8× / 1.5× over today — and roughly
halves bystander p50. It removes the contention rather than tolerating it.
- **MUTEX is the slowest of the three** (0.35× / 0.63× / 0.63× of today): per-acquisition
suspend/resume is not free at these rates.
- **MUTEX does win bystander latency decisively** (1.95.0µs vs 85196µs, and it collected
626k vs 24k samples). But read that honestly — part of the win is that its coroutines
spend their time *suspended waiting for the mutex instead of doing work*. Better
thread-yielding is partly a symptom of lower throughput, not purely a win.
## On-device result (SM-T220, playBenchmark, same account, n=3 per design)
Cold-start burst, 75s window, exact per-thread CPU accounting from `/proc/<tid>/stat`.
Warmups matched (~220-255 established sockets, ~200 relay reader threads each time).
| design | DefaultDispatcher CPU (ms/75s) mean / median / range | GC ms | threads |
|---|---|---|---|
| spin lock (pre-fix) | 136,743 / 117,750 / 112,230180,250 | 33,777 | 475 |
| parking, one lock per sub | 103,780 / 99,160 / 86,120130,680 | 30,235 | 510 |
| **striped, per (sub, relay)** | **88,953 / 82,160 / 72,860111,840** | **27,687** | 489 |
- **Striped vs spin: 35% mean, 30% median CPU, 18% GC — and the ranges do not
overlap** (striped max 111,840 < spin min 112,230). That separation is what the
parking-only change could not show; its range overlapped the baseline heavily.
- **Striped vs parking: 14% mean / 17% median**, ranges still overlap — directional,
not conclusive at n=3.
- **Thread count is unchanged across all three** (475 / 510 / 489). Expected: the lock
is blocking, and kotlinx marks IO tasks blocking and grows the pool. Striping reduces
how *often* threads block, not the fact that they can.
- Not a false win from broken subscriptions: the feed rendered live notes 3-15 min old
with avatars and reaction counts, on 223 sockets / 200 relay reader threads.
## Recommendation
**Do the striping; do not convert to `Mutex`.**
Striping attacks the cause — the lock is contended only because it is shared across
relays that touch disjoint keys. Once contention is ~0, the blocking-vs-suspending
question is moot: *a lock that is never contended never blocks a thread*. It also
happens to be the fastest option and is contained to two files, versus 262 overrides
and 110 call sites for a design that measures slower.
`Mutex` only becomes the right answer if a future critical section must genuinely span
relays (or do I/O), which none does today.
## The lock must not live inside a removable entry
The obvious shape — `ConcurrentMap<T, PerRelayState>` where `PerRelayState` owns both
the fields *and* its lock — is **wrong as soon as entries can be removed**.
`connecting()` / `disconnected()` genuinely mean "forget this relay's wire state", so
they want removal, and then:
```
T1: getOrPut(R) -> stateA ; stateA.lock.lock() // in a critical section
T2: disconnected(R) -> remove(R) // stateA is now orphaned
T3: getOrPut(R) -> stateB (NEW lock) ; stateB.lock.lock() // acquires immediately
-> T1 and T3 are both "in" relay R's critical section, excluding nothing.
```
Two ways out:
- **(A) never remove; null the fields.** Lock identity is stable, but entries
accumulate for every relay a sub has *ever* seen. Bounded but monotonic — and slow
monotonic growth in a long-lived process is precisely the class of bug this whole
investigation was about.
- **(B) stable stripe locks + removable state.** A fixed `Array(N) { PlatformLock() }`
indexed by `relay.hashCode()`, never mutated, so lock identity can't change; the
`ConcurrentMap<T, RelayState>` entries are then free to be added and removed with
exact `connecting`/`disconnected` semantics and no growth.
**(B) is the recommendation.** With N = 32 and ~191 relays, ~6 relays share a stripe —
still a ~32x contention reduction versus today's single lock per sub, with none of the
lock-lifetime hazard. `ConcurrentMap.remove` (added 2026-08-03, with tests in
`ConcurrentCollectionsTest`) is what makes (B) possible.
## Implementation sketch
1. Delete the dead `currentFilters()` (no-arg).
2. In `RequestSubscriptionState`, replace the five relay-keyed maps with a single
`ConcurrentMap<T, RelayState>` holding the five fields — **no lock inside**.
3. Add a fixed `private val stripes = Array(32) { PlatformLock() }` and
`withLock(reference)` = `stripes[reference.hashCode().absoluteValue % 32].withLock { }`.
The array is never mutated, so lock identity is stable for the object's life.
4. `connecting()` / `disconnected()` become `map.remove(reference)` — exact current
semantics, no residue.
5. Update the 11 `withLock` call sites in `PoolRequests` to pass the relay.
`decideCommandLocked`'s "MUST hold the lock" contract becomes "MUST hold *that
relay's stripe*" — tighten the kdoc.
6. Extend `PoolRequestsRefusalTest` with concurrent multi-relay access on one subId,
and add a striped variant to `LockDesignComparisonBenchmark` to confirm the
measured win survives stripe collisions.
## Risks
- **Stripe collisions serialize unrelated relays.** With 32 stripes and 191 relays this
is ~6-way sharing; it is a contention *reduction*, not elimination. If a future
profile shows it mattering, raise N — it is a one-line change with no semantic effect.
- **Two relays on one stripe must never be locked simultaneously by one thread** — that
would self-deadlock (`PlatformLock` is reentrant on JVM/Apple, so same-thread
re-entry is survivable, but the invariant should be stated). `PoolRequests` already
locks one relay at a time.
- The state machine's atomicity comments are load-bearing; the per-relay scoping must
be re-verified against any new `withLock` body added between now and the change.
- Striping is NOT a fix for a critical section that does I/O or spans relays. If one is
ever added, this analysis must be redone.
@@ -0,0 +1,154 @@
# Amethyst over Yggdrasil (IPv6 overlay)
Status: **fixed** — the four gaps found in the original assessment are closed. The last
section records what was deliberately left alone.
## What Yggdrasil looks like to the app
Yggdrasil is an encrypted end-to-end mesh. Every node gets an IPv6 address derived from its
public key inside `0200::/7` (nodes in `0200::/8`, subnets in `0300::/8`). Consequences:
- **No DNS.** A relay on the mesh is addressed as an IPv6 literal, always.
- **No certificates.** No CA issues for `0200::/7`, so relays run plain `ws://`. Not a
downgrade — the overlay already encrypts end to end and authenticates the peer by an
address derived from its public key.
- **On Android it is a `VpnService`**, so the app's default network becomes the VPN network.
- `0200::/7` is deprecated NSAP space, so nothing else routes there. An address in the range
is reachable *only* through a running mesh interface — which is what makes it safe to key
behavior off the prefix.
## What was wrong, and what fixed it
### 1. One relay, two identities
`RelayUrlNormalizer` folded hex case but not zero-compression, so
`[201:0d0e:9ba5:8bbc:0000:0000:0000:0001]` and `[201:d0e:9ba5:8bbc::1]` stayed two distinct
`NormalizedRelayUrl`s for one host — while OkHttp collapsed both to the same host when
dialing. Since that value keys the connection pool, the relay-list sets, the NIP-11 cache and
the per-relay stat maps, the app opened two sockets to one relay and counted it twice.
**Fix:** new `Ipv6` util (`quartz/utils/Ipv6.kt`) — pure-Kotlin parse, RFC 5952 canonical
format and range classification, no `java.net`, so it works on every KMP target.
`RelayUrlNormalizer.norm()` now canonicalizes the bracketed host. The canonical form is
byte-for-byte what OkHttp renders, so the key the app stores is the host it actually dials —
asserted differentially against OkHttp in `YggdrasilCompatCharacterizationTest`.
Affects every IPv6 relay, not just mesh ones; it only bit Yggdrasil users because on the mesh
a literal is the *only* way to name a relay. No migration needed: relay lists are rehydrated
from event tags through `normalizeOrNull`, so stored entries fold on load.
### 2. Schemeless entry defaulted to `wss://`
`isLocalHost()` knew `127.0.0.1` / `localhost` / `//umbrel:` / `192.168.` / `.local`, so a
mesh address fell through to the clearnet default and produced a `wss://` url whose TLS
handshake could never succeed.
**Fix:** new `RelayUrlNormalizer.isOverlayNetwork()` recognizes `0200::/7` and joins
`isOnion` / `isLocalHost` in choosing `ws://`. Clearnet IPv6 (`2001:db8::1`) still gets
`wss://`.
`isLocalHost()` separately grew the IPv6 twins of the literals it already knew — `::1`
(loopback), `fc00::/7` (unique local, the 192.168. analogue) and `fe80::/10` (link-local).
Those are the same question every caller is asking, so a relay on one now correctly skips TLS
and Tor and stays out of published relay lists.
### 3. Unbracketed literal silently rejected
`yggdrasilctl getSelf` prints the address unbracketed — exactly what gets pasted into "add a
relay". Normalization returned null (correctly: it is ambiguous with a scheme) and
`RelayUrlEditField.submitRelay` had no else branch, so the Add button did nothing at all.
**Fix, two halves:**
- `fix()` brackets a bare literal automatically, but only when the whole string parses as an
IPv6 address — so `31990:hex:dtag` (addressable pointer), `abcd:1234` (host:port) and
`relay.example.com:8080` still fall through untouched.
- The edit field now sets `isError` and shows `relay_url_not_valid` instead of no-opping.
That fixes the dead button for *all* invalid input, not just IPv6.
### 4. Tor routing broke mesh relays
`TorRelayEvaluation` classified mesh relays as "new", so with Tor on and the default "new
relays via Tor" they were dialed through the SOCKS proxy — which cannot route `0200::/7`.
Guaranteed failure, not privacy.
**Fix:** `useTor()` returns false for `isOverlayNetwork()`, checked right after the localhost
branch. Both the Android and desktop relay paths delegate here (`TorRelayState`,
`DesktopHttpClient`), so one change covers both. `RoleBasedHttpClientBuilder` got the same
treatment for non-relay HTTP (images, previews, NIP-05, money ops). Clearnet IPv6 relays keep
following the Tor setting — asserted in `YggdrasilTorRoutingTest`.
## Audit round: bugs found in the host predicates
Auditing the change above turned up a family of bugs in `isLocalHost` / `isOnion` that predate
it. All shared one root cause — the predicates ran `contains` over the **whole url** instead of
parsing the authority — and all are now anchored, parsed and covered by
`RelayUrlAuthorityAnchoringTest`.
These predicates decide whether a relay is exempt from Tor, and relay urls arrive from other
people (NIP-65 lists, relay hints, `r` tags), so they are attacker-controlled input.
| # | Bug | Effect |
|---|---|---|
| 1 | A path could impersonate the host: `wss://evil.example.com/127.0.0.1` answered `isLocalHost() == true` | Any relay list could hand the app a url that silently dropped its own Tor routing |
| 2 | `.onion:8080` never matched the `.onion/` test | An onion relay on an explicit port was not treated as onion — never forced onto Tor, hostname sent to the clearnet DNS resolver |
| 3 | `.onion.` / `localhost.` (RFC 1034 fully-qualified form) matched nothing | Same leak as #2, via a different spelling |
| 4 | Host tests were case-sensitive, but `fix()` runs *before* the RFC 3986 pass folds case | `LOCALHOST:8080` and `ABC.ONION:8080` were given a `wss://` scheme neither host can serve |
| 5 | Private IPv4 was substring-matched | `10.0.0.5`, `172.16.3.4`, `127.1.2.3` were not local (LAN relay got `wss://` and Tor), while `192.168.evil.com` — a registrable domain — was |
| 6 | `contains("localhost")` matched `notlocalhost.example.com` | Same Tor exemption as #1, via a registrable domain |
| 7 | A `://` inside a path was read as a scheme separator | `relay.com/x://127.0.0.1` read its path as the authority |
Two bugs were introduced by this branch and caught in the same pass: the IPv6 host lookup had
the #1 flaw (`wss://evil.example.com/[fd00::1]` read as localhost), and IPv6 canonicalization
could rewrite a **path** (`/x[0:0:0:0:0:0:0:1]y``/x[::1]y`), corrupting the url.
Fixes: a shared `hostStart` / `hostEnd` / `hostEndWithoutPort` trio bounds every test to the
authority, strips `:port` and trailing dots, and validates the scheme; private ranges are
parsed via the new `Ipv4` util and `Ipv6` rather than substring-matched; comparisons are
case-insensitive per RFC 4343; `NormalizedRelayUrl.isOnion()` now delegates instead of keeping
a second, weaker copy of the test.
Performance: no regression, likely a small win. The old form ran six full-string `contains`
scans; the new one bounds its work to the authority and rejects a DNS host from an IP parse on
a single character. `Ipv6.isLiteral` gained a two-colon gate so the schemeless `host:port` case
answers without allocating the parser's 16-byte buffer.
`Ipv6` itself is pinned by `Ipv6DifferentialTest`: 4000 random addresses round-trip against
`java.net.InetAddress` in both directions, and the canonical form is asserted equal to
OkHttp's host for the same address — so the relay identity the app stores provably matches the
host it dials.
## Deliberately not changed
**Mesh relays are still published and recommended.** `AdvertisedRelayInfoTag` (NIP-65) and
`RelayListRecommendationProcessor.filterValidRelays` only exclude localhost, so a mesh relay
in your relay list is still published to public relays and offered to other users via the
outbox model. Two consequences worth a maintainer's decision:
- Peers not on the mesh dial `[201:…]` and burn reconnect attempts on an unreachable host.
- Your Yggdrasil address — a stable, key-derived node identifier — becomes public.
Onion relays already have precedent for both readings: they *are* published, but
`filterValidRelays` gates them behind `hasOnionConnection`. The equivalent for overlay relays
would be a `hasMeshConnection` gate. That is a product call about whether mesh relays are
meant to be discoverable, so it is flagged rather than decided here.
## Not verified here
- **No live socket test.** The analysis container has no IPv6 stack at all (`AF_INET6`
`EAFNOSUPPORT`), so everything is verified below the socket: normalization, OkHttp URL/host
agreement, and the Tor routing decision. An on-device run against a real mesh relay is
still needed to confirm the happy path end to end.
- **Android VPN interaction untested.** `ConnectivityFlow` uses
`registerDefaultNetworkCallback`, so it follows the app into the VPN network. Whether
`isMeteredOrMobileData()` reads correctly through Yggdrasil's `VpnService` depends on
whether that app declares underlying networks — worth checking on device before assuming
data-saving mode behaves.
- Media loading (Coil) and NIP-05 resolution against mesh hosts were not exercised.
## Tests
- `quartz/…/utils/Ipv6Test.kt` — parser, RFC 5952 formatting, range classification.
- `quartz/…/relay/YggdrasilCompatCharacterizationTest.kt` — normalization end to end, plus
the differential assertions that our identity matches the host OkHttp dials.
- `commons/…/tor/YggdrasilTorRoutingTest.kt` — overlay relays never Torified, clearnet IPv6
still follows the setting.
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.utils.concurrent
import platform.Foundation.NSRecursiveLock
// NSRecursiveLock parks contended waiters in the kernel, matching the
// ReentrantLock semantics of the jvmAndroid actual (and the same choice
// commons' KmpLock made for iOS). This must NOT be a spin lock: quartz's
// relay client runs here too, and spinning is what produced the Android
// ANR documented on the expect declaration.
actual class PlatformLock {
private val delegate = NSRecursiveLock()
actual fun lock() = delegate.lock()
actual fun unlock() = delegate.unlock()
}
@@ -75,14 +75,14 @@ class FollowerCrawler(
* stops paging once it's reached, so a non-null value cuts the crawl short at
* that many per relay. Leave it null for completeness; set it only to bound a
* spot check. The per-page size is the relay's own default either way.
* @param timeoutMs per-page EOSE timeout for a relay before its next page fires.
* @param idleTimeoutMs per-page EOSE timeout for a relay before its next page fires.
* @param maxConcurrentRelays how many relays page at once (a global fan-out cap).
* @param insertBatchSize verified events group-committed per [IEventStore.batchInsert].
*/
class Config(
val relays: Set<NormalizedRelayUrl>,
val maxPerRelay: Int? = null,
val timeoutMs: Long = 15_000,
val idleTimeoutMs: Long = 15_000,
val maxConcurrentRelays: Int = 16,
val insertBatchSize: Int = 500,
)
@@ -162,7 +162,7 @@ class FollowerCrawler(
client.fetchAllPagesFromPool(
filters = perRelay,
timeoutMs = config.timeoutMs,
idleTimeoutMs = config.idleTimeoutMs,
maxConcurrentRelays = config.maxConcurrentRelays,
onRelayComplete = { relay, total ->
if (total > 0) log("[followers] ${relay.url}: $total kind:3 pages drained")
@@ -81,7 +81,7 @@ object RecipientRelayFetcher {
client: INostrClient,
pubKey: HexKey,
seedRelays: Set<NormalizedRelayUrl>,
timeoutMs: Long = 8_000L,
idleTimeoutMs: Long = 8_000L,
): Lists {
if (seedRelays.isEmpty()) return Lists(emptyList(), emptyList(), null)
@@ -99,7 +99,7 @@ object RecipientRelayFetcher {
val events =
client.fetchAll(
filters = seedRelays.associateWith { listOf(filter) },
timeoutMs = timeoutMs,
idleTimeoutMs = idleTimeoutMs,
)
var dm: ChatMessageRelayListEvent? = null
@@ -75,11 +75,11 @@ object KeyPackageFetcher {
client: INostrClient,
targetPubKey: HexKey,
relays: Set<NormalizedRelayUrl>,
timeoutMs: Long = 30_000,
idleTimeoutMs: Long = 30_000,
): KeyPackageEvent? {
if (relays.isEmpty()) return null
val filter = MarmotFilters.keyPackagesByAuthor(targetPubKey)
val events = client.fetchAll(filters = relays.associateWith { listOf(filter) }, timeoutMs = timeoutMs)
val events = client.fetchAll(filters = relays.associateWith { listOf(filter) }, idleTimeoutMs = idleTimeoutMs)
// fetchAll returns events sorted by created_at DESC, so the first
// KeyPackageEvent is the most recent one any relay had.
return events.firstNotNullOfOrNull { it as? KeyPackageEvent }
@@ -116,7 +116,8 @@ class InterningEventStore(
override suspend fun snapshotIdsForNegentropy(
filters: List<Filter>,
maxEntries: Int?,
): List<IdAndTime> = inner.snapshotIdsForNegentropy(filters, maxEntries)
onProgress: ((collected: Int) -> Unit)?,
): List<IdAndTime> = inner.snapshotIdsForNegentropy(filters, maxEntries, onProgress)
override suspend fun delete(filter: Filter) = inner.delete(filter)
@@ -25,12 +25,13 @@ import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.isReplaceable
import com.vitorpamplona.quartz.nip01Core.core.supersedes
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.store.ObservableEventStore
import com.vitorpamplona.quartz.nip01Core.store.ObservableEventStore.StoreChange
import com.vitorpamplona.quartz.nip01Core.store.owner
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
import com.vitorpamplona.quartz.utils.SortedList
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -342,19 +343,14 @@ class EventStoreProjection<T : Event>(
private fun supersedes(
new: Event,
existing: Event,
): Boolean =
when {
new.createdAt > existing.createdAt -> true
new.createdAt < existing.createdAt -> false
else -> new.id < existing.id
}
): Boolean = new.supersedes(existing)
/**
* Owner pubkey for ownership checks (NIP-09 author match,
* NIP-62 vanish target). For GiftWrap the owner is the p-tag
* recipient; for everything else it's `event.pubKey`.
*/
private fun ownerOf(event: Event): HexKey = (event as? GiftWrapEvent)?.recipientPubKey() ?: event.pubKey
private fun ownerOf(event: Event): HexKey = event.owner()
/**
* created_at DESC, id ASC. Sort keys are frozen at slot
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.core
/**
* The NIP-01 replaceable/addressable supersession rule: the winner of an
* address is the highest `created_at`, with ties broken by the LEXICALLY
* SMALLEST id. True when THIS event beats [existing] — equal events (same id)
* do not supersede themselves. One rule, shared by every store; the SQLite
* triggers encode the same comparison in SQL.
*/
fun Event.supersedes(existing: Event): Boolean =
when {
createdAt > existing.createdAt -> true
createdAt < existing.createdAt -> false
else -> id < existing.id
}
@@ -0,0 +1,81 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay.client.accessories
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.concurrent.Volatile
import kotlin.time.TimeSource
/**
* Monotonic "last activity" marker for the idle watchdogs shared by the accessory
* fetch/sync loops. [bump] on every sign of life from the relay; [elapsedMs] reports
* the silence since the last bump (or since construction, before the first bump).
*
* This is the timeout convention for every accessory in this package: an
* `idleTimeoutMs` is an **idle window measured from the relay's most recent
* progress**, not a wall-clock deadline — an actively streaming relay is never cut
* off mid-delivery, only one that goes silent. The name is the contract: a
* parameter here is called `idleTimeoutMs` precisely because it is not a deadline.
*
* [bump] is on the per-event hot path (a connection listener may bump for every
* message the relay sends — millions during a large download), so it must not
* allocate: a single [start] mark is taken once (unboxed field) and each bump only
* writes a `Long` of nanos-since-start into a `@Volatile` field. Reader threads
* write, the driver coroutine reads — visibility is all we need, so a plain volatile
* Long beats boxing a `ValueTimeMark` into an `AtomicReference` on every event.
*/
internal class IdleClock {
private val start = TimeSource.Monotonic.markNow()
@Volatile
private var lastNanos = 0L
fun bump() {
lastNanos = start.elapsedNow().inWholeNanoseconds
}
fun elapsedMs(): Long = (start.elapsedNow().inWholeNanoseconds - lastNanos) / 1_000_000
}
/**
* Receives the next item, giving up (returning `null`) only after [idleMs] elapse with
* no activity on [clock]. Because [clock] can be bumped by *any* relay message — not
* just items on this channel — unrelated progress (e.g. download events arriving during
* a reconcile wait) keeps pushing the deadline out. [idleMs] `<= 0` disables the
* watchdog: it waits until an item arrives (a disconnect is delivered as an item, so
* a dead socket still unblocks it).
*/
internal suspend fun <T> Channel<T>.receiveWithinIdle(
clock: IdleClock,
idleMs: Long,
): T? {
if (idleMs <= 0) return receive()
while (true) {
val remaining = idleMs - clock.elapsedMs()
if (remaining <= 0) return null
val item = withTimeoutOrNull(remaining) { receive() }
if (item != null) return item
// Timed out with nothing on this channel. If other activity bumped the clock
// meanwhile, the next `remaining` is positive and we wait again; otherwise it
// is <= 0 on the next iteration and we give up.
}
}
@@ -298,7 +298,11 @@ class NegentropyStoreSync(
}
}
try {
client.fetchAllPages(relay, listOf(filter), config.idleTimeoutMs) { event -> events.trySend(event) }
// Like fetchByIds, a download keeps a finite idle bound even when the
// whole-sync watchdog is disabled (idleTimeoutMs = 0) — a page that
// never EOSEs must not hang the sync forever.
val pageIdleMs = if (config.idleTimeoutMs > 0) config.idleTimeoutMs else DEFAULT_DOWNLOAD_IDLE_MS
client.fetchAllPages(relay, listOf(filter), pageIdleMs) { event -> events.trySend(event) }
} finally {
events.close()
}
@@ -32,21 +32,27 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip45Count.HyperLogLog
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.coroutines.coroutineContext
/**
* Sends a NIP-45 COUNT query to a single relay and suspends until
* the result arrives or the timeout expires.
*
* A COUNT exchange is a single response message, so [idleTimeoutMs] here is
* trivially the package-wide idle-window convention (time since the most
* recent message): no message can arrive before the one that completes it.
*
* @param relay Target relay to query.
* @param filter The filter to count against.
* @param timeoutMs How long to wait for a response (default 15 s).
* @param idleTimeoutMs How long to wait for the response (default 15 s).
* @return The [CountResult], or `null` on timeout.
*/
suspend fun INostrClient.count(
relay: NormalizedRelayUrl,
filter: Filter,
timeoutMs: Long = 15_000,
idleTimeoutMs: Long = 15_000,
): CountResult? {
val subId = newSubId()
val resultChannel = Channel<CountResult>(UNLIMITED)
@@ -64,23 +70,22 @@ suspend fun INostrClient.count(
}
}
addConnectionListener(listener)
return try {
addConnectionListener(listener)
val result =
try {
count(subId = subId, filters = mapOf(relay to listOf(filter)))
count(subId = subId, filters = mapOf(relay to listOf(filter)))
withTimeoutOrNull(timeoutMs) {
resultChannel.receive()
}
} finally {
unsubscribe(subId)
removeConnectionListener(listener)
withTimeoutOrNull(idleTimeoutMs) {
resultChannel.receive()
}
resultChannel.close()
return result
} finally {
// Every cleanup step belongs in the finally: closing the channel used to
// sit after it, so a throw (or cancellation) mid-wait skipped it while the
// sibling accessories all cleaned up fully.
unsubscribe(subId)
removeConnectionListener(listener)
resultChannel.close()
}
}
/**
@@ -88,13 +93,22 @@ suspend fun INostrClient.count(
* (one filter per relay) and suspends until all results arrive
* or the timeout expires.
*
* [idleTimeoutMs] is an **idle window measured from the most recent progress**, not a
* wall-clock deadline for the whole batch — the package-wide accessory
* convention: each *new* relay's COUNT result restarts it, so a large fan-out
* where results keep trickling in is never cut short. A relay re-sending a result
* it already gave is not progress and does not restart the window, which makes
* the call self-bounding (at most one window per relay). A caller wanting a hard
* wall-clock bound has `withTimeoutOrNull(ms) { count(...) }` — at the cost of
* discarding the partial map, which is why this returns whatever arrived instead.
*
* @param filters Map of relay -> filter to count.
* @param timeoutMs How long to wait for all responses (default 15 s).
* @param idleTimeoutMs Idle window between new responses (default 15 s).
* @return Map of relay -> [CountResult] for every relay that responded in time.
*/
suspend fun INostrClient.count(
filters: Map<NormalizedRelayUrl, List<Filter>>,
timeoutMs: Long = 15_000,
idleTimeoutMs: Long = 15_000,
): Map<NormalizedRelayUrl, CountResult> {
if (filters.isEmpty()) return emptyMap()
@@ -115,27 +129,52 @@ suspend fun INostrClient.count(
}
}
addConnectionListener(listener)
filters.forEach { (relay, filterList) ->
val subId = newSubId()
subIdToRelay[subId] = relay
count(subId = subId, filters = mapOf(relay to filterList))
}
val results = mutableMapOf<NormalizedRelayUrl, CountResult>()
withTimeoutOrNull(timeoutMs) {
try {
addConnectionListener(listener)
filters.forEach { (relay, filterList) ->
val subId = newSubId()
subIdToRelay[subId] = relay
count(subId = subId, filters = mapOf(relay to filterList))
}
// One idle window per new relay result. The inner loop absorbs repeats
// (a relay answering twice) inside the SAME window, so only genuinely
// new information pushes the deadline out — bounding the call at one
// window per relay without needing a wall-clock ceiling.
while (results.size < filters.size) {
val (relay, result) = resultChannel.receive()
val progressed =
withTimeoutOrNull(idleTimeoutMs) {
while (true) {
// Cancellation (this window expiring, or the caller giving up)
// only lands at a suspension point, and receive() does not
// suspend while the channel has buffered results — so check
// explicitly rather than draining a backlog uninterruptibly.
coroutineContext.ensureActive()
val (relay, result) = resultChannel.receive()
// put() returns the previous value: null means this relay
// had not answered yet, i.e. real progress.
if (results.put(relay, result) == null) break
}
true
}
if (progressed == null) break
}
// A result can land after the last window closed but before we unsubscribe;
// it costs nothing to keep, and dropping it would understate the count.
while (true) {
val (relay, result) = resultChannel.tryReceive().getOrNull() ?: break
results[relay] = result
}
} finally {
subIdToRelay.keys.forEach { unsubscribe(it) }
removeConnectionListener(listener)
resultChannel.close()
}
subIdToRelay.keys.forEach { unsubscribe(it) }
removeConnectionListener(listener)
resultChannel.close()
return results
}
@@ -152,20 +191,20 @@ suspend fun INostrClient.count(
*
* @param relays List of relays to query.
* @param filter The filter to count against.
* @param timeoutMs How long to wait for all responses (default 15 s).
* @param idleTimeoutMs Idle window between responses (default 15 s) — see [count].
* @return A merged [CountResult], or `null` if no relay responded.
*/
suspend fun INostrClient.countMerged(
relays: List<NormalizedRelayUrl>,
filter: Filter,
timeoutMs: Long = 15_000,
idleTimeoutMs: Long = 15_000,
): CountResult? {
if (relays.isEmpty()) return null
val results =
count(
filters = relays.associateWith { listOf(filter) },
timeoutMs = timeoutMs,
idleTimeoutMs = idleTimeoutMs,
)
if (results.isEmpty()) return null
@@ -31,47 +31,47 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
suspend fun INostrClient.fetchAll(
relay: String,
filter: Filter,
timeoutMs: Long = 30_000L,
) = fetchAll(newSubId(), mapOf(RelayUrlNormalizer.normalize(relay) to listOf(filter)), timeoutMs)
idleTimeoutMs: Long = 30_000L,
) = fetchAll(newSubId(), mapOf(RelayUrlNormalizer.normalize(relay) to listOf(filter)), idleTimeoutMs)
suspend fun INostrClient.fetchAll(
relay: String,
filters: List<Filter>,
timeoutMs: Long = 30_000L,
) = fetchAll(newSubId(), mapOf(RelayUrlNormalizer.normalize(relay) to filters), timeoutMs)
idleTimeoutMs: Long = 30_000L,
) = fetchAll(newSubId(), mapOf(RelayUrlNormalizer.normalize(relay) to filters), idleTimeoutMs)
suspend fun INostrClient.fetchAll(
subscriptionId: String = newSubId(),
relay: String,
filters: List<Filter>,
timeoutMs: Long = 30_000L,
) = fetchAll(subscriptionId, mapOf(RelayUrlNormalizer.normalize(relay) to filters), timeoutMs)
idleTimeoutMs: Long = 30_000L,
) = fetchAll(subscriptionId, mapOf(RelayUrlNormalizer.normalize(relay) to filters), idleTimeoutMs)
suspend fun INostrClient.fetchAll(
relay: NormalizedRelayUrl,
filter: Filter,
timeoutMs: Long = 30_000L,
) = fetchAll(newSubId(), mapOf(relay to listOf(filter)), timeoutMs)
idleTimeoutMs: Long = 30_000L,
) = fetchAll(newSubId(), mapOf(relay to listOf(filter)), idleTimeoutMs)
suspend fun INostrClient.fetchAll(
relay: NormalizedRelayUrl,
filters: List<Filter>,
timeoutMs: Long = 30_000L,
) = fetchAll(newSubId(), mapOf(relay to filters), timeoutMs)
idleTimeoutMs: Long = 30_000L,
) = fetchAll(newSubId(), mapOf(relay to filters), idleTimeoutMs)
suspend fun INostrClient.fetchAll(
subscriptionId: String = newSubId(),
relay: NormalizedRelayUrl,
filters: List<Filter>,
timeoutMs: Long = 30_000L,
) = fetchAll(subscriptionId, mapOf(relay to filters), timeoutMs)
idleTimeoutMs: Long = 30_000L,
) = fetchAll(subscriptionId, mapOf(relay to filters), idleTimeoutMs)
/**
* Subscribe [filters], collect every (deduped) event, and return once every
* relay reached a terminal state (EOSE, CLOSED, or cannot-connect) or the
* line went quiet for [timeoutMs].
* line went quiet for [idleTimeoutMs].
*
* [timeoutMs] is an **idle window, not a hard cap**: every arriving event or
* [idleTimeoutMs] is an **idle window, not a hard cap**: every arriving event or
* terminal signal resets it, so a slow relay actively streaming a large
* backlog is never cropped mid-delivery. The fetch only gives up after a full
* window of silence — or at the [maxTotalMs] wall-clock ceiling (default 10x
@@ -85,13 +85,13 @@ suspend fun INostrClient.fetchAll(
suspend fun INostrClient.fetchAll(
subscriptionId: String = newSubId(),
filters: Map<NormalizedRelayUrl, List<Filter>>,
timeoutMs: Long = 30_000L,
maxTotalMs: Long = timeoutMs * 10,
idleTimeoutMs: Long = 30_000L,
maxTotalMs: Long = idleTimeoutMs * 10,
): List<Event> {
val seenIds = mutableSetOf<HexKey>()
return fetchAllWithHooks(
filters = filters,
timeoutMs = timeoutMs,
idleTimeoutMs = idleTimeoutMs,
subscriptionId = subscriptionId,
maxTotalMs = maxTotalMs,
) { _, event -> seenIds.add(event.id) }
@@ -30,7 +30,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.coroutines.coroutineContext
/**
@@ -72,14 +71,28 @@ import kotlin.coroutines.coroutineContext
*
* @param relay The relay to query.
* @param filters Filters to apply on every page (the `until` field is overwritten per page).
* @param timeoutMs Maximum time to wait for a single page's EOSE before giving up.
* @param idleTimeoutMs Idle window per page — like every accessory timeout, it is measured
* from the relay's **most recent message**, not from the page's start: every arriving
* event resets it, so a slow relay actively streaming a large page is never cropped
* mid-delivery. A page only gives up after this much silence without an EOSE.
*
* Deliberately no wall-clock ceiling here, unlike [fetchAll]'s `maxTotalMs`. A ceiling
* would bound one *page*, not this call: the loop below reacts to a page ending by
* advancing the cursor and issuing the next REQ, so a relay trickling events forever
* against an unbounded filter would just be re-paged forever — measurably so (see
* NostrClientFetchAllPagesIdleTimeoutTest). Worse, cutting a page mid-stream advances
* `until` to the oldest event received *so far*, which only preserves the set if the
* relay streams strictly newest-first (NIP-01 recommends but does not require it) —
* otherwise the not-yet-sent events above that cursor are skipped. What actually
* bounds this walk is a [Filter.limit] (the documented way to cap a download) or
* cancelling the caller, which the [ensureActive] at the top of each page honors.
* @param onEvent Called once for every distinct event delivered, in page order.
* @return Total number of distinct events delivered across all pages.
*/
suspend fun INostrClient.fetchAllPages(
relay: NormalizedRelayUrl,
filters: List<Filter>,
timeoutMs: Long = 30_000L,
idleTimeoutMs: Long = 30_000L,
onNewPage: ((Long) -> Unit)? = null,
onEvent: (Event) -> Unit,
): Int {
@@ -144,6 +157,11 @@ suspend fun INostrClient.fetchAllPages(
val doneChannel = Channel<Unit>(Channel.CONFLATED)
// Idle watchdog for this page: every arriving event bumps it, so the page's
// timeout measures silence since the relay's most recent message (the same
// convention as fetchAll and the negentropy sync), never total page time.
val clock = IdleClock()
// Captured for the listener: the boundary second we re-fetch this page.
val boundary = until
var received = 0
@@ -160,39 +178,62 @@ suspend fun INostrClient.fetchAllPages(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
received++
// Drop a boundary-second event we already delivered on an
// earlier page (the inclusive re-fetch returns it again).
if (boundary != null && event.createdAt == boundary && event.id in seenAtBoundary) return
// The bump is in a finally so it runs for EVERY event —
// including the duplicate that returns early below, which is
// still a sign of life — and, being a volatile write, runs
// AFTER the counters below. That ordering matters: these
// counters are written on the relay's reader thread and read
// by the driver coroutine once the wait ends. The EOSE path
// gets its happens-before from the channel, but the idle
// path has no such edge, so without the release write the
// driver could read a stale `pageMinTs` (ending the walk
// early) or an unsafely published `idsAtPageMin`.
try {
received++
// Drop a boundary-second event we already delivered on an
// earlier page (the inclusive re-fetch returns it again).
if (boundary != null && event.createdAt == boundary && event.id in seenAtBoundary) return
// Count this event against every active filter it satisfies
// (one event can match more than one). Only a non-search filter
// may advance the `until` cursor: a search hit — possibly old,
// relevance-ranked — must not drag the cursor back and make the
// next page skip events a co-resident normal filter still needs.
var atLeastOne = false
var advancesCursor = false
for ((index, filter) in activeFilters) {
if (matchCountPerFilter[index] < (filter.limit ?: Int.MAX_VALUE) && filter.match(event)) {
matchCountPerFilter[index]++
atLeastOne = true
if (filter.search == null) advancesCursor = true
}
}
if (atLeastOne) {
onEvent(event)
delivered++
// Track the oldest advancing second and the ids delivered
// in it — that becomes the next boundary and its dedup set.
if (advancesCursor) {
if (event.createdAt < pageMinTs) {
pageMinTs = event.createdAt
idsAtPageMin.clear()
idsAtPageMin.add(event.id)
} else if (event.createdAt == pageMinTs) {
idsAtPageMin.add(event.id)
// Count this event against every active filter it satisfies
// (one event can match more than one). Only a non-search filter
// may advance the `until` cursor: a search hit — possibly old,
// relevance-ranked — must not drag the cursor back and make the
// next page skip events a co-resident normal filter still needs.
var atLeastOne = false
var advancesCursor = false
// Indexed loop, not `for ((i, f) in activeFilters)`: this runs for
// EVERY event on the relay's reader thread (millions in a bulk
// download) and the destructuring form allocates an Iterator per
// event. Same reason quartz uses the `fast*` operators elsewhere
// in hot event paths — those only cover Array, so a List needs
// the index form.
for (i in activeFilters.indices) {
val active = activeFilters[i]
val index = active.index
val filter = active.value
if (matchCountPerFilter[index] < (filter.limit ?: Int.MAX_VALUE) && filter.match(event)) {
matchCountPerFilter[index]++
atLeastOne = true
if (filter.search == null) advancesCursor = true
}
}
if (atLeastOne) {
onEvent(event)
delivered++
// Track the oldest advancing second and the ids delivered
// in it — that becomes the next boundary and its dedup set.
if (advancesCursor) {
if (event.createdAt < pageMinTs) {
pageMinTs = event.createdAt
idsAtPageMin.clear()
idsAtPageMin.add(event.id)
} else if (event.createdAt == pageMinTs) {
idsAtPageMin.add(event.id)
}
}
}
} finally {
clock.bump()
}
}
@@ -222,9 +263,10 @@ suspend fun INostrClient.fetchAllPages(
subscribe(subId, mapOf(relay to activeFilters.map { it.value }), listener)
withTimeoutOrNull(timeoutMs) {
doneChannel.receive()
}
// Wait for the page's terminal signal (EOSE / CLOSED / cannot-connect),
// giving up only after [idleTimeoutMs] of silence — the wait resets on every
// arriving event, so an actively streaming page is never cut mid-delivery.
doneChannel.receiveWithinIdle(clock, idleTimeoutMs)
unsubscribe(subId)
doneChannel.close()
@@ -276,14 +318,14 @@ suspend fun INostrClient.fetchAllPages(
suspend fun INostrClient.fetchAllPages(
relay: String,
filters: List<Filter>,
timeoutMs: Long = 30_000L,
idleTimeoutMs: Long = 30_000L,
onNewPage: ((Long) -> Unit)? = null,
onEvent: (Event) -> Unit,
): Int =
fetchAllPages(
relay = RelayUrlNormalizer.normalize(relay),
filters = filters,
timeoutMs = timeoutMs,
idleTimeoutMs = idleTimeoutMs,
onNewPage = onNewPage,
onEvent = onEvent,
)
@@ -50,7 +50,10 @@ import kotlinx.coroutines.sync.Semaphore
* @param filters per-relay filter lists; the key set is the relays queried, in
* iteration order (pass a [LinkedHashMap]/`associateWith` result to control it).
* A `search` filter is fetched as a single relevance page — see [fetchAllPages].
* @param timeoutMs per-page EOSE timeout handed to each relay's [fetchAllPages].
* @param idleTimeoutMs per-page idle window handed to each relay's [fetchAllPages]
* measured from that relay's most recent message (every event resets it), not
* from the page's start. As in [fetchAllPages] there is no wall-clock ceiling;
* bound a relay's walk with a [Filter.limit], or cancel the caller.
* @param maxConcurrentRelays upper bound on relays paginating at once (≥ 1).
* @param onNewPage optional `(until, relay)` tick before each non-first page.
* @param onRelayStart optional hook fired as each relay's download begins.
@@ -60,7 +63,7 @@ import kotlinx.coroutines.sync.Semaphore
*/
suspend fun INostrClient.fetchAllPagesFromPool(
filters: Map<NormalizedRelayUrl, List<Filter>>,
timeoutMs: Long = 30_000L,
idleTimeoutMs: Long = 30_000L,
maxConcurrentRelays: Int = 8,
onNewPage: ((until: Long, relay: NormalizedRelayUrl) -> Unit)? = null,
onRelayStart: ((relay: NormalizedRelayUrl) -> Unit)? = null,
@@ -80,7 +83,7 @@ suspend fun INostrClient.fetchAllPagesFromPool(
fetchAllPages(
relay = relay,
filters = filtersForRelay,
timeoutMs = timeoutMs,
idleTimeoutMs = idleTimeoutMs,
onNewPage = onNewPage?.let { cb -> { until -> cb(until, relay) } },
) { event -> onEvent(event, relay) }
onRelayComplete?.invoke(relay, total)
@@ -41,13 +41,13 @@ import kotlinx.coroutines.withTimeoutOrNull
* funnel every arriving event through the suspending [onEvent] hook (verify /
* persist / filter — return `true` to keep it in the result), and return the
* accepted `(relay, event)` pairs once every relay reached a terminal state
* (EOSE, CLOSED, or cannot-connect) or the line went quiet for [timeoutMs].
* (EOSE, CLOSED, or cannot-connect) or the line went quiet for [idleTimeoutMs].
*
* [timeoutMs] is an **idle window, not a hard cap**: the clock only runs while
* [idleTimeoutMs] is an **idle window, not a hard cap**: the clock only runs while
* the relays are silent, and every arriving event or terminal signal resets
* it. A slow relay actively streaming a large backlog is therefore never
* cropped mid-delivery — the fetch ends when the work is done or when nothing
* has arrived for [timeoutMs] (a stall). The terminal conditions (EOSE /
* has arrived for [idleTimeoutMs] (a stall). The terminal conditions (EOSE /
* CLOSED / cannot-connect per relay) are what bound the fetch; the timeout's
* only job is detecting relays that will never reach one. [maxTotalMs]
* (default 10x the idle window) is the wall-clock ceiling that keeps a
@@ -62,20 +62,20 @@ import kotlinx.coroutines.withTimeoutOrNull
* as a hard failure via [classifyDrainFailure] (connect refused / DNS / TLS /
* dead HTTP upgrade — NOT slow relays or 429s) is recorded, so callers can
* prune proven-dead relays from future routing instead of paying the full
* [timeoutMs] on them again.
* [idleTimeoutMs] on them again.
* - **[pendingOnAuthRequired]** — a relay that refuses the REQ with an
* `auth-required:` CLOSED is kept pending rather than treated as terminal:
* the caller's NIP-42 responder answers the challenge and the client re-fires
* this same subscription, so the post-auth events are collected instead of
* returning empty. If auth never satisfies it, the relay simply falls through
* to the [timeoutMs].
* to the [idleTimeoutMs].
* - **[onTimeout]** — diagnostic hook fired when the idle window elapsed with
* relays still pending: receives the stalled set, the terminal reasons seen so
* far (`"eose"` / `"closed:<msg>"` / `"cannot:<msg>"`), and what was collected.
*/
suspend fun INostrClient.fetchAllWithHooks(
filters: Map<NormalizedRelayUrl, List<Filter>>,
timeoutMs: Long = 8_000L,
idleTimeoutMs: Long = 8_000L,
subscriptionId: String = newSubId(),
pendingOnAuthRequired: Boolean = false,
deadOut: MutableMap<NormalizedRelayUrl, DrainFailure>? = null,
@@ -86,9 +86,11 @@ suspend fun INostrClient.fetchAllWithHooks(
* adversarial or misbehaving relay could pin the caller forever. The cap
* restores an upper bound while staying far above the idle window, so a
* legitimately streaming relay still finishes its backlog. Pass
* [Long.MAX_VALUE] for a deliberately uncapped drain.
* [Long.MAX_VALUE] for a deliberately uncapped drain; a non-positive value
* also uncaps (absorbing an `idleTimeoutMs * 10` overflow from an
* effectively-infinite idle window).
*/
maxTotalMs: Long = timeoutMs * 10,
maxTotalMs: Long = idleTimeoutMs * 10,
onEvent: suspend (relay: NormalizedRelayUrl, event: Event) -> Boolean,
): List<Pair<NormalizedRelayUrl, Event>> {
if (filters.isEmpty()) return emptyList()
@@ -144,7 +146,7 @@ suspend fun INostrClient.fetchAllWithHooks(
coroutineScope {
subscribe(subscriptionId, filters, listener)
val watchdog =
if (maxTotalMs == Long.MAX_VALUE) {
if (maxTotalMs <= 0 || maxTotalMs == Long.MAX_VALUE) {
null
} else {
launch {
@@ -188,7 +190,7 @@ suspend fun INostrClient.fetchAllWithHooks(
// Slow path: both dry — arm one idle wait for the next signal.
if (pending == null) {
val progressed =
withTimeoutOrNull(timeoutMs) {
withTimeoutOrNull(idleTimeoutMs) {
select<Unit> {
eventChannel.onReceive { pending = it }
doneChannel.onReceive { (relay, reason) ->
@@ -254,7 +256,7 @@ suspend fun INostrClient.fetchAllWithHooks(
*/
suspend fun INostrClient.fetchAllPagesFromPoolWithHooks(
filters: Map<NormalizedRelayUrl, List<Filter>>,
timeoutMs: Long = 30_000L,
idleTimeoutMs: Long = 30_000L,
maxConcurrentRelays: Int = 8,
onEvent: suspend (relay: NormalizedRelayUrl, event: Event) -> Boolean,
): List<Pair<NormalizedRelayUrl, Event>> {
@@ -285,7 +287,7 @@ suspend fun INostrClient.fetchAllPagesFromPoolWithHooks(
try {
fetchAllPagesFromPool(
filters = filters,
timeoutMs = timeoutMs,
idleTimeoutMs = idleTimeoutMs,
maxConcurrentRelays = maxConcurrentRelays,
) { event, relay -> eventChannel.trySend(relay to event) }
} finally {
@@ -29,8 +29,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.coroutines.coroutineContext
suspend fun INostrClient.fetchFirst(
relay: String,
@@ -64,10 +66,29 @@ suspend fun INostrClient.fetchFirst(
filters: List<Filter>,
) = fetchFirst(subscriptionId, mapOf(relay to filters))
/**
* Subscribe [filters], return the first event any relay delivers (or `null` when
* every relay reached a terminal state — EOSE, CLOSED, or cannot-connect — with
* nothing matching, or the line went quiet).
*
* [idleTimeoutMs] is an **idle window measured from the most recent progress**, not a
* wall-clock deadline — the package-wide accessory convention. Progress means a
* signal that actually advances the fetch: an event, or the first terminal state
* from a relay still being waited on. Repeat chatter from a relay already
* accounted for (a CLOSED/reconnect loop) is *not* progress and does not restart
* the window — the same rule the negentropy watchdog applies to NOTICE/CLOSED
* error chatter, and what keeps a flapping relay from holding this open forever.
*
* That makes the call self-bounding: at most one progress signal per relay, each
* granting a fresh window. There is deliberately no ceiling parameter — a caller
* who wants a hard wall-clock bound already has one in
* `withTimeoutOrNull(ms) { fetchFirst(...) }`, which costs nothing here since a
* timed-out fetch returns `null` either way.
*/
suspend fun INostrClient.fetchFirst(
subscriptionId: String = newSubId(),
filters: Map<NormalizedRelayUrl, List<Filter>>,
timeoutMs: Long = 30_000L,
idleTimeoutMs: Long = 30_000L,
): Event? {
val eventChannel = Channel<Event>(UNLIMITED)
val doneChannel = Channel<NormalizedRelayUrl>(UNLIMITED)
@@ -112,29 +133,55 @@ suspend fun INostrClient.fetchFirst(
try {
subscribe(subscriptionId, filters, listener)
withTimeoutOrNull(timeoutMs) {
while (remaining.isNotEmpty()) {
select {
eventChannel.onReceive { event ->
result = event
remaining.clear()
}
doneChannel.onReceive { relay ->
// A relay sends its matching events before its EOSE, so an event may
// already be buffered when this completion fires. select() picks a ready
// clause at random, so without this drain we could treat the relay as done
// and exit while its event still sits unread in the channel.
val buffered = eventChannel.tryReceive().getOrNull()
if (buffered != null) {
result = buffered
remaining.clear()
} else {
remaining.remove(relay)
}
// One idle window per unit of progress. The inner loop keeps consuming
// non-progress signals INSIDE the same window, so repeat chatter from an
// already-accounted-for relay cannot push the deadline out; only a real
// advance escapes to the outer loop and earns a fresh window.
while (remaining.isNotEmpty()) {
val progressed =
withTimeoutOrNull(idleTimeoutMs) {
while (true) {
// Cancellation (this window expiring, or the caller giving up)
// only lands at a suspension point, and select() completes
// without suspending while either channel has something
// buffered — so check explicitly rather than draining a
// backlog of chatter uninterruptibly.
coroutineContext.ensureActive()
val advanced =
select<Boolean> {
eventChannel.onReceive { event ->
result = event
remaining.clear()
true
}
doneChannel.onReceive { relay ->
// A relay sends its matching events before its EOSE, so an event may
// already be buffered when this completion fires. select() picks a ready
// clause at random, so without this drain we could treat the relay as done
// and exit while its event still sits unread in the channel.
val buffered = eventChannel.tryReceive().getOrNull()
if (buffered != null) {
result = buffered
remaining.clear()
true
} else {
// Only the FIRST terminal signal from a relay we are still
// waiting on advances the fetch; a repeat is chatter.
remaining.remove(relay)
}
}
}
if (advanced) break
}
true
}
}
if (progressed == null) break
}
// An event can land after the last terminal signal but before we
// unsubscribe; without this drain it would be dropped and the fetch
// would report "nothing found" while holding a match.
if (result == null) result = eventChannel.tryReceive().getOrNull()
} finally {
unsubscribe(subscriptionId)
eventChannel.close()
@@ -47,14 +47,12 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.concurrent.Volatile
import kotlin.concurrent.atomics.AtomicInt
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.concurrent.atomics.decrementAndFetch
import kotlin.concurrent.atomics.incrementAndFetch
import kotlin.coroutines.coroutineContext
import kotlin.math.min
import kotlin.time.TimeSource
/**
* Outcome of a successful [negentropySync] run.
@@ -1180,52 +1178,4 @@ internal val KEEP_ALIVE_ID = "f".repeat(64)
* still honor "run until the socket drops".
*/
private const val DEFAULT_CONNECT_TIMEOUT_MS = 30_000L
private const val DEFAULT_DOWNLOAD_IDLE_MS = 60_000L
/**
* Monotonic "last activity" marker for the idle watchdog. [bump] on every sign of
* life from the relay; [elapsedMs] reports the silence since the last bump.
*
* [bump] is on the per-event hot path (the connection listener bumps for every
* message the relay sends — millions during a large download), so it must not
* allocate: a single [start] mark is taken once (unboxed field) and each bump only
* writes a `Long` of nanos-since-start into a `@Volatile` field. Reader threads
* write, the driver coroutine reads — visibility is all we need, so a plain volatile
* Long beats boxing a `ValueTimeMark` into an `AtomicReference` on every event.
*/
private class IdleClock {
private val start = TimeSource.Monotonic.markNow()
@Volatile
private var lastNanos = 0L
fun bump() {
lastNanos = start.elapsedNow().inWholeNanoseconds
}
fun elapsedMs(): Long = (start.elapsedNow().inWholeNanoseconds - lastNanos) / 1_000_000
}
/**
* Receives the next item, giving up (returning `null`) only after [idleMs] elapse with
* no activity on [clock]. Because [clock] is bumped by *any* relay message — not just
* items on this channel — unrelated progress (e.g. download events arriving during a
* reconcile wait) keeps pushing the deadline out. [idleMs] `<= 0` disables the
* watchdog: it waits until an item arrives (a disconnect is delivered as an item, so
* a dead socket still unblocks it).
*/
private suspend fun <T> Channel<T>.receiveWithinIdle(
clock: IdleClock,
idleMs: Long,
): T? {
if (idleMs <= 0) return receive()
while (true) {
val remaining = idleMs - clock.elapsedMs()
if (remaining <= 0) return null
val item = withTimeoutOrNull(remaining) { receive() }
if (item != null) return item
// Timed out with nothing on this channel. If other activity bumped the clock
// meanwhile, the next `remaining` is positive and we wait again; otherwise it
// is <= 0 on the next iteration and we give up.
}
}
internal const val DEFAULT_DOWNLOAD_IDLE_MS = 60_000L
@@ -34,6 +34,7 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.time.TimeSource
/**
* One relay's verdict on a published event: [accepted] plus the reason the
@@ -45,6 +46,12 @@ import kotlinx.coroutines.withTimeoutOrNull
class PublishResult(
val accepted: Boolean,
val message: String,
/**
* Milliseconds from the publish to this relay's OK (true or false — a rejection
* is still a measured round trip), or -1 when the relay never answered with an
* OK. On an already-open socket this is an honest NIP-66 `rtt-write`.
*/
val elapsedMs: Long = -1,
) {
/**
* True when this failure came from the transport (never connected,
@@ -102,6 +109,7 @@ suspend fun INostrClient.publishAndCollectResults(
timeoutInSeconds: Long = 15,
): Map<NormalizedRelayUrl, PublishResult> {
val resultChannel = Channel<DetailedResult>(UNLIMITED)
val mark = TimeSource.Monotonic.markNow()
Log.d("publishAndConfirm") { "Waiting for ${relayList.size} responses" }
@@ -133,8 +141,13 @@ suspend fun INostrClient.publishAndCollectResults(
when (msg) {
is OkMessage -> {
if (msg.eventId == event.id) {
resultChannel.trySend(DetailedResult(relay.url, msg.success, msg.message))
// The relayList guard matters, not just the id: the same event may
// have been published to OTHER relays by an earlier call (probe
// waves, republish), and counting their late OKs here would inflate
// receivedResults and end the wait loop before every listed relay
// answered — misreporting the missing ones as NO_RESPONSE.
if (msg.eventId == event.id && relay.url in relayList) {
resultChannel.trySend(DetailedResult(relay.url, msg.success, msg.message, mark.elapsedNow().inWholeMilliseconds))
Log.d("publishAndConfirm") { "onSendResponse Received response for ${msg.eventId} from relay ${relay.url} message ${msg.message} success ${msg.success}" }
}
}
@@ -160,7 +173,7 @@ suspend fun INostrClient.publishAndCollectResults(
val currentResult = receivedResults[result.relay]
// do not override a successful result.
if (currentResult == null || !currentResult.accepted) {
receivedResults[result.relay] = PublishResult(result.success, result.message)
receivedResults[result.relay] = PublishResult(result.success, result.message, result.elapsedMs)
}
}
}
@@ -191,4 +204,5 @@ private class DetailedResult(
val relay: NormalizedRelayUrl,
val success: Boolean,
val message: String,
val elapsedMs: Long = -1,
)
@@ -12,14 +12,56 @@ count, negentropy sync/reconcile) already exists.
Import as `com.vitorpamplona.quartz.nip01Core.relay.client.accessories.<name>` (or
`...client.reqs.<name>` for the flow/subscribe helpers).
## Timeout convention
Every wait in this package is an **idle window measured from the relay's most recent
progress**, never a wall-clock deadline: real progress resets it, so an actively
streaming relay is never cut off mid-delivery — the operation only gives up after a
full window of silence. The shared primitives are in `IdleWatchdog.kt` (`IdleClock` +
`receiveWithinIdle`); use them in a new accessory.
**The parameter is named `idleTimeoutMs`, never `timeoutMs`** — the name is the
contract, so a caller can't mistake it for a deadline. The sole exception is
`publishAndConfirm`'s `timeoutInSeconds`, which genuinely *is* a fixed window (see
below); the differing name is the tell.
**Progress, not merely traffic.** A message that tells us nothing new — a relay
re-CLOSEing after we already recorded it as done, a duplicate COUNT — must not
restart the window, or a flapping relay keeps the call alive indefinitely. This is
the rule the negentropy watchdog already applies to `NOTICE`/`CLOSED` chatter, and
it is what makes `fetchFirst` and multi-relay `count` self-bounding: at most one
window per relay.
**No accessory takes a wall-clock ceiling parameter.** A hard bound composes at the
call site — `withTimeoutOrNull(ms) { fetchFirst(...) }` — so duplicating it in every
signature buys nothing. Prefer the idle window inside (the caller cannot implement
it; it needs the message stream) and the wall clock outside. Two consequences worth
knowing:
- `fetchAllPages` has no ceiling and could not usefully have one. A per-page cap
bounds a *page*, not the call: the loop reacts to a page ending by advancing the
cursor and issuing the next `REQ`, so an endless trickle is just re-paged (a
400 ms cap measured 8 `REQ`s and no return). It also makes truncation unsafe —
cutting a page mid-stream advances `until` to the oldest event received *so far*,
which only preserves the set if the relay streams strictly newest-first, which
NIP-01 recommends but does not require. Bound a paged download with the filter's
`limit`, or by cancelling.
- `fetchAll` / `fetchAllWithHooks` keep a pre-existing `maxTotalMs`, and it earns
its place: an endless *event* trickle there is genuine progress, so the call never
self-terminates, and the internal cap returns the events collected so far where an
external `withTimeoutOrNull` would discard them.
The write side is its own case: `publishAndConfirm`'s `timeoutInSeconds` is a fixed
window to collect the `OK`s — a bounded confirmation round-trip, not a stream.
## One-shot reads (subscribe → collect → return)
| Function | File | Use when |
| --- | --- | --- |
| `fetchAll(relay, filter, timeoutMs)` | `NostrClientFetchAllExt` | Get every event matching a filter in one REQ, deduped by id, until EOSE or timeout. **No verify, no store** — just the events. |
| `fetchFirst(relay, filter, timeoutMs)` | `NostrClientFetchFirstExt` | Get the first matching event and stop (returns `null` on none/timeout). |
| `fetchAllPages(relay, filters, timeoutMs)` | `NostrClientFetchAllPagesExt` | Fully retrieve a result set larger than the relay's per-REQ cap (strfry `limit`, ~500) by walking a `created_at` cursor. Bound it with the filter's `limit`. |
| `fetchAllPagesFromPool(filters, ...)` | `NostrClientFetchAllPagesPoolExt` | Same paging, across several relays at once, deduped across them. |
| `fetchAll(relay, filter, idleTimeoutMs)` | `NostrClientFetchAllExt` | Get every event matching a filter in one REQ, deduped by id, until EOSE or a full idle window of silence. **No verify, no store** — just the events. |
| `fetchFirst(relay, filter, idleTimeoutMs)` | `NostrClientFetchFirstExt` | Get the first matching event and stop (returns `null` on none/timeout). |
| `fetchAllPages(relay, filters, idleTimeoutMs)` | `NostrClientFetchAllPagesExt` | Fully retrieve a result set larger than the relay's per-REQ cap (strfry `limit`, ~500) by walking a `created_at` cursor. Bound it with the filter's `limit`. |
| `fetchAllPagesFromPool(filters, ...)` | `NostrClientFetchAllPagesPoolExt` | Same paging, across several relays at once. No cross-relay dedup — the `WithHooks` variant below dedups. |
| `fetchAllWithHooks(filters, ...)` | `NostrClientFetchAllWithHooksExt` | `fetchAll` with a suspending per-`(relay, event)` accept hook (verify+store as events arrive), per-relay terminal-reason tracking, optional dead-relay collection (`deadOut` + `classifyDrainFailure`), keep-pending-on-`auth-required` CLOSED (NIP-42 re-fire), and a timeout diagnostic hook. |
| `fetchAllPagesFromPoolWithHooks(filters, ...)` | `NostrClientFetchAllWithHooksExt` | `fetchAllPagesFromPool` with the same suspending accept hook, run single-threaded in one consumer; deduped across relays by `SeenIds` before the hook. |
@@ -42,7 +84,7 @@ Import as `com.vitorpamplona.quartz.nip01Core.relay.client.accessories.<name>` (
| Function | File | Use when |
| --- | --- | --- |
| `count(relay, filter, timeoutMs)` | `NostrClientCountExt` | NIP-45 `COUNT` against one relay (`null` on timeout / no support). |
| `count(relay, filter, idleTimeoutMs)` | `NostrClientCountExt` | NIP-45 `COUNT` against one relay (`null` on timeout / no support). |
| `countMerged(relays, filter, ...)` | `NostrClientCountExt` | Merged count across relays. |
## Negentropy (NIP-77)
@@ -0,0 +1,482 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay.client.accessories
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap
/**
* How much of a filter's history has already been pulled from one relay, so a
* restart does not pull it again.
*
* A negentropy relay needs none of this — reconciliation downloads only the
* diff. Most relays lack NIP-77, and a paged fetch ([fetchAllPages]) has no
* memory: it re-downloads everything it walked last time, every restart,
* forever. So for those, remember the band of `created_at` covered per
* (relay, filter), and the next run asks only for the two legs outside it:
*
* stored band: |<-------- covered -------->|
* next fetch: <------| |------>
*
* Keyed by the WHOLE filter deliberately: any edit to a filter is a new key
* with no band, so the next run starts over — the safe direction to be wrong
* in, and the intended way to force a re-walk.
*
* A band does not guarantee completeness (a truncating relay, an event
* back-dated into a walked span). The trade is deliberate: re-reading a
* corpus on every restart is a certain daily cost, while both holes are
* occasional and self-heal on the next filter change or full re-walk.
*
* Persistence is the caller's: [export] the map on a schedule and [restore]
* it at startup. [onChange] fires whenever a band changes, so a persistence
* layer can mark itself dirty without polling.
*
* Not to be confused with the `relay.client.paging` package: its
* `RelayLoadingCursors` are in-memory POSITIONS for demand-driven UI paging
* within one session, while these are persistent INTERVALS — a claim about
* coverage that outlives the process and licenses skipping work.
*/
class SyncCoverage(
// How long a band may narrow work before the whole filter is walked
// again. Everything a band claims is a claim about the past; this is how
// long to trust it without re-testing.
private val fullResyncSeconds: Long = DEFAULT_FULL_RESYNC_SECONDS,
private val now: () -> Long = { TimeUtils.now() },
private val onChange: () -> Unit = {},
) {
/** A covered `created_at` interval, inclusive at both ends. */
data class Span(
val min: Long,
val max: Long,
) {
fun widen(other: Span) = Span(minOf(min, other.min), maxOf(max, other.max))
}
/**
* What is already covered for one (relay, filter) pair.
*
* [spans] is PER KIND, and that is the whole point of it. A band used to
* hold one interval for the entire filter, which is a claim no multi-kind
* walk can support: ask for `kinds: [0, 30382]`, see profiles back to 2020
* and score cards only from 2025, and the band reads 2020..2026 — so the
* next run skips 2020..2025 for BOTH, and the score cards in that interior
* are never asked for again. A long-lived kind vouched for a short-lived
* one. Per kind, each carries only the evidence actually collected for it.
*
* Filters that name no kinds at all cannot be split, so they keep a single
* span under [ALL_KINDS] — the same claim as before, correctly scoped to
* the case where it is the only claim available.
*
* [complete] is the difference between "we walked this span" (a paged
* fetch) and "we are in sync below this point" (a finished negentropy
* reconcile, which compared the whole range). Only a complete band may
* skip its older leg. It is a property of the BAND rather than of a span:
* a reconcile compares the filter's whole id set at once, so it either
* covers every kind in it or none.
*
* [fullAt] is when the last pass that started from nothing finished — the
* clock for the periodic re-walk.
*/
data class Band(
val spans: Map<Int, Span>,
val complete: Boolean = false,
val fullAt: Long = 0,
) {
/** The outer edges across every kind — for logging and for the file's compatibility fields. */
val minCreatedAt: Long get() = spans.values.minOfOrNull { it.min } ?: 0
val maxCreatedAt: Long get() = spans.values.maxOfOrNull { it.max } ?: 0
/** Widen each kind by its counterpart, keeping kinds only one side knows. */
fun widen(other: Band): Band {
val merged = spans.toMutableMap()
for ((kind, span) in other.spans) merged[kind] = merged[kind]?.widen(span) ?: span
return Band(merged, complete || other.complete, fullAt)
}
}
private val bands = ConcurrentMap<String, Band>()
// filter -> its canonical json. Filter.toJson() runs to tens of thousands
// of characters for author-scoped filters, and a fan-out keys once per
// relay per cycle over the SAME handful of filter instances. Filter
// compares by identity, so this map is an identity cache — and an
// identity cache retains every distinct instance it is handed. A caller
// that rebuilds its filter each cycle would grow it forever, so past
// MAX_FINGERPRINTS new instances key correctly but are not cached.
private val fingerprints = ConcurrentMap<Filter, String>()
/**
* The filters to actually run now, given what is already covered: the
* whole filter when nothing is recorded (or the band went stale),
* otherwise the legs outside the band, clamped to the filter's own
* `since`/`until`.
*
* The legs are INCLUSIVE of the band's edges (`until = min`, not
* `min - 1`): a page boundary can split a run of events sharing one
* `created_at`, and excluding the edge would strand the rest of that
* second in no leg at all. The cost is re-reading one second's worth of
* events per leg, which a store rejects as duplicates.
*/
fun legs(
url: NormalizedRelayUrl,
filter: Filter,
floor: Long? = null,
): List<Filter> {
val band = bands[key(url, filter)] ?: return listOf(filter)
// Time for another full pass: relays gain old events, and without
// this the band's claim is never re-tested.
if (isStale(band)) return listOf(filter)
if (band.spans.isEmpty()) return listOf(filter)
val kinds = filter.kinds
if (kinds.isNullOrEmpty()) {
// Nothing to split by. One span, exactly as before.
return windows(filter, band.spans[ALL_KINDS], band.complete, floor)
.map { (since, until) -> filter.copy(since = since, until = until) }
}
// Per kind, then REGROUPED by the windows each one wants. Kinds whose
// coverage agrees — the overwhelmingly common case, and the only case
// at all until they diverge — collapse back into one ask, so a filter
// that used to produce two legs still produces two rather than two per
// kind. Only a kind whose evidence genuinely differs earns its own.
val byWindows = LinkedHashMap<List<Pair<Long?, Long?>>, MutableList<Int>>()
for (kind in kinds) {
// ALL_KINDS as the fallback: a band written before coverage was
// tracked per kind, restored from such a file. It carries the old,
// wider claim for every kind — the behaviour this replaces — and
// self-corrects on the first paged walk that reports per kind.
val span = band.spans[kind] ?: band.spans[ALL_KINDS]
byWindows.getOrPut(windows(filter, span, band.complete, floor)) { mutableListOf() }.add(kind)
}
return byWindows.flatMap { (windows, group) ->
// toList(): `group` is the mutable accumulator above, and handing
// the same instance to every Filter in the group would publish it
// through a public return value. Filters are treated as immutable
// everywhere else; this keeps that true by construction.
val kindsForGroup = group.toList()
windows.map { (since, until) -> filter.copy(kinds = kindsForGroup, since = since, until = until) }
}
}
/**
* The `(since, until)` pairs still outstanding for ONE span — the leg
* arithmetic, with the filter's own bounds applied and nothing else.
* A null [span] means no evidence at all, so the whole filter is wanted.
*/
private fun windows(
filter: Filter,
span: Span?,
complete: Boolean,
floor: Long?,
): List<Pair<Long?, Long?>> {
if (span == null) return listOf(filter.since to filter.until)
val out = mutableListOf<Pair<Long?, Long?>>()
// Older: up to and including the span's floor, but not past the
// filter's (or, when the filter has no `since`, the caller's [floor] —
// a sync window the filter itself must not carry, or it would change
// the band's key every run). A complete band compared its whole range
// already, but only down to the floor it ran against: a caller now
// reaching deeper — a raised backfill window — re-opens the span below.
val since = filter.since ?: floor
val wantsOlder =
if (complete) {
since != null && since < span.min
} else {
since == null || span.min >= since
}
if (wantsOlder) out.add(filter.since to minOf(span.min, filter.until ?: Long.MAX_VALUE))
// Newer: from the span's ceiling on, but not past the filter's.
if (filter.until == null || span.max <= filter.until) {
out.add(maxOf(span.max, filter.since ?: Long.MIN_VALUE) to filter.until)
}
return out
}
/**
* Widen the band for (url, filter) to include what a completed fetch saw.
*
* [paged] gates the mechanism: a negentropy sync needs no band, and
* recording one would only risk narrowing a future reconciliation.
* Nothing is recorded for a fetch that saw no events — an empty result
* says nothing about what the relay holds.
*
* [reconciledThrough] is the strong case: a FINISHED reconcile compared
* the filter's whole range, so the caller is in sync up to the instant
* the sync STARTED — recorded against that instant rather than the newest
* event seen, because "the relay had nothing newer" and "we never asked"
* must not look alike.
*/
fun record(
url: NormalizedRelayUrl,
filter: Filter,
observedMin: Long?,
observedMax: Long?,
paged: Boolean,
reconciledThrough: Long? = null,
observedByKind: Map<Int, Span>? = null,
) {
if (reconciledThrough != null) {
// A reconcile compares the filter's whole id set in one pass, so
// the span it earns is the same for every kind the filter names —
// no per-kind evidence needed or possible.
val span = Span(observedMin ?: reconciledThrough, reconciledThrough)
put(url, filter, kindsOf(filter).associateWith { span }, complete = true)
return
}
if (!paged) return
// Read ONCE. `now` is a clock call, and this was invoking it twice per
// entry — so a 40-kind map took 80 readings, and worse, a span's floor
// and ceiling were judged against two different instants.
val at = now()
if (observedByKind != null) {
// Guarded per span for the same reason the aggregate is below.
val plausible =
observedByKind.filterValues {
isPlausible(it.min, at) && isPlausible(it.max, at)
}
if (plausible.isEmpty()) return
val named = filter.kinds
val spans =
if (named.isNullOrEmpty()) {
// A filter naming no kinds cannot be split, so [legs] reads
// ALL_KINDS and nothing else. Storing what the walk saw per
// kind would record a band no lookup can ever reach — it
// would exist and do nothing. Collapse to the union, which
// is the only claim such a filter can make.
mapOf(ALL_KINDS to plausible.values.reduce { a, b -> a.widen(b) })
} else {
// Only kinds the filter NAMES. A relay may answer with more
// than it was asked for, and a caller whose containment
// check runs against a different filter than the band is
// keyed by passes those straight through. Keeping them
// would be inert for [legs] — which looks up the filter's
// own kinds — but NOT for [Band.minCreatedAt], which the
// state file writes as its rollback-compat `min`/`max`. An
// off-filter kind seen further back would widen those past
// anything the filter's kinds support, so a binary from
// before per-kind spans would read that file and
// over-claim: this fix undone through the compat path.
plausible.filterKeys { it in named }
}
if (spans.isEmpty()) return
put(url, filter, spans, complete = false)
return
}
// No per-kind evidence. For a filter naming one kind (or none) the
// aggregate IS the per-kind answer and nothing is lost. For a filter
// naming several it is not: attributing one interval to all of them is
// exactly the over-claim [Band.spans] exists to stop, and a band that
// over-claims skips events silently — strictly worse than re-reading
// them. So record nothing and say why, once. The caller resumes as if
// it had no band, which is where it was before bands existed.
val kinds = kindsOf(filter)
if (kinds.size > 1) {
if (!warnedAboutUnattributed) {
warnedAboutUnattributed = true
Log.w("SyncCoverage") {
"paged record for a ${kinds.size}-kind filter with no per-kind spans — no band recorded, so this " +
"walk will not resume. Pass observedByKind (see SyncCoverage.observe) to earn one."
}
}
return
}
// Guarded even though callers should filter with [isPlausible] per
// event: a 1970 floor or a far-future ceiling would make the band
// claim the whole timeline, and the leg outside it would ask for a
// range nothing can be in, forever.
if (observedMin == null || observedMax == null) return
if (!isPlausible(observedMin, at) || !isPlausible(observedMax, at)) return
put(url, filter, kinds.associateWith { Span(observedMin, observedMax) }, complete = false)
}
/** The kinds a band is keyed by: the filter's, or [ALL_KINDS] when it names none. */
private fun kindsOf(filter: Filter): List<Int> = filter.kinds?.takeIf { it.isNotEmpty() } ?: listOf(ALL_KINDS)
/**
* Widen (or reset) the band. A pass that ran because the previous band
* had gone stale REPLACES it: it re-walked the whole filter, so its own
* span is the complete picture and [Band.fullAt] restarts from here.
*/
private fun put(
url: NormalizedRelayUrl,
filter: Filter,
spans: Map<Int, Span>,
complete: Boolean,
) {
val fresh = Band(spans, complete, now())
bands.merge(key(url, filter), fresh) { old, new ->
if (isStale(old)) new else old.widen(new)
}
onChange()
}
private fun isStale(band: Band): Boolean = now() - band.fullAt >= fullResyncSeconds
/**
* The narrowest single filter that still covers what every one of [urls]
* needs — the window a shared negentropy snapshot has to be taken over.
*
* In steady state every relay carries a complete band and this collapses
* to `since = the oldest of their ceilings` — the difference between
* snapshotting an id set of millions and one of a few thousand. One relay
* that has never synced puts it back to the full filter, correctly: that
* relay genuinely needs everything.
*/
fun coveringWindow(
urls: List<NormalizedRelayUrl>,
filter: Filter,
): Filter {
if (urls.isEmpty()) return filter
var since = Long.MAX_VALUE
for (url in urls) {
val legs = legs(url, filter)
// Nothing outside its band: this relay asks nothing of the
// snapshot at all — the best case must not widen the window.
if (legs.isEmpty()) continue
// More than one leg means an older gap this relay still wants, so
// the snapshot cannot start above the filter's own floor.
val only = legs.singleOrNull() ?: return filter
val legSince = only.since ?: return filter
since = minOf(since, legSince)
}
// Every relay fully covered: any window would do; the unnarrowed
// filter is merely safe, and callers usually skip the sync entirely.
return if (since == Long.MAX_VALUE) filter else filter.copy(since = since)
}
/** What is currently covered, for logging and tests. */
fun band(
url: NormalizedRelayUrl,
filter: Filter,
): Band? = bands[key(url, filter)]
fun size(): Int = bands.size()
/** A point-in-time copy of every band, for a persistence layer to write out. */
fun export(): Map<String, Band> = bands.snapshot()
/** Load previously [export]ed bands, e.g. at startup. */
fun restore(entries: Map<String, Band>) {
for ((key, band) in entries) bands[key] = band
}
/**
* The identity of one (relay, filter) pair. [Filter.toJson] is the
* protocol's own canonical form, so two filters that mean the same thing
* key the same way and any edit keys differently — exactly the "config
* changed, start over" rule.
*/
private fun key(
url: NormalizedRelayUrl,
filter: Filter,
): String {
val fingerprint =
fingerprints[filter]
?: filter.toJson().also {
// Bounded: stable callers hit the cache at any size a real
// config produces; a caller minting fresh instances just
// pays the toJson each time instead of growing the heap.
if (fingerprints.size() < MAX_FINGERPRINTS) fingerprints[filter] = it
}
return "${url.url} $fingerprint"
}
// One line per process, not per walk: the point is to tell a caller it has
// not been migrated, and repeating it every leg would bury the log it is
// trying to be read in.
private var warnedAboutUnattributed = false
companion object {
/**
* The span key for a filter that names no kinds, and the fallback for
* a band restored from a file written before spans were per kind.
* Negative because NIP-01 kinds are not.
*/
const val ALL_KINDS = -1
/**
* Widen [into] with one event's stamp, so a caller can accumulate the
* per-kind evidence [record] wants as events arrive:
*
* val seen = mutableMapOf<Int, SyncCoverage.Span>()
* ... onEvent { SyncCoverage.observe(seen, it.kind, it.createdAt) }
* coverage.record(url, filter, …, paged = true, observedByKind = seen)
*
* Implausible stamps are dropped here rather than by each caller —
* per EVENT, never over a leg's aggregate, because one misdated event
* among hundreds of thousands would otherwise discard the whole band.
*
* Not synchronized: it replaces a pair of plain `var`s at each call
* site and is meant for the same single-consumer callback.
*/
fun observe(
into: MutableMap<Int, Span>,
kind: Int,
createdAt: Long,
now: Long = TimeUtils.now(),
) {
if (!isPlausible(createdAt, now)) return
val one = Span(createdAt, createdAt)
into[kind] = into[kind]?.widen(one) ?: one
}
// More filter instances than any deliberate configuration holds; only
// a caller rebuilding filters per cycle ever reaches it.
private const val MAX_FINGERPRINTS = 1_000
/**
* A week. Long enough that the narrow path is the normal one, short
* enough that anything a band is wrong about is wrong for days, not
* forever.
*/
const val DEFAULT_FULL_RESYNC_SECONDS = 7L * 24 * 60 * 60
/**
* 2020-01-01. Below this a `created_at` is a bug, not a date — the
* protocol did not exist. Also the natural floor for measuring a
* paged walk's progress when a filter names no `since`.
*/
const val PLAUSIBLE_FLOOR = 1_577_836_800L
// Clock skew a relay may legitimately be ahead by. Past this, a
// created_at is the author's fiction rather than a time.
private const val FUTURE_SKEW_SECONDS = 86_400L
/**
* Whether a `created_at` can be believed as evidence of coverage.
* Filter with this per EVENT, not over a leg's aggregate: one
* misdated event among hundreds of thousands would otherwise discard
* the whole relay's band.
*/
fun isPlausible(
createdAt: Long,
now: Long = TimeUtils.now(),
): Boolean = createdAt in PLAUSIBLE_FLOOR..(now + FUTURE_SKEW_SECONDS)
}
}
@@ -0,0 +1,141 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay.client.paging
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap
import kotlin.concurrent.Volatile
/**
* How far a bulk pagination has got, measured on the time axis — the only
* axis whose end is known in advance.
*
* A paged fetch (`fetchAllPages`) has no event denominator: how many events
* exist is exactly what it is finding out, so every count-based percentage
* degenerates to `downloaded/downloaded = 100%`. The time axis has both ends
* before the first request — the filter's `until` (or now) down to its
* `since` (or the accessories' `SyncCoverage.PLAUSIBLE_FLOOR`) — with each
* page's new `until` cursor reporting the exact position between them. It
* needs no COUNT support.
*
* The estimate assumes events are spread evenly over time, which they are
* not — so it errs pessimistic on the tail, and is a bound, not a promise.
*
* One instance can serve many concurrent paginations: keys are
* `"group|name"`, and the group prefix scopes [fraction], [reached] and
* [etaMs] so two groups never report each other's numbers.
*
* Its siblings in this package track different things: [RelayLoadingCursors]
* is demand-driven `until`+`limit` paging for one scope (a feed pulling
* older pages on demand, no window), and [RelayPagingProgress] is the
* per-relay display state derived from it. This class is for a BULK
* pagination over a known `[since, until]` window, where "how far through
* the window, and when will it finish" is the question.
*/
class PagingWindowProgress(
private val nowMillis: () -> Long = { TimeUtils.nowMillis() },
) {
private class Window(
val top: Long,
val bottom: Long,
val startedMs: Long,
@Volatile var current: Long,
)
private val windows = ConcurrentMap<String, Window>()
/**
* Begin a pagination over `[bottom, top]` seconds. An inverted window is
* not one; a single-second window (`top == bottom`) is — coverage legs
* that re-read a band's edge second are exactly that shape.
*/
fun begin(
key: String,
top: Long,
bottom: Long,
) {
if (top >= bottom) windows[key] = Window(top, bottom, nowMillis(), top)
}
/**
* The pagination reached [until]; monotonic, so a page that jumps back
* cannot un-advance it. The check-then-set is unsynchronized on purpose:
* one pagination is one coroutine, and a display racing a mark can only
* ever read a value one page stale.
*/
fun mark(
key: String,
until: Long,
) {
windows[key]?.let {
// Clamped to the window's own floor: relays serve events stamped
// 0, and one of those would drag the position to the epoch. Below
// the floor means the pagination is done, not time travel.
val reached = until.coerceAtLeast(it.bottom)
if (reached < it.current) it.current = reached
}
}
fun finish(key: String) {
windows.remove(key)
}
/**
* Fraction complete, averaged over every pagination still going in
* [group] (or all of them when null) — averaged rather than summed
* because each covers its own span, so "half of them done and half at
* zero" is 50%.
*/
fun fraction(group: String? = null): Double? {
val live = live(group)
if (live.isEmpty()) return null
return live.sumOf { w ->
val span = (w.top - w.bottom).coerceAtLeast(1)
((w.top - w.current).toDouble() / span).coerceIn(0.0, 1.0)
} / live.size
}
private fun live(group: String?): List<Window> =
if (group == null) {
windows.snapshot().values.toList()
} else {
windows
.snapshot()
.entries
.filter { it.key.startsWith("$group|") }
.map { it.value }
}
/** The oldest second [group] has reached, or null when nothing is paging. */
fun reached(group: String? = null): Long? = live(group).minOfOrNull { it.current }
/** Milliseconds left at the rate achieved so far, or null before it means anything. */
fun etaMs(group: String? = null): Long? {
val f = fraction(group) ?: return null
// Under a few percent the extrapolation is dominated by connect time
// and produces numbers worse than saying nothing.
if (f < 0.02) return null
val oldestStart = live(group).minOfOrNull { it.startedMs } ?: return null
val elapsed = nowMillis() - oldestStart
if (elapsed < 5_000) return null
return ((elapsed / f) - elapsed).toLong()
}
}
@@ -29,10 +29,14 @@ object FiltersChanged {
): Boolean {
if (oldFilters.size != newFilters.size) return true
oldFilters.forEachIndexed { index, oldFilter ->
val newFilter = newFilters.getOrNull(index) ?: return true
return needsToResendRequest(oldFilter, newFilter)
// Every filter must be compared. This used to be a forEachIndexed whose body
// `return`ed on the first iteration — a non-local return from this function — so
// only filters[0] was ever checked and a subscription whose first filter happened
// to be unchanged reported "no resend needed" however much the rest had changed,
// leaving the relay serving a stale filter set.
// Indexing is safe: the sizes were just proven equal.
for (i in oldFilters.indices) {
if (needsToResendRequest(oldFilters[i], newFilters[i])) return true
}
return false
}
@@ -84,25 +84,26 @@ class PoolRequests(
/*
* Locking model: every compound access to a subscription's state machine
* ([RequestSubscriptionState]) — including the check-then-send decision in
* [decideCommandLocked] — runs inside THAT subscription's own lock
* ([RequestSubscriptionState.withLock]).
* [decideCommandLocked] — runs inside the stripe for THAT (subscription, relay)
* pair ([RequestSubscriptionState.withLock], which takes the relay).
*
* A single subscription can span many relays, and each relay's
* socket-reader thread delivers messages into this class concurrently
* while the app thread adds/removes subscriptions — so the plain maps
* inside [RequestSubscriptionState] are written from several threads at
* once. That is both a memory hazard (concurrent map mutation) and a
* logic hazard: two threads must never both observe "no REQ in flight"
* and both send a REQ for the same sub id.
* A single subscription can span many relays, and each relay's socket-reader
* thread delivers messages into this class concurrently while the app thread
* adds/removes subscriptions. Two threads must never both observe "no REQ in
* flight" and both send a REQ for the same (sub, relay).
*
* The lock is per subscription, not global, because different subIds
* share no wire state: EVENT frames for different subs coming from
* different relay consumer threads must not serialize on each other (a
* global lock here measured negative scaling under 4 concurrent relay
* feeders). Listener callbacks and the actual socket sends are ALWAYS
* performed outside the lock — they re-enter this class through
* [onSent], so holding the lock across them would self-deadlock, and the
* lock is non-reentrant. Never hold two subscriptions' locks at once.
* The lock is striped PER RELAY rather than per subscription: every critical
* section below touches only one relay's slice of the state, so EVENT frames
* arriving for the same subId from different relays no longer serialize on each
* other. With ~191 relays that previously made a single per-sub lock contended
* ~191 threads deep — the production ANR in
* `quartz/plans/2026-08-03-poolrequests-lock-contention.md`.
*
* Two rules this file must keep:
* - Never hold two relays' stripes (or two subscriptions') at once. Every loop
* below locks exactly one relay at a time.
* - Listener callbacks and socket sends are ALWAYS performed outside the lock —
* they re-enter this class through [onSent].
*/
/**
@@ -201,7 +202,7 @@ class PoolRequests(
fun onConnecting(url: NormalizedRelayUrl) {
// Change states to connecting. One sub's lock at a time.
relayState.forEach { subId, state ->
state.withLock { state.connecting(url) }
state.withLock(url) { state.connecting(url) }
}
}
@@ -215,7 +216,7 @@ class PoolRequests(
when (cmd) {
is ReqCmd -> {
subState(cmd.subId).let { state ->
state.withLock { state.onOpenReq(relay, cmd.filters) }
state.withLock(relay) { state.onOpenReq(relay, cmd.filters) }
}
desiredSubListeners.get(cmd.subId)?.onSubscriptionStarted(
relay = relay.url,
@@ -225,7 +226,7 @@ class PoolRequests(
is CloseCmd -> {
subState(cmd.subId).let { state ->
state.withLock { state.onSubscriptionClosed(relay) }
state.withLock(relay) { state.onSubscriptionClosed(relay) }
}
desiredSubListeners.get(cmd.subId)?.onSubscriptionClosed(
relay = relay.url,
@@ -246,7 +247,7 @@ class PoolRequests(
var isLive = false
var forFilters: List<Filter>? = null
relayState.get(msg.subId)?.let { state ->
state.withLock {
state.withLock(relay.url) {
state.onNewEvent(relay.url)
isLive = state.currentState(relay.url) == ReqSubStatus.LIVE
forFilters = state.lastKnownFilterStates(relay.url)
@@ -264,7 +265,7 @@ class PoolRequests(
var forFilters: List<Filter>? = null
val cmd =
relayState.get(msg.subId)?.let { state ->
state.withLock {
state.withLock(relay.url) {
state.onEose(relay.url)
forFilters = state.lastKnownFilterStates(relay.url)
// Decide (and pre-mark) the resend while still holding the
@@ -293,7 +294,7 @@ class PoolRequests(
var forFilters: List<Filter>? = null
val cmd =
relayState.get(msg.subId)?.let { state ->
state.withLock {
state.withLock(relay.url) {
state.onClosed(relay.url)
forFilters = state.lastKnownFilterStates(relay.url)
recordRefusalIfStructural(state, relay.url, msg.message, forFilters)
@@ -322,7 +323,7 @@ class PoolRequests(
*/
fun onDisconnected(url: NormalizedRelayUrl) {
relayState.forEach { subId, state ->
state.withLock { state.disconnected(url) }
state.withLock(url) { state.disconnected(url) }
}
}
@@ -351,7 +352,7 @@ class PoolRequests(
if (!filters.isNullOrEmpty()) {
val send =
subState(subId).let { state ->
state.withLock {
state.withLock(relay) {
if (isStructurallyRefused(state, relay, filters)) {
false
} else {
@@ -379,7 +380,7 @@ class PoolRequests(
// These are all my subs.. need to figure out which relays have them
val subs = desiredSubs.get(subId)
if (subs != null && url in subs.keys) {
toNotify.add(subId to state.withLock { state.lastKnownFilterStates(url) })
toNotify.add(subId to state.withLock(url) { state.lastKnownFilterStates(url) })
}
}
@@ -400,7 +401,7 @@ class PoolRequests(
val state = subState(subId)
relaysToUpdate.forEach { relay ->
// Decide + pre-mark atomically under the sub's lock, then send outside it.
val cmd = state.withLock { decideCommandLocked(state, subId, relay) }
val cmd = state.withLock(relay) { decideCommandLocked(state, subId, relay) }
if (cmd != null) {
sync(relay, cmd)
}
@@ -21,84 +21,116 @@
package com.vitorpamplona.quartz.nip01Core.relay.client.reqs
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap
import com.vitorpamplona.quartz.utils.concurrent.PlatformLock
/**
* Manages the State of Subscriptions by logging states as the
* subscription progresses.
*
* Thread-safety: the plain maps below are only touched inside [withLock].
* The lock lives HERE — one per subscription — instead of a single global
* lock in PoolRequests, because every mutation is scoped to one subId:
* relays delivering EVENTs for *different* subscriptions have no shared
* state and must not serialize on each other. (A single global spin lock
* measured *negative* scaling: 4 relay consumer threads pushed less
* aggregate throughput through it than 1 — see
* quartz/plans/2026-07-02-nostrclient-receiver-perf.md.)
* **Thread-safety: the lock is striped per reference (per relay), not per
* subscription.** Every operation below is scoped to a single [reference] — all state
* lives in [RelayState] objects held in a [ConcurrentMap] keyed by it — so two relays
* delivering EVENTs for the SAME subscription touch disjoint state and no longer
* serialize on each other.
*
* That mattered in production: one subId spans every relay it is subscribed on, so a
* single per-subscription lock was contended ~191 threads deep on the Pixel 8 that
* produced `anr_2026-08-03-12-55-26-256` (it was a *spin* lock then, which burned 6 of
* 9 cores — see [PlatformLock]). Striping removes the contention instead of making
* waiting cheaper: measured 1.5-2.8x the throughput of one lock per sub in
* `quartz/src/jvmTest/.../prodbench/LockDesignComparisonBenchmark.kt`. Full analysis,
* including why a suspending `Mutex` was rejected, is in
* `quartz/plans/2026-08-03-poolrequests-lock-contention.md`.
*
* The stripe array is allocated once and NEVER mutated, so a stripe's identity is
* stable for this object's whole life. That is load-bearing: if locks lived inside the
* per-relay values, a thread holding one while another thread dropped and re-created
* that entry would leave both "inside" the critical section excluding nothing.
*
* Callers MUST NOT hold two references' stripes at once ([PoolRequests] locks one relay
* at a time, including inside its all-subs iterations), and MUST keep socket sends and
* listener callbacks outside the critical section — they re-enter this class through
* `onSent`, and the point of a lock is to be held briefly.
*/
@OptIn(ExperimentalAtomicApi::class)
class RequestSubscriptionState<T> {
class RequestSubscriptionState<T : Any> {
/**
* Tiny non-reentrant spin lock (same primitive as BasicRelayClient's
* connecting mutex). Critical sections are a handful of map operations,
* never I/O — callers MUST NOT re-enter and MUST NOT hold two
* subscriptions' locks at once (PoolRequests locks one sub at a time,
* including inside its all-subs iterations).
* One reference's (relay's) slice of this subscription's state. Plain `var`s: every
* field is written and read under that reference's stripe by [PoolRequests].
*
* `@PublishedApi internal` only because [withLock] is inline (this sits
* on the per-EVENT hot path; inlining avoids a closure allocation per
* message) — treat it as private.
* Note `RelayActiveRequestStates` uses this class WITHOUT locking. There the fields
* may be read stale — but the backing [ConcurrentMap] can no longer be structurally
* corrupted the way the plain `HashMap`s this replaced could.
*/
private class RelayState {
/** Null == no REQ state on this relay (fresh, or wiped by connecting/disconnected). */
var status: ReqSubStatus? = null
/** Filters of the REQ currently believed to be in flight. */
var filters: List<Filter>? = null
/**
* Survives connect/disconnect so that if new events still arrive we can link
* them with the filters the relay was processing.
*/
var lastKnownFilters: List<Filter>? = null
/**
* Refused-filter memory. Unlike [status]/[filters] — per-connection wire state
* wiped by [connecting]/[disconnected] — this SURVIVES reconnects on purpose: a
* relay that structurally refuses a filter (a search-only relay CLOSING a plain
* kinds REQ, a relay that "does not accept REQs", "too many filters", …) refuses
* it again on every new socket, so [PoolRequests.syncState] replaying it each
* reconnect is pure waste. [refusalCount] accumulates repeated refusals of the
* same shape so a one-off (transient) close isn't mistaken for a structural one.
* Cleared on a successful REQ ([onEose]/[onNewEvent]) or when the caller observes
* the desired filter meaningfully changed.
*/
var refusedFilters: List<Filter>? = null
var refusalCount: Int = 0
}
private val states = ConcurrentMap<T, RelayState>()
/**
* Fixed stripe array — allocated once, never mutated, so lock identity is stable.
* [STRIPE_COUNT] stripes over ~191 relays is roughly 6-way sharing: a ~32x
* contention reduction versus one lock per subscription. References colliding on a
* stripe merely serialize; correctness never depends on N.
*/
@PublishedApi
internal val lock = AtomicBoolean(false)
internal val stripes = Array(STRIPE_COUNT) { PlatformLock() }
inline fun <R> withLock(block: () -> R): R {
while (lock.exchange(true)) {
// Test-and-test-and-set: spin-read until it looks free (cheaper
// on the cache line than hammering exchange), then retry above.
while (lock.load()) { }
}
@PublishedApi
internal fun stripeFor(reference: T): PlatformLock = stripes[(reference.hashCode() and 0x7FFFFFFF) % STRIPE_COUNT]
/**
* Runs [block] holding [reference]'s stripe. Inline so the per-EVENT hot path
* allocates no closure.
*/
inline fun <R> withLock(
reference: T,
block: () -> R,
): R {
val lock = stripeFor(reference)
lock.lock()
try {
return block()
} finally {
lock.store(false)
lock.unlock()
}
}
// Logs the state of each channel to:
// 1. inform when an event is received as live
// 2. to block REQs being sent before finished (receiving an EOSE or Closed)
//
// If 2 happens, the relay might send multiple EOSEs in sequence
// for the same sub and we won't know which REQ was it for.
private val subStates = mutableMapOf<T, ReqSubStatus>()
private val filterStates = mutableMapOf<T, List<Filter>>()
/** Read-only lookup — never creates an entry. */
private fun peek(reference: T): RelayState? = states[reference]
/**
* This cache is used to make sure we know what the relay was processing
* before a close or disconnect so that if new events still arrive
* we can link them with the appropriate filters.
*/
private val lastKnownFilterStates = mutableMapOf<T, List<Filter>>()
/** Write lookup — creates the entry on first use. */
private fun mutable(reference: T): RelayState = states.getOrPut(reference) { RelayState() }
/**
* Refused-filter memory. Unlike [subStates]/[filterStates] above — per-connection
* wire state wiped by [connecting]/[disconnected] — this SURVIVES reconnects on
* purpose: a relay that structurally refuses a filter (a search-only relay CLOSING
* a plain kinds REQ, a relay that "does not accept REQs", "too many filters", …)
* refuses it again on every new socket, so [PoolRequests.syncState] replaying it
* each reconnect is pure waste. [refusalCounts] accumulates repeated refusals of
* the same shape so a one-off (transient) close isn't mistaken for a structural
* one. Cleared on a successful REQ ([onEose]/[onNewEvent]) or when the caller
* observes the desired filter meaningfully changed.
*/
private val refusedFilters = mutableMapOf<T, List<Filter>>()
private val refusalCounts = mutableMapOf<T, Int>()
fun refusedFilters(reference: T) = peek(reference)?.refusedFilters
fun refusedFilters(reference: T) = refusedFilters[reference]
fun refusalCount(reference: T) = refusalCounts[reference] ?: 0
fun refusalCount(reference: T) = peek(reference)?.refusalCount ?: 0
/**
* Records that [reference] refused [filters]. [sameAsLastRefusal] must be true when
@@ -111,73 +143,91 @@ class RequestSubscriptionState<T> {
filters: List<Filter>,
sameAsLastRefusal: Boolean,
) {
val state = mutable(reference)
if (sameAsLastRefusal) {
refusalCounts[reference] = refusalCount(reference) + 1
state.refusalCount += 1
} else {
refusedFilters[reference] = filters
refusalCounts[reference] = 1
state.refusedFilters = filters
state.refusalCount = 1
}
}
fun clearRefusal(reference: T) {
refusedFilters.remove(reference)
refusalCounts.remove(reference)
peek(reference)?.let {
it.refusedFilters = null
it.refusalCount = 0
}
}
fun currentFilters() = filterStates
fun currentFilters(reference: T) = peek(reference)?.filters
fun currentFilters(reference: T) = filterStates[reference]
fun lastKnownFilterStates(reference: T) = peek(reference)?.lastKnownFilters
fun lastKnownFilterStates(reference: T) = lastKnownFilterStates[reference]
fun currentState(reference: T) = subStates[reference]
fun currentState(reference: T) = peek(reference)?.status
fun onNewEvent(reference: T) {
val state = mutable(reference)
// The relay is serving this REQ (it matched an event), so any past refusal
// no longer applies — let it be tried freely again.
clearRefusal(reference)
if (subStates[reference] == ReqSubStatus.SENT) {
subStates[reference] = ReqSubStatus.QUERYING_PAST
state.refusedFilters = null
state.refusalCount = 0
if (state.status == ReqSubStatus.SENT) {
state.status = ReqSubStatus.QUERYING_PAST
}
}
fun onEose(reference: T) {
val state = mutable(reference)
// Reaching EOSE means the relay accepted and finished the REQ; clear any refusal.
clearRefusal(reference)
subStates[reference] = ReqSubStatus.LIVE
state.refusedFilters = null
state.refusalCount = 0
state.status = ReqSubStatus.LIVE
}
fun onClosed(reference: T) {
subStates[reference] = ReqSubStatus.CLOSED
// Closed messages are usually relays refusing to process a REQ
// This message keeps the state of filterStates intact to
// avoid sending the same filter, and getting immediately closed,
// over and over again.
// filterStates.remove(reference)
// Closed messages are usually relays refusing to process a REQ. This keeps
// [RelayState.filters] intact to avoid sending the same filter, and getting
// immediately closed, over and over again.
mutable(reference).status = ReqSubStatus.CLOSED
}
fun onOpenReq(
reference: T,
filters: List<Filter>,
) {
subStates[reference] = ReqSubStatus.SENT
filterStates[reference] = filters
lastKnownFilterStates[reference] = filters
val state = mutable(reference)
state.status = ReqSubStatus.SENT
state.filters = filters
state.lastKnownFilters = filters
}
fun onSubscriptionClosed(reference: T) {
subStates[reference] = ReqSubStatus.CLOSED
filterStates.remove(reference)
val state = mutable(reference)
state.status = ReqSubStatus.CLOSED
state.filters = null
}
fun connecting(reference: T) {
subStates.remove(reference)
filterStates.remove(reference)
// Wipes per-connection wire state only; lastKnownFilters and the refusal memory
// deliberately survive (see [RelayState]).
peek(reference)?.let {
it.status = null
it.filters = null
}
}
fun disconnected(reference: T) {
subStates.remove(reference)
filterStates.remove(reference)
peek(reference)?.let {
it.status = null
it.filters = null
}
}
companion object {
/**
* Comfortably above the IO dispatcher's thread count (64 / 2) so collisions stay
* rare even when many workers are inside this class at once.
*/
const val STRIPE_COUNT = 32
}
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.filters
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.tags.isIndexableTagName
import kotlinx.collections.immutable.PersistentMap
import kotlinx.collections.immutable.PersistentSet
import kotlinx.collections.immutable.persistentHashMapOf
@@ -238,7 +239,7 @@ class FilterIndex<S : Any> {
s.kinds[event.kind]?.let { result.addAll(it) }
if (s.tags.isNotEmpty()) {
for (tag in event.tags) {
if (tag.size >= 2 && tag[0].length == 1) {
if (tag.size >= 2 && isIndexableTagName(tag[0])) {
s.tags[tag[0]]?.get(tag[1])?.let { result.addAll(it) }
}
}
@@ -348,14 +349,14 @@ class FilterIndex<S : Any> {
if (!filter.tags.isNullOrEmpty()) {
val first =
filter.tags.entries.firstOrNull {
it.key.length == 1 && it.value.isNotEmpty()
isIndexableTagName(it.key) && it.value.isNotEmpty()
}
if (first != null) return first.value.map { TagKey(first.key, it) }
}
if (!filter.tagsAll.isNullOrEmpty()) {
val first =
filter.tagsAll.entries.firstOrNull {
it.key.length == 1 && it.value.isNotEmpty()
isIndexableTagName(it.key) && it.value.isNotEmpty()
}
if (first != null) return first.value.map { TagKey(first.key, it) }
}
@@ -44,6 +44,11 @@ fun NormalizedRelayUrl.toHttp() =
"https://$url"
}
fun NormalizedRelayUrl.isOnion() = url.contains(".onion/")
// Delegates rather than re-implementing `contains(".onion/")`: that copy missed
// `wss://host.onion:8080/`, so an onion relay on an explicit port was never forced onto Tor.
fun NormalizedRelayUrl.isOnion() = RelayUrlNormalizer.isOnion(this.url)
fun NormalizedRelayUrl.isLocalHost() = RelayUrlNormalizer.isLocalHost(this.url)
/** True for a relay inside an encrypted IPv6 overlay mesh. See [RelayUrlNormalizer.isOverlayNetwork]. */
fun NormalizedRelayUrl.isOverlayNetwork() = RelayUrlNormalizer.isOverlayNetwork(this.url)
@@ -21,6 +21,8 @@
package com.vitorpamplona.quartz.nip01Core.relay.normalizer
import androidx.collection.LruCache
import com.vitorpamplona.quartz.utils.Ipv4
import com.vitorpamplona.quartz.utils.Ipv6
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.Rfc3986
import kotlinx.coroutines.CancellationException
@@ -38,15 +40,183 @@ val normalizedUrls = LruCache<String, NormalizationResult>(5000)
class RelayUrlNormalizer {
companion object {
fun isLocalHost(url: String) =
url.contains("127.0.0.1") ||
url.contains("localhost") ||
url.contains("//umbrel:") ||
url.contains("192.168.") ||
url.contains(".local:") ||
url.contains(".local/")
/**
* Every host test below is anchored to the **authority** (`host[:port]`), never to the
* whole url. A plain `contains` reads the path and query too, so
* `wss://evil.example.com/127.0.0.1` used to answer true here — and since [isLocalHost]
* is what exempts a relay from Tor, any relay list could hand the app a url that quietly
* dropped its own Tor routing. Relay urls arrive from other people (NIP-65 lists, relay
* hints, `r` tags), so they are attacker-controlled input and have to be parsed as such.
*
* Anchoring is also what makes `host:port` work: `.onion:8080` never matched the old
* `.onion/` test, so an onion relay on an explicit port was not recognized as onion at
* all and its hostname went to the clearnet DNS resolver.
*/
fun isLocalHost(url: String): Boolean {
val start = hostStart(url)
val end = hostEnd(url, start)
if (end <= start) return false
val hostEnd = hostEndWithoutPort(url, start, end)
return isPrivateIpv4(url, start, hostEnd) ||
// RFC 6761: `localhost` and anything under it — the same rule SurgeDns applies
// when deciding whether a loopback answer is legitimate. A substring test would
// also match `notlocalhost.example.com`, which is registrable.
regionEquals(url, "localhost", start, hostEnd) ||
regionEndsWith(url, ".localhost", start, hostEnd) ||
regionEquals(url, "umbrel", start, hostEnd) ||
regionEndsWith(url, ".local", start, hostEnd) ||
isPrivateIpv6(url, start, end)
}
fun isOnion(url: String) = url.endsWith(".onion") || url.contains(".onion/")
/**
* The IPv4 ranges that are never a public relay: `127.0.0.0/8` loopback, the RFC 1918
* private blocks, `169.254.0.0/16` link-local and `0.0.0.0/8`.
*
* Parsed rather than substring-matched, which was wrong both ways: `contains("192.168.")`
* missed `10.0.0.5` and `172.16.3.4` — so a LAN relay was given `wss://` and dialed
* through Tor — while matching `192.168.evil.com`, a registrable domain that could
* therefore exempt itself from Tor.
*/
private fun isPrivateIpv4(
url: String,
start: Int,
end: Int,
): Boolean {
val bytes = Ipv4.parse(url, start, end) ?: return false
return Ipv4.isLoopback(bytes) ||
Ipv4.isPrivate(bytes) ||
Ipv4.isLinkLocal(bytes) ||
Ipv4.isUnspecified(bytes)
}
fun isOnion(url: String): Boolean {
val start = hostStart(url)
val end = hostEnd(url, start)
if (end <= start) return false
return regionEndsWith(url, ".onion", start, hostEndWithoutPort(url, start, end))
}
/**
* True for a relay inside an encrypted IPv6 overlay mesh — today `0200::/7`, the range
* Yggdrasil derives node addresses and subnets from.
*
* Unlike [isLocalHost] this is not a private address: it is reachable from anywhere on
* the mesh. But it is unreachable *off* the mesh, which has two consequences the relay
* stack has to honour — it can never be dialed through a SOCKS/Tor proxy, and it can
* never present a CA-issued certificate, so it speaks plain `ws://`. Both are safe:
* the overlay already encrypts end to end and authenticates the peer by its address,
* which is derived from the peer's public key.
*/
fun isOverlayNetwork(url: String): Boolean {
val start = hostStart(url)
val bytes = ipv6HostOf(url, start, hostEnd(url, start)) ?: return false
return Ipv6.isOverlayMesh(bytes)
}
/**
* The IPv6 twins of the literals in [isLocalHost]: `::1` (127.0.0.1), `fc00::/7` unique
* local addresses (192.168.0.0/16) and `fe80::/10` link-local. All three name a host that
* only exists on this machine or this LAN, which is what every caller of [isLocalHost]
* means by the question — so a relay on one must not be Torified, must not need TLS,
* and must not be advertised to the network.
*/
private fun isPrivateIpv6(
url: String,
start: Int,
end: Int,
): Boolean {
val bytes = ipv6HostOf(url, start, end) ?: return false
return Ipv6.isLoopback(bytes) || Ipv6.isUniqueLocal(bytes) || Ipv6.isLinkLocal(bytes)
}
/**
* Parses the authority of [url] as a bracketed IPv6 literal, dropping any `%zone` suffix.
* Returns null — on a single char comparison — for the overwhelmingly common case of a
* url with a DNS host.
*/
private fun ipv6HostOf(
url: String,
start: Int,
end: Int,
): ByteArray? {
if (start >= end || url[start] != '[') return null
val close = url.indexOf(']', start + 1)
if (close < 0 || close >= end || close <= start + 1) return null
val zone = url.indexOf('%', start + 1)
val addressEnd = if (zone in (start + 1) until close) zone else close
return Ipv6.parse(url.substring(start + 1, addressEnd))
}
/**
* Index of the first char of the authority: past `://`, or 0 for a schemeless host.
*
* The `://` only counts when what precedes it is a real RFC 3986 scheme
* (`ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )`). Otherwise `relay.com/x://127.0.0.1`
* would have its *path* read as the authority and answer true to [isLocalHost].
*/
private fun hostStart(url: String): Int {
val scheme = url.indexOf("://")
if (scheme <= 0 || !url[0].isLetter()) return 0
for (i in 1 until scheme) {
val c = url[i]
if (!c.isLetterOrDigit() && c != '+' && c != '-' && c != '.') return 0
}
return scheme + 3
}
/** Index just past the authority — the first `/`, `?` or `#`, or the end of [url]. */
private fun hostEnd(
url: String,
start: Int,
): Int {
var i = start
while (i < url.length) {
val c = url[i]
if (c == '/' || c == '?' || c == '#') return i
i++
}
return i
}
/**
* [end] trimmed back past a `:port` and any trailing dots, so the host tests see the
* name alone. RFC 1034's fully-qualified form ends in a dot (`abc.onion.`), and missing
* that spelling on [isOnion] would send a `.onion` name to the clearnet DNS resolver.
*/
private fun hostEndWithoutPort(
url: String,
start: Int,
end: Int,
): Int {
var stop =
if (url[start] == '[') {
// In an IPv6 authority only the `:` after the `]` can start a port.
val close = url.indexOf(']', start + 1)
if (close in start until end) close + 1 else end
} else {
val colon = url.lastIndexOf(':', end - 1)
if (colon >= start) colon else end
}
while (stop > start && url[stop - 1] == '.') stop--
return stop
}
// Host names are case-insensitive (RFC 4343), and `fix()` asks these questions *before*
// the RFC 3986 pass folds the case — so a case-sensitive test gave `LOCALHOST:8080` and
// `ABC.ONION:8080` a `wss://` scheme neither host can ever serve.
private fun regionEndsWith(
url: String,
suffix: String,
start: Int,
end: Int,
): Boolean = end - start >= suffix.length && url.regionMatches(end - suffix.length, suffix, 0, suffix.length, ignoreCase = true)
private fun regionEquals(
url: String,
name: String,
start: Int,
end: Int,
): Boolean = end - start == name.length && url.regionMatches(start, name, 0, name.length, ignoreCase = true)
fun isRelaySchemePrefix(url: String) = url.length > 6 && url[0] == 'w' && url[1] == 's'
@@ -82,19 +252,135 @@ class RelayUrlNormalizer {
return false
}
private fun norm(url: String) = NormalizedRelayUrl(Rfc3986.normalize(url))
private fun norm(url: String) = NormalizedRelayUrl(canonicalizeIpv6Host(Rfc3986.normalize(url)))
/**
* Rewrites a bracketed IPv6 host into its RFC 5952 canonical form.
*
* RFC 4291 lets one address be spelled many ways, and the RFC 3986 pass only folds hex
* case — so `[201:0d0e:9ba5:8bbc:0000:0000:0000:0001]` and `[201:d0e:9ba5:8bbc::1]`
* survive as two different [NormalizedRelayUrl]s for one host. That value keys the
* connection pool, the relay-list sets, the NIP-11 cache and the per-relay stats, so the
* app would dial the same relay twice and count it twice. OkHttp canonicalizes to this
* exact form when it dials, so folding here makes the stored key the host on the wire.
*
* Returns [url] itself — no allocation — when there is no literal or it is already
* canonical, which is every url with a DNS host.
*/
private fun canonicalizeIpv6Host(url: String): String {
// Anchored to the authority: a `[...]` in a path or query is data, and rewriting it
// would silently corrupt the url.
val open = hostStart(url)
if (open >= url.length || url[open] != '[') return url
val close = url.indexOf(']', open + 1)
if (close <= open + 1 || close >= hostEnd(url, open)) return url
val inner = url.substring(open + 1, close)
val canonical = Ipv6.canonicalizeOrNull(inner) ?: return url
if (canonical == inner) return url
return url.substring(0, open + 1) + canonical + url.substring(close)
}
private fun isInvisible(c: Char) = c == '\u200B' || c == '\u200C' || c == '\u200D' || c == '\u2060' || c == '\uFEFF'
/**
* Scans the authority (host[:port]) that starts at [start] and ends at the first
* `/`, `?` or `#`. Returns the end index, or -1 when the authority is empty or
* contains characters that never appear in a real relay host (`@` userinfo,
* percent-encoding, commas).
*/
private fun authorityEnd(
url: String,
start: Int,
): Int {
if (start >= url.length) return -1
var i = start
if (url[i] == '[') {
// IPv6 literal: defer validation to the RFC 3986 parser
while (i < url.length && url[i] != '/' && url[i] != '?' && url[i] != '#') i++
return i
}
while (i < url.length) {
val c = url[i]
if (c == '/' || c == '?' || c == '#') break
if (c == '@' || c == '%' || c == ',') return -1
i++
}
return if (i == start) -1 else i
}
/**
* Accepts a ws/wss url whose host starts at [hostStart] if the authority is sane
* and the path does not start with `//` (the signature of a second URL or a broken
* `https//` pasted after the scheme, e.g. `wss://https//nostr.watch/relay/x`).
*/
private fun fixWs(
url: String,
hostStart: Int,
): String? {
val end = authorityEnd(url, hostStart)
if (end < 0) return null
if (end + 1 < url.length && url[end] == '/' && url[end + 1] == '/') return null
return url
}
/**
* Converts an http(s) url to ws(s) only when it is a bare host — nothing after
* `host[:port]` but an optional trailing `/`. An http url with a path, query or
* fragment (Mastodon actor urls from bridge `proxy` tags, web pages, images) is
* a web resource, not a relay: converting it creates a wss:// url that can never
* answer and only wastes connection attempts.
*/
private fun fixHttp(
url: String,
hostStart: Int,
newScheme: String,
): String? {
val end = authorityEnd(url, hostStart)
if (end < 0) return null
val bareHost = end == url.length || (end == url.length - 1 && url[end] == '/')
if (!bareHost) return null
return "$newScheme${url.substring(hostStart)}"
}
/**
* Validates a schemeless candidate: the part before the first `/` must look like
* `host` or `host:port` — letters, digits, `.`, `-`, `_`, plus at most one `:`
* followed by digits only. Rejects addressable-event pointers (`31990:hex:dtag`),
* bare scheme leftovers (`wss:`) and anything else that would otherwise be blindly
* prefixed with `wss://`.
*/
private fun isBareHostAndPath(url: String): Boolean {
if (url[0] == '[') return true // IPv6 literal: defer to the RFC 3986 parser
var i = 0
var portStart = -1
while (i < url.length) {
val c = url[i]
if (c == '/') break
if (c == ':') {
if (portStart >= 0) return false
portStart = i + 1
} else if (portStart >= 0) {
if (c < '0' || c > '9') return false
} else if (!c.isLetterOrDigit() && c != '.' && c != '-' && c != '_') {
return false
}
i++
}
if (i == 0) return false
if (portStart >= 0 && portStart == i) return false
return true
}
@OptIn(ExperimentalContracts::class)
fun fix(rawUrl: String): String? {
if (rawUrl.length < 4) return null
if (rawUrl.contains("%00")) return null
// Trim trailing %20 (percent-encoded spaces from malformed event data)
val url =
rawUrl.trimEnd('%', '2', '0').let { trimmed ->
// Only accept if we actually removed a trailing %20 pattern
if (trimmed.length < rawUrl.length && rawUrl.endsWith("%20")) trimmed else rawUrl
}
// Trim trailing %20 (percent-encoded spaces from malformed event data).
// The endsWith gate keeps the hot path allocation-free: trimEnd would
// copy the string for ANY url merely ending in '%', '2' or '0' — which
// includes every port ending in zero ("wss://host:3030").
val url = if (rawUrl.endsWith("%20")) rawUrl.trimEnd('%', '2', '0') else rawUrl
if (url.length < 4) return null
// Reject URLs with %20 in the middle — these are garbage
@@ -109,17 +395,33 @@ class RelayUrlNormalizer {
}
}
val trimmed =
var trimmed =
if (url[0].isWhitespace() || url[url.length - 1].isWhitespace()) {
url.trim()
} else {
url
}
// Single pass: interior whitespace means multiple urls or prose in one field,
// backslashes never appear in a real relay url; both are garbage. Invisible
// characters (zero-width spaces, BOM) are copy-paste artifacts — strip them.
var hasInvisible = false
for (c in trimmed) {
if (c == '\\') return null
if (c.isWhitespace()) return null
if (isInvisible(c)) hasInvisible = true
}
if (hasInvisible) {
trimmed = buildString(trimmed.length) { for (c in trimmed) if (!isInvisible(c)) append(c) }
if (trimmed.length < 4) return null
}
// fast for good wss:// urls
if (isRelaySchemePrefix(trimmed)) {
if (isRelaySchemePrefixSecure(trimmed) || isRelaySchemePrefixInsecure(trimmed)) {
return trimmed
if (isRelaySchemePrefixSecure(trimmed)) {
return fixWs(trimmed, 6)
} else if (isRelaySchemePrefixInsecure(trimmed)) {
return fixWs(trimmed, 5)
}
}
@@ -127,31 +429,31 @@ class RelayUrlNormalizer {
if (isHttpPrefix(trimmed)) {
if (isHttpSSuffix(trimmed)) {
// https://
return "wss://${trimmed.drop(8)}"
return fixHttp(trimmed, 8, "wss://")
} else if (isHttpSuffix(trimmed)) {
// http://
return "ws://${trimmed.drop(7)}"
return fixHttp(trimmed, 7, "ws://")
}
}
// fast for good ww:// urls
if (trimmed.startsWith("ww://")) {
return "wss://${trimmed.drop(5)}"
return fixWs("wss://${trimmed.drop(5)}", 6)
}
// fast for good ww:// urls
if (trimmed.startsWith("was://")) {
return "wss://${trimmed.drop(6)}"
return fixWs("wss://${trimmed.drop(6)}", 6)
}
// fast for good ww:// urls
if (trimmed.startsWith("Wws://")) {
return "wss://${trimmed.drop(6)}"
return fixWs("wss://${trimmed.drop(6)}", 6)
}
// fast for good ww:// urls
if (trimmed.startsWith("Wss://")) {
return "wss://${trimmed.drop(6)}"
return fixWs("wss://${trimmed.drop(6)}", 6)
}
if (trimmed.contains("://")) {
@@ -160,10 +462,33 @@ class RelayUrlNormalizer {
return null
}
return if (isOnion(trimmed) || isLocalHost(trimmed)) {
"ws://$trimmed"
// protocol-relative urls (`//host/`) are just missing the scheme
val protocolRelative = if (trimmed.startsWith("//")) trimmed.drop(2) else trimmed
if (protocolRelative.length < 4) return null
// A bare IPv6 literal is missing its brackets, not malformed. This is the shape a
// user actually has in hand — `yggdrasilctl getSelf` prints the address unbracketed
// — and without the brackets `isBareHostAndPath` rejects it below as a host with too
// many colons. Only a string that parses as a whole address is bracketed, so an
// addressable-event pointer (`31990:hex:dtag`) or a `host:port` still falls through.
val bare =
if (protocolRelative[0] != '[' && Ipv6.isLiteral(protocolRelative)) {
"[$protocolRelative]"
} else {
protocolRelative
}
if (!isBareHostAndPath(bare)) {
Log.d("RelayUrlNormalizer") { "Rejected $url" }
return null
}
// Overlay and localhost relays cannot hold a certificate, so wss:// could only ever
// fail its handshake. Both carry their own encryption, so ws:// is not a downgrade.
return if (isOnion(bare) || isLocalHost(bare) || isOverlayNetwork(bare)) {
"ws://$bare"
} else {
"wss://$trimmed"
"wss://$bare"
}
}
@@ -186,6 +511,13 @@ class RelayUrlNormalizer {
val fixed = fix(url)
if (fixed != null) {
val normalized = norm(fixed)
// the RFC 3986 parser can drop or replace the scheme on odd inputs;
// anything that is not ws(s):// at this point cannot be connected to.
if (!isRelayUrl(normalized.url)) {
Log.d("NormalizedRelayUrl") { "Rejected $url" }
normalizedUrls.put(url, NormalizationResult.Error)
return null
}
normalizedUrls.put(url, NormalizationResult.Success(normalized))
normalized
} else {
@@ -53,6 +53,7 @@ import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.ClosedSendChannelException
import kotlinx.coroutines.launch
import kotlin.concurrent.Volatile
import kotlin.concurrent.atomics.AtomicLong
import kotlin.concurrent.atomics.ExperimentalAtomicApi
@@ -82,8 +83,18 @@ class RelaySession(
* The authenticated-identity store for this connection. The engine is the
* only writer (committed in [handleAuth] on a successful NIP-42 AUTH); the
* policy and the data plane read it through [requestContext].
*
* Copy-on-write on purpose: readers run concurrently with the engine —
* a REQ replay coroutine can hold the set (via `StoreQueryContext` or an
* `EventSource` reading [RequestContext.authenticatedUsers]) while a
* later AUTH frame commits a new identity. Each read hands out the
* current **immutable** set, so an in-flight query keeps a consistent
* snapshot instead of racing a mutating `LinkedHashSet`; `@Volatile`
* makes the swapped reference visible across threads. AUTH is rare, so
* the copy costs nothing on the hot path.
*/
private val authenticatedUsers = mutableSetOf<HexKey>()
@Volatile
private var authenticatedUsers = setOf<HexKey>()
/**
* The per-connection scope. Handed to the [policy] at connect (so gating
@@ -203,6 +214,13 @@ class RelaySession(
is IEventStore.InsertOutcome.Rejected -> {
send(OkMessage(cmd.event.id, false, outcome.reason))
}
is IEventStore.InsertOutcome.Failed -> {
// The store's error, not the event's — NIP-01's
// machine-readable prefix for that is "error:".
val reason = outcome.reason
send(OkMessage(cmd.event.id, false, if (reason.startsWith("error:")) reason else "error: $reason"))
}
}
}
} catch (_: ClosedSendChannelException) {
@@ -261,7 +279,7 @@ class RelaySession(
// Single, engine-side commit into the connection scope — after the full
// chain approved and a verifying policy voted to record.
if (record) authenticatedUsers.add(cmd.event.pubKey)
if (record) authenticatedUsers = authenticatedUsers + cmd.event.pubKey
send(OkMessage(cmd.event.id, true, ""))
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.server.backend
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip01Core.store.RejectionReason
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -36,6 +37,7 @@ import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.AtomicInt
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.cancellation.CancellationException
/**
* Group-commit writer for incoming EVENT publishes.
@@ -268,8 +270,8 @@ class IngestQueue(
* Run the SQLite transaction for the verified subset of [batch]
* and stitch outcomes back to a per-batch-index array. Failed
* verifies pre-mark `Rejected` and skip the insert. A whole-batch
* commit failure converts every persisted entry to `Rejected`
* with the throw message.
* commit failure converts every persisted entry to `Failed` with
* the throw message — the events were good; the store was not.
*/
private suspend fun runInsertStage(
batch: List<Submission>,
@@ -290,10 +292,15 @@ class IngestQueue(
} else {
try {
store.batchInsert(toInsert)
} catch (e: CancellationException) {
// Shutdown, not a store failure: rethrow so the loop
// stops instead of stamping the batch Failed and
// carrying on while cancelled.
throw e
} catch (e: Throwable) {
Log.w("IngestQueue") { "batchInsert failed for ${toInsert.size} events: ${e.message}" }
val reason = e.message ?: e::class.simpleName ?: "insert failed"
List(toInsert.size) { IEventStore.InsertOutcome.Rejected(reason) }
val reason = e.message ?: e::class.simpleName ?: RejectionReason.INSERT_FAILED
List(toInsert.size) { IEventStore.InsertOutcome.Failed(reason) }
}
}
@@ -349,7 +356,7 @@ class IngestQueue(
* inserts), so the message is informational, not user-facing.
*/
private val missingOutcome =
IEventStore.InsertOutcome.Rejected("internal error: missing outcome")
IEventStore.InsertOutcome.Failed("internal error: missing outcome")
/**
* Cap per batch. Sized to keep per-batch latency low (each
@@ -27,10 +27,11 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterIndex
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip01Core.store.IdAndTime
import com.vitorpamplona.quartz.nip01Core.store.RawEvent
import com.vitorpamplona.quartz.nip50Search.strippingSearchExtensions
import com.vitorpamplona.quartz.nip01Core.store.StoreQueryContext
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.withContext
import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.AtomicLong
import kotlin.concurrent.atomics.AtomicReference
@@ -50,14 +51,14 @@ import kotlin.concurrent.atomics.ExperimentalAtomicApi
* match. This avoids the quadratic O(N_subscribers × N_filters_per_sub) per-event walk
* that a SharedFlow-based broadcast would do.
*
* NIP-50 `search` strings are handed to the store with their `key:value`
* extension tokens stripped ([strippingSearchExtensions]): the SQLite FTS
* backend treats `:` as column-filter syntax, so a raw `include:spam`
* would raise "no such column" instead of matching. Per NIP-50, an
* unsupported extension is ignoredan extensions-only search therefore
* becomes unconstrained, not match-nothing. Relays that *do* implement
* extensions serve search through an [EventSource] backend, which
* receives the raw string.
* NIP-50 `search` strings are handed to the store **verbatim**, extension
* tokens included: whether `include:spam` is a directive, ignorable noise,
* or poison for a text engine is a property of the store, so each
* [IEventStore] implementation makes that call itself (see the NIP-50
* contract on [IEventStore]the built-in SQLite and filesystem stores
* strip the tokens at their own boundary). This layer also installs a
* [StoreQueryContext] around each store call so observer-relative stores
* can read the connection's NIP-42 identity.
*
* @property store The underlying persistent storage for events.
* @property ingest The group-commit writer pipeline. Accepted events fan out via the
@@ -179,6 +180,10 @@ class LiveEventStore(
is IEventStore.InsertOutcome.Rejected -> {
done.completeExceptionally(IllegalStateException(outcome.reason))
}
is IEventStore.InsertOutcome.Failed -> {
done.completeExceptionally(IllegalStateException(outcome.reason))
}
}
}
done.await()
@@ -208,6 +213,25 @@ class LiveEventStore(
}
}
/**
* Runs [block] with a [StoreQueryContext] carrying the connection's
* NIP-42-authenticated pubkeys, so observer-relative stores can read
* the caller's identity from the coroutine context. Skipped entirely
* for unauthenticated connections — the element's contract is
* "present means non-empty".
*/
private suspend inline fun <R> withCallerIdentity(
ctx: RequestContext,
crossinline block: suspend () -> R,
): R {
val users = ctx.authenticatedUsers
return if (users.isEmpty()) {
block()
} else {
withContext(StoreQueryContext(users)) { block() }
}
}
/**
* With deferred FTS, a search query must first drain the catch-up
* backlog — that keeps NIP-50 results exactly as fresh as the
@@ -248,9 +272,11 @@ class LiveEventStore(
index.register(filters, sub)
try {
store.query<Event>(filters.strippingSearchExtensions()) { event ->
seen.record(event.id)
onEach(event)
withCallerIdentity(ctx) {
store.query<Event>(filters) { event ->
seen.record(event.id)
onEach(event)
}
}
onEose()
// Drop the dedupe set so the live path stops paying for
@@ -295,9 +321,11 @@ class LiveEventStore(
index.register(filters, sub)
try {
store.rawQuery(filters.strippingSearchExtensions()) { raw ->
seen.record(raw.id)
onEachStored(raw)
withCallerIdentity(ctx) {
store.rawQuery(filters) { raw ->
seen.record(raw.id)
onEachStored(raw)
}
}
onEose()
seen.release()
@@ -312,7 +340,7 @@ class LiveEventStore(
filters: List<Filter>,
): Int {
drainFtsIfSearching(filters)
return store.count(filters.strippingSearchExtensions())
return withCallerIdentity(ctx) { store.count(filters) }
}
/**
@@ -320,7 +348,7 @@ class LiveEventStore(
* needs the full set of event ids matching the filter at the
* moment the NEG-OPEN arrives, not a streamed/live result.
*/
suspend fun snapshotQuery(filter: Filter): List<Event> = store.query(filter.strippingSearchExtensions())
suspend fun snapshotQuery(filter: Filter): List<Event> = store.query(filter)
/**
* Multi-filter snapshot. Unions the per-filter results and
@@ -333,7 +361,7 @@ class LiveEventStore(
val seen = HashSet<String>()
val merged = ArrayList<Event>()
for (f in filters) {
for (e in store.query<Event>(f.strippingSearchExtensions())) {
for (e in store.query<Event>(f)) {
if (seen.add(e.id)) merged += e
}
}
@@ -353,7 +381,7 @@ class LiveEventStore(
override suspend fun snapshotIdsForNegentropy(
filters: List<Filter>,
maxEntries: Int?,
): List<IdAndTime> = store.snapshotIdsForNegentropy(filters.strippingSearchExtensions(), maxEntries)
): List<IdAndTime> = store.snapshotIdsForNegentropy(filters, maxEntries)
// ------------------------------------------------------------------
// NIP-77 snapshot cache
@@ -63,7 +63,10 @@ interface RequestContext {
* The pubkeys that have authenticated on this connection via NIP-42. Empty
* when the connection is unauthenticated. Backed by the engine-owned scope
* and read live, so a REQ that arrives after a successful AUTH sees the
* freshly recorded pubkey(s).
* freshly recorded pubkey(s). Each read returns an **immutable snapshot**
* (the engine swaps the set copy-on-write on AUTH), so holding one across
* a long replay is safe — it just won't grow if another AUTH lands
* mid-query.
*/
val authenticatedUsers: Set<HexKey>
}
@@ -72,9 +72,13 @@ class LimitsPolicy(
return PolicyResult.Accepted(if (clamped === cmd.filters) cmd else ReqCmd(cmd.subId, clamped))
}
/**
* A COUNT is clamped by [RelayLimits.maxLimit] but NEVER given
* [RelayLimits.defaultLimit] — see [capLimits].
*/
override fun accept(cmd: CountCmd): PolicyResult<CountCmd> {
subscriptionRejection(cmd.queryId, cmd.filters)?.let { return PolicyResult.Rejected(it) }
val clamped = clampLimits(cmd.filters)
val clamped = capLimits(cmd.filters)
return PolicyResult.Accepted(if (clamped === cmd.filters) cmd else CountCmd(cmd.queryId, clamped))
}
@@ -106,6 +110,28 @@ class LimitsPolicy(
return filters.map { it.copy(limit = targetLimit(it.limit)) }
}
/**
* Cap what a COUNT asks for, without inventing a page size for it.
*
* `defaultLimit` answers "how many events should a REQ return when the
* client names no limit". A COUNT returns no events, so that question has
* no meaning for it — and applying the answer anyway turns every unbounded
* COUNT into `min(matches, defaultLimit)`.
*
* Silently: a relay holding 12,289,614 profiles replied `{"count":500}`,
* which is a plausible-looking number, so a client cannot tell it from the
* truth. The kinds that happened to fall under the default were correct,
* which is what made it survive.
*
* `maxLimit` still applies, because a client that explicitly asks to count
* at most N is asking a question this relay may bound.
*/
private fun capLimits(filters: List<Filter>): List<Filter> {
val max = limits.maxLimit ?: return filters
if (filters.none { it.limit != null && it.limit!! > max }) return filters
return filters.map { if (it.limit != null && it.limit!! > max) it.copy(limit = max) else it }
}
private fun targetLimit(current: Int?): Int? =
when {
current != null && limits.maxLimit != null && current > limits.maxLimit -> limits.maxLimit
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.store
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
/**
* The pubkey that CONTROLS this event for ownership checks — NIP-09 deletion
* authority and NIP-62 vanish targeting. A gift wrap (kind 1059) is signed by
* a random one-time key, so control belongs to its p-tag RECIPIENT (the
* recipient deletes the wraps addressed to them); every other event is
* controlled by its author. One rule, shared by every store — do not re-derive
* it inline.
*/
fun Event.owner(): HexKey = (this as? GiftWrapEvent)?.recipientPubKey() ?: pubKey
@@ -27,7 +27,37 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import kotlin.coroutines.cancellation.CancellationException
/**
* Storage contract for Nostr events: insert, filter-query, count, delete,
* NIP-50 full-text search, and NIP-77 negentropy snapshots.
*
* ## NIP-50 `search` contract
*
* Query/count/delete filters arrive with [Filter.search] **verbatim as the
* client sent it**, `key:value` extension tokens included (`include:spam`,
* `domain:example.com`, `language:en`, …). No layer above the store rewrites
* the string, so each implementation decides which extensions it supports:
*
* - A store that interprets an extension (rank profiles, trust floors,
* observer-relative scoring, …) reads it from the raw string — parse it
* with [com.vitorpamplona.quartz.nip50Search.SearchQuery.parse].
* - Extensions the store does NOT support must be **ignored, not matched as
* literal text and not treated as match-nothing** (NIP-50: relays "SHOULD
* ignore extensions they don't support"). Stores whose text engine would
* choke on the raw tokens strip them first with
* [com.vitorpamplona.quartz.nip50Search.strippingSearchExtensions] — this
* is what the built-in SQLite and filesystem stores do. An extensions-only
* search therefore collapses to an unconstrained query.
*
* ## Caller identity
*
* Ranked/observer-relative stores can read the caller's NIP-42-authenticated
* identity from the coroutine context via [StoreQueryContext]; the relay
* layer installs it around every REQ/COUNT-driven store call. It is ranking
* context only and absent for unauthenticated callers.
*/
interface IEventStore : AutoCloseable {
companion object {
/**
@@ -37,6 +67,12 @@ interface IEventStore : AutoCloseable {
* small enough that a pause request is honoured promptly.
*/
const val DEFAULT_FTS_REINDEX_BATCH = 1000
/**
* Suggested [snapshotIdsForNegentropy] `onProgress` cadence: report
* the running count roughly every this many collected entries.
*/
const val NEGENTROPY_PROGRESS_EVERY = 1000
}
/**
@@ -57,37 +93,64 @@ interface IEventStore : AutoCloseable {
/**
* Per-row outcome from [batchInsert]. The OK frame on the wire is
* built from this — `Accepted` becomes `OK true`, `Rejected.reason`
* becomes the false reason. NIP-01 says OK pairs to its EVENT by
* built from this — `Accepted` becomes `OK true`, the other two
* become the false reason. NIP-01 says OK pairs to its EVENT by
* id, not by order, so callers may dispatch outcomes in any order.
*/
sealed class InsertOutcome {
data object Accepted : InsertOutcome()
/**
* The EVENT's fault: policy said no — a duplicate, an expired
* or invalid event, a blocked author. Final: re-offering the
* same event yields the same answer, so dropping it is correct.
*/
data class Rejected(
val reason: String,
) : InsertOutcome()
/**
* The STORE's fault: the event was acceptable but could not be
* written — schema drift, a failed feed, a resource error. The
* event is lost unless the caller re-offers it, and nothing
* else will. Callers should count these apart from [Rejected]:
* a rising [Rejected] is usually the protocol working
* (duplicates on a wide fan-out), while a rising [Failed]
* means the store is losing good events.
*/
data class Failed(
val reason: String,
) : InsertOutcome()
}
/**
* Bulk insert in a single transaction with per-row error isolation.
* Returns one outcome per input event in the same order.
* Bulk insert with per-row attribution. Returns one outcome per
* input event in the same order.
*
* Implementations must isolate per-row failures so one bad event
* doesn't roll back the others (SQLite uses SAVEPOINTs). If the
* outer commit itself fails, every entry in the returned list is
* `Rejected` with the commit-failure reason.
* Implementations must isolate per-row problems so one bad event
* never costs the batch: a policy refusal is [InsertOutcome.Rejected],
* a store-side write error is [InsertOutcome.Failed] (SQLite uses
* SAVEPOINTs for the isolation). Throwing is reserved for failures
* with no per-event answer — the engine unreachable, the transaction
* never started — and a caller may read a throw as "nothing in this
* batch was written".
*
* Default impl runs each insert in its own transaction — correct
* but loses the group-commit win. SQLite overrides this.
* but loses the group-commit win — and cannot classify a throw from
* [insert], so it reports `Failed`: re-offering a duplicate is
* idempotent, while dropping a good event on a transient store
* error is not. Implementations that can tell a refusal from a
* write error should override and say which.
*/
suspend fun batchInsert(events: List<Event>): List<InsertOutcome> =
events.map { event ->
try {
insert(event)
InsertOutcome.Accepted
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
InsertOutcome.Rejected(e.message ?: e::class.simpleName ?: "insert failed")
InsertOutcome.Failed(e.message ?: e::class.simpleName ?: RejectionReason.INSERT_FAILED)
}
}
@@ -173,6 +236,13 @@ interface IEventStore : AutoCloseable {
* guard). The +1 sentinel lets the caller distinguish "exactly
* capped" from "too many to fit".
*
* [onProgress] is a liveness hook for corpora large enough that the
* walk takes minutes: implementations SHOULD invoke it every
* [NEGENTROPY_PROGRESS_EVERY]-ish collected entries with the running
* count. Callers must not rely on any particular cadence — a store
* that answers from an index may legitimately never call it. Pass
* `null` (the default) to opt out at zero cost.
*
* Default implementation falls back to the full-decode path so
* non-SQLite stores stay correct; SQLite overrides with a direct
* `SELECT id, created_at` against the `query_by_created_at_id`
@@ -182,8 +252,13 @@ interface IEventStore : AutoCloseable {
suspend fun snapshotIdsForNegentropy(
filters: List<Filter>,
maxEntries: Int? = null,
onProgress: ((collected: Int) -> Unit)? = null,
): List<IdAndTime> {
val all = query<Event>(filters).map { IdAndTime(it.createdAt, it.id) }
val all = ArrayList<IdAndTime>()
query<Event>(filters) { event ->
all.add(IdAndTime(event.createdAt, event.id))
if (onProgress != null && all.size % NEGENTROPY_PROGRESS_EVERY == 0) onProgress(all.size)
}
return if (maxEntries != null && all.size > maxEntries + 1) {
all.subList(0, maxEntries + 1)
} else {
@@ -258,6 +333,11 @@ interface IEventStore : AutoCloseable {
*
* Process roughly [batchSize] events starting from [resumeFrom]
* (`null` = from the beginning) and return a [FtsReindexProgress].
* [resumeFrom] / [FtsReindexProgress.cursor] is an **opaque,
* store-defined string**: callers persist it and pass it back
* unchanged, and must never parse, order, or compare it (SQLite
* encodes a kind + row id; another store may carry an engine
* continuation token).
* Drive it in a loop, feeding [FtsReindexProgress.cursor] back in,
* until [FtsReindexProgress.done] is `true`:
*
@@ -52,15 +52,17 @@ object NdjsonImportExport {
val imported: Long,
/** Events the store rejected — overwhelmingly duplicates (unique-id). */
val rejected: Long,
/** Good events the store could not write — a store-side error, not the event's. */
val failed: Long,
/** Events dropped for a bad signature (only when verifying). */
val invalid: Long,
/** Lines that didn't parse as a NIP-01 event. */
val malformed: Long,
) {
operator fun plus(o: ImportStats) = ImportStats(read + o.read, imported + o.imported, rejected + o.rejected, invalid + o.invalid, malformed + o.malformed)
operator fun plus(o: ImportStats) = ImportStats(read + o.read, imported + o.imported, rejected + o.rejected, failed + o.failed, invalid + o.invalid, malformed + o.malformed)
companion object {
val ZERO = ImportStats(0, 0, 0, 0, 0)
val ZERO = ImportStats(0, 0, 0, 0, 0, 0)
}
}
@@ -81,6 +83,7 @@ object NdjsonImportExport {
var read = 0L
var imported = 0L
var rejected = 0L
var failed = 0L
var invalid = 0L
var malformed = 0L
val batch = ArrayList<Event>(batchSize)
@@ -91,6 +94,7 @@ object NdjsonImportExport {
when (outcome) {
IEventStore.InsertOutcome.Accepted -> imported++
is IEventStore.InsertOutcome.Rejected -> rejected++
is IEventStore.InsertOutcome.Failed -> failed++
}
}
batch.clear()
@@ -112,7 +116,7 @@ object NdjsonImportExport {
if (batch.size >= batchSize) flush()
}
flush()
return ImportStats(read, imported, rejected, invalid, malformed)
return ImportStats(read, imported, rejected, failed, invalid, malformed)
}
/**
@@ -103,7 +103,7 @@ class ObservableEventStore(
// order. Already-expired ephemerals are dropped (matching
// [insert]). Accepted events are emitted on [_changes] only
// after the inner batch returns, so a commit failure that
// converts everything to Rejected suppresses the emits.
// converts everything to Failed suppresses the emits.
if (events.isEmpty()) return emptyList()
val outcomes = arrayOfNulls<IEventStore.InsertOutcome>(events.size)
@@ -114,7 +114,7 @@ class ObservableEventStore(
if (event.kind.isEphemeral()) {
outcomes[i] =
if (event.isExpired()) {
IEventStore.InsertOutcome.Rejected("blocked: Cannot insert an expired event")
IEventStore.InsertOutcome.Rejected(RejectionReason.EXPIRED)
} else {
IEventStore.InsertOutcome.Accepted
}
@@ -192,7 +192,8 @@ class ObservableEventStore(
override suspend fun snapshotIdsForNegentropy(
filters: List<Filter>,
maxEntries: Int?,
): List<IdAndTime> = inner.snapshotIdsForNegentropy(filters, maxEntries)
onProgress: ((collected: Int) -> Unit)?,
): List<IdAndTime> = inner.snapshotIdsForNegentropy(filters, maxEntries, onProgress)
override suspend fun liveNegentropySnapshot(maxEntries: Int) = inner.liveNegentropySnapshot(maxEntries)
@@ -0,0 +1,54 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.store
/**
* The machine-readable insert-rejection vocabulary, shared by every
* [IEventStore] implementation so the same condition always rejects with the
* same words — a caller tallying [IEventStore.InsertOutcome.Rejected] reasons,
* or a relay building `OK false` frames, must never see two stores spell
* "duplicate" differently.
*
* NIP-01: an `OK false` message SHOULD begin with a single-word
* machine-readable prefix followed by `:`. The full-sentence constants here
* are the standard reasons the built-in stores emit. `replaced:` is not in
* NIP-01 but is the de-facto prefix (strfry and others) for a replaceable
* event that lost to a stored newer version — distinct from `duplicate:`
* (the exact event is already held).
*/
object RejectionReason {
/**
* The NIP-01 prefix vocabulary itself lives in
* [com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix]
* — use its `format`/`parse` instead of hand-writing prefixes. The one
* prefix that enum lacks is the de-facto (not in NIP-01) word for a stale
* version of a replaceable/addressable event:
*/
const val PREFIX_REPLACED = "replaced:"
// The standard store reasons.
const val DUPLICATE = "duplicate: already have this event"
const val EXPIRED = "blocked: Cannot insert an expired event"
const val DELETED = "blocked: a deletion event exists"
const val VANISHED = "blocked: a request to vanish event exists"
const val REPLACED = "replaced: a newer version exists"
const val INSERT_FAILED = "error: insert failed"
}
@@ -0,0 +1,65 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.store
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext
/**
* The published caller-identity seam between a relay layer and an
* [IEventStore]: who is asking, carried on the coroutine context so it
* crosses the store boundary — and any decorator in between — without
* widening every `query`/`count` signature.
*
* The storage-backed relay path (`LiveEventStore`) installs this element
* around every REQ/COUNT-driven store call when the connection has
* NIP-42-authenticated pubkeys. A store whose results are
* observer-relative — web-of-trust ranking, "for-you" relevance, trust
* floors — reads it back:
*
* ```
* val observer = coroutineContext[StoreQueryContext]?.observer
* ```
*
* Contract: this is **ranking context only**. It may reorder or score
* results; it must never change *which* events match a filter — access
* control belongs to the relay policy layer, not the store. The element
* is absent for unauthenticated callers (and for direct store use outside
* a relay), so every read needs a null-tolerant fallback such as an
* operator-configured default observer.
*/
class StoreQueryContext(
/**
* The pubkeys authenticated on the calling connection via NIP-42, in
* no particular order. Never empty — the relay layer skips installing
* the element instead of installing an empty one.
*/
val authenticatedUsers: Set<HexKey>,
) : AbstractCoroutineContextElement(StoreQueryContext) {
companion object Key : CoroutineContext.Key<StoreQueryContext>
/**
* Convenience for the common single-identity case: one of
* [authenticatedUsers], or `null` when the set is empty.
*/
val observer: HexKey? get() = authenticatedUsers.firstOrNull()
}
@@ -25,7 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.AddressSerializer
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip01Core.store.owner
class EventIndexesModule(
val hasher: (db: SQLiteConnection) -> TagNameValueHasher,
@@ -206,12 +206,8 @@ class EventIndexesModule(
val kindLong = event.kind.toLong()
val pubkeyHash = hasher.hash(event.pubKey)
val eventOwnerHash =
if (event is GiftWrapEvent) {
event.recipientPubKey()?.let { hasher.hash(it) } ?: pubkeyHash
} else {
pubkeyHash
}
val ownerKey = event.owner()
val eventOwnerHash = if (ownerKey == event.pubKey) pubkeyHash else hasher.hash(ownerKey)
val eTagHash = hasher.hashETag(event.id)
@@ -82,7 +82,8 @@ class EventStore(
override suspend fun snapshotIdsForNegentropy(
filters: List<Filter>,
maxEntries: Int?,
): List<IdAndTime> = store.snapshotIdsForNegentropy(filters, maxEntries)
onProgress: ((collected: Int) -> Unit)?,
): List<IdAndTime> = store.snapshotIdsForNegentropy(filters, maxEntries, onProgress)
override suspend fun liveNegentropySnapshot(maxEntries: Int) = store.liveNegentropySnapshot(maxEntries)
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite
import androidx.sqlite.SQLiteConnection
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.store.RejectionReason
import com.vitorpamplona.quartz.nip40Expiration.expiration
class ExpirationModule : IModule {
@@ -44,7 +45,7 @@ class ExpirationModule : IModule {
FOR EACH ROW
BEGIN
-- Check for existing newer record
SELECT RAISE(ABORT, 'blocked: this event is expired')
SELECT RAISE(ABORT, '${RejectionReason.EXPIRED}')
WHERE NEW.expiration <= unixepoch();
END;
""".trimIndent(),
@@ -21,6 +21,7 @@
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import com.vitorpamplona.quartz.nip01Core.core.Tag
import com.vitorpamplona.quartz.nip01Core.tags.isIndexableTagName
interface IndexingStrategy {
/**
@@ -165,5 +166,5 @@ class DefaultIndexingStrategy(
override fun shouldIndex(
kind: Int,
tag: Tag,
) = tag.size >= 2 && tag[0].length == 1
) = tag.size >= 2 && isIndexableTagName(tag[0])
}
@@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Kind
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.core.isAddressable
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip01Core.store.IdAndTime
import com.vitorpamplona.quartz.nip01Core.store.RawEvent
import com.vitorpamplona.quartz.nip01Core.store.sqlite.sql.where
@@ -258,6 +259,7 @@ class QueryBuilder(
filters: List<Filter>,
db: SQLiteConnection,
maxEntries: Int? = null,
onProgress: ((collected: Int) -> Unit)? = null,
): List<IdAndTime> {
val inner =
if (filters.size == 1) {
@@ -278,7 +280,7 @@ class QueryBuilder(
} else {
inner
}
return db.runIdAndTimeQuery(query)
return db.runIdAndTimeQuery(query, onProgress)
}
private fun toSnapshotIdsSql(
@@ -454,7 +456,10 @@ class QueryBuilder(
return QuerySpec(sql, clause.args)
}
private fun SQLiteConnection.runIdAndTimeQuery(query: QuerySpec): List<IdAndTime> =
private fun SQLiteConnection.runIdAndTimeQuery(
query: QuerySpec,
onProgress: ((collected: Int) -> Unit)? = null,
): List<IdAndTime> =
prepare(query.sql).use { stmt ->
query.args.forEachIndexed { index, arg ->
stmt.bindText(index + 1, arg)
@@ -462,6 +467,7 @@ class QueryBuilder(
val results = ArrayList<IdAndTime>()
while (stmt.step()) {
results.add(IdAndTime(stmt.getLong(1), stmt.getText(0)))
if (onProgress != null && results.size % IEventStore.NEGENTROPY_PROGRESS_EVERY == 0) onProgress(results.size)
}
results
}

Some files were not shown because too many files have changed in this diff Show More