Strata Sync
Change an app that already syncs, without breaking convergence.
- IS: adding or changing models, fields, queries, mutations, sync groups, undo behaviour and collaborative fields in a running Strata Sync app, and diagnosing sync that has stopped working.
- IS NOT: creating a new project (use
scaffold-stratasync), or editing the engine itself.
Strata Sync is a clean-room implementation of the architecture Linear published; it contains no Linear code. The reverse-engineering notes are the reference for the concepts named below.
Reference files
| File |
Read when |
references/models-and-fields.md |
Adding or changing a model or field; every layer that must agree |
references/reading-and-writing.md |
Querying with hooks, mutating, optimistic updates, undo and redo |
references/groups-and-permissions.md |
Scoping rows to a tenant, team or user; joining and leaving groups |
references/debugging.md |
Data not arriving, outbox stuck, client re-bootstrapping in a loop |
The one rule that causes most bugs
A field exists only where it is registered. The sync layer filters explicitly at every hop, so a field added in one place and not the others is dropped silently, with no error anywhere. Adding a field means changing all of these in the same commit:
- The Drizzle column on the server (plus a migration).
- The model's
fields and its updateFields set in the server model config. A field absent from updateFields is silently discarded on every update.
- The
@Property() on the client model class.
- Any GraphQL projection the app maintains for its own reads.
If a value writes locally and vanishes after the server round-trip, it is almost always step 2.
Adding a field: the checklist
- [ ] Drizzle column added, migration generated and applied
- [ ] Server model config: field listed in `fields` and, if editable, in `updateFields`
- [ ] Client model: `@Property() declare name: Type;`
- [ ] Type coercion checked: date-only vs instant epochs are different encodings
- [ ] Schema hash changes, so clients re-bootstrap once. Confirm that is acceptable
- [ ] Round-trip tested: write it, reload the page, confirm it survived
The last line is the one that catches a missing updateFields entry, because everything looks correct until the reload.
Concepts, in Linear's vocabulary
| Term |
What it means here |
lastSyncId |
The server's monotonic counter, a string on the wire so it can pass Number.MAX_SAFE_INTEGER. The client stores its own and asks for everything after it |
| Bootstrap |
The initial load. auto picks full or local; full ignores local data; local reads the replica only |
| Delta packet |
A batch of sync actions (I, U, D, A, V, plus C for coverage and G for a group change) with a lastSyncId watermark |
| Outbox |
The durable transaction queue. queued → sent → awaitingSync → completed, keyed by clientId + clientTxId for idempotent retry |
| Partial index |
An index key and value naming a subset of a model, with coverage recorded so the same subset is not fetched twice |
| Sync group |
The permission boundary. A client only receives deltas for groups it subscribes to |
| Rebase |
Re-applying pending local writes on top of an incoming delta, field by field |
Anti-patterns
- Never read synced models with
fetch() or useEffect + useState. Use the hooks; the local replica is already there and a manual fetch will not see deltas.
- Never mutate a model object directly and expect it to sync. Go through
client.update() or the instance .save(), which records the transaction.
- Never add a field to the Drizzle schema alone and assume it syncs. See the checklist.
- Never treat
lastSyncId as a number. It is a string, and parsing it to a Number loses precision once the log is large.
- Never skip
observer() on a component reading MobX state. It will render once and never update.
- Never clear IndexedDB to "fix" sync without checking the outbox first. Unsent writes live there and clearing discards them.
1---2name: stratasync3description: Strata Sync4---56# Strata Sync78Change an app that already syncs, without breaking convergence.910- **IS:** adding or changing models, fields, queries, mutations, sync groups, undo behaviour and collaborative fields in a running Strata Sync app, and diagnosing sync that has stopped working.11- **IS NOT:** creating a new project (use `scaffold-stratasync`), or editing the engine itself.1213Strata Sync is a clean-room implementation of the architecture Linear published; it contains no Linear code. The [reverse-engineering notes](https://github.com/wzhudev/reverse-linear-sync-engine) are the reference for the concepts named below.1415## Reference files1617| File | Read when |18| -------------------------------------- | ------------------------------------------------------------------ |19| `references/models-and-fields.md` | Adding or changing a model or field; every layer that must agree |20| `references/reading-and-writing.md` | Querying with hooks, mutating, optimistic updates, undo and redo |21| `references/groups-and-permissions.md` | Scoping rows to a tenant, team or user; joining and leaving groups |22| `references/debugging.md` | Data not arriving, outbox stuck, client re-bootstrapping in a loop |2324## The one rule that causes most bugs2526**A field exists only where it is registered.** The sync layer filters explicitly at every hop, so a field added in one place and not the others is dropped silently, with no error anywhere. Adding a field means changing all of these in the same commit:27281. The Drizzle column on the server (plus a migration).292. The model's `fields` and its `updateFields` set in the server model config. **A field absent from `updateFields` is silently discarded on every update.**303. The `@Property()` on the client model class.314. Any GraphQL projection the app maintains for its own reads.3233If a value writes locally and vanishes after the server round-trip, it is almost always step 2.3435## Adding a field: the checklist3637```text38- [ ] Drizzle column added, migration generated and applied39- [ ] Server model config: field listed in `fields` and, if editable, in `updateFields`40- [ ] Client model: `@Property() declare name: Type;`41- [ ] Type coercion checked: date-only vs instant epochs are different encodings42- [ ] Schema hash changes, so clients re-bootstrap once. Confirm that is acceptable43- [ ] Round-trip tested: write it, reload the page, confirm it survived44```4546The last line is the one that catches a missing `updateFields` entry, because everything looks correct until the reload.4748## Concepts, in Linear's vocabulary4950| Term | What it means here |51| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |52| `lastSyncId` | The server's monotonic counter, a **string** on the wire so it can pass `Number.MAX_SAFE_INTEGER`. The client stores its own and asks for everything after it |53| Bootstrap | The initial load. `auto` picks full or local; `full` ignores local data; `local` reads the replica only |54| Delta packet | A batch of sync actions (`I`, `U`, `D`, `A`, `V`, plus `C` for coverage and `G` for a group change) with a `lastSyncId` watermark |55| Outbox | The durable transaction queue. `queued → sent → awaitingSync → completed`, keyed by `clientId + clientTxId` for idempotent retry |56| Partial index | An index key and value naming a subset of a model, with coverage recorded so the same subset is not fetched twice |57| Sync group | The permission boundary. A client only receives deltas for groups it subscribes to |58| Rebase | Re-applying pending local writes on top of an incoming delta, field by field |5960## Anti-patterns6162- **Never** read synced models with `fetch()` or `useEffect` + `useState`. Use the hooks; the local replica is already there and a manual fetch will not see deltas.63- **Never** mutate a model object directly and expect it to sync. Go through `client.update()` or the instance `.save()`, which records the transaction.64- **Never** add a field to the Drizzle schema alone and assume it syncs. See the checklist.65- **Never** treat `lastSyncId` as a number. It is a string, and parsing it to a `Number` loses precision once the log is large.66- **Never** skip `observer()` on a component reading MobX state. It will render once and never update.67- **Never** clear IndexedDB to "fix" sync without checking the outbox first. Unsent writes live there and clearing discards them.