tostore 3.5.0-test
tostore: ^3.5.0-test copied to clipboard
Fast distributed AI vector database and persistent local storage engine. High-performance key-value store supporting SQL, NoSQL, offline cache and encrypted data.
[ToStore]
English | 简体中文 | 日本語 | 한국어 | Español | Português (Brasil) | Русский | Deutsch | Français | Italiano | Türkçe
Quick Navigation #
- Why ToStore | Key Features | Installation Guide | KV Mode | Table Mode | Memory Mode
- Schema Definition | Distributed Architecture | Cascading Foreign Keys | Mobile/Desktop | Server/Agent | Primary Key Algorithms
- Advanced Queries (JOIN) | Aggregation & Statistics | Complex Logic (QueryCondition) | Reactive Query (watch) | Streaming Query
- Advanced KV | Bulk Operations | Vector & Hybrid Retrieval | Table-level TTL | Efficient Pagination | Memory Probe & Sync Retrieval (peek) | Query Cache | Atomic Expressions | Transactions
- Administration | Security Configuration | Error Handling | Performance & Diagnostics | Contributing | AI assistants
Why Choose ToStore? #
ToStore is a modern data engine designed for the AGI era and edge intelligence scenarios. Built on a Self-Routing node architecture, it gives nodes high autonomy and elastic horizontal scalability while logically decoupling performance from data scale.
Runtime modeling and non-blocking execution paths keep architecture evolution always online and fully transparent to business operations—declarative schema changes, data encoding key rotation, and massive data refactoring all happen seamlessly online. Built for Agent and automated O&M, it underpins their autonomous evolution and continuous iteration without interrupting service.
A unified data engine natively supporting relational structured data, high-dimensional vectors, and unstructured data, with built-in hybrid retrieval and multi-way recall fusion, plus enterprise-grade database capabilities including ACID transactions, complex relational queries (JOINs, cascading foreign keys), table-level TTL, aggregations, as well as distributed primary key algorithms, atomic expressions, encryption, multi-space isolation, and self-healing recovery.
As computing continues shifting toward edge intelligence, devices are no longer just "content displays". They are intelligent nodes responsible for local generation, environmental awareness, real-time decision-making, and coordinated data flows. ToStore gives the edge distributed capabilities strong enough for massive datasets and complex local AI generation. Deep intelligent collaboration between edge and cloud nodes provides a reliable data foundation for multi-modal interaction, semantic vector hybrid retrieval, spatial modeling, edge autonomous collaboration, and similar scenarios.
Key Features #
-
🤖 Runtime Evolution & Intelligent O&M
- Declarative schema changes—automatic refactoring, no manual versioning
- Key rotation, schema migration, massive refactoring—all online, business-transparent
- Built-in status spec for automated O&M and Agent recognition
- Architecture evolves continuously without interrupting service
-
🧠 Self-Routing Distributed Architecture
- Self-routing node architecture that decouples physical addressing from data scale
- Highly autonomous nodes collaborate to build flexible data topology
- Elastic horizontal scaling with deep edge-cloud node interconnection
-
🌐 Unified Cross-Platform Data Engine
- Unified API across mobile, desktop, web, and server environments
- Covers relational structured data, high-dimensional vectors, and unstructured data
- Complete data pipeline from local storage to edge-cloud collaboration
-
🔍 Structured Queries & Hybrid Retrieval
- Complex predicates, JOINs, aggregations, and table-level TTL
- Multi-channel recall on one query chain (vector + structured and more)
- Multi-way recall fusion ranking, with scores and channel diagnostics on the result
-
⚡ Parallel Execution & Resource Scheduling
- Cold start stays ~35 ms regardless of data scale
- Resource-aware load scheduling; multi-node parallel task decomposition
- Time-slicing keeps UI animations smooth even under heavy load
-
🔐 Data Security & Isolation
- Multi-space isolation with optional global sharing—multi-user / multi-tenant ready
- Built-in ChaCha20-Poly1305 and AES-256-GCM encryption
- Validated through multiple complex disaster recovery scenarios
Installation #
Important
Upgrading from v2.x? Please read the v3.x Upgrade Guide for critical migration steps and breaking changes.
Add tostore to your pubspec.yaml:
dependencies:
tostore: any # Please use the latest version
For AI Coding Assistants #
When generating ToStore client code with an AI assistant, give it the single-file corpus llms-full.txt — for example @llms-full.txt in the IDE, upload/paste the file, or index its raw URL in the assistant's docs. That file carries API signatures, constraints, and anti-patterns so the model stays aligned with the real public surface. Discovery index: llms.txt.
Quick Start #
Tip
How should you choose a storage mode?
- Key-Value Mode (KV): Best for configuration access, scattered state management, or JSON data storage. It is the fastest way to get started.
- Structured Table Mode: Best for core business data that needs complex queries, constraint validation, or large-scale data governance. By pushing integrity logic into the engine, you can significantly reduce application-layer development and maintenance costs.
- Memory Mode: Best for temporary computation, unit tests, or ultra-fast global state management. With global queries and
watchlisteners, you can reshape application interaction without maintaining a pile of global variables.
Key-Value Storage (KV) #
This mode is suitable when you do not need predefined structured tables. It is simple, practical, and backed by a high-performance storage engine. Its efficient indexing architecture keeps query performance highly stable and extremely responsive even on ordinary mobile devices at very large data scales. Data in different Spaces is naturally isolated, while global sharing is also supported.
// Initialize the database
final db = await ToStore.open();
// Set key-value pairs (supports String, int, bool, double, Map, List, Json, and more)
await db.setValue('user_profile', {
'name': 'John',
'age': 25,
});
// Switch space - isolate data for different users
await db.switchSpace(spaceName: 'user_123');
// Set a globally shared variable (isGlobal: true enables cross-space sharing, such as login state)
await db.setValue('current_user', 'John', isGlobal: true);
// Automatic expiration cleanup (TTL)
// Supports either a relative lifetime (ttl) or an absolute expiration time (expiresAt)
await db.setValue('temp_config', 'value', ttl: Duration(hours: 2));
await db.setValue('session_token', 'abc', expiresAt: DateTime(2026, 2, 31));
// Read data
final profile = await db.getValue('user_profile'); // Map<String, dynamic>
// Listen for real-time value changes (useful for refreshing local UI without extra state frameworks)
db.watchValue('current_user', isGlobal: true).listen((value) {
print('Logged-in user changed to: $value');
});
// Listen to multiple keys at once
db.watchValues(['current_user', 'login_status']).listen((map) {
print('Multiple config values were updated: $map');
});
// Remove data
await db.removeValue('current_user');
Tip
Need more Key-Value features?
For advanced operations like type-safe getters (getInt, getBool), atomic increments, prefix-based discovery, chained paginated record queries (db.kv.query()), and key counting, see Advanced Key-Value Operations (db.kv).
Flutter UI Auto-Refresh Example
In Flutter, StreamBuilder plus watchValue gives you a very concise reactive refresh flow:
StreamBuilder(
// When listening to a global variable, remember to set isGlobal: true
stream: db.watchValue('current_user', isGlobal: true),
builder: (context, snapshot) {
// snapshot.data is the latest value of 'current_user' in KV storage
final user = snapshot.data ?? 'Not logged in';
return Text('Current user: $user');
},
)
Structured Table Mode #
CRUD on structured tables requires the schema to be created in advance (see Schema Definition). Recommended integration approaches for different scenarios:
- Mobile/Desktop: For frequent startup scenarios, it is recommended to pass
schemasduring initialization. - Server/Agent: For long-running scenarios, it is recommended to create tables dynamically through
createTables.
// 1. Initialize the database
final db = await ToStore.open();
// 2. Insert data (prepare some base records)
final result = await db.insert('users', {
'username': 'John',
'email': 'john@example.com',
'age': 25,
});
// Unified operation result model: DbResult
// It is recommended to check hasErrors
if (!result.hasErrors) {
print('Insert succeeded, generated primary key ID: ${result.firstPrimaryKey}');
} else {
print('Insert failed: ${result.message}');
}
// Chained query (see [Query Operators](#query-operators); supports =, !=, >, <, LIKE, IN, and more)
final users = await db.query('users')
.where('age', '>', 20)
.where('username', 'like', '%John%')
.orderByDesc('age')
.limit(20);
// Update and delete
await db.update('users', {'age': 26}).where('username', '=', 'John');
await db.delete('users').where('username', '=', 'John');
// Real-time listening (see [Reactive Query](#reactive-query) for more details)
db.query('users').where('age', '>', 18).watch().listen((users) {
print('Users matching the condition have changed: $users');
});
// Pair with Flutter StreamBuilder for automatic local UI refresh
StreamBuilder(
stream: db.query('users').where('age', '>', 18).watch(),
builder: (context, snapshot) {
final users = snapshot.data ?? [];
return ListView.builder(
itemCount: users.length,
itemBuilder: (context, index) => Text(users[index]['username']),
);
},
);
Memory Mode #
For scenarios such as caching, temporary computation, or workloads that do not need persistence to disk, you can initialize a pure in-memory database via ToStore.memory(). In this mode, all data, including schemas, indexes, and key-value pairs, lives entirely in memory for maximum read/write performance.
💡 Also Works as Global State Management
You do not need a pile of global variables or a heavyweight state-management framework. By combining memory mode with watchValue or watch(), you can achieve fully automatic UI refresh across widgets and pages. It keeps the powerful retrieval abilities of a database while giving you a reactive experience far beyond ordinary variables, making it ideal for login state, live configuration, or global message counters.
Caution
Note: Data created in pure memory mode is completely lost after the app is closed or restarted. Do not use it for core business data.
// Initialize a pure in-memory database
final memDb = await ToStore.memory();
// Set a global state value (for example: unread message count)
await memDb.setValue('unread_count', 5, isGlobal: true);
// Listen from anywhere in the UI without passing parameters around
memDb.watchValue<int>('unread_count', isGlobal: true).listen((count) {
print('UI automatically sensed the message count change: $count');
});
// All CRUD, KV access, and vector search run at in-memory speed
await memDb.insert('active_users', {'name': 'Marley', 'status': 'online'});
Schema Definition #
Define once, and let the engine handle end-to-end automated governance so your application no longer carries heavy validation maintenance.
The following mobile, server-side, and agent examples all reuse appSchemas defined here.
TableSchema Overview #
const userSchema = TableSchema(
name: 'users', // Table name, required
tableId: 'users', // Unique identifier of the table, optional
primaryKeyConfig: PrimaryKeyConfig(
name: 'id', // Primary key field name, defaults to id
type: PrimaryKeyType.sequential, // Primary key auto-generation strategy
sequentialConfig: SequentialIdConfig(
initialValue: 1000, // Initial value for sequential IDs
increment: 1, // Step size
useRandomIncrement: false, // Whether to use random step sizes
),
),
fields: [
FieldSchema(
name: 'username', // Field name, required
type: DataType.text, // Field data type, required
nullable: false, // Whether null is allowed
minLength: 3, // Minimum length
maxLength: 32, // Maximum length
unique: true, // Whether it must be unique
fieldId: 'username', // Stable field identifier, optional, used to detect field renames
comment: 'Login name', // Optional comment
),
FieldSchema(
name: 'status',
type: DataType.integer,
minValue: 0, // Minimum numeric value
maxValue: 150, // Maximum numeric value
defaultValue: 0, // Static default value
createIndex: true, // Shortcut for creating an index
),
FieldSchema(
name: 'created_at',
type: DataType.datetime,
nullable: false,
defaultValueType: DefaultValueType.currentTimestamp, // Automatically fill with current time
createIndex: true,
),
],
indexes: const [
IndexSchema(
indexName: 'idx_users_status_created_at', // Optional index name
fields: ['status', 'created_at'], // Composite index fields
unique: false, // Whether it is a unique index
type: IndexType.btree, // Index type: btree/vector
),
],
foreignKeys: const [], // Optional foreign-key constraints; see "Foreign Keys & Cascading"
isGlobal: false, // Whether this is a global table; true means it can be shared across spaces
ttlConfig: null, // Optional table-level TTL; see "Table-level TTL"
);
const appSchemas = [userSchema];
-
Common
DataTypemappings:Type Corresponding Dart Type Description integerintStandard integer, suitable for IDs, counters, and similar data bigIntBigInt/StringLarge integers; recommended when numbers exceed 18 digits to avoid precision loss doubledoubleFloating-point number, suitable for prices, coordinates, and similar data textStringText string with optional length constraints blobUint8ListRaw binary data booleanboolBoolean value datetimeDateTime/StringDate/time; stored internally as ISO8601 arrayListList or array type jsonMap<String, dynamic>JSON object, suitable for dynamic structured data vectorVectorData/List<num>High-dimensional vector data for AI semantic retrieval (embeddings) -
PrimaryKeyTypeauto-generation strategies:Strategy Description Characteristics noneNo automatic generation You must manually provide the primary key during insertion sequentialSequential increment Good for human-friendly IDs, but less suitable for distributed performance timestampBasedTimestamp-based Recommended for distributed environments datePrefixedDate-prefixed Useful when date readability is important to the business shortCodeShort-code primary key Compact and suitable for external display All primary keys are stored as
text(String) by default.
Constraints & Auto-Validation #
You can write common validation rules directly into FieldSchema, avoiding duplicated logic in application code:
nullable: false: non-null constraintminLength/maxLength: text length constraintsminValue/maxValue: integer or floating-point range constraintsdefaultValue/defaultValueType: static default values and dynamic default valuesunique: unique constraintcreateIndex: create indexes for high-frequency filtering, sorting, or relationshipsfieldId/tableId: assist rename detection for fields and tables during migration
In addition, unique: true automatically creates a single-field unique index. createIndex: true and foreign keys automatically create single-field normal indexes. Use indexes when you need composite indexes, named indexes, or vector indexes.
Schema Evolution #
The engine automatically detects structural changes (adding, removing, or renaming tables/fields, attribute updates, index changes, and more) and completes data migration—no manual database versioning or migration scripts. Declarative schemas evolve on ToStore.open(); runtime changes use updateSchema—transparent to business logic, with uninterrupted reads and writes.
Promote a Unique Field to Primary Key
Promote an existing unique, non-null field to primary key (optional rename; works with existing data; business-transparent). Do not combine with setPrimaryKeyConfig.
- Mobile (declarative
schemas): Detected automatically onToStore.open(). The target primary key must usePrimaryKeyType.none(values come from the source unique field; other auto-generated PK types are not supported). Matching names are enough; for a rename, setfromFieldIdto the source field'sfieldId. - Server (runtime): Call
updateSchema(...).promoteFieldToPrimaryKey(sourceFieldName: ..., targetPrimaryKeyName: ...).targetPrimaryKeyNameis optional; omit it to keep the source field name.
Choosing an Integration Method #
- Mobile/Desktop: Best when passing
appSchemasdirectly intoToStore.open(...) - Server/Agent: Best when dynamically creating schemas at runtime via
createTables(appSchemas)
Mobile and Desktop Integration #
📱 Example: mobile_quickstart.dart
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
// On Android/iOS, resolve the app's writable directory first, then pass dbPath explicitly
final docDir = await getApplicationDocumentsDirectory();
final dbRoot = p.join(docDir.path, 'common');
// Reuse the appSchemas defined above
final db = await ToStore.open(
dbPath: dbRoot,
schemas: appSchemas,
);
// Multi-space architecture - isolate data for different users
await db.switchSpace(spaceName: 'user_123');
Tracking Startup Progress #
Normal schema changes are transparent to business logic and never block startup. Only in rare edge cases specific to mobile apps that are frequently force-closed (e.g., brief data validation and crash recovery after an abnormal exit) may initialization take noticeable time — use onStartupProgress to show a splash screen or progress indicator:
final db = await ToStore.open(
dbPath: dbRoot,
schemas: appSchemas,
onStartupProgress: (progress, stage) {
// progress: 0.0 – 1.0 | stage: opening → recovering → optimizing → ready
print('Startup ${(progress * 100).toStringAsFixed(0)}% [$stage]');
// Update splash screen / progress bar
},
);
// Database is fully ready here
Stages:
opening— Loading configuration and preparing the base enginerecovering— Security checks and crash recoveryoptimizing— Internal engine tuning and structural optimizationready— Initialization complete, ready for use
Keeping Login State & Logout (Active Space) #
Multi-space is ideal for isolating user data: one space per user, switched on login. With Active Space and close options, you can keep the current user across app restarts and support clean logout behavior.
- Keep login state: After switching a user into their own space, mark that space as active. Next launch can enter that space directly when opening the default instance, without a "default first, then switch" step.
- Logout: When the user logs out, close the database with
keepActiveSpace: false. The next launch will not automatically enter the previous user's space.
// After login: switch to the user's space and mark it active
await db.switchSpace(spaceName: 'user_$userId', keepActive: true);
// Optional: strictly stay in default when needed (for example, login screen only)
// final db = await ToStore.open(..., applyActiveSpaceOnDefault: false);
// On logout: close and clear the active space so the next launch starts from default
await db.close(keepActiveSpace: false);
Server and Agent Integration #
🖥️ Example: server_quickstart.dart
final db = await ToStore.open();
// Create table structures while the process is running
await db.createTables(appSchemas);
// Online schema updates
final result = await db.updateSchema('users')
.renameTable('users_new') // Rename table
.modifyField(
'username',
minLength: 5,
maxLength: 20,
unique: true
) // Modify field attributes
.renameField('old_name', 'new_name') // Rename field
.removeField('deprecated_field') // Remove field
.addField('created_at', type: DataType.datetime) // Add field
.removeIndex(fields: ['age']) // Remove index
.setPrimaryKeyConfig( // Change auto-generated PK strategy; avoid when the table already has data
const PrimaryKeyConfig(type: PrimaryKeyType.shortCode)
);
// Promote a unique field to PK (see "Promote a Unique Field to Primary Key"; do not chain with the above):
// await db.updateSchema('users').promoteFieldToPrimaryKey(
// sourceFieldName: 'user_id',
// targetPrimaryKeyName: 'uid', // optional; omit to keep the source field name
// );
// Monitor migration progress
final taskId = result.taskId;
if (taskId != null) {
// Inspect migration metadata
print('Estimated duration: ${result.estimateDuration?.inMilliseconds} ms');
print('Migration write mode: ${result.writeMode}'); // e.g. MigrationWriteMode.indexOnly
final status = await db.queryMigrationTaskStatus(taskId);
print('Migration progress: ${status?.progressPercentage}%');
}
// Optional performance tuning for pure server workloads
// yieldDurationMs controls how often long-running work yields time slices.
// The default is tuned to 8ms to keep frontend UI animations smooth.
// In environments without UI, 50ms is recommended for higher throughput.
final dbServer = await ToStore.open(
config: DataStoreConfig(yieldDurationMs: 50),
);
Advanced Usage #
ToStore provides a rich set of advanced capabilities for complex business scenarios:
Advanced Key-Value Operations (db.kv) #
For more complex Key-Value scenarios, it is recommended to use the db.kv namespace. It provides a complete set of APIs with space isolation, global sharing, multiple data types, and chained complex queries/filters (e.g. db.kv.query().prefix(...).orderBy...().limit(...) for pagination, sorting, expiry filtering, and more).
-
Basic Access
// Set value (supports String, int, bool, double, Map, List, etc.) await db.kv.set('key', 'value', ttl: Duration(hours: 1)); // Get raw dynamic value dynamic val = await db.kv.get('key'); // Remove a single key await db.kv.remove('key'); -
Type-Safe Getters Retrieve data directly in the target format without manual casting:
String? name = await db.kv.getString('user_name'); int? age = await db.kv.getInt('user_age'); bool? isVip = await db.kv.getBool('is_vip'); Map<String, dynamic>? profile = await db.kv.getMap('profile'); List<String>? tags = await db.kv.getList<String>('tags'); -
Bulk Operations Efficiently process multiple key-value pairs in a single operation:
// Bulk set await db.kv.setMany({ 'theme': 'dark', 'language': 'en_US', }); // Bulk remove await db.kv.removeKeys(['temp_1', 'temp_2']); -
Atomic Counters Safely increment or decrement numeric values in high-concurrency scenarios:
// Increment by 1 (default) await db.kv.setIncrement('view_count'); // Decrement by 5 (pass a negative amount) await db.kv.setIncrement('stock_count', amount: -5); -
Chained record queries (db.kv.query) Chainable API similar to
db.query(), for querying key-value records (including decodedvalue) with pagination.// First page: filter by prefix, newest updates first, 20 per page final page = await db.kv.query() .prefix('setting_') .orderByUpdatedAtDesc() // or orderByKeyAsc / orderByKeyDesc / orderByUpdatedAtAsc .limit(20); for (final record in page.data) { // record contains: key, value, updated_at, expires_at print('${record['key']} = ${record['value']}'); } // Recommended: page with next() / prev() (same as table queries; simplest) if (page.hasMore) { final page2 = await page.next(); print('Next page: ${page2.data.length}'); if (page2.hasPrev) { final back = await page2.prev(); print('Previous page: ${back.data.length}'); } } // Offset pagination (mutually exclusive with cursor; prefer next() above for deep pages) final byOffset = await db.kv.query() .orderByKeyAsc() .limit(20) .offset(20); // Total matching records (O(1) metadata count when no prefix) final total = await db.kv.query().prefix('setting_').count(); // First matching record final first = await db.kv.query().prefix('setting_').orderByKeyAsc().first(); // Global KV space final globalPage = await db.kv.query(isGlobal: true).limit(50); // Expired records are filtered by default; include uncleared expired ones with: final withExpired = await db.kv.query() .includeExpired() .limit(20);Common chain methods:
Method Description prefix(String)Filter by key prefix orderByKeyAsc/orderByKeyDescSort by key (primary key) orderByUpdatedAtAsc/orderByUpdatedAtDescSort by updated_atlimit(n)Max rows for this page (always specify explicitly) offset(n)Offset pagination (clears cursor) cursor(token)Special cases only: pass a pagination token across process/network includeExpired([true])Include expired records that have not been cleaned up yet count()Count matching records first()Return the first matching record (does not change the builder's limit) Query result
QueryResult: for everyday paging usehasMore/hasPrev+next()/prev();nextCursorToken/prevCursorTokenare only for cross-boundary transfer (same usage as table queries). -
Discovery & Management
// Enumerate key names only (no values); optional prefix / limit / offset final keys = await db.kv.getKeys(prefix: 'setting_'); final pageKeys = await db.kv.getKeys( prefix: 'setting_', limit: 100, offset: 0, ); // Count total keys in the current space final count = await db.kv.count(); // Check if a key exists and is not expired final exists = await db.kv.exists('config_cache'); // Memory probe (sync, in-memory only — see [Memory Probe and Sync Retrieval (peek)](#memory-probe-and-sync-retrieval-peek)) final theme = db.kv.peekGet('theme') ?? await db.kv.get('theme'); if (db.kv.peekExists('config_cache')) { /* ... */ } // Clear all KV data in the current space await db.kv.clear(); -
Lifecycle Management (TTL) Inspect or update expiration settings for existing keys:
// Get remaining duration Duration? ttl = await db.kv.getTtl('token'); // Update TTL for an existing key (expires in 7 days) await db.kv.setTtl('token', Duration(days: 7)); -
Reactive Watching
// Watch a single key db.kv.watch<int>('unread_count').listen((count) => print(count)); // Watch a snapshot of multiple keys db.kv.watchValues(['theme', 'font_size']).listen((map) => print(map)); -
Global Sharing (isGlobal) All the above methods support the optional
isGlobalparameter:truefor global space (shared across all spaces),false(default) for the current isolated space.
Bulk Operations #
ToStore provides specialized bulk processing interfaces optimized for large-scale data throughput. These interfaces utilize parallel task distribution and time-slicing to ensure UI responsiveness during heavy write operations.
| Method | Core Purpose | Data Requirements | Characteristics |
|---|---|---|---|
batchInsert |
Insert new records in bulk | Must contain all non-nullable fields | Pure insert, highest performance |
batchUpsert |
Insert or update (upsert) in bulk | Must contain all non-nullable fields | Full synchronization, identified by Primary Key or Unique Field |
batchUpdate |
Update existing records in bulk | Primary Key or Unique Field + Update Fields | Partial updates for existing records |
-
Bulk Insert (batchInsert)
await db.batchInsert('users', [ {'username': 'user1', 'email': '1@ex.com'}, {'username': 'user2', 'email': '2@ex.com'}, ]); -
Intelligent Bulk Synchronization (batchUpsert) Automatically identifies "Insert" or "Update" based on Primary Key or Unique Fields. Common for full data synchronization.
Important
Data Requirements: Since an insert might be triggered,
batchUpsertrequires every record to contain all non-nullable (nullable: false) fields. -
High-Performance Bulk Update (batchUpdate) Specifically for updating existing records. Each record must include a Primary Key or Unique Field as the identifier, along with the fields to be modified.
Tip
Partial Updates:
batchUpdateonly modifies the provided fields and does not require all non-nullable fields, making it ideal for incremental updates.await db.batchUpdate('users', [ {'username': 'john', 'age': 27}, // Identify by unique field 'username' and update 'age' {'id': '1002', 'status': 'active'}, // Can also use Primary Key directly ]);
Tip
You can set allowPartialErrors: true to ensure that individual record failures (e.g., a single constraint violation) do not reject the entire batch operation.
Vector Fields, Vector Indexes, and Hybrid Retrieval #
Vector retrieval uses the unified db.query(...).matchVector(...) query chain: it can be combined with structured predicates on the same chain, or fused with other recall branches. Scores and channel diagnostics are returned in QueryResult.retrieval, aligned 1:1 with data rows. Current examples focus on vector + structured paths; lexical, graph, and other channels will extend along the same chained hybrid retrieval model.
await db.createTables([
const TableSchema(
name: 'embeddings',
primaryKeyConfig: PrimaryKeyConfig(
name: 'id',
type: PrimaryKeyType.timestampBased,
),
fields: [
FieldSchema(
name: 'document_title',
type: DataType.text,
nullable: false,
),
FieldSchema(
name: 'category',
type: DataType.text,
nullable: false,
createIndex: true,
),
FieldSchema(
name: 'embedding',
type: DataType.vector, // Declare a vector field
nullable: false,
vectorConfig: VectorFieldConfig(
dimensions: 128, // Written and queried vectors must match this width
),
),
],
indexes: [
IndexSchema(
fields: ['embedding'], // Field to index
type: IndexType.vector, // Build a vector index
vectorConfig: VectorIndexConfig(
indexType: VectorIndexType.ngh, // ToStore built-in proprietary dense index
distanceMetric: VectorDistanceMetric.cosine, // Good for normalized embeddings
),
),
],
),
]);
final queryVector =
VectorData.fromList(List.generate(128, (i) => i * 0.01)); // Must match dimensions
// 1) Recommended: chained hybrid retrieval (pure vector ANN)
final result = await db
.query('embeddings')
.matchVector('embedding', queryVector) // default searchDepth = 50 (~95% recall intent)
.limit(5);
for (var i = 0; i < result.data.length; i++) {
final row = result.data[i];
final entry = result.retrieval?.entries[i];
final score = entry?.score;
final distance = entry?.meta?['distance'];
print('pk=${row['id']}, title=${row['document_title']}, '
'score=$score, distance=$distance');
}
// 2) Structured filter + vector (AND hybrid)
final filtered = await db
.query('embeddings')
.whereEqual('category', 'tech')
.matchVector('embedding', queryVector)
.limit(5);
// 3) Multi-way fused recall (vector + structured paths, engine-side RRF)
final otherVector =
VectorData.fromList(List.generate(128, (i) => i * 0.012));
final fused = await db
.query('embeddings')
.matchVector('embedding', queryVector, weight: 1.0)
.orMatchVector('embedding', otherVector, weight: 0.6, minScore: 0.2)
.or()
.whereEqual('category', 'tech')
.limit(10);
print('fusion=${fused.retrieval?.fusionMethod}'); // Multi-way is typically rrf
Schema / vector index config (VectorFieldConfig, VectorIndexConfig):
dimensions: must match the actual embedding width you writeindexType: opaque dense algorithm id; currentlyngh.distanceMetric: index-side similarity metric used for insert and search;cosineis common for semantic embeddings,l2suits Euclidean distance, andinnerProductsuits dot-product search. Changing it after data exists requires rebuilding the vector index.
Chained retrieval parameters (matchVector / orMatchVector, plus limit on the query chain):
field/vector: target vector field and query vector (VectorData/List<num>/Float32List)searchDepth: optional depth in[1, 100]mapped to recall intent[90%, 100%]via0.90 + depth/1000(depth50→ ~95% production baseline,80→ ~98%); engine picks a minimum-cost probe budget — best-effort ANN, not a guaranteed recall@K; omit → default50weight: fusion weight for this recall channel in multi-way retrieval; default1.0minScore: normalized similarity floor in[0.0 ~ 1.0]; candidates below it are droppeddistanceThreshold: distance ceiling; candidates beyond it are excludedlimit: number of results to return (equivalent to topK in typical ANN usage)
Result notes (QueryResult):
- Business rows are in
data; retrieval scores and channel info are inretrieval.entries, aligned 1:1 withdata entry.score: normalized similarity / fusion score, typically in0 ~ 1; larger means more relevantentry.meta['distance']: raw distance (common on the vector channel); forl2/cosine, smaller usually means closerretrieval.fusionMethod: usuallysinglefor one channel; multi-way fused recall is typicallyrrf(Reciprocal Rank Fusion)
Table-level TTL #
For logs, telemetry, events, and other data that should expire over time, you can define table-level TTL through ttlConfig. The engine will clean up expired records in the background automatically:
const TableSchema(
name: 'event_logs',
fields: [
FieldSchema(
name: 'created_at',
type: DataType.datetime,
nullable: false,
createIndex: true,
defaultValueType: DefaultValueType.currentTimestamp,
),
],
ttlConfig: TableTtlConfig(
ttlMs: 7 * 24 * 60 * 60 * 1000, // Keep for 7 days
// When sourceField is omitted, the engine creates the needed index automatically.
// Optional custom sourceField requirements:
// 1) type must be DataType.datetime
// 2) nullable must be false
// 3) defaultValueType must be DefaultValueType.currentTimestamp
// sourceField: 'created_at',
),
);
Intelligent Storage (Upsert) #
ToStore decides whether to update or insert based on the primary key or unique field included in data. where is not supported here; the conflict target is determined by the data itself.
// By primary key
final result = await db.upsert('users', {
'id': 1,
'username': 'john',
'email': 'john@example.com',
});
// By unique key (the record must contain all fields from a unique field plus required fields)
await db.upsert('users', {
'username': 'john',
'email': 'john@example.com',
'age': 26,
});
// Batch upsert (supports atomic mode or partial-success mode)
// allowPartialErrors: true means some rows may fail while others still succeed
final batchResult = await db.batchUpsert('users', [
{'username': 'a', 'email': 'a@example.com'},
{'username': 'b', 'email': 'b@example.com'},
], allowPartialErrors: true);
Advanced Queries #
ToStore provides a declarative chainable query API with flexible field handling and complex multi-table relationships.
1. Field Selection (select)
The select method specifies which fields are returned. If you do not call it, all fields are returned by default.
- Aliases: supports
field as aliassyntax (case-insensitive) to rename keys in the result set - Table-qualified fields: in multi-table joins,
table.fieldavoids naming conflicts - Aggregation mixing:
Aggobjects can be placed directly inside theselectlist
final results = await db.query('orders')
.select([
'orders.id',
'users.name as customer_name',
'orders.amount',
Agg.count('id', alias: 'total_items')
])
.join('users', 'orders.user_id', '=', 'users.id')
.where('orders.amount', '>', 1000)
.limit(20);
2. Joins (join)
Supports standard join (inner join), leftJoin, and rightJoin.
3. Smart Foreign-Key-Based Joins (Recommended)
If foreignKeys are defined correctly in TableSchema, you do not need to handwrite join conditions. The engine can resolve reference relationships and generate the optimal JOIN path automatically.
joinReferencedTable(tableName): automatically joins the parent table referenced by the current tablejoinReferencingTable(tableName): automatically joins child tables that reference the current table
// Assume posts defines a foreign key to users
final posts = await db.query('posts')
.joinReferencedTable('users') // Automatically resolves to ON posts.user_id = users.id
.select(['posts.title', 'users.username'])
.limit(20);
Aggregation Grouping and Statistics #
1. Aggregation (Agg factory)
Aggregate functions compute statistics over a dataset. With the alias parameter, you can customize result field names.
| Method | Purpose | Example |
|---|---|---|
Agg.count(field) |
Count non-null records | Agg.count('id', alias: 'total') |
Agg.sum(field) |
Sum values | Agg.sum('amount', alias: 'total_price') |
Agg.avg(field) |
Average value | Agg.avg('score', alias: 'average_score') |
Agg.max(field) |
Maximum value | Agg.max('age') |
Agg.min(field) |
Minimum value | Agg.min('price') |
Tip
Two common aggregation styles
- Shortcut methods (recommended for single metrics): call directly on the chain and get the computed value back immediately.
num? totalAge = await db.query('users').sum('age'); - Embedded in
select(for multiple metrics or grouping): passAggobjects into theselectlist.final stats = await db.query('orders').select(['status', Agg.sum('amount')]).groupBy(['status']);
2. Grouping & Filtering (groupBy / having)
Use groupBy to categorize records, then having to filter aggregated results, similar to SQL's HAVING behavior.
final stats = await db.query('orders')
.select([
'status',
Agg.sum('amount', alias: 'sum_amount'),
Agg.count('id', alias: 'order_count')
])
.groupBy(['status'])
// having accepts a QueryCondition used to filter aggregated results
.having(QueryCondition().where(Agg.sum('amount'), '>', 5000))
.limit(10);
3. Helper Query Methods
exists()(high-performance): checks whether any record matches. Unlikecount() > 0, it short-circuits as soon as one match is found, which is excellent for very large datasets.count(): efficiently returns the number of matching records.first(): a convenience method equivalent tolimit(1)and returning the first row directly as aMap.distinct([fields]): deduplicates results. Iffieldsare provided, uniqueness is calculated based on those fields.
// Efficient existence check
if (await db.query('users').whereEqual('email', 'test@test.com').exists()) {
print('Email is already registered');
}
// Get a deduplicated city list
final cities = await db.query('users').distinct(['city']);
Complex Logic with QueryCondition #
QueryCondition is ToStore's core tool for nested logic and parenthesized query construction. When simple chained where calls are not enough for expressions like (A AND B) OR (C AND D), this is the tool to use.
condition(QueryCondition sub): opens anANDnested grouporCondition(QueryCondition sub): opens anORnested groupor(): changes the next connector toOR(default isAND)
Example 1: Mixed OR Conditions
Equivalent SQL: WHERE is_active = true AND (role = 'admin' OR fans >= 1000)
final subGroup = QueryCondition()
.whereEqual('role', 'admin')
.or()
.whereGreaterThanOrEqualTo('fans', 1000);
final results = await db.query('users')
.whereEqual('is_active', true)
.condition(subGroup);
Example 2: Reusable Condition Fragments
You can define reusable business logic fragments once and combine them in different queries:
final hotUser = QueryCondition().whereGreaterThan('fans', 5000);
final recentLogin = QueryCondition().whereGreaterThan('last_login', '2024-01-01');
final targetUsers = await db.query('users')
.condition(hotUser)
.condition(recentLogin);
Streaming Query #
Suitable for very large datasets when you do not want to load everything into memory at once. Results can be processed as they are read.
db.streamQuery('users').listen((data) {
print('Processing one record: $data');
});
Reactive Query #
The watch() method lets you monitor query results in real time. It returns a Stream and automatically re-runs the query whenever matching data changes in the target table.
- Automatic debounce: built-in intelligent debouncing avoids redundant bursts of queries
- UI sync: works naturally with Flutter
StreamBuilderfor live-updating lists
// Simple listener
db.query('users').whereEqual('is_online', true).watch().listen((users) {
print('Online user count changed: ${users.length}');
});
// Flutter StreamBuilder integration example
// Local UI refreshes automatically when data changes
StreamBuilder<List<Map<String, dynamic>>>(
stream: db.query('messages').orderByDesc('id').limit(50).watch(),
builder: (context, snapshot) {
if (snapshot.hasData) {
return ListView.builder(
itemCount: snapshot.data!.length,
itemBuilder: (context, index) => MessageTile(snapshot.data![index]),
);
}
return CircularProgressIndicator();
},
)
Manual Query Result Caching #
Important
ToStore already includes an efficient multi-level intelligent LRU cache internally. Routine manual cache management is not recommended. Consider it only in special cases:
- Expensive full scans on unindexed data that rarely changes
- Persistent ultra-low-latency requirements even for non-hot queries
useQueryCache([Duration? expiry]): enable cache and optionally set an expirationnoQueryCache(): explicitly disable cache for this queryclearQueryCache(): manually invalidate the cache for this query pattern
final results = await db.query('heavy_table')
.where('non_indexed_field', '=', 'value')
.useQueryCache(const Duration(minutes: 10)); // Manual acceleration for a heavy query only
Query and Efficient Pagination #
Tip
Explicitly specify limit as the page size: It is strongly recommended to always specify limit in your queries. If omitted, the engine defaults to 1000 records to prevent querying too much data at once.
ToStore provides dual-mode pagination support. For list scrolling or infinite loading, we highly recommend using the built-in seamless cursor pagination; for specific page jumping, basic pagination is sufficient:
1. Basic Pagination (Offset Mode)
Suitable for scenarios where the data volume is small (e.g., under 10k) or when you need to jump to a specific page precisely.
final result = await db.query('users')
.orderByDesc('created_at')
.offset(40) // Skip the first 40 rows
.limit(20); // Take 20 rows
Tip
When offset becomes very large, the database must scan and discard a large number of records, and performance degrades linearly. For deep pagination or larger datasets, it is recommended to use Cursor Mode.
2. Cursor Pagination (Cursor Mode - Recommended)
Ideal for massive datasets and infinite scrolling. By recording the starting position of the current page's data stream, it seeks directly to that position during pagination, avoiding scanning and discarding historical data, and keeping deep pagination speed constant.
- Automatic Management: Set a limit for the page size, and simply call
next()orprev()for subsequent pages to achieve excellent pagination performance, simply and quickly. - Start Position Offsets: Supports combining with
.offset(N)in the initial query to locate the starting window, after which callingnext()directly fetches the subsequent pages.
// 1. Initiate initial query
final page1 = await db.query('users')
.orderByDesc('id')
.limit(20);
// 2. Fetch the next page
if (page1.hasMore) {
final page2 = await page1.next();
print('Next page items count: ${page2.data.length}');
// 3. Fetch the previous page
if (page2.hasPrev) {
final prevPage = await page2.prev();
print('Previous page data: ${prevPage.data}');
}
}
Advanced Scenario: Stateless Token Pagination (Token-based Cursor)
For everyday in-app paging, prefer next() / prev() above. Use cursor tokens only for client-server APIs or when serializing pagination state across processes/networks:
- The initial query returns
nextCursorTokenandprevCursorTokenstrings. - The subsequent query passes the token via
.cursor(token)to seek. - Note:
cursorandoffsetare mutually exclusive; setting one clears the other.
// Initial query (e.g., on API server-side)
final page1 = await db.query('users')
.orderByDesc('id')
.limit(20);
final String? nextToken = page1.nextCursorToken; // Serialize and return this token to the client
// When the client requests the next page with the token:
if (nextToken != null) {
final page2 = await db.query('users')
.orderByDesc('id')
.limit(20)
.cursor(nextToken); // Pass token to seek and read precisely
}
| Feature | Offset Mode | Cursor Mode |
|---|---|---|
| Query Performance | Degrades as page count increases | Constant speed for deep paging |
| Best for | Small datasets, exact page jumping | Massive datasets, infinite scrolling |
| Consistency under changes | Data changes can cause skipped/duplicate rows | Avoids duplicates and omissions caused by data changes |
Memory Probe and Sync Retrieval (peek) #
For scenarios with extreme throughput and latency requirements, ToStore provides the peek series of purely synchronous in-memory retrievals, absorbing burst hot-read traffic directly in-process: edge devices can sustain millions of read requests per second; servers on stronger hardware can reach tens of millions per machine (see Benchmarks).
Note
In-memory cache only: peek is a zero-scheduling, pure in-memory bypass. On cache miss it immediately returns empty/null; the engine performs no synchronous file I/O (avoiding event-loop blocking under high concurrency). For full persistent results, use await query() in application code.
Peek API Methods
| Method | Return Type | Description |
|---|---|---|
peekFirst() |
Map<String, dynamic>? |
Single record; returns null on cache miss |
peek() |
QueryResult<T> |
QueryResult with data list and pagination metadata (hasMore, cursors, etc.); populated only on cache hit |
peekExists() |
bool |
Synchronously checks whether a matching record exists in memory cache |
peekCount() |
int |
Synchronously counts matching records in memory cache |
result.peekNext() |
QueryResult<T> |
Synchronous next page when pagination result is cached |
result.peekPrev() |
QueryResult<T> |
Synchronous previous page when pagination result is cached |
Best Practice: Memory Probe First (Peek-Through)
// Single-record probe: memory probe first, then standard async query on miss
final q = db.query('users').where('id', '=', userId);
final user = q.peekFirst() ?? await q.first();
// Paginated probe query
final listQ = db.query('users').orderByDesc('id').limit(20);
var page = listQ.peek();
if (page.data.isEmpty) page = await listQ;
if (page.hasMore) {
final next = page.peekNext(); // cache hit: synchronous page turn
if (next.data.isEmpty) await page.next();
}
KV peek (db.kv)
| Method | Async counterpart | Description |
|---|---|---|
peekGet(key) |
get(key) |
Sync in-memory value probe; expired keys return null |
peekExists(key) |
exists(key) |
Sync in-memory existence check |
db.kv.query().peek() |
await db.kv.query() |
Paginated sync probe (prefix / sort / limit) |
db.kv.query().peekFirst() |
await db.kv.query().first() |
First matching record sync probe |
// Point lookup: probe first, fall back to async on miss
final theme = db.kv.peekGet('theme', isGlobal: true) ?? await db.kv.get('theme', isGlobal: true);
// Paginated KV probe
var page = db.kv.query().prefix('setting_').limit(20).peek();
if (page.data.isEmpty) page = await db.kv.query().prefix('setting_').limit(20);
Tip
Recommendation: Standard async queries (await query()) use event scheduling for long-term stability and fair multi-tasking; 100k+ QPS is sufficient for most workloads. The peek series is designed for single-machine extreme hot-read peak shaving at millions/tens of millions of QPS.
Foreign Keys and Cascading #
Foreign keys guarantee referential integrity and allow you to configure cascading updates and deletes. Relationships are validated on write and update. If cascade policies are enabled, related data is updated automatically, reducing consistency work in application code.
await db.createTables([
const TableSchema(
name: 'users',
primaryKeyConfig: PrimaryKeyConfig(name: 'id'),
fields: [
FieldSchema(name: 'username', type: DataType.text, nullable: false),
],
),
TableSchema(
name: 'posts',
primaryKeyConfig: const PrimaryKeyConfig(name: 'id'),
fields: [
const FieldSchema(name: 'title', type: DataType.text, nullable: false),
const FieldSchema(name: 'user_id', type: DataType.integer, nullable: false),
const FieldSchema(name: 'content', type: DataType.text),
],
foreignKeys: [
ForeignKeySchema(
name: 'fk_posts_user',
fields: ['user_id'], // Field in the current table
referencedTable: 'users', // Referenced table
referencedFields: ['id'], // Referenced field
onDelete: ForeignKeyCascadeAction.cascade, // Delete posts automatically when the user is deleted
onUpdate: ForeignKeyCascadeAction.cascade, // Cascade updates
),
],
),
]);
Query Operators #
All where(field, operator, value) conditions support the following operators (case-insensitive):
| Operator | Description | Example / Performance |
|---|---|---|
= |
Equal | where('status', '=', 'val') — [Recommended] Index Seek |
!=, <> |
Not equal | where('role', '!=', 'val') — [Caution] Full Table Scan |
> , >=, <, <= |
Comparison | where('age', '>', 18) — [Recommended] Index Scan |
IN |
In list | where('id', 'IN', [...]) — [Recommended] Index Seek |
NOT IN |
Not in list | where('status', 'NOT IN', [...]) — [Caution] Full Table Scan |
BETWEEN |
Range | where('age', 'BETWEEN', [18, 65]) — [Recommended] Index Scan |
LIKE |
Pattern match (% = any chars, _ = single char) |
where('name', 'LIKE', 'John%') — [Caution] See note below |
NOT LIKE |
Pattern mismatch | where('email', 'NOT LIKE', '...') — [Caution] Full Table Scan |
IS |
Is null | where('deleted_at', 'IS', null) — [Recommended] Index Seek |
IS NOT |
Is not null | where('email', 'IS NOT', null) — [Caution] Full Table Scan |
Semantic Query Methods (Recommended) #
Recommended for avoiding hand-written operator strings and for getting better IDE assistance.
1. Comparison
Used for direct numeric or string comparisons.
db.query('users').whereEqual('username', 'John'); // Equal
db.query('users').whereNotEqual('role', 'guest'); // Not equal
db.query('users').whereGreaterThan('age', 18); // Greater than
db.query('users').whereGreaterThanOrEqualTo('score', 60); // Greater than or equal
db.query('users').whereLessThan('price', 100); // Less than
db.query('users').whereLessThanOrEqualTo('quantity', 10); // Less than or equal
db.query('users').whereTrue('is_active'); // Is true
db.query('users').whereFalse('is_banned'); // Is false
2. Collection & Range
Used to test whether a field falls inside a set or a range.
db.query('users').whereIn('id', ['id1', 'id2']); // In list
db.query('users').whereNotIn('status', ['banned', 'pending']); // Not in list
db.query('users').whereBetween('age', 18, 65); // In range (inclusive)
3. Null Check
Used to test whether a field has a value.
db.query('users').whereNull('deleted_at'); // Is null
db.query('users').whereNotNull('email'); // Is not null
db.query('users').whereEmpty('nickname'); // Is null or empty string
db.query('users').whereNotEmpty('bio'); // Is not null and not empty
4. Pattern Matching
Supports SQL-style wildcard search (% matches any number of characters, _ matches a single character).
db.query('users').whereLike('name', 'John%'); // SQL-style pattern match
db.query('users').whereContains('bio', 'flutter'); // Contains match (LIKE '%value%')
db.query('users').whereStartsWith('name', 'Admin'); // Prefix match (LIKE 'value%')
db.query('users').whereEndsWith('email', '.com'); // Suffix match (LIKE '%value')
db.query('users').whereContainsAny('tags', ['dart', 'flutter']); // Fuzzy match against any item in the list
// Equivalent to: .where('age', '>', 18).where('name', 'like', '%John%')
final users = await db.query('users')
.whereGreaterThan('age', 18)
.whereLike('username', '%John%')
.orderByDesc('age')
.limit(20);
Caution
Query Performance Guide (Index vs Full-Scan)
In large-scale data scenarios (millions of rows or more), please follow these principles to avoid main thread lag and query timeouts:
- Index Optimized - [Recommended]:
- Semantic Methods:
whereEqual,whereGreaterThan,whereLessThan,whereIn,whereBetween,whereNull,whereTrue,whereFalse, andwhereStartsWith(prefix match). - Operators:
=,>,<,>=,<=,IN,BETWEEN,IS null,LIKE 'prefix%'. - Explanation: These operations achieve ultra-fast positioning via indexes. For
whereStartsWith/LIKE 'abc%', the index can still perform a prefix range scan.
- Full-Scan Risks - [Caution]:
- Fuzzy Matching:
whereContains(LIKE '%val%'),whereEndsWith(LIKE '%val'),whereContainsAny. - Negation Queries:
whereNotEqual(!=,<>),whereNotIn(NOT IN),whereNotNull(IS NOT null/whereNotEmpty). - Pattern Mismatch:
NOT LIKE. - Explanation: The above operations usually require traversing the entire data storage area even if an index is built. While the impact is minimal on mobile or small datasets, in distributed or ultra-large data analysis scenarios, they should be used cautiously, combined with other index conditions (e.g., narrow down data by ID or time range) and the
limitclause.
Distributed Architecture #
// Configure distributed nodes
final db = await ToStore.open(
config: DataStoreConfig(
distributedNodeConfig: const DistributedNodeConfig(
enableDistributed: true, // Enable distributed mode
clusterId: 1, // Cluster ID
centralServerUrl: 'https://127.0.0.1:8080',
accessToken: 'b7628a4f9b4d269b98649129'
)
)
);
// Batch insert
await db.batchInsert('vector_data', [
{'vector_name': 'face_2365', 'timestamp': DateTime.now()},
{'vector_name': 'face_2366', 'timestamp': DateTime.now()},
// ... efficient one-shot insertion of vector records
]);
// Stream and process large datasets
await for (final record in db.streamQuery('vector_data')
.where('vector_name', '=', 'face_2366')
.where('timestamp', '>=', DateTime.now().subtract(Duration(days: 30)))
.stream) {
// Process each result incrementally to avoid loading everything at once
print(record);
}
Primary Key Examples #
ToStore provides multiple distributed primary key algorithms for different business scenarios:
- Sequential primary key (
PrimaryKeyType.sequential):238978991 - Timestamp-based primary key (
PrimaryKeyType.timestampBased):1306866018836946 - Date-prefixed primary key (
PrimaryKeyType.datePrefixed):20250530182215887631 - Short-code primary key (
PrimaryKeyType.shortCode):9eXrF0qeXZ
// Sequential primary key configuration example
await db.createTables([
const TableSchema(
name: 'users',
primaryKeyConfig: PrimaryKeyConfig(
type: PrimaryKeyType.sequential,
sequentialConfig: SequentialIdConfig(
initialValue: 10000, // Starting value
increment: 50, // Step size
useRandomIncrement: true, // Random step size to hide business volume
),
),
fields: [/* field definitions */]
),
]);
Atomic Expressions #
The expression system provides type-safe atomic field updates. All calculations are executed atomically at the database layer, avoiding concurrent conflicts:
// Simple increment: balance = balance + 100
await db.update('accounts', {
'balance': Expr.field('balance') + Expr.value(100),
}).where('id', '=', accountId);
// Complex calculation: total = price * quantity + tax
await db.update('orders', {
'total': Expr.field('price') * Expr.field('quantity') + Expr.field('tax'),
}).where('id', '=', orderId);
// Multi-layer parentheses: finalPrice = ((price * quantity) + tax) * (1 - discount)
await db.update('orders', {
'finalPrice': ((Expr.field('price') * Expr.field('quantity')) + Expr.field('tax')) *
(Expr.value(1) - Expr.field('discount')),
}).where('id', '=', orderId);
// Use functions: price = min(price, maxPrice)
await db.update('products', {
'price': Expr.min(Expr.field('price'), Expr.field('maxPrice')),
}).where('id', '=', productId);
// Timestamp: updatedAt = now()
await db.update('users', {
'updatedAt': Expr.now(),
}).where('id', '=', userId);
Conditional expressions (for example, differentiating update vs insert in an upsert): use Expr.isUpdate() / Expr.isInsert() together with Expr.ifElse or Expr.when so the expression is evaluated only on update or only on insert.
// Upsert: increment on update, set to 1 on insert
// The insert branch can use a plain literal; expressions are only evaluated on the update path
await db.upsert('counters', {
'key': 'visits',
'count': Expr.ifElse(
Expr.isUpdate(),
Expr.field('count') + Expr.value(1),
1,
),
});
// Use Expr.when (single branch, otherwise null)
await db.upsert('orders', {
'id': orderId,
'updatedAt': Expr.when(Expr.isUpdate(), Expr.now(), otherwise: Expr.now()),
});
Transactions #
Transactions ensure atomicity across multiple operations: either everything succeeds or everything is rolled back, preserving data consistency.
Transaction characteristics
- multiple operations either all succeed or all roll back
- unfinished work is automatically recovered after crashes
- successful operations are safely persisted
// Basic transaction - atomically commit multiple operations
final txResult = await db.transaction(() async {
// Insert a user
await db.insert('users', {
'username': 'john',
'email': 'john@example.com',
'fans': 100,
});
// Atomic update using an expression
await db.update('users', {
'fans': Expr.field('fans') + Expr.value(50),
}).where('username', '=', 'john');
// If any operation fails, all changes are rolled back automatically
});
if (!txResult.hasErrors) {
print('Transaction committed successfully');
} else {
print('Transaction rolled back due to:');
for (final status in txResult.statuses) {
if (status.type != ResultType.success) {
print(' - [$status.codeKey}] $status.message}');
}
}
}
// Automatic rollback on error
final txResult2 = await db.transaction(() async {
await db.insert('users', {
'username': 'jane',
'email': 'jane@example.com',
});
throw Exception('Business logic error'); // Trigger rollback
}, rollbackOnError: true);
Administration and Maintenance #
The following APIs cover database administration, diagnostics, and maintenance for plugin-style development, admin panels, and operational scenarios:
- Table Management
createTable(schema): create a single table manually; useful for module loading or on-demand runtime table creationgetTableSchema(tableName): retrieve the defined schema information; useful for automated validation or UI model generationgetTableNames({isGlobal}): list table names in the global schema inventory (user tables). OptionalisGlobal:true= global only,false= non-global only, omitted = both. Non-global schemas are shared across spaces; only data is space-isolated.getTableInfo(tableName): retrieve runtime table statistics (totalRecordCount,totalTableDataSizeBytes,totalIndexDataSizeBytes,indexCount, creation time, whether the table is global)clear(tableName): clear all table data while safely retaining schema, indexes, and internal/external key constraintsdropTable(tableName): completely destroy a table and its schema; not reversible
- Space Management
currentSpaceName: get the current active space in real timelistSpaces(): list all allocated spaces in the current database instancegetSpaceInfo(useCache: true): space-local aggregates (totalRecordCount, table/index data size). UseuseCache: falseto reconcile from meta.deleteSpace(spaceName): delete a specific space and all of its data, exceptdefaultand the current active space
- Instance Discovery
config: inspect the final effectiveDataStoreConfigsnapshot for the instanceinstancePath: locate the physical storage directory preciselygetVersion()/setVersion(version): business-defined version control for application-level migration decisions (not the engine version)
- Maintenance
flush(flushStorage: true): force pending data to disk; ifflushStorage: true, the system is also asked to flush lower-level storage buffersdeleteDatabase(): remove all physical files and metadata for the current instance; use with care
- Diagnostics
db.status.memory(): inspect cache hit ratios, index-page usage, and overall heap allocationdb.status.space()/db.status.table(tableName): inspect live statistics and health information for spaces and tablesdb.status.config(): inspect the current runtime configuration snapshotdb.status.migration(taskId): track asynchronous migration progress in real time
final spaces = await db.listSpaces();
final tableNames = await db.getTableNames();
final spaceInfo = await db.getSpaceInfo(useCache: false);
final tableSchema = await db.getTableSchema('users');
final tableInfo = await db.getTableInfo('users');
print('spaces: $spaces');
print('tables: $tableNames');
print(spaceInfo.toJson());
print(tableSchema?.toJson());
print(tableInfo?.toJson());
await db.flush();
final memoryInfo = await db.status.memory();
final configInfo = await db.status.config();
print(memoryInfo.toJson());
print(configInfo.toJson());
Backup & Restore #
Especially useful for single-user local import/export, large offline data migration, and system rollback after failure:
- Backup (
backup)compress: whether to enable compression; recommended and enabled by defaultscope: controls the backup rangeBackupScope.database: backs up the entire database instance, including all spaces and global tablesBackupScope.currentSpace: backs up only the current active space, excluding global tablesBackupScope.currentSpaceWithGlobal: backs up the current space plus its related global tables, ideal for single-tenant or single-user migration
- Restore (
restore)backupPath: physical path to the backup packagecleanupBeforeRestore: whether to silently wipe related current data before restore;trueis recommended to avoid mixed logical statesdeleteAfterRestore: automatically delete the backup source file after successful restore
// Example: export the full data package for the current user
final backupPath = await db.backup(
compress: true,
scope: BackupScope.currentSpaceWithGlobal,
);
// Example: restore from a backup package and clean up the source file automatically
final restored = await db.restore(
backupPath,
cleanupBeforeRestore: true,
deleteAfterRestore: true,
);
Status Codes and Error Handling #
There are two channels for error and exception feedback in ToStore:
Note
Unified Diagnosis Foundation: Whether returned via the response result model (statuses in DbResult/QueryResult) or thrown via fatal exceptions (statuses in DbException), all diagnostic states are uniformly based on the structured ResultStatus system and share the same status codes, ensuring consistency.
- Response Result Model (Result-based Response)
For daily operations such as insert, update, delete, query, transactions, and runtime schema creation/modification. These operations will not throw exceptions when encountering constraint violations, validation failures, or invalid arguments. Instead, ToStore wraps the results using
DbResultorQueryResult, recording all diagnosis information in the status list. This guarantees that ordinary business logical errors do not interrupt the database.
hasErrors: Indicates if there are any errors in the current operation. In batch operations or transactions, if at least one error is present, this property istrue.statuses: A detailed list of allResultStatusdiagnoses for the operation. It supports 1:1 order-matching, which is very useful for batch operations.firstPrimaryKey: Read the physically generated primary key directly during a single insert/write operation without parsingstatusesmanually.ResultType: Enum for the category of state, convenient for branch handling and checks (e.g.isBusinessError,isDeveloperError).
- Exception Throwing (Exception-based Throwing)
For fatal errors caused by developer oversight or design flaws (e.g. schema verification failure during
ToStore.open, engine version mismatch, fatal data migration corruption, etc.). In these cases, ToStore throwsDbExceptionto halt execution, urging the developer to rectify it.
Warning
Development Guidelines: Ordinary business errors must not throw exceptions; they should be returned in the response result model to avoid disrupting application runtime.
Error and Exception Examples #
1. Single Write Response Handling
final result = await db.insert('users', {
'username': 'john',
'email': 'john@example.com',
});
if (result.hasErrors) {
// Get the first error type and description
print('Operation failed: [\${result.firstType.codeKey}] \${result.message}');
} else {
print('Write succeeded, primary key is: \${result.firstPrimaryKey}');
}
2. Batch Write Fine-Grained Diagnosis
final batchResult = await db.batchInsert('users', [
{'username': 'alice', 'email': 'alice@example.com'},
{'username': 'bob', 'email': 'invalid-email-format'}, // Validation fails
]);
if (batchResult.hasErrors) {
print('Batch operation partially failed: succeeded \${batchResult.successCount}, failed \${batchResult.failedCount}');
for (final status in batchResult.statuses) {
final int idx = status.index;
if (status is ConstraintStatus) {
print('Index [\$idx] constraint violation! Table: \${status.tableName}, fields: \${status.fields}');
} else if (status is InvalidArgumentStatus) {
print('Index [\$idx] argument error! Parameter! Parameter: \${status.parameterName}, passed value: \${status.passedValue}');
} else if (status.type != ResultType.success) {
print('Index [\$idx] error occurred: [\${status.codeKey}] \${status.message}');
}
}
}
3. Fatal Error and Initialization Exception Capture (DbException)
try {
// Initialize database with schemas that might have validation issues
final db = await ToStore.open(schemas: appSchemas);
} on DbException catch (e) {
print('❌ Fatal database exception! Error message: \n\${e.message}');
// Iterate through the detailed status list in the exception
for (final status in e.statuses) {
if (status is SchemaValidationStatus) {
print('Schema validation failed! Table! Table: \${status.tableName}, field: \${status.field}, invalid configuration: \${status.wrongValue}');
} else {
print('Diagnostic info: [\${status.codeKey}] \${status.message}');
}
}
}
For the full list of error types, leaf status codes, JSON serialization formats, and field mappings, please refer to the complete spec: ToStore ResultStatus Automatic Diagnosis and Status Parsing Specification.
Log Callback and Database Diagnostics #
ToStore can route database lifecycle logs back to the business layer through ToStore.setLogConfig(...).
onLogcallback receives allLogRecordinstances that pass the currentenableLogandlogLevelfilters.- LogLevel.error: Localized errors that do not affect normal execution.
- LogLevel.critical: Global disaster-level errors (such as disk full, out of memory, critical migration failure, etc.) that require manual intervention. Triggering alarms or notifications at this level is recommended.
- Call
ToStore.setLogConfig(...)before initialization so logs generated during initialization and automatic migration are also captured.
// Configure log parameters or callback
ToStore.setLogConfig(
enableLog: true,
logLevel: debugMode ? LogLevel.debug : LogLevel.warn,
logLabel: 'my_app_db', // Light gray header label to distinguish apps or database instances
onLog: (log) {
// In production, warn/error/critical can be reported to your backend or logging platform
// log.level corresponds to log levels (LogLevel.debug, info, warn, error, critical)
// log.message corresponds to the formatted log message
// log.status corresponds to the underlying ResultStatus diagnosis status (containing code and codeKey)
if (!debugMode && (log.level == LogLevel.warn || log.level == LogLevel.error || log.level == LogLevel.critical)) {
developer.log(log.message, name: 'my_app_db', time: log.timestamp);
}
},
);
final db = await ToStore.open();
Security Configuration #
Warning
Key management
| Key | Role | How to change | Full data rewrite? |
|---|---|---|---|
encodingKey |
Data encryption key | Set new value and open again |
Yes (slow) |
encryptionKey |
Security key; protects encodingKey |
Call db.rotateEncryptionKey at runtime |
No (fast) |
Never hardcode sensitive keys. To bind secrets to a device, store encryptionKey in the OS Keychain / Keystore / secure enclave and pass it into the engine.
final db = await ToStore.open(
config: DataStoreConfig(
encryptionConfig: EncryptionConfig(
// Supported: none, xorObfuscation, chacha20Poly1305, aes256Gcm
encryptionType: EncryptionType.chacha20Poly1305,
// Data encryption key: encrypts table/index/log data; changing it triggers a background rewrite
encodingKey: 'Your-Encoding-Key...',
// Security key: protects encodingKey; rotate online via db.rotateEncryptionKey
encryptionKey: 'Your-Secure-Encryption-Key...',
// standard: critical table data, B-tree indexes, and log payloads
// full: encrypts the entire engine files
encryptionScope: EncryptionScope.standard,
),
// Enable crash recovery logging (Write-Ahead Logging), enabled by default
enableJournal: true,
// Whether transactions force data to disk on commit; set false to reduce sync overhead
persistRecoveryOnCommit: true,
),
);
Changing encodingKey: set the new value in EncryptionConfig and open again. The engine detects the change and migrates encrypted data automatically in the background.
Rotating encryptionKey (periodic security/compliance rotation): no data rewrite; run online.
// If encryptionKey was never set explicitly, oldKey can be omitted
final result = await db.rotateEncryptionKey(newKey: 'new-secure-key');
// Or: await db.rotateEncryptionKey(oldKey: 'old-key', newKey: 'new-key');
if (result.hasErrors) {
// Handle failure (wrong old key, encodingKey migration in progress, etc.)
return;
}
// Success: pass the latest encryptionKey on the next ToStore.open
Value-Level Encryption (ToCrypto) #
Full-database encryption secures all table and index data, but may affect overall performance. If you only need to protect a few sensitive values, use ToCrypto instead. It is decoupled from the database, requires no db instance, and lets your application encode/decode values before write or after read. Output is Base64, which fits naturally in JSON or TEXT columns.
key(required):StringorUint8List. If it is not 32 bytes, SHA-256 is used to derive a 32-byte key.type(optional): encryption type fromToCryptoType, such asToCryptoType.chacha20Poly1305orToCryptoType.aes256Gcm. Defaults toToCryptoType.chacha20Poly1305.aad(optional): additional authenticated data of typeUint8List. If provided during encoding, the exact same bytes must be provided during decoding as well.
const key = 'my-secret-key';
// Encode: plaintext -> Base64 ciphertext (can be stored in DB or JSON)
final cipher = ToCrypto.encode('sensitive data', key: key);
// Decode when reading
final plain = ToCrypto.decode(cipher, key: key);
// Optional: bind contextual data with aad (must match during decode)
final aad = Uint8List.fromList(utf8.encode('users:id_number'));
final cipher2 = ToCrypto.encode('secret', key: key, aad: aad);
final plain2 = ToCrypto.decode(cipher2, key: key, aad: aad);
Advanced Configuration Explained (DataStoreConfig) #
Tip
Zero Config intelligence
ToStore automatically senses the platform, performance characteristics, available memory, and I/O behavior to optimize parameters such as concurrency, shard size, and cache budget. In 99% of common business scenarios, you do not need to fine-tune DataStoreConfig manually. The defaults already provide excellent performance for the current platform.
| Parameter | Default | Purpose & Recommendation |
|---|---|---|
yieldDurationMs |
8ms | Core recommendation. The time slice used when long tasks yield. 8ms aligns well with 120fps/60fps rendering and helps keep UI smooth during large queries or migrations. |
maxQueryOffset |
10000 | Query protection. When offset exceeds this threshold, an error is raised. This prevents pathological I/O from deep offset pagination. |
defaultQueryLimit |
1000 | Resource guardrail. Applied when a query does not specify limit, preventing accidental loading of massive result sets and potential OOM issues. |
cacheMemoryBudgetMB |
(auto) | Fine-grained memory management. Total cache memory budget. The engine uses it to drive LRU reclamation automatically. |
enableJournal |
true | Crash self-healing. When enabled, the engine can recover automatically after crashes or power failures. |
persistRecoveryOnCommit |
true | Strong durability guarantee. When true, committed transactions are synced to physical storage. When false, flushing is done asynchronously in the background for better speed, with a small risk of losing a tiny amount of data in extreme crashes. |
ttlCleanupIntervalMs |
300000 | Global TTL polling. The background interval for scanning expired data when the engine is not idle. Lower values delete expired data sooner but cost more overhead. |
maxConcurrency |
(auto) | Compute concurrency control. Sets the maximum parallel worker count for intensive tasks such as vector computation and encryption/decryption. Keeping it automatic is usually best. |
final db = await ToStore.open(
config: DataStoreConfig(
yieldDurationMs: 8, // Excellent for frontend UI smoothness; for servers, 50ms is often better
defaultQueryLimit: 50, // Force a maximum result-set size
enableJournal: true, // Ensure crash self-healing
),
);
Performance and Experience #
Benchmarks #
Highlights from the 100K suite and 1e9-record edge validation (3 rounds, 2026-08-29):
| Metric | Result |
|---|---|
| Cold start | ~35 ms (stable vs data scale) |
| PK Read (Hot Cache) | 4.5M ops/s |
| Batch Insert | 541K ops/s |
| Pagination (Hot Cache) | 604K ops/s |
| Vector ANN Search | 1715 ops/s |
Full charts and every operation: Benchmarks.
- Basic performance demo (basic-demo.mp4): even on ordinary mobile devices, startup, paging, and retrieval stay smooth past 100 million records.
- Disaster recovery stress test (disaster-recovery.mp4): under high-frequency writes, intentional crash/power-loss interruptions still recover quickly.
Experience Tips #
- 📱 Example project: the
exampledirectory includes a complete Flutter application - 🚀 Production builds: package and test in release mode; release performance is far beyond debug mode
- ✅ Standard tests: core capabilities are covered by standardized tests
If ToStore helps you, please give us a ⭐️ — it is one of the best ways to support the project. Thank you very much!
Contributing #
ToStore is a continuously evolving modern data engine, and we warmly welcome community contributions. Whether it's fixing bugs, improving documentation, refining architecture, or proposing new ideas, you can participate via PR:
- 🔗 Submit PR: Pull Requests
- 📖 Documentation: Wiki
- 📢 Issue Reporting: GitHub Issues
- 💬 Technical Discussion: GitHub Discussions