-
Notifications
You must be signed in to change notification settings - Fork 9
docs: add ADR for local-first reads #702
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
nogringo
wants to merge
3
commits into
master
Choose a base branch
from
docs/adr-local-first-reads
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,135 @@ | ||
| # 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 2026-08-17 | ||
|
|
||
| ## 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. | ||
|
|
||
| ## 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<T> { | ||
| /// Emits a `cache` value first, then every newer `relays` value as it | ||
| /// arrives. Closes after EOSE or timeout. | ||
| final Stream<NdkValue<T>> stream; | ||
|
|
||
| /// The last emitted value, relay-confirmed unless the read concludes on cache. | ||
| Future<NdkValue<T>> get future; | ||
| } | ||
|
|
||
| class NdkValue<T> { | ||
| final T? value; | ||
| final DataOrigin origin; | ||
| } | ||
|
|
||
| enum DataOrigin { cache, relays } | ||
| ``` | ||
|
|
||
| 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 | | ||
| | --- | --- | | ||
| | `(value, cache)` | local value, not confirmed | | ||
| | `(null, cache)` | nothing local yet, still loading | | ||
| | `(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<List<String>> getPrivateUserRelays({Duration? timeout}); | ||
| ``` | ||
|
|
||
| An omitted `timeout` uses the NDK default query 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 | | ||
|
|
||
|
|
||
|
|
||
| ## Consequences | ||
| 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 | ||
|
|
||
|
|
||
|
|
||
| ## 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 | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Make the cache-miss state representable for every declared
T.At Lines 42-45 and 50-58, the contract requires a cache emission when no cache exists, but
NdkValue<T>.valuecannot benullwhenTis non-nullable.Constrain reads that can miss the cache to nullable
T, or model the cache-miss state explicitly with a separate state field or sealed value type. Document this as a contract rule for all local-first reads, not onlygetPrivateUserRelays.Also applies to: 50-58
🤖 Prompt for AI Agents