Github|...

Flutter guide

Generate Dart models from your schema and build an offline-first mobile app.

Sp00ky provides Dart code generation for Flutter integration, allowing for natively typed data models.

Note

The Flutter/Dart client is currently in development. This guide shows the generated Dart models and how to use them with SurrealDB’s Dart client.

Generate Dart Models

Use the Sp00ky CLI to generate Dart models from your SurrealDB schema:

# Recommended: drive from sp00ky.yml's clientTypes entries
spky generate

# One-shot variant
spky --input ./schema/src/schema.surql --output ./lib/models.dart --format dart

This generates Dart classes with JSON serialization for your schema tables.

Generated Models Example

The CLI generates strongly-typed Dart classes for each table in your schema:

lib/models.dart
// Auto-generated by Sp00ky CLI
import 'dart:convert';

class Comment {
  /// Record ID of table: user
  String author;
  
  /// Assert: $value != NONE AND string::len($value) > 0
  String content;
  
  DateTime? createdAt;
  
  /// Record ID
  String id;
  
  /// Record ID of table: thread
  String threadId;

  Comment({
    required this.author,
    required this.content,
    this.createdAt,
    required this.id,
    required this.threadId,
  });

  factory Comment.fromJson(Map<String, dynamic> json) => Comment(
    author: json["author"],
    content: json["content"],
    createdAt: json["created_at"] == null 
      ? null 
      : DateTime.parse(json["created_at"]),
    id: json["id"],
    threadId: json["thread_id"],
  );

  Map<String, dynamic> toJson() => {
    "author": author,
    "content": content,
    "created_at": createdAt?.toIso8601String(),
    "id": id,
    "thread_id": threadId,
  };
}

class Thread {
  String author;
  String content;
  DateTime? createdAt;
  String id;
  String title;

  Thread({
    required this.author,
    required this.content,
    this.createdAt,
    required this.id,
    required this.title,
  });

  factory Thread.fromJson(Map<String, dynamic> json) => Thread(
    author: json["author"],
    content: json["content"],
    createdAt: json["created_at"] == null 
      ? null 
      : DateTime.parse(json["created_at"]),
    id: json["id"],
    title: json["title"],
  );

  Map<String, dynamic> toJson() => {
    "author": author,
    "content": content,
    "created_at": createdAt?.toIso8601String(),
    "id": id,
    "title": title,
  };
}

// SURQL Schema constant
const String SURQL_SCHEMA = """
DEFINE TABLE thread SCHEMAFULL
  PERMISSIONS
    FOR select WHERE true
    FOR update, delete, create WHERE $access = "account" AND author.id = $auth.id;
    
DEFINE FIELD title ON TABLE thread TYPE string
  ASSERT $value != NONE AND string::len($value) > 0;
// ... etc
""";

Usage with SurrealDB Dart Client

Use the generated models with the official SurrealDB Dart client:

lib/database.dart
import 'package:surrealdb/surrealdb.dart';
import 'models.dart';

class Database {
  late Surreal db;
  
  Future<void> init() async {
    db = Surreal();
    // `spky dev` exposes SurrealDB on 8666 by default.
    await db.connect('ws://localhost:8666/rpc');
    await db.use('main', 'main');
  }
  
  // Query threads
  Future<List<Thread>> getThreads() async {
    final result = await db.query(
      'SELECT * FROM thread ORDER BY created_at DESC LIMIT 20'
    );
    
    final List<dynamic> data = result.first;
    return data.map((json) => Thread.fromJson(json)).toList();
  }
  
  // Create a thread
  Future<Thread> createThread({
    required String title,
    required String content,
    required String authorId,
  }) async {
    final result = await db.create('thread', {
      'title': title,
      'content': content,
      'author': authorId,
      'created_at': DateTime.now().toIso8601String(),
    });
    
    return Thread.fromJson(result.first);
  }
  
  // Update a thread
  Future<void> updateThread(String id, Map<String, dynamic> updates) async {
    await db.merge(id, updates);
  }
  
  // Delete a thread
  Future<void> deleteThread(String id) async {
    await db.delete(id);
  }
  
  // Authenticate
  Future<void> signIn(String username, String password) async {
    await db.signin({
      'AC': 'account',
      'username': username,
      'password': password,
    });
  }
  
  Future<void> signUp(String username, String password) async {
    await db.signup({
      'AC': 'account',
      'username': username,
      'password': password,
    });
  }
}

Flutter UI Example

Here’s how to use the database in a Flutter widget:

