How It Works

Understand Tether's offline sync architecture for Laravel: mutation-log push, state-snapshot pull, ULID identity, idempotency, and conflict handling.

Tether is built on an offline sync architecture for Laravel: mutation-log push, state-snapshot pull, client-generated ULID identity, idempotent retries, and server-side conflict handling. Client writes are captured locally first, pushed to the server as immutable mutations, and reconciled by pulling authoritative server snapshots back to the client.


Data flow

[Client local DB]
      -> every write intercepted by Syncable trait
[Client mutation log]
      -> push (client-initiated, batched)
[Server POST /tether/push]
      -> PushSyncProcessor -> ConflictDetector -> MutationApplicator -> ServerMutationLog
[Server Eloquent models]  (authoritative state)
      -> StateSnapshotGenerator (updated_at / deleted_at cursor)
[Server POST /tether/pull response]
      -> SnapshotApplicator (forceFill + save by tether_id, without events)
[Client local DB]  (reconciled)

Push - sending mutations to the server

When the client calls TetherClient::push():

  1. Failed mutations rejected with reason error are retried while below max_retry_attempts.
  2. Pending mutations are loaded and chunked into batches of push_batch_size.
  3. Each batch is sent as JSON to the configured push URL.
  4. The server processes each mutation:
    • Checks duplicate mutation IDs for idempotent retry handling.
    • Runs the registered server push mutation mapper, if any.
    • Detects conflicts against the current server record.
    • Applies the mutation via MutationApplicator.
    • Records the applied mutation in tether_server_mutations.
  5. The server responds with applied, rejected, and conflicts arrays.
  6. Applied mutations become synced; rejected mutations become failed; conflicts become conflict and the current server state is applied locally.

Pull - receiving server state

When the client calls TetherClient::pull():

  1. The stored last_sync_cursor is read from tether_sync_state.
  2. A pull request is sent with the integer microsecond cursor, or null for a full first pull.
  3. The server queries registered models using their scopes and filters rows by updated_at / deleted_at.
  4. The response contains Snapshot entries, new_sync_cursor, and has_more.
  5. Each snapshot is applied locally by finding the model by tether_id, then using forceFill() and save() inside Model::withoutEvents().
  6. If has_more is true, the client repeats the pull using the updated cursor until all pages are consumed.

Pull is state-based, not mutation replay. This is why server-created records can reach clients even though they were never present in a client mutation log.


ULID identity

Every syncable record is assigned a client-generated ULID in its tether_id column at creation time. This is separate from the model's primary key unless you explicitly override the sync key.

  • Records created offline sync later without ID remapping.
  • The server matches incoming mutations by tether_id, not by local auto-increment keys.
  • Client and server tables can keep their own primary-key strategies.

Sync cursor

The sync cursor is an integer microsecond timestamp derived from syncable models' updated_at / deleted_at values. The client stores the last seen cursor and sends it with every pull request.

This means:

  • The first pull (last_sync_cursor = null) fetches everything in scope.
  • Subsequent pulls fetch only rows changed after the last cursor.
  • Cursors stay integers in requests, responses, logs, and client state.
  • Standard Laravel timestamps are required on syncable client and server models.

Conflict detection

A conflict occurs when a client pushes a non-create mutation for a server record that has been updated more recently than the incoming mutation. The default detector compares the mutation timestamp with the server record's updated_at.

Default resolution: server wins. The server returns the current server state; the client applies it locally and marks the mutation as conflict.

Custom resolvers can be registered per model - see Conflict Resolution.


Idempotency

All push operations are safe to retry. The server deduplicates by mutation_id:

  • Within a batch - duplicate mutation IDs in the same request are handled before they can be applied twice.
  • Across requests - previously applied mutation IDs are recognised and returned as applied without re-applying.

Enable strict_duplicates mode to return duplicates as rejected with reason duplicate instead of applied.


Concurrency protection

The client uses Laravel's atomic cache lock (tether_sync_lock) to ensure only one sync cycle runs at a time. If a cycle is already running, sync(), push(), or pull() returns a SyncResult with skipped = true.