Compare commits

...

29 Commits

Author SHA1 Message Date
Vitor Pamplona
72b8df7f6d Merge pull request #3663 from vitorpamplona/claude/sqlite-query-performance-bh2j27
SQLite query scaling: FTS contentless, tag-merge, pooled statements
2026-07-21 19:38:22 -04:00
Claude
d571bee394 fix(store): rank multi-filter search REQs + harden merge from branch audit
Two-reviewer adversarial audit of the branch found no correctness/data-loss/
crash bugs. This closes one gap and applies three robustness fixes:

- Multi-filter search ordering: a REQ whose filters all carry a search term
  (e.g. the client's search-across-kinds) was created_at-ordered via the union
  path. Now relevance-ordered — unionSubqueriesIfNeeded(projectRank) projects
  rank per branch, UNION ALL + GROUP BY row_id MIN(rank) dedups across branches
  keeping the best score. Only when every branch is a search branch; mixed
  search/non-search REQs and count/delete unions stay as before.
- prepareAuthorStreams/prepareTagStreams build cursors via buildStreams, which
  closes already-prepared statements if a later prepare throws (was: stranded
  checked-out, un-reset handles holding read locks in the pooled connection).
- Stream counts computed as Long so authors×kinds / values×kinds can't overflow
  Int back into the eligible band and route a huge fan-out into the merge.
- Renamed CachedStatement.finalize() -> finalizeStatement(): a no-arg finalize()
  is the JVM Object.finalize, risking a GC double-close of the native handle.

Tests: multi-filter search relevance + cross-branch dedup + count parity;
existing merge/cache/search suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqGuBuSUsRudGerPqDuoKA
2026-07-21 23:33:33 +00:00
Claude
52ba19d324 test(store): adversarial search-rank cases (two-tag+search, count parity, non-searchable delete)
From the branch audit: verifies the search-relevance path holds under a
two-tag-key filter (requires both tags, ranks by bm25, no duplicate rows),
that count(filter) matches query size under a limit smaller than the match set
(NIP-45), and that deleting a non-searchable event (no FTS row) fires the
contentless delete trigger against an absent rowid harmlessly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqGuBuSUsRudGerPqDuoKA
2026-07-21 23:12:59 +00:00
Claude
de114d1621 fix(store): relevance-order search+tag queries too, not just tag-free search
Any filter with a search term must be relevance-ranked (NIP-50), but only the
tag-free shape (makeSimpleSearch) was — search + a tag fell through to the
row-id-subquery path (prepareRowIDSubQueries/makeQueryIn), which ordered by
created_at.

prepareRowIDSubQueries gains projectRank: when a search filter joins event_fts,
it also projects the bm25 score as a `rank` column and cuts its LIMIT by rank
(most relevant, not newest); makeQueryIn(orderByRank) then presents the joined
result by that rank, created_at DESC as tie-break. Off by default, so
count/delete/union/negentropy (which must stay single-column and unranked) are
untouched. toSql wires it on whenever the filter carries a search term.

SearchRelevanceOrderTest adds a tag-scoped case (stronger-but-older outranks
weaker-but-newer, wrong-tag and non-matching excluded, limit cuts by score).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqGuBuSUsRudGerPqDuoKA
2026-07-21 22:54:47 +00:00
Claude
8e7b1b63be fix(store): order NIP-50 search by relevance (bm25), not created_at
NIP-50: results are returned "in descending order by quality of search result
... not by the usual .created_at", with the limit applied after the score. The
store sorted search by created_at DESC (pre-existing), so it returned the
newest matches rather than the best ones.

makeSimpleSearch (the search [+ kinds/authors/since/until] + limit shape) now
orders by FTS5 bm25 (ORDER BY event_fts.rank, created_at DESC as a tie-break).
Verified bm25 rank works on the contentless table through the join, and that a
stronger-but-older match outranks a weaker-but-newer one. The rarer
search+specific-tag shape and the negentropy snapshot still sort by created_at
(the row-id subquery can't carry rank; negentropy is a sync set) — documented.

This is a correctness fix, not a scaling one: bm25 scores every match, so
search latency still grows with the match set. Tests: SearchRelevanceOrderTest,
Fts5CapabilityProbe.bm25RankWorksOnContentlessTableInAJoin; QueryAssemblerTest
search plans updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqGuBuSUsRudGerPqDuoKA
2026-07-21 22:21:54 +00:00
Claude
e6a8f80826 fix(store): drop non-compliant rowid search ordering, keep FTS delete/size wins
searchOrderByRowId ordered NIP-50 search by the FTS rowid (ingestion order) to
get O(limit) search, but NIP-01's limit requires the newest events by
created_at. Once ingestion diverges from created_at (any historical sync) that
returns the wrong events under a limit — a spec violation — so the flag, its
QueryBuilder branch, and its test are removed. Search stays created_at-ordered;
corpus-independent search is an external-engine job, not this index.

The contentless + rowid=row_id schema stays for the reasons that don't touch
ordering: the delete trigger now seeks by rowid (O(log n)) instead of scanning
by an FTS column (O(n)) — measured ~78× faster at 8k rows and widening, on a
path every deletion hits — and the index is smaller. Benchmark reframed around
the delete win and the honest (unchanged) search cost; plan doc updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqGuBuSUsRudGerPqDuoKA
2026-07-21 22:05:30 +00:00
Claude
c7670a52d4 perf(store): scale NIP-50 search and tag-watcher queries with corpus size
The scale-curve report showed the SQLite store degrading with corpus size on
NIP-50 search (~18×) and the large-IN tag watcher, while point reads stayed
flat. Three read/size changes (write path and index set unchanged):

- FTS: rebuild event_fts as a contentless FTS5 table (content='',
  contentless_delete=1) keyed by rowid = event_headers.row_id. Drops the
  stored content copy (smaller index); external-content can't hold the
  derived indexable text, so contentless is the correct primitive. Adds an
  opt-in searchOrderByRowId strategy flag: ORDER BY event_fts.rowid DESC
  early-terminates (O(limit), corpus-independent) at the cost of
  ingestion-order results — flat ~0.22 ms vs created_at's 4.26 ms at 200k
  (~19×). reindexAll ends with 'optimize'; the periodic optimize() folds in a
  bounded segment 'merge'. DB version 4->5 with a drop-and-rebuild migration.

- MergeQueryExecutor: extend the k-way merge to the tag path (kinds + #e IN
  [hundreds] + limit), one cursor per (value[,kind]) stream heap-merged to the
  limit, deduping events that carry several queried values. O(limit + streams)
  instead of collecting all matches and sorting.

- StatementCachingConnection: pool multiple handles per SQL so the merge's
  many concurrent identical-SQL cursors all hit the cache (previously only the
  first did) and repeated polls reuse their per-stream statements.

Tests: contentless migration (real v4 DB upgrade), rowid-order search, tag
merge correctness (incl. cross-stream dedup), statement pool, FTS5 capability
probe, and an FTS search-scaling benchmark. Plan in
quartz/plans/2026-07-21-sqlite-query-scaling.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqGuBuSUsRudGerPqDuoKA
2026-07-21 21:45:42 +00:00
Vitor Pamplona
35e61e1d98 Merge pull request #3662 from vitorpamplona/fix/docs-license-badge-and-skill-md
docs: fix license badge and refresh stale SKILL.md
2026-07-21 17:38:07 -04:00
Vitor Pamplona
6730100853 docs: fix license badge and refresh stale SKILL.md
The README license badge label read "Apache-2.0" while LICENSE, PRIVACY.md
and every source header are MIT. Only the static label text was wrong (the
shields.io endpoint auto-detects), but it is the license on the front page.

SKILL.md had drifted from the codebase since the Kotlin DSL migration:

- All Gradle references pointed at Groovy `build.gradle` / `settings.gradle`;
  the repo is `.gradle.kts` throughout. Converted the snippets to Kotlin DSL
  and matched the repo's existing `getByName("release")` style.
- The plugins block listed `jetbrainsKotlinAndroid` (gone) and omitted
  `serialization` and `googleKsp`.
- compileSdk is 37, not 35. Added a pointer to libs.versions.toml so the
  number has a source of truth rather than drifting again.
- The client-tag section told readers to create
  `nip01Core/tags/clientTag/TagArrayBuilderExt.kt` and edit both `build()`
  functions in TextNoteEvent. That file already exists at
  `nip89AppHandlers/clientTag/`, and the tag is now applied centrally by the
  NostrSignerWithClientTag decorator — so rebranding is a one-constant edit
  to CLIENT_TAG_NAME.
- Default relays pointed at `quartz/src/main/java/...`, a path that does not
  exist in the KMP layout; they live in commons `defaults/`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 17:27:39 -04:00
Vitor Pamplona
69671d0009 Merge pull request #3660 from vitorpamplona/claude/nostr-relay-filters-mapping-bu95ib
Optimize relay query performance: cost-based driver selection, tag-author indexing, and fanout memoization
2026-07-21 15:35:38 -04:00
Claude
078758888a perf(relay): remove live-path allocations in LiveEventStore + FilterIndex
The remaining SmallReqFloorBenchmark waste, on the per-row replay, the
per-event live fanout, and the per-accepted-event index probe:

- LiveEventStore replay dedupe: a SeenIds holder with an inline lock
  replaces the local-fn-plus-lambda that allocated one closure per
  streamed row (and again per live delivery). Its HashSet is created
  empty so the JVM defers the backing table to the first add — a 0-row
  replay no longer allocates a 1024-slot table (was ~4 MB across the
  benchmark's 1000 idle subs).
- Live fanout serializes the event body once and passes it through
  onEachLive(event, body); RelaySession splices it into the per-sub
  frame prefix. An event matching N live subscriptions paid N identical
  Jackson passes before; now one. queryRaw's onEachLive signature gains
  the body arg (EventSourceBackend default serializes inline, no
  cross-sub memo, no regression). Measured: fanout 1->200 live subs
  0.50 ms (2.5 us/sub).
- FilterIndex holds subscribers in one persistent map per dimension, so
  candidatesFor (once per accepted ingest event) probes with the event's
  own fields and allocates no IdKey/AuthorKey/KindKey/TagKey wrappers;
  BucketKey now lives only in the rare register/unregister bookkeeping.

SmallReqFloorBenchmark grows a fanout stage (200 live subs, one submit)
to anchor the fanout number; it drives `live` directly and guards the
await with withTimeout so a future fanout regression fails fast.

Verified: quartz relay.server + FilterIndex suites (110 tests),
SmallReqFloorBenchmark, geode suite (126 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
2026-07-21 19:23:38 +00:00
Claude
387bfe99ee perf(relay): drop per-op allocations on the frame + search paths
Three hot-path allocation cuts the SmallReqFloorBenchmark stages flagged:

- strippingSearchExtensions: index-loop guard returns the same list with
  zero allocation when no filter carries a search term (every non-search
  REQ/COUNT/snapshot, the overwhelming majority).
- EoseMessage/OkMessage: direct-buildString wire form on the escape-free
  fast path (EOSE per REQ, OK per publish), skipping the generic
  serializer's node tree; exotic subIds/reasons fall back. Shared
  isEscapeFreeAscii helper in WireJson.kt, mirroring NegMsgMessage.

Verified: quartz relay.server + message-frame suites (110 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
2026-07-21 19:23:21 +00:00
Vitor Pamplona
4df7354ffc Merge pull request #3658 from vitorpamplona/fix/concord-messages-cold-boot
fix: Concord groups missing or slow on the Messages tab after a cold boot
2026-07-21 14:38:08 -04:00
Vitor Pamplona
a11de43400 Merge pull request #3659 from vitorpamplona/fix/persist-keypackage-algo-lists
fix: persist the MIP-00 key-package and favorite-algo-feeds lists
2026-07-21 14:37:54 -04:00
Vitor Pamplona
0a228a935b fix: persist the MIP-00 key-package and favorite-algo-feeds lists
AccountSettings declares 25 backup* fields and saves each on change, but two
were never wired into LocalPreferences — neither written to nor read back from
the encrypted prefs:

  - backupKeyPackageRelayList  (MIP-00, Marmot/MLS)
  - backupFavoriteAlgoFeedsList (kind 10090)

Both only ever existed for the lifetime of the process. Their consumers already
implement the restore-from-backup path — KeyPackageRelayListState's
normalizeKeyPackageRelayListWithBackup falls back to the field, and
FavoriteAlgoFeedsListState's init seeds the cache from it — so that code was
dead after every cold boot and the value read as empty until relays answered.

For the key-package list that matters beyond latency: it feeds
Account.publishRelaysFor(), which decides where this account's key packages are
published so others can add it to groups, and Account.updateKeyPackageRelays()
reads it as the *previous* list when computing an update.

Found by auditing all 25 backup* fields against their five LocalPreferences
wiring sites after the same gap turned up for the Concord community list; the
other 23, NIP-29's relay-group list included, are correctly wired.

Verified on device with a persist-then-cold-boot pair: the key-package list is
absent on the first boot and restores 1.6 s after the second. The favorite algo
feeds list could not be exercised on this account (it has no kind 10090, so
there is nothing to persist); it is wired identically and its type arguments
are compiler-checked, but it is not verified end to end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 14:23:38 -04:00
Vitor Pamplona
9ddf571b11 fix: persist the Concord community list so cold boot doesn't refetch it
AccountSettings has held backupConcordList and saved it on change since the
feature landed, but the field was never wired into LocalPreferences: it was
neither written to nor read back from the encrypted prefs. So it only ever
existed for the lifetime of the process, concordList() returned null on every
cold boot, and the kind-13302 joined-communities list had to be refetched from
relays before a single Concord plane could be subscribed.

Every sibling list — channel, community, hashtag, geohash, ephemeral chat,
relay group, trust provider — is persisted this way; Concord was the one that
was missed. That made it the only chat type whose rooms could not appear until
the network answered, which is the bulk of the cold-boot delay: the joined list
gates the control-plane REQ, the control plane gates the fold, and the fold
gates the channels.

Measured on device, boot -> first Concord plane wrap:
  - without the backup: liveCommunities sat empty for ~56 s waiting on the
    13302 fetch (first arrival from nostr.mom), first wrap at +45 s
  - with the backup restored: list decoded 1.6 s after boot (30 ms), first
    wrap at +6 s

Wired the same five sites the other lists use (pref key, save, read, parse,
restore). Prefs are encrypted and backupCashuWallet already sets the precedent
for persisting a secret-bearing event, so the community roots in the 13302
content are stored no differently than the wallet's.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 14:09:59 -04:00
Vitor Pamplona
1fee674350 fix: keep event-less placeholder rooms when a deletion arrives
The additive feed path re-filters the existing list whenever an incoming batch
contains a kind-5, dropping notes whose event has been deleted. Event-less
notes fell into the else branch and returned false, so they were dropped too.

An event-less row is a placeholder the filter synthesizes for a room with no
message yet — a just-joined Concord channel, NIP-29 group, Marmot group or
geohash cell. It carries no event, so it cannot have been deleted. Dropping it
removed every such row from Messages the moment ANY unrelated deletion landed,
and because this is the additive path the rows stayed gone until the next full
rebuild. A community whose channels are all quiet looked like it had never
loaded at all.

Verified on device: surviving Concord placeholders in sort() went 0 -> 14, and
a community that had been absent from Messages entirely now renders all of its
channels. Not Concord-specific — the same placeholderNote() pattern backs
NIP-29, Marmot and geohash rooms.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 13:22:06 -04:00
Vitor Pamplona
50025660a3 perf: cut Concord revision churn and quadratic control-plane re-folds
Cold boot bumped the session revision ~292 times for 3 communities, driving 22
Messages rebuilds and re-deriving every plane subscription each time. Three
compounding causes, all measured on device:

1. Every refold republished state even when the fold was identical.
   ConcordCommunityState and its components were plain classes, so StateFlow
   conflation never applied and a prior-epoch wrap that didn't move the
   anti-rollback floor still counted as a change. Make the fold result compare
   by value (AuthorityResolver holds only immutable value fields; a data class
   with a private constructor is fine).

2. A control wrap bumped twice — once from ingest() returning STRUCTURAL and
   once from the per-session state watcher reacting to the same refold. Add
   ConcordIngestOutcome.STRUCTURAL_FOLD for the two control-plane branches so
   the manager leaves those to the watcher, which (given 1) now fires only on
   genuine change. Guestbook and base-rekey keep STRUCTURAL: they mutate
   members/the rekey buffer, not state, so no watcher covers them.

3. refold() and controlFloorsLocked() re-opened the WHOLE wrap buffer on every
   control wrap, and opening a wrap is a NIP-44 decrypt + parse — making a
   backfill quadratic in decryptions (~8.6k opens to ingest 93 wraps for one
   community). Memoize editions by wrap id: one open per wrap, ingest() stays
   synchronous and results are unchanged.

Measured over one cold boot: revision bumps 292 -> 87, Messages rebuilds
22 -> 7, and time from first fold to all 17 channels 43s -> 7.5s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 13:21:54 -04:00
Vitor Pamplona
f67d242f7a fix: show Concord channels on Messages as soon as the control plane folds
A Concord control-plane fold is what first reveals a community's channels and
makes ConcordCommunitySession.state non-null, without which
ChatroomListKnownFeedFilter emits nothing at all for that community. None of it
flows through LocalCache.newEventBundles, so the additive feed path could not
see it: a folded channel only reached the Messages tab if a message for it
happened to arrive afterwards.

Cold boot therefore showed a subset of a community's channels, or omitted a
quiet community entirely, until some unrelated invalidation fired. Measured on
device: the Concord hub reported 3 communities / 17 channels folded in memory
while Messages rendered 3 rows and omitted one community completely.

AccountFeedContentStates already forces a rebuild for the Marmot, NIP-29,
geohash, view-mode and pin flows for exactly this reason; Concord was the one
missing collector. Add it, sampled the same way Account.kt samples this flow to
drive refreshConcordChannelIndex.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 13:21:35 -04:00
Claude
6033957b1c perf(relay): persistent-map FilterIndex snapshots, benchmark the sub population
FilterIndex registration runs on every REQ open/close but built each
new snapshot by copying both full maps — O(S) work and allocation per
REQ with S live subscriptions. Persistent (HAMT) maps keep the
wait-free single-load reads and CAS write loop while making a write
O(keys x log S) with structural sharing.

SmallReqFloorBenchmark grows a B@1k stage (1000 idle parked
subscriptions) to make the population cost visible, and its B stage
now enters queryRaw undispatched like production does: @1000 subs the
per-REQ cost drops 0.225 -> 0.151 ms and the measured population
penalty falls below run noise (was +0.011 ms per REQ).

With this and the undispatched replay, the in-process floor above the
raw store query is ~0.11 ms (was ~0.66 ms as first measured): A 0.120,
B 0.203, C 0.239 ms on a quiet machine.

Verified: FilterIndex tests, quartz relay.server suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
2026-07-21 16:22:13 +00:00
Claude
37923d9101 perf(relay): run the stored REQ replay undispatched
SmallReqFloorBenchmark showed the per-REQ floor on small results is
dominated by pipeline, not the store (raw query 0.125 ms vs 0.785 ms
session REQ->EOSE in-process). Half of the dispatch slice was the
scheduler hop between handleReq's launch and the query coroutine:
starting the job with CoroutineStart.UNDISPATCHED runs the stored
replay and EOSE inline on the receiving coroutine (the reader-pool
acquire doesn't suspend when a connection is free), parking only at
the live tail. Measured: dispatch+frames slice 0.397 -> 0.207 ms.

Commands on a connection are processed sequentially, so nothing can
target the subscription before the job lands in the registry at the
first suspension point.

Verified: quartz relay.server suite, SmallReqFloorBenchmark, geode
full test suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
2026-07-21 16:14:12 +00:00
David Kaspar
a960ceb6a8 Merge pull request #3657 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-21 16:25:36 +01:00
davotoula
3fdb976aa9 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-21 15:24:41 +00:00
davotoula
a6b40cfc0f docs(skill): prevent duplicate-key mistake in find-missing-translations 2026-07-21 16:19:39 +01:00
davotoula
b616f69c1d update cz, se, pt, de 2026-07-21 16:19:31 +01:00
Claude
57ffb3386d feat(geode): enable the tag+kind+pubkey index, refresh measured docs
TagAuthorIndexBenchmark at 1M events settles the flag: the DM-room
shape (kinds + authors + #p, 65 client assembler call sites) drops
14.2 ms -> 0.66 ms (~21x, growing with corpus size) while batch-insert
cost stays inside run noise (49.0 vs 47.4 us/event). Existing relay
DBs build the index on next open via ensureOptionalIndexes.

Also refreshes the docs the numbers made stale: IndexingStrategy KDoc
now records the 200k and 1M measurements instead of a TODO,
MergeQueryExecutor's tag-merge note points at the new relayBench
reactions-watch scenario, FsQueryPlanner/FsDriverSelectionBenchmark
reflect the landed cost-based pick (149 ms -> 4.0 ms at 30k events),
and RELAY.md documents that strategy flag flips materialize indexes on
the next open.

Verified: quartz jvmTest store suites, geode test (126), desktopApp
LocalRelayStore tests (5, incl. reopening a default-strategy DB with
the new pubkey-alone flag), relayBench compiles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
2026-07-21 15:07:12 +00:00
Claude
a3e239d33f feat(store): cost-based FS driver pick, runtime index materialization, new relayBench shapes
Acts on the measured gaps from TagAuthorIndexBenchmark and
FsDriverSelectionBenchmark:

- FsQueryPlanner: replace the fixed tags -> kinds -> authors driver
  order with a cost-based pick. Every legal driver (each tagsAll value,
  each tags key's value union, the kind set, the author set) opens a
  lazy directory iterator; all are drained in lockstep and the first to
  exhaust (the smallest listing) drives, so a giant idx/kind tree is
  never read past ~the smallest candidate's size. Fixes the 149 ms vs
  3.4 ms (~44x at 30k events) authors+kinds+limit regression.

- EventIndexesModule.ensureOptionalIndexes + SQLiteEventStore: flag-
  gated indexes are runtime config, not schema. An idempotent
  CREATE INDEX IF NOT EXISTS pass now runs on every open, so flipping
  an IndexingStrategy flag on an existing DB builds the index without
  a user_version bump.

- Desktop LocalRelayStore: enable indexEventsByPubkeyAlone. Shared
  ViewModels (Nip65RelayList, PrivateOutboxRelayList, VanishRequests)
  replay authors-only filters that full-scanned without
  (pubkey, created_at); existing DBs pick the index up on next open.

- relayBench Scenarios: add "conversation" (tag ∩ author ∩ kind, the
  DM-room shape, 65 client assembler call sites) and "reactions-watch"
  (kind 7 + #e IN 150 hottest notes) so the uncovered archetypes get
  head-to-head numbers vs strfry.

- quartz build: forward tagBenchScale/fsBenchScale to the test JVM.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
2026-07-21 14:54:48 +00:00
Claude
41240eeab9 docs(store): record measured index/planner gaps as TODOs in both stores
Real numbers from the two new benchmarks, so the trade-offs are on the
decision points instead of in a chat log:

- IndexingStrategy.indexTagsWithKindAndPubkey: the KDoc called the
  kinds+authors+tags shape "rarely used", but the client assembler
  survey found 65 call sites. TagAuthorIndexBenchmark @ 200k events:
  DM-room query 9.4 ms -> 0.6 ms (~15x) with the flag on, insert cost
  +14% (41.5 -> 47.3 us/event). TODO: re-evaluate defaults (geode).

- MergeQueryExecutor: tag-path analogue of the follow-feed collect-all
  sort (kinds + #e IN [hundreds] + limit never merges). Measured
  12.8 ms cold / 6.0 ms since-bounded at 200k events; revisit if
  relayBench shows it at relay scale.

- FsQueryPlanner: fixed driver order sends authors+kinds+limit (the
  most common CLI shape) through the kind tree. FsDriverSelection-
  Benchmark @ 30k events: 149 ms -> 3.4 ms (~44x) driving from the
  author tree; TODO: cost-based pick by directory entry counts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
2026-07-21 14:39:21 +00:00
Claude
5988db010d test(store): add tag∩author and FS driver-selection benchmarks
The 2026-07 client filter-assembler survey mapped 551 Filter
constructions to ~12 query archetypes. Two hot shapes had no benchmark
coverage in prodbench or relayBench, and the FS store had none at all:

- TagAuthorIndexBenchmark: the DM-room shape (kinds + authors + #p,
  65 assembler call sites) with indexTagsWithKindAndPubkey off vs on,
  including the insert-cost delta of the extra index; plus the
  reactions watcher (kinds=[7], #e IN 300, limit) cold and
  since-bounded, which has no tag-side k-way merge today.

- FsDriverSelectionBenchmark: FsQueryPlanner's fixed driver order
  (tags → kinds → authors) on authors+kinds+limit — the most common
  CLI shape — comparing the current kind-tree driver against an
  author-tree driver with kind post-filter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
2026-07-21 14:34:57 +00:00
50 changed files with 3495 additions and 353 deletions

View File

@@ -332,6 +332,35 @@ When adding translated strings to locale files:
- **Append new strings at the bottom** of the file, just before the closing `</resources>` tag. - **Append new strings at the bottom** of the file, just before the closing `</resources>` tag.
- Do NOT try to insert them in alphabetical or matching order — a separate process handles ordering. - Do NOT try to insert them in alphabetical or matching order — a separate process handles ordering.
- **Insert into each locale ONLY the keys missing from *that* locale — never a shared "union" block.** Because Crowdin strips keys asymmetrically (Step 2), a key you translate may already exist in some target locales. If you compute one union set of missing keys, translate it, and paste the *same* block into every locale, you will create **duplicate keys** in whichever locales already had them. Drive the insertion off the **per-locale** diff, not the union:
```bash
# For each locale, insert only the keys comm -23 reports missing FOR THAT LOCALE.
for l in cs de-rDE sv-rSE pt-rBR; do
missing=$(comm -23 \
<(grep '<string name=' $base/values/strings.xml | grep -v 'translatable="false"' \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<string name=' $base/values-$l/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort))
# ... append ONLY the $missing keys' translations to values-$l/strings.xml ...
done
```
(This bit us on 2026-07-21: `ps1_save_block`, `podcast_value_for_value`, and `chats_history_relays` were each missing in only *some* commons locales, but the same 3-key block was pasted into all four — producing duplicates in the locales that already had them.)
- **After inserting, verify each edited file has no duplicate keys AND is well-formed XML — before you call the task done.** A duplicate key is not a warning: the `commons` tree's Compose-resources build task fails hard on it (`convertXmlValueResourcesForCommonMain: … Duplicated key '…'`), which breaks the build for everyone. Quick post-insertion gate over every file you touched:
```bash
for f in <every edited strings.xml>; do
dups=$(grep -oE '<(string|plurals) name="[^"]*"' "$f" \
| sed 's/.*name="\([^"]*\)"/\1/' | sort | uniq -d)
[ -n "$dups" ] && echo "DUP in $f: $dups"
python3 -c "import xml.dom.minidom; xml.dom.minidom.parse('$f')" \
|| echo "MALFORMED $f"
done
# For a commons change, also run the build task that enforces this:
# ./gradlew :commons:convertXmlValueResourcesForCommonMain
```
## Common Mistakes ## Common Mistakes
@@ -344,6 +373,7 @@ When adding translated strings to locale files:
- **Trusting a git "sync-timestamp" heuristic to pre-filter the list** — this skill used to skip keys added before the last `New Crowdin translations` commit, on the theory that Crowdin had already "decided" them. It was dropped: a key added shortly before an export that translators hadn't reached yet is genuinely missing, so the heuristic silently dropped real work. Use the raw on-disk diff and reconcile against the Crowdin web UI's untranslated count instead. - **Trusting a git "sync-timestamp" heuristic to pre-filter the list** — this skill used to skip keys added before the last `New Crowdin translations` commit, on the theory that Crowdin had already "decided" them. It was dropped: a key added shortly before an export that translators hadn't reached yet is genuinely missing, so the heuristic silently dropped real work. Use the raw on-disk diff and reconcile against the Crowdin web UI's untranslated count instead.
- **Adding source-identical fallbacks locally** — they get overwritten on the next Crowdin sync. Android falls back to `values/strings.xml` at runtime anyway, so a key intentionally kept as English already renders correctly. Skip these by inspection (brand terms, loanwords, `v%1$s`-style strings); don't translate them to an identical value. - **Adding source-identical fallbacks locally** — they get overwritten on the next Crowdin sync. Android falls back to `values/strings.xml` at runtime anyway, so a key intentionally kept as English already renders correctly. Skip these by inspection (brand terms, loanwords, `v%1$s`-style strings); don't translate them to an identical value.
- **Skipping per-locale diffs when only diffing cs** — Crowdin can strip different keys in different locales (each translator's choice), so cs is not a reliable upper bound. Diff each target locale and union the results. - **Skipping per-locale diffs when only diffing cs** — Crowdin can strip different keys in different locales (each translator's choice), so cs is not a reliable upper bound. Diff each target locale and union the results.
- **Pasting the union set of missing keys into every locale → duplicate keys** — the union is the right set to *translate*, but the wrong set to *insert*. A key missing in only some locales, inserted into all of them, duplicates in the ones that already had it. Drive each file's insertion off its own per-locale diff (see Step 6). In `commons`, a duplicate key is build-breaking: `convertXmlValueResourcesForCommonMain` fails with `Duplicated key '…'`. **Always run the post-insertion duplicate + XML-wellformedness gate in Step 6 before declaring done.** (Happened 2026-07-21 with `ps1_save_block` / `podcast_value_for_value` / `chats_history_relays`.)
- **Inserting strings in a specific position** — always append at the bottom; ordering is handled separately - **Inserting strings in a specific position** — always append at the bottom; ordering is handled separately
- **Hardcoding `"1"` in a `<plurals>` `quantity="one"` item** — always use the count placeholder; otherwise non-English `one` categories produce wrong text - **Hardcoding `"1"` in a `<plurals>` `quantity="one"` item** — always use the count placeholder; otherwise non-English `one` categories produce wrong text
- **Copying English's `one`/`other` set into every locale** — each language must include all CLDR plural categories it uses (e.g. Czech needs `one`, `few`, `many`, `other`) - **Copying English's `one`/`other` set into every locale** — each language must include all CLDR plural categories it uses (e.g. Czech needs `one`, `few`, `many`, `other`)

View File

@@ -17,7 +17,7 @@ Join the social network you control.
[![Maven Central](https://img.shields.io/maven-central/v/com.vitorpamplona.quartz/quartz?label=Quartz%20%28Maven%20Central%29&labelColor=27303D&color=0877d2)](https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz) [![Maven Central](https://img.shields.io/maven-central/v/com.vitorpamplona.quartz/quartz?label=Quartz%20%28Maven%20Central%29&labelColor=27303D&color=0877d2)](https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz)
[![JitPack snapshots](https://img.shields.io/badge/Quartz%20snapshots-JitPack-27303D?labelColor=27303D&color=0877d2)](https://jitpack.io/#vitorpamplona/amethyst) [![JitPack snapshots](https://img.shields.io/badge/Quartz%20snapshots-JitPack-27303D?labelColor=27303D&color=0877d2)](https://jitpack.io/#vitorpamplona/amethyst)
[![CI](https://img.shields.io/github/actions/workflow/status/vitorpamplona/amethyst/build.yml?labelColor=27303D)](https://github.com/vitorpamplona/amethyst/actions/workflows/build.yml) [![CI](https://img.shields.io/github/actions/workflow/status/vitorpamplona/amethyst/build.yml?labelColor=27303D)](https://github.com/vitorpamplona/amethyst/actions/workflows/build.yml)
[![License: Apache-2.0](https://img.shields.io/github/license/vitorpamplona/amethyst?labelColor=27303D&color=0877d2)](/LICENSE) [![License: MIT](https://img.shields.io/github/license/vitorpamplona/amethyst?labelColor=27303D&color=0877d2)](/LICENSE)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/vitorpamplona/amethyst) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/vitorpamplona/amethyst)
## Download and Install ## Download and Install

View File

@@ -24,7 +24,9 @@ Build customized Amethyst Nostr clients for Android. Fork, rebrand, customize, a
2. **Android SDK** 2. **Android SDK**
- Command-line tools from https://developer.android.com/studio#command-line-tools-only - Command-line tools from https://developer.android.com/studio#command-line-tools-only
- Required components: build-tools, platform-tools, platforms;android-35 - Required components: build-tools, platform-tools, platforms;android-37
- The exact SDK level is `android-compileSdk` in `gradle/libs.versions.toml` —
check there if this number has drifted.
3. **Git** for cloning the repository 3. **Git** for cloning the repository
@@ -66,48 +68,54 @@ keyPassword=your-password
### 3. Configure Signing ### 3. Configure Signing
Add to `amethyst/build.gradle` inside the `android {}` block: Add to `amethyst/build.gradle.kts` inside the `android {}` block:
```gradle ```kotlin
def keystorePropertiesFile = rootProject.file("keystore.properties") val keystorePropertiesFile = rootProject.file("keystore.properties")
def keystoreProperties = new Properties() val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) { if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
} }
signingConfigs { signingConfigs {
release { create("release") {
if (keystorePropertiesFile.exists()) { if (keystorePropertiesFile.exists()) {
storeFile rootProject.file(keystoreProperties['storeFile']) storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
storePassword keystoreProperties['storePassword'] storePassword = keystoreProperties["storePassword"] as String
keyAlias keystoreProperties['keyAlias'] keyAlias = keystoreProperties["keyAlias"] as String
keyPassword keystoreProperties['keyPassword'] keyPassword = keystoreProperties["keyPassword"] as String
} }
} }
} }
``` ```
This needs `import java.util.Properties` at the top of the file.
Update the release buildType to use the signing config: Update the release buildType to use the signing config:
```gradle ```kotlin
buildTypes { buildTypes {
release { getByName("release") {
signingConfig signingConfigs.release signingConfig = signingConfigs.getByName("release")
// ... existing config // ... existing config
} }
} }
``` ```
Verify with `./gradlew :amethyst:signingReport` — the release variants should
report your keystore rather than `~/.android/debug.keystore`.
### 4. Disable Google Services (Required for F-Droid) ### 4. Disable Google Services (Required for F-Droid)
**⚠️ CRITICAL:** The Google Services plugin fails when you change the package name. For F-Droid builds, disable it. **⚠️ CRITICAL:** The Google Services plugin fails when you change the package name. For F-Droid builds, disable it.
Edit `amethyst/build.gradle`, comment out the plugin: Edit `amethyst/build.gradle.kts`, comment out the plugin:
```gradle ```kotlin
plugins { plugins {
alias(libs.plugins.androidApplication) alias(libs.plugins.androidApplication)
alias(libs.plugins.jetbrainsKotlinAndroid)
// alias(libs.plugins.googleServices) // DISABLED for F-Droid // alias(libs.plugins.googleServices) // DISABLED for F-Droid
alias(libs.plugins.jetbrainsComposeCompiler) alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.serialization)
alias(libs.plugins.googleKsp)
} }
``` ```
@@ -141,8 +149,8 @@ Edit `amethyst/src/main/res/values/strings.xml`:
### Change Package ID ### Change Package ID
Edit `amethyst/build.gradle`: Edit `amethyst/build.gradle.kts`:
```gradle ```kotlin
android { android {
defaultConfig { defaultConfig {
applicationId = "com.yourcompany.yourapp" applicationId = "com.yourcompany.yourapp"
@@ -152,8 +160,8 @@ android {
### Change Project Name ### Change Project Name
Edit `settings.gradle`: Edit `settings.gradle.kts`:
```gradle ```kotlin
rootProject.name = "YourAppName" rootProject.name = "YourAppName"
``` ```
@@ -167,36 +175,28 @@ Replace icon files in:
Make your app identify itself on posts with `["client", "YourAppName"]`. Make your app identify itself on posts with `["client", "YourAppName"]`.
**1. Create tag builder extension:** You do **not** need to add the tag per event type. The client tag is applied
centrally by `NostrSignerWithClientTag`, a signer decorator that appends the tag
to everything it signs (and respects the user's "add client tag" privacy
setting). Changing the name is a one-constant edit:
Create `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/clientTag/TagArrayBuilderExt.kt`: Edit `amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt`:
```kotlin ```kotlin
package com.vitorpamplona.quartz.nip01Core.tags.clientTag const val CLIENT_TAG_NAME = "YourAppName"
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
fun <T : Event> TagArrayBuilder<T>.client(clientName: String) =
addUnique(arrayOf(ClientTag.TAG_NAME, clientName))
``` ```
**2. Add to TextNoteEvent:** That constant is passed to `NostrSignerWithClientTag` when the account's signer
is built, so every signed event carries your name.
Edit `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt`: The tag itself lives in
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/`
Add import: (`ClientTag`, `TagArrayBuilderExt`, `NostrSignerWithClientTag`) — you only need to
```kotlin touch it if you want the optional NIP-89 handler address / relay hint variants.
import com.vitorpamplona.quartz.nip01Core.tags.clientTag.client
```
In both `build()` functions, add after `alt(...)`:
```kotlin
client("YourAppName")
```
### Modify Default Relays ### Modify Default Relays
Edit relay configuration in `quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/` or the UI settings files. Edit `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/defaults/Constants.kt`
(see also `AmethystDefaults.kt` and `DefaultDmIndexerRelays.kt` in the same folder).
## Troubleshooting ## Troubleshooting

View File

@@ -38,8 +38,10 @@ import com.vitorpamplona.amethyst.model.UiSettings
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
@@ -55,6 +57,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
@@ -168,7 +171,10 @@ private object PrefKeys {
const val LATEST_GEOHASH_LIST = "latestGeohashList" const val LATEST_GEOHASH_LIST = "latestGeohashList"
const val LATEST_EPHEMERAL_LIST = "latestEphemeralChatList" const val LATEST_EPHEMERAL_LIST = "latestEphemeralChatList"
const val LATEST_RELAY_GROUP_LIST = "latestRelayGroupList" const val LATEST_RELAY_GROUP_LIST = "latestRelayGroupList"
const val LATEST_CONCORD_LIST = "latestConcordList"
const val LATEST_TRUST_PROVIDER_LIST = "latestTrustProviderList" const val LATEST_TRUST_PROVIDER_LIST = "latestTrustProviderList"
const val LATEST_KEY_PACKAGE_RELAY_LIST = "latestKeyPackageRelayList"
const val LATEST_FAVORITE_ALGO_FEEDS_LIST = "latestFavoriteAlgoFeedsList"
const val CALLS_ENABLED = "calls_enabled" const val CALLS_ENABLED = "calls_enabled"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog" const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog" const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
@@ -577,7 +583,10 @@ object LocalPreferences {
putOrRemove(PrefKeys.LATEST_GEOHASH_LIST, settings.backupGeohashList) putOrRemove(PrefKeys.LATEST_GEOHASH_LIST, settings.backupGeohashList)
putOrRemove(PrefKeys.LATEST_EPHEMERAL_LIST, settings.backupEphemeralChatList) putOrRemove(PrefKeys.LATEST_EPHEMERAL_LIST, settings.backupEphemeralChatList)
putOrRemove(PrefKeys.LATEST_RELAY_GROUP_LIST, settings.backupRelayGroupList) putOrRemove(PrefKeys.LATEST_RELAY_GROUP_LIST, settings.backupRelayGroupList)
putOrRemove(PrefKeys.LATEST_CONCORD_LIST, settings.backupConcordList)
putOrRemove(PrefKeys.LATEST_TRUST_PROVIDER_LIST, settings.backupTrustProviderList) putOrRemove(PrefKeys.LATEST_TRUST_PROVIDER_LIST, settings.backupTrustProviderList)
putOrRemove(PrefKeys.LATEST_KEY_PACKAGE_RELAY_LIST, settings.backupKeyPackageRelayList)
putOrRemove(PrefKeys.LATEST_FAVORITE_ALGO_FEEDS_LIST, settings.backupFavoriteAlgoFeedsList)
putOrRemove(PrefKeys.LATEST_PAYMENT_TARGETS, settings.backupNipA3PaymentTargets) putOrRemove(PrefKeys.LATEST_PAYMENT_TARGETS, settings.backupNipA3PaymentTargets)
putOrRemove(PrefKeys.LATEST_CASHU_WALLET, settings.backupCashuWallet) putOrRemove(PrefKeys.LATEST_CASHU_WALLET, settings.backupCashuWallet)
putOrRemove(PrefKeys.LATEST_NUTZAP_INFO, settings.backupNutzapInfo) putOrRemove(PrefKeys.LATEST_NUTZAP_INFO, settings.backupNutzapInfo)
@@ -763,7 +772,10 @@ object LocalPreferences {
val latestGeohashListStr = getString(PrefKeys.LATEST_GEOHASH_LIST, null) val latestGeohashListStr = getString(PrefKeys.LATEST_GEOHASH_LIST, null)
val latestEphemeralListStr = getString(PrefKeys.LATEST_EPHEMERAL_LIST, null) val latestEphemeralListStr = getString(PrefKeys.LATEST_EPHEMERAL_LIST, null)
val latestRelayGroupListStr = getString(PrefKeys.LATEST_RELAY_GROUP_LIST, null) val latestRelayGroupListStr = getString(PrefKeys.LATEST_RELAY_GROUP_LIST, null)
val latestConcordListStr = getString(PrefKeys.LATEST_CONCORD_LIST, null)
val latestTrustProviderListStr = getString(PrefKeys.LATEST_TRUST_PROVIDER_LIST, null) val latestTrustProviderListStr = getString(PrefKeys.LATEST_TRUST_PROVIDER_LIST, null)
val latestKeyPackageRelayListStr = getString(PrefKeys.LATEST_KEY_PACKAGE_RELAY_LIST, null)
val latestFavoriteAlgoFeedsListStr = getString(PrefKeys.LATEST_FAVORITE_ALGO_FEEDS_LIST, null)
val latestPaymentTargetsStr = getString(PrefKeys.LATEST_PAYMENT_TARGETS, null) val latestPaymentTargetsStr = getString(PrefKeys.LATEST_PAYMENT_TARGETS, null)
val latestCashuWalletStr = getString(PrefKeys.LATEST_CASHU_WALLET, null) val latestCashuWalletStr = getString(PrefKeys.LATEST_CASHU_WALLET, null)
val latestNutzapInfoStr = getString(PrefKeys.LATEST_NUTZAP_INFO, null) val latestNutzapInfoStr = getString(PrefKeys.LATEST_NUTZAP_INFO, null)
@@ -823,7 +835,10 @@ object LocalPreferences {
val latestGeohashList = async { parseEventOrNull<GeohashListEvent>(latestGeohashListStr) } val latestGeohashList = async { parseEventOrNull<GeohashListEvent>(latestGeohashListStr) }
val latestEphemeralList = async { parseEventOrNull<EphemeralChatListEvent>(latestEphemeralListStr) } val latestEphemeralList = async { parseEventOrNull<EphemeralChatListEvent>(latestEphemeralListStr) }
val latestRelayGroupList = async { parseEventOrNull<SimpleGroupListEvent>(latestRelayGroupListStr) } val latestRelayGroupList = async { parseEventOrNull<SimpleGroupListEvent>(latestRelayGroupListStr) }
val latestConcordList = async { parseEventOrNull<ConcordCommunityListEvent>(latestConcordListStr) }
val latestTrustProviderList = async { parseEventOrNull<TrustProviderListEvent>(latestTrustProviderListStr) } val latestTrustProviderList = async { parseEventOrNull<TrustProviderListEvent>(latestTrustProviderListStr) }
val latestKeyPackageRelayList = async { parseEventOrNull<KeyPackageRelayListEvent>(latestKeyPackageRelayListStr) }
val latestFavoriteAlgoFeedsList = async { parseEventOrNull<FavoriteAlgoFeedsListEvent>(latestFavoriteAlgoFeedsListStr) }
val latestPaymentTargets = async { parseEventOrNull<PaymentTargetsEvent>(latestPaymentTargetsStr) } val latestPaymentTargets = async { parseEventOrNull<PaymentTargetsEvent>(latestPaymentTargetsStr) }
val latestCashuWallet = val latestCashuWallet =
async { async {
@@ -875,7 +890,10 @@ object LocalPreferences {
val latestGeohashListResolved = latestGeohashList.await() val latestGeohashListResolved = latestGeohashList.await()
val latestEphemeralListResolved = latestEphemeralList.await() val latestEphemeralListResolved = latestEphemeralList.await()
val latestRelayGroupListResolved = latestRelayGroupList.await() val latestRelayGroupListResolved = latestRelayGroupList.await()
val latestConcordListResolved = latestConcordList.await()
val latestTrustProviderListResolved = latestTrustProviderList.await() val latestTrustProviderListResolved = latestTrustProviderList.await()
val latestKeyPackageRelayListResolved = latestKeyPackageRelayList.await()
val latestFavoriteAlgoFeedsListResolved = latestFavoriteAlgoFeedsList.await()
val latestPaymentTargetsResolved = latestPaymentTargets.await() val latestPaymentTargetsResolved = latestPaymentTargets.await()
val latestCashuWalletResolved = latestCashuWallet.await() val latestCashuWalletResolved = latestCashuWallet.await()
val latestNutzapInfoResolved = latestNutzapInfo.await() val latestNutzapInfoResolved = latestNutzapInfo.await()
@@ -969,7 +987,10 @@ object LocalPreferences {
backupGeohashList = latestGeohashListResolved, backupGeohashList = latestGeohashListResolved,
backupEphemeralChatList = latestEphemeralListResolved, backupEphemeralChatList = latestEphemeralListResolved,
backupRelayGroupList = latestRelayGroupListResolved, backupRelayGroupList = latestRelayGroupListResolved,
backupConcordList = latestConcordListResolved,
backupTrustProviderList = latestTrustProviderListResolved, backupTrustProviderList = latestTrustProviderListResolved,
backupKeyPackageRelayList = latestKeyPackageRelayListResolved,
backupFavoriteAlgoFeedsList = latestFavoriteAlgoFeedsListResolved,
lastReadPerRoute = MutableStateFlow(lastReadPerRouteResolved), lastReadPerRoute = MutableStateFlow(lastReadPerRouteResolved),
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion), hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
dismissedPollNoteIds = MutableStateFlow(dismissedPollNoteIds), dismissedPollNoteIds = MutableStateFlow(dismissedPollNoteIds),

View File

@@ -77,7 +77,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.webBookmarks.dal.WebBookmar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.dal.WorkoutFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.dal.WorkoutFeedFilter
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class AccountFeedContentStates( class AccountFeedContentStates(
@@ -201,6 +203,26 @@ class AccountFeedContentStates(
} }
} }
// A Concord control-plane fold is what first reveals a community's channels (and what makes
// ConcordCommunitySession.state non-null, without which ChatroomListKnownFeedFilter emits
// nothing at all for that community). None of it flows through LocalCache.newEventBundles,
// so the additive path can't see it: a folded channel reaches the Messages tab only if a
// message for it happens to arrive afterwards. Cold boot therefore shows a *subset* of a
// community's channels, or omits a quiet community entirely, until some unrelated
// invalidation fires. Rebuild on every structural change instead. `revision` bumps only on
// fold/membership/rekey (never a plain message), and sample() coalesces the burst of folds
// that lands as each control plane catches up — the same pairing Account.kt uses to drive
// refreshConcordChannelIndex off this flow.
scope.launch(Dispatchers.IO) {
@OptIn(FlowPreview::class)
account.concordSessions.revision
.drop(1)
.sample(500)
.collect {
dmKnown.invalidateData()
}
}
// Same for the Concord view mode (inline channels vs one row per community). // Same for the Concord view mode (inline channels vs one row per community).
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
account.settings.concordViewMode account.settings.concordViewMode

View File

@@ -32,6 +32,7 @@
<string name="referenced_event_not_found">Odkazovaná událost nebyla nalezena</string> <string name="referenced_event_not_found">Odkazovaná událost nebyla nalezena</string>
<string name="could_not_decrypt_the_message">Nepodařilo se dešifrovat zprávu</string> <string name="could_not_decrypt_the_message">Nepodařilo se dešifrovat zprávu</string>
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted --> <!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
<string name="chat_preview_decrypting">Dešifrování…</string>
<string name="group_picture">Obrázek skupiny</string> <string name="group_picture">Obrázek skupiny</string>
<string name="explicit_content">Explicitní obsah</string> <string name="explicit_content">Explicitní obsah</string>
<string name="relay_notice">Oznámení relé</string> <string name="relay_notice">Oznámení relé</string>
@@ -308,6 +309,10 @@
<string name="concord_invite_failed_invalid">Tento zvací odkaz je neplatný nebo jej nelze otevřít tímto účtem.</string> <string name="concord_invite_failed_invalid">Tento zvací odkaz je neplatný nebo jej nelze otevřít tímto účtem.</string>
<string name="concord_invite_failed_incompatible">Tento zvací odkaz nelze otevřít. Může být zastaralý nebo již nahrazený novějším, případně vytvořený novější verzí aplikace. Vyžádejte si nový zvací odkaz.</string> <string name="concord_invite_failed_incompatible">Tento zvací odkaz nelze otevřít. Může být zastaralý nebo již nahrazený novějším, případně vytvořený novější verzí aplikace. Vyžádejte si nový zvací odkaz.</string>
<string name="concord_invite_failed_revoked">Tento zvací odkaz byl zrušen a již jej nelze použít. Vyžádejte si nový.</string> <string name="concord_invite_failed_revoked">Tento zvací odkaz byl zrušen a již jej nelze použít. Vyžádejte si nový.</string>
<string name="concord_invite_failed_expired">Platnost tohoto pozvánkového odkazu vypršela a nelze jej již použít. Vyžádejte si nový odkaz.</string>
<string name="concord_invite_preview_unknown_name">Název komunity se zobrazí až po připojení</string>
<string name="concord_invite_preview_explainer">Připojením se spojíte s relayi této pozvánky, zveřejníte oznámení o připojení podepsané vaším účtem a přidáte komunitu do svého seznamu. Dokud nestisknete Připojit se, nic se neodešle.</string>
<string name="concord_invite_preview_relays">Relaye, které tato pozvánka kontaktuje: %1$s</string>
<string name="concord_home_title">Kanály Concord</string> <string name="concord_home_title">Kanály Concord</string>
<string name="concord_home_empty">Zatím jste se nepřipojili k žádným kanálům Concord. Vytvořte nějaký nebo otevřete pozvánku.</string> <string name="concord_home_empty">Zatím jste se nepřipojili k žádným kanálům Concord. Vytvořte nějaký nebo otevřete pozvánku.</string>
<string name="concord_channels_empty">Zatím žádné kanály.</string> <string name="concord_channels_empty">Zatím žádné kanály.</string>
@@ -323,6 +328,10 @@
<string name="concord_channel_delete_title">Smazat kanál?</string> <string name="concord_channel_delete_title">Smazat kanál?</string>
<string name="concord_channel_delete_message">Smazat #%1$s? Tuto akci nelze vrátit zpět a kanál nelze znovu vytvořit se stejným id.</string> <string name="concord_channel_delete_message">Smazat #%1$s? Tuto akci nelze vrátit zpět a kanál nelze znovu vytvořit se stejným id.</string>
<string name="concord_channel_delete_confirm">Smazat</string> <string name="concord_channel_delete_confirm">Smazat</string>
<string name="concord_leave_community">Opustit komunitu</string>
<string name="concord_leave_title">Opustit komunitu?</string>
<string name="concord_leave_message">Opustit %1$s? Bude odebrána ze seznamu tohoto účtu a přestane se synchronizovat na vašich zařízeních. Komunita o tom nedostane oznámení a nebudete odebráni z jejího seznamu členů. Zprávy, které již nedokážete dešifrovat, mohou být neobnovitelné, a vrátit se můžete pouze s novou pozvánkou.</string>
<string name="concord_leave_owner_warning">Tuto komunitu jste vytvořili vy. Opuštěním ji nesmažete ani ji nepředáte nikomu jinému, ale zahodíte klíč vlastníka uložený ve vašem seznamu — už byste ji nemohli spravovat.</string>
<string name="concord_edit_relays_desc">Kde jsou publikovány a čteny šifrované roviny této komunity.</string> <string name="concord_edit_relays_desc">Kde jsou publikovány a čteny šifrované roviny této komunity.</string>
<string name="concord_typing_one">%1$s píše…</string> <string name="concord_typing_one">%1$s píše…</string>
<string name="concord_typing_two">%1$s a %2$s píší…</string> <string name="concord_typing_two">%1$s a %2$s píší…</string>
@@ -373,6 +382,13 @@
<string name="concord_members_remove_title">Odebrat člena?</string> <string name="concord_members_remove_title">Odebrat člena?</string>
<string name="concord_members_remove_message">Tímto se otočí šifrovací klíč komunity, takže tento člen už nebude moci číst nic odeslaného poté. Všem ostatním se klíč vymění automaticky. Tuto akci nelze vrátit zpět.</string> <string name="concord_members_remove_message">Tímto se otočí šifrovací klíč komunity, takže tento člen už nebude moci číst nic odeslaného poté. Všem ostatním se klíč vymění automaticky. Tuto akci nelze vrátit zpět.</string>
<string name="concord_members_remove_confirm">Odebrat</string> <string name="concord_members_remove_confirm">Odebrat</string>
<string name="concord_members_roles">Role…</string>
<string name="concord_members_roles_title">Přiřadit role</string>
<string name="concord_members_roles_message">Vyberte všechny role, které by měl tento člen mít. Zrušením zaškrtnutí role ji odeberete.</string>
<string name="concord_members_roles_save">Uložit</string>
<string name="concord_members_roles_out_of_reach">Tento člen vám není podřízen</string>
<string name="concord_members_roles_none_assignable">Žádné role, které můžete přiřadit</string>
<string name="concord_members_roles_failed">Role tohoto člena se nepodařilo aktualizovat.</string>
<string name="concord_role_owner">Vlastník</string> <string name="concord_role_owner">Vlastník</string>
<string name="concord_role_admin">Správce</string> <string name="concord_role_admin">Správce</string>
<string name="concord_role_banned">Zablokovaný</string> <string name="concord_role_banned">Zablokovaný</string>
@@ -499,6 +515,15 @@
<string name="share_as_image_url">Sdílet jako URL obrázku</string> <string name="share_as_image_url">Sdílet jako URL obrázku</string>
<string name="share_as_image_generating">Generování náhledu…</string> <string name="share_as_image_generating">Generování náhledu…</string>
<string name="share_as_image_watermark">Sdíleno přes Amethyst</string> <string name="share_as_image_watermark">Sdíleno přes Amethyst</string>
<string name="share_as_qr">Sdílet jako QR</string>
<string name="share_as_qr_mode_web">Webový odkaz</string>
<string name="share_as_qr_mode_nostr">Nostr odkaz</string>
<string name="share_as_qr_hint_web">Naskenujte pomocí libovolného fotoaparátu telefonu</string>
<string name="share_as_qr_hint_nostr">Naskenujte pomocí Nostr aplikace</string>
<string name="share_as_qr_kind_picture">Obrázek</string>
<string name="share_as_qr_code_description_web">QR kód obsahující webový odkaz na tuto poznámku</string>
<string name="share_as_qr_code_description_nostr">QR kód obsahující Nostr odkaz na tuto poznámku</string>
<string name="share_as_qr_thumbnail_hidden_sensitive">Náhled je skrytý kvůli citlivému obsahu</string>
<string name="quick_action_copy_user_id">ID autora</string> <string name="quick_action_copy_user_id">ID autora</string>
<string name="quick_action_copy_note_id">ID poznámky</string> <string name="quick_action_copy_note_id">ID poznámky</string>
<string name="quick_action_copy_text">Kopírovat text</string> <string name="quick_action_copy_text">Kopírovat text</string>
@@ -834,6 +859,7 @@
<string name="napplet_consent_storage">Tento nApplet chce používat své soukromé úložiště.</string> <string name="napplet_consent_storage">Tento nApplet chce používat své soukromé úložiště.</string>
<string name="napplet_consent_pay">Tento nApplet chce zaplatit Lightning fakturu.</string> <string name="napplet_consent_pay">Tento nApplet chce zaplatit Lightning fakturu.</string>
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". --> <!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
<string name="napplet_consent_pay_no_amount">⚠ Tento nApplet chce zaplatit Lightning fakturu, která neuvádí ŽÁDNOU částku — o výši odebrané platby rozhoduje příjemce. Povolte to jen tehdy, pokud mu důvěřujete.</string>
<string name="napplet_consent_resource">Tento nApplet chce načíst webový zdroj.</string> <string name="napplet_consent_resource">Tento nApplet chce načíst webový zdroj.</string>
<string name="napplet_consent_upload">Tento nApplet chce nahrát soubor na váš mediální server.</string> <string name="napplet_consent_upload">Tento nApplet chce nahrát soubor na váš mediální server.</string>
<string name="napplet_consent_notify">Tento nApplet vám chce zobrazovat oznámení.</string> <string name="napplet_consent_notify">Tento nApplet vám chce zobrazovat oznámení.</string>
@@ -848,11 +874,74 @@
kind-only summary would hide what is actually being signed. These replaceable lists are kind-only summary would hide what is actually being signed. These replaceable lists are
already cached on the account, so the dialog diffs the proposed list against the current already cached on the account, so the dialog diffs the proposed list against the current
one and reports what actually changes rather than a raw total. --> one and reports what actually changes rather than a raw total. -->
<plurals name="napplet_consent_diff_follow_added">
<item quantity="one">začne sledovat %1$d nový účet</item>
<item quantity="few">začne sledovat %1$d nové účty</item>
<item quantity="many">začne sledovat %1$d nového účtu</item>
<item quantity="other">začne sledovat %1$d nových účtů</item>
</plurals>
<plurals name="napplet_consent_diff_follow_removed">
<item quantity="one">PŘESTANE SLEDOVAT %1$d účet</item>
<item quantity="few">PŘESTANE SLEDOVAT %1$d účty</item>
<item quantity="many">PŘESTANE SLEDOVAT %1$d účtu</item>
<item quantity="other">PŘESTANE SLEDOVAT %1$d účtů</item>
</plurals>
<plurals name="napplet_consent_diff_relay_added">
<item quantity="one">přidá %1$d relay</item>
<item quantity="few">přidá %1$d relaye</item>
<item quantity="many">přidá %1$d relaye</item>
<item quantity="other">přidá %1$d relayů</item>
</plurals>
<plurals name="napplet_consent_diff_relay_removed">
<item quantity="one">ODEBERE %1$d relay</item>
<item quantity="few">ODEBERE %1$d relaye</item>
<item quantity="many">ODEBERE %1$d relaye</item>
<item quantity="other">ODEBERE %1$d relayů</item>
</plurals>
<plurals name="napplet_consent_diff_mute_added">
<item quantity="one">ztlumí %1$d další osobu</item>
<item quantity="few">ztlumí %1$d další osoby</item>
<item quantity="many">ztlumí %1$d další osoby</item>
<item quantity="other">ztlumí %1$d dalších osob</item>
</plurals>
<plurals name="napplet_consent_diff_mute_removed">
<item quantity="one">ZRUŠÍ ZTLUMENÍ %1$d osoby</item>
<item quantity="few">ZRUŠÍ ZTLUMENÍ %1$d osob</item>
<item quantity="many">ZRUŠÍ ZTLUMENÍ %1$d osoby</item>
<item quantity="other">ZRUŠÍ ZTLUMENÍ %1$d osob</item>
</plurals>
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is <!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
the display name, shown next to their avatar. --> the display name, shown next to their avatar. -->
<string name="napplet_consent_diff_follow_one">⚠ Tímto začnete sledovat %1$s.</string>
<string name="napplet_consent_diff_unfollow_one">⚠ Tímto PŘESTANETE SLEDOVAT %1$s.</string>
<string name="napplet_consent_diff_mute_one">⚠ Tímto ztlumíte %1$s.</string>
<string name="napplet_consent_diff_unmute_one">⚠ Tímto ZRUŠÍTE ZTLUMENÍ %1$s.</string>
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". --> <!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
<string name="napplet_consent_diff_follows">⚠ Tímto se přepíše váš seznam sledovaných: %1$s.</string>
<string name="napplet_consent_diff_relays">⚠ Tímto se přepíše váš seznam relayů: %1$s. Vaše příspěvky a čtení se přesunou spolu s ním.</string>
<string name="napplet_consent_diff_mutes">⚠ Tímto se přepíše váš seznam ztlumených: %1$s. Ztlumená slova a hashtagy se zde nezobrazují — celý seznam zobrazíte klepnutím na “Zobrazit událost”.</string>
<string name="napplet_consent_diff_joiner">%1$s a %2$s</string>
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. --> <!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
<string name="napplet_consent_diff_none">Tímto se váš stávající seznam znovu zveřejní beze změny.</string>
<!-- No cached copy to compare against, so the whole list is what gets written. --> <!-- No cached copy to compare against, so the whole list is what gets written. -->
<plurals name="napplet_consent_diff_no_baseline">
<item quantity="one">⚠ Tímto se zapíše seznam s %1$d položkou. Amethyst nemá uloženou kopii k porovnání.</item>
<item quantity="few">⚠ Tímto se zapíše seznam s %1$d položkami. Amethyst nemá uloženou kopii k porovnání.</item>
<item quantity="many">⚠ Tímto se zapíše seznam s %1$d položkami. Amethyst nemá uloženou kopii k porovnání.</item>
<item quantity="other">⚠ Tímto se zapíše seznam s %1$d položkami. Amethyst nemá uloženou kopii k porovnání.</item>
</plurals>
<plurals name="napplet_consent_effect_deletes">
<item quantity="one">⚠ Tímto se požaduje smazání %1$d vaší události.</item>
<item quantity="few">⚠ Tímto se požaduje smazání %1$d vašich událostí.</item>
<item quantity="many">⚠ Tímto se požaduje smazání %1$d vaší události.</item>
<item quantity="other">⚠ Tímto se požaduje smazání %1$d vašich událostí.</item>
</plurals>
<plurals name="napplet_consent_effect_tags">
<item quantity="one">Nese %1$d značku. Klepnutím na “Zobrazit událost” uvidíte přesně, co by bylo podepsáno.</item>
<item quantity="few">Nese %1$d značky. Klepnutím na “Zobrazit událost” uvidíte přesně, co by bylo podepsáno.</item>
<item quantity="many">Nese %1$d značky. Klepnutím na “Zobrazit událost” uvidíte přesně, co by bylo podepsáno.</item>
<item quantity="other">Nese %1$d značek. Klepnutím na “Zobrazit událost” uvidíte přesně, co by bylo podepsáno.</item>
</plurals>
<!-- Signer permissions: first-connect dialog --> <!-- Signer permissions: first-connect dialog -->
<string name="napplet_connect_title">Připojit k Nostr</string> <string name="napplet_connect_title">Připojit k Nostr</string>
<string name="napplet_connect_subtitle">se chce připojit k vašemu účtu Nostr</string> <string name="napplet_connect_subtitle">se chce připojit k vašemu účtu Nostr</string>
@@ -889,9 +978,13 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app --> <!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<string name="napplet_op_decrypt">číst vaše soukromé zprávy</string> <string name="napplet_op_decrypt">číst vaše soukromé zprávy</string>
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) --> <!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
<string name="napplet_op_decrypt_from">číst vaše soukromé zprávy s %1$s</string>
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name --> <!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
<string name="nip46_signer_allow_always_for">Vždy povolit pro %1$s</string>
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read --> <!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
<string name="nip46_signer_decrypt_failed">Amethyst nemohl tuto zprávu dešifrovat. Možná není určena pro tento účet.</string>
<!-- Label above the counterparty avatar in the decrypt consent dialog --> <!-- Label above the counterparty avatar in the decrypt consent dialog -->
<string name="nip46_signer_messages_with">Zprávy s</string>
<!-- Permissions management screen --> <!-- Permissions management screen -->
<string name="napplet_permissions_title">Připojené aplikace</string> <string name="napplet_permissions_title">Připojené aplikace</string>
<string name="napplet_permissions_revoke_all">Odvolat všechna oprávnění</string> <string name="napplet_permissions_revoke_all">Odvolat všechna oprávnění</string>
@@ -1535,6 +1628,41 @@
<string name="local_blossom_cache_profile_pics_only">Cachovat pouze profilové obrázky</string> <string name="local_blossom_cache_profile_pics_only">Cachovat pouze profilové obrázky</string>
<string name="local_blossom_cache_profile_pics_only_caption">Omezit lokální cache pouze na profilové obrázky. Obrázky a videa z feedu budou stahovány přímo z původních serverů.</string> <string name="local_blossom_cache_profile_pics_only_caption">Omezit lokální cache pouze na profilové obrázky. Obrázky a videa z feedu budou stahovány přímo z původních serverů.</string>
<string name="no_blossom_server_message">Nemáte nastaveny žádné Blossom servery. Můžete použít Amethystův seznam, nebo přidat některý níže ↓</string> <string name="no_blossom_server_message">Nemáte nastaveny žádné Blossom servery. Můžete použít Amethystův seznam, nebo přidat některý níže ↓</string>
<string name="media_servers_upload_section">Chování při nahrávání</string>
<string name="blossom_mirror_uploads">Zrcadlit nahrané soubory</string>
<string name="blossom_mirror_uploads_caption">Po nahrání zkopíruje soubor na vaše další servery Blossom, aby zůstal dostupný i v případě, že některý z nich přestane fungovat.</string>
<string name="blossom_optimize_media">Optimalizovat média na serveru</string>
<string name="blossom_optimize_media_caption">Nahrávejte přes koncový bod /media serveru, aby mohl odstranit metadata a soubor zkomprimovat. Uložený soubor se může lišit od originálu.</string>
<string name="manage_stored_files">Spravovat uložené soubory</string>
<string name="my_blossom_data">Mé soubory Blossom</string>
<string name="blossom_refresh">Obnovit</string>
<string name="blossom_sync_all">Synchronizovat vše</string>
<string name="blossom_sync_gaps">Některé z vašich souborů ještě nejsou na všech vašich serverech.</string>
<string name="blossom_syncing">Kopírování vašich souborů mezi servery…</string>
<string name="blossom_sync_done">Synchronizace dokončena</string>
<string name="blossom_sync_cancel">Zrušit</string>
<string name="blossom_sync_channel_name">Synchronizace Blossom</string>
<string name="blossom_sync_channel_description">Zobrazuje průběh kopírování vašich souborů mezi servery Blossom.</string>
<string name="manage_stored_files_empty">Na vašich serverech Blossom nebyly nalezeny žádné uložené soubory.</string>
<string name="blossom_mirror_to_missing">Zrcadlit na chybějící</string>
<string name="blossom_delete_from">Smazat z…</string>
<string name="blossom_delete_from_host">Smazat z %1$s</string>
<string name="blossom_report">Nahlásit</string>
<string name="blossom_open">Otevřít</string>
<string name="blossom_payment_required">Tento server vyžaduje za nahrání platbu: %1$s</string>
<string name="blossom_payment_title">Vyžadována platba</string>
<string name="blossom_payment_message">%1$s si za uložení tohoto souboru účtuje platbu přes Lightning. Pro pokračování zaplaťte ze své propojené peněženky.</string>
<string name="blossom_payment_server_says">%1$s říká: “%2$s”</string>
<string name="blossom_pay">Zaplatit</string>
<plurals name="blossom_pay_sats">
<item quantity="one">Zaplatit %1$d sat</item>
<item quantity="few">Zaplatit %1$d saty</item>
<item quantity="many">Zaplatit %1$d satu</item>
<item quantity="other">Zaplatit %1$d satů</item>
</plurals>
<string name="blossom_report_title">Nahlásit blob</string>
<string name="blossom_report_comment_hint">Důvod (nepovinné)</string>
<string name="blossom_send">Odeslat</string>
<string name="recommended_media_servers">Doporučené média servery</string> <string name="recommended_media_servers">Doporučené média servery</string>
<string name="built_in_servers_description">Výchozí seznam Amethysty. Můžete je přidat jednotlivě nebo přidat seznam.</string> <string name="built_in_servers_description">Výchozí seznam Amethysty. Můžete je přidat jednotlivě nebo přidat seznam.</string>
<string name="use_default_servers">Použít výchozí seznam</string> <string name="use_default_servers">Použít výchozí seznam</string>
@@ -1578,6 +1706,11 @@
<string name="follow_list_kind3follows_users_only">Všech Uživatelů Sledování</string> <string name="follow_list_kind3follows_users_only">Všech Uživatelů Sledování</string>
<string name="follow_list_kind3_follows_users_only">Výchozí seznam sledování</string> <string name="follow_list_kind3_follows_users_only">Výchozí seznam sledování</string>
<string name="follow_list_aroundme">Kolem mě</string> <string name="follow_list_aroundme">Kolem mě</string>
<string name="follow_list_teleport">Teleportovat na místo…</string>
<string name="follow_geohash">Sledovat toto místo</string>
<string name="unfollow_geohash">Přestat sledovat toto místo</string>
<string name="geo_post_posting_to">Zveřejnění na</string>
<string name="geo_post_change_place">Změnit</string>
<string name="follow_list_global">Globální</string> <string name="follow_list_global">Globální</string>
<string name="follow_list_curated">Kurátorováno</string> <string name="follow_list_curated">Kurátorováno</string>
<string name="follow_list_mine">Moje</string> <string name="follow_list_mine">Moje</string>
@@ -1992,6 +2125,9 @@
<string name="default_relays_longer">Obnovit výchozí nastavení</string> <string name="default_relays_longer">Obnovit výchozí nastavení</string>
<string name="geohash_title">Zveřejnit polohu jako </string> <string name="geohash_title">Zveřejnit polohu jako </string>
<string name="geohash_explainer">Přidá Geohash vaší polohy do příspěvku. Veřejnost bude vědět, že se nacházíte do 5 km od aktuální polohy</string> <string name="geohash_explainer">Přidá Geohash vaší polohy do příspěvku. Veřejnost bude vědět, že se nacházíte do 5 km od aktuální polohy</string>
<string name="geohash_teleport_action">✈ Teleportovat sem</string>
<string name="location_pick_on_map">Vyberte místo na mapě</string>
<string name="location_change_place">Změnit místo na mapě</string>
<string name="geohash_exclusive">Lokace-Exkluzívní příspěvek</string> <string name="geohash_exclusive">Lokace-Exkluzívní příspěvek</string>
<string name="geohash_exclusive_explainer">Uvidí to pouze následovníci umístění. Tvoji obecní následovníci to neuvidí.</string> <string name="geohash_exclusive_explainer">Uvidí to pouze následovníci umístění. Tvoji obecní následovníci to neuvidí.</string>
<string name="hashtag_exclusive">Hashtag-exkluzivní příspěvek</string> <string name="hashtag_exclusive">Hashtag-exkluzivní příspěvek</string>
@@ -2188,7 +2324,19 @@
<string name="relay_group_field_topics_hint">bitcoin, nostr, umění</string> <string name="relay_group_field_topics_hint">bitcoin, nostr, umění</string>
<string name="relay_group_field_geohash">Poloha (geohash)</string> <string name="relay_group_field_geohash">Poloha (geohash)</string>
<string name="relay_group_field_geohash_hint">u0nd</string> <string name="relay_group_field_geohash_hint">u0nd</string>
<string name="relay_group_location_add">Přidat místo</string>
<string name="relay_group_location_add_desc">Připněte svou skupinu na mapu, aby ji lidé v okolí mohli objevit.</string>
<string name="relay_group_location_edit">Změnit místo</string>
<string name="relay_group_location_clear">Odebrat místo</string>
<string name="relay_group_location_manual">Zadat geohash ručně</string>
<!-- Reusable geohash location picker (shared, not group-specific) --> <!-- Reusable geohash location picker (shared, not group-specific) -->
<string name="location_picker_title">Zvolit místo</string>
<string name="location_picker_hint">Posuňte mapu, vyhledejte místo nebo použijte svou aktuální polohu.</string>
<string name="location_picker_search_hint">Vyhledejte město nebo adresu</string>
<string name="location_picker_search_empty">Nebylo nalezeno žádné odpovídající místo.</string>
<string name="location_picker_use_mine">Použít mou aktuální polohu</string>
<string name="location_picker_area">Velikost oblasti</string>
<string name="location_picker_confirm">Použít toto místo</string>
<string name="relay_group_section_structure">Struktura</string> <string name="relay_group_section_structure">Struktura</string>
<string name="relay_group_parent_desc">Vnořte tuto skupinu pod nadřazenou a vytvořte hierarchii.</string> <string name="relay_group_parent_desc">Vnořte tuto skupinu pod nadřazenou a vytvořte hierarchii.</string>
<string name="relay_group_parent_label">Nadřazená skupina</string> <string name="relay_group_parent_label">Nadřazená skupina</string>
@@ -2211,6 +2359,8 @@
<string name="relay_group_discovery_empty_filtered">Pro tento filtr zatím nebyly nalezeny žádné skupiny.</string> <string name="relay_group_discovery_empty_filtered">Pro tento filtr zatím nebyly nalezeny žádné skupiny.</string>
<string name="relay_group_favorite_relay">Oblíbit tento relay</string> <string name="relay_group_favorite_relay">Oblíbit tento relay</string>
<string name="relay_group_threads_empty">Zatím žádná vlákna. Založte jedno tlačítkem +.</string> <string name="relay_group_threads_empty">Zatím žádná vlákna. Založte jedno tlačítkem +.</string>
<string name="relay_group_threads_loading_older">Načítání starších vláken…</string>
<string name="relay_group_threads_all_caught_up">Žádná starší vlákna</string>
<string name="relay_group_thread_new">Nové vlákno</string> <string name="relay_group_thread_new">Nové vlákno</string>
<string name="relay_group_thread_untitled">Bez názvu</string> <string name="relay_group_thread_untitled">Bez názvu</string>
<string name="relay_group_thread_title_label">Název</string> <string name="relay_group_thread_title_label">Název</string>
@@ -2332,6 +2482,8 @@
<string name="error_dialog_pay_withdraw_error">Nepodařilo se vybrat</string> <string name="error_dialog_pay_withdraw_error">Nepodařilo se vybrat</string>
<string name="cashu_failed_redemption">Nepodařilo se použít Cashu</string> <string name="cashu_failed_redemption">Nepodařilo se použít Cashu</string>
<string name="cashu_failed_redemption_explainer_error_msg">Mint poskytl následující chybovou zprávu: %1$s</string> <string name="cashu_failed_redemption_explainer_error_msg">Mint poskytl následující chybovou zprávu: %1$s</string>
<string name="cashu_unsafe_mint_url">Nebezpečná adresa Cashu mintu</string>
<string name="cashu_unsafe_mint_url_explainer">Amethyst nekontaktoval mint tohoto tokenu. %1$s</string>
<string name="cashu_successful_redemption">Obdrženo Cashu</string> <string name="cashu_successful_redemption">Obdrženo Cashu</string>
<string name="cashu_successful_redemption_explainer">%1$s sats bylo odesláno do vaší peněženky. (Poplatek: %2$s sats)</string> <string name="cashu_successful_redemption_explainer">%1$s sats bylo odesláno do vaší peněženky. (Poplatek: %2$s sats)</string>
<string name="cashu_no_wallet_found">V systému nebyla nalezena žádná kompatibilní peněženka Cashu</string> <string name="cashu_no_wallet_found">V systému nebyla nalezena žádná kompatibilní peněženka Cashu</string>

View File

@@ -26,6 +26,7 @@
<string name="referenced_event_not_found">Referenziertes Ereignis nicht gefunden</string> <string name="referenced_event_not_found">Referenziertes Ereignis nicht gefunden</string>
<string name="could_not_decrypt_the_message">Nachricht konnte nicht entschlüsselt werden</string> <string name="could_not_decrypt_the_message">Nachricht konnte nicht entschlüsselt werden</string>
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted --> <!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
<string name="chat_preview_decrypting">Wird entschlüsselt…</string>
<string name="group_picture">Gruppenbild</string> <string name="group_picture">Gruppenbild</string>
<string name="explicit_content">Anstößiger Inhalt</string> <string name="explicit_content">Anstößiger Inhalt</string>
<string name="relay_notice">Relay-Hinweis</string> <string name="relay_notice">Relay-Hinweis</string>
@@ -302,6 +303,10 @@
<string name="concord_invite_failed_invalid">Dieser Einladungslink ist ungültig oder kann mit diesem Konto nicht geöffnet werden.</string> <string name="concord_invite_failed_invalid">Dieser Einladungslink ist ungültig oder kann mit diesem Konto nicht geöffnet werden.</string>
<string name="concord_invite_failed_incompatible">Dieser Einladungslink kann nicht geöffnet werden. Er ist möglicherweise veraltet, wurde bereits durch einen neueren ersetzt oder mit einer neueren Version der App erstellt. Bitte um einen neuen Einladungslink.</string> <string name="concord_invite_failed_incompatible">Dieser Einladungslink kann nicht geöffnet werden. Er ist möglicherweise veraltet, wurde bereits durch einen neueren ersetzt oder mit einer neueren Version der App erstellt. Bitte um einen neuen Einladungslink.</string>
<string name="concord_invite_failed_revoked">Dieser Einladungslink wurde widerrufen und kann nicht mehr verwendet werden. Bitte um einen neuen.</string> <string name="concord_invite_failed_revoked">Dieser Einladungslink wurde widerrufen und kann nicht mehr verwendet werden. Bitte um einen neuen.</string>
<string name="concord_invite_failed_expired">Dieser Einladungslink ist abgelaufen und kann nicht mehr verwendet werden. Bitte um einen neuen Link.</string>
<string name="concord_invite_preview_unknown_name">Der Name der Community wird erst nach dem Beitritt angezeigt</string>
<string name="concord_invite_preview_explainer">Beim Beitreten verbindest du dich mit den Relays dieser Einladung, veröffentlichst eine mit deinem Konto signierte Beitrittsankündigung und fügst die Community deiner Liste hinzu. Es wird nichts gesendet, bis du auf Beitreten tippst.</string>
<string name="concord_invite_preview_relays">Relays, die diese Einladung kontaktieren wird: %1$s</string>
<string name="concord_home_title">Concord-Kanäle</string> <string name="concord_home_title">Concord-Kanäle</string>
<string name="concord_home_empty">Du bist noch keinen Concord-Kanälen beigetreten. Erstelle einen oder öffne einen Einladungslink.</string> <string name="concord_home_empty">Du bist noch keinen Concord-Kanälen beigetreten. Erstelle einen oder öffne einen Einladungslink.</string>
<string name="concord_channels_empty">Noch keine Kanäle.</string> <string name="concord_channels_empty">Noch keine Kanäle.</string>
@@ -317,6 +322,10 @@
<string name="concord_channel_delete_title">Kanal löschen?</string> <string name="concord_channel_delete_title">Kanal löschen?</string>
<string name="concord_channel_delete_message">#%1$s löschen? Dies kann nicht rückgängig gemacht werden und der Kanal kann nicht mit derselben ID neu erstellt werden.</string> <string name="concord_channel_delete_message">#%1$s löschen? Dies kann nicht rückgängig gemacht werden und der Kanal kann nicht mit derselben ID neu erstellt werden.</string>
<string name="concord_channel_delete_confirm">Löschen</string> <string name="concord_channel_delete_confirm">Löschen</string>
<string name="concord_leave_community">Community verlassen</string>
<string name="concord_leave_title">Community verlassen?</string>
<string name="concord_leave_message">%1$s verlassen? Sie wird aus der Liste dieses Kontos entfernt und hört auf, sich auf deinen Geräten zu synchronisieren. Die Community wird nicht benachrichtigt und du wirst nicht aus ihrer Mitgliederliste entfernt. Nachrichten, die du nicht mehr entschlüsseln kannst, sind möglicherweise nicht wiederherstellbar, und du kannst nur mit einer neuen Einladung zurückkehren.</string>
<string name="concord_leave_owner_warning">Du hast diese Community erstellt. Beim Verlassen wird sie weder gelöscht noch an jemand anderen übergeben, aber der auf deiner Liste gespeicherte Eigentümerschlüssel wird verworfen — du könntest sie nicht mehr verwalten.</string>
<string name="concord_edit_relays_desc">Wo die verschlüsselten Planes dieser Community veröffentlicht und gelesen werden.</string> <string name="concord_edit_relays_desc">Wo die verschlüsselten Planes dieser Community veröffentlicht und gelesen werden.</string>
<string name="concord_typing_one">%1$s schreibt…</string> <string name="concord_typing_one">%1$s schreibt…</string>
<string name="concord_typing_two">%1$s und %2$s schreiben…</string> <string name="concord_typing_two">%1$s und %2$s schreiben…</string>
@@ -361,6 +370,13 @@
<string name="concord_members_remove_title">Mitglied entfernen?</string> <string name="concord_members_remove_title">Mitglied entfernen?</string>
<string name="concord_members_remove_message">Dies rotiert den Verschlüsselungsschlüssel der Community, sodass dieses Mitglied nichts mehr lesen kann, was danach gesendet wird. Für alle anderen wird der Schlüssel automatisch erneuert. Dies kann nicht rückgängig gemacht werden.</string> <string name="concord_members_remove_message">Dies rotiert den Verschlüsselungsschlüssel der Community, sodass dieses Mitglied nichts mehr lesen kann, was danach gesendet wird. Für alle anderen wird der Schlüssel automatisch erneuert. Dies kann nicht rückgängig gemacht werden.</string>
<string name="concord_members_remove_confirm">Entfernen</string> <string name="concord_members_remove_confirm">Entfernen</string>
<string name="concord_members_roles">Rollen…</string>
<string name="concord_members_roles_title">Rollen zuweisen</string>
<string name="concord_members_roles_message">Wähle jede Rolle aus, die dieses Mitglied haben soll. Das Abwählen einer Rolle entfernt sie.</string>
<string name="concord_members_roles_save">Speichern</string>
<string name="concord_members_roles_out_of_reach">Du stehst nicht über diesem Mitglied</string>
<string name="concord_members_roles_none_assignable">Keine Rollen, die du zuweisen kannst</string>
<string name="concord_members_roles_failed">Die Rollen dieses Mitglieds konnten nicht aktualisiert werden.</string>
<string name="concord_role_owner">Eigentümer</string> <string name="concord_role_owner">Eigentümer</string>
<string name="concord_role_admin">Administrator</string> <string name="concord_role_admin">Administrator</string>
<string name="concord_role_banned">Gesperrt</string> <string name="concord_role_banned">Gesperrt</string>
@@ -485,6 +501,15 @@
<string name="share_as_image_url">Als Bild-URL teilen</string> <string name="share_as_image_url">Als Bild-URL teilen</string>
<string name="share_as_image_generating">Vorschau wird erstellt…</string> <string name="share_as_image_generating">Vorschau wird erstellt…</string>
<string name="share_as_image_watermark">Geteilt über Amethyst</string> <string name="share_as_image_watermark">Geteilt über Amethyst</string>
<string name="share_as_qr">Als QR teilen</string>
<string name="share_as_qr_mode_web">Web-Link</string>
<string name="share_as_qr_mode_nostr">Nostr-Link</string>
<string name="share_as_qr_hint_web">Mit jeder Handykamera scannen</string>
<string name="share_as_qr_hint_nostr">Mit einer Nostr-App scannen</string>
<string name="share_as_qr_kind_picture">Bild</string>
<string name="share_as_qr_code_description_web">QR-Code mit einem Web-Link zu dieser Notiz</string>
<string name="share_as_qr_code_description_nostr">QR-Code mit einem Nostr-Link zu dieser Notiz</string>
<string name="share_as_qr_thumbnail_hidden_sensitive">Vorschaubild bei sensiblen Inhalten ausgeblendet</string>
<string name="quick_action_copy_user_id">Autoren-ID kopieren</string> <string name="quick_action_copy_user_id">Autoren-ID kopieren</string>
<string name="quick_action_copy_note_id">Beitrags-ID kopieren</string> <string name="quick_action_copy_note_id">Beitrags-ID kopieren</string>
<string name="quick_action_copy_text">Text kopieren</string> <string name="quick_action_copy_text">Text kopieren</string>
@@ -812,6 +837,7 @@
<string name="napplet_consent_storage">Dieses nApplet möchte seinen privaten Speicher verwenden.</string> <string name="napplet_consent_storage">Dieses nApplet möchte seinen privaten Speicher verwenden.</string>
<string name="napplet_consent_pay">Dieses nApplet möchte eine Lightning-Rechnung bezahlen.</string> <string name="napplet_consent_pay">Dieses nApplet möchte eine Lightning-Rechnung bezahlen.</string>
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". --> <!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
<string name="napplet_consent_pay_no_amount">⚠ Dieses nApplet möchte eine Lightning-Rechnung bezahlen, die KEINEN Betrag angibt — der Zahlungsempfänger entscheidet, wie viel entnommen wird. Erlaube dies nur, wenn du ihm vertraust.</string>
<string name="napplet_consent_resource">Dieses nApplet möchte eine Web-Ressource abrufen.</string> <string name="napplet_consent_resource">Dieses nApplet möchte eine Web-Ressource abrufen.</string>
<string name="napplet_consent_upload">Dieses nApplet möchte eine Datei auf Ihren Medienserver hochladen.</string> <string name="napplet_consent_upload">Dieses nApplet möchte eine Datei auf Ihren Medienserver hochladen.</string>
<string name="napplet_consent_notify">Dieses nApplet möchte Ihnen Benachrichtigungen anzeigen.</string> <string name="napplet_consent_notify">Dieses nApplet möchte Ihnen Benachrichtigungen anzeigen.</string>
@@ -824,11 +850,56 @@
kind-only summary would hide what is actually being signed. These replaceable lists are kind-only summary would hide what is actually being signed. These replaceable lists are
already cached on the account, so the dialog diffs the proposed list against the current already cached on the account, so the dialog diffs the proposed list against the current
one and reports what actually changes rather than a raw total. --> one and reports what actually changes rather than a raw total. -->
<plurals name="napplet_consent_diff_follow_added">
<item quantity="one">folgt %1$d neuem Konto</item>
<item quantity="other">folgt %1$d neuen Konten</item>
</plurals>
<plurals name="napplet_consent_diff_follow_removed">
<item quantity="one">ENTFOLGT %1$d Konto</item>
<item quantity="other">ENTFOLGT %1$d Konten</item>
</plurals>
<plurals name="napplet_consent_diff_relay_added">
<item quantity="one">fügt %1$d Relay hinzu</item>
<item quantity="other">fügt %1$d Relays hinzu</item>
</plurals>
<plurals name="napplet_consent_diff_relay_removed">
<item quantity="one">ENTFERNT %1$d Relay</item>
<item quantity="other">ENTFERNT %1$d Relays</item>
</plurals>
<plurals name="napplet_consent_diff_mute_added">
<item quantity="one">schaltet %1$d weitere Person stumm</item>
<item quantity="other">schaltet %1$d weitere Personen stumm</item>
</plurals>
<plurals name="napplet_consent_diff_mute_removed">
<item quantity="one">HEBT DIE STUMMSCHALTUNG von %1$d Person AUF</item>
<item quantity="other">HEBT DIE STUMMSCHALTUNG von %1$d Personen AUF</item>
</plurals>
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is <!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
the display name, shown next to their avatar. --> the display name, shown next to their avatar. -->
<string name="napplet_consent_diff_follow_one">⚠ Dies folgt %1$s.</string>
<string name="napplet_consent_diff_unfollow_one">⚠ Dies ENTFOLGT %1$s.</string>
<string name="napplet_consent_diff_mute_one">⚠ Dies stummschaltet %1$s.</string>
<string name="napplet_consent_diff_unmute_one">⚠ Dies HEBT DIE STUMMSCHALTUNG von %1$s AUF.</string>
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". --> <!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
<string name="napplet_consent_diff_follows">⚠ Dies überschreibt deine Folgeliste: %1$s.</string>
<string name="napplet_consent_diff_relays">⚠ Dies überschreibt deine Relay-Liste: %1$s. Deine Beiträge und Lesevorgänge ziehen mit um.</string>
<string name="napplet_consent_diff_mutes">⚠ Dies überschreibt deine Stummschaltungsliste: %1$s. Stummgeschaltete Wörter und Hashtags werden hier nicht angezeigt — tippe auf “Ereignis anzeigen” für die vollständige Liste.</string>
<string name="napplet_consent_diff_joiner">%1$s und %2$s</string>
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. --> <!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
<string name="napplet_consent_diff_none">Dies veröffentlicht deine bestehende Liste unverändert erneut.</string>
<!-- No cached copy to compare against, so the whole list is what gets written. --> <!-- No cached copy to compare against, so the whole list is what gets written. -->
<plurals name="napplet_consent_diff_no_baseline">
<item quantity="one">⚠ Dies schreibt eine Liste mit %1$d Eintrag. Amethyst hat keine zwischengespeicherte Kopie zum Vergleich.</item>
<item quantity="other">⚠ Dies schreibt eine Liste mit %1$d Einträgen. Amethyst hat keine zwischengespeicherte Kopie zum Vergleich.</item>
</plurals>
<plurals name="napplet_consent_effect_deletes">
<item quantity="one">⚠ Dies fordert die Löschung von %1$d deiner Ereignisse an.</item>
<item quantity="other">⚠ Dies fordert die Löschung von %1$d deiner Ereignisse an.</item>
</plurals>
<plurals name="napplet_consent_effect_tags">
<item quantity="one">Enthält %1$d Tag. Tippe auf “Ereignis anzeigen”, um genau zu sehen, was signiert würde.</item>
<item quantity="other">Enthält %1$d Tags. Tippe auf “Ereignis anzeigen”, um genau zu sehen, was signiert würde.</item>
</plurals>
<!-- Signer permissions: first-connect dialog --> <!-- Signer permissions: first-connect dialog -->
<string name="napplet_connect_title">Mit Nostr verbinden</string> <string name="napplet_connect_title">Mit Nostr verbinden</string>
<string name="napplet_connect_subtitle">möchte sich mit deinem Nostr-Konto verbinden</string> <string name="napplet_connect_subtitle">möchte sich mit deinem Nostr-Konto verbinden</string>
@@ -865,9 +936,13 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app --> <!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<string name="napplet_op_decrypt">deine privaten Nachrichten lesen</string> <string name="napplet_op_decrypt">deine privaten Nachrichten lesen</string>
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) --> <!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
<string name="napplet_op_decrypt_from">deine privaten Nachrichten mit %1$s lesen</string>
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name --> <!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
<string name="nip46_signer_allow_always_for">Immer erlauben für %1$s</string>
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read --> <!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
<string name="nip46_signer_decrypt_failed">Amethyst konnte diese Nachricht nicht entschlüsseln. Sie ist möglicherweise nicht an dieses Konto adressiert.</string>
<!-- Label above the counterparty avatar in the decrypt consent dialog --> <!-- Label above the counterparty avatar in the decrypt consent dialog -->
<string name="nip46_signer_messages_with">Nachrichten mit</string>
<!-- Permissions management screen --> <!-- Permissions management screen -->
<string name="napplet_permissions_title">Verbundene Apps</string> <string name="napplet_permissions_title">Verbundene Apps</string>
<string name="napplet_permissions_revoke_all">Alle Berechtigungen widerrufen</string> <string name="napplet_permissions_revoke_all">Alle Berechtigungen widerrufen</string>
@@ -1491,6 +1566,39 @@
<string name="local_blossom_cache_profile_pics_only">Nur Profilbilder cachen</string> <string name="local_blossom_cache_profile_pics_only">Nur Profilbilder cachen</string>
<string name="local_blossom_cache_profile_pics_only_caption">Den lokalen Cache auf Profilbilder beschränken. Feed-Bilder und -Videos werden direkt von den Originalservern geladen.</string> <string name="local_blossom_cache_profile_pics_only_caption">Den lokalen Cache auf Profilbilder beschränken. Feed-Bilder und -Videos werden direkt von den Originalservern geladen.</string>
<string name="no_blossom_server_message">Du hast keine Blossom Server gesetzt. Du kannst die Amethyst-Liste verwenden oder unten einen hinzufügen ↓</string> <string name="no_blossom_server_message">Du hast keine Blossom Server gesetzt. Du kannst die Amethyst-Liste verwenden oder unten einen hinzufügen ↓</string>
<string name="media_servers_upload_section">Upload-Verhalten</string>
<string name="blossom_mirror_uploads">Uploads spiegeln</string>
<string name="blossom_mirror_uploads_caption">Nach dem Hochladen wird die Datei auf deine anderen Blossom-Server kopiert, damit sie verfügbar bleibt, falls einer offline geht.</string>
<string name="blossom_optimize_media">Medien auf dem Server optimieren</string>
<string name="blossom_optimize_media_caption">Lade über den /media-Endpunkt des Servers hoch, damit dieser Metadaten entfernen und die Datei komprimieren kann. Die gespeicherte Datei kann sich vom Original unterscheiden.</string>
<string name="manage_stored_files">Gespeicherte Dateien verwalten</string>
<string name="my_blossom_data">Meine Blossom-Dateien</string>
<string name="blossom_refresh">Aktualisieren</string>
<string name="blossom_sync_all">Alle synchronisieren</string>
<string name="blossom_sync_gaps">Einige deiner Dateien liegen noch nicht auf allen deinen Servern.</string>
<string name="blossom_syncing">Deine Dateien werden über die Server kopiert…</string>
<string name="blossom_sync_done">Synchronisierung abgeschlossen</string>
<string name="blossom_sync_cancel">Abbrechen</string>
<string name="blossom_sync_channel_name">Blossom-Synchronisierung</string>
<string name="blossom_sync_channel_description">Zeigt den Fortschritt beim Kopieren deiner Dateien über deine Blossom-Server hinweg.</string>
<string name="manage_stored_files_empty">Keine gespeicherten Dateien auf deinen Blossom-Servern gefunden.</string>
<string name="blossom_mirror_to_missing">Auf fehlende spiegeln</string>
<string name="blossom_delete_from">Löschen von…</string>
<string name="blossom_delete_from_host">Löschen von %1$s</string>
<string name="blossom_report">Melden</string>
<string name="blossom_open">Öffnen</string>
<string name="blossom_payment_required">Dieser Server verlangt eine Zahlung zum Hochladen: %1$s</string>
<string name="blossom_payment_title">Zahlung erforderlich</string>
<string name="blossom_payment_message">%1$s verlangt eine Lightning-Zahlung, um diese Datei zu speichern. Bezahle aus deiner verbundenen Wallet, um fortzufahren.</string>
<string name="blossom_payment_server_says">%1$s sagt: “%2$s”</string>
<string name="blossom_pay">Bezahlen</string>
<plurals name="blossom_pay_sats">
<item quantity="one">%1$d Sat bezahlen</item>
<item quantity="other">%1$d Sats bezahlen</item>
</plurals>
<string name="blossom_report_title">Blob melden</string>
<string name="blossom_report_comment_hint">Grund (optional)</string>
<string name="blossom_send">Senden</string>
<string name="recommended_media_servers">Empfohlene Medienserver</string> <string name="recommended_media_servers">Empfohlene Medienserver</string>
<string name="built_in_servers_description">Amethysts Standardliste. Du kannst einzelne Server hinzufügen oder die gesamte Liste auf einmal übernehmen.</string> <string name="built_in_servers_description">Amethysts Standardliste. Du kannst einzelne Server hinzufügen oder die gesamte Liste auf einmal übernehmen.</string>
<string name="use_default_servers">Standardliste verwenden</string> <string name="use_default_servers">Standardliste verwenden</string>
@@ -1534,6 +1642,11 @@
<string name="follow_list_kind3follows_users_only">Alle Benutzer Folgen</string> <string name="follow_list_kind3follows_users_only">Alle Benutzer Folgen</string>
<string name="follow_list_kind3_follows_users_only">Standard-Folgenliste</string> <string name="follow_list_kind3_follows_users_only">Standard-Folgenliste</string>
<string name="follow_list_aroundme">In der Nähe</string> <string name="follow_list_aroundme">In der Nähe</string>
<string name="follow_list_teleport">Zu einem Ort teleportieren…</string>
<string name="follow_geohash">Diesem Ort folgen</string>
<string name="unfollow_geohash">Diesem Ort nicht mehr folgen</string>
<string name="geo_post_posting_to">Veröffentlichen in</string>
<string name="geo_post_change_place">Ändern</string>
<string name="follow_list_global">Weltweit</string> <string name="follow_list_global">Weltweit</string>
<string name="follow_list_curated">Kuratiert</string> <string name="follow_list_curated">Kuratiert</string>
<string name="follow_list_mine">Meine</string> <string name="follow_list_mine">Meine</string>
@@ -1944,6 +2057,10 @@
<string name="default_relays_longer">Auf Standardeinstellung zurücksetzen</string> <string name="default_relays_longer">Auf Standardeinstellung zurücksetzen</string>
<string name="geohash_title">Ort preisgeben als </string> <string name="geohash_title">Ort preisgeben als </string>
<string name="geohash_explainer">Fügt dem Beitrag einen Geohash Ihres Standorts hinzu. Die Öffentlichkeit wird wissen, dass du dich innerhalb von 5 km (3 mi) vom aktuellen Standort befinden</string> <string name="geohash_explainer">Fügt dem Beitrag einen Geohash Ihres Standorts hinzu. Die Öffentlichkeit wird wissen, dass du dich innerhalb von 5 km (3 mi) vom aktuellen Standort befinden</string>
<string name="geohash_teleport_title">Teleportieren</string>
<string name="geohash_teleport_action">✈ Hierher teleportieren</string>
<string name="location_pick_on_map">Wähle einen Ort auf der Karte</string>
<string name="location_change_place">Ort auf der Karte ändern</string>
<string name="geohash_exclusive">Standort-exklusiver Beitrag</string> <string name="geohash_exclusive">Standort-exklusiver Beitrag</string>
<string name="geohash_exclusive_explainer">Nur Follower des Ortes werden es sehen. Deine allgemeinen Follower werden es nicht sehen.</string> <string name="geohash_exclusive_explainer">Nur Follower des Ortes werden es sehen. Deine allgemeinen Follower werden es nicht sehen.</string>
<string name="hashtag_exclusive">Hashtag-exklusive Beitrag</string> <string name="hashtag_exclusive">Hashtag-exklusive Beitrag</string>
@@ -2136,7 +2253,19 @@
<string name="relay_group_field_topics_hint">bitcoin, nostr, kunst</string> <string name="relay_group_field_topics_hint">bitcoin, nostr, kunst</string>
<string name="relay_group_field_geohash">Ort (Geohash)</string> <string name="relay_group_field_geohash">Ort (Geohash)</string>
<string name="relay_group_field_geohash_hint">u0nd</string> <string name="relay_group_field_geohash_hint">u0nd</string>
<string name="relay_group_location_add">Einen Ort hinzufügen</string>
<string name="relay_group_location_add_desc">Markiere deine Gruppe auf einer Karte, damit Leute in der Nähe sie entdecken können.</string>
<string name="relay_group_location_edit">Ort ändern</string>
<string name="relay_group_location_clear">Ort entfernen</string>
<string name="relay_group_location_manual">Einen Geohash manuell eingeben</string>
<!-- Reusable geohash location picker (shared, not group-specific) --> <!-- Reusable geohash location picker (shared, not group-specific) -->
<string name="location_picker_title">Ort wählen</string>
<string name="location_picker_hint">Verschiebe die Karte, suche nach einem Ort oder verwende deinen aktuellen Standort.</string>
<string name="location_picker_search_hint">Nach einer Stadt oder Adresse suchen</string>
<string name="location_picker_search_empty">Kein passender Ort gefunden.</string>
<string name="location_picker_use_mine">Meinen aktuellen Standort verwenden</string>
<string name="location_picker_area">Gebietsgröße</string>
<string name="location_picker_confirm">Diesen Ort verwenden</string>
<string name="relay_group_section_structure">Struktur</string> <string name="relay_group_section_structure">Struktur</string>
<string name="relay_group_parent_desc">Ordne diese Gruppe einer übergeordneten Gruppe unter, um eine Hierarchie aufzubauen.</string> <string name="relay_group_parent_desc">Ordne diese Gruppe einer übergeordneten Gruppe unter, um eine Hierarchie aufzubauen.</string>
<string name="relay_group_parent_label">Übergeordnete Gruppe</string> <string name="relay_group_parent_label">Übergeordnete Gruppe</string>
@@ -2159,6 +2288,8 @@
<string name="relay_group_discovery_empty_filtered">Noch keine Gruppen für diesen Filter gefunden.</string> <string name="relay_group_discovery_empty_filtered">Noch keine Gruppen für diesen Filter gefunden.</string>
<string name="relay_group_favorite_relay">Dieses Relay favorisieren</string> <string name="relay_group_favorite_relay">Dieses Relay favorisieren</string>
<string name="relay_group_threads_empty">Noch keine Threads. Starte einen mit der +-Schaltfläche.</string> <string name="relay_group_threads_empty">Noch keine Threads. Starte einen mit der +-Schaltfläche.</string>
<string name="relay_group_threads_loading_older">Ältere Threads werden geladen…</string>
<string name="relay_group_threads_all_caught_up">Keine älteren Threads</string>
<string name="relay_group_thread_new">Neuer Thread</string> <string name="relay_group_thread_new">Neuer Thread</string>
<string name="relay_group_thread_untitled">Ohne Titel</string> <string name="relay_group_thread_untitled">Ohne Titel</string>
<string name="relay_group_thread_title_label">Titel</string> <string name="relay_group_thread_title_label">Titel</string>
@@ -2272,6 +2403,8 @@
<string name="error_dialog_pay_withdraw_error">Konnte nicht abheben</string> <string name="error_dialog_pay_withdraw_error">Konnte nicht abheben</string>
<string name="cashu_failed_redemption">Cashu konnte nicht eingelöst werden</string> <string name="cashu_failed_redemption">Cashu konnte nicht eingelöst werden</string>
<string name="cashu_failed_redemption_explainer_error_msg">Mint hat folgende Fehlermeldung geliefert: %1$s</string> <string name="cashu_failed_redemption_explainer_error_msg">Mint hat folgende Fehlermeldung geliefert: %1$s</string>
<string name="cashu_unsafe_mint_url">Unsichere Cashu-Mint-Adresse</string>
<string name="cashu_unsafe_mint_url_explainer">Amethyst hat die Mint dieses Tokens nicht kontaktiert. %1$s</string>
<string name="cashu_successful_redemption">Cashu erhalten</string> <string name="cashu_successful_redemption">Cashu erhalten</string>
<string name="cashu_successful_redemption_explainer">%1$s Sats wurden an Ihre Wallet gesendet. (Gebühren: %2$s Sats)</string> <string name="cashu_successful_redemption_explainer">%1$s Sats wurden an Ihre Wallet gesendet. (Gebühren: %2$s Sats)</string>
<string name="cashu_no_wallet_found">Keine kompatible Cashu-Brieftasche im System gefunden</string> <string name="cashu_no_wallet_found">Keine kompatible Cashu-Brieftasche im System gefunden</string>

View File

@@ -26,6 +26,7 @@
<string name="referenced_event_not_found">Evento referenciado não encontrado</string> <string name="referenced_event_not_found">Evento referenciado não encontrado</string>
<string name="could_not_decrypt_the_message">Não foi possível descriptografar a mensagem</string> <string name="could_not_decrypt_the_message">Não foi possível descriptografar a mensagem</string>
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted --> <!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
<string name="chat_preview_decrypting">Descriptografando…</string>
<string name="group_picture">Imagem do grupo</string> <string name="group_picture">Imagem do grupo</string>
<string name="explicit_content">Conteúdo explícito</string> <string name="explicit_content">Conteúdo explícito</string>
<string name="relay_notice">Aviso do relay</string> <string name="relay_notice">Aviso do relay</string>
@@ -302,6 +303,10 @@
<string name="concord_invite_failed_invalid">Este link de convite é inválido ou não pode ser aberto com esta conta.</string> <string name="concord_invite_failed_invalid">Este link de convite é inválido ou não pode ser aberto com esta conta.</string>
<string name="concord_invite_failed_incompatible">Não é possível abrir este link de convite. Ele pode estar desatualizado ou já ter sido substituído por um mais recente, ou ter sido criado com uma versão mais nova do app. Peça um novo link de convite.</string> <string name="concord_invite_failed_incompatible">Não é possível abrir este link de convite. Ele pode estar desatualizado ou já ter sido substituído por um mais recente, ou ter sido criado com uma versão mais nova do app. Peça um novo link de convite.</string>
<string name="concord_invite_failed_revoked">Este link de convite foi revogado e não pode mais ser usado. Peça um novo.</string> <string name="concord_invite_failed_revoked">Este link de convite foi revogado e não pode mais ser usado. Peça um novo.</string>
<string name="concord_invite_failed_expired">Este link de convite expirou e não pode mais ser usado. Peça um link novo.</string>
<string name="concord_invite_preview_unknown_name">O nome da comunidade só é revelado depois que você entra</string>
<string name="concord_invite_preview_explainer">Entrar conecta aos relays deste convite, publica um anúncio de entrada assinado pela sua conta e adiciona a comunidade à sua lista. Nada é enviado até você tocar em Entrar.</string>
<string name="concord_invite_preview_relays">Relays que este convite vai contatar: %1$s</string>
<string name="concord_home_title">Canais Concord</string> <string name="concord_home_title">Canais Concord</string>
<string name="concord_home_empty">Você ainda não entrou em nenhum canal Concord. Crie um ou abra um link de convite.</string> <string name="concord_home_empty">Você ainda não entrou em nenhum canal Concord. Crie um ou abra um link de convite.</string>
<string name="concord_channels_empty">Nenhum canal ainda.</string> <string name="concord_channels_empty">Nenhum canal ainda.</string>
@@ -317,6 +322,10 @@
<string name="concord_channel_delete_title">Excluir canal?</string> <string name="concord_channel_delete_title">Excluir canal?</string>
<string name="concord_channel_delete_message">Excluir #%1$s? Isso não pode ser desfeito e o canal não pode ser recriado com o mesmo id.</string> <string name="concord_channel_delete_message">Excluir #%1$s? Isso não pode ser desfeito e o canal não pode ser recriado com o mesmo id.</string>
<string name="concord_channel_delete_confirm">Excluir</string> <string name="concord_channel_delete_confirm">Excluir</string>
<string name="concord_leave_community">Sair da comunidade</string>
<string name="concord_leave_title">Sair da comunidade?</string>
<string name="concord_leave_message">Sair de %1$s? Ela é removida da lista desta conta e para de sincronizar nos seus dispositivos. A comunidade não é notificada e você não é removido da lista de membros. Mensagens que você não conseguir mais descriptografar podem ser irrecuperáveis, e você só poderá voltar com um novo convite.</string>
<string name="concord_leave_owner_warning">Você criou esta comunidade. Sair não a exclui nem a entrega a mais ninguém, mas descarta a chave de proprietário armazenada na sua lista — você não conseguiria gerenciá-la novamente.</string>
<string name="concord_edit_relays_desc">Onde os planos criptografados desta comunidade são publicados e lidos.</string> <string name="concord_edit_relays_desc">Onde os planos criptografados desta comunidade são publicados e lidos.</string>
<string name="concord_typing_one">%1$s está digitando…</string> <string name="concord_typing_one">%1$s está digitando…</string>
<string name="concord_typing_two">%1$s e %2$s estão digitando…</string> <string name="concord_typing_two">%1$s e %2$s estão digitando…</string>
@@ -361,6 +370,13 @@
<string name="concord_members_remove_title">Remover membro?</string> <string name="concord_members_remove_title">Remover membro?</string>
<string name="concord_members_remove_message">Isso rotaciona a chave de criptografia da comunidade para que este membro não possa mais ler nada enviado depois. Todos os outros recebem novas chaves automaticamente. Isso não pode ser desfeito.</string> <string name="concord_members_remove_message">Isso rotaciona a chave de criptografia da comunidade para que este membro não possa mais ler nada enviado depois. Todos os outros recebem novas chaves automaticamente. Isso não pode ser desfeito.</string>
<string name="concord_members_remove_confirm">Remover</string> <string name="concord_members_remove_confirm">Remover</string>
<string name="concord_members_roles">Funções…</string>
<string name="concord_members_roles_title">Atribuir funções</string>
<string name="concord_members_roles_message">Escolha todas as funções que este membro deve ter. Desmarcar uma função a remove.</string>
<string name="concord_members_roles_save">Salvar</string>
<string name="concord_members_roles_out_of_reach">Você não tem posição superior a este membro</string>
<string name="concord_members_roles_none_assignable">Nenhuma função que você possa atribuir</string>
<string name="concord_members_roles_failed">Não foi possível atualizar as funções deste membro.</string>
<string name="concord_role_owner">Dono</string> <string name="concord_role_owner">Dono</string>
<string name="concord_role_admin">Administrador</string> <string name="concord_role_admin">Administrador</string>
<string name="concord_role_banned">Banido</string> <string name="concord_role_banned">Banido</string>
@@ -485,6 +501,15 @@
<string name="share_as_image_url">Compartilhar como URL de imagem</string> <string name="share_as_image_url">Compartilhar como URL de imagem</string>
<string name="share_as_image_generating">Gerando prévia…</string> <string name="share_as_image_generating">Gerando prévia…</string>
<string name="share_as_image_watermark">Compartilhado via Amethyst</string> <string name="share_as_image_watermark">Compartilhado via Amethyst</string>
<string name="share_as_qr">Compartilhar como QR</string>
<string name="share_as_qr_mode_web">Link web</string>
<string name="share_as_qr_mode_nostr">Link Nostr</string>
<string name="share_as_qr_hint_web">Escaneie com a câmera de qualquer celular</string>
<string name="share_as_qr_hint_nostr">Escaneie com um aplicativo Nostr</string>
<string name="share_as_qr_kind_picture">Imagem</string>
<string name="share_as_qr_code_description_web">Código QR contendo um link web para esta nota</string>
<string name="share_as_qr_code_description_nostr">Código QR contendo um link Nostr para esta nota</string>
<string name="share_as_qr_thumbnail_hidden_sensitive">Miniatura oculta por conteúdo sensível</string>
<string name="quick_action_copy_user_id">ID do Autor</string> <string name="quick_action_copy_user_id">ID do Autor</string>
<string name="quick_action_copy_note_id">ID da Nota</string> <string name="quick_action_copy_note_id">ID da Nota</string>
<string name="quick_action_copy_text">Copiar texto</string> <string name="quick_action_copy_text">Copiar texto</string>
@@ -810,6 +835,7 @@
<string name="napplet_consent_storage">Este nApplet quer usar seu armazenamento privado.</string> <string name="napplet_consent_storage">Este nApplet quer usar seu armazenamento privado.</string>
<string name="napplet_consent_pay">Este nApplet quer pagar uma fatura Lightning.</string> <string name="napplet_consent_pay">Este nApplet quer pagar uma fatura Lightning.</string>
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". --> <!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
<string name="napplet_consent_pay_no_amount">⚠ Este nApplet quer pagar uma fatura Lightning que NÃO especifica um valor — quem recebe decide quanto é retirado. Só permita isto se você confiar nele.</string>
<string name="napplet_consent_resource">Este nApplet quer buscar um recurso da web.</string> <string name="napplet_consent_resource">Este nApplet quer buscar um recurso da web.</string>
<string name="napplet_consent_upload">Este nApplet quer enviar um arquivo para o seu servidor de mídia.</string> <string name="napplet_consent_upload">Este nApplet quer enviar um arquivo para o seu servidor de mídia.</string>
<string name="napplet_consent_notify">Este nApplet quer mostrar notificações para você.</string> <string name="napplet_consent_notify">Este nApplet quer mostrar notificações para você.</string>
@@ -822,11 +848,56 @@
kind-only summary would hide what is actually being signed. These replaceable lists are kind-only summary would hide what is actually being signed. These replaceable lists are
already cached on the account, so the dialog diffs the proposed list against the current already cached on the account, so the dialog diffs the proposed list against the current
one and reports what actually changes rather than a raw total. --> one and reports what actually changes rather than a raw total. -->
<plurals name="napplet_consent_diff_follow_added">
<item quantity="one">segue %1$d nova conta</item>
<item quantity="other">segue %1$d novas contas</item>
</plurals>
<plurals name="napplet_consent_diff_follow_removed">
<item quantity="one">DEIXA DE SEGUIR %1$d conta</item>
<item quantity="other">DEIXA DE SEGUIR %1$d contas</item>
</plurals>
<plurals name="napplet_consent_diff_relay_added">
<item quantity="one">adiciona %1$d relay</item>
<item quantity="other">adiciona %1$d relays</item>
</plurals>
<plurals name="napplet_consent_diff_relay_removed">
<item quantity="one">REMOVE %1$d relay</item>
<item quantity="other">REMOVE %1$d relays</item>
</plurals>
<plurals name="napplet_consent_diff_mute_added">
<item quantity="one">silencia mais %1$d pessoa</item>
<item quantity="other">silencia mais %1$d pessoas</item>
</plurals>
<plurals name="napplet_consent_diff_mute_removed">
<item quantity="one">REMOVE O SILÊNCIO de %1$d pessoa</item>
<item quantity="other">REMOVE O SILÊNCIO de %1$d pessoas</item>
</plurals>
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is <!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
the display name, shown next to their avatar. --> the display name, shown next to their avatar. -->
<string name="napplet_consent_diff_follow_one">⚠ Isto segue %1$s.</string>
<string name="napplet_consent_diff_unfollow_one">⚠ Isto DEIXA DE SEGUIR %1$s.</string>
<string name="napplet_consent_diff_mute_one">⚠ Isto silencia %1$s.</string>
<string name="napplet_consent_diff_unmute_one">⚠ Isto REMOVE O SILÊNCIO de %1$s.</string>
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". --> <!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
<string name="napplet_consent_diff_follows">⚠ Isto reescreve sua lista de quem você segue: %1$s.</string>
<string name="napplet_consent_diff_relays">⚠ Isto reescreve sua lista de relays: %1$s. Suas publicações e leituras vão junto.</string>
<string name="napplet_consent_diff_mutes">⚠ Isto reescreve sua lista de silenciados: %1$s. Palavras e hashtags silenciadas não são mostradas aqui — toque em “Mostrar evento” para ver a lista completa.</string>
<string name="napplet_consent_diff_joiner">%1$s e %2$s</string>
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. --> <!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
<string name="napplet_consent_diff_none">Isto republica sua lista existente sem alterações.</string>
<!-- No cached copy to compare against, so the whole list is what gets written. --> <!-- No cached copy to compare against, so the whole list is what gets written. -->
<plurals name="napplet_consent_diff_no_baseline">
<item quantity="one">⚠ Isto grava uma lista de %1$d entrada. O Amethyst não tem cópia em cache para comparar.</item>
<item quantity="other">⚠ Isto grava uma lista de %1$d entradas. O Amethyst não tem cópia em cache para comparar.</item>
</plurals>
<plurals name="napplet_consent_effect_deletes">
<item quantity="one">⚠ Isto solicita a exclusão de %1$d dos seus eventos.</item>
<item quantity="other">⚠ Isto solicita a exclusão de %1$d dos seus eventos.</item>
</plurals>
<plurals name="napplet_consent_effect_tags">
<item quantity="one">Carrega %1$d tag. Toque em “Mostrar evento” para ver exatamente o que seria assinado.</item>
<item quantity="other">Carrega %1$d tags. Toque em “Mostrar evento” para ver exatamente o que seria assinado.</item>
</plurals>
<!-- Signer permissions: first-connect dialog --> <!-- Signer permissions: first-connect dialog -->
<string name="napplet_connect_title">Conectar ao Nostr</string> <string name="napplet_connect_title">Conectar ao Nostr</string>
<string name="napplet_connect_subtitle">quer se conectar à sua conta Nostr</string> <string name="napplet_connect_subtitle">quer se conectar à sua conta Nostr</string>
@@ -863,9 +934,13 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app --> <!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<string name="napplet_op_decrypt">ler suas mensagens privadas</string> <string name="napplet_op_decrypt">ler suas mensagens privadas</string>
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) --> <!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
<string name="napplet_op_decrypt_from">ler suas mensagens privadas com %1$s</string>
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name --> <!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
<string name="nip46_signer_allow_always_for">Sempre permitir para %1$s</string>
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read --> <!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
<string name="nip46_signer_decrypt_failed">O Amethyst não conseguiu descriptografar esta mensagem. Ela pode não ser destinada a esta conta.</string>
<!-- Label above the counterparty avatar in the decrypt consent dialog --> <!-- Label above the counterparty avatar in the decrypt consent dialog -->
<string name="nip46_signer_messages_with">Mensagens com</string>
<!-- Permissions management screen --> <!-- Permissions management screen -->
<string name="napplet_permissions_title">Apps conectados</string> <string name="napplet_permissions_title">Apps conectados</string>
<string name="napplet_permissions_revoke_all">Revogar todas as permissões</string> <string name="napplet_permissions_revoke_all">Revogar todas as permissões</string>
@@ -1489,6 +1564,39 @@
<string name="local_blossom_cache_profile_pics_only">Apenas armazenar fotos de perfil em cache</string> <string name="local_blossom_cache_profile_pics_only">Apenas armazenar fotos de perfil em cache</string>
<string name="local_blossom_cache_profile_pics_only_caption">Restringir o cache local apenas a fotos de perfil. Imagens e vídeos do feed serão buscados diretamente dos servidores originais.</string> <string name="local_blossom_cache_profile_pics_only_caption">Restringir o cache local apenas a fotos de perfil. Imagens e vídeos do feed serão buscados diretamente dos servidores originais.</string>
<string name="no_blossom_server_message">Você não tem nenhum servidor de Blossom configurado. Você pode usar a lista de Amethyst, ou adicionar um abaixo ↓</string> <string name="no_blossom_server_message">Você não tem nenhum servidor de Blossom configurado. Você pode usar a lista de Amethyst, ou adicionar um abaixo ↓</string>
<string name="media_servers_upload_section">Comportamento de envio</string>
<string name="blossom_mirror_uploads">Espelhar envios</string>
<string name="blossom_mirror_uploads_caption">Após o envio, copie o arquivo para seus outros servidores Blossom para que ele continue disponível caso um fique fora do ar.</string>
<string name="blossom_optimize_media">Otimizar mídia no servidor</string>
<string name="blossom_optimize_media_caption">Envie pelo endpoint /media do servidor para que ele possa remover metadados e compactar o arquivo. O arquivo armazenado pode diferir do original.</string>
<string name="manage_stored_files">Gerenciar arquivos armazenados</string>
<string name="my_blossom_data">Meus arquivos Blossom</string>
<string name="blossom_refresh">Atualizar</string>
<string name="blossom_sync_all">Sincronizar tudo</string>
<string name="blossom_sync_gaps">Alguns dos seus arquivos ainda não estão em todos os seus servidores.</string>
<string name="blossom_syncing">Copiando seus arquivos entre servidores…</string>
<string name="blossom_sync_done">Sincronização concluída</string>
<string name="blossom_sync_cancel">Cancelar</string>
<string name="blossom_sync_channel_name">Sincronização Blossom</string>
<string name="blossom_sync_channel_description">Mostra o progresso enquanto seus arquivos são copiados entre seus servidores Blossom.</string>
<string name="manage_stored_files_empty">Nenhum arquivo armazenado encontrado nos seus servidores Blossom.</string>
<string name="blossom_mirror_to_missing">Espelhar para os ausentes</string>
<string name="blossom_delete_from">Excluir de…</string>
<string name="blossom_delete_from_host">Excluir de %1$s</string>
<string name="blossom_report">Denunciar</string>
<string name="blossom_open">Abrir</string>
<string name="blossom_payment_required">Este servidor exige pagamento para enviar: %1$s</string>
<string name="blossom_payment_title">Pagamento necessário</string>
<string name="blossom_payment_message">%1$s cobra um pagamento lightning para armazenar este arquivo. Pague pela sua carteira conectada para continuar.</string>
<string name="blossom_payment_server_says">%1$s diz: “%2$s”</string>
<string name="blossom_pay">Pagar</string>
<plurals name="blossom_pay_sats">
<item quantity="one">Pagar %1$d sat</item>
<item quantity="other">Pagar %1$d sats</item>
</plurals>
<string name="blossom_report_title">Denunciar blob</string>
<string name="blossom_report_comment_hint">Motivo (opcional)</string>
<string name="blossom_send">Enviar</string>
<string name="recommended_media_servers">Servidores de Mídia Recomendados</string> <string name="recommended_media_servers">Servidores de Mídia Recomendados</string>
<string name="built_in_servers_description">Lista padrão do Amethyst. Você pode adicioná-los individualmente ou adicionar a lista.</string> <string name="built_in_servers_description">Lista padrão do Amethyst. Você pode adicioná-los individualmente ou adicionar a lista.</string>
<string name="use_default_servers">Usar Lista Padrão</string> <string name="use_default_servers">Usar Lista Padrão</string>
@@ -1503,7 +1611,9 @@
<string name="media_server_primary_badge">Principal</string> <string name="media_server_primary_badge">Principal</string>
<string name="media_server_added">Adicionado</string> <string name="media_server_added">Adicionado</string>
<string name="media_server_reorder">Reordenar servidor</string> <string name="media_server_reorder">Reordenar servidor</string>
<string name="media_server_status_online">No ar</string>
<string name="media_server_status_slow">Lento</string> <string name="media_server_status_slow">Lento</string>
<string name="media_server_status_offline">Fora do ar</string>
<string name="media_server_status_checking">Verificando…</string> <string name="media_server_status_checking">Verificando…</string>
<string name="payment_targets">Destinos de pagamento</string> <string name="payment_targets">Destinos de pagamento</string>
<string name="payment_targets_explainer">Publique seus endereços de pagamento para que outros possam enviar fundos diretamente para você.</string> <string name="payment_targets_explainer">Publique seus endereços de pagamento para que outros possam enviar fundos diretamente para você.</string>
@@ -1532,6 +1642,11 @@
<string name="follow_list_kind3follows_users_only">Seguindo do Usuários</string> <string name="follow_list_kind3follows_users_only">Seguindo do Usuários</string>
<string name="follow_list_kind3_follows_users_only">Lista Padrão de Seguir</string> <string name="follow_list_kind3_follows_users_only">Lista Padrão de Seguir</string>
<string name="follow_list_aroundme">Perto de mim</string> <string name="follow_list_aroundme">Perto de mim</string>
<string name="follow_list_teleport">Teletransportar para um lugar…</string>
<string name="follow_geohash">Seguir este local</string>
<string name="unfollow_geohash">Deixar de seguir este local</string>
<string name="geo_post_posting_to">Publicando em</string>
<string name="geo_post_change_place">Alterar</string>
<string name="follow_list_global">Global</string> <string name="follow_list_global">Global</string>
<string name="follow_list_curated">Curada</string> <string name="follow_list_curated">Curada</string>
<string name="follow_list_mine">Meus</string> <string name="follow_list_mine">Meus</string>
@@ -1942,6 +2057,10 @@
<string name="default_relays_longer">Redefinir para os Padrões</string> <string name="default_relays_longer">Redefinir para os Padrões</string>
<string name="geohash_title">Expor localização como </string> <string name="geohash_title">Expor localização como </string>
<string name="geohash_explainer">Adicione um geohash da sua localização à postagem. O público saberá que você está a 5 km (3 milhas) do local atual</string> <string name="geohash_explainer">Adicione um geohash da sua localização à postagem. O público saberá que você está a 5 km (3 milhas) do local atual</string>
<string name="geohash_teleport_title">Teletransporte</string>
<string name="geohash_teleport_action">✈ Teletransportar para cá</string>
<string name="location_pick_on_map">Escolher um lugar no mapa</string>
<string name="location_change_place">Alterar lugar no mapa</string>
<string name="geohash_exclusive">Postagem exclusiva de localização</string> <string name="geohash_exclusive">Postagem exclusiva de localização</string>
<string name="geohash_exclusive_explainer">Somente seguidores da localização verão isso. Seus seguidores gerais não verão isso.</string> <string name="geohash_exclusive_explainer">Somente seguidores da localização verão isso. Seus seguidores gerais não verão isso.</string>
<string name="hashtag_exclusive">Postagem exclusiva de Hashtag</string> <string name="hashtag_exclusive">Postagem exclusiva de Hashtag</string>
@@ -2134,7 +2253,19 @@
<string name="relay_group_field_topics_hint">bitcoin, nostr, arte</string> <string name="relay_group_field_topics_hint">bitcoin, nostr, arte</string>
<string name="relay_group_field_geohash">Localização (geohash)</string> <string name="relay_group_field_geohash">Localização (geohash)</string>
<string name="relay_group_field_geohash_hint">un0d</string> <string name="relay_group_field_geohash_hint">un0d</string>
<string name="relay_group_location_add">Adicionar um local</string>
<string name="relay_group_location_add_desc">Fixe seu grupo em um mapa para que pessoas próximas possam descobri-lo.</string>
<string name="relay_group_location_edit">Alterar local</string>
<string name="relay_group_location_clear">Remover local</string>
<string name="relay_group_location_manual">Inserir um geohash manualmente</string>
<!-- Reusable geohash location picker (shared, not group-specific) --> <!-- Reusable geohash location picker (shared, not group-specific) -->
<string name="location_picker_title">Escolher local</string>
<string name="location_picker_hint">Mova o mapa, pesquise um lugar ou use sua localização atual.</string>
<string name="location_picker_search_hint">Pesquise uma cidade ou endereço</string>
<string name="location_picker_search_empty">Nenhum lugar correspondente encontrado.</string>
<string name="location_picker_use_mine">Usar minha localização atual</string>
<string name="location_picker_area">Tamanho da área</string>
<string name="location_picker_confirm">Usar este local</string>
<string name="relay_group_section_structure">Estrutura</string> <string name="relay_group_section_structure">Estrutura</string>
<string name="relay_group_parent_desc">Aninhe este grupo sob um grupo pai para construir uma hierarquia.</string> <string name="relay_group_parent_desc">Aninhe este grupo sob um grupo pai para construir uma hierarquia.</string>
<string name="relay_group_parent_label">Grupo pai</string> <string name="relay_group_parent_label">Grupo pai</string>
@@ -2157,6 +2288,8 @@
<string name="relay_group_discovery_empty_filtered">Nenhum grupo encontrado para este filtro ainda.</string> <string name="relay_group_discovery_empty_filtered">Nenhum grupo encontrado para este filtro ainda.</string>
<string name="relay_group_favorite_relay">Favoritar este relay</string> <string name="relay_group_favorite_relay">Favoritar este relay</string>
<string name="relay_group_threads_empty">Nenhuma thread ainda. Comece uma com o botão +.</string> <string name="relay_group_threads_empty">Nenhuma thread ainda. Comece uma com o botão +.</string>
<string name="relay_group_threads_loading_older">Carregando tópicos mais antigos…</string>
<string name="relay_group_threads_all_caught_up">Nenhum tópico mais antigo</string>
<string name="relay_group_thread_new">Nova thread</string> <string name="relay_group_thread_new">Nova thread</string>
<string name="relay_group_thread_untitled">Sem título</string> <string name="relay_group_thread_untitled">Sem título</string>
<string name="relay_group_thread_title_label">Título</string> <string name="relay_group_thread_title_label">Título</string>
@@ -2270,6 +2403,8 @@
<string name="error_dialog_pay_withdraw_error">Não foi possível sacar</string> <string name="error_dialog_pay_withdraw_error">Não foi possível sacar</string>
<string name="cashu_failed_redemption">Não foi possível resgatar o Cashu</string> <string name="cashu_failed_redemption">Não foi possível resgatar o Cashu</string>
<string name="cashu_failed_redemption_explainer_error_msg">O Mint forneceu a seguinte mensagem de erro: %1$s</string> <string name="cashu_failed_redemption_explainer_error_msg">O Mint forneceu a seguinte mensagem de erro: %1$s</string>
<string name="cashu_unsafe_mint_url">Endereço de mint Cashu não seguro</string>
<string name="cashu_unsafe_mint_url_explainer">O Amethyst não entrou em contato com o mint deste token. %1$s</string>
<string name="cashu_successful_redemption">Cashu Recebido</string> <string name="cashu_successful_redemption">Cashu Recebido</string>
<string name="cashu_successful_redemption_explainer">%1$s sats foram enviados para a sua carteira. (Taxas: %2$s sats)</string> <string name="cashu_successful_redemption_explainer">%1$s sats foram enviados para a sua carteira. (Taxas: %2$s sats)</string>
<string name="cashu_no_wallet_found">Nenhuma carteira Cashu compatível encontrada no sistema</string> <string name="cashu_no_wallet_found">Nenhuma carteira Cashu compatível encontrada no sistema</string>

View File

@@ -26,6 +26,7 @@
<string name="referenced_event_not_found">Refererad händelse hittades inte</string> <string name="referenced_event_not_found">Refererad händelse hittades inte</string>
<string name="could_not_decrypt_the_message">Kunde inte dekryptera meddelandet</string> <string name="could_not_decrypt_the_message">Kunde inte dekryptera meddelandet</string>
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted --> <!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
<string name="chat_preview_decrypting">Dekrypterar…</string>
<string name="group_picture">Grupp bild</string> <string name="group_picture">Grupp bild</string>
<string name="explicit_content">Explicit Innehåll</string> <string name="explicit_content">Explicit Innehåll</string>
<string name="relay_notice">Relay-meddelande</string> <string name="relay_notice">Relay-meddelande</string>
@@ -302,6 +303,10 @@
<string name="concord_invite_failed_invalid">Den här inbjudningslänken är ogiltig eller kan inte öppnas med det här kontot.</string> <string name="concord_invite_failed_invalid">Den här inbjudningslänken är ogiltig eller kan inte öppnas med det här kontot.</string>
<string name="concord_invite_failed_incompatible">Den här inbjudningslänken kan inte öppnas. Den kan vara föråldrad eller redan ersatt av en nyare, eller skapad med en nyare version av appen. Be om en ny inbjudningslänk.</string> <string name="concord_invite_failed_incompatible">Den här inbjudningslänken kan inte öppnas. Den kan vara föråldrad eller redan ersatt av en nyare, eller skapad med en nyare version av appen. Be om en ny inbjudningslänk.</string>
<string name="concord_invite_failed_revoked">Den här inbjudningslänken har återkallats och kan inte längre användas. Be om en ny.</string> <string name="concord_invite_failed_revoked">Den här inbjudningslänken har återkallats och kan inte längre användas. Be om en ny.</string>
<string name="concord_invite_failed_expired">Den här inbjudningslänken har upphört att gälla och kan inte längre användas. Be om en ny länk.</string>
<string name="concord_invite_preview_unknown_name">Gemenskapens namn visas först efter att du gått med</string>
<string name="concord_invite_preview_explainer">Att gå med ansluter till inbjudningens reläer, publicerar ett anslutningsmeddelande signerat av ditt konto och lägger till gemenskapen i din lista. Inget skickas förrän du trycker på Gå med.</string>
<string name="concord_invite_preview_relays">Reläer som inbjudan kommer att kontakta: %1$s</string>
<string name="concord_home_title">Concord-kanaler</string> <string name="concord_home_title">Concord-kanaler</string>
<string name="concord_home_empty">Du har inte gått med i några Concord-kanaler än. Skapa en, eller öppna en inbjudningslänk.</string> <string name="concord_home_empty">Du har inte gått med i några Concord-kanaler än. Skapa en, eller öppna en inbjudningslänk.</string>
<string name="concord_channels_empty">Inga kanaler än.</string> <string name="concord_channels_empty">Inga kanaler än.</string>
@@ -317,6 +322,10 @@
<string name="concord_channel_delete_title">Radera kanal?</string> <string name="concord_channel_delete_title">Radera kanal?</string>
<string name="concord_channel_delete_message">Radera #%1$s? Detta kan inte ångras och kanalen kan inte återskapas med samma id.</string> <string name="concord_channel_delete_message">Radera #%1$s? Detta kan inte ångras och kanalen kan inte återskapas med samma id.</string>
<string name="concord_channel_delete_confirm">Radera</string> <string name="concord_channel_delete_confirm">Radera</string>
<string name="concord_leave_community">Lämna gemenskapen</string>
<string name="concord_leave_title">Lämna gemenskapen?</string>
<string name="concord_leave_message">Lämna %1$s? Den tas bort från det här kontots lista och slutar synkas på dina enheter. Gemenskapen meddelas inte och du tas inte bort från dess medlemslista. Meddelanden som du inte längre kan dekryptera kan gå förlorade, och du kan bara återvända med en ny inbjudan.</string>
<string name="concord_leave_owner_warning">Du skapade den här gemenskapen. Att lämna raderar den inte och överlämnar den inte till någon annan, men det kasserar ägarnyckeln som lagras i din lista — du skulle inte kunna hantera den igen.</string>
<string name="concord_edit_relays_desc">Var den här gemenskapens krypterade plan publiceras och läses.</string> <string name="concord_edit_relays_desc">Var den här gemenskapens krypterade plan publiceras och läses.</string>
<string name="concord_typing_one">%1$s skriver…</string> <string name="concord_typing_one">%1$s skriver…</string>
<string name="concord_typing_two">%1$s och %2$s skriver…</string> <string name="concord_typing_two">%1$s och %2$s skriver…</string>
@@ -361,6 +370,13 @@
<string name="concord_members_remove_title">Ta bort medlem?</string> <string name="concord_members_remove_title">Ta bort medlem?</string>
<string name="concord_members_remove_message">Detta roterar gemenskapens krypteringsnyckel så att den här medlemmen inte längre kan läsa något som skickas därefter. Alla andra får automatiskt en ny nyckel. Detta kan inte ångras.</string> <string name="concord_members_remove_message">Detta roterar gemenskapens krypteringsnyckel så att den här medlemmen inte längre kan läsa något som skickas därefter. Alla andra får automatiskt en ny nyckel. Detta kan inte ångras.</string>
<string name="concord_members_remove_confirm">Ta bort</string> <string name="concord_members_remove_confirm">Ta bort</string>
<string name="concord_members_roles">Roller…</string>
<string name="concord_members_roles_title">Tilldela roller</string>
<string name="concord_members_roles_message">Välj alla roller som den här medlemmen ska ha. Att avmarkera en roll tar bort den.</string>
<string name="concord_members_roles_save">Spara</string>
<string name="concord_members_roles_out_of_reach">Du har inte högre rang än den här medlemmen</string>
<string name="concord_members_roles_none_assignable">Inga roller som du kan tilldela</string>
<string name="concord_members_roles_failed">Kunde inte uppdatera den här medlemmens roller.</string>
<string name="concord_role_owner">Ägare</string> <string name="concord_role_owner">Ägare</string>
<string name="concord_role_admin">Administratör</string> <string name="concord_role_admin">Administratör</string>
<string name="concord_role_banned">Bannlyst</string> <string name="concord_role_banned">Bannlyst</string>
@@ -485,6 +501,15 @@
<string name="share_as_image_url">Dela som bild-URL</string> <string name="share_as_image_url">Dela som bild-URL</string>
<string name="share_as_image_generating">Genererar förhandsvisning…</string> <string name="share_as_image_generating">Genererar förhandsvisning…</string>
<string name="share_as_image_watermark">Delad via Amethyst</string> <string name="share_as_image_watermark">Delad via Amethyst</string>
<string name="share_as_qr">Dela som QR</string>
<string name="share_as_qr_mode_web">Webblänk</string>
<string name="share_as_qr_mode_nostr">Nostr-länk</string>
<string name="share_as_qr_hint_web">Skanna med valfri telefonkamera</string>
<string name="share_as_qr_hint_nostr">Skanna med en Nostr-app</string>
<string name="share_as_qr_kind_picture">Bild</string>
<string name="share_as_qr_code_description_web">QR-kod med en webblänk till den här noten</string>
<string name="share_as_qr_code_description_nostr">QR-kod med en Nostr-länk till den här noten</string>
<string name="share_as_qr_thumbnail_hidden_sensitive">Miniatyrbild dold för känsligt innehåll</string>
<string name="quick_action_copy_user_id">Användar ID</string> <string name="quick_action_copy_user_id">Användar ID</string>
<string name="quick_action_copy_note_id">Antecknings ID</string> <string name="quick_action_copy_note_id">Antecknings ID</string>
<string name="quick_action_copy_text">Kopiera Text</string> <string name="quick_action_copy_text">Kopiera Text</string>
@@ -810,6 +835,7 @@
<string name="napplet_consent_storage">Det här nApplet vill använda sin privata lagring.</string> <string name="napplet_consent_storage">Det här nApplet vill använda sin privata lagring.</string>
<string name="napplet_consent_pay">Det här nApplet vill betala en Lightning-faktura.</string> <string name="napplet_consent_pay">Det här nApplet vill betala en Lightning-faktura.</string>
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". --> <!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
<string name="napplet_consent_pay_no_amount">⚠ Den här nApplet vill betala en Lightning-faktura som anger INGET belopp — betalningsmottagaren avgör hur mycket som tas. Tillåt detta endast om du litar på den.</string>
<string name="napplet_consent_resource">Det här nApplet vill hämta en webbresurs.</string> <string name="napplet_consent_resource">Det här nApplet vill hämta en webbresurs.</string>
<string name="napplet_consent_upload">Det här nApplet vill ladda upp en fil till din medieserver.</string> <string name="napplet_consent_upload">Det här nApplet vill ladda upp en fil till din medieserver.</string>
<string name="napplet_consent_notify">Det här nApplet vill visa dig aviseringar.</string> <string name="napplet_consent_notify">Det här nApplet vill visa dig aviseringar.</string>
@@ -822,11 +848,56 @@
kind-only summary would hide what is actually being signed. These replaceable lists are kind-only summary would hide what is actually being signed. These replaceable lists are
already cached on the account, so the dialog diffs the proposed list against the current already cached on the account, so the dialog diffs the proposed list against the current
one and reports what actually changes rather than a raw total. --> one and reports what actually changes rather than a raw total. -->
<plurals name="napplet_consent_diff_follow_added">
<item quantity="one">följer %1$d nytt konto</item>
<item quantity="other">följer %1$d nya konton</item>
</plurals>
<plurals name="napplet_consent_diff_follow_removed">
<item quantity="one">SLUTAR FÖLJA %1$d konto</item>
<item quantity="other">SLUTAR FÖLJA %1$d konton</item>
</plurals>
<plurals name="napplet_consent_diff_relay_added">
<item quantity="one">lägger till %1$d relä</item>
<item quantity="other">lägger till %1$d reläer</item>
</plurals>
<plurals name="napplet_consent_diff_relay_removed">
<item quantity="one">TAR BORT %1$d relä</item>
<item quantity="other">TAR BORT %1$d reläer</item>
</plurals>
<plurals name="napplet_consent_diff_mute_added">
<item quantity="one">tystar %1$d person till</item>
<item quantity="other">tystar %1$d personer till</item>
</plurals>
<plurals name="napplet_consent_diff_mute_removed">
<item quantity="one">SLUTAR TYSTA %1$d person</item>
<item quantity="other">SLUTAR TYSTA %1$d personer</item>
</plurals>
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is <!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
the display name, shown next to their avatar. --> the display name, shown next to their avatar. -->
<string name="napplet_consent_diff_follow_one">⚠ Detta följer %1$s.</string>
<string name="napplet_consent_diff_unfollow_one">⚠ Detta SLUTAR FÖLJA %1$s.</string>
<string name="napplet_consent_diff_mute_one">⚠ Detta tystar %1$s.</string>
<string name="napplet_consent_diff_unmute_one">⚠ Detta SLUTAR TYSTA %1$s.</string>
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". --> <!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
<string name="napplet_consent_diff_follows">⚠ Detta skriver om din följlista: %1$s.</string>
<string name="napplet_consent_diff_relays">⚠ Detta skriver om din relälista: %1$s. Dina inlägg och läsningar flyttas med den.</string>
<string name="napplet_consent_diff_mutes">⚠ Detta skriver om din tystningslista: %1$s. Tystade ord och hashtaggar visas inte här — tryck på “Visa händelse” för hela listan.</string>
<string name="napplet_consent_diff_joiner">%1$s och %2$s</string>
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. --> <!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
<string name="napplet_consent_diff_none">Detta återpublicerar din befintliga lista oförändrad.</string>
<!-- No cached copy to compare against, so the whole list is what gets written. --> <!-- No cached copy to compare against, so the whole list is what gets written. -->
<plurals name="napplet_consent_diff_no_baseline">
<item quantity="one">⚠ Detta skriver en lista med %1$d post. Amethyst har ingen cachad kopia att jämföra med.</item>
<item quantity="other">⚠ Detta skriver en lista med %1$d poster. Amethyst har ingen cachad kopia att jämföra med.</item>
</plurals>
<plurals name="napplet_consent_effect_deletes">
<item quantity="one">⚠ Detta begär radering av %1$d av dina händelser.</item>
<item quantity="other">⚠ Detta begär radering av %1$d av dina händelser.</item>
</plurals>
<plurals name="napplet_consent_effect_tags">
<item quantity="one">Bär %1$d tagg. Tryck på “Visa händelse” för att se exakt vad som skulle signeras.</item>
<item quantity="other">Bär %1$d taggar. Tryck på “Visa händelse” för att se exakt vad som skulle signeras.</item>
</plurals>
<!-- Signer permissions: first-connect dialog --> <!-- Signer permissions: first-connect dialog -->
<string name="napplet_connect_title">Anslut till Nostr</string> <string name="napplet_connect_title">Anslut till Nostr</string>
<string name="napplet_connect_subtitle">vill ansluta till ditt Nostr-konto</string> <string name="napplet_connect_subtitle">vill ansluta till ditt Nostr-konto</string>
@@ -863,9 +934,13 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app --> <!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<string name="napplet_op_decrypt">läsa dina privata meddelanden</string> <string name="napplet_op_decrypt">läsa dina privata meddelanden</string>
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) --> <!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
<string name="napplet_op_decrypt_from">läsa dina privata meddelanden med %1$s</string>
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name --> <!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
<string name="nip46_signer_allow_always_for">Tillåt alltid för %1$s</string>
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read --> <!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
<string name="nip46_signer_decrypt_failed">Amethyst kunde inte dekryptera det här meddelandet. Det kanske inte är adresserat till det här kontot.</string>
<!-- Label above the counterparty avatar in the decrypt consent dialog --> <!-- Label above the counterparty avatar in the decrypt consent dialog -->
<string name="nip46_signer_messages_with">Meddelanden med</string>
<!-- Permissions management screen --> <!-- Permissions management screen -->
<string name="napplet_permissions_title">Anslutna appar</string> <string name="napplet_permissions_title">Anslutna appar</string>
<string name="napplet_permissions_revoke_all">Återkalla alla behörigheter</string> <string name="napplet_permissions_revoke_all">Återkalla alla behörigheter</string>
@@ -1489,6 +1564,39 @@
<string name="local_blossom_cache_profile_pics_only">Cacha endast profilbilder</string> <string name="local_blossom_cache_profile_pics_only">Cacha endast profilbilder</string>
<string name="local_blossom_cache_profile_pics_only_caption">Begränsa den lokala cachen till profilbilder. Bilder och videor i flödet hämtas direkt från originalservrarna.</string> <string name="local_blossom_cache_profile_pics_only_caption">Begränsa den lokala cachen till profilbilder. Bilder och videor i flödet hämtas direkt från originalservrarna.</string>
<string name="no_blossom_server_message">Du har inga Blossom servrar satta. Du kan använda Amethyst\'s lista, eller lägga till en nedan ↓</string> <string name="no_blossom_server_message">Du har inga Blossom servrar satta. Du kan använda Amethyst\'s lista, eller lägga till en nedan ↓</string>
<string name="media_servers_upload_section">Uppladdningsbeteende</string>
<string name="blossom_mirror_uploads">Spegla uppladdningar</string>
<string name="blossom_mirror_uploads_caption">Efter uppladdning kopieras filen till dina andra Blossom-servrar så att den förblir tillgänglig om en går offline.</string>
<string name="blossom_optimize_media">Optimera media på servern</string>
<string name="blossom_optimize_media_caption">Ladda upp via serverns /media-slutpunkt så att den kan ta bort metadata och komprimera filen. Den lagrade filen kan skilja sig från originalet.</string>
<string name="manage_stored_files">Hantera lagrade filer</string>
<string name="my_blossom_data">Mina Blossom-filer</string>
<string name="blossom_refresh">Uppdatera</string>
<string name="blossom_sync_all">Synka alla</string>
<string name="blossom_sync_gaps">Några av dina filer finns inte på alla dina servrar ännu.</string>
<string name="blossom_syncing">Kopierar dina filer mellan servrar…</string>
<string name="blossom_sync_done">Synkning klar</string>
<string name="blossom_sync_cancel">Avbryt</string>
<string name="blossom_sync_channel_name">Blossom-synk</string>
<string name="blossom_sync_channel_description">Visar förloppet medan dina filer kopieras mellan dina Blossom-servrar.</string>
<string name="manage_stored_files_empty">Inga lagrade filer hittades på dina Blossom-servrar.</string>
<string name="blossom_mirror_to_missing">Spegla till saknade</string>
<string name="blossom_delete_from">Ta bort från…</string>
<string name="blossom_delete_from_host">Ta bort från %1$s</string>
<string name="blossom_report">Rapportera</string>
<string name="blossom_open">Öppna</string>
<string name="blossom_payment_required">Den här servern kräver betalning för uppladdning: %1$s</string>
<string name="blossom_payment_title">Betalning krävs</string>
<string name="blossom_payment_message">%1$s tar ut en Lightning-betalning för att lagra den här filen. Betala från din anslutna plånbok för att fortsätta.</string>
<string name="blossom_payment_server_says">%1$s säger: “%2$s”</string>
<string name="blossom_pay">Betala</string>
<plurals name="blossom_pay_sats">
<item quantity="one">Betala %1$d sat</item>
<item quantity="other">Betala %1$d sats</item>
</plurals>
<string name="blossom_report_title">Rapportera blob</string>
<string name="blossom_report_comment_hint">Anledning (valfritt)</string>
<string name="blossom_send">Skicka</string>
<string name="recommended_media_servers">Rekommenderade Mediaservrar</string> <string name="recommended_media_servers">Rekommenderade Mediaservrar</string>
<string name="built_in_servers_description">Amethysts standardlista. Du kan lägga till dem individuellt eller lägga till listan.</string> <string name="built_in_servers_description">Amethysts standardlista. Du kan lägga till dem individuellt eller lägga till listan.</string>
<string name="use_default_servers">Använd standardlistan</string> <string name="use_default_servers">Använd standardlistan</string>
@@ -1532,6 +1640,11 @@
<string name="follow_list_kind3follows_users_only">Alla Användare Följare</string> <string name="follow_list_kind3follows_users_only">Alla Användare Följare</string>
<string name="follow_list_kind3_follows_users_only">Standard Följ-Lista</string> <string name="follow_list_kind3_follows_users_only">Standard Följ-Lista</string>
<string name="follow_list_aroundme">Runt mig</string> <string name="follow_list_aroundme">Runt mig</string>
<string name="follow_list_teleport">Teleportera till en plats…</string>
<string name="follow_geohash">Följ den här platsen</string>
<string name="unfollow_geohash">Sluta följa den här platsen</string>
<string name="geo_post_posting_to">Publicerar till</string>
<string name="geo_post_change_place">Ändra</string>
<string name="follow_list_global">Global</string> <string name="follow_list_global">Global</string>
<string name="follow_list_curated">Kurerad</string> <string name="follow_list_curated">Kurerad</string>
<string name="follow_list_mine">Mina</string> <string name="follow_list_mine">Mina</string>
@@ -1942,6 +2055,10 @@
<string name="default_relays_longer">Återställ till standardinställningar</string> <string name="default_relays_longer">Återställ till standardinställningar</string>
<string name="geohash_title">Exponera plats som </string> <string name="geohash_title">Exponera plats som </string>
<string name="geohash_explainer">Lägger till en Geohash av din plats i inlägget. Allmänheten kommer att veta att du befinner dig inom 5 km från nuvarande plats</string> <string name="geohash_explainer">Lägger till en Geohash av din plats i inlägget. Allmänheten kommer att veta att du befinner dig inom 5 km från nuvarande plats</string>
<string name="geohash_teleport_title">Teleportera</string>
<string name="geohash_teleport_action">✈ Teleportera hit</string>
<string name="location_pick_on_map">Välj en plats på kartan</string>
<string name="location_change_place">Ändra plats på kartan</string>
<string name="geohash_exclusive">Plats-exklusivt inlägg</string> <string name="geohash_exclusive">Plats-exklusivt inlägg</string>
<string name="geohash_exclusive_explainer">Endast anhängare av platsen kommer att se den. Dina allmänna anhängare kommer inte att se den.</string> <string name="geohash_exclusive_explainer">Endast anhängare av platsen kommer att se den. Dina allmänna anhängare kommer inte att se den.</string>
<string name="hashtag_exclusive">Hashtag-exklusivt inlägg</string> <string name="hashtag_exclusive">Hashtag-exklusivt inlägg</string>
@@ -2134,7 +2251,19 @@
<string name="relay_group_field_topics_hint">bitcoin, nostr, konst</string> <string name="relay_group_field_topics_hint">bitcoin, nostr, konst</string>
<string name="relay_group_field_geohash">Plats (geohash)</string> <string name="relay_group_field_geohash">Plats (geohash)</string>
<string name="relay_group_field_geohash_hint">u0nd</string> <string name="relay_group_field_geohash_hint">u0nd</string>
<string name="relay_group_location_add">Lägg till en plats</string>
<string name="relay_group_location_add_desc">Fäst din grupp på en karta så att folk i närheten kan upptäcka den.</string>
<string name="relay_group_location_edit">Ändra plats</string>
<string name="relay_group_location_clear">Ta bort plats</string>
<string name="relay_group_location_manual">Ange en geohash manuellt</string>
<!-- Reusable geohash location picker (shared, not group-specific) --> <!-- Reusable geohash location picker (shared, not group-specific) -->
<string name="location_picker_title">Välj plats</string>
<string name="location_picker_hint">Flytta kartan, sök efter en plats eller använd din nuvarande plats.</string>
<string name="location_picker_search_hint">Sök efter en stad eller adress</string>
<string name="location_picker_search_empty">Ingen matchande plats hittades.</string>
<string name="location_picker_use_mine">Använd min nuvarande plats</string>
<string name="location_picker_area">Områdesstorlek</string>
<string name="location_picker_confirm">Använd den här platsen</string>
<string name="relay_group_section_structure">Struktur</string> <string name="relay_group_section_structure">Struktur</string>
<string name="relay_group_parent_desc">Placera den här gruppen under en överordnad för att bygga en hierarki.</string> <string name="relay_group_parent_desc">Placera den här gruppen under en överordnad för att bygga en hierarki.</string>
<string name="relay_group_parent_label">Överordnad grupp</string> <string name="relay_group_parent_label">Överordnad grupp</string>
@@ -2157,6 +2286,8 @@
<string name="relay_group_discovery_empty_filtered">Inga grupper hittades för det här filtret ännu.</string> <string name="relay_group_discovery_empty_filtered">Inga grupper hittades för det här filtret ännu.</string>
<string name="relay_group_favorite_relay">Favoritmarkera det här reläet</string> <string name="relay_group_favorite_relay">Favoritmarkera det här reläet</string>
<string name="relay_group_threads_empty">Inga trådar ännu. Starta en med +-knappen.</string> <string name="relay_group_threads_empty">Inga trådar ännu. Starta en med +-knappen.</string>
<string name="relay_group_threads_loading_older">Läser in äldre trådar…</string>
<string name="relay_group_threads_all_caught_up">Inga äldre trådar</string>
<string name="relay_group_thread_new">Ny tråd</string> <string name="relay_group_thread_new">Ny tråd</string>
<string name="relay_group_thread_untitled">Namnlös</string> <string name="relay_group_thread_untitled">Namnlös</string>
<string name="relay_group_thread_title_label">Titel</string> <string name="relay_group_thread_title_label">Titel</string>
@@ -2270,6 +2401,8 @@
<string name="error_dialog_pay_withdraw_error">Kunde inte ta ut</string> <string name="error_dialog_pay_withdraw_error">Kunde inte ta ut</string>
<string name="cashu_failed_redemption">Kunde inte lösa in Cashu</string> <string name="cashu_failed_redemption">Kunde inte lösa in Cashu</string>
<string name="cashu_failed_redemption_explainer_error_msg">Mint gav följande felmeddelande: %1$s</string> <string name="cashu_failed_redemption_explainer_error_msg">Mint gav följande felmeddelande: %1$s</string>
<string name="cashu_unsafe_mint_url">Osäker Cashu-mint-adress</string>
<string name="cashu_unsafe_mint_url_explainer">Amethyst kontaktade inte den här tokenens mint. %1$s</string>
<string name="cashu_successful_redemption">Cashu mottagen</string> <string name="cashu_successful_redemption">Cashu mottagen</string>
<string name="cashu_successful_redemption_explainer">%1$s sats skickades till din plånbok. (Avgifter: %2$s sats)</string> <string name="cashu_successful_redemption_explainer">%1$s sats skickades till din plånbok. (Avgifter: %2$s sats)</string>
<string name="cashu_no_wallet_found">Ingen kompatibel Cashu-plånbok hittades på systemet</string> <string name="cashu_no_wallet_found">Ingen kompatibel Cashu-plånbok hittades på systemet</string>

View File

@@ -62,8 +62,17 @@ enum class ConcordIngestOutcome {
* a chat/reaction/reply/delete message landing, or a duplicate wrap. Must NOT bump the revision. */ * a chat/reaction/reply/delete message landing, or a duplicate wrap. Must NOT bump the revision. */
NON_STRUCTURAL, NON_STRUCTURAL,
/** Ours and changed structure: a Control-Plane fold (metadata/channels/membership/authority), a /** Ours and re-folded the Control Plane. The fold republishes [ConcordCommunitySession.state],
* guestbook membership change, or a buffered base-rekey. Bumps the revision. */ * so the session's own state watcher is what bumps the revision — and, because the folded state
* compares by value, only when the fold actually *changed* something. A control wrap that folds
* to an identical state (a prior-epoch wrap that doesn't move the anti-rollback floor, a role
* edition that touches nothing we subscribe on) therefore costs no bump at all. The manager must
* NOT bump on this outcome as well, or every control wrap counts twice. */
STRUCTURAL_FOLD,
/** Ours and changed structure *without* touching [ConcordCommunitySession.state]: a guestbook
* membership change (which republishes `members`) or a buffered base-rekey. No state watcher
* covers these, so the manager bumps the revision directly. */
STRUCTURAL, STRUCTURAL,
; ;
@@ -145,6 +154,22 @@ class ConcordCommunitySession(
// Deduped inbound wraps. // Deduped inbound wraps.
private val controlWraps = LinkedHashMap<HexKey, Event>() private val controlWraps = LinkedHashMap<HexKey, Event>()
/**
* Decrypted control editions memoized by wrap id.
*
* Both [refold] and [controlFloorsLocked] fold their WHOLE buffer on every inbound control
* wrap, and turning a wrap into an edition is a NIP-44 open + parse. Re-deriving them each
* time made a cold-boot backfill quadratic in decryptions — one measured boot did ~8.6k opens
* to ingest 93 control wraps for a single community. Memoizing makes it one open per wrap.
*
* Wrap ids are unique and a wrap only ever belongs to one plane (it is routed by `pubKey`), so
* a single id-keyed map is safe across the current and prior-epoch Control Planes even though
* they open under different keys. A wrap that fails to open caches `null` so it is not retried
* on every subsequent fold. The wrap buffers are only ever added to, so this tracks their
* lifetime exactly and needs no separate eviction.
*/
private val editionByWrapId = HashMap<HexKey, ControlEdition?>()
// Prior-epoch Control Plane address -> (wrapId -> wrap). Kept apart from [controlWraps]: these // Prior-epoch Control Plane address -> (wrapId -> wrap). Kept apart from [controlWraps]: these
// never join the live fold, they only produce the anti-rollback floor. // never join the live fold, they only produce the anti-rollback floor.
private val historicalControlWraps = HashMap<HexKey, LinkedHashMap<HexKey, Event>>() private val historicalControlWraps = HashMap<HexKey, LinkedHashMap<HexKey, Event>>()
@@ -280,7 +305,7 @@ class ConcordCommunitySession(
fun auxStreamKeys(): List<GroupKey> = listOf(guestbookKey, nextBaseRekeyKey) fun auxStreamKeys(): List<GroupKey> = listOf(guestbookKey, nextBaseRekeyKey)
/** The community's current Control Plane editions — the input a moderation edition chains onto. */ /** The community's current Control Plane editions — the input a moderation edition chains onto. */
fun controlEditions(): List<ControlEdition> = lock.withLock { ConcordActions.controlEditions(controlWraps.values.toList(), controlPlaneKey) } fun controlEditions(): List<ControlEdition> = lock.withLock { editionsLocked(controlWraps.values.toList(), controlPlaneKey) }
/** The raw Control Plane wraps buffered so far — the input a Refounding compacts (CORD-06 §3). */ /** The raw Control Plane wraps buffered so far — the input a Refounding compacts (CORD-06 §3). */
fun controlPlaneWraps(): List<Event> = lock.withLock { controlWraps.values.toList() } fun controlPlaneWraps(): List<Event> = lock.withLock { controlWraps.values.toList() }
@@ -314,7 +339,7 @@ class ConcordCommunitySession(
if (controlWraps.put(wrap.id, wrap) != null) return ConcordIngestOutcome.NON_STRUCTURAL // dup if (controlWraps.put(wrap.id, wrap) != null) return ConcordIngestOutcome.NON_STRUCTURAL // dup
} }
refold() refold()
return ConcordIngestOutcome.STRUCTURAL return ConcordIngestOutcome.STRUCTURAL_FOLD
} }
guestbookAddress -> { guestbookAddress -> {
lock.withLock { lock.withLock {
@@ -341,7 +366,7 @@ class ConcordCommunitySession(
if (buffer.put(wrap.id, wrap) != null) return ConcordIngestOutcome.NON_STRUCTURAL // dup if (buffer.put(wrap.id, wrap) != null) return ConcordIngestOutcome.NON_STRUCTURAL // dup
} }
refold() refold()
return ConcordIngestOutcome.STRUCTURAL return ConcordIngestOutcome.STRUCTURAL_FOLD
} }
val current = lock.withLock { channelKeysByAddress[wrap.pubKey] } val current = lock.withLock { channelKeysByAddress[wrap.pubKey] }
if (current != null) { if (current != null) {
@@ -422,7 +447,7 @@ class ConcordCommunitySession(
val wraps = controlWraps.values.toList() val wraps = controlWraps.values.toList()
val folded = val folded =
ConcordCommunityState.fold( ConcordCommunityState.fold(
ConcordActions.controlEditions(wraps, controlPlaneKey), editionsLocked(wraps, controlPlaneKey),
entry.owner, entry.owner,
controlFloorsLocked(), controlFloorsLocked(),
) )
@@ -455,6 +480,24 @@ class ConcordCommunitySession(
for (channelIdHex in newChannels) reprojectChannel(channelIdHex) for (channelIdHex in newChannels) reprojectChannel(channelIdHex)
} }
/**
* [wraps] opened into editions through [editionByWrapId], so a wrap is only ever decrypted
* once no matter how many folds it participates in. Caller must hold [lock].
*/
private fun editionsLocked(
wraps: Collection<Event>,
planeKey: GroupKey,
): List<ControlEdition> =
wraps.mapNotNull { wrap ->
if (editionByWrapId.containsKey(wrap.id)) {
editionByWrapId[wrap.id]
} else {
val edition = ConcordStreamEnvelope.openOrNull(wrap, planeKey)?.let { ControlEdition.fromRumor(it.rumor) }
editionByWrapId[wrap.id] = edition
edition
}
}
/** /**
* The per-entity anti-rollback floor: the authority-gated heads of every prior epoch's * The per-entity anti-rollback floor: the authority-gated heads of every prior epoch's
* Control Plane we still hold a root for, folded **oldest epoch first** so each epoch is * Control Plane we still hold a root for, folded **oldest epoch first** so each epoch is
@@ -472,7 +515,7 @@ class ConcordCommunitySession(
var floors = emptyMap<String, EntityFloor>() var floors = emptyMap<String, EntityFloor>()
for ((address, keyAtEpoch) in historicalControlKeys.entries.sortedBy { it.value.second }) { for ((address, keyAtEpoch) in historicalControlKeys.entries.sortedBy { it.value.second }) {
val wraps = historicalControlWraps[address]?.values?.toList() ?: continue val wraps = historicalControlWraps[address]?.values?.toList() ?: continue
val editions = ConcordActions.controlEditions(wraps, keyAtEpoch.first) val editions = editionsLocked(wraps, keyAtEpoch.first)
if (editions.isEmpty()) continue if (editions.isEmpty()) continue
floors = ConcordCommunityState.authorizedHeads(editions, entry.owner, floors) floors = ConcordCommunityState.authorizedHeads(editions, entry.owner, floors)
} }

View File

@@ -150,6 +150,9 @@ class ConcordSessionManager(
seenOnRelays: Set<NormalizedRelayUrl> = emptySet(), seenOnRelays: Set<NormalizedRelayUrl> = emptySet(),
): Boolean { ): Boolean {
val outcome = registry.ingest(wrap, seenOnRelays) val outcome = registry.ingest(wrap, seenOnRelays)
// Only the planes that change structure *without* republishing `state`. A control-plane
// fold returns STRUCTURAL_FOLD and is bumped by the per-session state watcher instead, which
// (since the folded state compares by value) fires only when the fold genuinely changed.
if (outcome == ConcordIngestOutcome.STRUCTURAL) bumpRevision() if (outcome == ConcordIngestOutcome.STRUCTURAL) bumpRevision()
return outcome.claimed return outcome.claimed
} }

View File

@@ -161,7 +161,14 @@ class FeedContentState(
if (noteEvent != null) { if (noteEvent != null) {
!cacheProvider.hasBeenDeleted(noteEvent) !cacheProvider.hasBeenDeleted(noteEvent)
} else { } else {
false // An event-less row is a placeholder the filter synthesized for a room
// that has no message yet — a just-joined Concord channel, NIP-29 group,
// Marmot group or geohash cell. It carries no event, so it cannot have
// been deleted, and dropping it here deleted every such row from the
// Messages list the moment ANY kind-5 landed in an unrelated batch. The
// row then stayed gone until the next full rebuild, which is why a quiet
// community looked like it had never loaded at all.
true
} }
}.toImmutableList() }.toImmutableList()
} }

View File

@@ -108,7 +108,7 @@ class ConcordCommunitySessionTest {
// Feed the genesis control wraps → state folds, channels + membership resolve. A fold is // Feed the genesis control wraps → state folds, channels + membership resolve. A fold is
// STRUCTURAL (it moves the subscription set), so it's allowed to bump the revision. // STRUCTURAL (it moves the subscription set), so it's allowed to bump the revision.
community.genesisWraps.forEach { assertEquals(ConcordIngestOutcome.STRUCTURAL, session.ingest(it)) } community.genesisWraps.forEach { assertEquals(ConcordIngestOutcome.STRUCTURAL_FOLD, session.ingest(it)) }
val state = session.state.value val state = session.state.value
assertEquals("Nostrichs", state?.metadata?.name) assertEquals("Nostrichs", state?.metadata?.name)
assertTrue(state!!.channels.containsKey(community.generalChannelIdHex)) assertTrue(state!!.channels.containsKey(community.generalChannelIdHex))

View File

@@ -70,7 +70,7 @@ class ConcordSessionRegistryTest {
assertTrue(registry.subscribeAddresses().contains(beta.controlPlane.publicKeyHex)) assertTrue(registry.subscribeAddresses().contains(beta.controlPlane.publicKeyHex))
// A genesis control wrap routes to Alpha's session and folds it (STRUCTURAL). // A genesis control wrap routes to Alpha's session and folds it (STRUCTURAL).
alpha.genesisWraps.forEach { assertEquals(ConcordIngestOutcome.STRUCTURAL, registry.ingest(it)) } alpha.genesisWraps.forEach { assertEquals(ConcordIngestOutcome.STRUCTURAL_FOLD, registry.ingest(it)) }
val alphaState = registry.sessionFor(alpha.communityIdHex)!!.state.value val alphaState = registry.sessionFor(alpha.communityIdHex)!!.state.value
assertEquals("Alpha", alphaState?.metadata?.name) assertEquals("Alpha", alphaState?.metadata?.name)

View File

@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.store.sqlite.DefaultIndexingStrategy
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -42,6 +43,18 @@ class LocalRelayStore(
) : AutoCloseable { ) : AutoCloseable {
companion object { companion object {
val LOCAL_RELAY_URL: NormalizedRelayUrl = NormalizedRelayUrl("ws://localhost/amethyst-local/") val LOCAL_RELAY_URL: NormalizedRelayUrl = NormalizedRelayUrl("ws://localhost/amethyst-local/")
/**
* Client defaults plus the authors-without-kinds index: shared
* ViewModels (`Nip65RelayListViewModel`, `PrivateOutboxRelayListViewModel`,
* `VanishRequestsState`) replay `authors`-only filters against this
* store, which full-scan without `(pubkey, created_at)` — the
* `(kind, pubkey, …)` index can't serve them, pubkey is its second
* column. A personal store is small, so the extra insert cost is
* negligible; existing DBs get the index built on next open via
* `ensureOptionalIndexes`.
*/
val INDEX_STRATEGY = DefaultIndexingStrategy(indexEventsByPubkeyAlone = true)
} }
private fun dbDir(pubKeyHex: String): File = File(homeDir, ".amethyst/accounts/${pubKeyHex.take(8)}") private fun dbDir(pubKeyHex: String): File = File(homeDir, ".amethyst/accounts/${pubKeyHex.take(8)}")
@@ -87,14 +100,14 @@ class LocalRelayStore(
dir.mkdirs() dir.mkdirs()
val path = File(dir, "events.db").absolutePath val path = File(dir, "events.db").absolutePath
try { try {
store = EventStore(dbName = path, relay = LOCAL_RELAY_URL) store = EventStore(dbName = path, relay = LOCAL_RELAY_URL, indexStrategy = INDEX_STRATEGY)
_lastError.value = null _lastError.value = null
refreshStats() refreshStats()
} catch (e: Exception) { } catch (e: Exception) {
Log.w("LocalRelayStore") { "DB open failed, recreating: ${e.message}" } Log.w("LocalRelayStore") { "DB open failed, recreating: ${e.message}" }
try { try {
deleteDbFiles(path) deleteDbFiles(path)
store = EventStore(dbName = path, relay = LOCAL_RELAY_URL) store = EventStore(dbName = path, relay = LOCAL_RELAY_URL, indexStrategy = INDEX_STRATEGY)
_lastError.value = "Database was recreated: ${e.message}" _lastError.value = "Database was recreated: ${e.message}"
} catch (e2: Exception) { } catch (e2: Exception) {
_lastError.value = "Cannot open local store: ${e2.message}" _lastError.value = "Cannot open local store: ${e2.message}"

View File

@@ -57,6 +57,14 @@ fun relayIndexingStrategy(
// index unconditionally; without it the filter walks the whole // index unconditionally; without it the filter walks the whole
// time index. // time index.
indexEventsByPubkeyAlone = true, indexEventsByPubkeyAlone = true,
// The tag ∩ author ∩ kind shape (DM rooms, reports-by-follows,
// follows-scoped community feeds — 65 client assembler call sites)
// otherwise reads every row for the tag/kind before filtering the
// author. TagAuthorIndexBenchmark @ 1M events: 14.2 ms -> 0.66 ms
// (~21x, growing with corpus size) with insert cost inside run noise
// (49.0 vs 47.4 µs/event). Existing DBs build the index on next open
// via ensureOptionalIndexes.
indexTagsWithKindAndPubkey = true,
indexFullTextSearch = fullTextSearch, indexFullTextSearch = fullTextSearch,
// Tokenize off the commit path; NostrServer drives the catch-up // Tokenize off the commit path; NostrServer drives the catch-up
// worker and search queries drain it first, so NIP-50 stays // worker and search queries drain it first, so NIP-50 stays

View File

@@ -83,6 +83,8 @@ val store = EventStore(
By default, all single-letter tags with values are indexed. Override `shouldIndex(kind, tag)` for custom behavior. More indexes = faster queries but larger database. By default, all single-letter tags with values are indexed. Override `shouldIndex(kind, tag)` for custom behavior. More indexes = faster queries but larger database.
Flag flips are safe on existing databases: any flag-gated index the strategy wants but the on-disk schema lacks is created on the next open (idempotent `CREATE INDEX IF NOT EXISTS`, one-time build cost) — no schema version bump involved. Disabling a flag never drops an existing index.
`indexFullTextSearch` defaults to `true` and controls the NIP-50 full-text index (`event_fts`). Set it to `false` when search is served elsewhere (e.g. a Vespa backend, or a `SearchEventSource` as shown below): inserts skip the FTS tokenization cost, no `event_fts` table/trigger is created, and any filter carrying a non-empty `search` term returns no matches. `indexFullTextSearch` defaults to `true` and controls the NIP-50 full-text index (`event_fts`). Set it to `false` when search is served elsewhere (e.g. a Vespa backend, or a `SearchEventSource` as shown below): inserts skip the FTS tokenization cost, no `event_fts` table/trigger is created, and any filter carrying a non-empty `search` term returns no matches.
## Non-Storage Relays (search, redirector, computed) ## Non-Storage Relays (search, redirector, computed)

View File

@@ -94,6 +94,8 @@ kotlin {
// Forward the negentropy-benchmark corpus size to the test JVM. // Forward the negentropy-benchmark corpus size to the test JVM.
System.getProperty("negBenchN")?.let { systemProperty("negBenchN", it) } System.getProperty("negBenchN")?.let { systemProperty("negBenchN", it) }
System.getProperty("followBenchScale")?.let { systemProperty("followBenchScale", it) } System.getProperty("followBenchScale")?.let { systemProperty("followBenchScale", it) }
System.getProperty("tagBenchScale")?.let { systemProperty("tagBenchScale", it) }
System.getProperty("fsBenchScale")?.let { systemProperty("fsBenchScale", it) }
// Opt-in JFR profiling of a benchmark run (-PnegProfile=/tmp/neg.jfr). // Opt-in JFR profiling of a benchmark run (-PnegProfile=/tmp/neg.jfr).
(project.findProperty("negProfile") as? String)?.let { (project.findProperty("negProfile") as? String)?.let {
jvmArgs("-XX:+FlightRecorder", "-XX:StartFlightRecording=filename=$it,settings=profile,dumponexit=true") jvmArgs("-XX:+FlightRecorder", "-XX:StartFlightRecording=filename=$it,settings=profile,dumponexit=true")

View File

@@ -0,0 +1,168 @@
# SQLite query scaling: contentless FTS + rowid search, tag-path merge, pooled statements
**Status: shipped.** Follow-up to a scale-curve report (`SQLite vs Vespa`,
throughput vs corpus size) showing the app-side SQLite store degrading with
corpus size on several query shapes — NIP-50 search ~18× (25k→400k), batch
ingest 16×, author-timeline 13×, follow-feed 2.4× — while point reads stayed
flat. Two of those (author-timeline, follow-feed) were already addressed
(the pubkey-alone index and `MergeQueryExecutor`) and mostly reflect running
the report under the *client* `DefaultIndexingStrategy` rather than
`relayIndexingStrategy()`. This change adds the **large-IN tag watcher** to the
merge executor, fixes the **FTS delete path** (which degraded with corpus
size) and shrinks the FTS index, fixes **NIP-50 search ordering** (it was
sorting by `created_at`, not relevance), and fixes a **statement-cache** miss
the merge paths hit.
It does **not** fix NIP-50 *search* latency: bm25 relevance scoring (like the
old `created_at` sort) must visit every match, so search cost still grows with
the match set (§1). Corpus-independent search is an external-engine job.
Everything here is read/size work; the write path and on-disk index set are
unchanged except the FTS table, which gets *smaller* and deletes faster.
## 1. FTS — contentless index (fast deletes + smaller), NOT a search-scaling fix
The old index was `fts5(event_header_row_id, content)`: it stored a second
copy of the tokenized text, its rowid was auto-assigned (unrelated to the
event), and — the real problem — the `fts_foreign_key` delete trigger deleted
by the `event_header_row_id` *column*, which FTS5 cannot seek, so it **scanned
the whole index per delete**.
Changes (`FullTextSearchModule`, `QueryBuilder`, DB version 4→5):
- **rowid = `event_headers.row_id`.** Set explicitly on every insert; the
delete trigger, reindex, and catch-up paths key off it. Deletes become an
O(log n) primary-key seek instead of an O(n) column scan. This is the win:
every event removal fires the trigger — replaceable rotation, kind-5,
expiration, right-to-vanish — so on the old schema deletion throughput
degraded with corpus size.
- **Contentless** (`fts5(content, content='', contentless_delete=1)`). The
indexed text is *derived* (`SearchableEvent.indexableContent()`, not a raw
column), so FTS5 **external-content** — which re-reads the source column from
the base table — cannot express it; **contentless** is the correct primitive.
It drops the stored content copy (index shrinks) and `contentless_delete=1`
keeps the delete trigger working.
- **Segment compaction.** `reindexAll` finishes with `'optimize'`, and the
periodic `SQLiteEventStore.optimize()` (geode's maintenance tick) folds in a
bounded `'merge'`, so incremental / deferred-catch-up inserts don't leave the
index as many small segments.
Delete cost — `FtsSearchScalingBenchmark.deleteByColumnVsByRowid`, 500 deletes,
in-memory:
| rows | by column (old) | by rowid (new) |
|---|---:|---:|
| 2k | 91.8 ms | 6.6 ms |
| 8k | 362.0 ms | 4.7 ms |
By-column grows ~linearly with the table (O(n)/delete); by-rowid is flat —
~78× at 8k rows and widening.
**Search ordering fixed to relevance (NIP-50), which the store was getting
wrong.** NIP-50 says results are returned "in descending order by quality of
search result ... not by the usual `.created_at`", with the limit applied after
the score — but the store sorted search by `created_at DESC` (pre-existing).
*Every* search filter now orders by FTS5 bm25 (`ORDER BY event_fts.rank`,
`created_at DESC` as a tie-break): the tag-free shape (`makeSimpleSearch`) and
`search + tag` (the `prepareRowIDSubQueries`/`makeQueryIn` path, which carries
the rank column through the row-id subquery via `projectRank` and applies the
LIMIT by rank). Only the negentropy snapshot keeps `created_at` — a sync set,
not a ranked result. Verified against stronger-but-older matches outranking
weaker-but-newer ones, tag-scoped, with the limit cutting by score
(`SearchRelevanceOrderTest`, `Fts5CapabilityProbe`).
An earlier draft of *this* change instead added a `searchOrderByRowId` flag
(order by the FTS rowid for O(limit) search); that is *ingestion* order — wrong
events under a limit once ingestion diverges from time order, and not relevance
either — so it was removed.
This is a **correctness** fix, not a scaling one: bm25 (like the created_at
sort) must score every match, so search latency still grows with the match set
(the report's 18× curve) and `optimize` doesn't change the asymptotics
(measured within noise at 100k/200k). Corpus-independent search is genuinely an
external-engine job (the Vespa side of the report), not this index.
Migration (v4→v5): the old rowids can't be remapped, so `event_fts` is dropped
and rebuilt — synchronous stores rebuild in the upgrade transaction (client
corpora are small), deferred stores reset the catch-up watermark to 0 and let
the background worker repopulate (no long migration transaction).
`ContentlessFtsMigrationTest` fabricates a real v4 DB (old schema + garbage
rows + `user_version=4`) and asserts the reopen rebuilds search, wipes the
stale rows, maps rowid→row_id, and keeps the delete trigger working.
## 2. Tag watcher — extend `MergeQueryExecutor` to the tag path
`kinds=[7] AND #e=[hundreds of note ids] LIMIT n` (reactions/replies) had the
same shape the follow-feed fix already solved for authors: the per-value
streams come sorted off `(tag_hash[, kind], created_at)`, but their union does
not, so SQLite collected every matching row and TEMP-B-TREE-sorted to the limit
— O(matching history), growing with the corpus (`TagAuthorIndexBenchmark`:
12.8 ms cold at 200k → 14.2 ms at 1M).
`MergeQueryExecutor` now opens one lazy newest-first cursor per `(value[,
kind])` off `query_by_tags_hash_kind` (or `query_by_tags_hash` when there is no
kind, gated by `indexTagsByCreatedAtAlone`) and heap-merges to the limit —
**O(limit + streams)**. Unlike the author path, one event can carry several
queried tag values, so the tag merge **dedups by event id** through a `seen`
set (the author path, one pubkey per event, skips it). Eligibility is narrow
(one non-`d` tag key, ≥2 values, a limit, no ids/authors/d-tag/search, no
AND-tags); everything else falls through to the single-SQL plan. Counts and
deletes are unchanged (still single-SQL). `TagMergeCorrectnessTest` pins it
against a Kotlin reference including cross-stream dedup, since/until windows,
the raw path, and tie handling (tag cursors order off `event_tags`, which has
no id column, so same-second ties are a valid newest-N but not id-exact).
## 3. Pooled statement cache — so the merges actually cache
`StatementCachingConnection` kept **one** handle per SQL string. The k-way
merge opens *many* identical-SQL cursors at once (one per author/tag stream),
so every stream past the first missed the cache and prepared uncached, and a
repeated follow-feed / reactions REQ re-prepared all of them each poll. The
cache now keeps a small **pool** per SQL (bounded by a global cap, default
raised 256→512), so concurrent same-SQL checkouts reuse cached handles and
repeated polls reuse their per-stream cursors. `StatementCachingConnectionTest`
covers sequential reuse, concurrent distinctness/independence, freed-handle
reuse, and cap overflow → uncached fallback.
## Correctness / scope
- DB version bump 4→5 with a real-upgrade test.
- New capability test (`Fts5CapabilityProbe`) pins the FTS5 features the
contentless index needs (contentless_delete, absent-rowid delete no-op,
rowid ordering, merge/optimize) to the bundled SQLite (3.50.1), so a driver
downgrade fails loudly.
- All existing `store.sqlite` tests pass unchanged except `QueryAssemblerTest`,
whose asserted EXPLAIN output updated for the new join column
(`event_fts.rowid`) and the contentless table's virtual-index marker
(`0:M2``0:M1`).
- Search ordering: bm25 relevance for every search REQ shape — tag-free,
`search + tag`, and multi-filter all-search (e.g. the client's
search-across-kinds, unioned then deduped by event keeping the best score).
Mixed search/non-search multi-filter REQs stay `created_at` (a non-search
branch has no defined relevance).
## Audit follow-ups (post-review hardening)
A two-reviewer adversarial audit of the branch found no correctness/data-loss/
crash bugs; it produced these robustness fixes and one gap-closure:
- **Multi-filter search ordering** (gap): a REQ of several filters that all
carry a search term was `created_at`-ordered (the union path). Now
relevance-ordered via `unionSubqueriesIfNeeded(projectRank)` — each branch
projects `rank`, `UNION ALL` + `GROUP BY row_id MIN(rank)` dedups across
branches keeping the best score. Only when every branch is a search branch
(and FTS is on); count/delete unions stay single-column.
- **Merge stream-prep leak** (F1): `prepareAuthorStreams`/`prepareTagStreams`
now build cursors through `buildStreams`, which closes any already-prepared
statements if a later prepare throws — otherwise a mid-loop failure stranded
checked-out, un-reset handles (read locks) in the pooled connection.
- **Stream-count overflow** (F3): `authors×kinds` / `values×kinds` computed as
`Long` so a pathological product can't wrap `Int` back into the eligible
band and route a huge fan-out into the merge.
- **Finalizer footgun** (F2): the pooled statement's `finalize()` was renamed
`finalizeStatement()` — a no-arg `finalize()` is the JVM's `Object.finalize`,
so the GC could double-close the native handle after explicit close.
Migration cost noted (low/operational, not correctness): the synchronous
v4→v5 rebuild runs in the upgrade transaction; atomic and kill-safe (rolls
back to v4), but a very large client store pays a one-time first-open stall.

View File

@@ -33,7 +33,7 @@ import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
import com.vitorpamplona.quartz.concord.cord04Roles.asFloor import com.vitorpamplona.quartz.concord.cord04Roles.asFloor
/** A channel id paired with its current folded definition. */ /** A channel id paired with its current folded definition. */
class ConcordChannel( data class ConcordChannel(
val channelIdHex: String, val channelIdHex: String,
val definition: ChannelEntity, val definition: ChannelEntity,
) )
@@ -49,7 +49,7 @@ class ConcordChannel(
* "Every member keeps the entire Control Plane in sync — it is small and must * "Every member keeps the entire Control Plane in sync — it is small and must
* stay complete." Recompute this whenever the known editions change. * stay complete." Recompute this whenever the known editions change.
*/ */
class ConcordCommunityState( data class ConcordCommunityState(
val ownerPubKey: String, val ownerPubKey: String,
val metadata: MetadataEntity?, val metadata: MetadataEntity?,
val channels: Map<String, ConcordChannel>, val channels: Map<String, ConcordChannel>,

View File

@@ -44,7 +44,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
* assigned Role and holds [ConcordPermissions.MANAGE_ROLES]. Cycles that never * assigned Role and holds [ConcordPermissions.MANAGE_ROLES]. Cycles that never
* touch the owner can never bootstrap themselves. * touch the owner can never bootstrap themselves.
*/ */
class AuthorityResolver private constructor( data class AuthorityResolver private constructor(
private val ownerLower: String, private val ownerLower: String,
private val roles: Map<String, RoleEntity>, private val roles: Map<String, RoleEntity>,
private val memberRoles: Map<String, Set<String>>, private val memberRoles: Map<String, Set<String>>,

View File

@@ -64,7 +64,7 @@ object ConcordJson {
* drops the role, and with it every authority (grant) that depends on it. * drops the role, and with it every authority (grant) that depends on it.
*/ */
@Serializable @Serializable
class RoleScope( data class RoleScope(
val kind: String = "server", val kind: String = "server",
@SerialName("channel_id") val channelId: String? = null, @SerialName("channel_id") val channelId: String? = null,
) )
@@ -75,7 +75,7 @@ class RoleScope(
* ranks higher; no role may claim position 0 (reserved for the owner). * ranks higher; no role may claim position 0 (reserved for the owner).
*/ */
@Serializable @Serializable
class RoleEntity( data class RoleEntity(
val name: String = "", val name: String = "",
val position: Long = 0, val position: Long = 0,
/** u64 permission bitfield as a decimal string. */ /** u64 permission bitfield as a decimal string. */
@@ -94,7 +94,7 @@ class RoleEntity(
* terminates at the owner (see [AuthorityResolver]). * terminates at the owner (see [AuthorityResolver]).
*/ */
@Serializable @Serializable
class GrantEntity( data class GrantEntity(
val member: String = "", val member: String = "",
@SerialName("role_ids") val roleIds: List<String> = emptyList(), @SerialName("role_ids") val roleIds: List<String> = emptyList(),
) )
@@ -105,7 +105,7 @@ class GrantEntity(
* A [deleted] channel is terminal — its id is never reused. * A [deleted] channel is terminal — its id is never reused.
*/ */
@Serializable @Serializable
class ChannelEntity( data class ChannelEntity(
val name: String = "", val name: String = "",
val private: Boolean = false, val private: Boolean = false,
val voice: Boolean = false, val voice: Boolean = false,
@@ -122,7 +122,7 @@ class ChannelEntity(
* community name too. * community name too.
*/ */
@Serializable @Serializable
class MetadataEntity( data class MetadataEntity(
val name: String = "", val name: String = "",
val icon: ImagePointer? = null, val icon: ImagePointer? = null,
val banner: ImagePointer? = null, val banner: ImagePointer? = null,

View File

@@ -25,6 +25,22 @@ class EoseMessage(
) : Message { ) : Message {
override fun label() = LABEL override fun label() = LABEL
/**
* Wire form is `["EOSE","<subId>"]` — sent once per REQ, so it is on
* the per-subscription floor. Splice it directly when [subId] needs no
* escaping (the common case: client-chosen sub ids are short ASCII),
* skipping the generic serializer's node tree. Byte-identical output;
* any exotic subId falls back.
*/
override fun toJson(): String {
if (!isEscapeFreeAscii(subId)) return super.toJson()
return buildString(subId.length + 12) {
append("[\"EOSE\",\"")
append(subId)
append("\"]")
}
}
companion object { companion object {
const val LABEL = "EOSE" const val LABEL = "EOSE"
} }

View File

@@ -29,6 +29,25 @@ class OkMessage(
) : Message { ) : Message {
override fun label() = LABEL override fun label() = LABEL
/**
* Wire form is `["OK","<eventId>",<true|false>,"<message>"]` — sent
* once per published EVENT. [eventId] is validated hex (always
* escape-free); splice directly when [message] also needs no escaping,
* which covers the empty-string success ack and the plain-ASCII
* rejection reasons. Byte-identical output; a reason with quotes or
* non-ASCII falls back to the generic serializer.
*/
override fun toJson(): String {
if (!isEscapeFreeAscii(message)) return super.toJson()
return buildString(eventId.length + message.length + 20) {
append("[\"OK\",\"")
append(eventId)
append(if (success) "\",true,\"" else "\",false,\"")
append(message)
append("\"]")
}
}
companion object { companion object {
const val LABEL = "OK" const val LABEL = "OK"

View File

@@ -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.nip01Core.relay.commands.toClient
/**
* True when every char of [s] is printable ASCII (0x200x7e) and not a JSON
* metacharacter (`"` / `\`) — i.e. exactly the bytes a JSON string encoder
* would emit verbatim between the quotes. Frame builders use this to gate a
* direct-`buildString` fast path against the generic serializer: when it holds
* the spliced output is byte-identical, and any exotic value (control chars,
* quotes, non-ASCII) falls back to the escaping serializer.
*/
internal fun isEscapeFreeAscii(s: String): Boolean {
for (c in s) {
if (c < ' ' || c > '~' || c == '"' || c == '\\') return false
}
return true
}

View File

@@ -22,6 +22,11 @@ package com.vitorpamplona.quartz.nip01Core.relay.filters
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.collections.immutable.PersistentMap
import kotlinx.collections.immutable.PersistentSet
import kotlinx.collections.immutable.persistentHashMapOf
import kotlinx.collections.immutable.persistentHashSetOf
import kotlinx.collections.immutable.toPersistentHashSet
import kotlin.concurrent.atomics.AtomicReference import kotlin.concurrent.atomics.AtomicReference
import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.ExperimentalAtomicApi
@@ -62,9 +67,10 @@ import kotlin.concurrent.atomics.ExperimentalAtomicApi
* copy-on-write CAS loops, mirroring the * copy-on-write CAS loops, mirroring the
* `nip86RelayManagement.server.BanStore` pattern. Reads in * `nip86RelayManagement.server.BanStore` pattern. Reads in
* [candidatesFor] and [forEach] are wait-free single-load atomic. * [candidatesFor] and [forEach] are wait-free single-load atomic.
* Writes (subscription register / unregister) copy the inner maps * Writes (subscription register / unregister) build the next snapshot
* — fine for this workload because writes are subscription-rate * from persistent (HAMT) maps — O(keys × log S) with structural
* (rare) while reads are event-rate (frequent). * sharing rather than a full O(S) copy of both maps, since on a relay
* a write happens on every REQ open and close.
* *
* ## What the index does NOT cover * ## What the index does NOT cover
* *
@@ -109,14 +115,26 @@ class FilterIndex<S : Any> {
private object Unindexed : BucketKey private object Unindexed : BucketKey
/** /**
* Single immutable snapshot. [buckets] maps a key to the set of * Single immutable snapshot. Subscribers are held in one map per
* subscribers registered under it; [assignments] is the reverse * indexable dimension so [candidatesFor] — called once per accepted
* map used by [unregister] to find a subscriber's keys without * ingest event, the hot read — can probe each dimension with the
* scanning every bucket. * event's own field (`event.id`, `event.pubKey`, `tag[0]`/`tag[1]`,
* `event.kind`) and allocate no key-wrapper objects. [assignments] is
* the reverse map ([S] → the [BucketKey]s it occupies) used by
* [unregister]; the wrappers live only here, built on the rare
* register path.
*
* Persistent (HAMT) maps/sets: a register/unregister produces the
* next snapshot in O(keys × log S) with structural sharing, instead
* of copying full maps — registration happens on every REQ open/close.
*/ */
private data class State<S>( private data class State<S>(
val buckets: Map<BucketKey, Set<S>> = emptyMap(), val ids: PersistentMap<HexKey, PersistentSet<S>> = persistentHashMapOf(),
val assignments: Map<S, Set<BucketKey>> = emptyMap(), val authors: PersistentMap<HexKey, PersistentSet<S>> = persistentHashMapOf(),
val tags: PersistentMap<String, PersistentMap<String, PersistentSet<S>>> = persistentHashMapOf(),
val kinds: PersistentMap<Int, PersistentSet<S>> = persistentHashMapOf(),
val unindexed: PersistentSet<S> = persistentHashSetOf(),
val assignments: PersistentMap<S, PersistentSet<BucketKey>> = persistentHashMapOf(),
) )
private val state: AtomicReference<State<S>> = AtomicReference(State()) private val state: AtomicReference<State<S>> = AtomicReference(State())
@@ -181,18 +199,23 @@ class FilterIndex<S : Any> {
while (true) { while (true) {
val current = state.load() val current = state.load()
val keys = current.assignments[subscriber] ?: return val keys = current.assignments[subscriber] ?: return
val newBuckets = current.buckets.toMutableMap() var ids = current.ids
var authors = current.authors
var tags = current.tags
var kinds = current.kinds
var unindexed = current.unindexed
for (key in keys) { for (key in keys) {
val cur = newBuckets[key] ?: continue when (key) {
val next = cur - subscriber is IdKey -> ids = ids.removeSub(key.id, subscriber)
if (next.isEmpty()) { is AuthorKey -> authors = authors.removeSub(key.author, subscriber)
newBuckets.remove(key) is KindKey -> kinds = kinds.removeSub(key.kind, subscriber)
} else { is TagKey -> tags = tags.removeTagSub(key.letter, key.value, subscriber)
newBuckets[key] = next Unindexed -> unindexed = unindexed.remove(subscriber)
} }
} }
val newAssignments = current.assignments - subscriber val next =
if (state.compareAndSet(current, State(newBuckets, newAssignments))) return State(ids, authors, tags, kinds, unindexed, current.assignments.remove(subscriber))
if (state.compareAndSet(current, next)) return
} }
} }
@@ -202,19 +225,22 @@ class FilterIndex<S : Any> {
* candidate to handle negative constraints. * candidate to handle negative constraints.
* *
* Iteration order is insertion-stable per call but otherwise * Iteration order is insertion-stable per call but otherwise
* unspecified. * unspecified. Allocates only the result set — dimensions are
* probed with the event's own fields, no key wrappers.
*/ */
fun candidatesFor(event: Event): Set<S> { fun candidatesFor(event: Event): Set<S> {
val s = state.load() val s = state.load()
if (s.buckets.isEmpty()) return emptySet() if (s.assignments.isEmpty()) return emptySet()
val result = LinkedHashSet<S>() val result = LinkedHashSet<S>()
s.buckets[Unindexed]?.let { result.addAll(it) } if (s.unindexed.isNotEmpty()) result.addAll(s.unindexed)
s.buckets[IdKey(event.id)]?.let { result.addAll(it) } s.ids[event.id]?.let { result.addAll(it) }
s.buckets[AuthorKey(event.pubKey)]?.let { result.addAll(it) } s.authors[event.pubKey]?.let { result.addAll(it) }
s.buckets[KindKey(event.kind)]?.let { result.addAll(it) } s.kinds[event.kind]?.let { result.addAll(it) }
for (tag in event.tags) { if (s.tags.isNotEmpty()) {
if (tag.size >= 2 && tag[0].length == 1) { for (tag in event.tags) {
s.buckets[TagKey(tag[0], tag[1])]?.let { result.addAll(it) } if (tag.size >= 2 && tag[0].length == 1) {
s.tags[tag[0]]?.get(tag[1])?.let { result.addAll(it) }
}
} }
} }
return result return result
@@ -235,19 +261,75 @@ class FilterIndex<S : Any> {
keys: List<BucketKey>, keys: List<BucketKey>,
) { ) {
if (keys.isEmpty()) return if (keys.isEmpty()) return
val keySet = keys.toSet() val keySet = keys.toPersistentHashSet()
while (true) { while (true) {
val current = state.load() val current = state.load()
val newBuckets = current.buckets.toMutableMap() var ids = current.ids
var authors = current.authors
var tags = current.tags
var kinds = current.kinds
var unindexed = current.unindexed
for (key in keySet) { for (key in keySet) {
val cur = newBuckets[key] ?: emptySet() when (key) {
if (subscriber in cur) continue is IdKey -> ids = ids.addSub(key.id, subscriber)
newBuckets[key] = cur + subscriber is AuthorKey -> authors = authors.addSub(key.author, subscriber)
is KindKey -> kinds = kinds.addSub(key.kind, subscriber)
is TagKey -> tags = tags.addTagSub(key.letter, key.value, subscriber)
Unindexed -> unindexed = unindexed.add(subscriber)
}
} }
val existing = current.assignments[subscriber] val existing = current.assignments[subscriber]
val merged = if (existing == null) keySet else existing + keySet val merged = existing?.addAll(keySet) ?: keySet
val newAssignments = current.assignments + (subscriber to merged) val next = State(ids, authors, tags, kinds, unindexed, current.assignments.put(subscriber, merged))
if (state.compareAndSet(current, State(newBuckets, newAssignments))) return if (state.compareAndSet(current, next)) return
}
}
// Per-dimension add/remove of one subscriber, returning the same map
// instance when nothing changed so the CAS builds minimal new nodes.
private fun <K> PersistentMap<K, PersistentSet<S>>.addSub(
key: K,
sub: S,
): PersistentMap<K, PersistentSet<S>> {
val cur = this[key] ?: persistentHashSetOf()
val next = cur.add(sub)
return if (next === cur) this else put(key, next)
}
private fun <K> PersistentMap<K, PersistentSet<S>>.removeSub(
key: K,
sub: S,
): PersistentMap<K, PersistentSet<S>> {
val cur = this[key] ?: return this
val next = cur.remove(sub)
return when {
next === cur -> this
next.isEmpty() -> remove(key)
else -> put(key, next)
}
}
private fun PersistentMap<String, PersistentMap<String, PersistentSet<S>>>.addTagSub(
letter: String,
value: String,
sub: S,
): PersistentMap<String, PersistentMap<String, PersistentSet<S>>> {
val inner = this[letter] ?: persistentHashMapOf()
val newInner = inner.addSub(value, sub)
return if (newInner === inner) this else put(letter, newInner)
}
private fun PersistentMap<String, PersistentMap<String, PersistentSet<S>>>.removeTagSub(
letter: String,
value: String,
sub: S,
): PersistentMap<String, PersistentMap<String, PersistentSet<S>>> {
val inner = this[letter] ?: return this
val newInner = inner.removeSub(value, sub)
return when {
newInner === inner -> this
newInner.isEmpty() -> remove(letter)
else -> put(letter, newInner)
} }
} }

View File

@@ -49,6 +49,7 @@ import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.cache.LargeCache import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.ClosedSendChannelException import kotlinx.coroutines.channels.ClosedSendChannelException
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -291,8 +292,16 @@ class RelaySession(
// Policy may rewrite filters to match the user's access level. // Policy may rewrite filters to match the user's access level.
val filters = (result as PolicyResult.Accepted).cmd.filters val filters = (result as PolicyResult.Accepted).cmd.filters
// UNDISPATCHED: the stored replay runs inline on this coroutine —
// the reader-pool acquire doesn't suspend when a connection is
// free, so EVENT frames and EOSE go out without a scheduler hop
// (SmallReqFloorBenchmark: the hop was most of the dispatch
// slice on small REQs). The coroutine first parks at the live
// tail (awaitCancellation), which is when launch returns and the
// job lands in [subscriptions]; commands on this connection are
// processed sequentially, so nothing can target the sub earlier.
val job = val job =
scope.launch { scope.launch(start = CoroutineStart.UNDISPATCHED) {
try { try {
if (policy.filtersOutgoingEvents) { if (policy.filtersOutgoingEvents) {
// Screened path: every event is materialized so the // Screened path: every event is materialized so the
@@ -331,7 +340,20 @@ class RelaySession(
}, },
) )
}, },
onEachLive = { event -> send(EventMessage(cmd.subId, event)) }, // Live events arrive with their wire body already
// serialized (once per event, shared across every
// matching subscription): splice it into the same
// per-sub frame prefix as the stored replay, no
// per-event EventMessage or re-serialize.
onEachLive = { _, body ->
sendRaw(
buildString(framePrefix.length + body.length + 1) {
append(framePrefix)
append(body)
append(']')
},
)
},
onEose = { send(EoseMessage(cmd.subId)) }, onEose = { send(EoseMessage(cmd.subId)) },
) )
} }

View File

@@ -74,13 +74,57 @@ class LiveEventStore(
* One live REQ subscription. Carries the filters (for the * One live REQ subscription. Carries the filters (for the
* post-index `match` re-check needed for negative constraints * post-index `match` re-check needed for negative constraints
* like `since` / `until` / `tagsAll`) and the delivery callback * like `since` / `until` / `tagsAll`) and the delivery callback
* the index dispatches into. Identity-keyed inside [FilterIndex]. * the index dispatches into. [deliver] receives the event and its
* pre-serialized wire body (memoized once per fanout across all
* matching subscribers). Identity-keyed inside [FilterIndex].
*/ */
private class LiveSubscription( private class LiveSubscription(
val filters: List<Filter>, val filters: List<Filter>,
val deliver: (Event) -> Unit, val deliver: (Event, String) -> Unit,
) )
/**
* Replay-dedupe set for one REQ. During the historical replay the
* store's ids are [record]ed here so the concurrent live path can
* drop an event the replay also emitted; after EOSE the set is
* [release]d and the live path forwards everything.
*
* Written from the replay coroutine and read from the [IngestQueue]
* drain coroutine (via `fanout`), so every access takes a tiny spin
* lock — [locked] is `inline`, so the per-row `record` / `isDuplicate`
* calls allocate no closure. The backing `HashSet` is created empty
* up front (so the register-before-replay race guarantee holds) but
* the JVM defers its table allocation to the first `add`, so a
* zero-row replay costs only the empty set object, not a sized table.
* It MUST stay a mutable set under a lock, never a copy-on-add
* immutable set — `set + id` per row made large replays O(n²).
*/
private class SeenIds {
private val lock = AtomicBoolean(false)
private var ids: HashSet<String>? = HashSet()
private inline fun <R> locked(block: () -> R): R {
while (lock.exchange(true)) {
while (lock.load()) { }
}
try {
return block()
} finally {
lock.store(false)
}
}
fun record(id: String) {
locked { ids?.add(id) }
}
fun isDuplicate(id: String): Boolean = locked { ids?.contains(id) ?: false }
fun release() {
locked { ids = null }
}
}
/** /**
* Fire-and-forget enqueue: hand [event] to the [IngestQueue] and * Fire-and-forget enqueue: hand [event] to the [IngestQueue] and
* fire [onComplete] once the writer's batch has a per-row * fire [onComplete] once the writer's batch has a per-row
@@ -149,9 +193,17 @@ class LiveEventStore(
* batch writer. * batch writer.
*/ */
private fun fanout(event: Event) { private fun fanout(event: Event) {
for (sub in index.candidatesFor(event)) { val candidates = index.candidatesFor(event)
if (candidates.isEmpty()) return
// Serialize the wire body at most once for this event, no matter
// how many subscriptions match it — the old path re-serialized the
// whole event per matching subscriber, so a note landing in N live
// feeds paid N identical Jackson passes. Lazy so a fanout that
// matches nothing (index over-approximates) serializes nothing.
var body: String? = null
for (sub in candidates) {
if (sub.filters.any { it.match(event) }) { if (sub.filters.any { it.match(event) }) {
sub.deliver(event) sub.deliver(event, body ?: event.toJson().also { body = it })
} }
} }
} }
@@ -178,61 +230,33 @@ class LiveEventStore(
onEose: () -> Unit, onEose: () -> Unit,
) { ) {
drainFtsIfSearching(filters) drainFtsIfSearching(filters)
// During the historical replay, record ids the store has // The index registers *before* the replay starts (otherwise an
// emitted so the live path can dedupe. The index registers // event accepted mid-replay would slip past the live path entirely
// *before* the replay starts (otherwise an event accepted // — same race the previous SharedFlow-based implementation closed
// mid-replay would slip past the live path entirely — same // with `onSubscription`), and [SeenIds] bridges the two coroutines:
// race the previous SharedFlow-based implementation closed // the replay records ids here, the live `deliver` drops duplicates,
// with `onSubscription`). // and after EOSE the set is released so every live event forwards.
// val seen = SeenIds()
// The set is read from the [IngestQueue] drain coroutine (in
// `deliver`, called synchronously from `fanout`) and written
// from this coroutine (the historical-replay closure below),
// so access is guarded by a tiny spin lock (contains/add,
// never I/O). It MUST be a mutable set under a lock, not an
// immutable Set under an AtomicReference with copy-on-add:
// `set + id` copies the whole set per streamed event, which
// made large replays accidentally O(n²) — a 100k-event REQ
// crawled at ~700 events/s and the rate degraded as the
// response grew (see the plan doc's giant-REQ finding).
//
// Once cleared to null after EOSE, `deliver` short-circuits
// and every live event is forwarded.
val seenLock = AtomicBoolean(false)
var seenIds: HashSet<String>? = HashSet(1024)
fun <R> seenLocked(block: () -> R): R {
while (seenLock.exchange(true)) {
while (seenLock.load()) { }
}
try {
return block()
} finally {
seenLock.store(false)
}
}
val sub = val sub =
LiveSubscription( LiveSubscription(
filters = filters, filters = filters,
deliver = { event -> deliver = { event, _ ->
val duplicate = seenLocked { seenIds?.contains(event.id) ?: false } if (!seen.isDuplicate(event.id)) onEach(event)
if (duplicate) return@LiveSubscription
onEach(event)
}, },
) )
index.register(filters, sub) index.register(filters, sub)
try { try {
store.query<Event>(filters.strippingSearchExtensions()) { event -> store.query<Event>(filters.strippingSearchExtensions()) { event ->
seenLocked { seenIds?.add(event.id) } seen.record(event.id)
onEach(event) onEach(event)
} }
onEose() onEose()
// Drop the dedupe set so the live path stops paying for // Drop the dedupe set so the live path stops paying for
// it. From this point the index drives delivery and // it. From this point the index drives delivery and
// duplicates are no longer possible. // duplicates are no longer possible.
seenLocked { seenIds = null } seen.release()
// Suspend until the caller's coroutine is cancelled // Suspend until the caller's coroutine is cancelled
// (e.g. NIP-01 CLOSE or connection drop). The `finally` // (e.g. NIP-01 CLOSE or connection drop). The `finally`
// unregisters from the index. // unregisters from the index.
@@ -255,42 +279,28 @@ class LiveEventStore(
ctx: RequestContext, ctx: RequestContext,
filters: List<Filter>, filters: List<Filter>,
onEachStored: (RawEvent) -> Unit, onEachStored: (RawEvent) -> Unit,
onEachLive: (Event) -> Unit, onEachLive: (Event, String) -> Unit,
onEose: () -> Unit, onEose: () -> Unit,
) { ) {
drainFtsIfSearching(filters) drainFtsIfSearching(filters)
val seenLock = AtomicBoolean(false) val seen = SeenIds()
var seenIds: HashSet<String>? = HashSet(1024)
fun <R> seenLocked(block: () -> R): R {
while (seenLock.exchange(true)) {
while (seenLock.load()) { }
}
try {
return block()
} finally {
seenLock.store(false)
}
}
val sub = val sub =
LiveSubscription( LiveSubscription(
filters = filters, filters = filters,
deliver = { event -> deliver = { event, body ->
val duplicate = seenLocked { seenIds?.contains(event.id) ?: false } if (!seen.isDuplicate(event.id)) onEachLive(event, body)
if (duplicate) return@LiveSubscription
onEachLive(event)
}, },
) )
index.register(filters, sub) index.register(filters, sub)
try { try {
store.rawQuery(filters.strippingSearchExtensions()) { raw -> store.rawQuery(filters.strippingSearchExtensions()) { raw ->
seenLocked { seenIds?.add(raw.id) } seen.record(raw.id)
onEachStored(raw) onEachStored(raw)
} }
onEose() onEose()
seenLocked { seenIds = null } seen.release()
awaitCancellation() awaitCancellation()
} finally { } finally {
index.unregister(sub) index.unregister(sub)

View File

@@ -78,9 +78,9 @@ interface SessionBackend {
ctx: RequestContext, ctx: RequestContext,
filters: List<Filter>, filters: List<Filter>,
onEachStored: (RawEvent) -> Unit, onEachStored: (RawEvent) -> Unit,
onEachLive: (Event) -> Unit, onEachLive: (Event, String) -> Unit,
onEose: () -> Unit, onEose: () -> Unit,
): Unit = query(ctx, filters, onEachLive, onEose) ): Unit = query(ctx, filters, { onEachLive(it, it.toJson()) }, onEose)
/** Answers a NIP-45 COUNT with an exact cardinality for the caller in [ctx]. */ /** Answers a NIP-45 COUNT with an exact cardinality for the caller in [ctx]. */
suspend fun count( suspend fun count(

View File

@@ -147,13 +147,38 @@ class EventIndexesModule(
*/ */
fun migrateV2AddPubkeyIndex(db: SQLiteConnection) { fun migrateV2AddPubkeyIndex(db: SQLiteConnection) {
if (!indexStrategy.indexEventsByPubkeyAlone) return if (!indexStrategy.indexEventsByPubkeyAlone) return
val orderBy = db.execSQL("CREATE INDEX IF NOT EXISTS query_by_pubkey_created ON event_headers (pubkey, ${orderByColumns()})")
if (indexStrategy.useAndIndexIdOnOrderBy) { }
"created_at DESC, id ASC"
} else { private fun orderByColumns() =
"created_at DESC" if (indexStrategy.useAndIndexIdOnOrderBy) {
} "created_at DESC, id ASC"
db.execSQL("CREATE INDEX IF NOT EXISTS query_by_pubkey_created ON event_headers (pubkey, $orderBy)") } else {
"created_at DESC"
}
/**
* Materializes any flag-gated index the current [indexStrategy] wants
* but the on-disk schema predates. Flags are runtime configuration, not
* schema — a deployment can flip one without a `user_version` bump — so
* this runs idempotently on every open. The first open after enabling a
* flag pays a one-time index build over the existing rows; subsequent
* opens are no-ops. A disabled flag never drops an existing index (that
* stays an operator decision).
*/
fun ensureOptionalIndexes(db: SQLiteConnection) {
if (indexStrategy.indexEventsByCreatedAtAlone) {
db.execSQL("CREATE INDEX IF NOT EXISTS query_by_created_at_id ON event_headers (${orderByColumns()})")
}
if (indexStrategy.indexEventsByPubkeyAlone) {
db.execSQL("CREATE INDEX IF NOT EXISTS query_by_pubkey_created ON event_headers (pubkey, ${orderByColumns()})")
}
if (indexStrategy.indexTagsByCreatedAtAlone) {
db.execSQL("CREATE INDEX IF NOT EXISTS query_by_tags_hash ON event_tags (tag_hash, created_at DESC)")
}
if (indexStrategy.indexTagsWithKindAndPubkey) {
db.execSQL("CREATE INDEX IF NOT EXISTS query_by_tags_hash_kind_pubkey ON event_tags (tag_hash, kind, pubkey_hash, created_at DESC)")
}
} }
val sqlInsertHeader = val sqlInsertHeader =

View File

@@ -31,6 +31,37 @@ import com.vitorpamplona.quartz.utils.EventFactory
/** /**
* NIP-50 full-text search index over event content. * NIP-50 full-text search index over event content.
* *
* The index is a **contentless** FTS5 table (`content=''`,
* `contentless_delete=1`) whose `rowid` is the event's
* `event_headers.row_id`. Two consequences:
*
* - **Deletes.** The `fts_foreign_key` trigger deletes by `rowid` (an FTS5
* primary-key seek, O(log n)). The old `fts5(event_header_row_id,
* content)` schema deleted by a *regular* column, which FTS5 cannot seek —
* it scans the whole index per delete (O(n)), so deletion throughput
* degraded with corpus size. Every event removal fires this trigger
* (replaceable rotation, kind-5, expiration, right-to-vanish), so the
* seek matters. Measured `Fts5CapabilityProbe`/`FtsSearchScalingBenchmark`:
* ~78× at 8k rows and widening with the table.
* - **Size.** A contentless table stores only the inverted index, not a
* second copy of the tokenized text. The indexed text is *derived*
* ([SearchableEvent.indexableContent], not any raw column), so FTS5
* external-content — which reads the source column from the base table —
* cannot express it; contentless is the correct primitive.
*
* Search results are ordered by **relevance**, per NIP-50 ("descending order
* by quality of search result ... not by the usual `.created_at`", limit
* applied after the score) — via FTS5 bm25 (`ORDER BY event_fts.rank`), with
* `created_at DESC` only as a tie-break. This holds for *every* search filter:
* the tag-free shape ([QueryBuilder.makeSimpleSearch]), `search + tag` (whose
* row-id subquery carries the rank through via `projectRank`), and a
* multi-filter all-search REQ (unioned, deduped by event keeping the best
* score). Only the negentropy snapshot — and a multi-filter REQ mixing search
* and non-search branches — keep `created_at` (a sync set / a branch with no
* defined relevance). bm25 must score every match, so search latency still grows with the
* match set regardless of ordering; corpus-independent search needs an
* external engine, not this index.
*
* When [enabled] is `false` the module becomes an inert no-op: no * When [enabled] is `false` the module becomes an inert no-op: no
* `event_fts` virtual table and no `fts_foreign_key` delete trigger are * `event_fts` virtual table and no `fts_foreign_key` delete trigger are
* created, inserts skip the per-event tokenization cost, and the reindex * created, inserts skip the per-event tokenization cost, and the reindex
@@ -52,29 +83,53 @@ class FullTextSearchModule(
) : IModule { ) : IModule {
val tableName = "event_fts" val tableName = "event_fts"
val triggerName = "fts_foreign_key" val triggerName = "fts_foreign_key"
val eventHeaderRowIdName = "event_header_row_id"
/**
* The FTS column that links back to `event_headers`. It is the implicit
* `rowid` of the (contentless) FTS table, which we set equal to
* `event_headers.row_id` on every insert — so the join is
* `event_headers.row_id = event_fts.rowid`, with no stored column.
*/
val rowIdColumn = "rowid"
val contentName = "content" val contentName = "content"
val stateTableName = "fts_catchup_state" val stateTableName = "fts_catchup_state"
/**
* Whether the on-disk `event_fts` is an FTS5 table (vs the fts4/fts3
* fallback). Only FTS5 supports the contentless schema and the
* `merge`/`optimize` maintenance commands; the fallback stores content
* and skips maintenance. Read only on the (single-threaded) writer.
* Cached lazily from `sqlite_master` so a reopened DB — where [create]
* never runs — still resolves it.
*/
private var isFts5: Boolean? = null
override fun create(db: SQLiteConnection) { override fun create(db: SQLiteConnection) {
if (!enabled) return if (!enabled) return
val ftsVersion = versionFinder(db) val ftsVersion = versionFinder(db)
db.execSQL( isFts5 = ftsVersion >= 5
""" // FTS5: contentless index (no stored content copy) with delete
CREATE VIRTUAL TABLE $tableName // support. fts4/fts3 (bundled driver never selects them) fall back to
USING fts$ftsVersion($eventHeaderRowIdName, $contentName) // a plain content-storing table — rowid-explicit insert and
""", // delete-by-rowid work there too, only the size win is FTS5-only.
) val columns =
if (ftsVersion >= 5) {
"$contentName, content='', contentless_delete=1"
} else {
contentName
}
db.execSQL("CREATE VIRTUAL TABLE $tableName USING fts$ftsVersion($columns)")
// Foreign key cleanup for full text search // Foreign key cleanup for full text search. Deletes by the FTS rowid
// (= event_headers.row_id); a header with no FTS row (non-searchable
// kind) deletes nothing, which is a harmless no-op.
db.execSQL( db.execSQL(
""" """
CREATE TRIGGER $triggerName CREATE TRIGGER $triggerName
AFTER DELETE ON event_headers AFTER DELETE ON event_headers
FOR EACH ROW FOR EACH ROW
BEGIN BEGIN
DELETE FROM $tableName DELETE FROM $tableName WHERE $tableName.rowid = old.row_id;
WHERE old.row_id = $tableName.$eventHeaderRowIdName;
END; END;
""", """,
) )
@@ -82,6 +137,16 @@ class FullTextSearchModule(
createStateTable(db) createStateTable(db)
} }
private fun resolveIsFts5(db: SQLiteConnection): Boolean {
isFts5?.let { return it }
val sql =
db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").use { stmt ->
stmt.bindText(1, tableName)
if (stmt.step()) stmt.getText(0) else ""
}
return sql.contains("fts5", ignoreCase = true).also { isFts5 = it }
}
/** /**
* Watermark for the deferred path: everything with * Watermark for the deferred path: everything with
* `row_id <= last_row_id` is guaranteed indexed. Idempotent — also * `row_id <= last_row_id` is guaranteed indexed. Idempotent — also
@@ -124,13 +189,13 @@ class FullTextSearchModule(
val insertFTS = val insertFTS =
""" """
INSERT OR ROLLBACK INTO $tableName ($eventHeaderRowIdName, $contentName) INSERT OR ROLLBACK INTO $tableName (rowid, $contentName)
VALUES (?, ?) VALUES (?, ?)
""".trimIndent() """.trimIndent()
val deleteFTSByRowId = val deleteFTSByRowId =
""" """
DELETE FROM $tableName WHERE $eventHeaderRowIdName = ? DELETE FROM $tableName WHERE rowid = ?
""".trimIndent() """.trimIndent()
fun insert( fun insert(
@@ -200,7 +265,19 @@ class FullTextSearchModule(
dropTrigger(db) dropTrigger(db)
drop(db) drop(db)
create(db) create(db)
populateAll(db)
// The rebuild just wrote every row as its own tiny segment; compact
// them into one so the first search after a reindex isn't a scan over
// hundreds of segments.
optimize(db)
}
/**
* Scan every stored searchable event and insert its derived content into
* the (already created, empty) FTS index, keyed by `event_headers.row_id`.
* The caller owns the transaction and the create/drop lifecycle.
*/
private fun populateAll(db: SQLiteConnection) {
val kinds = searchableKindsPresent(db) val kinds = searchableKindsPresent(db)
if (kinds.isEmpty()) return if (kinds.isEmpty()) return
@@ -230,6 +307,58 @@ class FullTextSearchModule(
} }
} }
/**
* v4 → v5 migration: the pre-v5 index was `fts5(event_header_row_id,
* content)` with an auto-assigned rowid unrelated to `event_headers`, and
* it stored a second copy of the content. v5 is the contentless,
* rowid = row_id schema. The old rowids can't be remapped in place, so the
* table is dropped and repopulated. Runs inside the migration transaction.
*
* - **synchronous** stores rebuild now (client corpora are small).
* - **deferred** stores reset the catch-up watermark to 0 so the relay's
* background worker repopulates without a long migration transaction.
*/
fun migrateV4ToContentless(db: SQLiteConnection) {
if (!enabled) return
dropTrigger(db)
drop(db)
create(db)
if (deferIndexing) {
db.prepare("UPDATE $stateTableName SET last_row_id = 0 WHERE id = 1").use { it.step() }
} else {
populateAll(db)
optimize(db)
}
}
/**
* Full FTS5 segment compaction — merges the b-tree segments left by
* incremental inserts into one, so a `MATCH` touches a single segment
* instead of dozens. Expensive (rewrites the whole index); call it once
* after a rebuild, not per batch. No-op on the fts4/fts3 fallback.
*/
fun optimize(db: SQLiteConnection) {
if (!enabled || !resolveIsFts5(db)) return
db.prepare("INSERT INTO $tableName($tableName) VALUES ('optimize')").use { it.step() }
}
/**
* Bounded incremental segment merge — does at most [pages] pages of merge
* work, so it stays cheap enough to run on a periodic maintenance tick
* while the index keeps growing from deferred catch-up. No-op on the
* fts4/fts3 fallback.
*/
fun mergeSegments(
db: SQLiteConnection,
pages: Int = 16,
) {
if (!enabled || !resolveIsFts5(db)) return
db.prepare("INSERT INTO $tableName($tableName, rank) VALUES ('merge', ?)").use { stmt ->
stmt.bindLong(1, pages.toLong())
stmt.step()
}
}
/** /**
* Process one batch of a resumable rebuild: re-derive the FTS rows * Process one batch of a resumable rebuild: re-derive the FTS rows
* for up to [batchSize] events whose `row_id > ` [afterRowId] and * for up to [batchSize] events whose `row_id > ` [afterRowId] and
@@ -329,13 +458,11 @@ class FullTextSearchModule(
// Unlike [reindexBatch] there is NO per-row delete here: rows past // Unlike [reindexBatch] there is NO per-row delete here: rows past
// the watermark were never indexed (deferred mode skips insert()), // the watermark were never indexed (deferred mode skips insert()),
// and the watermark advances atomically with the FTS rows it // and the watermark advances atomically with the FTS rows it
// covers, so a crash replay is impossible. The delete would also // covers, so a crash replay is impossible. (Delete-by-rowid is now
// be ruinous — `event_header_row_id` is a plain FTS5 column, so // O(log n) on the contentless index, so this is purely about not
// deleting by it scans the whole FTS table per row, which turned // doing redundant work.) Consequence: switching a database back and
// the first catch-up implementation O(n²). Consequence: switching // forth between deferred and synchronous strategies requires a
// a database back and forth between deferred and synchronous // [reindexAll] in between (same rule as a searchable-kinds change).
// strategies requires a [reindexAll] in between (same rule as a
// searchable-kinds change).
val limit = batchSize.coerceAtLeast(1) val limit = batchSize.coerceAtLeast(1)
val kinds = searchableKindsPresent(db) val kinds = searchableKindsPresent(db)
var last = watermark var last = watermark

View File

@@ -74,9 +74,21 @@ interface IndexingStrategy {
* Activate this if you see too many Tag-centric Filters without * Activate this if you see too many Tag-centric Filters without
* kind AND pubkey at the same time. * kind AND pubkey at the same time.
* *
* This is a rarely used index (reports by your follows or * This shape (reports by your follows, NIP-04 DM rooms, follows-scoped
* NIP-04 DMs for instance) that becomes quite large without * community feeds) is not rare on the client side: the 2026-07 filter
* major gains. * assembler survey counted 65 call sites building
* `kinds + authors + tags`. Without this index the plan seeks
* `(tag_hash, kind)` and reads every row for that tag/kind before
* filtering the author.
*
* Measured by `TagAuthorIndexBenchmark` (jvmTest prodbench): the
* DM-room query drops 9.4 ms → 0.6 ms (~15×) at 200k events and
* 14.2 ms → 0.66 ms (~21×) at 1M — the gap grows with corpus size —
* while batch-insert cost stays inside run noise (49.0 vs 47.4
* µs/event at 1M). geode enables it; the client default stays off
* because a client store's per-tag row counts are bounded by one
* user's data. Flipping it on an existing DB is safe: the index is
* built on next open by `EventIndexesModule.ensureOptionalIndexes`.
* *
* Keep in mind that activating too many indexes increases the size of the * Keep in mind that activating too many indexes increases the size of the
* DB so much that the indexes themselves won't fit in memory, requiring * DB so much that the indexes themselves won't fit in memory, requiring

View File

@@ -24,52 +24,94 @@ import androidx.sqlite.SQLiteConnection
import androidx.sqlite.SQLiteStatement import androidx.sqlite.SQLiteStatement
/** /**
* k-way merge executor for the **home-feed** query shape: * k-way merge executor for the two "wide fan-out, newest-N" query shapes
* `authors=[…] (+ kinds=[…]) [+ since/until] limit=N` ordered newest-first. * whose single-SQL plan reads O(matching history) rather than O(limit):
* *
* SQLite serves this by seeking every `(kind, pubkey)` combo and feeding * 1. **home-feed** — `authors=[…] (+ kinds=[…]) [+ since/until] limit=N`.
* *all* matching rows through a LIMIT-bounded sorter — so it reads O(the * SQLite seeks every `(kind, pubkey)` combo and feeds *all* matching rows
* followed set's whole matching history). For prolific follows on a cold * through a LIMIT-bounded sorter — O(the followed set's whole matching
* on-disk DB that's the `follow-feed` regression (relayBench: 97 ms vs * history). For prolific follows on a cold on-disk DB that was the
* strfry 17 ms). See `quartz/plans/2026-07-04-follow-feed-read-tradeoff.md`. * `follow-feed` regression (relayBench: 97 ms vs strfry 17 ms). See
* `quartz/plans/2026-07-04-follow-feed-read-tradeoff.md`.
* 2. **tag watcher** — `#<x>=[hundreds of values] (+ kinds=[…])
* [+ since/until] limit=N`, the reactions/replies archetype
* (`kinds=[7] AND #e=[note ids]`). The per-value streams come sorted off
* `(tag_hash[, kind], created_at)`, but their union does not, so SQLite
* collects every matching row and TEMP-B-TREE sorts to the limit — the
* tag-index analogue of the follow-feed shape. Measured by
* `TagAuthorIndexBenchmark` (jvmTest prodbench): `#e IN 300, limit 500`
* cost 12.8 ms cold at 200k events and 14.2 ms at 1M, growing with
* matching history.
* *
* Each `(kind, pubkey)` is already a newest-first stream off the * Each stream is already a newest-first cursor off an existing index:
* `query_by_kind_pubkey_created (kind, pubkey, created_at DESC)` index * - authors: `query_by_kind_pubkey_created (kind, pubkey, created_at DESC)`
* (or `query_by_pubkey_created` for authors-only). This opens one lazy * (or `query_by_pubkey_created` for authors-only);
* cursor per stream and merges their heads, stopping at the limit — so it * - tags: `query_by_tags_hash_kind (tag_hash, kind, created_at DESC)`
* reads only **O(limit + streams)** rows regardless of how much history the * (or `query_by_tags_hash` for the no-kind case, gated by
* authors have, and it reuses the existing indexes (no write/size cost). * [IndexingStrategy.indexTagsByCreatedAtAlone]).
*
* The merge opens one lazy cursor per stream, merges their heads newest-first,
* and stops at the limit — reading **O(limit + streams)** rows regardless of
* how much history the authors/tags have, and reusing the existing indexes
* (no write/size cost). With the pooled statement cache
* ([StatementCachingConnection]) the per-stream cursors are prepared once and
* reused across repeated polls of the same REQ.
* *
* Merge order is `created_at DESC`, tie-broken by `id ASC`. NIP-01 leaves * Merge order is `created_at DESC`, tie-broken by `id ASC`. NIP-01 leaves
* same-`created_at` ties unspecified, so the returned set is a valid * same-`created_at` ties unspecified, so the returned set is a valid newest-N
* newest-N either way. The `id ASC` tie-break is exact — byte-for-byte the * either way. The `id ASC` tie-break is exact — byte-for-byte the same events
* same events the single-SQL path returns — only when the store indexes id * the single-SQL path returns — only when the store indexes id
* ([IndexingStrategy.useAndIndexIdOnOrderBy]): then each per-stream cursor * ([IndexingStrategy.useAndIndexIdOnOrderBy]) **and** the stream cursor can
* streams in `(created_at DESC, id ASC)` straight off the index, so a * order by id off the index. The author streams can (id is on
* stream's same-second head really is its id-minimum. Without that index * `event_headers`); the tag streams cannot (the cursor orders off
* the per-stream cursor yields same-second rows in rowid order, so the * `event_tags`, which has no id column), so a tag stream yields same-second
* result is still a valid newest-N but may differ from the single-SQL path * rows in rowid order — still a valid newest-N, but same-second ties may
* exactly at a same-second boundary. * differ from an id-ordered reference.
*
* **Cross-stream duplicates.** An author appears in exactly one author stream
* (one pubkey per event), so the home-feed merge never double-counts. A single
* event can carry several of the queried tag values (or a repeated tag), so it
* can surface in several tag streams — the single-SQL path dedups with
* `SELECT DISTINCT`. The tag merge therefore dedups by event id through a
* `seen` set; the author merge skips that set entirely.
*/ */
internal object MergeQueryExecutor { internal object MergeQueryExecutor {
/** Author-stream projection: a single `event_headers` scan, unqualified. */
const val COLS = "id, pubkey, created_at, kind, tags, content, sig" const val COLS = "id, pubkey, created_at, kind, tags, content, sig"
/** Tag-stream projection: `event_tags` joins `event_headers`, so qualify. */
private const val EH_COLS =
"event_headers.id, event_headers.pubkey, event_headers.created_at, event_headers.kind, event_headers.tags, event_headers.content, event_headers.sig"
/** /**
* Above this many streams, fall back to the single-SQL plan: the * Above this many streams, fall back to the single-SQL plan: the
* per-stream cursor setup stops paying off, and huge author lists are * per-stream cursor setup stops paying off, and huge fan-outs are
* collecting a lot no matter what. `kinds.size × authors.size`. * collecting a lot no matter what. `kinds.size × (authors|values).size`.
*/ */
const val MAX_STREAMS = 2048 const val MAX_STREAMS = 2048
/** /**
* Stream count if [filter] is merge-eligible, else `-1`. Eligible = a * Stream count if [filter] is merge-eligible under *either* shape, else
* simple (no tag/search/id/d-tag) query with authors + a limit, whose * `-1`. Routing check for [QueryBuilder]; [run] re-derives which shape.
* per-stream index exists. `kinds` optional: with it, one stream per
* `(kind, author)`; without, one per author (needs the pubkey index).
*/ */
fun streamCount( fun streamCount(
filter: QueryBuilder.FilterWithDTags, filter: QueryBuilder.FilterWithDTags,
indexStrategy: IndexingStrategy, indexStrategy: IndexingStrategy,
): Int {
val authorStreams = authorStreamCount(filter, indexStrategy)
if (authorStreams > 0) return authorStreams
return tagStreamCount(filter, indexStrategy)
}
/**
* Author-shape stream count, or `-1`. Eligible = a simple (no tag/search/
* id/d-tag) query with authors + a limit, whose per-stream index exists.
* `kinds` optional: with it, one stream per `(kind, author)`; without, one
* per author (needs the pubkey index).
*/
fun authorStreamCount(
filter: QueryBuilder.FilterWithDTags,
indexStrategy: IndexingStrategy,
): Int { ): Int {
if (!filter.isSimpleQuery()) return -1 if (!filter.isSimpleQuery()) return -1
if (filter.ids != null) return -1 if (filter.ids != null) return -1
@@ -83,27 +125,69 @@ internal object MergeQueryExecutor {
// distinct set. // distinct set.
val distinctAuthors = authors.distinct().size val distinctAuthors = authors.distinct().size
val kinds = filter.kinds val kinds = filter.kinds
// Long product: a pathological authors×kinds could overflow Int and
// wrap back into the eligible band, routing a huge fan-out here.
val streams = val streams =
if (kinds != null && kinds.isNotEmpty()) { if (kinds != null && kinds.isNotEmpty()) {
distinctAuthors * kinds.distinct().size distinctAuthors.toLong() * kinds.distinct().size
} else { } else {
// authors-only needs the (pubkey, created_at) index to stream. // authors-only needs the (pubkey, created_at) index to stream.
if (!indexStrategy.indexEventsByPubkeyAlone) return -1 if (!indexStrategy.indexEventsByPubkeyAlone) return -1
distinctAuthors distinctAuthors.toLong()
} }
// A single stream is already the optimal single index seek — let the // A single stream is already the optimal single index seek — let the
// normal path handle it; only merge when there's something to merge. // normal path handle it; only merge when there's something to merge.
return if (streams in 2..MAX_STREAMS) streams else -1 return if (streams in 2..MAX_STREAMS.toLong()) streams.toInt() else -1
} }
/** Prepares one bound, newest-first cursor per stream. */ /**
private fun prepareStreams( * Tag-shape stream count, or `-1`. Eligible = a single non-`d` tag key
* with `IN` (any-of) semantics and ≥2 distinct values, plus a limit, no
* ids/authors/d-tag/search, and no `AND`-tags (`tagsAll`) — the large-IN
* watcher shape. `kinds` optional: with it, one stream per
* `(value, kind)` off `query_by_tags_hash_kind`; without, one per value
* off `query_by_tags_hash` (needs [IndexingStrategy.indexTagsByCreatedAtAlone]).
*
* Authors are excluded on purpose: `tag ∩ author ∩ kind` is a covered
* single seek under [IndexingStrategy.indexTagsWithKindAndPubkey], not a
* fan-out, and mixing an author predicate into per-tag streams would not
* reduce the read.
*/
fun tagStreamCount(
filter: QueryBuilder.FilterWithDTags,
indexStrategy: IndexingStrategy,
): Int {
if (filter.ids != null) return -1
if (filter.authors != null) return -1
if (filter.dTags != null) return -1
if (filter.search != null && filter.search.isNotEmpty()) return -1
if (filter.limit == null || filter.limit <= 0) return -1
// AND-tags can't be expressed as a union of per-value streams.
if (filter.nonDTagsAll != null && filter.nonDTagsAll.isNotEmpty()) return -1
val inTags = filter.nonDTagsIn ?: return -1
// A second tag key would AND across keys — not a single union.
if (inTags.size != 1) return -1
val values = inTags.values.first().distinct()
if (values.size < 2) return -1
val kinds = filter.kinds?.distinct()?.takeIf { it.isNotEmpty() }
val streams =
if (kinds != null) {
values.size.toLong() * kinds.size
} else {
if (!indexStrategy.indexTagsByCreatedAtAlone) return -1
values.size.toLong()
}
return if (streams in 2..MAX_STREAMS.toLong()) streams.toInt() else -1
}
/** Prepares one bound, newest-first cursor per author stream. */
private fun prepareAuthorStreams(
db: SQLiteConnection, db: SQLiteConnection,
filter: QueryBuilder.FilterWithDTags, filter: QueryBuilder.FilterWithDTags,
indexStrategy: IndexingStrategy, indexStrategy: IndexingStrategy,
): List<SQLiteStatement> { ): List<SQLiteStatement> {
// Dedup so a repeated pubkey/kind can't open two identical cursors and // Dedup so a repeated pubkey/kind can't open two identical cursors and
// double-emit (see streamCount). // double-emit (see authorStreamCount).
val authors = filter.authors!!.distinct() val authors = filter.authors!!.distinct()
val kinds = filter.kinds?.distinct()?.takeIf { it.isNotEmpty() } val kinds = filter.kinds?.distinct()?.takeIf { it.isNotEmpty() }
val since = filter.since val since = filter.since
@@ -117,8 +201,7 @@ internal object MergeQueryExecutor {
val orderBy = val orderBy =
if (indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" if (indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC"
val stmts = ArrayList<SQLiteStatement>((kinds?.size ?: 1) * authors.size) return if (kinds != null) {
if (kinds != null) {
val sql = val sql =
buildString { buildString {
append("SELECT ").append(COLS) append("SELECT ").append(COLS)
@@ -128,16 +211,14 @@ internal object MergeQueryExecutor {
if (since != null) append(" AND created_at >= ?") if (since != null) append(" AND created_at >= ?")
append(" ORDER BY ").append(orderBy) append(" ORDER BY ").append(orderBy)
} }
for (kind in kinds) { buildStreams(kinds.size * authors.size) { i ->
for (author in authors) { val stmt = db.prepare(sql)
val stmt = db.prepare(sql) var p = 1
var p = 1 stmt.bindLong(p++, kinds[i / authors.size].toLong())
stmt.bindLong(p++, kind.toLong()) stmt.bindText(p++, authors[i % authors.size])
stmt.bindText(p++, author) if (until != null) stmt.bindLong(p++, until)
if (until != null) stmt.bindLong(p++, until) if (since != null) stmt.bindLong(p++, since)
if (since != null) stmt.bindLong(p++, since) stmt
stmts.add(stmt)
}
} }
} else { } else {
val sql = val sql =
@@ -149,30 +230,135 @@ internal object MergeQueryExecutor {
if (since != null) append(" AND created_at >= ?") if (since != null) append(" AND created_at >= ?")
append(" ORDER BY ").append(orderBy) append(" ORDER BY ").append(orderBy)
} }
for (author in authors) { buildStreams(authors.size) { i ->
val stmt = db.prepare(sql) val stmt = db.prepare(sql)
var p = 1 var p = 1
stmt.bindText(p++, author) stmt.bindText(p++, authors[i])
if (until != null) stmt.bindLong(p++, until) if (until != null) stmt.bindLong(p++, until)
if (since != null) stmt.bindLong(p++, since) if (since != null) stmt.bindLong(p++, since)
stmts.add(stmt) stmt
}
}
}
/** Prepares one bound, newest-first cursor per tag-value stream. */
private fun prepareTagStreams(
db: SQLiteConnection,
filter: QueryBuilder.FilterWithDTags,
hasher: TagNameValueHasher,
): List<SQLiteStatement> {
val entry = filter.nonDTagsIn!!.entries.first()
val tagName = entry.key
val values = entry.value.distinct()
val kinds = filter.kinds?.distinct()?.takeIf { it.isNotEmpty() }
val since = filter.since
val until = filter.until
// The tag cursors stream off event_tags (which has no id column), so
// the tie order can only be created_at DESC — see the class doc.
return if (kinds != null) {
val sql =
buildString {
append("SELECT ").append(EH_COLS)
append(" FROM event_tags INDEXED BY query_by_tags_hash_kind")
append(" JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id")
append(" WHERE event_tags.tag_hash = ? AND event_tags.kind = ?")
if (until != null) append(" AND event_tags.created_at <= ?")
if (since != null) append(" AND event_tags.created_at >= ?")
append(" ORDER BY event_tags.created_at DESC")
}
buildStreams(values.size * kinds.size) { i ->
val stmt = db.prepare(sql)
var p = 1
stmt.bindLong(p++, hasher.hash(tagName, values[i / kinds.size]))
stmt.bindLong(p++, kinds[i % kinds.size].toLong())
if (until != null) stmt.bindLong(p++, until)
if (since != null) stmt.bindLong(p++, since)
stmt
}
} else {
val sql =
buildString {
append("SELECT ").append(EH_COLS)
append(" FROM event_tags INDEXED BY query_by_tags_hash")
append(" JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id")
append(" WHERE event_tags.tag_hash = ?")
if (until != null) append(" AND event_tags.created_at <= ?")
if (since != null) append(" AND event_tags.created_at >= ?")
append(" ORDER BY event_tags.created_at DESC")
}
buildStreams(values.size) { i ->
val stmt = db.prepare(sql)
var p = 1
stmt.bindLong(p++, hasher.hash(tagName, values[i]))
if (until != null) stmt.bindLong(p++, until)
if (since != null) stmt.bindLong(p++, since)
stmt
} }
} }
return stmts
} }
/** /**
* Runs the merge, calling [onRow] with each winning cursor positioned on * Runs the merge for whichever shape [filter] matches, calling [onRow]
* the row to emit, newest-first, up to `limit`. [onRow] must read the * with each winning cursor positioned on the row to emit, newest-first,
* current row (it stays valid until the next step). * up to `limit`. [onRow] must read the current row (it stays valid until
* the next step). [hasher] is only consulted for the tag shape.
*/ */
fun run( fun run(
db: SQLiteConnection, db: SQLiteConnection,
filter: QueryBuilder.FilterWithDTags, filter: QueryBuilder.FilterWithDTags,
indexStrategy: IndexingStrategy, indexStrategy: IndexingStrategy,
hasher: (SQLiteConnection) -> TagNameValueHasher,
onRow: (SQLiteStatement) -> Unit,
) {
if (authorStreamCount(filter, indexStrategy) > 0) {
// One pubkey per event ⇒ author streams never overlap: no dedup.
mergeStreams(prepareAuthorStreams(db, filter, indexStrategy), filter.limit!!, dedup = false, onRow)
} else {
// A single event can match several tag values ⇒ dedup by id.
mergeStreams(prepareTagStreams(db, filter, hasher(db)), filter.limit!!, dedup = true, onRow)
}
}
/**
* Prepares [count] cursors via [prepareOne], closing any already-prepared
* statements if a later prepare throws — otherwise a mid-loop failure would
* strand checked-out, un-reset handles in the pooled connection (dead
* slots holding read locks). On success the caller ([mergeStreams]) owns
* closing them.
*/
private inline fun buildStreams(
count: Int,
prepareOne: (Int) -> SQLiteStatement,
): List<SQLiteStatement> {
val stmts = ArrayList<SQLiteStatement>(count)
try {
for (i in 0 until count) stmts.add(prepareOne(i))
} catch (e: Throwable) {
for (s in stmts) {
try {
s.close()
} catch (_: Throwable) {
}
}
throw e
}
return stmts
}
/**
* Heap-free k-way merge over the prepared [stmts]: repeatedly emits the
* newest live head (`created_at DESC`, tie `id ASC`) until [limit] rows
* are emitted or every stream is drained. When [dedup] is set an event id
* already emitted is skipped (its cursor still advances), so a row that
* surfaces in several streams is emitted once.
*/
private fun mergeStreams(
stmts: List<SQLiteStatement>,
limit: Int,
dedup: Boolean,
onRow: (SQLiteStatement) -> Unit, onRow: (SQLiteStatement) -> Unit,
) { ) {
val stmts = prepareStreams(db, filter, indexStrategy)
try { try {
val k = stmts.size val k = stmts.size
val headCreatedAt = LongArray(k) val headCreatedAt = LongArray(k)
@@ -188,8 +374,8 @@ internal object MergeQueryExecutor {
} }
} }
val seen = if (dedup) HashSet<String>() else null
var emitted = 0 var emitted = 0
val limit = filter.limit!!
while (emitted < limit) { while (emitted < limit) {
// Pick the newest live head: created_at DESC, then id ASC. // Pick the newest live head: created_at DESC, then id ASC.
var best = -1 var best = -1
@@ -204,8 +390,11 @@ internal object MergeQueryExecutor {
} }
if (best == -1) break if (best == -1) break
onRow(stmts[best]) // cursor is still on the head row // Emit unless this id was already emitted by another stream.
emitted++ if (seen == null || seen.add(headId[best]!!)) {
onRow(stmts[best]) // cursor is still on the head row
emitted++
}
// Advance the winner to its next row. // Advance the winner to its next row.
if (stmts[best].step()) { if (stmts[best].step()) {

View File

@@ -49,7 +49,7 @@ class QueryBuilder(
val merge = filter.toFilterWithDTags() val merge = filter.toFilterWithDTags()
if (MergeQueryExecutor.streamCount(merge, indexStrategy) > 0) { if (MergeQueryExecutor.streamCount(merge, indexStrategy) > 0) {
val out = ArrayList<T>(merge.limit!!) val out = ArrayList<T>(merge.limit!!)
MergeQueryExecutor.run(db, merge, indexStrategy) { out.add(it.toEvent()) } MergeQueryExecutor.run(db, merge, indexStrategy, hasher) { out.add(it.toEvent()) }
return out return out
} }
return db.runQuery(toSql(filter, hasher(db))) return db.runQuery(toSql(filter, hasher(db)))
@@ -62,7 +62,7 @@ class QueryBuilder(
) { ) {
val merge = filter.toFilterWithDTags() val merge = filter.toFilterWithDTags()
if (MergeQueryExecutor.streamCount(merge, indexStrategy) > 0) { if (MergeQueryExecutor.streamCount(merge, indexStrategy) > 0) {
MergeQueryExecutor.run(db, merge, indexStrategy) { onEach(it.toEvent()) } MergeQueryExecutor.run(db, merge, indexStrategy, hasher) { onEach(it.toEvent()) }
return return
} }
db.runQuery(toSql(filter, hasher(db)), onEach) db.runQuery(toSql(filter, hasher(db)), onEach)
@@ -102,7 +102,7 @@ class QueryBuilder(
val merge = filter.toFilterWithDTags() val merge = filter.toFilterWithDTags()
if (MergeQueryExecutor.streamCount(merge, indexStrategy) > 0) { if (MergeQueryExecutor.streamCount(merge, indexStrategy) > 0) {
val out = ArrayList<RawEvent>(merge.limit!!) val out = ArrayList<RawEvent>(merge.limit!!)
MergeQueryExecutor.run(db, merge, indexStrategy) { out.add(it.toRawEvent()) } MergeQueryExecutor.run(db, merge, indexStrategy, hasher) { out.add(it.toRawEvent()) }
return out return out
} }
return db.runRawQuery(toSql(filter, hasher(db))) return db.runRawQuery(toSql(filter, hasher(db)))
@@ -115,7 +115,7 @@ class QueryBuilder(
) { ) {
val merge = filter.toFilterWithDTags() val merge = filter.toFilterWithDTags()
if (MergeQueryExecutor.streamCount(merge, indexStrategy) > 0) { if (MergeQueryExecutor.streamCount(merge, indexStrategy) > 0) {
MergeQueryExecutor.run(db, merge, indexStrategy) { onEach(it.toRawEvent()) } MergeQueryExecutor.run(db, merge, indexStrategy, hasher) { onEach(it.toRawEvent()) }
return return
} }
db.runRawQuery(toSql(filter, hasher(db)), onEach) db.runRawQuery(toSql(filter, hasher(db)), onEach)
@@ -202,13 +202,17 @@ class QueryBuilder(
) )
} }
val rowIdSubqueries = prepareRowIDSubQueries(filter, hasher) // A search term that survived the simple-search branch above always
// combines with tags here (search + #t etc.). NIP-50 still orders by
// relevance, so carry the FTS rank through the subquery and out.
val rankSearch = newFilter.search != null && newFilter.search.isNotEmpty()
val rowIdSubqueries = prepareRowIDSubQueries(filter, hasher, projectRank = rankSearch)
return if (rowIdSubqueries == null) { return if (rowIdSubqueries == null) {
QuerySpec(makeEverythingQuery()) QuerySpec(makeEverythingQuery())
} else { } else {
QuerySpec( QuerySpec(
makeQueryIn(rowIdSubqueries.sql), makeQueryIn(rowIdSubqueries.sql, orderByRank = rankSearch),
rowIdSubqueries.args, rowIdSubqueries.args,
) )
} }
@@ -220,7 +224,13 @@ class QueryBuilder(
): QuerySpec { ): QuerySpec {
if (filters.size == 1) return toSql(filters.first(), hasher) if (filters.size == 1) return toSql(filters.first(), hasher)
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher) // A multi-filter search REQ (e.g. the client's search-across-kinds,
// all filters sharing one term) must still be NIP-50 relevance-ordered.
// Only when EVERY branch is a search branch (and FTS is on, so each has
// a rank column) — a non-search branch has no defined relevance, so a
// mixed REQ falls back to created_at.
val rankSearch = fts.enabled && filters.all { !it.search.isNullOrEmpty() }
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher, projectRank = rankSearch)
return if (rowIdSubqueries == null) { return if (rowIdSubqueries == null) {
QuerySpec( QuerySpec(
@@ -229,7 +239,7 @@ class QueryBuilder(
) )
} else { } else {
QuerySpec( QuerySpec(
makeQueryIn(rowIdSubqueries.sql), makeQueryIn(rowIdSubqueries.sql, orderByRank = rankSearch),
rowIdSubqueries.args, rowIdSubqueries.args,
) )
} }
@@ -427,7 +437,7 @@ class QueryBuilder(
val sql = val sql =
buildString { buildString {
append("SELECT event_headers.id, event_headers.created_at FROM event_headers") append("SELECT event_headers.id, event_headers.created_at FROM event_headers")
append("\nINNER JOIN ${fts.tableName} ON event_headers.row_id = ${fts.tableName}.${fts.eventHeaderRowIdName}") append("\nINNER JOIN ${fts.tableName} ON event_headers.row_id = ${fts.tableName}.rowid")
if (clause.conditions.isNotEmpty()) { if (clause.conditions.isNotEmpty()) {
append("\nWHERE ${clause.conditions}") append("\nWHERE ${clause.conditions}")
} }
@@ -470,14 +480,20 @@ class QueryBuilder(
private fun makeEverythingQuery() = "SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY created_at DESC${if (indexStrategy.useAndIndexIdOnOrderBy) ", id ASC" else ""}" private fun makeEverythingQuery() = "SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY created_at DESC${if (indexStrategy.useAndIndexIdOnOrderBy) ", id ASC" else ""}"
private fun makeQueryIn(rowIdQuery: String) = // [orderByRank] presents the joined result in NIP-50 relevance order: the
""" // subquery (built with `projectRank`) exposes the FTS bm25 score as a
// `rank` column, and `created_at DESC` is only a tie-break. Off, it keeps
// the default newest-first ordering for every non-search shape.
private fun makeQueryIn(
rowIdQuery: String,
orderByRank: Boolean = false,
) = """
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers
INNER JOIN ( INNER JOIN (
$rowIdQuery $rowIdQuery
) AS filtered ) AS filtered
ON event_headers.row_id = filtered.row_id ON event_headers.row_id = filtered.row_id
ORDER BY created_at DESC${if (indexStrategy.useAndIndexIdOnOrderBy) ", id ASC" else ""} ORDER BY ${if (orderByRank) "filtered.rank, " else ""}created_at DESC${if (indexStrategy.useAndIndexIdOnOrderBy) ", id ASC" else ""}
""".trimIndent() """.trimIndent()
private fun <T : Event> SQLiteConnection.runQuery(query: QuerySpec): List<T> = private fun <T : Event> SQLiteConnection.runQuery(query: QuerySpec): List<T> =
@@ -713,16 +729,34 @@ class QueryBuilder(
fun unionSubqueriesIfNeeded( fun unionSubqueriesIfNeeded(
filters: List<Filter>, filters: List<Filter>,
hasher: TagNameValueHasher, hasher: TagNameValueHasher,
// See [prepareRowIDSubQueries]. When set, every branch is a search
// branch that also projects a `rank` column; the union keeps one row
// per event with its BEST (min) bm25 score so the caller can present
// the whole multi-filter search REQ NIP-50-ranked. Callers must only
// pass true when all filters carry a search term (else a branch has no
// rank column). Off for count/delete, which stay single-column.
projectRank: Boolean = false,
): QuerySpec? { ): QuerySpec? {
val inner = val inner =
filters.mapNotNull { filter -> filters.mapNotNull { filter ->
prepareRowIDSubQueries(filter, hasher) prepareRowIDSubQueries(filter, hasher, projectRank)
} }
if (inner.isEmpty()) return null if (inner.isEmpty()) return null
return if (inner.size == 1) { if (inner.size == 1) return inner.first()
inner.first()
return if (projectRank) {
// UNION ALL keeps every (row_id, rank) so an event matching two
// branches under different terms isn't dropped before MIN; GROUP BY
// then dedups by event keeping the best score.
QuerySpec(
sql =
"SELECT row_id, MIN(rank) as rank FROM (\n " +
inner.joinToString("\n UNION ALL\n ") { "SELECT row_id, rank FROM (${it.sql})" } +
"\n ) GROUP BY row_id",
args = inner.flatMap { it.args },
)
} else { } else {
QuerySpec( QuerySpec(
sql = inner.joinToString("\n UNION\n ") { "SELECT row_id FROM (${it.sql})" }, sql = inner.joinToString("\n UNION\n ") { "SELECT row_id FROM (${it.sql})" },
@@ -748,6 +782,13 @@ class QueryBuilder(
fun prepareRowIDSubQueries( fun prepareRowIDSubQueries(
filter: Filter, filter: Filter,
hasher: TagNameValueHasher, hasher: TagNameValueHasher,
// When set on a search filter, the subquery also projects the FTS
// bm25 score as a `rank` column and orders its own LIMIT by relevance,
// so the caller ([makeQueryIn] with `orderByRank`) can present results
// NIP-50-ranked. Off (the default) for count/delete/union/negentropy,
// which never expose a second column (a two-column subquery breaks
// `row_id IN (…)`) and don't rank.
projectRank: Boolean = false,
): QuerySpec? { ): QuerySpec? {
if (filter.isEmpty()) return null if (filter.isEmpty()) return null
@@ -761,6 +802,10 @@ class QueryBuilder(
val mustJoinSearch = filter.search != null && fts.enabled val mustJoinSearch = filter.search != null && fts.enabled
// Only emit the rank column when there is actually an FTS join to take
// it from; a `projectRank` request on a tag-only filter is ignored.
val emitRank = projectRank && mustJoinSearch
val nonDTagsIn = filter.tags?.filter { it.key != "d" } ?: emptyMap() val nonDTagsIn = filter.tags?.filter { it.key != "d" } ?: emptyMap()
val nonDTagsAll = filter.tagsAll?.filter { it.key != "d" } ?: emptyMap() val nonDTagsAll = filter.tagsAll?.filter { it.key != "d" } ?: emptyMap()
@@ -789,7 +834,11 @@ class QueryBuilder(
buildString { buildString {
// always do tags if there are any // always do tags if there are any
if (reverseLookup) { if (reverseLookup) {
append("SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags") append("SELECT DISTINCT(event_tags.event_header_row_id) as row_id")
// rank is functionally determined by the row_id (one FTS
// row per event), so it doesn't change what DISTINCT folds.
if (emitRank) append(", ${fts.tableName}.rank as rank")
append(" FROM event_tags")
// it's quite rare to have 2 tags in the filter, but possible // it's quite rare to have 2 tags in the filter, but possible
nonDTagsIn.keys.forEachIndexed { index, tagName -> nonDTagsIn.keys.forEachIndexed { index, tagName ->
@@ -815,13 +864,15 @@ class QueryBuilder(
} }
if (mustJoinSearch) { if (mustJoinSearch) {
append(" INNER JOIN ${fts.tableName} ON ${fts.tableName}.${fts.eventHeaderRowIdName} = event_tags.event_header_row_id") append(" INNER JOIN ${fts.tableName} ON ${fts.tableName}.rowid = event_tags.event_header_row_id")
} }
} else if (mustJoinSearch) { } else if (mustJoinSearch) {
append("SELECT ${fts.tableName}.${fts.eventHeaderRowIdName} as row_id FROM ${fts.tableName}") append("SELECT ${fts.tableName}.rowid as row_id")
if (emitRank) append(", ${fts.tableName}.rank as rank")
append(" FROM ${fts.tableName}")
if (hasHeaders) { if (hasHeaders) {
append(" INNER JOIN event_headers ON event_headers.row_id = ${fts.tableName}.${fts.eventHeaderRowIdName}") append(" INNER JOIN event_headers ON event_headers.row_id = ${fts.tableName}.rowid")
} }
} else { } else {
// no tags and no search. // no tags and no search.
@@ -934,15 +985,17 @@ class QueryBuilder(
append(" WHERE ${clause.conditions}") append(" WHERE ${clause.conditions}")
} }
if (filter.limit != null) { if (filter.limit != null) {
if (reverseLookup) { if (emitRank) {
// NIP-50: the LIMIT keeps the most RELEVANT rows, not
// the newest, so the inner cut is by rank too.
append(" ORDER BY rank")
} else if (reverseLookup) {
append(" ORDER BY event_tags.created_at DESC") append(" ORDER BY event_tags.created_at DESC")
append(" LIMIT ")
append(filter.limit)
} else { } else {
append(" ORDER BY event_headers.created_at DESC") append(" ORDER BY event_headers.created_at DESC")
append(" LIMIT ")
append(filter.limit)
} }
append(" LIMIT ")
append(filter.limit)
} }
} }
@@ -1001,11 +1054,17 @@ class QueryBuilder(
val sql = val sql =
buildString { buildString {
append("SELECT event_headers.id, event_headers.pubkey, event_headers.created_at, event_headers.kind, event_headers.tags, event_headers.content, event_headers.sig FROM event_headers") append("SELECT event_headers.id, event_headers.pubkey, event_headers.created_at, event_headers.kind, event_headers.tags, event_headers.content, event_headers.sig FROM event_headers")
append("\nINNER JOIN ${fts.tableName} ON event_headers.row_id = ${fts.tableName}.${fts.eventHeaderRowIdName}") append("\nINNER JOIN ${fts.tableName} ON event_headers.row_id = ${fts.tableName}.rowid")
if (clause.conditions.isNotEmpty()) { if (clause.conditions.isNotEmpty()) {
append("\nWHERE ${clause.conditions}") append("\nWHERE ${clause.conditions}")
} }
append("\nORDER BY event_headers.created_at DESC") // NIP-50: search results are ordered by relevance ("quality of
// search result"), not created_at, and the limit is applied
// after the score. FTS5 exposes bm25 as the `rank` column (more
// negative = more relevant), so ORDER BY rank ascending is
// best-match-first. created_at DESC is only a tie-break so
// equally-relevant matches come newest-first deterministically.
append("\nORDER BY ${fts.tableName}.rank, event_headers.created_at DESC")
if (indexStrategy.useAndIndexIdOnOrderBy) { if (indexStrategy.useAndIndexIdOnOrderBy) {
append(", event_headers.id ASC") append(", event_headers.id ASC")
} }

View File

@@ -60,7 +60,7 @@ class SQLiteEventStore(
val extraPragmas: List<String> = emptyList(), val extraPragmas: List<String> = emptyList(),
) { ) {
companion object { companion object {
const val DATABASE_VERSION = 4 const val DATABASE_VERSION = 5
} }
val seedModule = SeedModule() val seedModule = SeedModule()
@@ -160,6 +160,11 @@ class SQLiteEventStore(
setUserVersion(this, DATABASE_VERSION) setUserVersion(this, DATABASE_VERSION)
} }
} }
// Flag-gated indexes are runtime config, not schema: a
// deployment that flips an IndexingStrategy flag on an
// existing DB gets the index built here (idempotent,
// one-time cost), with no user_version bump involved.
eventIndexModule.ensureOptionalIndexes(db)
}, },
) )
} }
@@ -208,6 +213,12 @@ class SQLiteEventStore(
// watermark seeds at the current MAX(row_id). // watermark seeds at the current MAX(row_id).
fullTextSearchModule.createStateTable(db) fullTextSearchModule.createStateTable(db)
} }
4 -> {
// Upgrade from version 4 to 5: the FTS index became a
// contentless table keyed by event_headers.row_id. The old
// rowids can't be remapped, so drop and repopulate.
fullTextSearchModule.migrateV4ToContentless(db)
}
} }
} }
} }
@@ -269,6 +280,12 @@ class SQLiteEventStore(
pool.useWriter { db -> pool.useWriter { db ->
db.execSQL("PRAGMA analysis_limit = 400;") db.execSQL("PRAGMA analysis_limit = 400;")
db.execSQL("PRAGMA optimize;") db.execSQL("PRAGMA optimize;")
// Fold a bounded FTS segment merge into the same periodic
// maintenance tick: incremental (and deferred catch-up) inserts
// leave the NIP-50 index as many small segments, and a MATCH
// queries every one. Bounded so the tick stays cheap; a no-op when
// there is nothing to merge or FTS is off.
fullTextSearchModule.mergeSegments(db)
} }
/** /**

View File

@@ -34,46 +34,72 @@ import androidx.sqlite.SQLiteStatement
* real reset + clearBindings happens on the next checkout). Statements are * real reset + clearBindings happens on the next checkout). Statements are
* only truly finalized when the connection itself closes. * only truly finalized when the connection itself closes.
* *
* Each SQL string caches a small **pool** of handles rather than a single
* one, so overlapping checkouts of the *same* SQL all reuse cached handles.
* That is exactly the k-way-merge query shape ([MergeQueryExecutor]): it
* opens one identical-SQL cursor per author/tag stream — dozens to hundreds
* live at once — which a single-handle cache could not serve (every stream
* past the first fell back to an uncached prepare). The pool lets a repeated
* follow-feed / reactions-watcher REQ reuse its per-stream cursors instead
* of re-preparing them each poll.
*
* Constraints, by design of the call sites: * Constraints, by design of the call sites:
* - **Not thread-safe** — same contract as the underlying connection, * - **Not thread-safe** — same contract as the underlying connection,
* which the pool already serializes (single writer under a mutex). * which the pool already serializes (single writer under a mutex; each
* - **No overlapping use of the same SQL** — checking out one SQL string * reader held by one coroutine at a time).
* twice without closing the first use would alias one native handle.
* Insert/query paths never nest the same statement; a checkout while
* the previous one is still open falls back to an uncached statement.
*/ */
class StatementCachingConnection( class StatementCachingConnection(
private val delegate: SQLiteConnection, private val delegate: SQLiteConnection,
/** /**
* Ceiling on retained statements. Query SQL embeds one `?` per filter * Ceiling on retained statements across all SQL strings. Query SQL
* element, so shape variety is client-controlled — without a cap a * embeds one `?` per filter element, so shape variety is
* long-lived relay connection would accumulate native handles without * client-controlled — without a cap a long-lived relay connection would
* bound. Once full, unseen SQL just prepares uncached. 256 comfortably * accumulate native handles without bound. Once full, unseen SQL (or an
* covers the write path's fixed set plus the recurring filter shapes. * extra concurrent copy of a cached SQL) just prepares uncached. 512
* covers the write path's fixed set, the recurring single-shot filter
* shapes, and a few hundred concurrent per-stream merge cursors.
*/ */
private val maxCachedStatements: Int = 256, private val maxCachedStatements: Int = 512,
) : SQLiteConnection by delegate { ) : SQLiteConnection by delegate {
private val cache = HashMap<String, CachedStatement>() // One reusable pool per SQL string. Several entries of the same pool may
// be checked out simultaneously (the merge path); a `prepare` reuses the
// first free entry, grows the pool while under the global cap, and only
// then falls back to an uncached statement.
private val cache = HashMap<String, ArrayList<CachedStatement>>()
private var cachedCount = 0
override fun prepare(sql: String): SQLiteStatement { override fun prepare(sql: String): SQLiteStatement {
val cached = val pool = cache[sql]
cache[sql] ?: run { if (pool != null) {
if (cache.size >= maxCachedStatements) return delegate.prepare(sql) for (i in pool.indices) {
CachedStatement(delegate.prepare(sql)).also { cache[sql] = it } val stmt = pool[i]
if (!stmt.checkedOut) {
stmt.checkedOut = true
stmt.clearBindings()
return stmt
}
}
// Every pooled handle for this SQL is in use — grow if the global
// budget allows, else serve an uncached statement.
if (cachedCount >= maxCachedStatements) return delegate.prepare(sql)
return CachedStatement(delegate.prepare(sql)).also {
it.checkedOut = true
pool.add(it)
cachedCount++
} }
if (cached.checkedOut) {
// Same SQL prepared while the previous handle is still in use —
// stay correct with a plain uncached statement.
return delegate.prepare(sql)
} }
cached.checkedOut = true if (cachedCount >= maxCachedStatements) return delegate.prepare(sql)
cached.clearBindings() return CachedStatement(delegate.prepare(sql)).also {
return cached it.checkedOut = true
cache[sql] = arrayListOf(it)
cachedCount++
}
} }
override fun close() { override fun close() {
cache.values.forEach { runCatching { it.finalize() } } cache.values.forEach { pool -> pool.forEach { runCatching { it.finalizeStatement() } } }
cache.clear() cache.clear()
cachedCount = 0
delegate.close() delegate.close()
} }
@@ -94,6 +120,10 @@ class StatementCachingConnection(
checkedOut = false checkedOut = false
} }
fun finalize() = delegate.close() // Not named `finalize`: a no-arg `finalize()` is treated by the JVM as
// Object.finalize(), so the GC would call it and double-close the
// native handle after our explicit close(). This is only ever invoked
// explicitly from the connection's close().
fun finalizeStatement() = delegate.close()
} }
} }

View File

@@ -202,8 +202,20 @@ fun Filter.strippingSearchExtensions(): Filter {
/** /**
* Applies [strippingSearchExtensions] to every filter, returning this * Applies [strippingSearchExtensions] to every filter, returning this
* same list when no filter carried extension tokens. * same list when no filter carried extension tokens.
*
* This runs on every REQ/COUNT/snapshot, and the overwhelming majority
* carry no `search` term at all, so the no-search case must not allocate:
* bail before building any list when nothing could be stripped.
*/ */
fun List<Filter>.strippingSearchExtensions(): List<Filter> { fun List<Filter>.strippingSearchExtensions(): List<Filter> {
var hasSearch = false
for (i in indices) {
if (!this[i].search.isNullOrEmpty()) {
hasSearch = true
break
}
}
if (!hasSearch) return this
var changed = false var changed = false
val out = val out =
map { map {

View File

@@ -238,9 +238,9 @@ class QueryAssemblerTest : BaseDBTest() {
INNER JOIN ( INNER JOIN (
SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers ORDER BY event_headers.created_at DESC LIMIT 10) SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers ORDER BY event_headers.created_at DESC LIMIT 10)
UNION UNION
SELECT row_id FROM (SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.kind IN ("1", "1111")) AND (event_headers.pubkey = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d") AND (event_fts MATCH "keywords") ORDER BY event_headers.created_at DESC LIMIT 100) SELECT row_id FROM (SELECT event_fts.rowid as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.rowid WHERE (event_headers.kind IN ("1", "1111")) AND (event_headers.pubkey = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d") AND (event_fts MATCH "keywords") ORDER BY event_headers.created_at DESC LIMIT 100)
UNION UNION
SELECT row_id FROM (SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.kind = "20") AND (event_fts MATCH "cats") ORDER BY event_headers.created_at DESC LIMIT 30) SELECT row_id FROM (SELECT event_fts.rowid as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.rowid WHERE (event_headers.kind = "20") AND (event_fts MATCH "cats") ORDER BY event_headers.created_at DESC LIMIT 30)
) AS filtered ) AS filtered
ON event_headers.row_id = filtered.row_id ON event_headers.row_id = filtered.row_id
ORDER BY $orderBy ORDER BY $orderBy
@@ -252,13 +252,13 @@ class QueryAssemblerTest : BaseDBTest() {
│ │ └── SCAN (subquery-1) │ │ └── SCAN (subquery-1)
│ ├── UNION USING TEMP B-TREE │ ├── UNION USING TEMP B-TREE
│ │ ├── CO-ROUTINE (subquery-3) │ │ ├── CO-ROUTINE (subquery-3)
│ │ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 0:M2 │ │ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 0:M1
│ │ │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) │ │ │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?)
│ │ │ └── USE TEMP B-TREE FOR ORDER BY │ │ │ └── USE TEMP B-TREE FOR ORDER BY
│ │ └── SCAN (subquery-3) │ │ └── SCAN (subquery-3)
│ └── UNION USING TEMP B-TREE │ └── UNION USING TEMP B-TREE
│ ├── CO-ROUTINE (subquery-5) │ ├── CO-ROUTINE (subquery-5)
│ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 0:M2 │ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 0:M1
│ │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) │ │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?)
│ │ └── USE TEMP B-TREE FOR ORDER BY │ │ └── USE TEMP B-TREE FOR ORDER BY
│ └── SCAN (subquery-5) │ └── SCAN (subquery-5)
@@ -275,9 +275,9 @@ class QueryAssemblerTest : BaseDBTest() {
INNER JOIN ( INNER JOIN (
SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers ORDER BY event_headers.created_at DESC LIMIT 10) SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers ORDER BY event_headers.created_at DESC LIMIT 10)
UNION UNION
SELECT row_id FROM (SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.kind IN ("1", "1111")) AND (event_headers.pubkey = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d") AND (event_fts MATCH "keywords") ORDER BY event_headers.created_at DESC LIMIT 100) SELECT row_id FROM (SELECT event_fts.rowid as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.rowid WHERE (event_headers.kind IN ("1", "1111")) AND (event_headers.pubkey = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d") AND (event_fts MATCH "keywords") ORDER BY event_headers.created_at DESC LIMIT 100)
UNION UNION
SELECT row_id FROM (SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.kind = "20") AND (event_fts MATCH "cats") ORDER BY event_headers.created_at DESC LIMIT 30) SELECT row_id FROM (SELECT event_fts.rowid as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.rowid WHERE (event_headers.kind = "20") AND (event_fts MATCH "cats") ORDER BY event_headers.created_at DESC LIMIT 30)
) AS filtered ) AS filtered
ON event_headers.row_id = filtered.row_id ON event_headers.row_id = filtered.row_id
ORDER BY $orderBy ORDER BY $orderBy
@@ -290,13 +290,13 @@ class QueryAssemblerTest : BaseDBTest() {
│ │ └── SCAN (subquery-1) │ │ └── SCAN (subquery-1)
│ ├── UNION USING TEMP B-TREE │ ├── UNION USING TEMP B-TREE
│ │ ├── CO-ROUTINE (subquery-3) │ │ ├── CO-ROUTINE (subquery-3)
│ │ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 0:M2 │ │ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 0:M1
│ │ │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) │ │ │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?)
│ │ │ └── USE TEMP B-TREE FOR ORDER BY │ │ │ └── USE TEMP B-TREE FOR ORDER BY
│ │ └── SCAN (subquery-3) │ │ └── SCAN (subquery-3)
│ └── UNION USING TEMP B-TREE │ └── UNION USING TEMP B-TREE
│ ├── CO-ROUTINE (subquery-5) │ ├── CO-ROUTINE (subquery-5)
│ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 0:M2 │ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 0:M1
│ │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) │ │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?)
│ │ └── USE TEMP B-TREE FOR ORDER BY │ │ └── USE TEMP B-TREE FOR ORDER BY
│ └── SCAN (subquery-5) │ └── SCAN (subquery-5)
@@ -712,10 +712,10 @@ class QueryAssemblerTest : BaseDBTest() {
assertEquals( assertEquals(
""" """
SELECT event_headers.id, event_headers.pubkey, event_headers.created_at, event_headers.kind, event_headers.tags, event_headers.content, event_headers.sig FROM event_headers SELECT event_headers.id, event_headers.pubkey, event_headers.created_at, event_headers.kind, event_headers.tags, event_headers.content, event_headers.sig FROM event_headers
INNER JOIN event_fts ON event_headers.row_id = event_fts.event_header_row_id INNER JOIN event_fts ON event_headers.row_id = event_fts.rowid
WHERE (event_fts MATCH "keywords") AND (event_headers.pubkey IN ("7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d", "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14", "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9")) WHERE (event_fts MATCH "keywords") AND (event_headers.pubkey IN ("7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d", "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14", "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9"))
ORDER BY event_headers.created_at DESC, event_headers.id ASC ORDER BY event_fts.rank, event_headers.created_at DESC, event_headers.id ASC
├── SCAN event_fts VIRTUAL TABLE INDEX 0:M2 ├── SCAN event_fts VIRTUAL TABLE INDEX 0:M1
├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?)
└── USE TEMP B-TREE FOR ORDER BY └── USE TEMP B-TREE FOR ORDER BY
""".trimIndent(), """.trimIndent(),
@@ -725,10 +725,10 @@ class QueryAssemblerTest : BaseDBTest() {
assertEquals( assertEquals(
""" """
SELECT event_headers.id, event_headers.pubkey, event_headers.created_at, event_headers.kind, event_headers.tags, event_headers.content, event_headers.sig FROM event_headers SELECT event_headers.id, event_headers.pubkey, event_headers.created_at, event_headers.kind, event_headers.tags, event_headers.content, event_headers.sig FROM event_headers
INNER JOIN event_fts ON event_headers.row_id = event_fts.event_header_row_id INNER JOIN event_fts ON event_headers.row_id = event_fts.rowid
WHERE (event_fts MATCH "keywords") AND (event_headers.pubkey IN ("7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d", "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14", "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9")) WHERE (event_fts MATCH "keywords") AND (event_headers.pubkey IN ("7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d", "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14", "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9"))
ORDER BY event_headers.created_at DESC ORDER BY event_fts.rank, event_headers.created_at DESC
├── SCAN event_fts VIRTUAL TABLE INDEX 0:M2 ├── SCAN event_fts VIRTUAL TABLE INDEX 0:M1
├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?)
└── USE TEMP B-TREE FOR ORDER BY └── USE TEMP B-TREE FOR ORDER BY
""".trimIndent(), """.trimIndent(),
@@ -741,14 +741,14 @@ class QueryAssemblerTest : BaseDBTest() {
fun testKindAndSearch() = fun testKindAndSearch() =
forEachDB { db -> forEachDB { db ->
val filter = Filter(kinds = listOf(1, 1111, 10000), search = "keywords") val filter = Filter(kinds = listOf(1, 1111, 10000), search = "keywords")
val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "event_headers.created_at DESC, event_headers.id ASC" else "event_headers.created_at DESC" val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "event_fts.rank, event_headers.created_at DESC, event_headers.id ASC" else "event_fts.rank, event_headers.created_at DESC"
assertEquals( assertEquals(
""" """
SELECT event_headers.id, event_headers.pubkey, event_headers.created_at, event_headers.kind, event_headers.tags, event_headers.content, event_headers.sig FROM event_headers SELECT event_headers.id, event_headers.pubkey, event_headers.created_at, event_headers.kind, event_headers.tags, event_headers.content, event_headers.sig FROM event_headers
INNER JOIN event_fts ON event_headers.row_id = event_fts.event_header_row_id INNER JOIN event_fts ON event_headers.row_id = event_fts.rowid
WHERE (event_fts MATCH "keywords") AND (event_headers.kind IN ("1", "1111", "10000")) WHERE (event_fts MATCH "keywords") AND (event_headers.kind IN ("1", "1111", "10000"))
ORDER BY $orderBy ORDER BY $orderBy
├── SCAN event_fts VIRTUAL TABLE INDEX 0:M2 ├── SCAN event_fts VIRTUAL TABLE INDEX 0:M1
├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?)
└── USE TEMP B-TREE FOR ORDER BY └── USE TEMP B-TREE FOR ORDER BY
""".trimIndent(), """.trimIndent(),

View File

@@ -0,0 +1,202 @@
/*
* 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.sqlite
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.utils.EventFactory
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* NIP-50: search results are ordered by "quality of search result" (relevance),
* **not** by `created_at`, and the limit is applied after the score. The store
* uses FTS5 bm25 (`ORDER BY event_fts.rank`), so a stronger match outranks a
* newer one.
*/
class SearchRelevanceOrderTest {
private val signer = NostrSignerSync()
private fun note(
content: String,
createdAt: Long,
) = signer.sign(TextNoteEvent.build(content, createdAt = createdAt))
@Test
fun strongerMatchOutranksNewer() =
runBlocking {
val store = EventStore(dbName = null)
try {
// Older, but the term appears 3× in a short doc → most relevant.
val strong = note("bitcoin bitcoin bitcoin", createdAt = 1_000)
// Newer, term once buried in a long doc → least relevant.
val weak = note("bitcoin is one topic among many other unrelated words here padding", createdAt = 9_000)
// Newer still, but does not match at all.
val nonMatch = note("completely different subject entirely", createdAt = 9_999)
store.insert(weak)
store.insert(strong)
store.insert(nonMatch)
val results = store.query<Event>(Filter(search = "bitcoin", limit = 10)).map { it.id }
// Relevance order (strong before weak) — the opposite of
// created_at DESC (which would put weak first) — and the
// non-matching note is absent.
assertEquals(listOf(strong.id, weak.id), results)
} finally {
store.close()
}
}
@Test
fun limitAppliesAfterRelevanceScore() =
runBlocking {
val store = EventStore(dbName = null)
try {
// Three docs of decreasing relevance but increasing created_at,
// so created_at order and relevance order are exact opposites.
val best = note("apple apple apple apple", createdAt = 1)
val mid = note("apple apple filler words", createdAt = 2)
val worst = note("apple among lots of other unrelated filler words here", createdAt = 3)
store.insert(best)
store.insert(mid)
store.insert(worst)
// limit=2 after scoring keeps the two MOST RELEVANT, not the
// two newest.
val top2 = store.query<Event>(Filter(search = "apple", limit = 2)).map { it.id }
assertEquals(listOf(best.id, mid.id), top2)
} finally {
store.close()
}
}
/** A `#t` tag makes this a `search + tag` filter — the combined path. */
private var idSeq = 0
private fun hexId(n: Int): String {
val s = n.toString(16)
return "0".repeat(64 - s.length) + s
}
private fun tagged(
content: String,
createdAt: Long,
topic: String,
): Event =
EventFactory.create(
hexId(++idSeq),
"00".repeat(32),
createdAt,
1,
arrayOf(arrayOf("t", topic)),
content,
"0".repeat(128),
)
private fun evk(
content: String,
createdAt: Long,
kind: Int,
): Event = EventFactory.create(hexId(++idSeq), "00".repeat(32), createdAt, kind, arrayOf(), content, "0".repeat(128))
@Test
fun multiFilterSearchIsRelevanceOrderedAcrossBranches() =
runBlocking {
val store = EventStore(dbName = null)
try {
// Relevance A > B > C, created_at A < B < C (reverse), and the
// branches split by kind: A,C are kind 1 (TextNote), B is kind
// 1111 (Comment) — both searchable kinds.
val a = evk("apple apple apple apple", 1, 1)
val b = evk("apple apple apple", 2, 1111)
val c = evk("apple filler filler filler filler filler", 3, 1)
store.batchInsert(listOf(a, b, c))
// The client's search-across-kinds shape: one term, two filters.
val filters =
listOf(
Filter(search = "apple", kinds = listOf(1), limit = 100),
Filter(search = "apple", kinds = listOf(1111), limit = 100),
)
val ids = store.query<Event>(filters).map { it.id }
assertEquals(listOf(a.id, b.id, c.id), ids, "multi-filter search must be relevance-ordered across branches")
} finally {
store.close()
}
}
@Test
fun multiFilterSearchDedupsEventsMatchingSeveralBranches() =
runBlocking {
val store = EventStore(dbName = null)
try {
val x = evk("banana banana banana", 1, 1)
val y = evk("banana one", 2, 1)
store.batchInsert(listOf(x, y))
// Overlapping branches: both kind-1 events match BOTH filters.
// GROUP BY row_id must fold each to a single ranked row.
val filters =
listOf(
Filter(search = "banana", kinds = listOf(1, 6), limit = 100),
Filter(search = "banana", kinds = listOf(1, 2), limit = 100),
)
val ids = store.query<Event>(filters).map { it.id }
assertEquals(ids.size, ids.toSet().size, "no event may appear twice across branches")
assertEquals(listOf(x.id, y.id), ids, "deduped, relevance-ordered")
assertEquals(2, store.count(filters), "count parity across the union")
} finally {
store.close()
}
}
@Test
fun searchWithATagIsAlsoRelevanceOrdered() =
runBlocking {
val store = EventStore(dbName = null)
try {
// All tagged #t=nostr; relevance decreases as created_at rises,
// so created_at order would be the exact reverse of relevance.
val strong = tagged("nostr nostr nostr", createdAt = 1, topic = "nostr")
val weak = tagged("nostr among many other unrelated filler words here padding", createdAt = 2, topic = "nostr")
// Matches the term but wrong tag → excluded by the tag filter.
val wrongTag = tagged("nostr nostr nostr nostr", createdAt = 3, topic = "other")
// Right tag but doesn't match the term → excluded by search.
val noMatch = tagged("bitcoin only", createdAt = 4, topic = "nostr")
store.batchInsert(listOf(strong, weak, wrongTag, noMatch))
val filter = Filter(search = "nostr", tags = mapOf("t" to listOf("nostr")), limit = 10)
val ids = store.query<Event>(filter).map { it.id }
assertEquals(listOf(strong.id, weak.id), ids, "search + tag must be relevance-ordered, tag-scoped")
// limit after score keeps the most relevant one.
val top1 = store.query<Event>(filter.copy(limit = 1)).map { it.id }
assertEquals(listOf(strong.id), top1)
} finally {
store.close()
}
}
}

View File

@@ -0,0 +1,113 @@
/*
* 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.sqlite
import androidx.sqlite.SQLiteStatement
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotSame
import kotlin.test.assertSame
class StatementCachingConnectionTest {
private lateinit var conn: StatementCachingConnection
private val sql = "SELECT ? AS v"
@BeforeTest
fun setup() {
conn = StatementCachingConnection(BundledSQLiteDriver().open(":memory:"))
}
@AfterTest
fun tearDown() {
conn.close()
}
private fun readOne(stmt: SQLiteStatement): Long {
stmt.step()
return stmt.getLong(0)
}
@Test
fun sequentialSameSqlReusesTheSameHandle() {
var first: SQLiteStatement? = null
conn.prepare(sql).use { stmt ->
stmt.bindLong(1, 7)
assertEquals(7, readOne(stmt))
first = stmt
}
// Closed (returned to pool) — the next prepare of the same SQL must
// hand back the very same cached handle, not a fresh prepare.
conn.prepare(sql).use { stmt ->
assertSame(first, stmt)
stmt.bindLong(1, 9)
assertEquals(9, readOne(stmt))
}
}
@Test
fun concurrentSameSqlHandlesAreDistinctAndIndependent() {
// The k-way-merge shape: many identical-SQL cursors live at once.
val a = conn.prepare(sql)
val b = conn.prepare(sql)
val c = conn.prepare(sql)
assertNotSame(a, b)
assertNotSame(b, c)
assertNotSame(a, c)
a.bindLong(1, 1)
b.bindLong(1, 2)
c.bindLong(1, 3)
// Each cursor keeps its own bindings/position even while the others
// are open — no aliasing of one native handle.
assertEquals(1, readOne(a))
assertEquals(2, readOne(b))
assertEquals(3, readOne(c))
a.close()
b.close()
c.close()
// After release, a fresh concurrent burst reuses the pooled handles.
val reused = conn.prepare(sql)
assertSame(a, reused, "pool should hand back a freed handle before preparing anew")
reused.close()
}
@Test
fun overflowingTheGlobalCapFallsBackToUncached() {
val small = StatementCachingConnection(BundledSQLiteDriver().open(":memory:"), maxCachedStatements = 2)
try {
val live = (0 until 5).map { small.prepare(sql) }
// All five must be usable even though only two can be cached; the
// extra three are plain uncached statements.
live.forEachIndexed { i, stmt ->
stmt.bindLong(1, i.toLong())
assertEquals(i.toLong(), readOne(stmt))
}
live.forEach { it.close() }
} finally {
small.close()
}
}
}

View File

@@ -0,0 +1,295 @@
/*
* 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.sqlite
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.utils.EventFactory
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Correctness guard for the tag-stream path of [MergeQueryExecutor]: the
* `#<x>=[values] (+ kinds) [+ since/until] limit=N` watcher shape
* (reactions/replies). The merge must return the same newest-N a
* `SELECT DISTINCT … ORDER BY created_at DESC LIMIT N` would, deduping events
* that carry several of the queried tag values.
*
* Where `created_at` is distinct the order is fully determined and asserted
* against a Kotlin reference. Where it ties, the tag cursors can only order by
* `created_at` (event_tags has no id column), so the result is a valid
* newest-N but not id-exact — those cases assert set + size instead.
*/
class TagMergeCorrectnessTest {
private val hex = "0123456789abcdef"
private fun mix(seed: Long): Long {
var z = seed + -0x61c8864680b583ebL
z = (z xor (z ushr 30)) * -0x40a7b892e31b1a47L
z = (z xor (z ushr 27)) * -0x6b2fb644ecceee15L
return z xor (z ushr 31)
}
private fun hex64(
salt: Long,
index: Int,
): String {
val out = CharArray(64)
for (w in 0 until 4) {
val v = mix(salt * 1_000_003 + index.toLong() * 4 + w)
for (b in 0 until 8) {
val byte = ((v ushr (b * 8)) and 0xFF).toInt()
out[(w * 8 + b) * 2] = hex[byte ushr 4]
out[(w * 8 + b) * 2 + 1] = hex[byte and 0xF]
}
}
return out.concatToString()
}
private val sig = "0".repeat(128)
private var idSeq = 0
private fun ev(
createdAt: Long,
kind: Int,
eTags: List<String>,
): Event =
EventFactory.create(
hex64(7, idSeq++),
hex64(1, idSeq),
createdAt,
kind,
eTags.map { arrayOf("e", it) }.toTypedArray(),
"",
sig,
)
private val newestFirst =
Comparator<Event> { a, b ->
if (a.createdAt != b.createdAt) b.createdAt.compareTo(a.createdAt) else a.id.compareTo(b.id)
}
private fun reference(
all: List<Event>,
values: Set<String>,
kinds: Set<Int>?,
since: Long?,
until: Long?,
limit: Int,
): List<String> =
all
.asSequence()
.filter { e -> e.tags.any { it.size >= 2 && it[0] == "e" && it[1] in values } }
.filter { kinds == null || it.kind in kinds }
.filter { since == null || it.createdAt >= since }
.filter { until == null || it.createdAt <= until }
.sortedWith(newestFirst)
.map { it.id }
.distinct()
.take(limit)
.toList()
private fun mergeEligible(
store: EventStore,
filter: Filter,
): Boolean =
MergeQueryExecutor.streamCount(
with(store.store.queryBuilder) { filter.toFilterWithDTags() },
store.store.queryBuilder.indexStrategy,
) > 0
private fun newStore() =
EventStore(
dbName = null,
indexStrategy =
DefaultIndexingStrategy(
indexTagsByCreatedAtAlone = true,
useAndIndexIdOnOrderBy = true,
indexFullTextSearch = false,
),
)
@Test
fun distinctCreatedAt_withKinds_matchesReference() =
runBlocking {
val store = newStore()
val notes = (0 until 6).map { hex64(2, it) }
val all = ArrayList<Event>()
var t = 1_700_000_000L
for (round in 0 until 40) {
// reactions (kind 7) and replies (kind 1) to a rotating note
all.add(ev(t++, 7, listOf(notes[round % notes.size])))
all.add(ev(t++, 1, listOf(notes[(round + 1) % notes.size])))
}
// Noise: kind-7 to notes NOT in the query set, and other tags.
for (i in 0 until 100) all.add(ev(t++, 7, listOf(hex64(9, i))))
store.batchInsert(all)
val queried = notes.take(3)
val filter = Filter(kinds = listOf(7), tags = mapOf("e" to queried), limit = 25)
assertTrue(mergeEligible(store, filter), "tag watcher must be merge-eligible")
val merged = store.query<Event>(filter).map { it.id }
assertEquals(reference(all, queried.toSet(), setOf(7), null, null, 25), merged)
store.close()
}
@Test
fun crossStreamDuplicate_emittedOnce() =
runBlocking {
val store = newStore()
val a = hex64(3, 0)
val b = hex64(3, 1)
val all = ArrayList<Event>()
var t = 1_700_000_000L
// Events tagging BOTH a and b — they appear in both value streams
// and must be emitted exactly once.
repeat(5) { all.add(ev(t++, 1, listOf(a, b))) }
// Events tagging only one of them.
repeat(5) { all.add(ev(t++, 1, listOf(a))) }
repeat(5) { all.add(ev(t++, 1, listOf(b))) }
store.batchInsert(all)
val filter = Filter(kinds = listOf(1), tags = mapOf("e" to listOf(a, b)), limit = 500)
assertTrue(mergeEligible(store, filter))
val merged = store.query<Event>(filter).map { it.id }
assertEquals(merged.size, merged.toSet().size, "no event may be emitted twice")
assertEquals(15, merged.size, "5 both + 5 a-only + 5 b-only = 15 distinct")
assertEquals(reference(all, setOf(a, b), setOf(1), null, null, 500), merged)
store.close()
}
@Test
fun noKinds_usesTagCreatedAtIndex() =
runBlocking {
val store = newStore()
val notes = (0 until 5).map { hex64(4, it) }
val all = ArrayList<Event>()
var t = 1_700_000_000L
for (round in 0 until 30) all.add(ev(t++, (round % 3) + 1, listOf(notes[round % notes.size])))
for (i in 0 until 60) all.add(ev(t++, 1, listOf(hex64(8, i))))
store.batchInsert(all)
val queried = notes.take(3)
val filter = Filter(tags = mapOf("e" to queried), limit = 20)
assertTrue(mergeEligible(store, filter), "no-kind tag watcher must be merge-eligible with the tag index")
val merged = store.query<Event>(filter).map { it.id }
assertEquals(reference(all, queried.toSet(), null, null, null, 20), merged)
store.close()
}
@Test
fun withSinceAndUntil_boundsTheWindow() =
runBlocking {
val store = newStore()
val notes = (0 until 4).map { hex64(5, it) }
val all = ArrayList<Event>()
val base = 1_700_000_000L
for (i in 0 until 300) all.add(ev(base + i.toLong(), 7, listOf(notes[i % notes.size])))
store.batchInsert(all)
val since = base + 50
val until = base + 250
val filter = Filter(kinds = listOf(7), tags = mapOf("e" to notes), since = since, until = until, limit = 500)
assertTrue(mergeEligible(store, filter))
val merged = store.query<Event>(filter).map { it.id }
val ref = reference(all, notes.toSet(), setOf(7), since, until, 500)
assertEquals(ref, merged)
assertTrue(merged.isNotEmpty() && ref.size < 300)
store.close()
}
@Test
fun rawPathMatchesDecoded() =
runBlocking {
val store = newStore()
val notes = (0 until 6).map { hex64(6, it) }
val all = ArrayList<Event>()
var t = 1_700_000_000L
for (round in 0 until 25) all.add(ev(t++, 7, listOf(notes[round % notes.size])))
store.batchInsert(all)
val filter = Filter(kinds = listOf(7), tags = mapOf("e" to notes.take(3)), limit = 15)
val decoded = store.query<Event>(filter).map { it.id }
val raw = store.store.rawQuery(filter).map { it.id }
assertEquals(decoded, raw, "the zero-decode raw path must match the decoded query")
store.close()
}
@Test
fun tiedCreatedAt_matchesReferenceAsSet() =
runBlocking {
val store = newStore()
val notes = (0 until 4).map { hex64(10, it) }
val all = ArrayList<Event>()
// Many events share a created_at — the tag cursor can't id-order
// within a second, so assert a valid newest-N by set + size.
var t = 1_700_000_000L
for (block in 0 until 15) {
val ts = t
for (n in notes) all.add(ev(ts, 7, listOf(n)))
t += 1
}
store.batchInsert(all)
val filter = Filter(kinds = listOf(7), tags = mapOf("e" to notes), limit = 22)
assertTrue(mergeEligible(store, filter))
val merged = store.query<Event>(filter).map { it.id }
assertEquals(22, merged.size)
assertEquals(merged.size, merged.toSet().size)
// The whole result must sit within the newest slice the reference
// would return once ties are resolved either way: everything in
// `merged` must be at or above the created_at cutoff.
val ids = merged.toSet()
val chosen = all.filter { it.id in ids }
val cutoff = chosen.minOf { it.createdAt }
val eligibleAboveCutoff = all.filter { it.createdAt > cutoff }.map { it.id }.toSet()
assertTrue(eligibleAboveCutoff.all { it in ids }, "every event newer than the cutoff must be included")
store.close()
}
@Test
fun ineligibleShapesFallThrough() =
runBlocking {
val store = newStore()
store.batchInsert(listOf(ev(1_700_000_000L, 7, listOf(hex64(2, 0)))))
// Single value → single seek, not a merge.
assertFalse(mergeEligible(store, Filter(kinds = listOf(7), tags = mapOf("e" to listOf(hex64(2, 0))), limit = 10)))
// No limit → not merge-eligible.
assertFalse(mergeEligible(store, Filter(kinds = listOf(7), tags = mapOf("e" to listOf(hex64(2, 0), hex64(2, 1))))))
// Authors present → covered-index seek shape, not a tag merge.
assertFalse(
mergeEligible(
store,
Filter(kinds = listOf(7), authors = listOf(hex64(1, 1)), tags = mapOf("e" to listOf(hex64(2, 0), hex64(2, 1))), limit = 10),
),
)
store.close()
}
}

View File

@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.isAddressable
import com.vitorpamplona.quartz.nip01Core.core.isReplaceable import com.vitorpamplona.quartz.nip01Core.core.isReplaceable
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.store.sqlite.TagNameValueHasher import com.vitorpamplona.quartz.nip01Core.store.sqlite.TagNameValueHasher
import java.nio.file.DirectoryStream
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import kotlin.io.path.exists import kotlin.io.path.exists
@@ -36,16 +37,23 @@ import kotlin.io.path.exists
* *
* Step-2 coverage: * Step-2 coverage:
* - `ids` → direct canonical opens * - `ids` → direct canonical opens
* - `tagsAll`/`tags` → tag index union (first key) * - `tagsAll`/`tags`/`kinds`/`authors` → cheapest index tree drives
* - `kinds` → kind index union * (capped entry-count comparison), the rest post-filter
* - `authors` → author index union
* - otherwise → full scan via every `idx/kind/<k>/` subtree * - otherwise → full scan via every `idx/kind/<k>/` subtree
* *
* The planner is intentionally dumb about selectivity — "first available * Driver choice is cost-based: every legal driver (each `tagsAll` value
* driver wins". A cost-based picker (smallest listing) can slot in * alone — AND semantics make any single value a complete driver — each
* later without changing callers. All FilterMatcher semantics (tag * `tags` key's value union, the kind set, the author set) opens a lazy
* AND/OR, since/until, id, author, kind cross-checks) are enforced in * directory iterator, all are drained in lockstep, and the first to
* the orchestrator, so picking a loose driver is correctness-safe. * exhaust — the smallest listing — drives. A giant tree (`idx/kind/1/`
* with a million entries) is therefore never read past ~the smallest
* candidate's size. Before the pick, the fixed tags → kinds → authors
* order sent `authors + kinds + limit` — the most common CLI shape —
* through the kind tree: 149 ms fixed-order vs 4.0 ms cost-based at 30k
* events per `FsDriverSelectionBenchmark` (floor: author-only at
* 1.4 ms). All FilterMatcher semantics (tag AND/OR,
* since/until, id, author, kind cross-checks) are enforced in the
* orchestrator, so any driver pick is correctness-safe.
*/ */
internal class FsQueryPlanner( internal class FsQueryPlanner(
private val layout: FsLayout, private val layout: FsLayout,
@@ -74,19 +82,100 @@ internal class FsQueryPlanner(
return ftsDriver(search) return ftsDriver(search)
} }
firstTagKey(filter)?.let { (name, values) -> val candidates = driverCandidates(filter)
return mergeDesc(values.map { v -> walkDir(layout.tagValueDir(name, v, hasher.hash(name, v))) }) if (candidates.isEmpty()) return allKindsDriver()
return mergeDesc(cheapestDriver(candidates).map { walkDir(it) })
}
/**
* Every set of index directories that, walked and post-filtered, yields
* a superset of the filter's matches:
* - each `tagsAll` value alone (AND semantics — every match carries it),
* - each `tags` key's full value union (OR within a key, AND across),
* - the kind set, and the author set.
* Listed in the old fixed-priority order so [cheapestDriver] keeps that
* order on cost ties.
*/
private fun driverCandidates(filter: Filter): List<List<Path>> {
val out = ArrayList<List<Path>>()
filter.tagsAll?.forEach { (name, values) ->
values.forEach { v -> out.add(listOf(layout.tagValueDir(name, v, hasher.hash(name, v)))) }
}
filter.tags?.forEach { (name, values) ->
if (values.isNotEmpty()) {
out.add(values.map { v -> layout.tagValueDir(name, v, hasher.hash(name, v)) })
}
}
filter.kinds?.takeIf { it.isNotEmpty() }?.let { kinds ->
out.add(kinds.map { layout.kindDir(it) })
}
filter.authors?.takeIf { it.isNotEmpty() }?.let { authors ->
out.add(authors.map { layout.authorDir(it) })
}
return out
}
/**
* Smallest candidate by lockstep listing drain: one lazy directory
* iterator per candidate, all advanced [COST_BATCH] entries per round —
* the first to exhaust its listing is the smallest, so a giant tree is
* never read past ~the smallest candidate's size (a candidate that
* exhausts on round one costs the others one batch each). A candidate
* whose dirs are all missing exhausts immediately: driving from an empty
* mandatory predicate correctly yields an empty result. If every
* candidate survives [COST_CAP] entries, all are huge and relative
* driver choice stops mattering — the first (old fixed-priority order)
* wins.
*/
private fun cheapestDriver(candidates: List<List<Path>>): List<Path> {
if (candidates.size == 1) return candidates[0]
val cursors = candidates.map { EntryCursor(it) }
try {
var advanced = 0L
while (advanced < COST_CAP) {
for (i in cursors.indices) {
if (!cursors[i].skip(COST_BATCH)) return candidates[i]
}
advanced += COST_BATCH
}
return candidates[0]
} finally {
cursors.forEach { it.close() }
}
}
/** Lazy entry iterator over a candidate's directories, in order. */
private class EntryCursor(
dirs: List<Path>,
) : AutoCloseable {
private val remaining = ArrayDeque(dirs)
private var stream: DirectoryStream<Path>? = null
private var iter: Iterator<Path> = emptyList<Path>().iterator()
/** Advances up to [n] entries; false when the listing ends first. */
fun skip(n: Int): Boolean {
var left = n
while (left > 0) {
if (iter.hasNext()) {
iter.next()
left--
continue
}
close()
val dir = remaining.removeFirstOrNull() ?: return false
if (!Files.isDirectory(dir)) continue
stream = Files.newDirectoryStream(dir)
iter = stream!!.iterator()
}
return true
} }
filter.kinds?.let { kinds -> override fun close() {
return mergeDesc(kinds.map { walkDir(layout.kindDir(it)) }) stream?.close()
stream = null
iter = emptyList<Path>().iterator()
} }
filter.authors?.let { authors ->
return mergeDesc(authors.map { walkDir(layout.authorDir(it)) })
}
return allKindsDriver()
} }
/** /**
@@ -301,14 +390,15 @@ internal class FsQueryPlanner(
var top: Candidate, var top: Candidate,
) )
// ---- helpers ------------------------------------------------------ private companion object {
/** Entries each candidate's cursor advances per lockstep round. */
const val COST_BATCH = 64
/** First tag filter with at least one value, preferring `tagsAll`. */ /**
private fun firstTagKey(filter: Filter): Pair<String, List<String>>? { * Stop draining once every candidate has survived this many
filter.tagsAll?.firstNonEmpty()?.let { return it } * entries: past it they are all huge, relative choice stops
filter.tags?.firstNonEmpty()?.let { return it } * mattering, and the first candidate in priority order wins.
return null */
const val COST_CAP = 65_536L
} }
private fun Map<String, List<String>>.firstNonEmpty(): Pair<String, List<String>>? = entries.firstOrNull { it.value.isNotEmpty() }?.let { it.key to it.value }
} }

View File

@@ -202,7 +202,7 @@ class FollowFeedReadBenchmark {
runBlocking { runBlocking {
store.store.pool.useReader { c -> store.store.pool.useReader { c ->
var n = 0 var n = 0
MergeQueryExecutor.run(c, filter, store.store.queryBuilder.indexStrategy) { n++ } MergeQueryExecutor.run(c, filter, store.store.queryBuilder.indexStrategy, store.store.seedModule::hasher) { n++ }
n n
} }
} }

View File

@@ -0,0 +1,185 @@
/*
* 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.prodbench
import androidx.sqlite.SQLiteConnection
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.store.sqlite.DefaultIndexingStrategy
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import com.vitorpamplona.quartz.utils.EventFactory
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
/**
* Two measurements around the contentless FTS index, motivating the v4→v5
* schema change and being honest about what it does and does not fix.
*
* 1. **Delete scaling — the win.** The old `fts5(event_header_row_id,
* content)` schema had the `fts_foreign_key` trigger delete by a regular
* FTS column, which FTS5 cannot seek (it scans, O(n) per delete). The
* contentless schema keys deletes off the rowid (= event_headers.row_id),
* an O(log n) primary-key seek. Every event removal fires this trigger
* (replaceable rotation, kind-5, expiration, right-to-vanish).
* 2. **Search scaling — the limit.** `MATCH … ORDER BY rank LIMIT n` (NIP-50
* relevance ordering, bm25) must score *every* matching document, so
* search cost grows with the match set regardless of ordering (created_at
* has the same shape). Segment `optimize` compacts the index but does not
* change that; corpus-independent search needs an external engine. Shown
* fragmented vs optimized to size the (secondary) compaction effect.
*
* Size search with `-DftsBenchScale=N` (default 1). Not an assertion test.
*/
class FtsSearchScalingBenchmark {
companion object {
val SCALE = System.getProperty("ftsBenchScale")?.toInt() ?: 1
val SIZES = listOf(50_000, 100_000, 200_000).map { it * SCALE }
const val NEEDLE = "zzneedle"
}
private val hex = "0123456789abcdef"
private fun mix(seed: Long): Long {
var z = seed + -0x61c8864680b583ebL
z = (z xor (z ushr 30)) * -0x40a7b892e31b1a47L
z = (z xor (z ushr 27)) * -0x6b2fb644ecceee15L
return z xor (z ushr 31)
}
private fun hex64(index: Int): String {
val out = CharArray(64)
for (w in 0 until 4) {
val v = mix(index.toLong() * 4 + w + 99)
for (b in 0 until 8) {
val byte = ((v ushr (b * 8)) and 0xFF).toInt()
out[(w * 8 + b) * 2] = hex[byte ushr 4]
out[(w * 8 + b) * 2 + 1] = hex[byte and 0xF]
}
}
return String(out)
}
private val vocab = (0 until 400).map { "word$it" }
private val sig = "0".repeat(128)
private fun seed(n: Int): List<Event> {
val base = 1_700_000_000L
val events = ArrayList<Event>(n)
for (i in 0 until n) {
val r = mix(i.toLong())
val content =
buildString {
for (w in 0 until 8) append(vocab[((r ushr (w * 3)) and 0x1FF).toInt() % vocab.size]).append(' ')
// ~1% carry the searched term.
if (i % 100 == 0) append(NEEDLE)
}
events.add(EventFactory.create(hex64(i), hex64(i % 5000), base + i.toLong(), 1, emptyArray(), content, sig))
}
return events
}
private inline fun timeMs(block: () -> Unit): Double {
val start = System.nanoTime()
block()
return (System.nanoTime() - start) / 1e6
}
private fun SQLiteConnection.exec(sql: String) = prepare(sql).use { it.step() }
@Test
fun deleteByColumnVsByRowid() {
// Old-schema (delete by FTS column) vs contentless (delete by rowid),
// 500 deletes at two table sizes. By-column should grow with the
// table; by-rowid should stay flat.
println("─ FtsSearchScalingBenchmark.delete (500 deletes) ─")
println(" %-9s %14s %14s".format("rows", "byColumn", "byRowid"))
for (n in listOf(2_000 * SCALE, 8_000 * SCALE)) {
val db = BundledSQLiteDriver().open(":memory:")
try {
db.exec("CREATE VIRTUAL TABLE col USING fts5(event_header_row_id, content)")
db.exec("CREATE VIRTUAL TABLE row USING fts5(content, content='', contentless_delete=1)")
for (i in 1..n) {
db.prepare("INSERT INTO col(event_header_row_id, content) VALUES (?, 'alpha beta gamma')").use {
it.bindLong(1, i.toLong())
it.step()
}
db.prepare("INSERT INTO row(rowid, content) VALUES (?, 'alpha beta gamma')").use {
it.bindLong(1, i.toLong())
it.step()
}
}
val byColumn =
timeMs {
for (i in 1..500) {
db.prepare("DELETE FROM col WHERE event_header_row_id = ?").use {
it.bindLong(1, i.toLong())
it.step()
}
}
}
val byRowid =
timeMs {
for (i in 1..500) {
db.prepare("DELETE FROM row WHERE rowid = ?").use {
it.bindLong(1, i.toLong())
it.step()
}
}
}
println(" %-9s %11.2f ms %11.2f ms".format("${n / 1000}k", byColumn, byRowid))
} finally {
db.close()
}
}
}
@Test
fun searchScaling() =
runBlocking {
println("─ FtsSearchScalingBenchmark.search (created_at DESC, limit=50) ─")
println(" %-9s %16s %16s".format("corpus", "fragmented", "optimized"))
for (size in SIZES) {
val store = EventStore(dbName = null, indexStrategy = DefaultIndexingStrategy())
try {
seed(size).chunked(10_000).forEach { store.batchInsert(it) }
val f = Filter(search = NEEDLE, limit = 50)
val frag = time(store, f)
store.store.reindexFullTextSearch() // rebuild + optimize()
val opt = time(store, f)
println(" %-9s %13.2f ms %13.2f ms".format("${size / 1000}k", frag, opt))
} finally {
store.close()
}
}
}
private suspend fun time(
store: EventStore,
filter: Filter,
): Double {
repeat(3) { store.query<Event>(filter) }
val runs = 20
val start = System.nanoTime()
repeat(runs) { store.query<Event>(filter) }
return (System.nanoTime() - start) / 1e6 / runs
}
}

View File

@@ -32,11 +32,14 @@ import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import com.vitorpamplona.quartz.utils.EventFactory import com.vitorpamplona.quartz.utils.EventFactory
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import java.util.concurrent.atomic.AtomicInteger
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
@@ -65,6 +68,8 @@ class SmallReqFloorBenchmark {
const val AUTHORS = 2_500 // ~20 events per author, matching author-archive const val AUTHORS = 2_500 // ~20 events per author, matching author-archive
const val ROUNDS = 400 const val ROUNDS = 400
const val WARMUP = 100 const val WARMUP = 100
const val IDLE_SUBS = 1_000
const val FANOUT_SUBS = 200
} }
private fun hexId(seed: Int): String = seed.toString(16).padStart(64, '0') private fun hexId(seed: Int): String = seed.toString(16).padStart(64, '0')
@@ -122,16 +127,19 @@ class SmallReqFloorBenchmark {
} }
// --- B: backend queryRaw to EOSE (live machinery included) --- // --- B: backend queryRaw to EOSE (live machinery included) ---
// UNDISPATCHED mirrors the production path (RelaySession.handleReq
// starts the query coroutine undispatched), so BA is the live
// machinery itself, not a benchmark-only scheduler hop.
suspend fun timeBackend(round: Int): Long { suspend fun timeBackend(round: Int): Long {
val eose = CompletableDeferred<Long>() val eose = CompletableDeferred<Long>()
val t0 = System.nanoTime() val t0 = System.nanoTime()
val job = val job =
scope.launch { scope.launch(start = CoroutineStart.UNDISPATCHED) {
live.queryRaw( live.queryRaw(
ctx = ctx, ctx = ctx,
filters = listOf(filterFor(round)), filters = listOf(filterFor(round)),
onEachStored = {}, onEachStored = {},
onEachLive = {}, onEachLive = { _, _ -> },
onEose = { eose.complete(System.nanoTime() - t0) }, onEose = { eose.complete(System.nanoTime() - t0) },
) )
} }
@@ -143,6 +151,33 @@ class SmallReqFloorBenchmark {
val b = LongArray(ROUNDS) val b = LongArray(ROUNDS)
repeat(ROUNDS) { b[it] = timeBackend(it) } repeat(ROUNDS) { b[it] = timeBackend(it) }
// --- B@1k: same, with 1000 idle live subscriptions parked ---
// Register/unregister cost scales with the live population
// (FilterIndex mutates a shared snapshot per REQ open/close),
// which the single-sub stage can't see. Each idle sub filters
// on an author absent from the corpus: 0-row replay, then parks
// at the live tail and stays registered.
val idleJobs =
(0 until IDLE_SUBS).map { i ->
val ready = CompletableDeferred<Unit>()
val job =
scope.launch(start = CoroutineStart.UNDISPATCHED) {
live.queryRaw(
ctx = ctx,
filters = listOf(Filter(authors = listOf(hexId(1_000_000 + i)), kinds = listOf(1), limit = 1)),
onEachStored = {},
onEachLive = { _, _ -> },
onEose = { ready.complete(Unit) },
)
}
ready.await()
job
}
repeat(WARMUP) { timeBackend(it) }
val b1k = LongArray(ROUNDS)
repeat(ROUNDS) { b1k[it] = timeBackend(it) }
idleJobs.forEach { it.cancel() }
// --- C: full session dispatch, REQ json in → EOSE frame out --- // --- C: full session dispatch, REQ json in → EOSE frame out ---
suspend fun timeSession(round: Int): Long { suspend fun timeSession(round: Int): Long {
val eose = CompletableDeferred<Long>() val eose = CompletableDeferred<Long>()
@@ -160,15 +195,67 @@ class SmallReqFloorBenchmark {
val c = LongArray(ROUNDS) val c = LongArray(ROUNDS)
repeat(ROUNDS) { c[it] = timeSession(it) } repeat(ROUNDS) { c[it] = timeSession(it) }
// --- fanout: one live event → FANOUT_SUBS live subscriptions ---
// All subs register on `live` directly (via queryRaw, same backend
// we submit into) and filter an author with no stored events (0-row
// replay, then park live). Submitting one matching event fans out to
// every sub; the body is serialized once and spliced per sub, so
// this measures the shared-serialization path (#2). skipVerify so
// the synthetic sig is accepted. Fewer rounds than AC: each round
// is FANOUT_SUBS deliveries and a real group-commit insert.
val fanAuthor = hexId(9_000_001)
val delivered = AtomicInteger(0)
var fanDone = CompletableDeferred<Long>()
var fanStart = 0L
val fanJobs =
(0 until FANOUT_SUBS).map {
val ready = CompletableDeferred<Unit>()
val job =
scope.launch(start = CoroutineStart.UNDISPATCHED) {
live.queryRaw(
ctx = ctx,
filters = listOf(Filter(authors = listOf(fanAuthor), kinds = listOf(1))),
onEachStored = {},
onEachLive = { _, _ ->
if (delivered.incrementAndGet() == FANOUT_SUBS) {
fanDone.complete(System.nanoTime() - fanStart)
}
},
onEose = { ready.complete(Unit) },
)
}
ready.await()
job
}
val fanRounds = 60
val fanWarmup = 15
val fan = LongArray(fanRounds)
var fanSeq = 0
repeat(fanWarmup + fanRounds) { r ->
delivered.set(0)
fanDone = CompletableDeferred()
val ev = EventFactory.create<Event>(hexId(9_500_000 + fanSeq), fanAuthor, 1_700_000_000L + fanSeq, 1, emptyArray(), "fanout $fanSeq", sig)
fanSeq++
fanStart = System.nanoTime()
live.submit(ev, skipVerify = true) {}
val nanos = withTimeout(30_000) { fanDone.await() }
if (r >= fanWarmup) fan[r - fanWarmup] = nanos
}
fanJobs.forEach { it.cancel() }
assertEquals(true, rowsA > 0, "author filters must return rows") assertEquals(true, rowsA > 0, "author filters must return rows")
val mA = median(a) val mA = median(a)
val mB = median(b) val mB = median(b)
val mB1k = median(b1k)
val mC = median(c) val mC = median(c)
println("SmallReqFloorBenchmark @ ${EVENTS / 1000}k events, ~${rowsA / ROUNDS} rows/req, medians of $ROUNDS") println("SmallReqFloorBenchmark @ ${EVENTS / 1000}k events, ~${rowsA / ROUNDS} rows/req, medians of $ROUNDS")
println(" A raw store query: ${"%6.3f".format(mA)} ms") println(" A raw store query: ${"%6.3f".format(mA)} ms")
println(" B backend queryRaw→EOSE: ${"%6.3f".format(mB)} ms (live machinery +${"%6.3f".format(mB - mA)})") println(" B backend queryRaw→EOSE: ${"%6.3f".format(mB)} ms (live machinery +${"%6.3f".format(mB - mA)})")
println(" B@${IDLE_SUBS} idle subs: ${"%6.3f".format(mB1k)} ms (population cost +${"%6.3f".format(mB1k - mB)})")
println(" C session REQ→EOSE: ${"%6.3f".format(mC)} ms (dispatch+frames +${"%6.3f".format(mC - mB)})") println(" C session REQ→EOSE: ${"%6.3f".format(mC)} ms (dispatch+frames +${"%6.3f".format(mC - mB)})")
val mFan = median(fan)
println(" fanout 1→$FANOUT_SUBS live subs: ${"%6.3f".format(mFan)} ms (${"%.2f".format(mFan * 1000 / FANOUT_SUBS)} µs/sub; body serialized once)")
server.close() server.close()
scope.cancel() scope.cancel()

View File

@@ -0,0 +1,184 @@
/*
* 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.prodbench
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.store.sqlite.DefaultIndexingStrategy
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import com.vitorpamplona.quartz.utils.EventFactory
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
/**
* Measures the two tag-path query shapes the client filter-assembler survey
* (2026-07) found hot but that no existing benchmark covers:
*
* 1. **tag ∩ author (DM-room shape)** — `kinds=[4] AND authors=[peer] AND
* #p=[me] LIMIT n`. 65 assembler call sites build this shape (every
* NIP-04 chat room, reports-by-follows, follows-scoped community feeds).
* [com.vitorpamplona.quartz.nip01Core.store.sqlite.IndexingStrategy.indexTagsWithKindAndPubkey]
* gates a covering `(tag_hash, kind, pubkey_hash, created_at)` index for
* it, but the flag is off everywhere (including geode). Without it the
* plan seeks `(tag_hash, kind)` and reads EVERY DM the user has ever
* received before filtering to the one peer. This compares query latency
* with the flag off vs on, and the batch-insert cost the extra index adds.
*
* 2. **large-IN tag watcher (reactions shape)** — `kinds=[7] AND
* #e=[hundreds of note ids] LIMIT n`. The per-value streams come sorted
* off `(tag_hash, kind, created_at)`, but their union does not, so SQLite
* collects every matching row and TEMP-B-TREE sorts to the limit — the
* tag-index analogue of the follow-feed regression
* [MergeQueryExecutor] fixed for author streams. Reported with and
* without a `since` bound to show what EOSE-warm steady state hides.
*
* Size the seed with `-DtagBenchScale=N` (default 1 ≈ ~200k events).
*/
class TagAuthorIndexBenchmark {
companion object {
val SCALE = System.getProperty("tagBenchScale")?.toInt() ?: 1
}
private val hex = "0123456789abcdef"
private fun mix(seed: Long): Long {
var z = seed + -0x61c8864680b583ebL
z = (z xor (z ushr 30)) * -0x40a7b892e31b1a47L
z = (z xor (z ushr 27)) * -0x6b2fb644ecceee15L
return z xor (z ushr 31)
}
private fun hex64(
salt: Long,
index: Int,
): String {
val out = CharArray(64)
for (w in 0 until 4) {
val v = mix(salt * 1_000_003 + index.toLong() * 4 + w)
for (b in 0 until 8) {
val byte = ((v ushr (b * 8)) and 0xFF).toInt()
out[(w * 8 + b) * 2] = hex[byte ushr 4]
out[(w * 8 + b) * 2 + 1] = hex[byte and 0xF]
}
}
return String(out)
}
private val sig = "0".repeat(128)
private var idSeq = 0
private fun ev(
pubkey: String,
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
): Event = EventFactory.create(hex64(7, idSeq++), pubkey, createdAt, kind, tags, "", sig)
private fun seedEvents(): List<Event> {
idSeq = 0
val base = 1_700_000_000L
val span = 3_000_000L // ~35 days
val me = hex64(9, 0)
val events = ArrayList<Event>(220_000 * SCALE)
// DM inbox: 200 peers, 300 DMs each → 60k kind-4 rows sharing the
// same (p:me) tag hash. The room query wants one peer's 300.
val peers = (0 until 200).map { hex64(2, it) }
for ((i, peer) in peers.withIndex()) {
repeat(300 * SCALE) {
val ts = base + (mix(i * 131L + it) and 0x7fffffff) % span
events.add(ev(peer, ts, 4, arrayOf(arrayOf("p", me))))
}
}
// Notification noise: 2000 authors mention me in kind-1 notes, so
// (p:me) spans multiple kinds like a real inbox does.
repeat(40_000 * SCALE) {
val author = hex64(3, it % 2_000)
val ts = base + (mix(it * 17L) and 0x7fffffff) % span
events.add(ev(author, ts, 1, arrayOf(arrayOf("p", me))))
}
// Reactions: 100k kind-7 events spread over 5000 target notes, for
// the large-IN watcher shape.
val noteIds = (0 until 5_000).map { hex64(5, it) }
repeat(100_000 * SCALE) {
val author = hex64(4, it % 3_000)
val ts = base + (mix(it * 29L) and 0x7fffffff) % span
events.add(ev(author, ts, 7, arrayOf(arrayOf("e", noteIds[it % noteIds.size]))))
}
return events
}
@Test
fun compareTagAuthorIndex() =
runBlocking {
val events = seedEvents()
val me = hex64(9, 0)
val peers = (0 until 200).map { hex64(2, it) }
val noteIds = (0 until 5_000).map { hex64(5, it) }
println("─ TagAuthorIndexBenchmark: ${events.size} events (scale=$SCALE) ─")
val strategies =
listOf(
"flag-off" to DefaultIndexingStrategy(indexFullTextSearch = false),
"flag-on " to DefaultIndexingStrategy(indexFullTextSearch = false, indexTagsWithKindAndPubkey = true),
)
for ((label, strategy) in strategies) {
val store = EventStore(dbName = null, indexStrategy = strategy)
val t0 = System.nanoTime()
events.chunked(10_000).forEach { store.batchInsert(it) }
val insertMs = (System.nanoTime() - t0) / 1e6
println("$label ═ insert: %.0f ms (%.1f µs/event)".format(insertMs, insertMs * 1000 / events.size))
// 1. DM room: one peer's DMs out of the whole (p:me) inbox.
val room = Filter(kinds = listOf(4), authors = listOf(peers[42]), tags = mapOf("p" to listOf(me)), limit = 100)
time(store, "dm-room (#p ∩ author ∩ kind, limit 100)", room)
// 2. Reactions watcher: 300 note ids, cold (no since).
val watcher = Filter(kinds = listOf(7), tags = mapOf("e" to noteIds.take(300)), limit = 500)
time(store, "reactions (#e IN 300, limit 500, cold)", watcher)
// 3. Same watcher, EOSE-warm (since bounds the window).
val warm = watcher.copy(since = 1_700_000_000L + 2_900_000L)
time(store, "reactions (#e IN 300, limit 500, since)", warm)
store.close()
}
}
private suspend fun time(
store: EventStore,
label: String,
filter: Filter,
) {
repeat(3) { store.query<Event>(filter) }
val runs = 10
var rows = 0
val start = System.nanoTime()
repeat(runs) { rows = store.query<Event>(filter).size }
val ms = (System.nanoTime() - start) / 1e6 / runs
println(" %-42s %8.2f ms (%d rows)".format(label, ms, rows))
}
}

View File

@@ -0,0 +1,159 @@
/*
* 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.fs
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.utils.EventFactory
import kotlinx.coroutines.runBlocking
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.exists
import kotlin.test.Test
/**
* Guards [FsQueryPlanner]'s cost-based driver pick on the
* `authors + kinds + limit` shape — the most common CLI query (27
* assembler call sites; every `amy feed`-style author timeline over
* non-replaceable kinds).
*
* Under the pre-pick fixed order (tags → kinds → authors),
* `Filter(authors=[pk], kinds=[1], limit=n)` drove from `idx/kind/1/`
* (the biggest tree in any real store) and post-filtered the author:
* 149 ms at 30k events. The lockstep pick drives from the author tree
* and runs at ~4 ms. The benchmark times:
*
* - **planner (cost-based pick)**: the filter as the planner runs it —
* should sit near the floor, far below a kind-tree walk.
* - **author-driver emulation**: the author tree walked via an
* authors-only query with the kind check applied by the caller — the
* reference the pick is expected to match or beat.
* - **author-only floor**: `authors + limit` with no kind, the cheapest
* possible walk of the same tree.
*
* Size the seed with `-DfsBenchScale=N` (default 1 ≈ ~30k events; each
* event is a file + ~3 hardlinks, so seeding dominates wall time).
*/
class FsDriverSelectionBenchmark {
companion object {
val SCALE = System.getProperty("fsBenchScale")?.toInt() ?: 1
}
private val hex = "0123456789abcdef"
private fun mix(seed: Long): Long {
var z = seed + -0x61c8864680b583ebL
z = (z xor (z ushr 30)) * -0x40a7b892e31b1a47L
z = (z xor (z ushr 27)) * -0x6b2fb644ecceee15L
return z xor (z ushr 31)
}
private fun hex64(
salt: Long,
index: Int,
): String {
val out = CharArray(64)
for (w in 0 until 4) {
val v = mix(salt * 1_000_003 + index.toLong() * 4 + w)
for (b in 0 until 8) {
val byte = ((v ushr (b * 8)) and 0xFF).toInt()
out[(w * 8 + b) * 2] = hex[byte ushr 4]
out[(w * 8 + b) * 2 + 1] = hex[byte and 0xF]
}
}
return String(out)
}
private val sig = "0".repeat(128)
private var idSeq = 0
private fun ev(
pubkey: String,
createdAt: Long,
kind: Int,
): Event = EventFactory.create(hex64(7, idSeq++), pubkey, createdAt, kind, emptyArray(), "", sig)
@Test
fun compareDrivers() =
runBlocking {
val root: Path = Files.createTempDirectory("fs-driver-bench-")
val store = FsEventStore(root)
try {
val base = 1_700_000_000L
val span = 3_000_000L
val target = hex64(9, 0)
// Background: 300 authors × 100 kind-1 notes.
val bg = ArrayList<Event>(30_000 * SCALE + 300)
repeat(30_000 * SCALE) {
val author = hex64(1, it % 300)
bg.add(ev(author, base + (mix(it * 31L) and 0x7fffffff) % span, 1))
}
// Target author: 200 kind-1 notes + 50 kind-7 reactions.
repeat(200) { bg.add(ev(target, base + (mix(it * 131L) and 0x7fffffff) % span, 1)) }
repeat(50) { bg.add(ev(target, base + (mix(it * 61L) and 0x7fffffff) % span, 7)) }
val t0 = System.nanoTime()
store.transaction { bg.forEach { insert(it) } }
val insertMs = (System.nanoTime() - t0) / 1e6
println("─ FsDriverSelectionBenchmark: ${bg.size} events (scale=$SCALE), seed %.0f ms ─".format(insertMs))
// The planner's own pick — expected to choose the author
// tree over the ~30k-entry kind-1 tree.
val kindDriven = Filter(authors = listOf(target), kinds = listOf(1), limit = 50)
time(store, "planner (cost-based pick)") { store.query<Event>(kindDriven).size }
// Reference: author tree walked explicitly, kind checked
// by the caller — the pick should match or beat this.
time(store, "author-driver emulation") {
store
.query<Event>(Filter(authors = listOf(target), limit = 250))
.asSequence()
.filter { it.kind == 1 }
.take(50)
.count()
}
// Floor: author-only shape, the cheapest walk of the tree.
val authorOnly = Filter(authors = listOf(target), limit = 50)
time(store, "author-only floor") { store.query<Event>(authorOnly).size }
} finally {
store.close()
if (root.exists()) {
Files.walk(root).use { it.sorted(Comparator.reverseOrder()).forEach { p -> Files.deleteIfExists(p) } }
}
}
}
private inline fun time(
store: FsEventStore,
label: String,
run: () -> Int,
) {
repeat(3) { run() }
val runs = 10
var rows = 0
val start = System.nanoTime()
repeat(runs) { rows = run() }
val ms = (System.nanoTime() - start) / 1e6 / runs
println(" %-32s %8.2f ms (%d rows)".format(label, ms, rows))
}
}

View File

@@ -0,0 +1,132 @@
/*
* 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.sqlite
import androidx.sqlite.SQLiteConnection
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.utils.Secp256k1Instance
import kotlinx.coroutines.runBlocking
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.deleteIfExists
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Verifies the real v4 → v5 FTS upgrade path: a database written with the old
* `fts5(event_header_row_id, content)` index (auto-assigned rowid, content
* stored) must, on next open, drop that table and rebuild a contentless index
* keyed by `event_headers.row_id`, with search intact.
*
* The v4 state is fabricated by opening a fresh v5 store, then rewriting its
* `event_fts` to the old schema (with deliberately wrong content, to prove the
* rebuild wipes it) and stamping `user_version = 4`.
*/
class ContentlessFtsMigrationTest {
private val signer = NostrSignerSync()
private lateinit var dbFile: Path
private fun path() = dbFile.toAbsolutePath().toString()
@BeforeTest
fun setup() {
Secp256k1Instance
dbFile = Files.createTempFile("contentless-fts-migration-", ".db")
Files.deleteIfExists(dbFile)
}
@AfterTest
fun tearDown() {
listOf("", "-wal", "-shm", "-journal").forEach { Path.of(dbFile.toString() + it).deleteIfExists() }
}
@Test
fun upgradesOldFtsSchemaAndRebuildsSearch() =
runBlocking {
val alpha = signer.sign(TextNoteEvent.build("uniqalpha searchable body", createdAt = 1_700_000_000L))
val beta = signer.sign(TextNoteEvent.build("uniqbeta searchable body", createdAt = 1_700_000_100L))
// 1. Fresh v5 store, seed events, close.
EventStore(dbName = path(), relay = null).also {
it.insert(alpha)
it.insert(beta)
it.close()
}
// 2. Rewrite event_fts to the pre-v5 schema with WRONG content and
// downgrade user_version to 4 — the state a v4 database is in.
BundledSQLiteDriver().open(path()).use { db ->
db.exec("DROP TRIGGER IF EXISTS fts_foreign_key")
db.exec("DROP TABLE IF EXISTS event_fts")
db.exec("CREATE VIRTUAL TABLE event_fts USING fts5(event_header_row_id, content)")
db.exec(
"""
CREATE TRIGGER fts_foreign_key AFTER DELETE ON event_headers FOR EACH ROW
BEGIN DELETE FROM event_fts WHERE old.row_id = event_fts.event_header_row_id; END
""".trimIndent(),
)
// Stale/garbage rows: a real v4 index would hold correct data,
// but seeding garbage proves the migration rebuilds from
// event_headers rather than trusting the old table.
db.exec("INSERT INTO event_fts(event_header_row_id, content) VALUES (1, 'uniqstale garbage')")
db.exec("PRAGMA user_version = 4")
}
// 3. Reopen with current code → onUpgrade(4→5) → migrateV4ToContentless.
val store = EventStore(dbName = path(), relay = null)
try {
// Rebuilt from event_headers: real content is searchable...
assertEquals(alpha.id, store.query<Event>(Filter(search = "uniqalpha")).single().id)
assertEquals(beta.id, store.query<Event>(Filter(search = "uniqbeta")).single().id)
// ...and the old garbage is gone.
assertTrue(store.query<Event>(Filter(search = "uniqstale")).isEmpty())
// The new rowid IS event_headers.row_id: the join returns the
// right event for each FTS rowid.
store.store.pool.useReader { db ->
db
.prepare(
"SELECT h.id FROM event_fts f JOIN event_headers h ON h.row_id = f.rowid ORDER BY f.rowid",
).use { stmt ->
val ids = ArrayList<String>()
while (stmt.step()) ids.add(stmt.getText(0))
assertEquals(listOf(alpha.id, beta.id), ids, "FTS rowid must map to event_headers.row_id")
}
}
// The rebuilt delete trigger still cleans up FTS on delete.
store.store.delete(beta.id)
assertTrue(store.query<Event>(Filter(search = "uniqbeta")).isEmpty())
assertEquals(alpha.id, store.query<Event>(Filter(search = "uniqalpha")).single().id)
} finally {
store.close()
}
}
private fun SQLiteConnection.exec(sql: String) = prepare(sql).use { it.step() }
}

View File

@@ -0,0 +1,123 @@
/*
* 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.sqlite
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* Pins the FTS5 features the [FullTextSearchModule] contentless index depends
* on to the bundled SQLite. All three shipped between SQLite 3.43 and 3.44
* (2023); if the bundled driver is ever downgraded below that, this fails
* loudly instead of the store silently breaking search on delete.
*
* - `content=''` **contentless** table with `contentless_delete=1`: lets the
* index drop the duplicated content column yet still delete rows by rowid
* (the `fts_foreign_key` trigger needs it).
* - explicit `rowid` on insert (= `event_headers.row_id`): the join key and
* the O(log n) delete key.
* - bm25 `rank` on a contentless table, through a join: NIP-50 relevance order.
* - `'merge'` / `'optimize'` maintenance commands: segment compaction.
*/
class Fts5CapabilityProbe {
@Test
fun contentlessDeleteAndRowidOrderingAreSupported() {
val db = BundledSQLiteDriver().open(":memory:")
try {
db.execSQL("CREATE VIRTUAL TABLE cl USING fts5(content, content='', contentless_delete=1)")
db.execSQL("INSERT INTO cl(rowid, content) VALUES (100, 'hello world')")
db.execSQL("INSERT INTO cl(rowid, content) VALUES (50, 'hello there')")
db.execSQL("INSERT INTO cl(rowid, content) VALUES (200, 'hello again')")
db.execSQL("DELETE FROM cl WHERE rowid = 50")
// Deleting an absent rowid must be a harmless no-op: the store's
// fts_foreign_key trigger fires on EVERY event_headers delete, but
// only searchable events ever got an FTS row.
db.execSQL("DELETE FROM cl WHERE rowid = 999999")
val order = ArrayList<Long>()
db.prepare("SELECT rowid FROM cl WHERE cl MATCH 'hello' ORDER BY rowid DESC LIMIT 5").use {
while (it.step()) order.add(it.getLong(0))
}
// Deleted 50 is gone; the rest come back newest-rowid first.
assertEquals(listOf(200L, 100L), order)
} finally {
db.close()
}
}
@Test
fun bm25RankWorksOnContentlessTableInAJoin() {
// NIP-50 orders by relevance, not created_at. Verify FTS5 bm25 `rank`
// works on a contentless table and is reachable through the same
// join-back-to-base-table shape the store's search query uses.
val db = BundledSQLiteDriver().open(":memory:")
try {
db.execSQL("CREATE TABLE headers (row_id INTEGER PRIMARY KEY, created_at INTEGER, tag TEXT)")
db.execSQL("CREATE VIRTUAL TABLE fts USING fts5(content, content='', contentless_delete=1)")
// row 10: term appears 3× in a short doc (most relevant).
// row 20: term once in a long doc (least relevant) but NEWER.
db.execSQL("INSERT INTO headers VALUES (10, 100, 'A')")
db.execSQL("INSERT INTO fts(rowid, content) VALUES (10, 'needle needle needle')")
db.execSQL("INSERT INTO headers VALUES (20, 999, 'B')")
db.execSQL("INSERT INTO fts(rowid, content) VALUES (20, 'needle alpha beta gamma delta epsilon zeta eta')")
// created_at DESC would return B (999) first; relevance returns A.
val byRank = ArrayList<String>()
db
.prepare(
"""
SELECT headers.tag FROM headers
INNER JOIN fts ON headers.row_id = fts.rowid
WHERE fts MATCH 'needle'
ORDER BY fts.rank
LIMIT 10
""".trimIndent(),
).use { while (it.step()) byRank.add(it.getText(0)) }
assertEquals(listOf("A", "B"), byRank, "bm25 rank must put the more relevant (shorter, higher-tf) doc first")
} finally {
db.close()
}
}
@Test
fun segmentMergeAndOptimizeAreSupported() {
val db = BundledSQLiteDriver().open(":memory:")
try {
db.execSQL("CREATE VIRTUAL TABLE m USING fts5(content)")
db.execSQL("INSERT INTO m(rowid, content) VALUES (1, 'a b c')")
db.execSQL("INSERT INTO m(rowid, content) VALUES (2, 'd e f')")
// Bounded incremental merge, then a full optimize — both must parse
// and run without error on the bundled build.
db.execSQL("INSERT INTO m(m, rank) VALUES ('merge', -16)")
db.execSQL("INSERT INTO m(m) VALUES ('optimize')")
val hits = ArrayList<Long>()
db.prepare("SELECT rowid FROM m WHERE m MATCH 'e' ORDER BY rowid").use {
while (it.step()) hits.add(it.getLong(0))
}
assertEquals(listOf(2L), hits)
} finally {
db.close()
}
}
}

View File

@@ -70,11 +70,11 @@ object Scenarios {
} }
val topAuthors = notesByAuthor.entries.sortedWith(compareByDescending<Map.Entry<String, Int>> { it.value }.thenBy { it.key }).map { it.key } val topAuthors = notesByAuthor.entries.sortedWith(compareByDescending<Map.Entry<String, Int>> { it.value }.thenBy { it.key }).map { it.key }
val hottestThread = val hotNotes =
eTagRefs.entries eTagRefs.entries
.sortedWith(compareByDescending<Map.Entry<String, Int>> { it.value }.thenBy { it.key }) .sortedWith(compareByDescending<Map.Entry<String, Int>> { it.value }.thenBy { it.key })
.firstOrNull() .map { it.key }
?.key val hottestThread = hotNotes.firstOrNull()
val mostMentioned = val mostMentioned =
pTagRefs.entries pTagRefs.entries
.sortedWith(compareByDescending<Map.Entry<String, Int>> { it.value }.thenBy { it.key }) .sortedWith(compareByDescending<Map.Entry<String, Int>> { it.value }.thenBy { it.key })
@@ -86,6 +86,23 @@ object Scenarios {
.firstOrNull() .firstOrNull()
?.key ?.key
// The author that most often tags the most-mentioned pubkey — a
// conversation pair for the tag ∩ author (DM-room) query shape.
val conversationPeer =
mostMentioned?.let { me ->
val byAuthor = HashMap<String, Int>()
for (e in events) {
if (e.kind != 1 || e.pubKey == me) continue
if (e.tags.any { it.size >= 2 && it[0] == "p" && it[1] == me }) {
byAuthor.merge(e.pubKey, 1, Int::plus)
}
}
byAuthor.entries
.sortedWith(compareByDescending<Map.Entry<String, Int>> { it.value }.thenBy { it.key })
.firstOrNull()
?.key
}
// Evenly spread sample of note ids — a "fetch these 100 events" batch. // Evenly spread sample of note ids — a "fetch these 100 events" batch.
val idSample = val idSample =
if (noteIds.size <= 100) { if (noteIds.size <= 100) {
@@ -159,6 +176,33 @@ object Scenarios {
), ),
) )
} }
if (mostMentioned != null && conversationPeer != null) {
// The tag ∩ author ∩ kind shape (65 client assembler call
// sites: NIP-04 DM rooms, reports-by-follows, follows-scoped
// community feeds). Modeled on kind 1 because public corpora
// carry no DMs; the index path exercised is identical.
add(
Scenario(
"conversation",
"notes by one author tagging the most-mentioned pubkey (DM-room shape)",
Filter(kinds = listOf(1), authors = listOf(conversationPeer), tags = mapOf("p" to listOf(mostMentioned)), limit = 500),
),
)
}
if (hotNotes.size > 1) {
// Large-IN tag watcher: per-value streams come sorted off the
// tag index but their union does not, exposing whether the
// store collects+sorts or merges. 150 values stays inside
// strfry's default 200-element filter cap.
val watched = hotNotes.take(150)
add(
Scenario(
"reactions-watch",
"reactions on the ${watched.size} hottest notes (visible-feed reaction watcher)",
Filter(kinds = listOf(7), tags = mapOf("e" to watched), limit = 500),
),
)
}
topHashtag?.let { topHashtag?.let {
add( add(
Scenario( Scenario(