Wires the receiving side of NIP-XX BOLT12 zaps (kind 9736) into every place a
NIP-57 lightning zap is counted or shown. Sending is intentionally left for
later. Modeled on the lightning-zap scheme (synchronous, the proof carries the
amount, counted the moment it validates) rather than the onchain scheme (async
chain backend, PENDING/CONFIRMED, CONFIRMED-only) — BOLT12 proof verification is
a self-contained synchronous check, so no resolver/backend is needed.
Model (commons):
- Bolt12ZapEntry + Note.bolt12Zaps map keyed by the proof's invoice_payment_hash
(the spec dedup key); addBolt12Zap/removeBolt12ZapBySource; folded into
updateZapTotal (millisats → sats) alongside lightning/onchain/nutzap amounts;
wired into clearChildLinks, moveAllReferencesTo, removeNote,
hasZapsBoostsOrReactions, hasZapped, and the isZappedBy family.
Ingestion (LocalCache):
- consume(Bolt12ZapEvent): validate synchronously via Bolt12ZapValidator, then
addBolt12Zap on the resolved targets (e / a / profile); computeReplyTo and
live-activity channel routing branches; dispatch case.
Subscriptions: added kind 9736 to every filter carrying LnZapEvent.KIND
(notifications, replies/reactions to notes & addresses, profile received-zaps,
live-activity goal + messages, nest room + collectors, notification dispatcher,
shared NotificationKinds, app-functions).
Aggregation / notifications: UserProfileZapsViewModel (mapper), NotificationSummaryState
(both passes), NotificationFeedFilter (kinds, zap-receipt detection, payer author
resolution, muted-thread + own-event gates), NotificationKinds own-event exception,
ThreadAssembler.anchorsItsOwnThread, and the commons live-activity aggregators
(RoomZapsState, LiveStreamTopZappers, NestViewModel).
UI: RenderBolt12Zap standalone card (styled like the lightning card, labeled
BOLT12) wired into NoteCompose + ThreadFeedView; Bolt12ZapGallery in the
reactions row (payer avatars + amounts, unverified/compressed proofs dimmed);
reaction-row counter gate; KindNames / KindDisplayName entries.
Note: validated BOLT12 zaps are counted immediately; a compressed proof whose
signatures aren't yet verifiable (pending lightning/bolts#1346 merkle
reconstruction) is stored with cryptoVerified=false and dimmed in the gallery.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
Implements the quartz-side of the proposed "BOLT12 Zaps" NIP
(nostr-protocol/nips#2421): public, self-verifying zap events that prove a
BOLT12 payment without an LNURL server or recipient-operated receipt publisher.
Events (nip-88 style templates/tags/builders):
- Bolt12ZapEvent (kind 9736) and Bolt12ZapIntentEvent (kind 9737)
- shared tags: amount (msats), offer, proof, P (payer), zap_id, description
- registered both kinds in EventFactory
BOLT12 decoding (new, no existing KMP library):
- Bolt12Bech32: canonicalization (+ continuation / whitespace) + no-checksum,
no-length-limit bech32 for lno1 offers and lnp1 payer proofs
- Tlv: BigSize codec, TLV stream reader/writer, tu64 helpers
- Bolt12Offer / Bolt12PayerProof parsers (proof TLV types per lightning/bolts#1346)
- Bolt12Merkle: BOLT12 tagged-hash + signature merkle root + signature digest
Validation:
- Bolt12ZapValidator runs the NIP's steps (structure, embedded-intent match,
payer-proof binding: invreq_payer_note == nostr:nipXX:<intent-id>,
invoice_amount == amount) and returns a typed result with the payment-hash
dedup key
- Bolt12ProofVerifier checks preimage->payment_hash and the invoice/proof
BIP-340 signatures for fully-disclosed proofs; compressed proofs are reported
as unverified pending the (still-draft) lightning/bolts#1346 test vectors
- Bolt12ZapBuilder assembles+signs the intent and the final zap
Tests: 24 commonTest cases covering bech32/TLV/merkle round-trips, event tag
structure + factory typing, and validator accept/reject paths (self-signed
BOLT12 fixtures exercise the full merkle + schnorr path).
Note: this is the receive/verify + assembly layer only. Origination is blocked
on the upstream BOLT12 payer-proof spec merging and a wallet/NWC rail exposing
lnp proofs; no Amethyst payment rail returns one today.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
The home feed always emitted a live-bubbles item followed by a 5dp
Spacer, even when the live section (live streams, geohash/ephemeral
chats) was empty — which is the common case. With no bubbles to draw,
that Spacer reserved dead vertical space at the very top of the list,
stacking on top of the LazyColumn's FeedPadding and the first note's own
top padding. The result was an oversized gap between the top-bar divider
and the first author's picture, most noticeable when the New Threads /
Replies tab row is hidden (single tab) so the divider sits right under
the top bar.
Move the row and its trailing Spacer inside a non-empty check so they
only render when there are actual bubbles. When the live section is
empty the item now collapses to zero height, bringing the home feed's
top gap in line with every other feed screen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTFiQykXfCShHUCA3yM2pN
Give the donation card a bolder, more exciting look:
- Add a gradient hero header (bitcoin orange → amethyst purple) with a
glowing circular bolt badge and a larger, white title
- Round the card corners (16dp) and add elevation for a modern, floating feel
- Overlay the close button on the hero and keep the description, version
credit, zap splits and donate button in a clean body section
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019bM2CKE5Mnao4FG85HPuAV
Bring four settings screens onto the SettingsSection card kit used by the
UI Preferences / Security Filters screens, and drop the last Save/Cancel
flow in favor of auto-save + back-button-only navigation.
- Privacy Options: remove SavingTopBar + TorDialogViewModel staging; bind
controls directly to TorSettingsFlow.tryEmit (the existing debounced
propertyWatchFlow already persists changes), so edits save on change and
the screen has only a back arrow. Rebuild the body from SettingsSection
cards: a segmented Tor-engine tile, an Orbot-port block, a live preset
picker row, and per-usage switch tiles.
- Profile UI & Home Tabs: reskin the ad-hoc rows into SettingsSection
cards with SettingsSwitchTile / SegmentedChoiceTile (Home Tabs keeps the
"can't disable the last tab" guard via the enabled flag).
- Calendar Reminders: swap the hand-rolled TopAppBar for
TopBarWithBackButton and reskin into a SettingsSection card with a
segmented lead-time selector, preserving the CalendarReminderPrefs writes
and WorkManager schedule/cancel side-effects.
- Shared kit: make SegmentedChoiceTile internal for reuse and add a compact
title-only SettingsSwitchTile overload with enabled support.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013JvrwLYbGVJQhabfZS7sBy
A Concord community pinned to the bottom bar wouldn't load at all when its
private kind-13302 joined-communities list wasn't already cached: the tab and
its server screen stayed blank. The list often lives only on the community's
own relays (Armada/Vector publish it there, never to the user's outbox), and
the only fetch that looked beyond the outbox — importConcordCommunities — was
triggered solely from the Concord hub and never queried the community's relays.
Carry each pinned community's bootstrap relays on its BottomBarEntry.Concord
tab (captured from the joined-list entry at pin time) and:
- importConcordCommunities now takes extra relays and folds in the relays saved
on every pinned Concord tab, so the list is found where it actually lives;
- ConcordChannelPreload bootstraps app-wide: it fetches the list for any pinned
community we don't yet know, so the tab and server screen fill in without the
user ever opening the hub.
Once the list folds into the cache, ConcordChannelListState.liveCommunities
already surfaces it reactively (verified by a new late-arrival test) and the
plane preload picks the community up — so a late-arriving list with no local
backup now updates the tab and the Concord Channels screen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSiSXaHMEpuo3gcDuTZ24u
The NIP-89 client tag says "this app composed this event", so it belongs only
on templates Amethyst authored. NostrSignerWithClientTag was applied at the
account level, which meant it also fired on every event we sign on behalf of
an external client.
That is wrong twice over. It misattributes the event, and — because the tag is
appended before signing — it rewrites the exact bytes the caller is about to
have hashed into an id. NIP-07 callers routinely re-check the returned event
against the template they submitted, and block/buzz compares tags outright
(web/src/shared/lib/nostr-signer.ts):
JSON.stringify(actual.tags) === JSON.stringify(expected.tags)
so joining a Buzz community from the in-app browser failed with "The NIP-07
extension returned an invalid signed event". Probed live over the WebView
devtools protocol: kind, created_at, content and pubkey all round-tripped
intact and only tags differed, by exactly the ["client","Amethyst"] we append.
Add NostrSigner.withoutClientTag() and use it at the two boundaries where the
template belongs to someone else:
- the napplet broker, covering napplets, nSites and web apps over NIP-07
- Nip46SignerState, where we act as another client's bunker — the same defect,
and quieter, since that client never learns why its event changed underneath
it
Amethyst's own events are untouched and still carry the tag. Unwrapping keeps
everything layered below (metering, NIP-13 mining) and leaves pubKey alone.
There is no NIP-55 provider surface to fix; we are only ever the client there.
Verified against Buzz's own four acceptance conditions after the change:
pubkey matches, sameUnsignedEvent true, id and sig present — and the invite
join then succeeded.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
asadmansr/android-test-report-action@v1.2.0 is a Docker action whose
Dockerfile is `FROM ubuntu:18.04` + `apt-get install python` (Python 2).
Ubuntu 18.04 (bionic) is end-of-life, so its apt archives are now
unreliable and Python 2 has no installation candidate — the image rebuild
runs on every CI invocation, takes ~8 minutes, and has started hard-failing
the test-and-build-android job (`E: Package 'python' has no installation
candidate`). The action was last released in 2020 and is unmaintained, and
it was referenced by a movable tag rather than a pinned commit.
Swap it for mikepenz/action-junit-report (actively maintained, Apache-2.0,
JS action — no Docker rebuild), pinned to the v6.4.2 commit SHA. Use
annotate_only so it needs no `checks: write` permission and keeps working on
pull requests from forks; fail_on_failure keeps the job red when a unit test
fails, matching the previous step's behavior.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HMtpSuyr8FBh2ZQ22BV4ZP
Lower the quartz module's Kotlin jvmTarget for both the JVM and Android
compilations from JVM_21 to JVM_17, broadening the range of runtimes that
can consume the published library.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1Hn61gQJ1joUznESzUHU4
A live stream opened for the first time drew ~90px of black above and below
the picture. The video surface itself was correct 16:9; the box enclosing it
was not. Measured on a Pixel 9 emulator: container [0,274][1080,1062] (788px
= StreamingHeaderModifier's 300.dp cap) holding a TextureView of
[0,364][1080,972] (608px = 16:9 at 1080 wide), centred, so (788-608)/2 = 90px
per side.
Two independent causes, both needed fixing:
ContentWarningGate takes a `modifier` but drops it for anything not flagged
sensitive — the non-sensitive path emits `content()` bare. ZoomableContentView
was routing mediaSizingModifier() through exactly that parameter, so for
ordinary media the sizing never reached the layout at all. With no height
constraint the player stretched to whatever ceiling enclosed it and
letterboxed the frame inside. Apply the sizing to the inner Box, which is
always emitted.
Even applied, the ratio was unknown on a first play: a NIP-53 stream carries
no imeta `dim`, and MediaAspectRatioCache is only filled once the decoder
reports a size. The miss was frozen for the whole visit because the cache was
a plain LruCache read during composition, which triggers no recomposition when
it later fills — hence the bars vanishing only on a *second* visit to the same
stream. Back cache entries with snapshot state so a composition-time read
updates, and default an unknown video to 16:9 so the first layout already
lands in the right place.
VideoView keeps reading the cache inside remember() on purpose, with a comment
explaining why: making it observable there flips the ratio mid-playback, which
both adds an aspectRatio and emits an extra Spacer, and restructuring children
around a live AndroidView strands the player on a stale surface — the video
redraws at native size in the corner while layout bounds still look correct.
Verified on a cold cache: container and TextureView are both
[0,274][1080,882], against a header ending at 274 — zero gap. Feed image and
video layouts unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MediaCodec instances are a per-process resource with a hard per-device
ceiling — the Android emulator's c2.goldfish.h264.decoder declares
`concurrent-instances max="4"`. Past that, MediaCodec.start() fails with
NO_MEMORY, MediaCodecRenderer reports "Failed to initialize decoder", and
the video surfaces to the user as "can't load". Opening a live stream after
scrolling a few feed videos reproduced this reliably; killing the process
made the same stream play, since that released every held codec.
The device ceiling was already computed by SimultaneousPlaybackCalculator,
but only reached ExoPlayerPool as `poolSize`, which governs how many idle
players are *retained*. The acquire path was uncapped
(`coldPool.poll() ?: builder.build(context)`), and MediaSessionPool held a
hardcoded LruCache(10) of sessions, each pinning a checked-out player. So a
4-decoder device would happily hold 10.
Enforce the budget where players are handed out:
- Track live decoders process-wide, counting checked-out and warm players
(cold ones have been stop()'d and hold none). The counter and the pool
registry are global because PlaybackService builds one pool for direct
traffic and another for Tor-proxied traffic; a per-pool budget let the
app hold twice the ceiling.
- Before a cold or fresh player is handed out, reclaim headroom by demoting
warm players to cold — own pool first, then siblings. Warm entries are a
scroll-back cache, so they are the right thing to give up under pressure.
- Size the session cache from the same device budget, keeping the previous
10 as an upper bound so capable devices are unaffected.
Verified on the emulator: 9 codec allocations across a session with zero
NO_MEMORY and zero decoder-init failures, where allocation #5 previously
died.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- private monitor, and correct the commit() KDoc
- replace atomics handshake with a single monitor
- flag userFinder re-entrancy assumption in commit() KDoc
`activeSubscriptions` was a plain LinkedHashSet iterated (`SetsKt.minus`) and
mutated (`clear`/`addAll`) with no synchronization, while `invalidateFilters()`
ran synchronously on whatever thread called subscribe/unsubscribe.
Those callers are genuinely concurrent: `ComposeSubscriptionManager` invokes
`invalidateKeys()` after releasing its own lock, and
`LifecycleAwareSubscription`'s 30s grace-period unsubscribe fires on a
`Dispatchers.Default` worker while composition subscribes from elsewhere.
Hence the reported `ConcurrentModificationException` on
`DefaultDispatcher-worker-70`.
This is the only member of `EventFinderFilterAssembler.group` that implements
`IEoseManager` directly; its two siblings extend `BaseEoseManager`, whose
`invalidateFilters` hands off to `BundledUpdate` and is therefore never run on
the caller's thread nor concurrently with itself.
Fix, matching that existing pattern and avoiding locks:
- Route through `BundledUpdate`. `BasicBundledUpdate` holds `isProcessing`
under a Mutex, so only one body runs at a time — the concurrent
iterate-vs-mutate window is gone by construction. It also moves the
`allKeys()` scan and per-stub `getOrCreateUser` off the caller thread, which
`ComposeSubscriptionManager` documents as "called by main. Keep it really
fast."
- Hold the state in an `AtomicReference<Set<...>>` of immutable snapshots
swapped with `exchange()`, plus an `AtomicBoolean` teardown flag, mirroring
the `AtomicReference` + CAS idiom in `FilterIndex`/`BanStore`.
- `bundler.cancel()` cannot stop a body already executing (no suspension
points), so `destroy()` flags first and an in-flight body compensates by
releasing what it just acquired. Double-unsubscribe is a no-op.
No locks are introduced; the hot path is strictly cheaper than before.
Tested: the new concurrency test reproduces the exact production failure
against the pre-fix code (`ConcurrentModificationException` alongside the
overlap detector) and passes after. Full :amethyst suite green (941 tests).
Note the pre-existing `UserFinderQueryState` identity-equality churn is
deliberately NOT addressed here: the set-diff never converges because each run
allocates fresh wrappers. It widens this race window but is an independent
defect needing its own design decision.
Adds full NIP-88 poll support to Amethyst Desktop and a search content-type
filter for polls.
Polls (DesktopPollCard):
- Render kind-1068 polls in feed + thread (and reposted/boosted polls) as an
interactive card via NoteCard's bottomContent slot.
- Vote (single-choice radio / multi-choice checkbox), re-vote ("Change vote")
seeded with the prior selection; hide-until-voted with a "View results" opt-in.
- Tallies reuse commons PollResponsesCache; responses are fetched from the
poll's OWN declared relays (NIP-88 relay tags) unioned with connected relays,
so the full tally loads regardless of the viewer's relay set. Votes are
likewise published to the poll's relays (not just broadcastToAll).
- Result row marks the viewer's own choice (border + check), tap a row to see
its voters, footer shows distinct-voter count + deadline/ended state, and the
voter gallery draws the viewer front-most with a ring.
- Create polls from the composer (options, single/multi, optional deadline);
the dialog content scrolls with a pinned Cancel/Publish row; a poll requires
a question and >=2 options.
Wiring:
- DesktopLocalCache.consume for kind 1068/1018 (response links into pollState).
- DesktopFeedFilters + FilterBuilders surface polls; feed/thread interaction
subscriptions fetch kind-1018 responses.
- Thread + profile pass myPubKeyHex so the viewer's vote-state renders.
Search "Polls" facet:
- KindRegistry preset + alias for kind 1068 (auto-renders the filter chip and a
NIP-50 kind filter); SearchResultsList renders poll results interactively and
SearchScreen fetches their responses.
Also:
- Read-only accounts see results instead of dead vote controls.
- Cold-start: the response subscription re-evaluates as relays connect.
- Pull the upstream fix for the pre-existing RelayLatencyTracker.sweep
ConcurrentModificationException (synchronized(pending)) so relay-health
reclassify no longer crashes the UI during search.
Ripple/shaping: clickable elements clip to their shape for bounded ripple.
Tests: commons PollResponsesCache (dedup/tally/WoT sort) + DesktopLocalCache
response-linking.
Deferred (noted in review): wall-clock re-check of a poll expiring mid-view;
mention-dropdown now inside the composer scroll.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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
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
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
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
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
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
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>
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
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