2026-04-05 23:20:21 -03:00
|
|
|
|
# Serverpod Offline Sync
|
2026-01-13 00:35:53 -03:00
|
|
|
|
|
2026-01-25 23:58:12 -03:00
|
|
|
|
[](https://github.com/marcelomendoncasoares)
|
2026-04-05 23:20:21 -03:00
|
|
|
|
[](https://pub.dev/packages/serverpod_offline_sync)
|
2026-09-16 17:14:17 -03:00
|
|
|
|
[](https://pub.dev/packages/serverpod_offline_sync)
|
2026-07-09 01:16:49 -03:00
|
|
|
|
[](https://github.com/marcelomendoncasoares/serverpod_offline_sync/blob/main/LICENSE)
|
2026-04-05 23:20:21 -03:00
|
|
|
|
[](https://github.com/marcelomendoncasoares/serverpod_offline_sync/actions/workflows/ci.yaml)
|
2026-01-13 00:35:53 -03:00
|
|
|
|
|
2026-04-05 23:20:21 -03:00
|
|
|
|
A plugin to build offline-first applications with Serverpod. 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 Serverpod's existing APIs.
|
2026-01-13 00:35:53 -03:00
|
|
|
|
|
2026-04-05 23:20:21 -03:00
|
|
|
|
- [Serverpod Offline Sync](#serverpod-offline-sync)
|
2026-07-09 01:16:49 -03:00
|
|
|
|
- [Overview](#overview)
|
2026-01-25 23:58:12 -03:00
|
|
|
|
- [Features](#features)
|
|
|
|
|
|
- [Getting started](#getting-started)
|
2026-07-09 01:16:49 -03:00
|
|
|
|
- [1. Add the package to the project](#1-add-the-package-to-the-project)
|
|
|
|
|
|
- [2. Prepare the synced tables](#2-prepare-the-synced-tables)
|
2026-09-04 22:10:53 -03:00
|
|
|
|
- [3. Configure the sync engine on the client](#3-configure-the-sync-engine-on-the-client)
|
|
|
|
|
|
- [4. Wire the sync call on the client](#4-wire-the-sync-call-on-the-client)
|
2026-01-25 23:58:12 -03:00
|
|
|
|
- [Usage](#usage)
|
2026-09-14 13:39:07 -03:00
|
|
|
|
- [Spaces and sharing](#spaces-and-sharing)
|
2026-07-09 01:16:49 -03:00
|
|
|
|
- [Data modeling limitations](#data-modeling-limitations)
|
2026-09-17 10:39:07 -03:00
|
|
|
|
- [Reserved values](#reserved-values)
|
2026-07-09 01:16:49 -03:00
|
|
|
|
- [How it works](#how-it-works)
|
|
|
|
|
|
- [Performance](#performance)
|
|
|
|
|
|
- [Example](#example)
|
|
|
|
|
|
- [Additional information](#additional-information)
|
2026-01-25 23:58:12 -03:00
|
|
|
|
- [License](#license)
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-07-09 01:16:49 -03:00
|
|
|
|
## Overview
|
|
|
|
|
|
|
|
|
|
|
|
**Serverpod Offline Sync** turns an ordinary [Serverpod](https://serverpod.dev)
|
|
|
|
|
|
backend into an offline-first, collaborative system, without changing how you
|
|
|
|
|
|
write your app. It is a delta-CRDT engine that runs **the same code on the
|
|
|
|
|
|
client and server databases**, transparently and with minimal configuration.
|
|
|
|
|
|
|
|
|
|
|
|
It sits nearly alone in the space of offline-first solutions, because most
|
|
|
|
|
|
others either use a non-relational database, require a new data API, or don't
|
|
|
|
|
|
preserve foreign-key/unique invariants while merging data. This package is built
|
|
|
|
|
|
on top of Serverpod, leveraging its ORM, server, and client to expose these
|
|
|
|
|
|
capabilities with almost nothing new to learn: you build a normal offline
|
|
|
|
|
|
Serverpod app (generated models, ordinary CRUD) and the plugin handles
|
|
|
|
|
|
replication automatically.
|
|
|
|
|
|
|
|
|
|
|
|
The sync engine is a **black box that "just works"**: changes are tracked
|
|
|
|
|
|
atomically with each operation and you never intervene in conflict resolution.
|
|
|
|
|
|
Conflicts that need a human decision **bubble up as visible state** for the end
|
|
|
|
|
|
user to act on (e.g. a delete reverted by a concurrent constraint reappears, so
|
|
|
|
|
|
the user can simply delete it again). Migrations work normally, and deleted rows
|
|
|
|
|
|
never leave their tables — they are transparently hidden from the user.
|
|
|
|
|
|
|
|
|
|
|
|
> The CRDT layer was inspired by the excellent work on
|
|
|
|
|
|
> [Synql](https://github.com/coast-team/synql)
|
|
|
|
|
|
> ([Ignat et al., DAIS 2024](https://inria.hal.science/hal-04969158v4/document)).
|
|
|
|
|
|
> This package goes beyond Synql by covering the full set of foreign-key
|
|
|
|
|
|
> `onDelete` actions, extending unique-constraint handling to deeply relational
|
|
|
|
|
|
> schemas, and shipping a production-ready implementation.
|
|
|
|
|
|
|
2026-01-13 00:35:53 -03:00
|
|
|
|
## Features
|
|
|
|
|
|
|
2026-07-09 01:16:49 -03:00
|
|
|
|
- **Offline-first, no new API.** Read and write generated models with ordinary
|
|
|
|
|
|
Serverpod CRUD against a local database; the plugin replicates for you.
|
|
|
|
|
|
- **Same code on both ends.** Identical logic runs on the client's SQLite and
|
|
|
|
|
|
the server's Postgres, so there are no asymmetric merge results to reconcile.
|
|
|
|
|
|
- **Automatic conflict resolution.** Field-level merges ordered by hybrid
|
|
|
|
|
|
logical clocks, with a monotone causal-length tombstone governing row
|
|
|
|
|
|
existence so add/delete/restore can never oscillate.
|
|
|
|
|
|
- **Relational integrity preserved.** All foreign-key `onDelete` actions
|
|
|
|
|
|
(restrict / cascade / set null / set default) and unique constraints are
|
|
|
|
|
|
honored after every merge, as if the merged data had existed in a single
|
|
|
|
|
|
database at the time of each operation.
|
|
|
|
|
|
- **Conflicts surface to the user.** Resolutions that require a human decision
|
|
|
|
|
|
become visible state instead of silent data loss.
|
2026-09-14 13:39:07 -03:00
|
|
|
|
- **Spaces and sharing.** Data is isolated per space; each user has a personal
|
|
|
|
|
|
space and can be a member of any number of read-only or read-write shared
|
|
|
|
|
|
spaces.
|
2026-07-09 01:16:49 -03:00
|
|
|
|
- **One-shot and continuous sync.** The same stream-based protocol powers both
|
|
|
|
|
|
pull-to-refresh and near-real-time streaming.
|
|
|
|
|
|
- **Minimal overhead.** Metadata is normalized and never copies row data,
|
|
|
|
|
|
keeping the storage and performance penalty small (see [Performance](#performance)).
|
2026-01-13 00:35:53 -03:00
|
|
|
|
|
|
|
|
|
|
## Getting started
|
|
|
|
|
|
|
2026-07-09 01:16:49 -03:00
|
|
|
|
Using the package on a project takes just five steps. After this setup, you
|
|
|
|
|
|
develop the app with normal repository calls against the wrapped `session`.
|
2026-01-13 00:35:53 -03:00
|
|
|
|
|
2026-07-09 01:16:49 -03:00
|
|
|
|
> [!NOTE]
|
|
|
|
|
|
> This package is still in development and is not yet ready for production use.
|
|
|
|
|
|
> Although the package is functional and feature-complete, it will still pass
|
|
|
|
|
|
> through some refactors and breaking changes for a better integration on real
|
|
|
|
|
|
> Serverpod projects.
|
|
|
|
|
|
|
|
|
|
|
|
### 1. Add the package to the project
|
|
|
|
|
|
|
2026-07-10 13:16:43 -03:00
|
|
|
|
Add the packages to the server and client `pubspec.yaml` files.
|
2026-07-09 01:16:49 -03:00
|
|
|
|
|
|
|
|
|
|
```yaml
|
2026-07-09 01:39:13 -03:00
|
|
|
|
# your_project_client/pubspec.yaml
|
2026-07-09 01:16:49 -03:00
|
|
|
|
dependencies:
|
2026-10-08 23:34:54 -03:00
|
|
|
|
serverpod_offline_sync_client: 0.0.10
|
2026-09-14 17:06:48 -03:00
|
|
|
|
```
|
2026-07-09 01:16:49 -03:00
|
|
|
|
|
|
|
|
|
|
```yaml
|
2026-07-09 01:39:13 -03:00
|
|
|
|
# your_project_server/pubspec.yaml
|
2026-07-09 01:16:49 -03:00
|
|
|
|
dependencies:
|
2026-10-08 23:34:54 -03:00
|
|
|
|
serverpod_offline_sync_server: 0.0.10
|
2026-09-14 17:06:48 -03:00
|
|
|
|
```
|
2026-07-09 01:39:13 -03:00
|
|
|
|
|
2026-07-10 13:16:43 -03:00
|
|
|
|
> [!NOTE]
|
2026-10-08 23:34:54 -03:00
|
|
|
|
> Version `0.0.10` requires Serverpod `>=4.0.4 <4.1.0`.
|
2026-09-14 17:06:48 -03:00
|
|
|
|
|
2026-07-09 01:16:49 -03:00
|
|
|
|
After adding the dependencies, list the `serverpod_offline_sync` module on the
|
2026-08-31 21:15:35 -03:00
|
|
|
|
`generator.yaml` file and enable the experimental `database: sync` option:
|
2026-07-09 01:16:49 -03:00
|
|
|
|
|
|
|
|
|
|
```yaml
|
|
|
|
|
|
modules:
|
|
|
|
|
|
serverpod_offline_sync:
|
|
|
|
|
|
nickname: offline_sync
|
2026-08-31 21:15:35 -03:00
|
|
|
|
|
|
|
|
|
|
experimental_features:
|
|
|
|
|
|
databaseSync: true
|
2026-07-09 01:16:49 -03:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
The next call to `serverpod generate` and `serverpod create-migration` will
|
|
|
|
|
|
include the `offline_sync` module in the project. Note that the client tables
|
|
|
|
|
|
will only be present on the client's migration if at least one model on the
|
2026-08-31 21:15:35 -03:00
|
|
|
|
project is defined with `database: sync`, `database: all`, or `database: client`.
|
2026-07-09 01:16:49 -03:00
|
|
|
|
|
|
|
|
|
|
### 2. Prepare the synced tables
|
|
|
|
|
|
|
2026-08-31 21:15:35 -03:00
|
|
|
|
Each model you want to sync must be defined with `database: sync`. That generates
|
|
|
|
|
|
the table on both the client and the server, uses a UUID primary key, and
|
2026-09-14 13:39:07 -03:00
|
|
|
|
injects the `spaceId` ownership field. You mostly never set `spaceId` nor see
|
2026-08-31 21:15:35 -03:00
|
|
|
|
its value on reads; it is maintained by the CRDT sync layer.
|
2026-07-09 01:16:49 -03:00
|
|
|
|
|
|
|
|
|
|
```yaml
|
|
|
|
|
|
class: Person
|
|
|
|
|
|
table: person
|
2026-08-31 21:15:35 -03:00
|
|
|
|
database: sync
|
2026-07-09 01:16:49 -03:00
|
|
|
|
fields:
|
|
|
|
|
|
name: String
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-09-04 22:10:53 -03:00
|
|
|
|
The `serverpod generate` command will emit the `syncTables` list on the server
|
|
|
|
|
|
and the client. It will also wire the sync engine on the generated `Serverpod`
|
|
|
|
|
|
class, so no extra configuration is needed on the server.
|
2026-07-09 01:16:49 -03:00
|
|
|
|
|
2026-09-04 22:10:53 -03:00
|
|
|
|
### 3. Configure the sync engine on the client
|
2026-07-09 01:16:49 -03:00
|
|
|
|
|
2026-09-04 22:10:53 -03:00
|
|
|
|
The generated client opens a CRDT-wrapped session for the same tables:
|
2026-07-09 01:16:49 -03:00
|
|
|
|
|
|
|
|
|
|
```dart
|
2026-09-04 22:10:53 -03:00
|
|
|
|
final session = await client.createSyncSession(
|
|
|
|
|
|
databasePath,
|
|
|
|
|
|
isDebugMode: kDebugMode,
|
2026-07-09 01:16:49 -03:00
|
|
|
|
persistentUserId: persistentUserId,
|
|
|
|
|
|
);
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Then, store the `session` instance in the service locator.
|
|
|
|
|
|
|
2026-09-04 22:10:53 -03:00
|
|
|
|
### 4. Wire the sync call on the client
|
2026-01-13 00:35:53 -03:00
|
|
|
|
|
2026-07-09 01:16:49 -03:00
|
|
|
|
Be sure to authenticate the client before calling the sync methods. Otherwise,
|
|
|
|
|
|
the sync operation will fail with a `Unauthorized` error.
|
2026-01-13 00:35:53 -03:00
|
|
|
|
|
|
|
|
|
|
```dart
|
2026-07-09 01:16:49 -03:00
|
|
|
|
// Push local changes and merge remote ones once (i.e. pull-to-refresh).
|
2026-09-14 13:39:07 -03:00
|
|
|
|
await client.offlineSync.syncOnce(session);
|
2026-07-09 01:16:49 -03:00
|
|
|
|
|
|
|
|
|
|
// Or stream changes both ways until cancelled for a near-real-time sync.
|
2026-09-14 13:39:07 -03:00
|
|
|
|
final syncSession = client.offlineSync.syncContinuously(session);
|
2026-01-13 00:35:53 -03:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-09-16 11:40:42 -03:00
|
|
|
|
When disposing a client replica, cancel its sync subscription before closing
|
|
|
|
|
|
the local database:
|
|
|
|
|
|
|
|
|
|
|
|
```dart
|
|
|
|
|
|
await syncSession.cancel();
|
|
|
|
|
|
await session.close();
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-07-09 01:16:49 -03:00
|
|
|
|
## Usage
|
|
|
|
|
|
|
2026-07-09 02:09:51 -03:00
|
|
|
|
Once wired up, use normal generated-model CRUD against the `session` instance,
|
|
|
|
|
|
like `Person.db.insertRow(session, person)`, `Person.db.find(session)`, etc.
|
2026-07-09 01:16:49 -03:00
|
|
|
|
The sync layer tracks every operation atomically; you never touch conflict
|
|
|
|
|
|
resolution.
|
|
|
|
|
|
|
2026-09-14 13:39:07 -03:00
|
|
|
|
### Spaces and sharing
|
2026-07-09 01:16:49 -03:00
|
|
|
|
|
2026-09-14 13:39:07 -03:00
|
|
|
|
Data is isolated per **space**. Each user has their own implicit personal space
|
|
|
|
|
|
and can be a member of any number of shared spaces, with read-only or read-write
|
2026-07-09 01:16:49 -03:00
|
|
|
|
access.
|
|
|
|
|
|
|
|
|
|
|
|
- **On the server**, wrap database operations in `session.db.transactionForUser`
|
2026-09-14 13:39:07 -03:00
|
|
|
|
to target the correct space.
|
|
|
|
|
|
- **On the client**, set a `persistentUserId` to target the personal space by
|
|
|
|
|
|
default, or use `transactionForUser` to target a shared space.
|
2026-07-09 01:16:49 -03:00
|
|
|
|
|
2026-09-14 13:39:07 -03:00
|
|
|
|
Shared spaces are created and managed **on the server only** through the
|
|
|
|
|
|
`session.offlineSync.spaces` API.
|
2026-07-09 02:09:51 -03:00
|
|
|
|
|
|
|
|
|
|
```dart
|
2026-09-14 13:39:07 -03:00
|
|
|
|
// Create a shared space granting read-write access to a single user.
|
|
|
|
|
|
final spaceId = await session.offlineSync.spaces.createFor(userUuid);
|
2026-07-09 02:09:51 -03:00
|
|
|
|
|
2026-09-14 13:39:07 -03:00
|
|
|
|
// Create a shared space with multiple members.
|
|
|
|
|
|
final spaceId = await session.offlineSync.spaces.create(
|
2026-07-09 02:09:51 -03:00
|
|
|
|
grants: {
|
2026-09-14 13:39:07 -03:00
|
|
|
|
userUuid: OfflineSyncSpaceRole.readWrite,
|
|
|
|
|
|
anotherUserUuid: OfflineSyncSpaceRole.readOnly,
|
2026-07-09 02:09:51 -03:00
|
|
|
|
},
|
|
|
|
|
|
// It is also possible to pass a transaction object to all operations.
|
|
|
|
|
|
transaction: transaction,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// Add or update a member's role (readOnly or readWrite).
|
2026-09-14 13:39:07 -03:00
|
|
|
|
await session.offlineSync.spaces.grant(
|
|
|
|
|
|
space: spaceId,
|
2026-07-09 02:09:51 -03:00
|
|
|
|
user: bobUuid,
|
2026-09-14 13:39:07 -03:00
|
|
|
|
role: OfflineSyncSpaceRole.readWrite,
|
2026-07-09 02:09:51 -03:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Remove a member. No-op if they are not already a member.
|
2026-09-14 13:39:07 -03:00
|
|
|
|
await session.offlineSync.spaces.revoke(space: spaceId, user: bobUuid);
|
2026-07-09 02:09:51 -03:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-09-14 13:39:07 -03:00
|
|
|
|
If a streaming sync is ongoing, clients learn of new spaces and role changes on
|
2026-07-09 02:09:51 -03:00
|
|
|
|
the next sync cycle and apply them immediately. There is no need to restart the
|
|
|
|
|
|
sync session for changes to take effect.
|
|
|
|
|
|
|
|
|
|
|
|
> The package does not expose a management endpoint, since this is a domain
|
|
|
|
|
|
> responsibility. If desired, create your own endpoints where you can enforce
|
|
|
|
|
|
> an authorization policy (invitations, team ownership, etc.) and call the
|
2026-09-14 13:39:07 -03:00
|
|
|
|
> `session.offlineSync.spaces` API accordingly.
|
2026-07-09 02:09:51 -03:00
|
|
|
|
|
2026-07-09 01:16:49 -03:00
|
|
|
|
### Data modeling limitations
|
|
|
|
|
|
|
|
|
|
|
|
Because of the nature of merge conflicts, the package imposes some data-modeling
|
2026-08-31 21:15:35 -03:00
|
|
|
|
limitations. With `database: sync`, Serverpod reports most of them at generate
|
|
|
|
|
|
time. The rest are enforced at runtime during initialization. Most are
|
|
|
|
|
|
fundamental to the design and can never be lifted.
|
|
|
|
|
|
|
|
|
|
|
|
- Every synced table must be defined with `database: sync`.
|
|
|
|
|
|
- Will exist on both the client and the server, like `database: all`.
|
|
|
|
|
|
- The type of the `id` will be `UuidValue` with `defaultPersist=random_v7`.
|
2026-09-14 13:39:07 -03:00
|
|
|
|
- A `spaceId` field will be added as a cascade relation to `offline_sync_spaces`.
|
|
|
|
|
|
- Both `id` and `spaceId` can be manually declared for more control.
|
|
|
|
|
|
- Unique indexes must include `spaceId` together with the target columns.
|
2026-07-09 01:16:49 -03:00
|
|
|
|
- Global unique indexes are unsupported, except for FK-only indexes.
|
|
|
|
|
|
- Unique indexes are only supported with at least one
|
|
|
|
|
|
`String`/`UuidValue`/nullable column.
|
|
|
|
|
|
- All 1:1 relations must have the foreign-key column nullable (`optional`
|
|
|
|
|
|
relation).
|
2026-09-14 13:39:07 -03:00
|
|
|
|
- The only allowed non-synced-to-synced relation is `spaceId -> offline_sync_spaces.id`.
|
2026-08-25 11:25:48 -03:00
|
|
|
|
- Non-nullable foreign-key relations must be declared as `deferred`.
|
|
|
|
|
|
- Foreign-key action `onDelete=Restrict` must be replaced by `onDelete=NoAction`.
|
2026-07-09 01:16:49 -03:00
|
|
|
|
|
|
|
|
|
|
Respecting these limitations, all other database invariants — including
|
|
|
|
|
|
foreign-key actions — are preserved, with the exception of check constraints,
|
|
|
|
|
|
which Serverpod does not support either.
|
|
|
|
|
|
|
2026-09-17 10:39:07 -03:00
|
|
|
|
#### Reserved values
|
|
|
|
|
|
|
|
|
|
|
|
Besides the schema restrictions, some values are reserved by the sync layer and
|
|
|
|
|
|
cannot be authored by the user. Such values are used by the engine to resolve
|
|
|
|
|
|
unique conflicts and are not expected to represent a restriction to real
|
|
|
|
|
|
production usage. Trying to author a reserved value will immediately throw an
|
|
|
|
|
|
`OfflineSyncReservedValueException` exception to prevent later conflicts.
|
|
|
|
|
|
|
|
|
|
|
|
- Unique text values cannot be authored with a suffix matching
|
|
|
|
|
|
`__conflict__<UUID>`, `__hidden__<UUID>`, or `__park__<UUID>`. Non-unique
|
|
|
|
|
|
text fields are unrestricted.
|
|
|
|
|
|
- Non-nullable unique UUID columns that are not foreign keys reserve version 8
|
|
|
|
|
|
for generated alternatives. Primary keys, foreign keys, nullable UUID columns
|
|
|
|
|
|
and non-unique UUID columns are unrestricted.
|
|
|
|
|
|
|
2026-07-09 01:16:49 -03:00
|
|
|
|
## How it works
|
|
|
|
|
|
|
|
|
|
|
|
- **Architecture.** The engine is a transparent layer between the application
|
2026-08-04 11:04:04 -03:00
|
|
|
|
and its database. Its models and database implementation live in the shared
|
|
|
|
|
|
package; the client package only binds the generated module caller to the
|
|
|
|
|
|
shared sync transport. Because identical code runs on the client's SQLite and
|
|
|
|
|
|
the server's Postgres, there are no asymmetric merge results to reconcile.
|
2026-07-09 01:16:49 -03:00
|
|
|
|
Migrations are generated symmetrically for both ends. The engine respects the
|
|
|
|
|
|
three mathematical properties a CRDT must hold to be consistent: idempotence,
|
|
|
|
|
|
associativity, and commutativity.
|
2026-08-25 11:25:48 -03:00
|
|
|
|
- **Metadata model.** Four metadata tables track change history and relational
|
|
|
|
|
|
projection state: `crdt_data_rows` (insert + visibility), `crdt_data_fields`
|
|
|
|
|
|
(update), `crdt_data_foreign_key` (attempted and materialized foreign-key
|
|
|
|
|
|
values), and `crdt_data_tombstone` (delete/restore). Hybrid logical clocks
|
|
|
|
|
|
order field-level values and break ties. A monotone causal-length tombstone
|
|
|
|
|
|
governs row existence, so add/delete/restore advance generations forward and
|
|
|
|
|
|
can never oscillate.
|
2026-07-09 01:16:49 -03:00
|
|
|
|
- **Visibility as a pure function.** Replicas never coordinate. Each merge
|
|
|
|
|
|
applies remote facts monotonically, then derives the visible database —
|
|
|
|
|
|
tombstone state, unique-conflict winners, and foreign-key repair — as a
|
|
|
|
|
|
deterministic projection computed to a fixed point.
|
2026-09-14 13:39:07 -03:00
|
|
|
|
- **Ownership & spaces.** A *space* is the unit of replication and ownership; a
|
2026-07-09 01:16:49 -03:00
|
|
|
|
*user* is an authentication identity. Each row is globally identified by
|
2026-09-14 13:39:07 -03:00
|
|
|
|
`(table, rowId)` and owned by exactly one space, so merging is always one chain
|
|
|
|
|
|
per row. Spaces can be purged for GDPR / account erasure via a
|
2026-07-09 01:16:49 -03:00
|
|
|
|
database-enforced cascade.
|
|
|
|
|
|
- **Sync protocol.** A session handshakes once to validate that both ends share
|
2026-09-14 13:39:07 -03:00
|
|
|
|
the same table schema. Per cycle, the server sends the authoritative space set,
|
|
|
|
|
|
and every space the user can access is synced (transferred all at once in
|
2026-07-09 01:16:49 -03:00
|
|
|
|
chunks, merged one at a time). The same stream-based implementation powers both
|
|
|
|
|
|
one-shot and continuous sync.
|
|
|
|
|
|
|
|
|
|
|
|
For a deeper dive, see the design docs in [`docs/`](docs):
|
|
|
|
|
|
[row ownership](docs/row-ownership.md),
|
|
|
|
|
|
[foreign-key invariants](docs/foreign-key-invariants.md),
|
|
|
|
|
|
[unique-constraint invariants](docs/unique-constraint-invariants.md), and
|
|
|
|
|
|
[sync/non-sync relations](docs/sync-non-sync-relations.md).
|
|
|
|
|
|
|
|
|
|
|
|
## Performance
|
|
|
|
|
|
|
|
|
|
|
|
Since all operations are tracked, a CRDT layer can weigh heavily on storage and
|
|
|
|
|
|
speed. This package was built to keep that penalty minimal. The benchmark below
|
|
|
|
|
|
uses 1,000 rows to measure the impact against a baseline of the same operations
|
|
|
|
|
|
without the CRDT layer, both on SQLite (a fresh report is posted as a comment
|
|
|
|
|
|
on every pull request):
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
📊 SELECT performance impact:
|
|
|
|
|
|
Time: 12.62 ms --> 12.67 ms (48.50 μs)
|
|
|
|
|
|
CRDT overhead: 0.38% slower
|
|
|
|
|
|
|
|
|
|
|
|
📊 INSERT performance impact:
|
|
|
|
|
|
Delay per insert: 231.04 μs
|
|
|
|
|
|
|
|
|
|
|
|
📊 UPDATE performance impact:
|
|
|
|
|
|
Delay per update: 1.88 ms
|
|
|
|
|
|
|
|
|
|
|
|
📊 DELETE performance impact:
|
|
|
|
|
|
Delay per delete: 408.75 μs
|
|
|
|
|
|
|
|
|
|
|
|
💽 Storage impact (base footprint removed):
|
|
|
|
|
|
Storage overhead range: 62.07% --> 300.00%
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Despite the high *percentage* slowdown on insert/update/delete, the absolute
|
|
|
|
|
|
impact is mostly in the microsecond range — barely noticeable in practice, and
|
|
|
|
|
|
far below the network delay those operations would incur through an online
|
|
|
|
|
|
endpoint. Storage tops out at ~3× the base size, an acceptable trade for
|
|
|
|
|
|
offline-first speed and availability.
|
|
|
|
|
|
|
|
|
|
|
|
## Example
|
|
|
|
|
|
|
|
|
|
|
|
The bundled desktop demo shows the model directly: two independent replicas of
|
|
|
|
|
|
one account side by side, plus the server's merged truth. Drive each replica's
|
|
|
|
|
|
sync independently and watch them converge. It visualizes hidden / tombstoned
|
|
|
|
|
|
rows and every foreign-key `onDelete` action, with guided scenarios for the
|
|
|
|
|
|
restrict / cascade / set-null / set-default / unique-conflict cases.
|
|
|
|
|
|
|
|
|
|
|
|
Run it by starting the test server — the app launches alongside it:
|
|
|
|
|
|
|
|
|
|
|
|
```sh
|
|
|
|
|
|
cd test/serverpod_offline_sync_test_server
|
|
|
|
|
|
serverpod start
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
See [`example/offline_sync_demo`](example/offline_sync_demo) for details; the
|
|
|
|
|
|
entire integration lives in ~60 lines in
|
|
|
|
|
|
[`lib/src/offline_replica.dart`](example/offline_sync_demo/lib/src/offline_replica.dart).
|
|
|
|
|
|
|
|
|
|
|
|
## Additional information
|
2026-01-13 00:35:53 -03:00
|
|
|
|
|
2026-07-09 01:16:49 -03:00
|
|
|
|
The package is tested extensively following Serverpod's testing principles. The
|
|
|
|
|
|
vast majority of the tests live in the `serverpod_offline_sync_test_server`
|
|
|
|
|
|
package, whose server is shared with the `example/offline_sync_demo` project.
|
2026-01-13 00:35:53 -03:00
|
|
|
|
|
|
|
|
|
|
## License
|
|
|
|
|
|
|
2026-07-09 01:16:49 -03:00
|
|
|
|
This package is distributed under the BSD 3-Clause License. See the LICENSE
|
|
|
|
|
|
file for more information.
|