syncly-engine
v0.1.10
Published
Expo database sync engine
Maintainers
Readme
Syncly
⚠️ Warning: This project is currently under active development and is not intended for production use. APIs may change, and there may be breaking changes until a stable 1.0.0 release.
A local-first database synchronization engine for Expo/React Native applications, implementing the transactional outbox pattern for reliable data sync with cloud providers.
Project Overview
Syncly is a synchronization engine that ensures your mobile app data is always available offline and stays in sync with cloud backends (initially Firestore). It follows a local-first philosophy where SQLite is the single source of truth, and all writes go through a repository that atomically persists business data alongside queued sync payloads.
Key Principles
- Local Source of Truth: SQLite is the authoritative store for all application data
- Transactional Outbox Pattern: Every write atomically persists the business row AND a sync payload snapshot
- Offline-First: The app reads and writes to SQLite without requiring network connectivity
- Provider Abstraction: Sync operations go through swappable provider adapters (Firestore supported)
- ULID Keys: All entity IDs use ULID (Universally Unique Lexicographically Sortable Identifiers) for sortability and uniqueness
- Soft Deletes: Records are soft-deleted via a
deletedAttimestamp rather than hard deleted
Architecture
+----------------------------------------------------------+
| Application |
| |
| +----------------------------------------------------+ |
| | SynclyClient | |
| | +-------------+ +---------------+ +-----------+ | |
| | | Repository | | SyncEngine | | Provider | | |
| | | | | | | Registry | | |
| | +-----+-------+ +-------+------+ +-----+-----+ | |
| | | | | | |
| +--------|------------------|--------------|--------+ |
| | | | |
| v v v |
| +----------------------------------------------------+ |
| | SQLite Database | |
| | +------------+ +--------------+ +-------------+ | |
| | | sync_queue | | sync_state | | sync_log | | |
| | | | | | | | | |
| | | sync_pull_ | | sync_conflict| | <entity> | | |
| | | checkpoint | | | | tables | | |
| | +------------+ +--------------+ +-------------+ | |
| +----------------------------------------------------+ |
| | |
| v |
| +----------------------------------------------------+ |
| | SyncProviderAdapter | |
| | +----------------------------------------------+ | |
| | | FirestoreSyncAdapter | | |
| | | (or other cloud provider implementations) | | |
| | +----------------------------------------------+ | |
| +----------------------------------------------------+ |
| | |
| v |
| +----------------------------------------------------+ |
| | Cloud Backend | |
| | (Firestore) | |
| +----------------------------------------------------+ |
| |
+----------------------------------------------------------+Sync Flow
Write Path:
+--------+ +-----------+ +------------+ +-------+
| Client | --> | Repository| --> | SQLite WAL | --> | Outbox|
+--------+ +-----------+ +------------+ +---+---+
|
v
Read Path: +-------+
+--------+ +-----------+ +------------+ | Queue |
| Client | --> | Repository| --> | SQLite WAL | <-- |Worker |
+--------+ +-----------+ +------------+ +---+---+
|
+------------+ v
| Firestore | <------------+----+
| Adapter | |
+------------+ |
| |
v v
+----------------+ +----------+
| Firestore | | Sync |
| (Cloud) | | Engine |
+----------------+ +----------+Schema Versions
| Version | Tables Created |
|---------|---------------|
| 1 | sync_queue |
| 2 | sync_state, sync_log |
| 7 | sync_pull_checkpoint, sync_conflict |
| 8 | All tables (consolidated) |
Installation
npm install syncly-engine
# or
yarn add syncly-engineExpo Plugin Setup
The Syncly config plugin is optional.
- If you do not use Syncly background tasks, you can skip this plugin entirely.
- If you use Syncly background tasks on iOS, add the plugin so it can write required
Info.plistkeys.
Add the Syncly config plugin to your app config only when you need plugin-managed native config:
{
"expo": {
"plugins": ["syncly-engine"]
}
}If your app uses Syncly background tasks, enable iOS background processing keys through plugin options:
{
"expo": {
"plugins": [
[
"syncly-engine",
{
"enableBackgroundProcessing": true
}
]
]
}
}enableBackgroundProcessing defaults to false.
Core Concepts
Transactional Outbox Pattern
Every database write operation (insert, update, delete) is wrapped in a transaction that:
- Writes the business data to the entity table
- Creates a sync payload snapshot in the
sync_queuetable
The SyncEngine consumes these queued payloads independently, without re-querying business tables. This ensures that if a sync fails, the business data is already persisted and the payload can be retried.
Local-First Data Access
- App Read Path:
sqlite-only- All reads query SQLite directly - App Write Path:
repository-only- All writes go throughSyncRepository - Sync Pattern:
transactional-outbox- Business data and sync payload written atomically
ULID Primary Keys
All entities use ULID for their primary key. ULIDs are:
- Lexicographically sortable
- Globally unique
- Safe to generate on mobile devices without coordination
Soft Deletes
Records are soft-deleted by setting a deletedAt timestamp. This allows:
- Deleted records to be synced to cloud providers
- Local recovery of accidentally deleted records
- Audit trail of deletions
Conflict Resolution
When a remote change is pulled and conflicts with local pending changes:
- Last-Write-Wins: The newer record (based on
updatedAt) takes precedence - Conflict Records: All conflicts are logged to
sync_conflicttable for review - Outstanding Queue Jobs: Marked with
conflictstatus when overridden
Quick Start
import { createSynclyClient, Entity, Column, PrimaryKey, CloudCollection } from 'syncly-engine';
// 1. Define your entity
@Entity('tasks')
@CloudCollection('tasks')
class Task {
@PrimaryKey()
id: string;
@Column({ type: 'string' })
title: string;
@Column({ type: 'string', nullable: true })
description: string | null;
@Column({ type: 'datetime' })
updatedAt: number;
@Column({ type: 'datetime', nullable: true })
deletedAt: number | null;
}
// 2. Create the client
const client = await createSynclyClient({
entities: [Task],
startEngine: true,
});
// 3. Get a repository for the entity
const taskRepo = client.getRepository(Task);
// 4. Perform CRUD operations
const task = await taskRepo.insert({
id: generateUlid(), // You generate the ULID
title: 'Complete project',
description: null,
updatedAt: Date.now(),
deletedAt: null,
});
// 5. Update with automatic sync queueing
const updated = await taskRepo.update(task.id, {
title: 'Complete project (updated)',
updatedAt: Date.now(),
});
// 6. Soft delete (sets deletedAt, queues sync)
await taskRepo.delete(task.id);Entity Definition
Entities are defined using TypeScript decorators:
import { Entity, Column, PrimaryKey, Index, CloudCollection, CloudField } from 'syncly-engine';
@Entity('posts')
@CloudCollection('posts')
@Index({ columns: ['updatedAt'], unique: false })
class Post {
@PrimaryKey()
id: string;
@Column({ type: 'string' })
title: string;
@Column({ type: 'string' })
content: string;
@Column({ type: 'datetime' })
updatedAt: number;
@Column({ type: 'datetime', nullable: true })
deletedAt: number | null;
}Column Naming
Model properties can stay idiomatic TypeScript camelCase while SQLite columns use another convention.
Use columnName for one-off mappings:
class Task {
@PrimaryKey({ columnName: 'id' })
id: string;
@Column({ type: 'datetime', columnName: 'updated_at' })
updatedAt: number;
@Column({ type: 'datetime', nullable: true, columnName: 'deleted_at' })
deletedAt: number | null;
}For a whole entity, set a naming strategy on @Entity(...):
@Entity({ tableName: 'tasks', columnNamingStrategy: 'snake_case' })
@CloudCollection('tasks')
class Task {
@PrimaryKey()
id: string;
@Column({ type: 'datetime' })
updatedAt: number; // SQLite column: updated_at
}For all entities registered by a client, set columnNamingStrategy during client creation:
const client = await createSynclyClient({
entities: [Task, Post],
columnNamingStrategy: 'snake_case',
});Explicit columnName values take precedence over entity-level and client-level naming strategies. Supported built-in strategies are 'property' (default), 'camelCase', and 'snake_case'. A custom strategy function can also return a column name from { entityName, tableName, propertyKey }.
Decorator Reference
| Decorator | Purpose | Options |
|-----------|---------|---------|
| @Entity(tableName) / @Entity(options) | Marks a class as a sync entity | tableName: string, columnNamingStrategy?: 'property' \| 'camelCase' \| 'snake_case' \| function |
| @Column(options) | Defines a column | type: 'string' \| 'integer' \| 'real' \| 'boolean' \| 'json' \| 'datetime', columnName?: string, nullable?: boolean, default?: unknown |
| @PrimaryKey(options) | Defines the primary key | strategy?: 'ulid' (default), columnName?: string |
| @Index(options) | Creates a database index | columns: string[], unique?: boolean, name?: string |
| @CloudCollection(name) | Maps entity to cloud collection | name: string |
| @CloudField(options) | Configures cloud field mapping | name?: string, omitOnCloud?: boolean |
SyncModel Base Class
For entity classes with static repository methods:
import { SyncModel, Entity, Column, PrimaryKey, CloudCollection } from 'syncly-engine';
@Entity('tasks')
@CloudCollection('tasks')
class Task extends SyncModel {
@PrimaryKey()
id: string;
@Column({ type: 'string' })
title: string;
@Column({ type: 'datetime' })
updatedAt: number;
@Column({ type: 'datetime', nullable: true })
deletedAt: number | null;
}
// Static methods available directly on the model
const task = await Task.create({
id: generateUlid(),
title: 'New Task',
updatedAt: Date.now(),
deletedAt: null,
});
const found = await Task.find('01H...');
const all = await Task.all();
await Task.deleteById('01H...');Client Creation
import { createSynclyClient } from 'syncly-engine';
import { FirestoreSyncAdapter, createFirestoreSyncAdapter } from 'syncly-engine/firestore';
import { initializeApp } from 'firebase/app';
import { getFirestore } from 'firebase/firestore';
const firebaseApp = initializeApp({ /* firebase config */ });
const firestore = getFirestore(firebaseApp);
const firestoreAdapter = createFirestoreSyncAdapter({ firestore });
const client = await createSynclyClient({
entities: [Task, Post, User],
providers: [
{
adapter: firestoreAdapter,
isDefault: true,
},
],
databaseOptions: {
databaseName: 'mydata.db',
enableChangeListener: false,
},
columnNamingStrategy: 'property', // Optional: 'snake_case' for SQLite snake_case columns
startEngine: true, // Start sync engine automatically
syncSchemas: true, // Sync entity schemas to SQLite
bindModels: true, // Bind models to database for SyncModel methods
});Client Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| entities | SynclyEntityConstructor[] | [] | Entity classes to register |
| providers | SyncProviderRegistration[] | [] | Provider adapters |
| registry | SyncProviderRegistry | - | Pre-configured provider registry |
| defaultProvider | SyncProviderName | 'firestore' | Default sync provider |
| database | SynclyDatabase | - | Existing database instance |
| databaseOptions | SynclyDatabaseOptions | {} | Database configuration |
| startEngine | boolean | false | Start engine on client creation |
| syncSchemas | boolean | true | Create entity tables automatically |
| bindModels | boolean | true | Bind models to database |
| columnNamingStrategy | 'property' \| 'camelCase' \| 'snake_case' \| function | 'property' | Default SQLite column naming strategy for registered entities |
| engine | SyncEngineOptions | {} | Engine configuration |
Repository Operations
Insert
const taskRepo = client.getRepository(Task);
const task = await taskRepo.insert({
id: generateUlid(),
title: 'New Task',
description: 'Task description',
updatedAt: Date.now(),
deletedAt: null,
});Update
const updated = await taskRepo.update(task.id, {
title: 'Updated Title',
updatedAt: Date.now(),
});Save (Insert or Update)
// If id exists, updates; otherwise inserts
const saved = await taskRepo.save({
id: existingId ?? newId,
title: 'Task Title',
updatedAt: Date.now(),
deletedAt: null,
});Find
const found = await taskRepo.find('01H...');
if (!found) {
// Handle not found
}
const task = await taskRepo.findOrFail('01H...'); // Throws if not foundDelete (Soft Delete)
await taskRepo.delete('01H...');
// Sets deletedAt = Date.now() and queues a delete operationQuery Builder
const tasks = await taskRepo.query()
.where('updatedAt', '>', lastWeek)
.orderBy('updatedAt', 'desc')
.limit(20)
.get();
// Chain methods for complex queries
const highPriority = await taskRepo.query()
.where('priority', '=', 'high')
.where('completed', '=', false)
.orderBy('createdAt', 'asc')
.paginate(1, 10);
// Get first result
const first = await taskRepo.query()
.orderBy('updatedAt', 'desc')
.first();
// Count matching records
const count = await taskRepo.query()
.where('deletedAt', '=', null)
.count();
// Check existence
const exists = await taskRepo.query()
.where('id', '=', someId)
.exists();SyncEngine Operations
// Start automatic sync (runs on interval)
client.start();
// Stop automatic sync
client.stop();
// Pause all sync operations
await client.pause();
// Resume sync operations
await client.resume();
// Perform a single sync cycle (pull, then push)
const summary = await client.syncOnce();
console.log(`Push: ${summary.push.succeeded} succeeded, ${summary.push.failed} failed`);
console.log(`Pull: ${summary.pull.appliedChanges} applied`);
// Perform only push (drain the local outbox queue)
const pushSummary = await client.drainOnce();
// Perform only pull (fetch and apply remote changes)
const pullSummary = await client.pullOnce();Engine Configuration Options
const engine = createSyncEngine({
database,
registry: providerRegistry,
batchSize: 10, // Jobs per push batch (default: 10)
pullBatchSize: 50, // Changes per pull batch (default: 10)
maxPushBatchesPerRun: 25, // Max batches per sync cycle (default: 25)
pollIntervalMs: 30_000, // Auto-sync interval in ms (default: 30000)
networkCheck: async () => { // Custom network check
const state = await Network.getNetworkStateAsync();
return state.isConnected === true;
},
retryPolicy: { // Override default retry policy
initialDelayMs: 5_000,
multiplier: 2,
maxDelayMs: 5 * 60_000,
maxAttempts: 8,
retryableErrorCodes: ['network/unavailable', 'provider/rate-limited'],
},
telemetry: (event) => { // Telemetry listener
console.log('[Telemetry]', event.type);
},
});Provider Configuration
Firestore Adapter
import { FirestoreSyncAdapter, createFirestoreSyncAdapter } from 'syncly-engine/firestore';
import { initializeApp } from 'firebase/app';
import { getFirestore } from 'firebase/firestore';
const firebaseApp = initializeApp({
apiKey: 'your-api-key',
authDomain: 'your-project.firebaseapp.com',
projectId: 'your-project-id',
});
const firestore = getFirestore(firebaseApp);
const adapter = createFirestoreSyncAdapter({
firestore,
pathStrategy: {
resolve({ collection, recordId }) {
// Custom path resolution if needed
return {
collectionPath: `users/${userId}/${collection}`,
documentId: recordId,
documentPath: `users/${userId}/${collection}/${recordId}`,
};
},
},
});
const client = await createSynclyClient({
providers: [
{
adapter,
isDefault: true,
},
],
});Provider Registry
import { createSyncProviderRegistry, getActiveSyncProviderRegistry } from 'syncly-engine/provider';
const registry = createSyncProviderRegistry(
[
{ adapter: firestoreAdapter, isDefault: true },
{ adapter: customAdapter, enabled: true },
],
'firestore', // default provider
);
// Use a specific provider for a repository
const repo = client.getRepository(Task, { provider: 'custom' });
// Change default provider
registry.setDefaultProvider('custom');
// List registered providers
const providers = registry.list();Provider Adapter Interface
interface SyncProviderAdapter {
readonly name: SyncProviderName;
readonly capabilities: {
supportsBatchUpsert: boolean;
supportsTransactions: boolean;
supportsConflictHooks: boolean;
};
healthCheck(): Promise<ProviderHealth>;
upsertDocument(input: UpsertDocumentInput): Promise<ProviderMutationResult>;
deleteDocument(input: DeleteDocumentInput): Promise<ProviderMutationResult>;
batchUpsert?(input: BatchUpsertInput): Promise<readonly ProviderMutationResult[]>;
executeQueueRecord(record: SyncQueueRecord): Promise<ProviderMutationResult>;
pullRemoteChanges?(request: SyncPullRequest): Promise<SyncPullResult>;
mapError(error: unknown): ProviderError;
}Observability
Sync Events
Subscribe to real-time sync events:
import { subscribeToSyncEvents } from 'syncly-engine/observability';
const subscription = subscribeToSyncEvents((event) => {
switch (event.type) {
case 'job:queued':
console.log(`Queued: ${event.entity}/${event.recordId}`);
break;
case 'job:syncing':
console.log(`Syncing: ${event.entity}/${event.recordId}`);
break;
case 'job:success':
console.log(`Synced: ${event.entity}/${event.recordId}`);
break;
case 'job:failed':
console.log(`Failed: ${event.entity}/${event.recordId} - ${event.errorCode}`);
break;
case 'job:retrying':
console.log(`Retrying: ${event.entity}/${event.recordId} (attempt ${event.state.pendingJobs})`);
break;
case 'engine:paused':
console.log('Sync engine paused');
break;
case 'engine:resumed':
console.log('Sync engine resumed');
break;
case 'pull:applied':
console.log(`Pulled: ${event.entity}/${event.recordId}`);
break;
case 'pull:conflict':
console.log(`Conflict: ${event.entity}/${event.recordId} - ${event.conflictResolution}`);
break;
}
});
// Unsubscribe when done
subscription.remove();Sync State
Query the current sync state for any record:
import { getSyncStatus } from 'syncly-engine/observability';
const state = await getSyncStatus(database, Task, '01H...');
if (state) {
console.log(`Status: ${state.status}`);
console.log(`Pending jobs: ${state.pendingJobs}`);
console.log(`Last synced: ${new Date(state.lastSyncedAt ?? 0)}`);
console.log(`Last error: ${state.lastErrorCode}`);
}Sync Logs
import { listRecentSyncLogs, getRecentFailures } from 'syncly-engine/observability';
// Get recent sync events
const logs = await listRecentSyncLogs(database, {
entity: 'Task',
limit: 20,
statuses: ['success', 'failed'],
});
// Get recent failures
const failures = await getRecentFailures(database, {
entity: 'Task',
});Telemetry
Listen for internal telemetry events:
const client = await createSynclyClient({
// ...
engine: {
telemetry: (event) => {
switch (event.type) {
case 'database:migration:started':
console.log(`Migration: ${event.fromVersion} -> ${event.toVersion}`);
break;
case 'engine:cycle:completed':
console.log(`Cycle completed:`, event.summary);
break;
case 'engine:cycle:failed':
console.log(`Cycle failed: ${event.errorMessage}`);
break;
case 'engine:recovered-interrupted-jobs':
console.log(`Recovered ${event.count} interrupted jobs`);
break;
case 'queue:corrupt-payload':
console.log(`Corrupt payload: ${event.jobId}`);
break;
}
},
},
});Pending Job Count
import { getPendingSyncJobCount } from 'syncly-engine/queue';
const pending = await getPendingSyncJobCount(database);
console.log(`${pending} jobs waiting to sync`);Background Task Setup
For background sync on mobile, configure expo-background-task:
import * as BackgroundTask from 'expo-background-task';
import * as TaskManager from 'expo-task-manager';
import {
defineSyncEngineBackgroundTask,
registerSyncEngineBackgroundTaskAsync,
} from 'syncly-engine/engine';
const TASK_NAME = 'syncly-background-sync';
defineSyncEngineBackgroundTask(TASK_NAME, async () => {
// This runs in background
const client = await createSynclyClient({ /* ... */ });
await client.syncOnce();
await client.shutdown();
});
async function setupBackgroundSync() {
const status = await BackgroundTask.requestPermissionsAsync();
if (status.available) {
await registerSyncEngineBackgroundTaskAsync(TASK_NAME, {
minimumInterval: 15 * 60, // 15 minutes minimum
});
}
}
// Check if background task is defined
if (TaskManager.isTaskDefined(TASK_NAME)) {
console.log('Background task is defined');
}Configuration Options Reference
SynclyClientOptions
| Option | Type | Default |
|--------|------|---------|
| entities | SynclyEntityConstructor[] | [] |
| providers | SyncProviderRegistration[] | [] |
| registry | SyncProviderRegistry | - |
| defaultProvider | SyncProviderName | 'firestore' |
| database | SynclyDatabase | - |
| databaseOptions | SynclyDatabaseOptions | {} |
| startEngine | boolean | false |
| syncSchemas | boolean | true |
| bindModels | boolean | true |
| columnNamingStrategy | 'property' \| 'camelCase' \| 'snake_case' \| function | 'property' |
| engine | Partial<SyncEngineOptions> | {} |
SyncEngineOptions
| Option | Type | Default |
|--------|------|---------|
| database | SynclyDatabase | (required) |
| registry | SyncProviderRegistry | - |
| batchSize | number | 10 |
| pullBatchSize | number | 10 |
| maxPushBatchesPerRun | number | 25 |
| pollIntervalMs | number | 30000 |
| networkCheck | () => Promise<boolean> | - |
| retryPolicy | Partial<RetryPolicy> | - |
| telemetry | SyncTelemetryListener | - |
RetryPolicy
| Option | Type | Default |
|--------|------|---------|
| initialDelayMs | number | 5000 |
| multiplier | number | 2 |
| maxDelayMs | number | 300000 (5 min) |
| maxAttempts | number | 8 |
| retryableErrorCodes | string[] | See phase0.ts |
SynclyDatabaseOptions
| Option | Type | Default |
|--------|------|---------|
| databaseName | string | 'syncly.db' |
| enableChangeListener | boolean | false |
| directory | string | - |
| telemetry | SyncTelemetryListener | - |
Database Schema
sync_queue
Stores queued sync operations.
| Column | Type | Description |
|--------|------|-------------|
| id | TEXT | ULID job ID |
| entity | TEXT | Entity name |
| recordId | TEXT | Record ULID |
| operation | TEXT | insert, update, delete, upsert |
| payload | TEXT | JSON snapshot of the record |
| provider | TEXT | Target provider name |
| status | TEXT | pending, syncing, retrying, success, warning, failed, conflict, paused |
| retryCount | INTEGER | Number of retry attempts |
| nextRetryAt | INTEGER | Timestamp for next retry (null if not retrying) |
| lastErrorCode | TEXT | Error code from last attempt |
| lastErrorMessage | TEXT | Error message from last attempt |
| createdAt | INTEGER | ULID timestamp |
| updatedAt | INTEGER | Last modification timestamp |
sync_state
Aggregated sync state per record.
| Column | Type | Description |
|--------|------|-------------|
| entity | TEXT | Entity name |
| recordId | TEXT | Record ID |
| provider | TEXT | Provider name |
| status | TEXT | Aggregate status |
| pendingJobs | INTEGER | Count of pending/syncing/retrying jobs |
| lastOperation | TEXT | Last sync operation |
| lastQueuedAt | INTEGER | When last queued |
| lastAttemptedAt | INTEGER | When last attempted |
| lastSyncedAt | INTEGER | When last succeeded |
| lastErrorCode | TEXT | Last error code |
| lastErrorMessage | TEXT | Last error message |
| warningCode | TEXT | Last warning code |
| warningMessage | TEXT | Last warning message |
| updatedAt | INTEGER | Last update timestamp |
sync_log
Event log for sync operations (max 250 rows).
| Column | Type | Description |
|--------|------|-------------|
| id | TEXT | ULID log ID |
| jobId | TEXT | Related job ID (nullable) |
| entity | TEXT | Entity name |
| recordId | TEXT | Record ID |
| provider | TEXT | Provider name |
| event | TEXT | queued, syncing, success, warning, retrying, failed, conflict, pulled |
| status | TEXT | Job status at event time |
| operation | TEXT | Operation type (nullable) |
| errorCode | TEXT | Error code (nullable) |
| errorMessage | TEXT | Error message (nullable) |
| warningCode | TEXT | Warning code (nullable) |
| warningMessage | TEXT | Warning message (nullable) |
| createdAt | INTEGER | Event timestamp |
sync_pull_checkpoint
Tracks cursor position for pull operations.
| Column | Type | Description |
|--------|------|-------------|
| provider | TEXT | Provider name (PK) |
| entity | TEXT | Entity name (PK) |
| cursorUpdatedAt | INTEGER | Cursor updatedAt value |
| cursorRecordId | TEXT | Cursor record ID |
| lastPulledAt | INTEGER | Last successful pull timestamp |
| updatedAt | INTEGER | Last update timestamp |
sync_conflict
Records detected sync conflicts.
| Column | Type | Description |
|--------|------|-------------|
| id | TEXT | ULID conflict ID |
| provider | TEXT | Provider name |
| entity | TEXT | Entity name |
| recordId | TEXT | Record ID |
| resolution | TEXT | local-wins or remote-wins |
| reason | TEXT | Human-readable reason |
| localUpdatedAt | INTEGER | Local record timestamp |
| remoteUpdatedAt | INTEGER | Remote record timestamp |
| localPayload | TEXT | JSON of local state (nullable) |
| remotePayload | TEXT | JSON of remote state (nullable) |
| createdAt | INTEGER | Conflict detection timestamp |
License
MIT
