chore: Move the drift_offline_first to legacy in favor of Serverpod implementation (#32)

This commit is contained in:
Marcelo Mendonça Soares 2026-04-05 23:20:21 -03:00 • committed by Marcelo Soares
commit cbc472523b
No known key found for this signature in database
GPG key ID: CB2F09CE66E99EAD
78 changed files with 13575 additions and 284 deletions

9
.gitattributes vendored Normal file
View file

@ -0,0 +1,9 @@
**/*.g.dart linguist-generated=true
**/generated/** linguist-generated=true
**/protocol/** linguist-generated=true
**/serverpod_test_tools.dart linguist-generated=true
**/migrations/** linguist-generated=true
# Compensate for too broad protocol pattern
**/lib/src/**/migrations/** linguist-generated=false
**/test/**/migrations/** linguist-generated=false

View file

@ -9,8 +9,8 @@
## Project Context ## Project Context
- This package is a non-invasive plugin implementation of offline-first - This package is a non-invasive plugin implementation of offline-first
features for Drift that uses CRDT (conflict-free replicated data types) for features for Serverpod that uses CRDT (conflict-free replicated data types)
conflict resolution. for conflict resolution.
- The package is in early stages and more documentation will be added as it - The package is in early stages and more documentation will be added as it
evolves. Currently, rely on the code and comments to understand how it works. evolves. Currently, rely on the code and comments to understand how it works.
Although extensive docs are not present, the code is well-documented and the Although extensive docs are not present, the code is well-documented and the

View file

@ -16,25 +16,6 @@ concurrency:
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs: jobs:
generate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Dart
uses: dart-lang/setup-dart@v1
- name: Run pub get
run: dart pub get
- name: Generate code
run: dart run build_runner build --delete-conflicting-outputs
- name: Upload generated code
uses: actions/upload-artifact@v4
with:
name: generated-code
path: |
**/*.g.dart
**/*.freezed.dart
lib/**/*.part.dart
dart_format: dart_format:
name: dart format name: dart format
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -42,123 +23,102 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Dart - name: Set up Dart
uses: dart-lang/setup-dart@v1 uses: dart-lang/setup-dart@v1
- name: Run pub get # - name: Run pub get
run: dart pub get # run: dart pub get
# Skip code generation for formatting check # - name: Verify formatting
- name: Verify formatting # run: dart format --output=none --set-exit-if-changed .
run: dart format --output=none --set-exit-if-changed .
dart_analyze: dart_analyze:
needs: generate
name: dart analyze name: dart analyze
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Dart - name: Set up Dart
uses: dart-lang/setup-dart@v1 uses: dart-lang/setup-dart@v1
- name: Run pub get # - name: Run pub get
run: dart pub get # run: dart pub get
- name: Download generated code # - name: Run lint
uses: actions/download-artifact@v4 # run: dart analyze .
with:
name: generated-code
- name: Run lint
run: dart analyze .
tests: # tests:
needs: generate # name: dart test
name: dart test # runs-on: ubuntu-latest
runs-on: ubuntu-latest # permissions:
permissions: # pull-requests: write
pull-requests: write # contents: read
contents: read # steps:
steps: # - uses: actions/checkout@v4
- uses: actions/checkout@v4 # - name: Set up Dart
- name: Set up Dart # uses: dart-lang/setup-dart@v1
uses: dart-lang/setup-dart@v1 # with:
with: # sdk: stable
sdk: stable # - name: Run pub get
- name: Run pub get # run: dart pub get
run: dart pub get # - name: Run tests with coverage
- name: Download generated code # run: dart test --coverage=coverage --reporter=failures-only
uses: actions/download-artifact@v4 # - name: Activate coverage package
with: # run: dart pub global activate coverage
name: generated-code # - name: Format coverage
- name: Run tests with coverage # run: dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info --report-on=lib --check-ignore --ignore-files="**/*.g.dart"
run: dart test --coverage=coverage --reporter=failures-only # - name: Generate coverage report
- name: Activate coverage package # run: dart scripts/coverage_report.dart --ci > coverage-results.txt
run: dart pub global activate coverage # - name: Comment on PR
- name: Format coverage # if: github.event_name == 'pull_request'
run: dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info --report-on=lib --check-ignore --ignore-files="**/*.g.dart" # uses: thollander/actions-comment-pull-request@v3
- name: Generate coverage report # with:
run: dart scripts/coverage_report.dart --ci > coverage-results.txt # file-path: coverage-results.txt
- name: Comment on PR # comment-tag: coverage-results
if: github.event_name == 'pull_request'
uses: thollander/actions-comment-pull-request@v3
with:
file-path: coverage-results.txt
comment-tag: coverage-results
benchmarks: # benchmarks:
needs: generate # name: run benchmarks
name: run benchmarks # runs-on: ubuntu-latest
runs-on: ubuntu-latest # permissions:
permissions: # pull-requests: write
pull-requests: write # contents: write
contents: write # steps:
steps: # - uses: actions/checkout@v4
- uses: actions/checkout@v4 # - name: Set up Dart
- name: Set up Dart # uses: dart-lang/setup-dart@v1
uses: dart-lang/setup-dart@v1 # with:
with: # sdk: stable
sdk: stable # - name: Run pub get
- name: Run pub get # run: dart pub get
run: dart pub get # - name: Configure Git
- name: Download generated code # run: |
uses: actions/download-artifact@v4 # git config --global user.name "GitHub Actions"
with: # git config --global user.email "actions@github.com"
name: generated-code # - name: Fetch Existing Benchmark Data
- name: Configure Git # run: git fetch origin refs/notes/benchmarks:refs/notes/benchmarks || true
run: | # - name: Run benchmarks
git config --global user.name "GitHub Actions" # run: dart benchmark/run.dart --ci > benchmark-results.txt
git config --global user.email "actions@github.com" # - name: Comment on PR
- name: Fetch Existing Benchmark Data # if: github.event_name == 'pull_request'
run: git fetch origin refs/notes/benchmarks:refs/notes/benchmarks || true # uses: thollander/actions-comment-pull-request@v3
- name: Run benchmarks # with:
run: dart benchmark/run.dart --ci > benchmark-results.txt # file-path: benchmark-results.txt
- name: Comment on PR # comment-tag: benchmark-results
if: github.event_name == 'pull_request' # - name: Store Benchmark Data in Git Notes
uses: thollander/actions-comment-pull-request@v3 # run: |
with: # if [ "${{ github.event_name }}" = "pull_request" ]; then
file-path: benchmark-results.txt # COMMIT_SHA=${{ github.event.pull_request.head.sha }}
comment-tag: benchmark-results # else
- name: Store Benchmark Data in Git Notes # COMMIT_SHA=${{ github.sha }}
run: | # fi
if [ "${{ github.event_name }}" = "pull_request" ]; then # git notes --ref=benchmarks add -f -F benchmark-results.txt $COMMIT_SHA
COMMIT_SHA=${{ github.event.pull_request.head.sha }} # - name: Push Benchmark Data
else # if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork != true
COMMIT_SHA=${{ github.sha }} # run: git push origin refs/notes/benchmarks
fi
git notes --ref=benchmarks add -f -F benchmark-results.txt $COMMIT_SHA
- name: Push Benchmark Data
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork != true
run: git push origin refs/notes/benchmarks
publish_check: # publish_check:
needs: generate # name: dart publish check
name: dart publish check # runs-on: ubuntu-latest
runs-on: ubuntu-latest # steps:
steps: # - uses: actions/checkout@v4
- uses: actions/checkout@v4 # - name: Set up Dart
- name: Set up Dart # uses: dart-lang/setup-dart@v1
uses: dart-lang/setup-dart@v1 # with:
with: # sdk: stable
sdk: stable # - name: Run pub get
- name: Run pub get # run: dart pub get
run: dart pub get # - name: Run publish dry run
- name: Download generated code # run: dart pub publish --dry-run
uses: actions/download-artifact@v4
with:
name: generated-code
- name: Run publish dry run
run: dart pub publish --dry-run

1
.gitignore vendored
View file

@ -7,7 +7,6 @@
pubspec.lock pubspec.lock
.vscode/ .vscode/
*.g.dart
# Coverage reports # Coverage reports
coverage/ coverage/

View file

@ -1,17 +1,17 @@
# Drift Offline Sync # Serverpod Offline Sync
[![GitHub](https://img.shields.io/badge/GitHub-marcelomendoncasoares-181717.svg?style=flat&logo=github)](https://github.com/marcelomendoncasoares) [![GitHub](https://img.shields.io/badge/GitHub-marcelomendoncasoares-181717.svg?style=flat&logo=github)](https://github.com/marcelomendoncasoares)
[![Pub Package](https://img.shields.io/pub/v/drift_offline_sync.svg)](https://pub.dev/packages/drift_offline_sync) [![Pub Package](https://img.shields.io/pub/v/serverpod_offline_sync.svg)](https://pub.dev/packages/serverpod_offline_sync)
[![Dart SDK Version](https://badgen.net/pub/sdk-version/drift_offline_sync.svg)](https://pub.dev/packages/drift_offline_sync) [![Dart SDK Version](https://badgen.net/pub/sdk-version/serverpod_offline_sync.svg)](https://pub.dev/packages/serverpod_offline_sync)
[![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg)](https://github.com/marcelomendoncasoares/drift_offline_sync/blob/main/LICENSE) [![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg)](https://github.com/marcelomendoncasoares/serverpod_offline_sync/blob/main/LICENSE)
[![CI](https://github.com/marcelomendoncasoares/drift_offline_sync/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/marcelomendoncasoares/drift_offline_sync/actions/workflows/ci.yaml) [![CI](https://github.com/marcelomendoncasoares/serverpod_offline_sync/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/marcelomendoncasoares/serverpod_offline_sync/actions/workflows/ci.yaml)
A plugin to build offline-first applications with Drift. Develop your app as a A plugin to build offline-first applications with Serverpod. Develop your app
normal offline app and use this non-invasive plugin to automatically handle the as a normal offline app and use this non-invasive plugin to automatically
sync process using CRDT (Conflict-free Replicated Data Type). Works handle the sync process using CRDT (Conflict-free Replicated Data Type). Works
out-of-the-box with Drift's existing APIs. out-of-the-box with Serverpod's existing APIs.
- [Drift Offline Sync](#drift-offline-sync) - [Serverpod Offline Sync](#serverpod-offline-sync)
- [Features](#features) - [Features](#features)
- [Getting started](#getting-started) - [Getting started](#getting-started)
- [Usage](#usage) - [Usage](#usage)

41
SPEC.md
View file

@ -1,41 +0,0 @@
# Specification for the implementation
## Starting the database
- After the `createAll()`, call a custom function to inspect the database and
list:
- Each table unique constraints
- All foreign keys
Check
[this research](https://www.perplexity.ai/search/on-drift-for-flutter-after-all-aaj9LV1QTeSST3brpolEMA)
for reference.
## How to specify which constraint to apply to each case?
Unique can be handled as:
- on_conflict_merge (default)
- on_conflict_overwrite
Foreign keys can be handled as:
- on_fk_deleted_delete_this (default, if cascade delete)
- on_fk_deleted_set_null (default, if optional)
- on_fk_deleted_restore_related
## After merge on sync, a callback must be available to check custom rules
There must have an easy way to issue fixes and re-run the merge.
## Next:
- [ ] Design the registration of constraints in the database.
- [ ] Integrate the CRDT for retrieval of changes.
- [ ] Integrate the CRDT merge logic.
- [ ] Integrate the compensation logic with the merge logic.
- [ ] Implement a regenerate process to run after migrations.
- [ ] Add pending tests marked as TODO.
- [ ] Transform this repo in a monorepo with this package and a Flutter version
that provides a custom `driftDatabase` function that already passes the
closure to add the `next_hlc_timestamp` function to the database.

View file

@ -0,0 +1,164 @@
name: CI
on:
workflow_dispatch:
push:
branches:
- main
- stable-*
pull_request:
branches:
- main
- stable-*
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
generate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Dart
uses: dart-lang/setup-dart@v1
- name: Run pub get
run: dart pub get
- name: Generate code
run: dart run build_runner build --delete-conflicting-outputs
- name: Upload generated code
uses: actions/upload-artifact@v4
with:
name: generated-code
path: |
**/*.g.dart
**/*.freezed.dart
lib/**/*.part.dart
dart_format:
name: dart format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Dart
uses: dart-lang/setup-dart@v1
- name: Run pub get
run: dart pub get
# Skip code generation for formatting check
- name: Verify formatting
run: dart format --output=none --set-exit-if-changed .
dart_analyze:
needs: generate
name: dart analyze
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Dart
uses: dart-lang/setup-dart@v1
- name: Run pub get
run: dart pub get
- name: Download generated code
uses: actions/download-artifact@v4
with:
name: generated-code
- name: Run lint
run: dart analyze .
tests:
needs: generate
name: dart test
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Set up Dart
uses: dart-lang/setup-dart@v1
with:
sdk: stable
- name: Run pub get
run: dart pub get
- name: Download generated code
uses: actions/download-artifact@v4
with:
name: generated-code
- name: Run tests with coverage
run: dart test --coverage=coverage --reporter=failures-only
- name: Activate coverage package
run: dart pub global activate coverage
- name: Format coverage
run: dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info --report-on=lib --check-ignore --ignore-files="**/*.g.dart"
- name: Generate coverage report
run: dart scripts/coverage_report.dart --ci > coverage-results.txt
- name: Comment on PR
if: github.event_name == 'pull_request'
uses: thollander/actions-comment-pull-request@v3
with:
file-path: coverage-results.txt
comment-tag: coverage-results
benchmarks:
needs: generate
name: run benchmarks
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: write
steps:
- uses: actions/checkout@v4
- name: Set up Dart
uses: dart-lang/setup-dart@v1
with:
sdk: stable
- name: Run pub get
run: dart pub get
- name: Download generated code
uses: actions/download-artifact@v4
with:
name: generated-code
- name: Configure Git
run: |
git config --global user.name "GitHub Actions"
git config --global user.email "actions@github.com"
- name: Fetch Existing Benchmark Data
run: git fetch origin refs/notes/benchmarks:refs/notes/benchmarks || true
- name: Run benchmarks
run: dart benchmark/run.dart --ci > benchmark-results.txt
- name: Comment on PR
if: github.event_name == 'pull_request'
uses: thollander/actions-comment-pull-request@v3
with:
file-path: benchmark-results.txt
comment-tag: benchmark-results
- name: Store Benchmark Data in Git Notes
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
COMMIT_SHA=${{ github.event.pull_request.head.sha }}
else
COMMIT_SHA=${{ github.sha }}
fi
git notes --ref=benchmarks add -f -F benchmark-results.txt $COMMIT_SHA
- name: Push Benchmark Data
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork != true
run: git push origin refs/notes/benchmarks
publish_check:
needs: generate
name: dart publish check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Dart
uses: dart-lang/setup-dart@v1
with:
sdk: stable
- name: Run pub get
run: dart pub get
- name: Download generated code
uses: actions/download-artifact@v4
with:
name: generated-code
- name: Run publish dry run
run: dart pub publish --dry-run

13
legacy/drift_offline_sync/.gitignore vendored Normal file
View file

@ -0,0 +1,13 @@
# https://dart.dev/guides/libraries/private-files
# Created by `dart pub`
.dart_tool/
# Avoid committing pubspec.lock for library packages; see
# https://dart.dev/guides/libraries/private-files#pubspeclock.
pubspec.lock
.vscode/
# Coverage reports
coverage/
coverage-results.txt

View file

@ -0,0 +1,3 @@
## 0.0.1
- Initial version.

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Marcelo Mendonça Soares
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,90 @@
# Drift Offline Sync
[![GitHub](https://img.shields.io/badge/GitHub-marcelomendoncasoares-181717.svg?style=flat&logo=github)](https://github.com/marcelomendoncasoares)
[![Pub Package](https://img.shields.io/pub/v/drift_offline_sync.svg)](https://pub.dev/packages/drift_offline_sync)
[![Dart SDK Version](https://badgen.net/pub/sdk-version/drift_offline_sync.svg)](https://pub.dev/packages/drift_offline_sync)
[![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg)](https://github.com/marcelomendoncasoares/drift_offline_sync/blob/main/LICENSE)
[![CI](https://github.com/marcelomendoncasoares/drift_offline_sync/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/marcelomendoncasoares/drift_offline_sync/actions/workflows/ci.yaml)
A plugin to build offline-first applications with Drift. Develop your app as a
normal offline app and use this non-invasive plugin to automatically handle the
sync process using CRDT (Conflict-free Replicated Data Type). Works
out-of-the-box with Drift's existing APIs.
- [Drift Offline Sync](#drift-offline-sync)
- [What is this package?](#what-is-this-package)
- [Usage](#usage)
- [How does it work?](#how-does-it-work)
- [Performance considerations](#performance-considerations)
- [License](#license)
---
## What is this package?
This package is an adapter for a Drift database that can transform any offline application into offline-first with multi-device support. It can be plugged into any application developed with an offline database (managed in Drift) to provide full synchronization with a remote server. The only required change is the replacement of the original `Migrator` object in the database declaration by the one provided in this package and that all tables have a UUID as part of its primary key.
Under the hood, the sync engine uses Conflict-free Replicated Data Types (CRDT) to merge updates and solve merge conflicts. Beyond traditional CRDT approaches, it implements a robust mechanism to preserve invariants of the system (foreign key and unique constraints, for example) and handle migrations. This is what enables this engine to be safely used with traditional relational data instead of sacrificing data integrity and strict typing to use key-value storages.
## Usage
Just use the `OfflineSyncMigrator` in the `createMigrator` method of your database class.
```dart
class TodoDb extends _$TodoDb {
TodoDb(super.e);
@override
int schemaVersion = 1;
// This is what needs to be added to the user database class to make it CRDT aware.
@override
OfflineSyncMigrator createMigrator() => OfflineSyncMigrator(
this,
userId: userId,
nodeId: nodeId,
synchronizedTables: [
todosTable,
categories,
users,
sharedTodos,
],
excludeTables: [
listing,
product,
],
);
}
```
Make sure that all tables have a UUID as part of its primary key - standard practice for distributed systems.
## How does it work?
When the database is initialized, the `OfflineSyncMigrator` will create a few control tables and add triggers to all user tables listed for synchronization (defaults to all tables). Each table will receive three triggers: after insert, after update and after delete. The triggers serve to populate the main CRDT control table with all the changes (granular, individualized to field updates and their invariance preserving rules). Each update is marked with an HLC (Hybrid Logical Clock) timestamp that will be used as reference to further merging changes between devices.
During migrations, as triggers are created expecting the current table schema, they must be removed to avoid errors - like trying to compare a column that was removed. So, after migration is finished, a `regenerate` is called to compare the changes and synchronize the central CRDT registry. In the meantime, changes to the schema (like column addition and deletion) are reflected to the stored data on the central registry.
Although schema changes does not get propagated through the merging process—there is no CRDT registry for table schemas—this is not a concern, as changes can only be synchronized if the databases match version. So, after migration, all databases will converge to the same schema, with each carrying their pending merge data to the new format. Only then, sync will occur.
## Performance considerations
The greater costs of this adapter are:
- Database size can be doubled or more.
- Each insert/update/delete happen twice.
- Migrations require a regenerate process that increases time considerably.
The advantages are:
- Offline-first sync.
- No performance cost on read operations.
- Simple offline app development.
- Invariant safety - no weird states in the data.
- Plug and play to any existing offline app.
- No dealing with remote-aware repositories.
## License
This package is distributed under the MIT License. See the LICENSE file for
more information.

View file

@ -0,0 +1,132 @@
include: package:lints/recommended.yaml
linter:
rules:
- always_declare_return_types
- always_put_required_named_parameters_first
- annotate_redeclares
- avoid_bool_literals_in_conditional_expressions
- avoid_catches_without_on_clauses
- avoid_catching_errors
- avoid_empty_else
- avoid_escaping_inner_quotes
- avoid_field_initializers_in_const_classes
- avoid_final_parameters
- avoid_js_rounded_ints
- avoid_positional_boolean_parameters
- avoid_private_typedef_functions
- avoid_relative_lib_imports
- avoid_returning_this
- avoid_setters_without_getters
- avoid_shadowing_type_parameters
- avoid_slow_async_io
- avoid_types_as_parameter_names
- avoid_unused_constructor_parameters
- avoid_void_async
- await_only_futures
- camel_case_extensions
- cancel_subscriptions
- cascade_invocations
- cast_nullable_to_non_nullable
- close_sinks
- collection_methods_unrelated_type
- combinators_ordering
- comment_references
- conditional_uri_does_not_exist
- curly_braces_in_flow_control_structures
- dangling_library_doc_comments
- depend_on_referenced_packages
- deprecated_consistency
- deprecated_member_use_from_same_package
- directives_ordering
- discarded_futures
- document_ignores
- empty_catches
- eol_at_end_of_file
- file_names
- hash_and_equals
- implicit_call_tearoffs
- invalid_case_patterns
- library_names
- literal_only_boolean_expressions
- matching_super_parameters
- missing_code_block_language_in_doc_comment
- missing_whitespace_between_adjacent_strings
- no_adjacent_strings_in_list
- no_duplicate_case_values
- no_literal_bool_comparisons
- no_runtimeType_toString
- no_self_assignments
- no_wildcard_variable_uses
- non_constant_identifier_names
- noop_primitive_operations
- null_check_on_nullable_type_parameter
- omit_local_variable_types
- only_throw_errors
- package_prefixed_library_names
- prefer_const_constructors
- prefer_const_declarations
- prefer_final_in_for_each
- prefer_final_locals
- prefer_foreach
- prefer_if_elements_to_conditional_expressions
- prefer_is_empty
- prefer_is_not_empty
- prefer_iterable_whereType
- prefer_mixin
- prefer_null_aware_method_calls
- prefer_relative_imports
- prefer_single_quotes
- prefer_typing_uninitialized_variables
- prefer_void_to_null
- provide_deprecation_message
- public_member_api_docs
- require_trailing_commas
- sized_box_shrink_expand
- sort_constructors_first
- sort_unnamed_constructors_first
- test_types_in_equals
- throw_in_finally
- tighten_type_of_initializing_formals
- type_annotate_public_apis
- type_literal_in_constant_pattern
- unawaited_futures
- unintended_html_in_doc_comment
- unnecessary_await_in_return
- unnecessary_breaks
- unnecessary_lambdas
- unnecessary_library_directive
- unnecessary_null_aware_operator_on_extension_on_nullable
- unnecessary_null_checks
- unnecessary_overrides
- unnecessary_parenthesis
- unnecessary_raw_strings
- unnecessary_statements
- unreachable_from_main
- unrelated_type_equality_checks
- use_colored_box
- use_decorated_box
- use_enums
- use_if_null_to_convert_nulls_to_bools
- use_is_even_rather_than_modulo
- use_named_constants
- use_raw_strings
- use_setters_to_change_properties
- use_string_buffers
- use_string_in_part_of_directives
- use_test_throws_matchers
- use_to_and_as_if_applicable
- valid_regexps
- void_checks
formatter:
page_width: 88
trailing_commas: preserve
analyzer:
exclude:
- "**/*.g.dart"
- "**/*.drift.dart"
language:
strict-casts: true
strict-inference: true

View file

@ -0,0 +1,62 @@
# Benchmarks
This folder contains benchmarks to measure the performance impact of using the `drift_offline_sync` package on database operations.
## What is Measured
The benchmarks measure two key metrics:
1. **Insert/Update/Delete Performance**: Time required to insert/update/delete
a large number of rows with many columns.
2. **Storage Overhead**: Database file size after inserting a large number of
rows with many columns.
The benchmarks are run in a separate isolate to avoid interference with the main
process. A baseline benchmark is run first to establish a reference point and
then a CRDT-enabled benchmark is run to measure the performance impact of using
the `drift_offline_sync` package.
## Running the Benchmarks
To run the benchmarks:
```bash
dart benchmark/run.dart
```
## Accessing Benchmark Results
When running from the CI, benchmark results are automatically stored as git notes on commits. If running on a pull-request, it is also pasted as a comment that is kept up to date on each push. To access historical benchmark data:
```bash
# Fetch benchmark notes from remote (if not already available)
git fetch origin refs/notes/benchmarks:refs/notes/benchmarks
# View benchmark results for the latest commit
git notes --ref=benchmarks show
# View benchmark results for a specific commit
git notes --ref=benchmarks show <commit-sha>
# View all commits with their benchmark results
git log --show-notes=benchmark
```
## Benchmark Details
### Table Structure
The benchmarks use `TableWithEveryColumnType` which includes:
- Boolean (`aBool`)
- DateTime (`aDateTime`)
- Text (`aText`)
- Integer (`anInt`)
- Int64 (`anInt64`)
- Real/Double (`aReal`)
- Blob (`aBlob`)
- Integer Enum (`anIntEnum`)
- Text with Converter (`aTextWithConverter`)
- UUID (`aUuid`)
This comprehensive table structure ensures the benchmarks reflect realistic use
cases with various data types.

View file

@ -0,0 +1,93 @@
import 'utils/benchmark.dart';
import 'utils/conversion.dart';
import 'utils/results.dart';
import 'utils/runner.dart';
Future<void> main(List<String> args) async {
final runningInCI = args.contains('--ci');
final rowCountArg = args.where((arg) => arg.startsWith('--rows=')).firstOrNull;
final rowCount = rowCountArg == null ? 1000 : int.parse(rowCountArg.substring(7));
final rowsString = formatter0.format(rowCount);
if (!runningInCI) print('\n${'=' * 60}');
print('## Performance Benchmark ($rowsString rows)\n');
print(
[
'This benchmark measures the performance impact of using CRDT ',
'synchronization on database operations.',
].join(runningInCI ? '' : '\n'),
);
if (!runningInCI) print('-' * 60);
final benchmarkResults = <BenchmarkResults>[];
for (final operation in Operation.values) {
final baselineResult = await runWithProgress(
'Running ${operation.name} benchmark (baseline)',
CrdtBenchmark(
'${operation.name} (baseline)',
crdtEnabled: false,
operation: operation,
rowCount: rowCount,
).report,
skipProgress: runningInCI,
);
final crdtResult = await runWithProgress(
'Running ${operation.name} benchmark (CRDT)',
CrdtBenchmark(
'${operation.name} (CRDT)',
crdtEnabled: true,
operation: operation,
rowCount: rowCount,
).report,
validator: (result) => result.$1 > baselineResult.$1,
skipProgress: runningInCI,
);
benchmarkResults.add(
BenchmarkResults(
operation: operation,
baseline: baselineResult,
crdt: crdtResult,
),
);
}
final spuriousBenchmarks = benchmarkResults.where(
(result) => result.baseline.$1 > result.crdt.$1,
);
if (spuriousBenchmarks.isNotEmpty) {
final leadingText = runningInCI ? '\n> ' : '\n';
print(
[
'$leadingText❌ Baseline time is greater than CRDT time. This is likely due ',
'to running with a very low number of rows ($rowsString). This is not ',
' a valid benchmark and the time comparison should be ignored.',
].join(runningInCI ? '' : '\n'),
);
for (final result in spuriousBenchmarks) {
final baselineDelay = result.baseline.$1.toFormattedDuration();
final crdtDelay = result.crdt.$1.toFormattedDuration();
print(' - ${result.operation.name.toUpperCase()}: $baselineDelay > $crdtDelay');
}
}
for (final result in benchmarkResults) {
printPerformanceImpact(
result,
rowCount: rowCount,
runningInCI: runningInCI,
);
}
printStorageImpact(
benchmarkResults.first,
rowCount: rowCount,
runningInCI: runningInCI,
);
print('\n${'-' * 60}');
print('✅ Benchmark complete!\n');
}

View file

@ -0,0 +1,99 @@
import 'dart:io';
import 'package:benchmark_harness/benchmark_harness.dart';
import 'package:drift_offline_sync/src/database/triggers.dart';
import 'package:path/path.dart' as p;
import 'database.dart';
export '../../lib/src/database/triggers.dart' show Operation;
class CrdtBenchmark extends AsyncBenchmarkBase {
CrdtBenchmark(
super.name, {
required this.crdtEnabled,
required this.operation,
required this.rowCount,
});
final bool crdtEnabled;
final Operation operation;
final int rowCount;
late final CrdtBenchmarkDatabase _db;
late final File _dbFile;
int _lastDatabaseSize = 0;
int _lastRowsCount = 0;
@override
Future<void> setup() async {
final dbPath = p.join(Directory.systemTemp.path, 'benchmark_$name.db');
_dbFile = File(dbPath);
_db = CrdtBenchmarkDatabase.createDatabase(
_dbFile,
crdtEnabled: crdtEnabled,
);
// Force the migration to be applied during setup.
await _db.batchInsertTestRows(1);
await _db.deleteTestRows(1);
await _db.managers.tableWithEveryColumnType.delete();
if (operation != Operation.insert) {
await _db.batchInsertTestRows(rowCount);
}
}
/// The default warmup is to invoke the [run] method once, but this pollutes the
/// results by persisting data before the run.
@override
Future<void> warmup() async {}
@override
Future<void> run() async {
_lastDatabaseSize = 0;
_lastRowsCount = 0;
switch (operation) {
case Operation.insert:
case Operation.update:
await _db.upsertTestRows(rowCount);
case Operation.delete:
await _db.deleteTestRows(rowCount);
}
}
@override
Future<(double, int)> report() async {
final averageMicroseconds = await measure();
return (averageMicroseconds, measureStorage());
}
@override
Future<void> teardown() async {
_lastDatabaseSize = _dbFile.lengthSync();
_lastRowsCount = await _db.testRowsCount();
await _db.close();
if (_dbFile.existsSync()) {
_dbFile.deleteSync();
}
}
int measureStorage() {
if (operation == Operation.delete) {
return _lastDatabaseSize;
}
if (_lastDatabaseSize == 0) {
throw Exception('Last database size is not set.');
}
if (_lastRowsCount < rowCount) {
throw Exception(
'Rows count is $_lastRowsCount but expected at least $rowCount.',
);
}
// If the benchmark ran for more than one loop, it can contain a greater number
// of rows than the expected. We need to normalize it to the expected total.
return _lastDatabaseSize ~/ (_lastRowsCount / rowCount);
}
}

View file

@ -0,0 +1,48 @@
import 'package:intl/intl.dart';
final formatter0 = NumberFormat.decimalPatternDigits(decimalDigits: 0);
final formatter2 = NumberFormat.decimalPatternDigits(decimalDigits: 2);
final formatter3 = NumberFormat.decimalPatternDigits(decimalDigits: 3);
extension FormattedStorageSize on num {
String toFormattedDuration() {
var time = this;
var unit = 1;
while (time.abs() > 1000 && unit < Duration.microsecondsPerSecond) {
time /= 1000;
unit *= 1000;
}
while (unit > Duration.microsecondsPerMinute && time.abs() > 60) {
time /= 60;
unit *= 60;
}
return '${formatter2.format(time)} ${switch (unit) {
1 => 'μs',
Duration.millisecondsPerSecond => 'ms',
Duration.microsecondsPerSecond => 's',
Duration.microsecondsPerMinute => 'min',
Duration.microsecondsPerHour => 'h',
_ => throw Exception('Unexpected time unit: $unit'),
}}';
}
String toFormattedStorageSize() {
var storageIncrease = toDouble();
var storageUnit = 1;
while (storageIncrease > 1024) {
storageIncrease /= 1024;
storageUnit *= 1024;
}
final storageUnitString = switch (storageUnit) {
1 => 'B',
1024 => 'KB',
const (1024 * 1024) => 'MB',
_ => throw Exception('Unexpected storage unit: $storageUnit'),
};
return '${formatter2.format(storageIncrease)} $storageUnitString';
}
}

View file

@ -0,0 +1,95 @@
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:drift_offline_sync/drift_offline_sync.dart';
import 'package:sqlite3/sqlite3.dart';
import 'package:uuid/uuid.dart';
import '../../test/utils/tables.dart';
part 'database.g.dart';
@DriftDatabase(tables: [TableWithEveryColumnType])
class CrdtBenchmarkDatabase extends _$CrdtBenchmarkDatabase {
CrdtBenchmarkDatabase(super.e, {required this.crdtEnabled}) {
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
}
static CrdtBenchmarkDatabase createDatabase(
File file, {
required bool crdtEnabled,
}) {
if (file.existsSync()) {
file.deleteSync();
}
return CrdtBenchmarkDatabase(
NativeDatabase.opened(sqlite3.open(file.path)),
crdtEnabled: crdtEnabled,
);
}
final bool crdtEnabled;
@override
int get schemaVersion => 1;
@override
Migrator createMigrator() => !crdtEnabled
? super.createMigrator()
: OfflineSyncMigrator(
this,
userId: const Uuid().v7(),
nodeId: const Uuid().v7(),
synchronizedTables: [tableWithEveryColumnType],
);
Future<void> batchInsertTestRows(int count) async {
final baseTimestamp = DateTime.now();
await managers.tableWithEveryColumnType.bulkCreate(
(_) => List.generate(count, (i) => _createTestRow(i, baseTimestamp)),
);
}
Future<void> upsertTestRows(int count) async {
final baseTimestamp = DateTime.now();
for (var i = 0; i < count; i++) {
await managers.tableWithEveryColumnType.create(
(_) => _createTestRow(i, baseTimestamp),
mode: InsertMode.insertOrReplace,
);
}
}
Future<void> deleteTestRows(int count) async {
for (var i = 0; i < count; i++) {
await managers.tableWithEveryColumnType
.filter((t) => t.id.equals(RowId(i)))
.delete();
}
}
Future<int> testRowsCount() async {
return managers.tableWithEveryColumnType.count();
}
TableWithEveryColumnTypeCompanion _createTestRow(
int i,
DateTime baseTimestamp,
) {
return TableWithEveryColumnTypeCompanion(
id: Value(RowId(i)),
aBool: Value(i.isEven),
aDateTime: Value(baseTimestamp.add(Duration(seconds: i))),
aText: Value('Text $i'),
anInt: Value(i),
anInt64: Value(BigInt.from(i)),
aReal: Value(i.toDouble()),
aBlob: Value(Uint8List.fromList([i % 256])),
anIntEnum: const Value(null),
aTextWithConverter: const Value(null),
aUuid: const Value(null),
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,64 @@
import 'benchmark.dart';
import 'conversion.dart';
void printPerformanceImpact(
BenchmarkResults benchmarkResults, {
required int rowCount,
bool runningInCI = false,
}) {
print(
'${runningInCI ? '```' : ''}'
'\n📊 ${benchmarkResults.operation.name.toUpperCase()} performance impact:',
);
final (baselineTime, _) = benchmarkResults.baseline;
final (crdtTime, _) = benchmarkResults.crdt;
final runDelayUs = crdtTime - baselineTime;
final slowdown = runDelayUs / baselineTime * 100;
final baselineDelay = baselineTime.toFormattedDuration();
final crdtDelay = crdtTime.toFormattedDuration();
final runDelay = runDelayUs.toFormattedDuration();
final runDelayPerOperation = (runDelayUs / rowCount).toFormattedDuration();
print(' Time: $baselineDelay --> $crdtDelay ($runDelay)');
print(' CRDT overhead: ${formatter2.format(slowdown)}% slower');
print(' Delay per ${benchmarkResults.operation.name}: $runDelayPerOperation');
if (runningInCI) print('```');
}
void printStorageImpact(
BenchmarkResults benchmarkResults, {
required int rowCount,
bool runningInCI = false,
}) {
print('${runningInCI ? '```' : ''}\n💽 Storage impact:');
final (_, baselineSize) = benchmarkResults.baseline;
final (_, crdtSize) = benchmarkResults.crdt;
final storageIncrease = crdtSize - baselineSize;
final storageIncPercent = storageIncrease / baselineSize * 100;
final extraStoragePerRow = storageIncrease / rowCount;
final storageIncreaseVolume = storageIncPercent > 100
? '(x${formatter2.format(storageIncPercent / 100)})'
: '(+${(crdtSize - baselineSize).toFormattedStorageSize()})';
print(
' Storage size: ${baselineSize.toFormattedStorageSize()} '
'--> ${crdtSize.toFormattedStorageSize()} $storageIncreaseVolume',
);
print(' CRDT overhead: ${formatter2.format(storageIncPercent)}% increase');
print(' Extra storage per row: ${extraStoragePerRow.toFormattedStorageSize()}');
if (runningInCI) print('```');
}
class BenchmarkResults {
const BenchmarkResults({
required this.operation,
required this.baseline,
required this.crdt,
});
final Operation operation;
final (double, int) baseline;
final (double, int) crdt;
}

View file

@ -0,0 +1,39 @@
import 'dart:io';
import 'dart:isolate';
import 'package:cli_tools/src/logger/helpers/progress.dart';
Progress? _trackedAnimationInProgress;
Future<T> runWithProgress<T>(
String message,
Future<T> Function() runner, {
bool Function(T result)? validator,
bool skipProgress = false,
}) async {
if (skipProgress) {
return Isolate.run(runner);
}
_stopAnimationInProgress();
final progress = Progress(message, stdout);
_trackedAnimationInProgress = progress;
final result = await Isolate.run(runner);
_trackedAnimationInProgress = null;
if (validator?.call(result) ?? true) {
progress.complete();
} else {
progress.fail();
}
return result;
}
void _stopAnimationInProgress() {
if (_trackedAnimationInProgress != null) {
_trackedAnimationInProgress?.stopAnimation();
// Since animation modifies the current line we add a new line so that
// the next print doesn't end up on the same line.
stdout.write('\n');
}
_trackedAnimationInProgress = null;
}

Some files were not shown because too many files have changed in this diff Show more