lib/screens/threads_screen.dart
import 'package:flutter/material.dart';
import '../database.dart';
import '../models.dart';

class ThreadsScreen extends StatefulWidget {
  @override
  _ThreadsScreenState createState() => _ThreadsScreenState();
}

class _ThreadsScreenState extends State<ThreadsScreen> {
  final Database _db = Database();
  List<Thread> _threads = [];
  bool _isLoading = true;
  
  @override
  void initState() {
    super.initState();
    _loadThreads();
  }
  
  Future<void> _loadThreads() async {
    try {
      await _db.init();
      final threads = await _db.getThreads();
      setState(() {
        _threads = threads;
        _isLoading = false;
      });
    } catch (e) {
      print('Error loading threads: $e');
      setState(() => _isLoading = false);
    }
  }
  
  @override
  Widget build(BuildContext context) {
    if (_isLoading) {
      return Scaffold(
        body: Center(child: CircularProgressIndicator()),
      );
    }
    
    return Scaffold(
      appBar: AppBar(title: Text('Threads')),
      body: ListView.builder(
        itemCount: _threads.length,
        itemBuilder: (context, index) {
          final thread = _threads[index];
          return ListTile(
            title: Text(thread.title),
            subtitle: Text(thread.content),
            trailing: Text(
              thread.createdAt?.toString() ?? '',
              style: TextStyle(fontSize: 12),
            ),
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _createThread,
        child: Icon(Icons.add),
      ),
    );
  }
  
  Future<void> _createThread() async {
    // Show dialog and create thread
    // Implementation details omitted for brevity
  }
}

Images and files

Bucket reads are not HTTP: they are SurrealQL over the sync socket, one at a time behind the app’s own queries, so a cover that is re-read on every screen is paid for in full every time. bucket().read() goes through a local blob cache instead. Give it a directory and the bytes stay on disk under <directory>/<user>/<bucket>/<path>; a warm read never touches the socket, and spooky_flutter’s BucketImage adds Flutter’s own decoded-image cache on top, so a shelf scrolled back into view paints on its first frame.

lib/sync/database.dart
// pubspec.yaml
// dependencies:
//   spooky_core:
//     git: { url: https://github.com/mono424/sp00ky.git, path: packages/spooky_core }
//   spooky_flutter:
//     git: { url: https://github.com/mono424/sp00ky.git, path: packages/spooky_flutter }

final client = Sp00kyClient(Sp00kyConfig(
  database: DatabaseConfig(endpoint: endpoint, namespace: 'app', database: 'app'),
  schema: spookySchema,
  schemaSurql: surqlSchema,
  // Keep bucket files between runs. Without a directory the cache is
  // in-memory: reads still dedupe, nothing survives a restart.
  blobCache: BlobCacheConfig(
    directory: '${(await getApplicationSupportDirectory()).path}/sp00ky-blobs',
  ),
));

// Anywhere in the widget tree:
BucketImage(
  bucket: client.bucket('puzzle_covers'),
  path: '${coverKey}_t.webp',
  fallback: const CoverFallback(),
  fit: BoxFit.cover,
)

// Or the bytes, for anything that is not an <img>:
final Uint8List? pdf = await client.bucket('exports').read('report.pdf');

BucketImage shows a cached image as soon as it has decoded, with no placeholder flash and no fade; only a load that outlives instantWindow (120 ms by default, in practice a first download) paints the fallback and fades the image in over it.

The cache is the same shape as the web client’s (buckets): files never expire by age, the least recently used are evicted past maxBytes (default 256 MB), files are kept per signed-in user, and put/delete on a path invalidate it. A missing file is remembered for the process, so a row pointing at a cover that was never uploaded costs one remote read, not one per rebuild. bucket.evict(path) and bucket.prefetch(paths) give the app manual control; clearOnSignOut: true wipes a user’s files when they sign out.

Startup

await client.init() restores the circuit from a snapshot of the local store and reconciles it against the cached rows, without touching the network, so a query registered right after it paints from cache. The core writes that snapshot itself while the circuit has changed (shortly after boot, then every circuitCheckpointMs, default 30 s); also call client.checkpoint() when the app is paused, since it is free when nothing changed. A store without a snapshot still boots, from its rows, and gets one on the next checkpoint.

To have a screen’s first frame already hold its rows, keep that query open from startup (subscribe once, share the latest rows) rather than subscribing when the screen mounts.

Next steps

Pair this with a state-management solution such as Riverpod for reactive data binding.