lantern_client
Official pure-Dart package for Lantern, an in-memory graph key-vertex store with TTL-aware vertices and edges.
The package includes exact vertex values, strict TTL handling, bounded core CRUD, and the reusable transport foundation used by later query and traversal facades. Generated RPC request/response types remain private.
Scope
- Android and iOS are the first-class production targets.
- Dart VM is supported for tests and command-line integration tooling.
- This is a pure Dart package: it has no Flutter runtime dependency, plugin declaration, platform channel, or native implementation.
- Flutter Web and desktop support are not guaranteed by v0.1.
The public import is:
import 'package:lantern_client/lantern_client.dart';
final client = LanternClient.connect(
Uri.parse('https://lantern.example.com'),
tokenProvider: () async => session.accessToken,
);
final outcome = await client.putVertex(
VertexInput(
key: 'user:42',
value: VertexValue.string('alice'),
expiresIn: const Duration(minutes: 30),
),
);
if (outcome != PutOutcome.appliedAndLive) {
throw StateError('user:42 was not live after Put: $outcome');
}
final vertex = await client.getVertex('user:42');
VertexValue has named factories for every wire oneof kind; bare Dart numbers
are never guessed into a Protobuf numeric kind. VertexValue.nil() and
VertexValue.unset() remain distinct. VertexInput and EdgeInput omit wire
expiration when both expiration fields are absent, which means permanent
storage. expiresIn must be positive, is mutually exclusive with expiresAt,
and is converted to one absolute UTC instant from the client's clock before
chunking. After every chunk succeeds, the same clock is sampled once before
the plural call returns;
appliedAndLive is downgraded to expired when that absolute expiration is
already past locally. Bounded server outcomes remain authoritative otherwise,
so conditionNotMet and superseded are never reclassified. Device-clock skew
therefore affects relative TTLs and can only make the client more conservative.
Plural CRUD calls use 1,000-item chunks by default and reject logical batches
above 65,536 items. A later chunk failure throws BatchException with the
input-prefix length whose responses were fully observed and validated; despite
the existing committed field name, this is not an appliedAndLive count.
addEdge(s) accumulates weight. An optional exact 24-byte contribId
deduplicates a contribution only while it remains live; it cannot recover
the original effective weight after a Delete or expiry. putEdge(s)
overwrites weight and expiration idempotently.
For putVerticesIfAbsent, that count covers only prior chunks whose responses
were observed. The failed chunk may already have committed; its original
per-item outcomes are unknown, and replay performs a new condition evaluation
that may return conditionNotMet. Reconcile server state before retrying it.
Every singular Put resolves to a typed PutOutcome; plural Vertex and Edge
Puts both resolve to immutable, request-index-aligned result Lists. The server
clock decides appliedAndLive, expired, conditionNotMet, or superseded
at application time. An unconditional born-expired Put removes a prior live
item and reports expired; putVertexIfAbsent checks an existing live value
first and reports conditionNotMet without overwriting it. Unknown,
unspecified, or length-misaligned wire outcomes fail closed as
LanternInternalException.
Mobile retries and additive safety (current source)
Retries are opt-in and bounded. The default RetryPolicy() makes three total
attempts, uses full-jitter exponential backoff from 100 ms with a 2 second
per-delay cap, and retries unavailable only. One absolute deadline and one
cancellation token cover both attempts and backoff. There is no connectivity
preflight, endpoint discovery, or background execution guarantee.
final client = LanternClient.connect(
Uri.parse('https://lantern.example.com'),
retryPolicy: const RetryPolicy(),
idempotentAdds: true,
);
Reads and unconditional Put calls may retry. Plain Add (singular, plural,
or decaying), Put-if-absent, Delete, capped prefix Delete, streams, and unknown
operations are never automatically replayed; a lost Add response leaves its
original result ambiguous even if it committed. idempotentAdds: true fills
missing IDs with the canonical 24-byte format once per in-memory logical call
for live deduplication; caller-supplied IDs always win. For a failed plural
Add chunk, only earlier chunks with observed responses count toward
BatchException.committed.
Set LanternCallOptions(retry: false) when a higher-level durable coordinator
owns retry accounting. Each RPC is then attempted at most once while retaining
the client's deadline, cancellation, token-provider, and typed failure behavior.
A chunked plural logical call may still issue multiple RPCs.
Automatic IDs do not turn two application calls into one operation and do not survive process restart. This package does not implement an offline queue. A contribution ID deduplicates only while the server retains that contribution.
Hosted 0.3.1 retry caveat (#1471): Published
lantern_client 0.3.1 has the bug: with retryPolicy configured, a
receipt-less Add carrying IDs (including idempotentAdds IDs) is eligible for
automatic retry. An intervening Delete or expiry can erase dedup evidence, so
the retried Add may execute again with a different result. Do not rely on
idempotentAdds plus automatic retry to recover the original Add result;
upgrade to hosted lantern_client 0.3.2 for single-attempt plain Add, or use
receipt-backed Add/status on a certified endpoint to recover the original
result after response loss. The 0.3.1 archive remains unsafe for this case.
The hosted lantern_client_offline 0.3.0 outbox admits Put only. Merged
offline 0.4.0 source separately implements receipt-backed conditional Vertex
Put, exact Vertex/Edge Delete, and explicit-ContribID Edge Add; it is not a
published or qualified receipt release. Final #1399 performance, archive,
and physical evidence remain open (#1449's generic capture is not device
evidence). The opt-in online APIs described below are hosted in
lantern_client 0.3.2 on pub.dev; its published archive passed exact-content
verification. Offline 0.4.0 source declares lantern_client: ^0.3.2;
before its publication, resolve the offline candidate archive against
that hosted parent outside the checkout and without a path override.
addDecayingEdge expands a geometric curve into at most 16 staggered-TTL
contributions whose initial live sum is exact. With idempotentAdds enabled,
its contribution IDs are stamped for live deduplication; the plain decaying
call is not automatically retried, and IDs cannot recover an original result
after Delete or expiry.
Bounded online mutation receipts
Receipt-bearing Vertex Put, exact Vertex Delete, Edge Delete, and contribution-keyed Edge Add are explicit APIs alongside the existing receipt-less methods. Plain Add now reports ambiguous transport failures without replay, while receipt-bearing mutation retries validate endpoint continuity. Receipt-bearing Add requires a distinct explicit, nonzero 24-byte contribution ID for every item and returns the exact original effective weight. Finite Add inputs can accumulate to signed infinity, which remains an authoritative result. A retained NaN is likewise returned as semantic Dart NaN; NaN payload-bit identity is not portable across binary64 and ProtoJSON. The online package does not persist receipt state.
Fetch capability from the target endpoint, mint one immutable context, persist its mutation kind plus exact operation/group/endpoint bytes if recovery must survive process death, and use that same context for the mutation:
final capability = await client.getReceiptCapability();
if (capability case ReceiptCapabilityEnabled(
supportedMutations: final supported,
)) {
if (!supported.contains(ReceiptMutationKind.vertexPut) ||
!supported.contains(ReceiptMutationKind.vertexDelete)) {
throw StateError('endpoint does not support Vertex receipts');
}
final putContext = client.mintReceiptContext(
capability: capability,
mutation: ReceiptMutationKind.vertexPut,
itemCount: 1,
);
final put = await client.putVertexWithReceipt(
VertexInput(key: 'user:42', value: VertexValue.string('alice')),
context: putContext,
ifAbsent: true,
);
print('put outcome: ${put.outcome}');
final deleteContext = client.mintReceiptContext(
capability: capability,
mutation: ReceiptMutationKind.vertexDelete,
itemCount: 1,
);
try {
final result = await client.deleteVertexWithReceipt(
'user:42',
context: deleteContext,
);
print('existed: ${result.existed}');
} on ReceiptReconciliationException catch (error) {
final status = await client.getReceiptStatus(
error.context.operationIds.single,
);
switch (status.state) {
case ReceiptStatusState.confirmed:
final receipt = status.receipt! as VertexDeleteReceipt;
print('original existed: ${receipt.existed}');
case ReceiptStatusState.notYetObserved:
print('not observed; execution remains uncertain');
case ReceiptStatusState.noLongerProvable:
print('receipt evidence is no longer available');
}
}
}
Operation IDs encode the enabled deployment epoch and a server-time-based
issuance timestamp; operation and logical-call entropy comes from
cryptographically secure platform randomness by default. The clock and random
source are injectable for deterministic tests. Constructors validate exact
byte lengths, nonzero identity components, one shared epoch, unique operation
IDs, mutation-family capability, and request-index alignment before network
I/O. Vertex Put supports the same ifAbsent intent as its receipt-less
counterpart and returns immutable per-item PutOutcome values; receipt-bearing
Vertex Delete preserves an explicit result for every request index, including
false. Receipt-bearing Edge Add preserves every request-index-aligned
effective weight, including a born-expired 0, and rejects missing, duplicate,
zero, or wrong-sized contribution IDs before network I/O.
With RetryPolicy configured, a response-loss retry reuses the exact context
only after a read-only capability check confirms the same deployment epoch,
node ID, endpoint generation, and mutation-family support. The retry request
echoes that marker, so a load balancer cannot silently move the replay to
another endpoint. Bearer-token rotation is independent of continuity. Disabled
capability, removed family support, changed continuity, an exhausted ambiguous
attempt, or an untrusted response throws ReceiptReconciliationException with
the exact context for status lookup. Confirmed status exposes a sealed
MutationReceipt as VertexPutReceipt, VertexDeleteReceipt, or
EdgeDeleteReceipt, or EdgeAddReceipt. notYetObserved is not proof of
non-execution, and
noLongerProvable forbids automatic mutation replay.
Cursor-paged mobile lists
scanVertices, scanVertexKeys, and scanEdges fetch exactly one bounded
unary page. Each Page<T> keeps the item list immutable and exposes an opaque
ScanCursor?; null unambiguously means end of range. Vertex and keys scans
default to ascending order and support explicit descending order. Edge scans
follow the wire's ascending (tail, head) order.
final subscription = client
.scanVertexKeysAll(prefix: 'feed:', limit: 100)
.listen((page) {
// Append only this page to the visible ListView model.
visibleKeys.addAll(page.items);
});
// A screen should release its active page request when disposed.
await subscription.cancel();
The *All helpers remain page streams: they preserve page boundaries, fetch
at most one page at a time, stop fetching while paused, and propagate stream
cancellation to an active RPC. Prefer scanVertexKeys when values are not
needed; it avoids transferring and decoding vertex payloads. Smaller pages
reduce peak memory and cancellation latency, while larger pages reduce RPC
overhead and may use more mobile data before a screen can render or stop.
Prefix count and Delete operations are separate unary calls. Vertex Delete
requires a non-empty prefix, edge Delete requires at least one of tail/head
prefix, and dryRun previews the server-bounded count. A positive limit
caps one call only. The SDK never retries or silently loops prefix Delete.
Search, discovery, and traversal
The canonical SearchVertices contract is authoritative for document projection, Unicode analysis, relative BM25 scoring, TTL consistency, budgets, typed reasons, endpoint-sticky cursors, and HA. The maintained Flutter example compiles both one-shot and incremental flows, including capability discovery, phrase/typo options, pagination, disabled handling, and cancellation.
searchVertices exposes prefix scope, limit, any/all/min-should-match modes,
phrase matching, fuzziness, and prefix-term expansion. Nullable relevance
fields preserve the distinction between omitted server defaults and explicit
values. SearchMatchMode.minShouldMatch accepts a null/zero
minShouldMatch as the server-threshold sentinel. Non-zero thresholds under
another mode and phrase combined with an explicit mode/fuzziness/prefix terms
fail locally before transport. A server with search disabled returns the SearchDisabled result,
which lets UI code render a calm unavailable state without classifying an
exception. Only the typed SearchErrorReason.searchDisabled detail maps to
that result; missing positional postings remain a
LanternFailedPreconditionException with
SearchErrorReason.searchPositionsDisabled, so phrase capability failures are
never mistaken for a disabled index. getServerStatus().search exposes the
endpoint's positions flag, defaults, limits, implementation versions, and HA
configuration fingerprint.
LanternResourceExhaustedException.searchReason distinguishes work-budget
exhaustion from admission saturation, while searchWorkKind identifies the
exhausted counter. Cancellation, deadline, and work-budget failures are
terminal for that attempt; retry unchanged only for admission saturation with
jittered backoff, and issue only the newest input in incremental UI flows.
searchVerticesPage returns one immutable, endpoint-sticky ranked page with
nextCursor, effectiveLimit, truncated, and continuationLimited. Repeat
the same SearchOptions with that cursor. Expired or evicted sessions throw a
typed LanternAbortedException with SearchErrorReason.searchCursorStale;
tamper or request/config/endpoint mismatch is a
LanternInvalidArgumentException with searchCursorInvalid. Restart from page
one explicitly. searchVerticesStream follows pages lazily and never builds an
unbounded list; a bounded tail throws
LanternSearchContinuationLimitedException after the final retained hit.
SearchProjection.keyScore is the lightweight default.
SearchProjection.fullVertex includes the exact value/TTL snapshot selected
with ranking and avoids a racy follow-up read.
final result = await client.searchVertices(
'quiet cafe',
searchOptions: const SearchOptions(
prefix: 'place:',
matchMode: SearchMatchMode.all,
),
);
switch (result) {
case SearchEnabled(:final hits):
showHits(hits);
case SearchDisabled():
showSearchUnavailable();
}
await for (final hit in client.searchVerticesStream(
'quiet cafe',
searchOptions: const SearchOptions(
limit: 50,
prefix: 'place:',
projection: SearchProjection.fullVertex,
),
)) {
showHit(hit.key, hit.vertex);
}
For search-as-you-type, create one screen-owned IncrementalSearch, listen to
its core-Dart updates stream, call search for each edit, and dispose it
with the screen. It debounces input, cancels superseded calls, drops stale
successes and errors by epoch, and emits an explicit idle state for cleared or
too-short input. It has no Flutter or state-management dependency.
illuminate requires exactly one sealed traversal family:
BfsOptionsrequires positivestepandfanOut.PprOptions(topN: 0)retains every positive-mass vertex.LocalCommunityOptions(maxSize: 0)lets the sweep choose its natural size.
Nullable restart probability and epsilon preserve the server defaults;
explicit values are validated locally. Shared TraversalWeighting and
vertexPrefix apply to every family. The returned immutable Graph indexes
vertices by key and stores complete Edge objects by tail/head, including
expiration. edgeWeights is a derived compatibility view and never replaces
the TTL-bearing data.
topVerticesByDegree provides prefix-scoped cold-start ranking for out, in,
or both directions. getServerStatus and getReplicationStatus each fetch
one immutable snapshot; the SDK never starts status polling implicitly.
LanternClient.connect requires HTTPS by default; pass allowInsecure: true
only for local development. Supply a short-lived tokenProvider for
application calls. ping() uses the auth-exempt gRPC Health-v1 Connect+JSON
endpoint and throws
LanternHealthStatusException when the server is not serving. Generated
request/response types and the raw Connect client remain under lib/src/gen.
subscribeIdentity(bootstrap: true) exposes an identity-only CDC checkpoint
followed by exact Vertex/Edge key chunks. Each chunk has an origin-local
sequence, operation category, HLC, index, and final marker; no graph value,
weight, or contribution ID is exposed. Persist invalidation before advancing
the origin cursor on the final chunk. The checkpoint stores the last
committed sequence, while IdentityNextCursor.fromLastApplied converts a
durable last-applied vector into the next sequence expected by a resumed
stream. failedPrecondition means the retained tail has a gap and requires a
new checkpoint plus resident-key revalidation. The stream is scoped to one
deployment-wide graph; it does not provide tenant filtering or automatic
offline persistence. The default unary timeout is disabled for this long-lived
stream, but an explicit LanternCallOptions(timeout: ...) still applies.
final tail = client.subscribeIdentity(bootstrap: true);
await for (final frame in tail) {
switch (frame) {
case IdentityCheckpointFrame(:final lastSequences):
// Durably mark resident keys Unknown and save lastSequences.
case IdentityChunkFrame(:final vertexKeys, :final edgeKeys, :final isLast):
// Durably invalidate these exact identities; advance on isLast.
}
}
await client.ping();
// Cancel screen-owned calls with LanternCallOptions(cancellation: token).
await client.close();
Generate wire code
From the repository root, run:
sdks/dart/scripts/codegen.sh
The script generates from the canonical root proto/ workspace with these
immutable pins:
buf.build/protocolbuffers/dart:v22.5.0buf.build/connectrpc/dart:v1.0.0
The committed pubspec.lock resolves the accepted runtime set exactly to
connectrpc 1.0.0, protobuf 4.2.0, and fixnum 1.1.1 for reproducible CI.
Published dependency constraints allow compatible Connect 1.x and Protobuf
4.x releases so applications can resolve a shared dependency graph; the
Protobuf <5.0.0 ceiling is required by Connect-Dart 1.x.
The supported toolchain policy is recorded in ADR 0001: CI tests the package's Dart 3.11 floor and the pinned current Flutter/Dart pair. Generator/runtime compatibility pins move together through reviewed PRs.
Generated files are committed under lib/src/gen and must never be edited by
hand. The script removes only that directory before regeneration; it never
uses root buf generate --clean.
Package checks
cd sdks/dart
dart pub get --enforce-lockfile
dart format --output=none --set-exit-if-changed \
lib/lantern_client.dart lib/src/*.dart test
dart analyze lib test
dart test
dart doc --validate-links
dart pub publish --dry-run
See ADR 0001 for the transport decision, supported toolchain evidence, and gRPC fallback triggers.
The maintained Flutter example
demonstrates runtime token refresh,
secure endpoint configuration, app/screen lifecycle ownership, bounded paging,
incremental search, traversal, typed failure states, and the physical-device
smoke checklist. The full app is a repository integration fixture rather than
part of the lantern_client publish archive because it also exercises the
separate offline child by path. The archive retains a standalone online
example while remaining dependency-closed. The core SDK has no implicit offline
cache or background-delivery promise. The accepted
offline Repository and package contract
defines the official opt-in lantern_client_offline direction: a
storage-adapter-driven cache/outbox engine that remains separate from this
online package and excludes ambiguous mutations from generic replay.
Libraries
- lantern_client
- Official pure-Dart types and mobile transport foundation for Lantern clients.