π Replication Mode
Supastash determines incremental synchronization using a replication cursor, a dedicated timestamp column used to decide which rows are new since the last successful sync.
Every sync cycle answers one core question:
Which rows have arrived after this deviceβs last_sync_marker?
The column used as that marker depends on your selected replicationMode.
Choosing the correct replication mode directly impacts data consistency across multiple devices and long offline sessions.
π§ Two Replication Modes
configureSupastash({
replicationMode: "client-side", // or "server-side"
});
1οΈβ£ Client-Side Replication (Legacy)β
Uses: updated_at as the sync cursor.
How It Works
- Every device updates
updated_atlocally. - During sync, Supastash pulls rows where:
updated_at > last_synced_at.
Pros: Simple. No extra server setup required.
β οΈ The Problem (Important)β
Because updated_at is generated on the device:
- Devices may be offline for a long time.
- A device may reconnect later and push old updates.
Real Edge Case:
-
10:00 AM β Device A updates a row while offline.
βupdated_at = 10:00 -
10:05 AM β Device B updates the same table (offline).
βupdated_at = 10:05 -
10:10 AM β Device B reconnects and pushes its update to the server.
-
10:20 AM β Device C syncs, pulls Device Bβs update, and stores:
βlast_synced_at = 10:05for that table. -
Later β Device A reconnects and pushes its update (
updated_at = 10:00).
What Happens?β
Device C will never receive Device Aβs update, because:
10:00 < last_synced_at (10:05)
From Device Cβs perspective, the update appears older than its last sync checkpoint β so it is skipped.
This is the core weakness of client-side replication.
βFixing Itβ Is Not Clean: If you make the server update
updated_aton write, it no longer represents "when the user made the change," breaking the meaning of the column. This is why we need a different column.
2οΈβ£ Server-Side Replication (Recommended)β
Uses: arrived_at as the sync cursor.
How It Works
- Devices still send
updated_at. - But the server sets
arrived_at = now()when it receives the row. - Sync uses
arrived_at, notupdated_at.
Now the earlier problem disappears.
Same Scenario, Server-Side:
Now assume Supastash is using server-side replication with arrived_at as the sync cursor.
-
10:00 AM β Device A updates a row while offline.
βupdated_at = 10:00 -
10:05 AM β Device B updates the same table while offline.
βupdated_at = 10:05 -
10:10 AM β Device B reconnects and pushes its update.
β The server stores the row and setsarrived_at = 10:10. -
10:20 AM β Device C syncs.
β It pulls Device Bβs update and stores:
βlast_synced_at = 10:10(based onarrived_at, notupdated_at). -
Later β Device A reconnects and pushes its update (
updated_at = 10:00).
β The server setsarrived_at = 11:00(time of arrival).
What Happens?β
On the next sync, Device C will pull Device Aβs update, because:
arrived_at (11:00) > last_synced_at (10:10)
Even though the change originally happened at 10:00 AM,
it is synchronized based on when it arrived at the server.
That distinction prevents missed updates and guarantees correct ordering across devices.
π‘ Why Server-Side Is Betterβ
The example above says it all. Server-side replication orders sync by when data arrives at the server, not when a device originally set updated_at.
So even after long offline periods, updates are not skipped, ordering is based on arrival time, not device time.
π What You Must Add (Server-Side Mode)β
Each synced table must include:
arrived_at timestamptz NOT NULL DEFAULT now()
And the server must control it with a trigger.
π§ Trigger Function (Server-Controlled Arrival)β
CREATE OR REPLACE FUNCTION enforce_server_arrival_timestamp()
RETURNS trigger AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
NEW.arrived_at := now();
RETURN NEW;
END IF;
IF TG_OP = 'UPDATE' THEN
IF OLD.updated_at > NEW.updated_at THEN
RAISE EXCEPTION
'Stale update rejected. Existing updated_at (%) is newer than incoming (%)',
OLD.updated_at, NEW.updated_at;
END IF;
NEW.arrived_at := now();
RETURN NEW;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
Attach to each table:
CREATE TRIGGER set_arrived_at
BEFORE INSERT OR UPDATE ON public.orders
FOR EACH ROW
EXECUTE FUNCTION enforce_server_arrival_timestamp();
π Apply to Multiple Tablesβ
CREATE OR REPLACE FUNCTION apply_arrival_trigger_to_tables(table_names text[])
RETURNS void AS $$
DECLARE
t text;
BEGIN
FOREACH t IN ARRAY table_names
LOOP
EXECUTE format(
'CREATE TRIGGER set_arrived_at
BEFORE INSERT OR UPDATE ON public.%I
FOR EACH ROW
EXECUTE FUNCTION enforce_server_arrival_timestamp();',
t
);
END LOOP;
END;
$$ LANGUAGE plpgsql;
Usage:
SELECT apply_arrival_trigger_to_tables(
ARRAY['orders', 'order_items']
);
π Final Comparisonβ
| Mode | Sync Cursor | Safe After Long Offline? | Clock Safe? | Recommended |
|---|---|---|---|---|
| client-side | updated_at | β No | β No | Simple setups only |
| server-side | arrived_at | β Yes | β Yes | β Production |
π Summaryβ
- Client-side works for simple apps.
- Server-side works for real distributed offline systems.
If you care about correctness across devices and long offline gaps, use server-side replication.