pub.dev packagepub.dev version

Tecfy Database

A fast, realtime, JSON-based, index-driven database for Flutter — built on SQLite.

Store plain Dart Map<String, dynamic> documents like a NoSQL store (Firestore-style collection / doc API), while getting the raw speed of native SQLite indexes for the fields you actually query on.

AndroidiOSmacOSWindowsLinuxWeb
dart
import 'package:flutter/material.dart';
import 'package:tecfy_database/tecfy_database.dart';

late TecfyDatabase db;

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // 1. Declare your collections and which fields should be indexed.
  db = TecfyDatabase(
    dbName: 'app.db', // optional, defaults to "tecfy_db.db"
    collections: [
      TecfyCollection('tasks', tecfyIndexFields: [
        [TecfyIndexField(name: 'title',     type: FieldTypes.text, nullable: false)],
        [TecfyIndexField(name: 'isDone',    type: FieldTypes.boolean, asc: false)],
        [TecfyIndexField(name: 'createdAt', type: FieldTypes.datetime, asc: false)],
      ]),
    ],
  );

  // 2. Wait until the database file is opened and tables are ready.
  await db.isReady();

  runApp(const MyApp());
}

// 3. Write a document — any JSON-shaped Map works.
await db.collection('tasks').add(data: {
  'title': 'Buy milk',
  'isDone': false,
  'createdAt': DateTime.now(),
  'notes': {'priority': 'high'}, // non-indexed nested data is fine
});

// 4. Render it live with a StreamBuilder — UI updates automatically on every write.
StreamBuilder<List<Map<String, dynamic>>>(
  stream: db.collection('tasks').stream(orderBy: 'createdAt DESC'),
  builder: (context, snapshot) {
    final tasks = snapshot.data ?? [];
    return ListView(
      children: [for (final t in tasks) ListTile(title: Text(t['title']))],
    );
  },
);

Why Tecfy Database?

Most local database choices force a trade-off. Tecfy Database gives you all three.

ApproachFlexible schemaFast indexed queriesRealtime updates
Raw SQLite❌ rigid columns✅❌
Key/value (Hive, shared_prefs)✅❌ full scans⚠️ limited
Tecfy Database✅✅✅

Flexible schema

Store any Map<String, dynamic> shape as a JSON document — no rigid column definitions.

Fast indexed queries

Declare the fields you query on; they become real, typed, B-tree-indexed SQLite columns.

Realtime streams

Broadcast streams re-emit on every notifying write — plug straight into a StreamBuilder.

Cross-platform

Runs everywhere Flutter does: Android, iOS, macOS, Windows, Linux, and Web.

Automatic migration

Index changes are reconciled automatically on startup — just edit your collection declarations.

Batch operations

Atomic single-commit batches make bulk writes dramatically faster and fire one notification.

How it works

Every collection is a real SQLite table. Each document you insert is stored in full as JSON inside a hidden tecfy_json_bodycolumn — that's what makes the store schemaless. On top of that, each index field you declare is materialized as a real, typed, indexed SQLite column that mirrors a value from your JSON.

storage layout
Document you write:                  How it is stored in the "users" table:
{                                    +----------+--------------+-------------------------------+
  "name": "Sara",                    | name     | createdAt    | tecfy_json_body               |
  "mobile": "0100...",      ----->   | (indexed)| (indexed)    | (full JSON document)          |
  "createdAt": <DateTime>,           +----------+--------------+-------------------------------+
  "address": { "city": "Cairo" }     | "Sara"   | 1717000000.. | {"name":"Sara","mobile":...}  |
}                                     +----------+--------------+-------------------------------+
       indexed columns <--- duplicated ---+                ^
                                                           +- everything (including non-indexed
                                                              fields like "address") lives here

The design rule is simple: index the fields you query on, leave everything else in the document. Queries run against the indexed columns, so they stay fast as the table grows; reads always return your original document.

Installation

Add the package to your pubspec.yaml:

pubspec.yaml
dependencies:
  tecfy_database: ^1.1.0

Or pull it directly from Git:

pubspec.yaml
dependencies:
  tecfy_database:
    git:
      url: https://github.com/tecfy-co/flutter_tecfy_database.git

Then run:

bash
flutter pub get

No extra setup is required for Android, iOS, macOS, Windows, or Linux — the right SQLite backend is selected automatically at runtime.

Web setup

On the web, SQLite runs through WebAssembly, so you must ship two binaries in your app's web/ folder: sqlite3.wasm and sqflite_sw.js. Copy the matching binary versions following the official setup guide.

Quick start

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

