Offline Sync - Usage Guide¶
This guide covers enabling the sync action, the request/response contract, how the three-way merge resolves conflicts, restricting operations, reusing the action in custom scoped routes, and the two write-protection primitives sync relies on (the relation guard and read_only fields).
Enabling sync on a model¶
Sync is opt-in. Add it to the model's api_route_model decorator:
from fastedgy.orm import fields
from fastedgy.models.base import BaseModel
from fastedgy.api_route_model import api_route_model
@api_route_model(sync=True)
class Product(BaseModel):
name = fields.CharField(max_length=200)
description = fields.TextField(null=True)
price = fields.DecimalField(max_digits=10, decimal_places=2)
This adds one endpoint next to the standard CRUD routes:
Because sync only ever replays update and delete operations, it inherits the model's patch and delete permissions automatically — see Allowed operations below.
Request and response¶
The endpoint accepts a batch of operations (max 500 per request) and returns one result per operation, in order.
Request body¶
{
"operations": [
{
"op": "update",
"id": 42,
"payload": { "name": "New name", "price": "19.90" },
"base": { "name": "Old name", "price": "24.90", "quantity": 3 },
"created_at": "2026-07-22T09:15:00Z"
},
{
"op": "delete",
"id": 43,
"created_at": "2026-07-22T09:16:00Z"
}
]
}
| Field | Type | Notes |
|---|---|---|
op | "update" | "delete" | The buffered operation kind |
id | int | Server id of the target record |
payload | object | null | Buffered field changes (update only); unknown/excluded fields are dropped |
base | object | null | The record as the client last knew it — the merge baseline |
created_at | datetime | null | Client write time — the last-writer-wins tie-breaker |
Response body¶
{
"results": [
{
"id": 42,
"status": "merged",
"record": { "id": 42, "name": "New name", "price": "24.90", "quantity": 5 },
"applied_fields": ["name"],
"discarded_fields": ["price"]
},
{
"id": 43,
"status": "applied"
}
]
}
| Field | Type | Notes |
|---|---|---|
id | int | The operation's target id (echoed) |
status | enum | applied | merged | conflict | deleted | rejected — see the statuses table |
record | object | null | The resulting (or current, on conflict) server record |
applied_fields | string[] | null | Fields actually written (update) |
discarded_fields | string[] | null | Fields dropped because the server was fresher |
detail | string | null | Rejection reason (rejected only) |
How the three-way merge works¶
For an update, sync compares three versions of each field: the client's base (what it started from), the server's current value, and the buffered payload value.
- Reduce to writable fields. The payload is first reduced to the model's PATCH schema surface — unknown, excluded,
read_only, and server-managed fields are dropped, so the merge andapplied_fieldsonly ever cover real writes. - Detect server changes. A field is server-changed when
currentdiffers frombase. Server-managed fields (primary_key,read_only,auto_now,auto_now_add) and to-many relations are excluded from this diff; to-one relations are compared by id only (the base and the server selection may carry different shapes for the same relation). - Classify each payload field.
- Disjoint (server did not touch it) → always applied, regardless of clocks. Both sides' writes survive.
- Conflicting (both touched it) → resolved below.
- Resolve conflicts.
- Fields declared with
merge_blocks=Trueare merged line-by-line and drop out of the conflict set. - Remaining conflicts follow last-writer-wins: if the server is newer (
updated_at> the operationcreated_at), those fields are discarded; otherwise the client value wins.
- Fields declared with
The resulting status:
- all payload applied →
applied - some applied, some discarded →
merged(withapplied_fields/discarded_fields) - everything discarded →
conflict(the current serverrecordis returned)
Without a base snapshot
If an operation carries no base, sync cannot tell which fields the client actually diverged on, so it treats the whole payload as conflicting and resolves it as a single last-writer-wins unit. Always send a base to get field-level merging.
Delete resolution¶
A delete operation:
- succeeds (
applied) when the record is unchanged since the client's snapshot, or already gone (idempotent); - loses (
conflict) when the server has a fresher write — the delete is refused and the currentrecordis returned, so the client can decide whether to re-delete.
Allowed operations¶
By default the sync operations a model accepts mirror its route configuration:
updaterequires thepatchaction to be enabled,deleterequires thedeleteaction to be enabled.
So a model that disables deletion rejects buffered deletes with a 403:
@api_route_model(sync=True, delete=False) # accepts "update" only
class Tag(BaseModel):
name = fields.CharField(max_length=100)
Tighten the policy explicitly with the ops option, independently of the CRUD actions:
@api_route_model(sync={"ops": ["update"]}) # never replay deletes, even if delete is enabled
class Product(BaseModel):
...
An operation outside the allowed set is refused for the whole batch with a 403.
Reusing sync in custom routes¶
Like the other generated actions, sync exposes a reusable function — sync_items_action — so a custom route can apply a batch against a pre-scoped queryset. This is the multi-tenant pattern: restrict the reachable records to the caller's scope and let sync merge within it.
from fastapi import APIRouter, Depends
from fastedgy.http import Request
from fastedgy.api_route_model.actions.sync_action import (
SyncApplyInput,
SyncApplyResult,
sync_items_action,
)
router = APIRouter()
@router.post("/{workspace}/products/sync", response_model=SyncApplyResult)
async def sync_products(
request: Request,
data: SyncApplyInput,
workspace: Workspace = Depends(get_current_workspace),
) -> SyncApplyResult:
# Only records inside the caller's workspace are reachable: an operation
# targeting an id outside the scope resolves as "deleted"/"applied", never
# leaking or mutating another tenant's data.
query = Product.query.filter(workspace=workspace)
return await sync_items_action(
request,
Product,
data.operations,
query=query,
ops=("update",), # optional per-route policy override
)
sync_items_action(request, model_cls, operations, query=None, ops=None):
query— aQuerySet/manager scoping the records the batch may reach (defaults to the model's global query). Every lookup, patch and delete goes through it.ops— an explicit allow-list overriding the model's default (see Allowed operations).
The whole call runs in one transaction, with a savepoint per operation.
Write-protection primitives¶
Sync applies client-supplied payloads, so the same guards that protect the regular write routes protect it too. Two primitives are worth knowing when a model exposes sync.
The relation guard¶
A relation input in a payload cannot do to a related model what that model's own API forbids. When the related model is registered in the api_route_model registry, its action configuration governs the record-mutating operations reachable through the relation:
- a nested create requires the related model's
createaction, - a nested update requires its
patchaction, - a nested delete requires its
deleteaction.
Link-level operations (link/unlink/set/clear) belong to the owning side. On a to-many reverse relation (O2M / generic) they re-point the foreign key stored on the target records, so they count as an update of the target and are gated by its patch action; many-to-many links only touch the through table and stay free. Unregistered related models keep the historical behavior (child records managed through their parent).
A forbidden relation operation raises a 403 — on the regular routes and inside a sync batch alike. This guard is automatic; there is nothing to enable.
Read-only fields¶
Fields that must never be set from an API input (a platform role, an immutable owner) are declared read_only=True. They are excluded from the generated input schemas, so no POST/PATCH/sync payload can write them — sync's field reduction drops them before the merge.
To set such a field from server code, use BaseModel.apply_readonly_values(...), the explicit code-side escape hatch. See the Models reference for details.
Block text merge¶
Long-text fields lose data under plain last-writer-wins: two people editing different paragraphs would clobber each other. Declare such a field with merge_blocks=True and sync resolves its conflicts with a three-way, line-based merge (diff3) instead:
@api_route_model(sync=True)
class Product(BaseModel):
name = fields.CharField(max_length=200)
description = fields.TextField(null=True, merge_blocks=True)
Both sides edit from the same base:
- hunks touching disjoint line ranges all survive (both edits kept);
- overlapping hunks fall back to last-writer-wins on that hunk (the fresher side wins).
A field merged this way drops out of the conflict set, so it is reported in applied_fields rather than discarded_fields. Only string values with a string base and current are merged; anything else falls back to standard field-level resolution.
The merge is exposed directly as fastedgy.text_merge.merge_text_blocks(base, server, client, prefer_client) if you need it outside sync.