npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

syncly-engine

v0.1.10

Published

Expo database sync engine

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 deletedAt timestamp 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-engine

Expo 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.plist keys.

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:

  1. Writes the business data to the entity table
  2. Creates a sync payload snapshot in the sync_queue table

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 through SyncRepository
  • 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_conflict table for review
  • Outstanding Queue Jobs: Marked with conflict status 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 found

Delete (Soft Delete)

await taskRepo.delete('01H...');
// Sets deletedAt = Date.now() and queues a delete operation

Query 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