Sync
PomegranateDB includes a built-in pull/push sync protocol that is compatible with the Watermelon-style backend shape while staying small enough to wire into a custom API.
Overview
The sync cycle follows a push-first strategy:
- Push local changes to the server
- Pull remote changes from the server
- Apply remote changes locally (in a transaction)
- Mark pushed records as synced
Push-first minimizes conflicts — the server sees your changes before you pull theirs.
PomegranateDB also persists a lastPulledAt checkpoint in adapter metadata so each sync can request only incremental changes.
Usage
import { performSync } from 'pomegranate-db';
await performSync(db, {
pushChanges: async ({ changes, lastPulledAt }) => {
await fetch('/api/sync/push', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ changes, lastPulledAt }),
});
},
pullChanges: async ({ lastPulledAt }) => {
const response = await fetch('/api/sync/pull', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lastPulledAt }),
});
return response.json();
// Expected: { changes: { posts: { created: [], updated: [], deleted: [] } }, timestamp }
},
onConflict: (local, remote) => ({
...remote,
// Keep the locally edited title, but take the remote server fields.
title: local.title,
}),
});
What performSync() Does
For each call, the sync engine:
- Reads the last successful pull timestamp from metadata.
- Collects local created, updated, and deleted records from the adapter.
- Sends local changes to
pushChanges()if there is anything to push. - Calls
pullChanges()with the previous checkpoint. - Resolves update conflicts with
onConflict()when provided. - Applies remote changes through the adapter.
- Stores the new
timestampfrom the pull response as the next checkpoint.
If there are no local changes, the push step is skipped. If there are no remote changes, no remote apply work is done.
API Shape
interface SyncPullResult {
changes: {
[tableName: string]: {
created: RawRecord[];
updated: RawRecord[];
deleted: string[];
};
};
timestamp: number;
}
interface SyncPushPayload {
changes: {
[tableName: string]: {
created: RawRecord[];
updated: RawRecord[];
deleted: string[];
};
};
lastPulledAt: number;
}
interface SyncConfig {
pullChanges: (params: { lastPulledAt: number | null }) => Promise<SyncPullResult>;
pushChanges: (params: SyncPushPayload) => Promise<void>;
onConflict?: (local: RawRecord, remote: RawRecord) => RawRecord;
tables?: string[];
}
Important Details
pullChanges()receiveslastPulledAt: number | null. The first sync passesnull.pushChanges()always receives a number. On the first sync, PomegranateDB sends0when no checkpoint exists yet.tableslets you limit which local tables participate in a sync. If your backend also supports partial sync, capture the same table list in your ownpullChanges()andpushChanges()closures.- Pushed records are sanitized before they are sent:
_statusis normalized tosyncedand_changedis cleared.
Sync Columns
Every synced table has these columns (added automatically):
| Column | Purpose |
|---|---|
_status | synced, created, updated, or deleted |
_changed | Comma-separated list of locally changed columns |
When you create a record, _status is set to created. When you update it, _status becomes updated and _changed tracks which fields changed. After a successful sync push, _status returns to synced.
Pull Response Format
Your backend should return:
interface SyncPullResult {
changes: {
[tableName: string]: {
created: RawRecord[];
updated: RawRecord[];
deleted: string[];
};
};
timestamp: number;
}
timestamp should be the server-side checkpoint that the client should send back on the next pull.
Push Payload Format
PomegranateDB sends:
interface SyncPushPayload {
changes: {
[tableName: string]: {
created: RawRecord[];
updated: RawRecord[];
deleted: string[];
};
};
lastPulledAt: number;
}
This lets the server validate whether the client is pushing changes against an old snapshot and decide how strict it wants to be.
Conflict Resolution
When a record is updated both locally and remotely during the same sync window, you can provide onConflict(local, remote) to merge them.
await performSync(db, {
pushChanges,
pullChanges,
onConflict: (local, remote) => {
return {
...remote,
title: local.title,
notes: `${remote.notes ?? ''}\n${local.notes ?? ''}`.trim(),
};
},
});
Conflict Semantics
- Without
onConflict, the remote updated record wins. - With
onConflict, PomegranateDB passes the locally modified record snapshot and the incoming remote record to your handler. - Your handler must return the raw record that should be written locally.
- The resolved record is stored as synced after the merge.
- Conflict handling currently applies to remote
updatedrecords. Remote deletes are applied as-is.
Recommended Strategies
- Keep server-authoritative fields from
remotesuch as moderation state or version counters. - Keep user-authored text fields from
localwhen the device should win for drafts. - Merge field-by-field instead of choosing a whole-record winner when possible.
- Make conflict handlers deterministic so retries do not produce different results.
Configuration
interface SyncConfig {
pullChanges: (params: { lastPulledAt: number | null }) => Promise<SyncPullResult>;
pushChanges: (params: SyncPushPayload) => Promise<void>;
onConflict?: (local: RawRecord, remote: RawRecord) => RawRecord;
tables?: string[];
}
Backend Checklist
- If you support partial sync, use the same table list your client passes into
performSync({ tables })when building the request in your callback. - Ensure
timestampis monotonic for a given dataset. - Treat
deletedas tombstone IDs, not full records. - Make
pushChanges()idempotent or safely retryable when possible. - Validate incoming records before applying them to the server.
Client Tips
- Debounce sync calls instead of syncing after every write.
- Trigger sync when the app returns to the foreground or when connectivity changes.
- Log the last successful
timestampon your backend for debugging incremental sync bugs. - Test
onConflict()with real records, not just happy-path mocks.
Tips
- Debounce syncs — don't call
performSync()on every write. - Handle network errors — retry with backoff around your transport layer.
- Test empty pulls — make sure your backend still returns a valid
timestampeven when there are no changes. - Keep payloads stable — match table names and raw record shapes exactly between client and server.