lantern_client 0.1.0 copy "lantern_client: ^0.1.0" to clipboard
lantern_client: ^0.1.0 copied to clipboard

Pure Dart client SDK for Lantern, an in-memory graph key-vertex store with TTL-aware vertices and edges.

example/lib/main.dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';

import 'package:flutter/material.dart';
import 'package:lantern_client/lantern_client.dart';

/// Lossless, type-labelled text used by the example's value list.
String formatVertexValue(VertexValue value) => switch (value) {
  Float64Value(value: final number) => 'float64=$number',
  Float32Value(value: final number) => 'float32=$number',
  Int32Value(value: final number) => 'int32=$number',
  Int64Value(value: final number) => 'int64=$number',
  Uint32Value(value: final number) => 'uint32=$number',
  Uint64Value(value: final number) => 'uint64=$number',
  BoolValue(value: final boolean) => 'bool=$boolean',
  StringValue(value: final string) => 'string=${jsonEncode(string)}',
  BytesValue(value: final bytes) => 'bytes=base64:${base64Encode(bytes)}',
  TimestampValue(value: final timestamp) =>
    'timestamp=${timestamp.toUtc().toIso8601String()}',
  DurationValue(value: final duration) =>
    'duration_us=${duration.inMicroseconds}',
  NilValue() => 'nil',
  UnsetValue() => 'unset',
};

void main() {
  runApp(LanternExampleApp(configuration: DemoConfiguration.fromEnvironment()));
}

/// Non-secret runtime locations supplied with `--dart-define`.
final class DemoConfiguration {
  const DemoConfiguration({
    required this.endpoint,
    required this.tokenEndpoint,
    required this.allowInsecure,
  });

  final Uri endpoint;
  final Uri? tokenEndpoint;
  final bool allowInsecure;

  static DemoConfiguration? fromEnvironment() {
    const rawEndpoint = String.fromEnvironment('LANTERN_ENDPOINT');
    if (rawEndpoint.isEmpty) return null;
    const rawTokenEndpoint = String.fromEnvironment('LANTERN_TOKEN_ENDPOINT');
    return DemoConfiguration(
      endpoint: Uri.parse(rawEndpoint),
      tokenEndpoint: rawTokenEndpoint.isEmpty
          ? null
          : Uri.parse(rawTokenEndpoint),
      allowInsecure: const bool.fromEnvironment('LANTERN_ALLOW_INSECURE'),
    );
  }
}

/// Example root. The client is owned by the signed-in/app session below it.
class LanternExampleApp extends StatelessWidget {
  const LanternExampleApp({super.key, required this.configuration});

  final DemoConfiguration? configuration;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Lantern mobile example',
      theme: ThemeData(colorSchemeSeed: Colors.amber, useMaterial3: true),
      home: configuration == null
          ? const _ConfigurationHelp()
          : _ClientOwner(configuration: configuration!),
    );
  }
}

final class _RuntimeTokenProvider {
  _RuntimeTokenProvider(this.endpoint, {required bool allowInsecure}) {
    final uri = endpoint;
    if (uri != null && uri.scheme != 'https' && !allowInsecure) {
      throw ArgumentError.value(
        uri,
        'tokenEndpoint',
        'must use HTTPS unless LANTERN_ALLOW_INSECURE is explicitly enabled',
      );
    }
  }

  final Uri? endpoint;
  final HttpClient _http = HttpClient();

  /// Fetches a fresh user/device-scoped token from a BFF at call time.
  Future<String?> call() async {
    final uri = endpoint;
    if (uri == null) return null;
    final request = await _http.getUrl(uri);
    request.headers.set(HttpHeaders.acceptHeader, ContentType.json.mimeType);
    final response = await request.close();
    final body = await utf8.decodeStream(response);
    if (response.statusCode != HttpStatus.ok) {
      throw HttpException('token endpoint returned ${response.statusCode}');
    }
    final decoded = jsonDecode(body);
    if (decoded is! Map<String, Object?> ||
        decoded['access_token'] is! String ||
        (decoded['access_token']! as String).isEmpty) {
      throw const FormatException('token response needs access_token');
    }
    return decoded['access_token']! as String;
  }

  void close() => _http.close(force: true);
}

final class _ClientOwner extends StatefulWidget {
  const _ClientOwner({required this.configuration});

  final DemoConfiguration configuration;

  @override
  State<_ClientOwner> createState() => _ClientOwnerState();
}

final class _ClientOwnerState extends State<_ClientOwner> {
  late final _RuntimeTokenProvider _tokens;
  late final LanternClient _client;

