Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ jobs:
dotnet pack src/Rask.SQLite.Litestream/Rask.SQLite.Litestream.csproj -c Release -o ./artifacts
dotnet pack src/Rask.SQLite.Snapshots/Rask.SQLite.Snapshots.csproj -c Release -o ./artifacts
dotnet pack src/Rask.SQLite.Browser/Rask.SQLite.Browser.csproj -c Release -o ./artifacts
dotnet pack src/Rask.SQLite.Crdt/Rask.SQLite.Crdt.csproj -c Release -o ./artifacts
dotnet pack src/Rask.Postgres/Rask.Postgres.csproj -c Release -o ./artifacts
dotnet pack src/Rask.SqlServer/Rask.SqlServer.csproj -c Release -o ./artifacts
dotnet pack src/Rask.Data/Rask.Data.csproj -c Release -o ./artifacts
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ jobs:
- name: Pack Rask.SQLite.Browser
run: dotnet pack src/Rask.SQLite.Browser/Rask.SQLite.Browser.csproj -c Release --no-build -o ./artifacts

- name: Pack Rask.SQLite.Crdt
run: dotnet pack src/Rask.SQLite.Crdt/Rask.SQLite.Crdt.csproj -c Release --no-build -o ./artifacts

# The door out of one box — the two non-SQLite providers. They ship on the same cadence as the
# SQLite family above because `rask new --database postgres|sqlserver` scaffolds a reference to them.
- name: Pack Rask.Postgres
Expand Down
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,33 @@ them until tagged releases begin.
branches ([#661](https://github.com/pal-tamas/rask/issues/661)).

### Added
- **`Rask.SQLite.Crdt` — several replicas of one database, written independently and merged without
conflicts, through ordinary EF Core.** Wires the cr-sqlite extension into a `DbContext` so application
code stays LINQ, change tracking and `SaveChanges`, and merging happens per **column** rather than per
row: two devices editing different fields of the same record both keep their work, and last-writer-wins
applies only where two devices wrote the same field. `CrdtChangeFeed` exposes the change log with no
transport attached — `ReadChangesAsync` from a watermark, `ApplyChangesAsync` back — so the same log
works over a bucket, a socket, or nothing at all; pair it with `Rask.Sync.Client` for the bucket case.
Applying a change twice is a no-op, which is what makes re-sending safe after an upload whose outcome
is unknown. Two properties a transport has to build around, both verified against the real extension:
a replica's feed carries **every change it ever accepted**, still stamped with the originating
`site_id`, so `ReadLocalChangesAsync()` is what to publish or every device re-uploads every other
device's history; and a `db_version` belongs to the database it was read from — applying a peer's
change stamps it with *this* replica's next version — so a version orders your own publishing but can
never mean "everything peer X has after N". A batch applies in **one transaction**, so a peer's work
lands atomically and costs the receiver one version rather than one per column.
The package exists for the three requirements that otherwise fail *quietly*: the extension is per
connection rather than per process, so loading once at startup works until the pool recycles and then
silently stops (it is now loaded on every open and finalized before every close); cr-sqlite refuses a
`NOT NULL` column without a default, which is the exact shape EF emits for every required property, so
`ApplyCrdtConventions()` supplies them; and loading the extension seeds bookkeeping tables that make
`EnsureCreated` treat the database as already provisioned, so the schema must be created on a context
*without* the extension — otherwise nothing is created at all and the first symptom is the promotion
complaining about a missing primary key. Configuring a non-SQLite provider is reported rather than
skipped, because silently not replicating surfaces later as data loss. The native binary is supplied by
the app via `ExtensionPath`, since cr-sqlite ships one per platform. Documented in
[docs/sqlite-crdt.md](docs/sqlite-crdt.md); the merge behaviour is covered against the real extension
(`RASK_CRSQLITE_PATH`), and everything reachable without it always runs.
- **A waiting tab now finds out when the database becomes free.** `BrowserSqliteOwnership.Available`
completes in a non-owner tab once the owning tab closes, so an app can turn "close the other tab" into
"your data is ready — reload" instead of leaving the user to guess when the condition was met.
Expand Down
1 change: 1 addition & 0 deletions NUGET.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ dotnet add package Rask.SQLite.EntityFrameworkCore # the EF Core provider glu
dotnet add package Rask.SQLite.Litestream # managed continuous replication
dotnet add package Rask.SQLite.Snapshots # scheduled Online-Backup-API copies
dotnet add package Rask.SQLite.Browser # a persistent SQLite database inside a WASM app
dotnet add package Rask.SQLite.Crdt # many replicas, merged per column, through plain EF Core
dotnet add package Rask.Postgres # or Postgres
dotnet add package Rask.SqlServer # or SQL Server
```
Expand Down
2 changes: 2 additions & 0 deletions Rask.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
<Project Path="src/Rask.SQLite.Litestream/Rask.SQLite.Litestream.csproj"/>
<Project Path="src/Rask.SQLite.Snapshots/Rask.SQLite.Snapshots.csproj"/>
<Project Path="src/Rask.SQLite.Browser/Rask.SQLite.Browser.csproj"/>
<Project Path="src/Rask.SQLite.Crdt/Rask.SQLite.Crdt.csproj"/>
<Project Path="src/Rask.Validation.DataAnnotations/Rask.Validation.DataAnnotations.csproj"/>
<Project Path="src/Rask.Validation.FluentValidation/Rask.Validation.FluentValidation.csproj"/>
<Project Path="src/Rask.Testing/Rask.Testing.csproj"/>
Expand Down Expand Up @@ -103,6 +104,7 @@
<Project Path="tests/Rask.SQLite.Litestream.Tests/Rask.SQLite.Litestream.Tests.csproj"/>
<Project Path="tests/Rask.SQLite.Snapshots.Tests/Rask.SQLite.Snapshots.Tests.csproj"/>
<Project Path="tests/Rask.SQLite.Browser.Tests/Rask.SQLite.Browser.Tests.csproj"/>
<Project Path="tests/Rask.SQLite.Crdt.Tests/Rask.SQLite.Crdt.Tests.csproj"/>
<Project Path="tests/Rask.TestSupport/Rask.TestSupport.csproj"/>
<Project Path="tests/Rask.Validation.DataAnnotations.Tests/Rask.Validation.DataAnnotations.Tests.csproj"/>
<Project Path="tests/Rask.Validation.FluentValidation.Tests/Rask.Validation.FluentValidation.Tests.csproj"/>
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ in the [Tutorial](tutorial/00-overview.md); the reference for each is here.
| [Data access (EF Core)](data-access.md) | EF Core + SQLite in a Server app: `IDbContextFactory`, loading in the lifecycle, vertical slices, a DDD aggregate + value objects, and the SQLite decimal gotcha. |
| [Rask.Data](data.md) | The `Entity<TId>` base + EF interceptors: audit stamps, transparent soft delete, optimistic concurrency, and domain events — via `AddRaskData()` + `ApplyRaskConventions()`. |
| [SQLite production pragmas](sqlite.md) | Production SQLite via `UseRaskSqlite` / `AddRaskSqlite` (standalone `Rask.SQLite`): WAL, `foreign_keys`, `busy_timeout` & friends applied on every connection open, plus Litestream backup. |
| [Multi-writer SQLite (CRDT)](sqlite-crdt.md) | Several replicas of one database written independently and merged without conflicts via `UseRaskCrdt(...)` + `ApplyCrdtConventions()` (standalone `Rask.SQLite.Crdt`) — cr-sqlite behind ordinary EF Core, merging per column rather than per row, with the change feed exposed as a transport-free log. |
| [Choosing a database](databases.md) | SQLite (the default) vs PostgreSQL via `rask new --database`: what `UseRaskPostgres` configures, what the file-based batteries leave behind, how deploy changes, and why multi-instance isn't safe yet. |
| [CQRS](cqrs.md) | Source-generated, trim-safe queries / commands / notifications and pipeline behaviors via `AddRaskCqrs()` + `IDispatcher` (standalone `Rask.Cqrs`). |
| [Background jobs](jobs.md) | Durable enqueued / delayed / recurring work on the app's own database via `AddRaskJobs<Ctx>()` + `IJobQueue` (standalone `Rask.Jobs`) — at-least-once, with backoff. |
Expand Down
164 changes: 164 additions & 0 deletions docs/sqlite-crdt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# Multi-writer SQLite (`Rask.SQLite.Crdt`)

> **In practice:** several replicas of one database, written independently and merged without
> conflicts — while the application code stays ordinary EF Core.

```bash
dotnet add package Rask.SQLite.Crdt
```

```csharp
options.UseSqlite($"Data Source={file};Pooling=False")
.UseRaskCrdt(o => o.ExtensionPath = crsqlitePath);

protected override void OnModelCreating(ModelBuilder b) => b.ApplyCrdtConventions();

await context.PromoteToCrrsAsync(); // once the schema exists — see "Creating the schema" below

var feed = new CrdtChangeFeed(context);
var changes = await feed.ReadChangesAsync(sinceDbVersion: watermark);
await feed.ApplyChangesAsync(theirChanges);
```

This wraps [cr-sqlite](https://github.com/vlcn-io/cr-sqlite), a SQLite extension that turns tables into
*conflict-free replicated relations*. Rask's part is the EF Core integration: the extension has several
requirements EF violates by default, and each one fails in a way that points somewhere else.

## Merging is per column, not per row

The unit of replication is **one column of one row**, stamped with which replica set it and when:

| | Alice | Bob | after merging |
|---|---|---|---|
| `Title` | `"final"` | *untouched* | `"final"` |
| `Priority` | *untouched* | `9` | `9` |

Two devices editing different fields of the same record both keep their work. Last-writer-wins applies
only when two devices write the **same** field — the case where something genuinely has to be chosen.
That is the whole reason to reach for a CRDT rather than a `LastModified` column.

Applying a change twice is a no-op. That is what makes it safe to re-send after an upload whose outcome
is unknown, and it is why a replica never has to track what its peers already hold.

## Transport is deliberately absent

`CrdtChangeFeed` hands you an ordered log and takes one back. Where those bytes travel is the app's
business — an object-storage bucket, a socket, a file on a USB stick — and keeping it that way is what
lets this work with no server at all. For the bucket case, pair it with
[`Rask.Sync.Client`](sync-client.md).

```csharp
var mine = await feed.ReadChangesAsync(sinceDbVersion: theirWatermark);
// ... send `mine` however you like, receive `theirs` ...
await feed.ApplyChangesAsync(theirs);
```

Reading from a watermark is what makes a sync cost what *changed* rather than what *exists*.
`GetDbVersionAsync()` is the high-water mark; `GetSiteIdAsync()` is this replica's identity.

### Publish your own work, not everyone's

A replica's feed carries **every change it has ever accepted**, not just the ones it made — still
stamped with the originating replica's `site_id`, which is what makes them separable. Publishing the
unfiltered feed would have every device re-uploading every other device's history, growing with the
number of peers rather than with what changed. `ReadLocalChangesAsync()` is the one to publish:

```csharp
var mine = await feed.ReadLocalChangesAsync(sinceDbVersion: lastPublished);
```

### A `db_version` belongs to the database it was read from

This one is easy to get wrong. Applying a peer's change stamps it with **this** replica's next version,
not the originator's — so the same change has a different `db_version` in every database holding it.
A version can order *your own* publishing, but it can never express "everything peer X has after N".
Remembering what you have already fetched from a peer is the transport's job, not the feed's.

Applying a batch is one transaction, so a peer's work lands atomically and costs the receiver a single
version rather than one per column.

## The three things that fail quietly without this package

### The extension is per connection, not per process

`Microsoft.Data.Sqlite` pools connections, and a reused handle is a fresh open as far as extensions are
concerned. Loading once at startup therefore works until the pool recycles and then silently stops.
`UseRaskCrdt` loads it on every open and calls `crsql_finalize()` before every close.

**Use `Pooling=False`.** cr-sqlite keeps per-connection state, and a handle returned to the pool
mid-state and handed to somebody else corrupts quietly rather than failing.

### Every required column needs a SQL default

cr-sqlite refuses a `NOT NULL` column that has no default, and the requirement is not arbitrary: a peer
still running an older schema has to be able to apply a change that says nothing about a column it has
never heard of, and a default is what lets it. EF emits exactly that shape for every required property,
so a perfectly ordinary model is rejected outright.

`ApplyCrdtConventions()` gives every non-key, non-nullable column a default. It sets a default
*expression* rather than a value on purpose: EF suppresses a default equal to the CLR default — it
cannot tell "unset" from "set to `false`" — so a `bool` column would otherwise come out bare, and only
that one column would fail, which reads like a cr-sqlite bug rather than an EF one.

Columns you have already given a default keep it. Nullable columns are left alone, because `NULL` is
already an applicable value.

### Creating the schema: order matters

Loading cr-sqlite seeds its own bookkeeping tables, and `EnsureCreated` treats a database that already
has tables as provisioned. Creating the schema through a context that loads the extension therefore
creates **nothing at all**, and the first sign of trouble is `PromoteToCrrsAsync` complaining that a
table has no primary key.

```csharp
// 1. Schema on a context WITHOUT the extension.
await using (var plain = new AppContext(plainOptions))
{
await plain.Database.EnsureCreatedAsync(); // or MigrateAsync()
}

// 2. Promote on a context WITH it.
await using var context = new AppContext(crdtOptions);
await context.PromoteToCrrsAsync();
```

`PromoteToCrrsAsync()` promotes every table in the model. Name a subset through
`RaskCrdtOptions.Tables` when only part of the database is shared.

## The native binary is yours to supply

`ExtensionPath` points at cr-sqlite's loadable extension — `crsqlite.dylib`, `.so` or `.dll`. It is not
bundled, because cr-sqlite ships a separate binary per platform and which one is right depends on where
the app runs rather than on which package it referenced. Download it from
[cr-sqlite's releases](https://github.com/vlcn-io/cr-sqlite/releases) and deploy it alongside the app.

Configuring a non-SQLite provider is reported rather than skipped: silently doing nothing would leave an
app that looks like it works and never replicates, which surfaces later as data loss.

## Scope and limits

- **Per-field last-writer-wins**, not concurrent-edit merging within a field. Two people typing into the
same text box at the same time still lose one of the two versions.
- **Every replica holds the whole database.** There is no partial replication and no per-row
authorization, so this targets one user across many devices, and small trusted teams.
- **Promotion is one-way in practice.** Treat `crsql_as_crr` as part of the schema, applied wherever
migrations are.
- Tables need a primary key that is not `rowid` alone — cr-sqlite identifies rows by their key across
replicas, and an autoincrementing integer means something different on each device. Prefer a `Guid`.

## Testing

The merge behaviour is covered by tests that run against the real extension and skip when it is absent:

```bash
RASK_CRSQLITE_PATH=/path/to/crsqlite.dylib dotnet test tests/Rask.SQLite.Crdt.Tests
```

Everything reachable without the native binary — the conventions, the options, the connection
lifecycle — is covered by tests that always run.

## See also

- [Syncing between devices](sync-client.md) — moving the change feed over a bucket with no server.
- [Offline-first merge](sync.md) — the pure-logic merge engine, for apps not backed by SQLite.
- [SQLite production pragmas](sqlite.md) — WAL and friends on every connection.
Loading