late TecfyDatabase db;

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // 1. Declare your collections and which fields should be indexed.
  db = TecfyDatabase(
    dbName: 'app.db', // optional, defaults to "tecfy_db.db"
    collections: [
      TecfyCollection('tasks', tecfyIndexFields: [
        [TecfyIndexField(name: 'title',     type: FieldTypes.text, nullable: false)],
        [TecfyIndexField(name: 'isDone',    type: FieldTypes.boolean, asc: false)],
        [TecfyIndexField(name: 'createdAt', type: FieldTypes.datetime, asc: false)],
      ]),
    ],
  );

  // 2. Wait until the database file is opened and tables are ready.
  await db.isReady();

  runApp(const MyApp());
}

// 3. Write a document — any JSON-shaped Map works.
await db.collection('tasks').add(data: {
  'title': 'Buy milk',
  'isDone': false,
  'createdAt': DateTime.now(),
  'notes': {'priority': 'high'}, // non-indexed nested data is fine
});

// 4. Render it live with a StreamBuilder — UI updates automatically on every write.
StreamBuilder<List<Map<String, dynamic>>>(
  stream: db.collection('tasks').stream(orderBy: 'createdAt DESC'),
  builder: (context, snapshot) {
    final tasks = snapshot.data ?? [];
    return ListView(
      children: [for (final t in tasks) ListTile(title: Text(t['title']))],
    );
  },
);

Defining collections

A TecfyCollection maps to one SQLite table. Declare them once, up front:

dart
TecfyDatabase(
  collections: [
    TecfyCollection('tasks', tecfyIndexFields: [...]),
    TecfyCollection('users', tecfyIndexFields: [...]),
    TecfyCollection('roles'), // no indexes — pure JSON store, queryable only by id
  ],
);

Index fields & composite indexes

tecfyIndexFields is a list of indexes, where each index is itself a list of fields. A single-element list creates a single-column index; a multi-element list creates a composite index.

dart
TecfyCollection('tasks', tecfyIndexFields: [
  // Composite index on (title, desc) — great for queries that filter/sort by both.
  [
    TecfyIndexField(name: 'title', type: FieldTypes.text, nullable: false),
    TecfyIndexField(name: 'desc',  type: FieldTypes.integer),
  ],

  // Single-column index, sorted descending.
  [TecfyIndexField(name: 'isDone', type: FieldTypes.boolean, asc: false)],

  // Single-column datetime index, newest first.
  [TecfyIndexField(name: 'createdAt', type: FieldTypes.datetime, asc: false)],
]);

Primary keys

By default every collection gets an auto-incrementing integer primary key called id. To use your own (e.g. a UUID), pass a primaryField:

dart
TecfyCollection(
  'users',
  primaryField: TecfyIndexField(name: 'uid', type: FieldTypes.text),
  tecfyIndexFields: [
    [TecfyIndexField(name: 'name', type: FieldTypes.text, nullable: false)],
  ],
);

await db.collection('users').add(data: {'uid': 'u_123', 'name': 'Sara'});
await db.collection('users').doc('u_123').get(); // look up by your key

Field types

dart
enum FieldTypes { integer, real, text, blob, boolean, datetime }
TypeDart valueStored / read back
integerintstored as INTEGER
realdoublestored as REAL
textStringstored as TEXT
blobList<int>stored as BLOB
booleanboolstored as 1/0, read back as bool
datetimeDateTimestored as epoch integer, read back as DateTime

Writing data

dart
final tasks = db.collection('tasks');

// Insert. Returns true on success, false if a UNIQUE constraint blocks it.
final ok = await tasks.add(data: {
  'title': 'Write docs',
  'isDone': false,
  'createdAt': DateTime.now(),
});

// Update by primary key. `notifier: true` pushes the change to open streams.
await tasks.doc(taskId).update(
  data: {'title': 'Write docs', 'isDone': true, 'createdAt': DateTime.now()},
  notifier: true,
);

// Delete by primary key.
await tasks.doc(taskId).delete(notifier: true);

// Clear an entire collection (deletes all rows, keeps the table/schema).
await tasks.clear();

By default add notifies listeners; update and delete do not unless you opt in with notifier: true. update replaces the stored document — read it first and merge for partial updates.

Reading data

dart
final col = db.collection('users');

// Get every document (optionally ordered / grouped by an indexed column).
final all = await col.get(orderBy: 'name ASC');

// Get one document by primary key.
final user = await col.doc('u_123').get(); // Map<String, dynamic>?

// Existence check by primary key.
final has = await col.exists('u_123'); // bool

Querying & filters