  @override
  void initState() {
    super.initState();
    _tokens = _RuntimeTokenProvider(
      widget.configuration.tokenEndpoint,
      allowInsecure: widget.configuration.allowInsecure,
    );
    _client = LanternClient.connect(
      widget.configuration.endpoint,
      allowInsecure: widget.configuration.allowInsecure,
      tokenProvider: _tokens.call,
      retryPolicy: const RetryPolicy(),
      idempotentAdds: true,
    );
  }

  @override
  void dispose() {
    // A signed-in/app session owns the client. Transient inactive/background
    // lifecycle states do not close it.
    unawaited(_client.close());
    _tokens.close();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => _DiscoveryScreen(client: _client);
}

enum _UiPhase {
  loading,
  empty,
  ready,
  unauthenticated,
  unavailable,
  timeout,
  retryExhausted,
  error,
}

final class _DiscoveryScreen extends StatefulWidget {
  const _DiscoveryScreen({required this.client});

  final LanternClient client;

  @override
  State<_DiscoveryScreen> createState() => _DiscoveryScreenState();
}

final class _DiscoveryScreenState extends State<_DiscoveryScreen> {
  static const _prefix = 'flutter-demo:';
  static const _maxVisibleRows = 200;

  late final AppLifecycleListener _lifecycle;
  late final IncrementalSearch _search;
  late final StreamSubscription<SearchUpdate> _searchSubscription;
  late final ScrollController _scroll;
  LanternCancellationToken _screenCancellation = LanternCancellationToken();
  ScanCursor? _keysCursor;
  ScanCursor? _verticesCursor;
  var _loadingPage = false;
  var _phase = _UiPhase.loading;
  String _message = 'Loading';
  final List<String> _rows = [];

  @override
  void initState() {
    super.initState();
    _search = widget.client.incrementalSearch(
      options: const IncrementalSearchOptions(
        search: SearchOptions(prefix: _prefix),
      ),
    );
    _searchSubscription = _search.updates.listen(_onSearchUpdate);
    _scroll = ScrollController()..addListener(_onScroll);
    _lifecycle = AppLifecycleListener(
      onHide: _pauseScreenWork,
      onPause: _pauseScreenWork,
      // Intentionally no onInactive close/cancel: calls and system dialogs can
      // transiently mark an app inactive while it remains visible.
      onResume: _resumeAndRefetch,
    );
    unawaited(_refresh());
  }

  void _pauseScreenWork() {
    _screenCancellation.cancel('screen hidden');
    _search.search('');
  }

  void _resumeAndRefetch() {
    _screenCancellation = LanternCancellationToken();
    unawaited(_refresh());
  }

  Future<void> _refresh() async {
    _setPhase(_UiPhase.loading, 'Refreshing visible data');
    _keysCursor = null;
    _verticesCursor = null;
    _rows.clear();
    try {
      await _loadNextPage();
      if (_rows.isEmpty) {
        final coldStart = await widget.client.topVerticesByDegree(
          prefix: _prefix,
          direction: DegreeDirection.both,
          weighted: true,
          options: _callOptions,
        );
        _replaceRows(coldStart.map((entry) => 'cold-start ${entry.key}'));
      }
      _setPhase(
        _rows.isEmpty ? _UiPhase.empty : _UiPhase.ready,
        _rows.isEmpty ? 'No Lantern data yet' : 'Visible page data',
      );
    } catch (error) {
      _showFailure(error);
    }
  }

  LanternCallOptions get _callOptions => LanternCallOptions(
    timeout: const Duration(seconds: 5),
    cancellation: _screenCancellation,
  );

  Future<void> _loadNextPage() async {
    if (_loadingPage || (_keysCursor == null && _rows.isNotEmpty)) return;
    _loadingPage = true;
    try {
      // Keys-only is the efficient lazy-list backbone. Fetch the matching
      // value page separately only when this screen actually displays values.
      final keys = await widget.client.scanVertexKeys(
        prefix: _prefix,
        limit: 25,
        cursor: _keysCursor,
        options: _callOptions,
      );
      final vertices = await widget.client.scanVertices(
        prefix: _prefix,
        limit: 25,
        cursor: _verticesCursor,
        options: _callOptions,
      );
      _keysCursor = keys.nextCursor;
      _verticesCursor = vertices.nextCursor;
      _appendRows([
        ...keys.items.map((key) => 'key $key'),
        ...vertices.items.map(
          (vertex) =>
              'value ${vertex.key}: ${formatVertexValue(vertex.value)} '
              'expires=${vertex.expiration?.toIso8601String() ?? 'never'}',
        ),
      ]);
    } finally {
      _loadingPage = false;
    }
  }

  void _onScroll() {
    if (_scroll.position.extentAfter < 300 && _keysCursor != null) {
      unawaited(_loadNextPage());
    }
  }

