Skip to content

Offline Sync - Usage Guide

This guide covers enabling the sync action, the request/response contract, how the three-way merge resolves conflicts, restricting operations, declaring the client's replication regime, 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:

POST /api/products/sync

Because sync only ever replays update and delete operations, it inherits the model's patch and delete permissions automatically — see Allowed operations below.

Replication regime

sync=True announces a full mirror: the client pulls a paginated id/updated_at manifest of the whole model, then fetches the records that changed. That is the right trade for a reference table, and the wrong one for a table with a hundred thousand rows nobody wants on a phone.

Declare partial for those:

@api_route_model(sync={"mode": "partial"})
class Ticket(BaseModel):
    subject = fields.CharField(max_length=200)
Mode Client behaviour
full (default) Manifest + delta: every record is mirrored, and records missing server-side are pruned locally
partial Nothing is pre-downloaded. The mirror keeps whatever the reads returned, nothing is pruned, and writes still buffer in the outbox

Both modes are equally writable offline — the mode governs reads, not writes. The value reaches the client as synchronizable_mode in the model metadata; synchronizable remains a boolean, true for both modes.

The mode is a directive, not an enforcement: nothing server-side changes, and a client may ignore it.

An unknown mode is a typo worth catching, so it raises at metadata generation:

@api_route_model(sync={"mode": "partiel"})   # ValueError: Sync mode 'partiel' is not supported

To opt out of replication entirely, drop the action (sync absent, or sync=False) rather than passing mode="none" — a single spelling for a single intent.

Provisional values for server-generated fields

A record created offline is missing every value the server assigns: its id, its timestamps, and any business reference computed on save. The id and timestamps are internal, but a reference is usually on screen — a client showing an empty cell for it looks broken.

Declare what the client should display instead:

@api_route_model(sync={"mode": "partial"})
class Ticket(BaseModel):
    reference = fields.CharField(
        max_length=50,
        null=True,
        read_only=True,  # no input can write it
        local_placeholder="DRAFT-{seq}",
    )
    subject = fields.CharField(max_length=200)

The template surfaces as local_placeholder on the field metadata. {seq} is interpolated client-side from a local per-model counter, so the first two offline tickets read DRAFT-1 and DRAFT-2. It is a label for a pending record, not a guess at the server value: the buffered create never sends it (read_only fields are excluded from input schemas), and replaying the create replaces the local record with the server one, real reference included.

Pair it with read_only=True as above. Without it the field would be writable, and a client could persist its provisional label as the real value.

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.

  1. 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 and applied_fields only ever cover real writes.
  2. Detect server changes. A field is server-changed when current differs from base. 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).
  3. 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.
  4. Resolve conflicts.
    • Fields declared with merge_blocks=True are 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 operation created_at), those fields are discarded; otherwise the client value wins.

The resulting status:

  • all payload applied → applied
  • some applied, some discarded → merged (with applied_fields / discarded_fields)
  • everything discarded → conflict (the current server record is 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 current record is returned, so the client can decide whether to re-delete.

Allowed operations

By default the sync operations a model accepts mirror its route configuration:

  • update requires the patch action to be enabled,
  • delete requires the delete action 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 — a QuerySet/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 create action,
  • a nested update requires its patch action,
  • a nested delete requires its delete action.

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.

Back to Overview