Filters are built from three composable types: TecfyDbFilter (a single condition), TecfyDbAnd (all must match), and TecfyDbOr (any matches). And/Or can be nested arbitrarily.

dart
// Simple condition
final done = await db.collection('tasks').search(
  filter: TecfyDbFilter('isDone', TecfyDbOperators.isEqualTo, true),
  orderBy: 'createdAt DESC',
  limit: 20,
);

// Combined: title starts with "Re" AND created after a date.
// NOTE: datetime index columns compare as epoch ints — pass millisecondsSinceEpoch.
final recentReplies = await db.collection('tasks').search(
  filter: TecfyDbAnd([
    TecfyDbFilter('title', TecfyDbOperators.startWith, 'Re'),
    TecfyDbFilter('createdAt', TecfyDbOperators.isGreaterThan,
        DateTime.now().subtract(const Duration(days: 7)).millisecondsSinceEpoch),
  ]),
);

// Nested AND / OR
final filter = TecfyDbOr([
  TecfyDbFilter('isDone', TecfyDbOperators.isEqualTo, true),
  TecfyDbAnd([
    TecfyDbFilter('title', TecfyDbOperators.contains, 'urgent'),
    TecfyDbFilter('desc',  TecfyDbOperators.isNotEqualTo, null),
  ]),
]);

Operators

OperatorSQLNotes
isEqualTo=
isNotEqualTo!=
isGreaterThan>
isGreaterThanOrEqualTo>=
isLessThan<
isLessThanOrEqualTo<=
startWithLIKE 'value%'prefix match
endWithLIKE '%value'suffix match
containsLIKE '%value%'substring match
arrayInIN (...)value is a List
isNullIS NULL / IS NOT NULLvalue: true → is null, value: false → is not null

Search variants

dart
// Full result list
Future<List<Map<String, dynamic>>> search({filter, groupBy, having, orderBy, limit, offset});

// Count matching rows
Future<int?> searchCount({filter});

// Cheap existence check (LIMIT 1)
Future<bool> searchAny({filter});

Remember: field, orderBy, and groupBy must reference indexed columns. Fields that live only inside the JSON body are not queryable.

Realtime streams

Streams are broadcast Streams that re-emit whenever the collection changes via a notifying write — the natural fit for a Flutter StreamBuilder.

dart
// Live list, with optional filter + ordering
Stream<List<Map<String, dynamic>>> tasks =
    db.collection('tasks').stream(
      filter: TecfyDbFilter('isDone', TecfyDbOperators.isEqualTo, false),
      orderBy: 'createdAt DESC',
    );

// Live count
Stream<int> openCount = db.collection('tasks').count(
  filter: TecfyDbFilter('isDone', TecfyDbOperators.isEqualTo, false),
);

// Live single document
Stream<Map<String, dynamic>> task = db.collection('tasks').doc(taskId).stream();

Batch operations

For bulk writes, use a single SQLite batch so everything commits together — far faster than many individual awaits, and it only fires one notification.

dart
final col = db.collection('tasks');
final batch = col.getBatch();

for (final item in incoming) {
  await col.add(data: item, batch: batch);              // queued, not yet written
}
await col.doc(id).update(data: {...}, batch: batch);     // queued
await col.doc(oldId).delete(batch: batch);               // queued

// Commit everything atomically, then notify streams once.
await col.commitBatch(batch: batch, notify: true);

Schema changes & automatic migration

When the app starts, Tecfy compares your declared index fields against the SQLite file and reconciles them automatically: new index fields add a column and backfill from existing JSON; removed index fields drop the column; added/removed indexes are created or dropped; a changed primary key drops and recreates the table.

⚠️ Dropping an index field removes its column, and changing the primary key drops the table (its rows are lost). Your non-indexed document data is always safe in the JSON body as long as the table isn't dropped.

API reference

TecfyDatabase

dart
TecfyDatabase({required List<TecfyCollection> collections, String? dbName});

Future<bool> isReady();              // resolves once the DB file is open & ready
TecfyCollectionOperations collection(String name);  // throws if not declared
Future<void> clearDb();              // delete all rows in every collection
void dispose();                      // close the database

Collection — db.collection(name)

dart
Future<bool>                        add({required data, toEncodableEx, nullColumnHack, conflictAlgorithm, notify = true, batch});
Future<List<Map<String, dynamic>?>> get({orderBy, groupBy});
Future<List<Map<String, dynamic>>>  search({filter, groupBy, having, orderBy, limit, offset});
Future<int?>                        searchCount({filter});
Future<bool>                        searchAny({filter});
Future<bool>                        exists(dynamic id);
Future<bool>                        clear();                // delete all docs in this collection
Stream<List<Map<String, dynamic>>>  stream({filter, orderBy});
Stream<int>                         count({filter});
TecfyDocumentOperations             doc([dynamic id]);
Batch?                              getBatch();
Future<List<Object?>?>              commitBatch({required batch, notify = true, exclusive, noResult, continueOnError});
void                               refreshListers();        // force-refresh this collection's streams