  Future<void> _seedCrud() async {
    _setPhase(_UiPhase.loading, 'Writing TTL-aware sample data');
    try {
      await widget.client.putVertices([
        VertexInput(
          key: '${_prefix}alice',
          value: VertexValue.string('quiet cafe graph'),
          expiresIn: const Duration(minutes: 30),
        ),
        VertexInput(
          key: '${_prefix}bob',
          value: VertexValue.uint64((BigInt.one << 63) + BigInt.one),
        ),
      ], options: _callOptions);
      await widget.client.getVertex('${_prefix}alice', options: _callOptions);
      await widget.client.putEdge(
        EdgeInput(
          tail: '${_prefix}alice',
          head: '${_prefix}bob',
          weight: 1,
          expiresIn: const Duration(minutes: 10),
        ),
        options: _callOptions,
      );
      await widget.client.addEdge(
        EdgeInput(
          tail: '${_prefix}alice',
          head: '${_prefix}bob',
          weight: 0.5,
          expiresIn: const Duration(minutes: 5),
        ),
        options: _callOptions,
      );
      await widget.client.getEdge(
        EdgeRef('${_prefix}alice', '${_prefix}bob'),
        options: _callOptions,
      );
      await _runSearchContractExamples();
      await widget.client.putVertex(
        VertexInput(key: '${_prefix}temporary', value: VertexValue.nil()),
        options: _callOptions,
      );
      await widget.client.deleteVertex(
        '${_prefix}temporary',
        options: _callOptions,
      );
      await _refresh();
    } catch (error) {
      _showFailure(error);
    }
  }

  /// Maintained one-shot counterpart to the screen-owned incremental search
  /// created in [initState]. Flutter analyze/test compile both paths.
  Future<void> _runSearchContractExamples() async {
    final status = await widget.client.getServerStatus(options: _callOptions);
    final capability = status.search;
    _appendRows([
      'search enabled=${capability.enabled} '
          'positions=${capability.positionsEnabled} '
          'analyzer=${capability.analyzerVersion} '
          'projection=${capability.projectionVersion} '
          'fingerprint=${capability.configFingerprint}',
    ]);

    final oneShot = await widget.client.searchVertices(
      'quiet cafe',
      searchOptions: const SearchOptions(
        prefix: _prefix,
        matchMode: SearchMatchMode.all,
        limit: 10,
      ),
      options: _callOptions,
    );
    switch (oneShot) {
      case SearchDisabled():
        _appendRows(['Search is disabled on this endpoint']);
        return;
      case SearchEnabled(:final hits):
        _appendRows(hits.map((hit) => 'one-shot ${hit.key} ${hit.score}'));
    }

    if (capability.positionsEnabled) {
      await widget.client.searchVertices(
        'quiet cafe',
        searchOptions: const SearchOptions(prefix: _prefix, phrase: true),
        options: _callOptions,
      );
    }
    await widget.client.searchVertices(
      'serach',
      searchOptions: const SearchOptions(prefix: _prefix, fuzziness: 1),
      options: _callOptions,
    );

    await for (final hit in widget.client.searchVerticesStream(
      'quiet cafe',
      searchOptions: const SearchOptions(
        prefix: _prefix,
        limit: 2,
        projection: SearchProjection.fullVertex,
      ),
      options: _callOptions,
    )) {
      _appendRows(['paged ${hit.key} ${hit.vertex?.expiration}']);
    }

    final cancellation = LanternCancellationToken()..cancel('example');
    try {
      await widget.client.searchVerticesPage(
        'quiet cafe',
        searchOptions: const SearchOptions(prefix: _prefix),
        options: LanternCallOptions(cancellation: cancellation),
      );
    } on LanternCanceledException {
      _appendRows(['search cancelled without a partial page']);
    }
  }

  void _onSearchUpdate(SearchUpdate update) {
    switch (update.phase) {
      case SearchUpdatePhase.idle:
        _setPhase(_UiPhase.empty, 'Search cleared');
      case SearchUpdatePhase.loading:
        _setPhase(_UiPhase.loading, 'Searching “${update.query}”');
      case SearchUpdatePhase.results:
        _replaceRows(update.hits.map((hit) => '${hit.key} ${hit.score}'));
        _setPhase(
          update.hits.isEmpty ? _UiPhase.empty : _UiPhase.ready,
          update.hits.isEmpty ? 'No search results' : 'Latest search results',
        );
      case SearchUpdatePhase.disabled:
        _setPhase(_UiPhase.empty, 'Search is disabled on this server');
      case SearchUpdatePhase.error:
        _showFailure(update.error ?? StateError('search failed'));
    }
  }

