Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions doc/library-development/ADRs/local-first-reads.md
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;
}
Comment on lines +51 to +54

Copy link
Copy Markdown

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>.value cannot be null when T is 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 only getPrivateUserRelays.

Also applies to: 50-58

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@doc/library-development/ADRs/local-first-reads.md` around lines 42 - 45,
Update the local-first read contract around NdkValue<T> so cache misses are
representable for every declared T: either constrain cache-miss-capable reads to
nullable T or introduce an explicit miss state via a state field or sealed value
type. Apply and document this rule for all local-first reads, including
getPrivateUserRelays, rather than relying on a non-nullable NdkValue<T>.value.


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
Loading