Document — db.collection(name).doc(id)

dart
Future<Map<String, dynamic>?>      get();
Future<bool>                       update({required data, toEncodableEx, conflictAlgorithm, batch, notifier = false});
Future<bool>                       delete({notifier = false, batch});
Stream<Map<String, dynamic>>       stream({filter, orderBy});

Benchmarks

Indicative numbers only (in-memory FFI backend, Flutter 3.44.1 / Dart 3.12.1, Windows desktop). Your results will vary with hardware, payload size, and platform.

OperationCountTimePer op
Batch insert5,000 docs229 ms0.046 ms/doc
Indexed point query1,000254 ms0.254 ms/query
Full-scan lookup (no index)2001223 ms6.115 ms/lookup
Update1,000214 ms0.214 ms/op
Delete1,000188 ms0.188 ms/op

Best practices & gotchas

  • Index what you query, nothing more. Each index field adds a column + index (write cost + storage). Fields you only ever read can stay in the JSON body.
  • You can't filter/sort by non-indexed fields. If you need to query a field, declare it as an index field — even a single-column index is enough.
  • update replaces the document. Merge with the current value yourself for partial updates.
  • Set notify/notifier for live UIs. update/delete don't refresh streams unless you opt in.
  • Use batches for bulk writes. One commitBatch is dramatically faster and emits a single notification.
  • add returns false on a UNIQUE constraint instead of throwing — check the result when inserting with unique keys.
  • Always await db.isReady() before the first operation.

FAQ

Is this a real NoSQL database?

No — it's SQLite under the hood with a document-style API. You get schemaless JSON documents plus typed, indexed columns for the fields you query.

Can I query a field that isn't indexed?

No. Only declared index fields are queryable (search / filter / orderBy / groupBy). Non-indexed fields are stored and returned in the document but not directly queryable.

Does it support transactions?

It exposes Batch for atomic, single-commit writes. There is no separate transaction() API.

How do I do a partial update?

update replaces the whole document. Read it first (await doc(id).get()), merge, then update.

Is it null-safe / which SDKs?

Dart >=2.19.6 <4.0.0, Flutter >=1.17.0.

Troubleshooting

no such table right after startup

You didn't await db.isReady() before your first query. Always await it.

Web: databaseFactoryFfiWeb / missing wasm

Copy sqlite3.wasm and sqflite_sw.js into web/ (see Web setup).

Filtering by a DateTime throws Invalid argument

datetime index columns store an epoch integer. Pass the epoch value (yourDate.millisecondsSinceEpoch) as the filter value, not a DateTime object. add/get of DateTime fields works directly; only filter values need the integer form.

add returned false

A UNIQUE constraint (usually a duplicate primary key) blocked the insert. It returns false instead of throwing.

Stream didn't update

The write must notify: update/delete need notifier: true; commitBatch needs notify: true (default true).

Migration guide

Evolving your schema

Edit your TecfyCollection declarations and restart — Tecfy reconciles automatically. Adding or removing index fields is safe for your document data (it lives in the JSON body). Changing a primary key drops and recreates the table, so migrate that data yourself first.

Upgrading to 1.2.0

  • •New optional TecfyDatabase params databaseFactory and inMemory — backward compatible; existing constructors are unaffected.
  • •dispose() now returns Future<void> so you can await a clean close.
  • •New exports: DatabaseFactory, databaseFactoryFfi, sqfliteFfiInit, inMemoryDatabasePath (handy for writing your own tests).
  • •Bug fixes: custom (non-id) primary keys now work for doc() lookups and read-back; searchCount()/searchAny()/count() with no filter now count all rows (previously returned 0).

Platform support

PlatformBackendNotes / limitations
Androidsqflite—
iOSsqflite—
macOSsqflite—
Windowssqflite_common_ffiDB stored under the app documents directory.
Linuxsqflite_common_ffi—
Websqflite_common_ffi_webRequires sqlite3.wasm + sqflite_sw.js in web/; in-browser storage limits apply.

The correct backend is chosen automatically at runtime. This is a pure-Dart package relying on the sqflite family for native SQLite access.

Start building with Tecfy Database

Add it to your pubspec.yaml and ship realtime, index-driven storage everywhere Flutter runs.

↑↓to navigate
Need help?