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.
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.
| Approach | Flexible schema | Fast indexed queries | Realtime 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.
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 hereThe 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:
dependencies:
tecfy_database: ^1.1.0Or pull it directly from Git:
dependencies:
tecfy_database:
git:
url: https://github.com/tecfy-co/flutter_tecfy_database.gitThen run:
flutter pub getNo 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
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:
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.
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:
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 keyField types
enum FieldTypes { integer, real, text, blob, boolean, datetime }| Type | Dart value | Stored / read back |
|---|---|---|
integer | int | stored as INTEGER |
real | double | stored as REAL |
text | String | stored as TEXT |
blob | List<int> | stored as BLOB |
boolean | bool | stored as 1/0, read back as bool |
datetime | DateTime | stored as epoch integer, read back as DateTime |
Writing data
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
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'); // boolQuerying & 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.
// 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
| Operator | SQL | Notes |
|---|---|---|
isEqualTo | = | |
isNotEqualTo | != | |
isGreaterThan | > | |
isGreaterThanOrEqualTo | >= | |
isLessThan | < | |
isLessThanOrEqualTo | <= | |
startWith | LIKE 'value%' | prefix match |
endWith | LIKE '%value' | suffix match |
contains | LIKE '%value%' | substring match |
arrayIn | IN (...) | value is a List |
isNull | IS NULL / IS NOT NULL | value: true → is null, value: false → is not null |
Search variants
// 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.
// 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.
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
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 databaseCollection — db.collection(name)
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 streamsDocument — db.collection(name).doc(id)
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.
| Operation | Count | Time | Per op |
|---|---|---|---|
| Batch insert | 5,000 docs | 229 ms | 0.046 ms/doc |
| Indexed point query | 1,000 | 254 ms | 0.254 ms/query |
| Full-scan lookup (no index) | 200 | 1223 ms | 6.115 ms/lookup |
| Update | 1,000 | 214 ms | 0.214 ms/op |
| Delete | 1,000 | 188 ms | 0.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
| Platform | Backend | Notes / limitations |
|---|---|---|
| Android | sqflite | — |
| iOS | sqflite | — |
| macOS | sqflite | — |
| Windows | sqflite_common_ffi | DB stored under the app documents directory. |
| Linux | sqflite_common_ffi | — |
| Web | sqflite_common_ffi_web | Requires 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.