Halkyon Learn is offline-first: a learner studies and edits on several devices,
offline if need be, and the devices converge. Private and shared studysets are
end-to-end encrypted later, so the server must be able to sync data it cannot
read. Public studysets are a separate tier (plaintext, published, federated) and
are **not** part of this decision.
`serverpod_offline_sync` (0.0.8, publisher serverpod.dev, BSD-3-Clause) is a
delta-CRDT engine for Serverpod: the same code runs over SQLite on each device
and PostgreSQL on the server, models opt in with `database: sync`, conflicts are
resolved per field by hybrid logical clocks (last writer wins), and data is
isolated in **spaces** (a personal one per person, shared ones created on the
server).
The alternative was plain endpoints plus a local layer of our own: an ordered
change log with a monotonic sequence number and tombstones. It is not a
timestamp cursor (`updated_at > lastPulledAt`), because PostgreSQL's `now()` is
the transaction's *start* time and a row committed late would be missed for
good.
## Decision
Use `serverpod_offline_sync` for **private and shared** data. Every synced model
is `database: sync`.
## What the spike established
| Question | Result |
|---|---|
| Runs on our Serverpod? | Yes, on 4.0.2 with `dependency_overrides` for `serverpod`, `serverpod_client`, `serverpod_database`, `serverpod_serialization`; the package pins 4.0.0 exactly, but `serverpod_database` 4.0.0 and 4.0.2 are identical. A real round trip between two devices works. |
| References to server-only (public) content | No foreign key possible (rejected at generate time); a plain UUID column syncs unchanged. |
| Blobs (the content envelope) | Byte-identical up to 5 MB (locally 5 MB: 2.3 s up, 3.8 s down). The 512 KB request limit doesn't apply to sync streams. |
| Concurrent edits | The same field: the later edit wins, never corrupted. Different fields: both survive, **only with column-scoped updates**. |
| Change signal for the UI | `onMergeSuccess` fires once per merge, never when nothing is new. The engine owns the local SQLite database (no Drift, no reactive queries). |
| `bool` fields | Survive update round trips (the bug fixed in 0.0.7). |
| Persons and spaces | The personal space is keyed on `AuthUser.id`. Rows forged into another person's space never reach that person. Access to another space is granted, rows never move. Deleting a space removes every row and its change history. |
| Authentication | Syncs with server-side sessions; without a session, 401. |
## Rules that follow
1.**Updates name their columns** (`updateRow(..., columns: (t) => [...])`). A
full-row update rewrites every field, and the later full-row edit then wins
every field. Repositories are responsible for this.
2.**Content is append-only.** Card content is stored as revision rows