From d0fe6dcd0b1d93fc72d5b7b5d7306474728214cb Mon Sep 17 00:00:00 2001 From: Nogringo Date: Fri, 7 Aug 2026 16:25:20 +0200 Subject: [PATCH 1/3] docs: add ADR for local-first reads Defines NdkDataResponse and NdkValue as the return format for reads that render from cache first and refine when relays answer, with the private relay list (NIP-37 kind 10013) as first use. --- .../ADRs/local-first-reads.md | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 doc/library-development/ADRs/local-first-reads.md diff --git a/doc/library-development/ADRs/local-first-reads.md b/doc/library-development/ADRs/local-first-reads.md new file mode 100644 index 000000000..d9e8af822 --- /dev/null +++ b/doc/library-development/ADRs/local-first-reads.md @@ -0,0 +1,85 @@ +# Architecture Decision Record: Local-first reads + +Title: Local-first reads - return format for reads that render without waiting on the network + +## status + +proposed + +Updated on 05-08-2026 + +## contributors + +- Main contributor(s): nogringo + +- Reviewer(s): frnandu, 1leo + +- Final decision made by: frnandu, 1leo, nogringo + +## Context and Problem Statement + +Reads must render immediately from what is known locally, and refine when relays answer. The +current reads do neither: `getSingleNip51List(kind, forceRefresh:)` returns the cache and never +refreshes it, or skips the cache and blocks on the network. + +A read must also never let one state degrade into another. "not known yet" and "cannot be read" +must not look like "does not exist", because that is what an app acts on to create the missing +data, and for a replaceable event that overwrites what was already there. + +## Main Proposal + +```dart +class NdkDataResponse { + /// Emits a `cache` value first, then every newer `relays` value as it + /// arrives, even when it holds the same value as the cache. + /// Closes after EOSE or timeout. + final Stream> stream; + + /// The relay-confirmed value, so the last emitted one. + Future get future; +} + +class NdkValue { + final T value; + final DataOrigin origin; +} + +enum DataOrigin { cache, relays } +``` + +A `cache` value is always emitted first, even when nothing is cached. + +`stream` is backed by a `BehaviorSubject`, so `stream` and `future` can both be consumed and a +listener attached late still receives the latest value. + +`NdkValue` may later carry `createdAt`, `receivedAt` and `hasPendingWrites`. Only metadata that +makes sense for every read belongs there, anything specific to one read belongs in `T`. + +When the value can be absent, `T` is nullable and the origin disambiguates `null`: + +| emission | meaning | +| --- | --- | +| `(value, cache)` | local value, not confirmed | +| `(null, cache)` | nothing local yet, still loading | +| `(value, relays)` | confirmed value | +| `(null, relays)` | confirmed: nothing exists | + +Two cases are reported as stream errors, never as `null`, since `(null, relays)` is what an +app acts on to create the missing data: + +- the value exists but cannot be read, for instance it cannot be decrypted +- no relay was reachable, so absence cannot be concluded + +## First use: private relay list (NIP-37 kind 10013) + +```dart +// on Lists, requires a logged in account, no pubkey parameter +NdkDataResponse?> getPrivateUserRelays({Duration timeout}); +``` + +An empty list and no list are different answers: + +| emission | meaning | +| --- | --- | +| `([], relays)` | the event exists and holds no relay | +| `(null, relays)` | no kind 10013 event exists | From dfc80728f60c7544963333523cd041a67769a015 Mon Sep 17 00:00:00 2001 From: Leo <58687994+1-leo@users.noreply.github.com> Date: Sat, 8 Aug 2026 14:08:33 +0200 Subject: [PATCH 2/3] doc: updatge local first reads ADR --- .../ADRs/local-first-reads.md | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/doc/library-development/ADRs/local-first-reads.md b/doc/library-development/ADRs/local-first-reads.md index d9e8af822..1e7de614e 100644 --- a/doc/library-development/ADRs/local-first-reads.md +++ b/doc/library-development/ADRs/local-first-reads.md @@ -31,7 +31,7 @@ data, and for a replaceable event that overwrites what was already there. ```dart class NdkDataResponse { /// Emits a `cache` value first, then every newer `relays` value as it - /// arrives, even when it holds the same value as the cache. + /// arrives, even when it holds the same value as the cache (TBD). /// Closes after EOSE or timeout. final Stream> stream; @@ -47,6 +47,8 @@ class NdkValue { enum DataOrigin { cache, relays } ``` +NOTE: Does not cover ID queries where cache response is sufficient TBD + A `cache` value is always emitted first, even when nothing is cached. `stream` is backed by a `BehaviorSubject`, so `stream` and `future` can both be consumed and a @@ -83,3 +85,26 @@ An empty list and no list are different answers: | --- | --- | | `([], relays)` | the event exists and holds no relay | | `(null, relays)` | no kind 10013 event exists | + + + +## Consequences +Is a braking change as the query api changes. +Could be mitigated with the help of https://github.com/flutter/flutter/blob/master/docs/contributing/Data-driven-Fixes.md (preferred) +or https://pub.dev/packages/codemod +Especially important for external projects depending on NDK + + + +## Alternative proposals + +Instead of +```dart +enum DataOrigin { cache, relays } +``` +use a `Metadata` obj allowing for more flexibility when adding more metadata in the future. +Depending on the use case, we could also include specialized metadata, e.g., for cache access counts or P2P transmission statistics. +Because richer metadata will be slower due to DB access, we should keep the default minimal. + + +## Final Notes From 82a2179a058269065c9dcb72cc16dc05fc68bf07 Mon Sep 17 00:00:00 2001 From: Nogringo Date: Mon, 17 Aug 2026 15:31:39 +0200 Subject: [PATCH 3/3] doc: resolve open contracts in local first reads ADR --- .../ADRs/local-first-reads.md | 63 +++++++++++++------ 1 file changed, 44 insertions(+), 19 deletions(-) diff --git a/doc/library-development/ADRs/local-first-reads.md b/doc/library-development/ADRs/local-first-reads.md index 1e7de614e..f733c236c 100644 --- a/doc/library-development/ADRs/local-first-reads.md +++ b/doc/library-development/ADRs/local-first-reads.md @@ -6,7 +6,7 @@ Title: Local-first reads - return format for reads that render without waiting o proposed -Updated on 05-08-2026 +Updated on 2026-08-17 ## contributors @@ -26,38 +26,38 @@ A read must also never let one state degrade into another. "not known yet" and " must not look like "does not exist", because that is what an app acts on to create the missing data, and for a replaceable event that overwrites what was already there. +## Scope + +This is the return format of typed high-level reads: one read, one value, refined until it is +relay-confirmed. It does not redefine `requestNostrEvent` and `NdkResponse`, and it is not a +lifecycle for long-lived subscriptions. + +Per-relay provenance stays out of it: which relays hold an event is a cache concern, already +answered by `CacheManager.addEventSource(s)` and `loadEventSources()`. Read-specific metadata +belongs in `T`, so a read that needs more than the origin declares a `T` that carries it. + ## Main Proposal ```dart class NdkDataResponse { /// Emits a `cache` value first, then every newer `relays` value as it - /// arrives, even when it holds the same value as the cache (TBD). - /// Closes after EOSE or timeout. + /// arrives. Closes after EOSE or timeout. final Stream> stream; - /// The relay-confirmed value, so the last emitted one. - Future get future; + /// The last emitted value, relay-confirmed unless the read concludes on cache. + Future> get future; } class NdkValue { - final T value; + final T? value; final DataOrigin origin; } enum DataOrigin { cache, relays } ``` -NOTE: Does not cover ID queries where cache response is sufficient TBD - -A `cache` value is always emitted first, even when nothing is cached. - -`stream` is backed by a `BehaviorSubject`, so `stream` and `future` can both be consumed and a -listener attached late still receives the latest value. - -`NdkValue` may later carry `createdAt`, `receivedAt` and `hasPendingWrites`. Only metadata that -makes sense for every read belongs there, anything specific to one read belongs in `T`. - -When the value can be absent, `T` is nullable and the origin disambiguates `null`: +A `cache` value is always emitted first, even when nothing is cached, so `value` is nullable +for every read and the origin disambiguates `null`: | emission | meaning | | --- | --- | @@ -66,19 +66,44 @@ When the value can be absent, `T` is nullable and the origin disambiguates `null | `(value, relays)` | confirmed value | | `(null, relays)` | confirmed: nothing exists | +"newer" follows NIP-01 replacement ordering, highest `created_at` and ties broken by lowest +`id`, not arrival order. A relay answer that is already superseded is never emitted, so the +last emitted value is the winning one and `future` completes with it. + +A relay value equal to the cached one still emits once, because the origin change from `cache` +to `relays` is the confirmation. Relays repeating that same value do not emit again. + +A regular event is immutable, so an ID query that hits the cache is already confirmed: `cache` +is terminal there and no relay answer refines it. + +`stream` is backed by a `BehaviorSubject`, so `stream` and `future` can both be consumed and a +listener attached late still receives the latest value. + +`NdkValue` may later carry `createdAt`, `receivedAt` and `hasPendingWrites`. Only metadata that +makes sense for every read belongs there, anything specific to one read belongs in `T`. + Two cases are reported as stream errors, never as `null`, since `(null, relays)` is what an app acts on to create the missing data: - the value exists but cannot be read, for instance it cannot be decrypted - no relay was reachable, so absence cannot be concluded +`future` completes with the last emission, so it fails with the same errors, and it still carries +a `cache` origin when the timeout hits before any relay answered. + +That error channel stays narrow: a `BehaviorSubject` replays only the last value or the last +error, so a non-fatal error would hide a good value from a late listener. One unreachable relay +is not an error while another answers, and an unreadable cache is not terminal. + ## First use: private relay list (NIP-37 kind 10013) ```dart // on Lists, requires a logged in account, no pubkey parameter -NdkDataResponse?> getPrivateUserRelays({Duration timeout}); +NdkDataResponse> getPrivateUserRelays({Duration? timeout}); ``` +An omitted `timeout` uses the NDK default query timeout. + An empty list and no list are different answers: | emission | meaning | @@ -89,7 +114,7 @@ An empty list and no list are different answers: ## Consequences -Is a braking change as the query api changes. +Is a breaking change as the query API changes. Could be mitigated with the help of https://github.com/flutter/flutter/blob/master/docs/contributing/Data-driven-Fixes.md (preferred) or https://pub.dev/packages/codemod Especially important for external projects depending on NDK