mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-06 14:54:36 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb95cad98b | ||
|
|
74145ee8f3 | ||
|
|
42a91ffb79 | ||
|
|
e822911d09 | ||
|
|
5c56444054 | ||
|
|
dc45477deb | ||
|
|
f9bef87160 | ||
|
|
a58289b62c | ||
|
|
70d51cc98b | ||
|
|
067d68b89c | ||
|
|
129401bdaf | ||
|
|
8f8713d825 | ||
|
|
21b02d780f | ||
|
|
d3bd7a45b9 | ||
|
|
a5d2d153c8 | ||
|
|
3a702add57 | ||
|
|
5ec35c7772 | ||
|
|
9a4f6c6cdd | ||
|
|
c767298368 | ||
|
|
ad3ce4d1a1 | ||
|
|
0d0117061a | ||
|
|
fd5bd994a1 | ||
|
|
a7eec1d605 | ||
|
|
d9a58950ec | ||
|
|
ba7cc72dd2 | ||
|
|
156176f5fe | ||
|
|
1a25557bdb | ||
|
|
b48b87a60b | ||
|
|
cdbd550405 | ||
|
|
c029b271e7 | ||
|
|
4081ef1681 | ||
|
|
804b850095 | ||
|
|
42fc73f6cb | ||
|
|
19d8e3069d | ||
|
|
9231195890 | ||
|
|
564cafedc4 | ||
|
|
e54a546d10 | ||
|
|
b46063713e | ||
|
|
d54e54b48a | ||
|
|
2b2e47256b | ||
|
|
7b29f57526 | ||
|
|
a110ce0a30 | ||
|
|
ba78eb599e | ||
|
|
5896ae5eff | ||
|
|
fdc68e801a | ||
|
|
30742ab352 | ||
|
|
1622bd7109 | ||
|
|
64073f8fa9 | ||
|
|
3ef61bafcb | ||
|
|
ba35a87a7c | ||
|
|
ac6034f764 | ||
|
|
53be8d1755 | ||
|
|
431ad153bc | ||
|
|
bf41e75b78 | ||
|
|
a76a1911ea | ||
|
|
e10f76665f | ||
|
|
6cd91bb058 | ||
|
|
7922f9a4d1 | ||
|
|
0cc3f72f55 | ||
|
|
31c3eaf2b3 | ||
|
|
e0ea365368 | ||
|
|
dc1f0dd995 | ||
|
|
7d91931b75 | ||
|
|
f2e7a07a97 | ||
|
|
2e90cc24ba | ||
|
|
ebd9a163bc |
@@ -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 F01–F13, W01–W08, D01–D08, C01, S01–S06, 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 0–23, 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 0–100 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`.
|
||||
@@ -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` |
|
||||
| 1630–1633 | 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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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 (~10–20s 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))
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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 } }
|
||||
|
||||
+1
-1
@@ -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 }
|
||||
}
|
||||
|
||||
+8
-3
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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 }
|
||||
}
|
||||
|
||||
+2
-2
@@ -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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+2
-2
@@ -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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+2
-2
@@ -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 }
|
||||
}
|
||||
|
||||
+22
@@ -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",
|
||||
|
||||
+1
-1
@@ -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]
|
||||
|
||||
+1
-1
@@ -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
@@ -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>
|
||||
|
||||
+15
-15
@@ -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 }
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+10
@@ -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,
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -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
|
||||
|
||||
+76
@@ -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>())
|
||||
}
|
||||
}
|
||||
+28
@@ -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"
|
||||
|
||||
+70
@@ -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))
|
||||
}
|
||||
}
|
||||
+13
@@ -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"
|
||||
|
||||
|
||||
+86
@@ -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())
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -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())
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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" "$@"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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) }
|
||||
}
|
||||
|
||||
+42
-6
@@ -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
|
||||
|
||||
@@ -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": []
|
||||
|
||||
@@ -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
@@ -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 ~1–2 (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.9–5.0µs vs 85–196µ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,230–180,250 | 33,777 | 475 |
|
||||
| parking, one lock per sub | 103,780 / 99,160 / 86,120–130,680 | 30,235 | 510 |
|
||||
| **striped, per (sub, relay)** | **88,953 / 82,160 / 72,860–111,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.
|
||||
+36
@@ -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()
|
||||
}
|
||||
+3
-3
@@ -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")
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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 }
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
|
||||
+4
-8
@@ -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
|
||||
|
||||
+35
@@ -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
|
||||
}
|
||||
+81
@@ -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.
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -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()
|
||||
}
|
||||
|
||||
+74
-35
@@ -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
|
||||
|
||||
+17
-17
@@ -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) }
|
||||
|
||||
+80
-38
@@ -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,
|
||||
)
|
||||
|
||||
+6
-3
@@ -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)
|
||||
|
||||
+14
-12
@@ -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 {
|
||||
|
||||
+68
-21
@@ -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()
|
||||
|
||||
+1
-51
@@ -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
|
||||
|
||||
+17
-3
@@ -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,
|
||||
)
|
||||
|
||||
+47
-5
@@ -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)
|
||||
|
||||
+482
@@ -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)
|
||||
}
|
||||
}
|
||||
+141
@@ -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()
|
||||
}
|
||||
}
|
||||
+8
-4
@@ -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
|
||||
}
|
||||
|
||||
+28
-27
@@ -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)
|
||||
}
|
||||
|
||||
+139
-89
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -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) }
|
||||
}
|
||||
|
||||
+6
-1
@@ -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)
|
||||
|
||||
+359
-27
@@ -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 {
|
||||
|
||||
+20
-2
@@ -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, ""))
|
||||
}
|
||||
|
||||
+12
-5
@@ -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
|
||||
|
||||
+47
-19
@@ -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 ignored — an 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
|
||||
|
||||
+4
-1
@@ -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>
|
||||
}
|
||||
|
||||
+27
-1
@@ -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
|
||||
+91
-11
@@ -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`:
|
||||
*
|
||||
|
||||
+7
-3
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+4
-3
@@ -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)
|
||||
|
||||
|
||||
+54
@@ -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"
|
||||
}
|
||||
+65
@@ -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()
|
||||
}
|
||||
+3
-7
@@ -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)
|
||||
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
|
||||
+2
-1
@@ -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(),
|
||||
|
||||
+2
-1
@@ -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])
|
||||
}
|
||||
|
||||
+8
-2
@@ -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
Reference in New Issue
Block a user