  Future<void> _traverse(TraversalOptions traversal) async {
    _setPhase(_UiPhase.loading, 'Loading graph navigation');
    try {
      final graph = await widget.client.illuminate(
        '${_prefix}alice',
        traversal: traversal,
        weighting: TraversalWeighting.raw,
        vertexPrefix: _prefix,
        options: _callOptions,
      );
      _replaceRows(
        graph.allEdges.map(
          (edge) =>
              '${edge.tail} → ${edge.head} weight=${edge.weight} '
              'expires=${edge.expiration?.toIso8601String() ?? 'never'}',
        ),
      );
      _setPhase(
        graph.allEdges.isEmpty ? _UiPhase.empty : _UiPhase.ready,
        '${traversal.runtimeType} graph',
      );
    } catch (error) {
      _showFailure(error);
    }
  }

  void _appendRows(Iterable<String> rows) {
    if (!mounted) return;
    setState(() {
      _rows.addAll(rows);
      if (_rows.length > _maxVisibleRows) {
        _rows.removeRange(0, _rows.length - _maxVisibleRows);
      }
    });
  }

  void _replaceRows(Iterable<String> rows) {
    if (!mounted) return;
    setState(() {
      _rows
        ..clear()
        ..addAll(rows.take(_maxVisibleRows));
    });
  }

  void _setPhase(_UiPhase phase, String message) {
    if (!mounted) return;
    setState(() {
      _phase = phase;
      _message = message;
    });
  }

  void _showFailure(Object error) {
    final (phase, message) = switch (error) {
      LanternUnauthenticatedException() => (
        _UiPhase.unauthenticated,
        'Sign in again: the token is missing or expired',
      ),
      LanternRetryExhaustedException() => (
        _UiPhase.retryExhausted,
        'The bounded retry budget was exhausted',
      ),
      LanternUnavailableException() => (
        _UiPhase.unavailable,
        'Lantern is currently unreachable',
      ),
      LanternDeadlineExceededException() => (
        _UiPhase.timeout,
        'The request timed out',
      ),
      _ => (_UiPhase.error, 'Unexpected failure: $error'),
    };
    _setPhase(phase, message);
  }

  @override
  void dispose() {
    _screenCancellation.cancel('screen disposed');
    unawaited(_searchSubscription.cancel());
    unawaited(_search.dispose());
    _lifecycle.dispose();
    _scroll.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Lantern mobile discovery')),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(12),
            child: TextField(
              key: const Key('search-field'),
              onChanged: _search.search,
              decoration: const InputDecoration(
                labelText: 'Latest-query-wins search',
                border: OutlineInputBorder(),
              ),
            ),
          ),
          Wrap(
            spacing: 8,
            children: [
              FilledButton(
                onPressed: _seedCrud,
                child: const Text('Seed CRUD'),
              ),
              OutlinedButton(
                onPressed: () =>
                    _traverse(const BfsOptions(step: 2, fanOut: 10)),
                child: const Text('BFS'),
              ),
              OutlinedButton(
                onPressed: () => _traverse(const PprOptions(topN: 20)),
                child: const Text('PPR'),
              ),
              OutlinedButton(
                onPressed: () =>
                    _traverse(const LocalCommunityOptions(maxSize: 20)),
                child: const Text('Community'),
              ),
            ],
          ),
          Semantics(
            liveRegion: true,
            child: Padding(
              padding: const EdgeInsets.all(8),
              child: Text('${_phase.name}: $_message', key: const Key('state')),
            ),
          ),
          Expanded(
            child: ListView.builder(
              controller: _scroll,
              itemCount: _rows.length,
              itemBuilder: (context, index) =>
                  ListTile(title: Text(_rows[index])),
            ),
          ),
        ],
      ),
    );
  }
}

final class _ConfigurationHelp extends StatelessWidget {
  const _ConfigurationHelp();

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: SafeArea(
        child: Padding(
          padding: EdgeInsets.all(24),
          child: Text(
            'Set LANTERN_ENDPOINT with --dart-define. '
            'Use LANTERN_TOKEN_ENDPOINT for a runtime short-lived-token BFF. '
            'Plaintext also requires LANTERN_ALLOW_INSECURE=true and is for '
            'debug/trusted-LAN use only.',
            key: Key('configuration-help'),
          ),
        ),
      ),
    );
  }
}
0
likes
140
points
88
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Pure Dart client SDK for Lantern, an in-memory graph key-vertex store with TTL-aware vertices and edges.

Homepage
Repository (GitHub)
View/report issues
Contributing

Topics

#connectrpc #graph #kvs #lantern

License

Apache-2.0 (license)

Dependencies

connectrpc, fixnum, protobuf

More

Packages that depend on lantern_client