nip_append_only_list 0.1.1 copy "nip_append_only_list: ^0.1.1" to clipboard
nip_append_only_list: ^0.1.1 copied to clipboard

Local-first Dart implementation of the Nostr append-only lists NIP (kinds 1990/1991) on top of NDK.

nip_append_only_list #

Local-first Dart implementation of the Nostr append-only lists NIP (kinds 1990 Add / 1991 Remove). Built on top of ndk and broadcast_queue_shim_for_ndk, with a sembast-backed cleartext projection so previously-decrypted private entries remain readable across restarts even before a signer reconnects.

What this NIP solves #

NIP-51 stores lists as replaceable events: every edit rewrites the whole list, and concurrent edits from different devices silently overwrite each other. This NIP keeps the NIP-51 tag/encrypted-content format unchanged but stores each addition or removal as its own regular event:

  • kind:1990 - Add one or more entries to a list
  • kind:1991 - Remove one or more entries from a list

State is computed client-side as an OR-Set CRDT: an entry e is a member of list L for author P iff P has signed at least one Add for (L, e) and no Remove for (L, e) with a strictly later created_at. Adds and Removes commute, so offline / multi-device edits converge without coordination.

Local-first design #

Three persistence layers are wired by the caller:

  1. NDK CacheManager - raw 1990/1991 events as received from relays (encrypted content preserved). Used for incremental sync (since: <last_known_created_at>).
  2. Cleartext projection (this package's ProjectionStore, sembast) - per (author, listName) OR-Set bookkeeping in cleartext. Once a private entry has been decrypted with a signer, it stays readable at every subsequent boot, with or without the signer.
  3. OfflineBroadcast (from broadcast_queue_shim_for_ndk) - durable outgoing queue. Writes return as soon as the event is persisted; delivery survives restarts and retries until every targeted relay acks.

Security note: the projection stores cleartext for what was originally NIP-44 encrypted content. The caller is responsible for device-level protection (disk encryption, app sandbox).

Install #

dependencies:
  nip_append_only_list: ^0.1.0
  ndk: ^0.8.4-dev.1
  broadcast_queue_shim_for_ndk: ^0.2.0
  sembast: ^3.8.7

Usage #

import 'package:broadcast_queue_shim_for_ndk/broadcast_queue_shim_for_ndk.dart';
import 'package:ndk/ndk.dart';
import 'package:nip_append_only_list/nip_append_only_list.dart';
import 'package:sembast/sembast_io.dart'; // or sembast_web on web

final ndk = Ndk(NdkConfig(
  eventVerifier: Bip340EventVerifier(),
  cache: await SembastCacheManager.create(databasePath: 'data'),
));

final outboxDb = await databaseFactoryIo.openDatabase('outbox.db');
final projectionDb = await databaseFactoryIo.openDatabase('projection.db');

final outbox = OfflineBroadcast.withNdk(ndk, db: outboxDb);
outbox.start();

final lists = AppendOnlyLists(
  ndk: ndk,
  outbox: outbox,
  projection: ProjectionStore(projectionDb),
  // Optional: cap the size of consolidation events so relays don't
  // reject huge fresh-Adds or deletion bundles. Default 32 KB.
  // maxEventBytes: 32 * 1024,
);

// Log an account into NDK so write methods pick up its signer
// automatically. Alternatives: `ndk.accounts.loginExternalSigner(...)`
// for a NIP-46 bunker / Amber / NIP-07 signer, or simply pass `signer:`
// explicitly on every call.
ndk.accounts.loginPrivateKey(pubkey: myPubkey, privkey: myPrivkey);

// Add three entries to a list named "fruits".
// `relays` is optional; when omitted, the author's NIP-65 write relays
// are resolved automatically via NDK.
await lists.add(
  listName: 'fruits',
  entries: const [
    AppendOnlyListEntry(tag: 't', value: 'apple'),
    AppendOnlyListEntry(tag: 't', value: 'banana'),
    AppendOnlyListEntry(tag: 't', value: 'cherry', private: true), // encrypted
  ],
);

// Remove one.
await lists.remove(
  listName: 'fruits',
  entries: const [AppendOnlyListEntry(tag: 't', value: 'banana')],
);

// Read - works offline, no signer needed for entries already projected.
final state = await lists.getList(
  pubkey: myPubkey,
  listName: 'fruits',
);
print(state.entries); // {apple, cherry (private)}

// Reactive view (initial snapshot + live updates).
final sub = lists.watchList(
  pubkey: myPubkey,
  listName: 'fruits',
).listen((s) => print(s.entries));

// Periodically compact: emits a fresh Add capturing current state and
// NIP-09-deletes the superseded events.
await lists.consolidate(listName: 'fruits');

await sub.cancel();
await lists.dispose();

API surface #

Symbol Purpose
AppendOnlyListEntry (tag, value, private?) - identity is (tag, value)
AppendOnlyListOp add (kind 1990) / remove (kind 1991)
AppendOnlyListEvent Parsed event with separated public/private entries
AppendOnlyListState Folded OR-Set state for one (author, listName)
EntryStat Per-entry timestamps and presence test
buildAppendOnlyEvent(...) Builds (unsigned) 1990/1991 events; encrypts private entries
listFilter / deletionFilter NDK Filter builders
ProjectionStore Sembast-backed cleartext state store
AppendOnlyLists High-level usecase: read / write / watch / consolidate

The CRDT core (AppendOnlyListState.fromEvents, foldEvents) is pure and has no NDK dependency at runtime - it can be used standalone to fold any collection of parsed events into a resolved set.

Compatibility with NIP-51 #

This NIP is complementary to NIP-51, not a replacement. The two are intended to coexist: NIP-51 remains appropriate for human-curated, low-volume, mono-device lists; this NIP targets high-volume, automated, multi-device, and offline-first workflows. Clients may mirror state between the two for interoperability.

1
likes
160
points
107
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Local-first Dart implementation of the Nostr append-only lists NIP (kinds 1990/1991) on top of NDK.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

broadcast_queue_shim_for_ndk, ndk, sembast

More

Packages that depend on nip_append_only_list