diff --git a/.claude/plans/background-alive-sync.md b/.claude/plans/background-alive-sync.md new file mode 100644 index 0000000..da6db99 --- /dev/null +++ b/.claude/plans/background-alive-sync.md @@ -0,0 +1,205 @@ +# Plan: Background-Alive Sync Path + +**Date:** 2026-04-02 +**Status:** Implemented + +--- + +## Problem Statement + +When a push notification arrives while the app is **backgrounded but not terminated**, the current code falls through to `executeBackgroundSync()` and opens a second write connection even though the provider is still mounted with an existing open connection. This creates concurrent writers and relies on SQLite locking rather than single-connection ownership. + +--- + +## Three-State Model + +| App State | Connection | Behavior | +|-----------|-----------|----------| +| `active` | reuse existing | `performSync()` via `foregroundSyncCallback` | +| backgrounded + alive | reuse existing | `performSync()` via `foregroundSyncCallback` — same callback | +| terminated | open new | `executeBackgroundSync` → `registerBackgroundSyncCallback` | + +Both the active and backgrounded-alive cases call the same `foregroundSyncCallback` (`() => performSyncRef.current?.() ?? Promise.resolve()`). This is correct because `performSync` already has all necessary guards built in (`isSyncingRef`, `writeDbRef`, `isSyncReady`, Android network check). React state updates (`isSyncing`, `lastSyncTime`, etc.) queued while backgrounded apply harmlessly on next render when the user returns. + +The distinction between active and backgrounded-alive is only relevant at the **notification layer** in the example app (see Phase 3). + +--- + +## Why Not a Separate `backgroundAliveSyncCallback` + +The callback would be identical to `foregroundSyncCallback`. Two module-level variables holding the same function serves no purpose. `performSync` already guards against all the failure modes that would require a different implementation. + +--- + +## Will `useOnTableUpdate` Fire When Backgrounded? + +OP-SQLite's `updateHook` is a SQLite C callback that fires synchronously on the thread executing the write. When the app is backgrounded but alive, the JS runtime thread is still active and running the background task. The hook dispatch should work. + +**Caveat:** Depends on whether OP-SQLite dispatches the hook synchronously on the current thread or schedules it. If scheduled, delivery may be delayed until foregrounding. Behavior is correct either way — timing may vary. + +**Verification:** manually confirm `useOnTableUpdate` fires during a background-alive sync in testing. + +--- + +## Signal: How to Detect "Provider Is Mounted" + +`foregroundSyncCallback` is set by `usePushNotificationSync` inside a React hook. React hooks only run when the component tree is mounted. In a terminated → background launch there is no component tree, so the callback is never set. + +Therefore: **`getForegroundSyncCallback() !== null` is a reliable proxy for "provider is mounted, `writeDbRef.current` is valid."** + +--- + +## Implementation Plan + +### Phase 1 — Update `pushNotificationSyncTask.ts` (one-line change) + +Remove the `AppState.currentState === 'active'` guard. The callback's existence is sufficient — it implies the provider is mounted and the connection is live. + +```typescript +// Before: +if (AppState.currentState === 'active' && foregroundCallback) { + +// After: +if (foregroundCallback) { +``` + +Full updated task: + +```typescript +const foregroundCallback = getForegroundSyncCallback(); + +/** FOREGROUND / BACKGROUND-ALIVE MODE */ +// foregroundCallback being non-null means the provider is mounted +// (React hooks only run when the component tree is alive). +// Safe for both active and backgrounded states — performSync guards internally. +if (foregroundCallback) { + logger.info('📲 Provider is mounted, using existing sync'); + try { + await foregroundCallback(); + logger.info('✅ Sync completed'); + } catch (syncError) { + logger.error('❌ Sync failed:', syncError); + } + return; +} + +/** TERMINATED / NO PROVIDER MODE */ +if (!config) { + logger.info('📲 No config found, skipping background sync'); + return; +} + +await executeBackgroundSync(config); +``` + +The `AppState` import can be removed from this file if it is no longer used elsewhere. + +--- + +### Phase 2 — Tests + +**Update:** `src/core/pushNotifications/__tests__/pushNotificationSyncTask.test.ts` + +New cases to add: +- When `foregroundCallback` is set and `AppState === 'background'`: callback is called, `executeBackgroundSync` is NOT called +- When `foregroundCallback` is set and `AppState === 'inactive'`: callback is called, `executeBackgroundSync` is NOT called +- When `foregroundCallback` is set and `AppState === 'active'`: callback is called (existing test, now also covers removal of the AppState guard) +- When `foregroundCallback` is null and `AppState === 'background'`: `executeBackgroundSync` is called (terminated path) + +--- + +### Phase 3 — Update Expo Example App + +`registerBackgroundSyncCallback` handles the **terminated** case — no component tree, so the app must schedule a push notification to alert the user. This stays as-is. + +The **background-alive** case now goes through `performSync`, which means `useOnTableUpdate` hooks fire. The example should demonstrate sending a notification from `useOnTableUpdate` when the app is backgrounded — this is how users handle the background-alive notification scenario. + +**Update `examples/sync-demo-expo/src/App.tsx`:** + +Add `AppState` import from `react-native`. Update the existing `useOnTableUpdate` callback to schedule a notification when the app is not active and the operation is an INSERT: + +```typescript +import { ..., AppState } from 'react-native'; + +useOnTableUpdate<{ id: string; value: string; created_at: string }>({ + tables: [TABLE_NAME], + onUpdate: async (data) => { + /** BACKGROUND-ALIVE NOTIFICATION */ + // When app is backgrounded but alive, useOnTableUpdate fires normally. + // Schedule a local notification so the user is alerted, same as the + // terminated path handled by registerBackgroundSyncCallback. + if (AppState.currentState !== 'active' && data.operation === 'INSERT' && data.row) { + await Notifications.scheduleNotificationAsync({ + content: { + title: 'New item synced', + body: data.row.value || 'New data is available', + data: { rowId: data.row.id }, + }, + trigger: null, + }); + return; + } + + /** FOREGROUND UI UPDATE */ + const operationName = + data.operation === 'INSERT' + ? 'added' + : data.operation === 'UPDATE' + ? 'updated' + : 'deleted'; + + if (data.row) { + setRowNotification( + `🔔 Row ${operationName}: "${data.row.value.substring(0, 20)}${ + data.row.value.length > 20 ? '...' : '' + }"` + ); + } else { + setRowNotification(`🔔 Row ${operationName}`); + } + setTimeout(() => setRowNotification(null), 2000); + }, +}); +``` + +The callback needs to be `async` — add that. Also update the JSDoc comment above the `registerBackgroundSyncCallback` block and the `useOnTableUpdate` comment to explain the two-path split: + +- `registerBackgroundSyncCallback` → terminated case +- `useOnTableUpdate` with AppState check → background-alive case + +--- + +## File Impact + +| File | Change | Risk | +|------|--------|------| +| `src/core/pushNotifications/pushNotificationSyncTask.ts` | Remove `AppState.currentState === 'active' &&` guard; remove `AppState` import if unused | Very Low | +| `src/core/pushNotifications/__tests__/pushNotificationSyncTask.test.ts` | Add background/inactive AppState test cases | None | +| `examples/sync-demo-expo/src/App.tsx` | Add AppState check + notification in `useOnTableUpdate`; make callback async | Low | + +No new files. No new module-level variables. No changes to `useSyncManager`, `usePushNotificationSync`, or `pushNotificationSyncCallbacks`. + +--- + +## Failure Scenarios Covered + +| Scenario | Handled by | +|----------|-----------| +| Notification while app active | `foregroundCallback` → `performSync` (unchanged) | +| Notification while app backgrounded, provider mounted | `foregroundCallback` → `performSync`; `useOnTableUpdate` fires → notification sent | +| Notification while app terminated | `executeBackgroundSync` → `registerBackgroundSyncCallback` (unchanged) | +| Concurrent sync (foreground + background-alive race) | `isSyncingRef` guard inside `performSync` | +| Provider unmounted before callback fires | `foregroundCallback` is null → falls through to `executeBackgroundSync` | + +--- + +## Verification Criteria + +- [ ] `AppState === 'background'`: `foregroundCallback` is called, `executeBackgroundSync` is NOT called +- [ ] `AppState === 'inactive'`: same as above +- [ ] `AppState === 'active'`: same behavior as before (existing tests still pass) +- [ ] `foregroundCallback` null + any AppState: `executeBackgroundSync` is called +- [ ] `useOnTableUpdate` fires during a background-alive sync (manual test) +- [ ] Example app: notification is sent when `useOnTableUpdate` fires while app is backgrounded +- [ ] Example app: UI notification still shows when app is active +- [ ] All existing tests pass diff --git a/.claude/plans/database-locked-fix.md b/.claude/plans/database-locked-fix.md new file mode 100644 index 0000000..2634df9 --- /dev/null +++ b/.claude/plans/database-locked-fix.md @@ -0,0 +1,266 @@ +# Plan: Fix "Database is Locked" After Background Sync on App Open + +**Date:** 2026-04-02 +**Status:** Implemented + +--- + +## Problem Statement + +When a push notification arrives while the app is **terminated**, the background task (`executeBackgroundSync`) opens a write connection to the SQLite database and runs `cloudsync_network_sync()`. If the user opens the app while this task is running — or after it was interrupted — the foreground hits a **"database is locked"** (`SQLITE_BUSY`) error. + +--- + +## Process Model — Why This Is Hard + +**Both iOS and Android use the same process for background tasks:** +- **iOS**: launches app in background mode, promotes that same process to foreground when user opens the app +- **Android**: uses Android "Headless JS" — the JS runtime runs in the existing app process without a mounted component tree (same process, no component tree) + +This is confirmed by `pushNotificationSyncCallbacks.ts` already using module-level state that background tasks read — a pattern that only works in a shared process. + +**The key difference is how tasks get killed:** + +| | iOS | Android | +|---|---|---| +| Task killed | iOS terminates the *task*, **keeps the process alive** | Android typically kills the *entire process* | +| Lock released after kill? | ❌ No — orphaned connection stays open in same process | ✅ Yes — POSIX releases all locks on process death | + +**The critical failure path (both platforms, task killed in same process):** + +``` +1. Background task opens write DB connection +2. cloudsync_network_sync() starts (native, off JS thread) +3. TaskManager hits background task time limit + → Expo kills the *task*, process stays alive (iOS) + → OR background task is interrupted before finally runs (Android) + → The JS finally { db.close() } never executes +4. User opens app → same process → foreground init runs +5. Foreground tries to open write connection + → Same process still holds an open, unclosed write connection + → SQLITE_BUSY +6. busy_timeout waits... but the lock holder (orphaned connection in same process) + never releases → user is stuck +``` + +**Why `busy_timeout` alone is not enough for the same-process case:** +SQLite's busy_timeout retries until the lock is released. If the lock is held by an orphaned open connection in the same process that nobody will ever close, the lock is held **indefinitely**. The timeout expires and the user cannot open the database. + +**When `busy_timeout` alone IS sufficient (Android process-death case):** +If Android kills the entire app process (e.g. very aggressive task termination), POSIX releases all file locks automatically on process death. The foreground app starts fresh in a new process, finds no orphaned connections in module state, and `busy_timeout` covers the brief window before the lock is fully released. + +--- + +## Root Cause + +The background task opens a DB connection but there is no mechanism for the foreground to know about or clean up that connection if the task is interrupted. The connection becomes an **orphan**: still open, holding a write lock, with no code path that will ever call `db.close()` on it. + +--- + +## Solution: Module-level DB reference + forceful cleanup + +Since the background task and foreground run in the same process, module-level state is shared. The fix is to store the background task's DB connection in a module-level variable **before the first `await`**. The foreground checks this variable on startup and forcibly closes any orphaned connection before opening its own. + +``` +Background: + db = createDatabase(...) + setActiveBackgroundDb(db) ← store reference immediately, synchronously + ... sync (may get interrupted here) ... + finally: + clearActiveBackgroundDb() + db.close() + +Foreground initialization: + staleDb = consumeActiveBackgroundDb() + if (staleDb) { + staleDb.close() ← forcibly release the lock, regardless of whether + } background is done or still running + // Now safe to open own connection + db = createDatabase(...) +``` + +**Why forceful close is safe:** +- If the background task was already done: this is a no-op (reference was already cleared by the task's `finally` block) +- If the background task is interrupted/stuck: closing the connection rolls back any uncommitted write transaction and releases the lock. SQLite guarantees the database stays consistent after a connection is closed mid-transaction. The partial sync data is lost, but that is acceptable — the next foreground sync will re-download it +- If the native `cloudsync_network_sync()` call is still running when we close: OP-SQLite will return an error on the native side, the background task's `catch` block handles it, and the `finally` tries to close an already-closed connection — which we catch and ignore + +**For Android / true cross-process scenarios:** +Module-level state is not shared. However, when a separate background process is killed by the OS, POSIX guarantees all file locks are released (all file descriptors are closed on SIGKILL). `PRAGMA busy_timeout` handles the wait window here. This is a secondary defense for Android, not the primary fix for iOS. + +--- + +## Implementation Plan + +### Phase 1 — New module: `activeBackgroundDb.ts` + +**New file:** `src/core/background/activeBackgroundDb.ts` + +Manages the module-level reference to the background sync's DB connection. + +```typescript +import type { DB } from '@op-engineering/op-sqlite'; + +let _db: DB | null = null; + +/** Called by executeBackgroundSync before first await — sets reference synchronously */ +export function setActiveBackgroundDb(db: DB): void { + _db = db; +} + +/** + * Called by useDatabaseInitialization on startup. + * Returns and clears the reference so the caller owns the connection. + * Returns null if no background sync connection exists. + */ +export function consumeActiveBackgroundDb(): DB | null { + const db = _db; + _db = null; + return db; +} + +/** Called in background task's finally block to clear reference after normal close */ +export function clearActiveBackgroundDb(): void { + _db = null; +} +``` + +--- + +### Phase 2 — Update `executeBackgroundSync.ts` + +Set the module-level reference **before the first `await`** (synchronously), so it's visible to any foreground code that runs concurrently. Clear it and close in the `finally` block — but only if the foreground hasn't already consumed and closed it. + +Key changes: +- Pass `setActiveBackgroundDb` as `onOpen` callback to `createDatabase` (see Phase 4 amendment) — this fires synchronously after `open()` before any awaited PRAGMAs, closing the kill-window gap +- In `finally`: call `clearActiveBackgroundDb()` before `db.close()` +- Handle the case where `db.close()` throws because foreground already closed it (already caught by existing try/catch in `finally`) + +> **Amendment (post-review):** Originally called `setActiveBackgroundDb(db)` after `await createDatabase(...)` returned, which still left a window between `open()` and the first PRAGMA await. Fixed by using the `onOpen` callback in `createDatabase` (see Phase 4 amendment). + +--- + +### Phase 3 — Update `useDatabaseInitialization.ts` + +At the very start of the `initialize()` function, before opening any connections, check for and close any orphaned background connection. + +```typescript +import { consumeActiveBackgroundDb } from '../background/activeBackgroundDb'; + +const initialize = async () => { + /** CLEANUP ORPHANED BACKGROUND CONNECTION */ + const orphanedDb = consumeActiveBackgroundDb(); + if (orphanedDb) { + logger.warn('⚠️ Found orphaned background sync connection — closing to release write lock'); + try { + orphanedDb.updateHook(null); + orphanedDb.close(); + logger.info('✅ Orphaned connection closed'); + } catch (closeErr) { + logger.warn('⚠️ Could not close orphaned connection (may already be closed):', closeErr); + // Best effort — proceed regardless + } + } + + // ... rest of existing initialize() code unchanged +}; +``` + +--- + +### Phase 4 — Update `createDatabase.ts` + +Two changes: + +**1. Add `PRAGMA busy_timeout`** as a cross-process fallback (Android) and general-purpose defense. + +```typescript +const db = open({ name }); +await db.execute('PRAGMA busy_timeout = 10000'); // 10s fallback for cross-process +await db.execute('PRAGMA journal_mode = WAL'); +``` + +10 seconds is sufficient for Android's cross-process scenario — when the background process is killed, POSIX releases the lock within milliseconds. 10s covers any OS-level delay. + +**2. Add `onOpen?: (db: DB) => void` callback parameter** (amendment, see Phase 2 note). + +```typescript +export async function createDatabase( + name: string, + mode: 'write' | 'read', + onOpen?: (db: DB) => void +): Promise { + const db = open({ name }); + onOpen?.(db); // ← fires synchronously before any await + await db.execute('PRAGMA busy_timeout = 10000'); + ... +} +``` + +This allows callers to register ownership of the raw connection before any async gap. Background sync passes `setActiveBackgroundDb` here. Normal callers (foreground, read connections) pass nothing. + +--- + +### Phase 5 — Tests + +**New:** `src/core/background/__tests__/activeBackgroundDb.test.ts` +- `setActiveBackgroundDb` makes connection available +- `consumeActiveBackgroundDb` returns and clears the reference +- `clearActiveBackgroundDb` clears without returning + +**Update:** `src/core/background/__tests__/executeBackgroundSync.test.ts` +- Verify `createDatabase` is called with an `onOpen` callback (function arg in position 3) +- Verify that `onOpen` callback calls `setActiveBackgroundDb` with the DB +- Verify `clearActiveBackgroundDb` is called in the `finally` block (both success and error paths) +- Verify `db.close()` error after foreground already closed it is handled gracefully + +**Update:** `src/core/database/__tests__/useDatabaseInitialization.test.ts` +- When `consumeActiveBackgroundDb` returns a mock DB, verify `db.close()` is called on it before initialization proceeds +- When `consumeActiveBackgroundDb` returns `null`, verify normal initialization path + +**Update:** `src/core/database/__tests__/createDatabase.test.ts` +- Verify `PRAGMA busy_timeout = 10000` is executed +- Verify `busy_timeout` is set before `journal_mode` (ordering matters) +- Verify `onOpen` callback is called synchronously before any PRAGMA (pragma call count is 0 when `onOpen` fires) + +--- + +## File Impact + +| File | Change | Risk | +|------|--------|------| +| `src/core/background/activeBackgroundDb.ts` | New file, ~20 lines | None | +| `src/core/background/executeBackgroundSync.ts` | Pass `setActiveBackgroundDb` as `onOpen` to `createDatabase`; `clearActiveBackgroundDb()` in `finally` | Low | +| `src/core/database/useDatabaseInitialization.ts` | Add cleanup block at start of `initialize()` | Low | +| `src/core/database/createDatabase.ts` | Add `onOpen?` callback param + `PRAGMA busy_timeout = 10000` | Very Low | +| Test files | New + updated test cases (51 tests total) | None | + +--- + +## Failure Scenarios Covered + +| Scenario | Covered by | +|----------|-----------| +| Background task completes normally, user opens app immediately after | Phase 4 (`busy_timeout`) — lock released naturally, brief wait | +| Background task interrupted (iOS time limit), same process | Phase 1-3 — foreground forcibly closes orphaned connection | +| Background task stuck (CloudSync network timeout), same process | Phase 1-3 — foreground forcibly closes orphaned connection | +| Background process killed by OS (Android / cross-process) | Phase 4 — POSIX releases lock on process death, `busy_timeout` covers the window | +| No background sync running | `consumeActiveBackgroundDb()` returns null, zero overhead | + +--- + +## What This Does NOT Cover + +- If the CloudSync native library (`cloudsync_network_sync`) holds the lock for longer than `busy_timeout` in a cross-process scenario AND the process is not killed yet — this would require aborting the CloudSync native call, which is outside this library's control. In practice, iOS kills background processes well within 30s. + +--- + +## Verification Criteria + +- [x] `setActiveBackgroundDb(db)` is called synchronously via `onOpen` callback inside `createDatabase`, before any PRAGMA await +- [x] `clearActiveBackgroundDb()` is called in the `finally` block of `executeBackgroundSync` +- [x] `useDatabaseInitialization` calls `consumeActiveBackgroundDb()` and closes the returned connection before opening its own +- [x] If `consumeActiveBackgroundDb()` returns null, zero behavior change vs. current code +- [x] `PRAGMA busy_timeout = 10000` is first PRAGMA after `open()` in `createDatabase` +- [x] All existing tests pass +- [x] New `activeBackgroundDb` tests pass (51 tests total, all passing) +- [ ] Manual test: terminate app → trigger background sync → open app during sync → no locked error, app loads cleanly diff --git a/.claude/plans/push-mode-no-retry.md b/.claude/plans/push-mode-no-retry.md new file mode 100644 index 0000000..3a53f2a --- /dev/null +++ b/.claude/plans/push-mode-no-retry.md @@ -0,0 +1,126 @@ +# Plan: Remove Retries in Push Mode + +**Date:** 2026-04-02 +**Status:** Approved + +--- + +## Problem Statement + +After the database-locked fix, opening the app following a terminated-state background sync shows a gray screen for 20-30 seconds on Android. + +**Root cause (from logs):** + +``` +13:32:39 — User taps app icon (android activity not available yet) +13:32:46 — Background sync 1 starts (apply event) ← openDB + 3-attempt native retry +13:32:48 — Background sync 2 starts (check event) ← openDB + 3-attempt native retry, concurrent! +13:32:51 — Both syncs complete +13:32:59 — JS thread torn down ("finished thread" errors) +13:33:02 — App finally renders ← 23 seconds after tap +13:33:04 — Initial foreground sync: 4 attempts × ~1.3s = ~5s of extra blocking +``` + +Two contributing factors: +1. **Background syncs retry (native, 3 attempts)** — both tasks hold the JS engine busy during the headless→foreground Android transition +2. **Foreground initial sync retries (JS, 4 attempts × 1s delay)** — delays UI data availability after app opens + +**Why retries are unnecessary in push mode:** + +The server's two-notification protocol is the retry mechanism: +- `apply` event → app syncs (may get 0 changes if artifact not ready yet) +- `check` event (with `artifactURI`) → app syncs with the actual data + +If a sync attempt misses changes, the server sends the next notification. Client-side retries are redundant and actively harmful to startup performance. + +--- + +## Why Concurrent Background Syncs Were Happening + +The concurrency was a **symptom of retries**, not an independent problem: + +``` +t=0s apply arrives → sync attempt 1 → 0 changes → wait 500ms → attempt 2 starts +t=2s server sends check (artifact now ready) → second executeBackgroundSync fires + ↑ overlaps with apply's attempt 2 +``` + +Removing retries fixes this: the `apply` sync finishes in ~1-2s (single attempt), +so by the time `check` arrives 2 seconds later, nothing is running. No concurrency +guard needed. + +--- + +## Solution + +### Change 1 — `executeBackgroundSync.ts`: `maxAttempts: 1` + +Background sync always runs in push mode. No retries needed — the server's `check` +notification is the retry mechanism. + +```typescript +// Before +await executeSync(db, logger, { + useNativeRetry: true, + maxAttempts: 3, + attemptDelay: 500, +}); + +// After +await executeSync(db, logger, { + maxAttempts: 1, +}); +``` + +--- + +### Change 2 — `useSyncManager.ts`: `maxAttempts: 1` in push mode + +`syncMode` is already available. Pass `maxAttempts: 1` when in push mode: + +```typescript +const changes = await executeSync(writeDbRef.current, logger, { + useTransaction: true, + maxAttempts: syncMode === 'push' ? 1 : 4, + attemptDelay: syncMode === 'push' ? 0 : 1000, +}); +``` + +This covers both foreground notification-triggered syncs and the initial sync on app open. + +--- + +## File Impact + +| File | Change | Risk | +|------|--------|------| +| `src/core/background/executeBackgroundSync.ts` | `maxAttempts: 1` | Very Low | +| `src/core/sync/useSyncManager.ts` | `maxAttempts: syncMode === 'push' ? 1 : 4` | Very Low | + +--- + +## Expected Outcome + +``` +Before: gray screen 20-30s, two concurrent background syncs, foreground retries 4× +After: gray screen ~5-8s (Android JS context switch overhead only, not reducible by client code) +``` + +The remaining gray screen time is the Android headless→foreground JS context transition itself, which is outside this library's control. + +--- + +## Test Updates + +- `executeBackgroundSync.test.ts` — verify `executeSync` is called with `maxAttempts: 1` +- `useSyncManager` tests — verify `maxAttempts: 1` when `syncMode === 'push'`, `maxAttempts: 4` when `syncMode === 'polling'` + +--- + +## Verification Criteria + +- [ ] Background sync calls `executeSync` with `maxAttempts: 1` +- [ ] `useSyncManager` uses `maxAttempts: 1` when `syncMode === 'push'` +- [ ] `useSyncManager` uses `maxAttempts: 4` when `syncMode === 'polling'` (no regression) +- [ ] All existing tests pass +- [ ] Manual: terminate app → trigger push → open app → no gray screen delay, data synced correctly diff --git a/.claude/plans/test-suite.md b/.claude/plans/test-suite.md new file mode 100644 index 0000000..3be152b --- /dev/null +++ b/.claude/plans/test-suite.md @@ -0,0 +1,114 @@ +# Test Suite + +**Status:** Implemented +**Last updated:** 2026-03-20 +**Total:** 33 test files, 305 tests + +## How to Run + +```bash +# Run all tests +yarn test + +# Run with coverage report +yarn test:coverage + +# Open HTML coverage report +open coverage/lcov-report/index.html +``` + +## Stack + +- **Runner:** Jest (react-native preset) +- **Hooks:** `renderHook` from `@testing-library/react-native` +- **Mocks:** Co-located `__mocks__/` directories for native modules +- **Coverage thresholds:** statements 95, branches 85, functions 95, lines 95 + +## Architecture + +Tests are co-located next to source files in `__tests__/` directories, organized in 5 layers: + +1. **Pure functions** — no mocks needed +2. **Core logic** — mocked native modules (op-sqlite, NetInfo, Expo) +3. **Context consumer hooks** — wrapped in test providers +4. **Complex hooks** — renderHook with mocked dependencies +5. **Integration** — SQLiteSyncProvider rendering + +## Shared Mocks + +Located in `src/__mocks__/`: + +| Mock | What it provides | +|------|-----------------| +| `@op-engineering/op-sqlite` | `createMockDB()`, `open()`, `getDylibPath()` | +| `@react-native-community/netinfo` | `addEventListener`, `fetch`, `__emit()` | +| `react-native` | `AppState`, `Platform` | +| `expo-notifications` | Token, permissions, listeners | +| `expo-secure-store` | `getItemAsync`, `setItemAsync`, `deleteItemAsync` | +| `expo-task-manager` | `defineTask`, `isTaskRegisteredAsync` | +| `expo-constants` | `expoConfig.extra` | +| `expo-application` | `getIosIdForVendorAsync`, `getAndroidId` | + +Test utilities in `src/testUtils.tsx` provide `createTestWrapper` for provider-wrapped hook tests. + +## Test Files (33 files, 305 tests) + +### Layer 1: Pure Functions (39 tests) + +| Test file | Source | Tests | What's tested | +|-----------|--------|------:|---------------| +| `core/polling/__tests__/calculateAdaptiveSyncInterval.test.ts` | calculateAdaptiveSyncInterval | 10 | Base interval, idle backoff at threshold, exponential error backoff, caps at maxInterval, error priority over idle | +| `core/pushNotifications/__tests__/isSqliteCloudNotification.test.ts` | isSqliteCloudNotification | 13 | Foreground valid/invalid URI, iOS background body, Android JSON string body, Android dataString fallback, invalid JSON, wrong URI, empty data | +| `core/common/__tests__/logger.test.ts` | logger | 9 | debug=true logs info/warn, debug=false suppresses, error always logs, [SQLiteSync] prefix, ISO timestamp, default debug=false | +| `core/pushNotifications/__tests__/pushNotificationSyncCallbacks.test.ts` | pushNotificationSyncCallbacks | 5 | Register/get background callback, null default, set/get/clear foreground callback | +| `core/__tests__/constants.test.ts` | constants | 2 | FOREGROUND_DEBOUNCE_MS value, PUSH_NOTIFICATION_SYNC_TASK_NAME non-empty | + +### Layer 2: Core Logic (97 tests) + +| Test file | Source | Tests | What's tested | +|-----------|--------|------:|---------------| +| `core/database/__tests__/createDatabase.test.ts` | createDatabase | 9 | Opens DB, WAL journal mode, write mode pragmas, read mode pragmas, returns DB, propagates open/pragma errors | +| `core/sync/__tests__/initializeSyncExtension.test.ts` | initializeSyncExtension | 14 | Missing connectionString/auth validation, iOS/Android extension paths, version check, cloudsync_init per table, network_init, API key/accessToken auth, siteId logging | +| `core/sync/__tests__/executeSync.test.ts` | executeSync | 14 | JS retry loop (returns 0/count, stops on changes, max attempts, transaction wrapping, malformed JSON), native retry passthrough | +| `core/background/__tests__/backgroundSyncConfig.test.ts` | backgroundSyncConfig | 10 | Persist/get/clear config, null without SecureStore, parse errors, warn/error handling | +| `core/background/__tests__/backgroundSyncRegistry.test.ts` | backgroundSyncRegistry | 7 | Register (persist + task), unregister (task + clear), warns when unavailable, error handling | +| `core/background/__tests__/executeBackgroundSync.test.ts` | executeBackgroundSync | 13 | Opens DB, inits sync, executes with native retry, updateHook callback, changes collection, DB close in finally, error rethrow, close error handling | +| `core/pushNotifications/__tests__/registerPushToken.test.ts` | registerPushToken | 12 | Skip duplicate, correct URL, accessToken/apiKey auth headers, body fields, iOS/Android device ID, non-ok response, persist after success, SecureStore read/write errors, missing expo-application | +| `core/pushNotifications/__tests__/pushNotificationSyncTask.test.ts` | pushNotificationSyncTask | 8 | Task definition with/without ExpoTaskManager, handler routes to background sync, skips non-SQLite notification, foreground callback when app active, error handling, skip without config | +| `core/common/__tests__/optionalDependencies.test.ts` | optionalDependencies | 10 | Each Expo module available/null, ExpoConstants .default fallback, isBackgroundSyncAvailable (all present vs any missing) | + +### Layer 3: Context Consumer Hooks (10 tests) + +| Test file | Source | Tests | What's tested | +|-----------|--------|------:|---------------| +| `hooks/context/__tests__/useSqliteDb.test.ts` | useSqliteDb | 2 | Returns writeDb/readDb/initError from context, null defaults | +| `hooks/context/__tests__/useSyncStatus.test.ts` | useSyncStatus | 2 | Returns all status fields, default values | +| `hooks/context/__tests__/useSqliteSync.test.ts` | useSqliteSync | 2 | Returns merged contexts, triggerSync callable | +| `core/common/__tests__/useInternalLogger.test.ts` | useInternalLogger | 2 | Returns logger from context, has info/warn/error methods | +| `hooks/sync/__tests__/useTriggerSqliteSync.test.ts` | useTriggerSqliteSync | 2 | Returns triggerSync, calls through to context | + +### Layer 4: Complex Hooks (112 tests) + +_Includes useDatabaseInitialization which spans init + lifecycle._ + +| Test file | Source | Tests | What's tested | +|-----------|--------|------:|---------------| +| `hooks/sqlite/__tests__/useSqliteExecute.test.ts` | useSqliteExecute | 10 | Undefined when no db, execute on writeDb/readDb, error state, clears error, auto-sync after write, skip on readOnly/autoSync=false, non-Error wrapping | +| `hooks/sqlite/__tests__/useSqliteTransaction.test.ts` | useSqliteTransaction | 8 | Undefined when no writeDb, calls transaction, error state, clears error, auto-sync after commit, skip autoSync=false, non-Error wrapping | +| `hooks/sqlite/__tests__/useOnTableUpdate.test.ts` | useOnTableUpdate | 8 | Register/remove updateHook, table filtering, null row for DELETE, empty rows, fetch error, no-op when null | +| `hooks/sync/__tests__/useSqliteSyncQuery.test.ts` | useSqliteSyncQuery | 10 | Loading state, initial read, error, reactive subscription after debounce, callback updates, unmount cleanup, debounce clearing, stale subscription skip, unsubscribe | +| `core/sync/__tests__/useSyncManager.test.ts` | useSyncManager | 16 | Null db/not ready guards, sync lifecycle, empty sync counters, error state, interval recalculation (polling vs push), concurrent sync prevention, Android network check, error backoff | +| `core/sync/__tests__/useInitialSync.test.ts` | useInitialSync | 4 | Delayed trigger after 1500ms, not ready guard, once-only, cleanup on unmount | +| `core/lifecycle/__tests__/useAppLifecycle.test.ts` | useAppLifecycle | 10 | Register/remove AppState listener, foreground sync trigger, interval reset (polling only), debounce, background state tracking | +| `core/lifecycle/__tests__/useNetworkListener.test.ts` | useNetworkListener | 10 | Register/unsubscribe NetInfo, sync on reconnect, no sync online→online, background guard, isNetworkAvailable state, null isInternetReachable/isConnected handling | +| `core/polling/__tests__/useAdaptivePollingSync.test.ts` | useAdaptivePollingSync | 9 | Start/stop polling, no start in push/not ready, pause on background, resume, dynamic interval, cleanup | +| `core/pushNotifications/__tests__/usePushNotificationSync.test.ts` | usePushNotificationSync | 15 | Permission request, skip in polling, token registration, siteId retrieval, denied callback, foreground listener, sync trigger, ignore non-SQLite notification, background registration, fallback, unregister on mode switch, cleanup, handle failures | +| `core/database/__tests__/useDatabaseInitialization.test.ts` | useDatabaseInitialization | 12 | Creates write/read DBs, initializes sync extension, onDatabaseReady callback, re-init on config change, error handling, empty name/tables validation, close errors on unmount | + +### Layer 5: Integration And Public Surface (19 tests) + +| Test file | Source | Tests | What's tested | +|-----------|--------|------:|---------------| +| `core/__tests__/SQLiteSyncProvider.test.tsx` | SQLiteSyncProvider | 14 | Renders children, provides writeDb/readDb, initError/syncError, onDatabaseReady, default status, syncMode, triggerSync, adaptive config, re-init triggers, mode fallback, cleanup | +| `core/__tests__/SQLiteSyncProvider.integration.test.tsx` | SQLiteSyncProvider | 7 | Polling defaults without adaptivePolling, push foreground default, push fallback to polling, reinit on accessToken/apiKey/databaseId/table config changes | +| `core/__tests__/publicExports.test.ts` | public API surface | 2 | Root exports smoke test, `./backgroundSync` subpath export smoke test | diff --git a/.claude/plans/unit-test-expansion-plan.md b/.claude/plans/unit-test-expansion-plan.md new file mode 100644 index 0000000..f6248ba --- /dev/null +++ b/.claude/plans/unit-test-expansion-plan.md @@ -0,0 +1,374 @@ +# Unit Test Expansion Plan + +**Status:** Implemented +**Last updated:** 2026-03-20 +**Scope:** Expand and harden the library unit-test suite before moving on to end-to-end example app integration tests + +## Goal + +Increase the value of the library test suite in two ways: + +1. Add missing unit and library-level integration tests for important runtime behavior +2. Improve the existing tests by reducing unnecessary internal mocks, especially around provider and push flows + +This plan intentionally stops short of full end-to-end testing of the example apps. That is the next phase. + +## Success Criteria + +- Important public behaviors are tested directly, not only via wiring assertions +- User-scoped auth, provider reinitialization, polling defaults, and push fallback are covered +- Provider tests rely on fewer mocks and exercise more real runtime behavior +- Race conditions and cleanup behavior are tested for reactive hooks +- Public package exports and subpath exports have smoke coverage +- Coverage reporting and thresholds are introduced once the expanded suite is stable + +## Current Observations + +- The current suite is broad and useful, but some high-value areas are still too mock-heavy +- `SQLiteSyncProvider` tests currently mock nearly the full dependency graph, which limits real integration confidence +- Push notification tests exercise branch logic well, but still mock many internal modules at once +- There is no enforced coverage threshold in Jest config +- Full device/native integration is intentionally deferred to the next testing phase + +## Workstreams + +### Workstream 1: Provider And Public API + +#### Add tests + +1. `SQLiteSyncProvider` polling defaults integration +- Render with `syncMode="polling"` and no `adaptivePolling` +- Assert default adaptive config is passed into the real sync manager path +- Assert `currentSyncInterval` starts at `baseInterval` + +2. `SQLiteSyncProvider` `notificationListening` defaulting +- Render push mode without explicit `notificationListening` +- Assert effective value is `'foreground'` + +3. `SQLiteSyncProvider` auth reinitialization for `accessToken` +- Initial render with `accessToken="user-a"` +- Rerender with `accessToken="user-b"` +- Assert DB/sync init reruns with the new user token + +4. `SQLiteSyncProvider` auth reinitialization for `apiKey` +- Initial render with `apiKey="key-a"` +- Rerender with `apiKey="key-b"` +- Assert DB/sync init reruns + +5. `SQLiteSyncProvider` `databaseId` reinitialization +- Change `databaseId` +- Assert teardown and re-init + +6. `SQLiteSyncProvider` `databaseName` reinitialization +- Change `databaseName` +- Assert old DBs are closed and new ones are opened + +7. `SQLiteSyncProvider` `tablesToBeSynced` reinitialization +- Change table config content +- Assert safe re-init + +8. `SQLiteSyncProvider` migration ordering +- Assert DB open happens before `onDatabaseReady` +- Assert `onDatabaseReady` completes before sync init +- Assert failed migration prevents sync init + +9. `SQLiteSyncProvider` push fallback integration +- Render provider in push mode +- Simulate permission denied or token-registration failure +- Assert effective runtime mode becomes polling +- Assert polling-related behavior is active after fallback + +10. Public root export smoke test +- Import public exports from `src/index.tsx` +- Assert documented runtime exports exist + +11. Public subpath export smoke test +- Add coverage for `./backgroundSync` export path if it is intended to ship + +#### Improve existing tests + +- Refactor `src/core/__tests__/SQLiteSyncProvider.test.tsx` +- Stop mocking all internal hooks +- Mock only true external boundaries: + - DB creation / OP-SQLite + - sync extension/native SQL calls + - NetInfo + - AppState + - Expo notifications +- Replace prop-wiring assertions with behavior assertions through contexts + +### Workstream 2: Query, Execute, Transaction, And Table Update Hooks + +#### Add tests + +12. `useSqliteSyncQuery` no-`readDb` behavior +- Explicitly test intended behavior when `readDb` is null and `writeDb` exists + +13. `useSqliteSyncQuery` subscription replacement on rerender +- Query/args/fireOn change should unsubscribe the old reactive subscription exactly once + +14. `useSqliteSyncQuery` stale async result protection +- Query A resolves after query B replaces it +- Old result must not overwrite new state + +15. `useSqliteSyncQuery` callback error behavior +- Reactive callback throws or returns malformed data +- Assert predictable error handling + +16. `useSqliteSyncQuery` `fireOn` operation filtering +- Ensure operation-specific invalidation behaves as expected + +17. `useOnTableUpdate` multiple-table filtering edge cases +- Subscribed tables trigger +- Unrelated tables do not +- DELETE keeps `row = null` + +18. `useOnTableUpdate` cleanup on config change +- Changing tables or callback should remove old hook and install new one + +19. `useSqliteExecute` `isExecuting` lifecycle +- True while request is active +- False after success +- False after failure + +20. `useSqliteExecute` read-only without `readDb` +- Explicitly codify current behavior + +21. `useSqliteExecute` preserves main result when auto-sync fails +- Assert returned result is intact +- Assert `error` remains null + +22. `useSqliteTransaction` `isExecuting` lifecycle +- True while transaction is active +- False after success/failure + +23. `useSqliteTransaction` callback fidelity +- Assert provided transaction object is the one used for multiple statements + +24. `useSqliteTransaction` no auto-sync after failed transaction +- If transaction throws, `cloudsync_network_send_changes()` must not run + +#### Improve existing tests + +- Strengthen `useSqliteSyncQuery` tests to validate state transitions, not only mocked callback wiring +- Reduce duplicated mocking in execute/transaction tests by using a more realistic fake DB helper + +### Workstream 3: Lifecycle, Polling, And Sync Manager + +#### Add tests + +25. `useAppLifecycle` transition matrix +- `inactive -> active` +- `background -> active` +- `active -> active` +- Only real resume transitions should trigger sync + +26. `useNetworkListener` initial offline startup +- Start offline, then reconnect +- Assert first reconnect behavior is correct + +27. `useNetworkListener` repeated online noise +- Multiple online events without a new offline phase should not retrigger sync + +28. `useAdaptivePollingSync` timer replacement +- Interval changes should clear prior timer and schedule a new one + +29. `useAdaptivePollingSync` cleanup robustness +- Unmount clears timer cleanly +- No duplicate timers survive rerenders + +30. `useSyncManager` sync mode switching +- Behavior when params rerender from polling to push and back + +31. `useSyncManager` sync error recovery +- After failures, a successful sync resets `consecutiveSyncErrors` + +32. `useSyncManager` Android network edge cases +- More explicit coverage for `isConnected=true` with unusual `isInternetReachable` values + +#### Improve existing tests + +- Keep these tests mostly behavior-focused +- Avoid introducing extra mocks because this area is already relatively strong + +### Workstream 4: Sync Initialization And Database Initialization + +#### Add tests + +33. `initializeSyncExtension` failure on `loadExtension` +- Clear thrown error + +34. `initializeSyncExtension` failure on `cloudsync_network_init_custom` +- Clear thrown error + +35. `initializeSyncExtension` failure on `cloudsync_network_set_apikey` +- Clear thrown error + +36. `initializeSyncExtension` failure on `cloudsync_network_set_token` +- Clear thrown error + +37. `initializeSyncExtension` auth policy test +- Decide intended runtime behavior if both `apiKey` and `accessToken` are somehow supplied +- Test the chosen policy explicitly + +38. `useDatabaseInitialization` partial DB open failure +- Write DB succeeds, read DB fails +- Assert cleanup and fatal error behavior + +39. `useDatabaseInitialization` parameter-driven reinit +- Changing config should close prior DBs before reopening + +40. `useDatabaseInitialization` sync init should not run after fatal table creation failure +- Assert no further steps continue + +41. `useDatabaseInitialization` empty tables behavior +- Clarify and codify expected behavior with assertions beyond “does not crash” + +#### Improve existing tests + +- Keep `createDatabase` mocked, but use more behavior-rich fake DB instances +- Replace “no crash” tests with stronger assertions when possible + +### Workstream 5: Push Registration, Push Hooks, And Background Sync + +#### Add tests + +42. `registerPushToken` missing auth +- Assert clear error when neither `apiKey` nor `accessToken` is provided + +43. `registerPushToken` token re-registration policy +- Same Expo token with different `databaseId` +- Same Expo token with different `siteId` +- Same Expo token with changed auth +- Verify intended behavior explicitly + +44. `registerPushToken` Android device ID failure +- `getAndroidId()` missing or throws + +45. `usePushNotificationSync` token registration failure path +- Registration fails after permissions granted +- Assert warning and fallback behavior + +46. `usePushNotificationSync` custom permission prompt flow +- `renderPushPermissionPrompt` +- `allow` proceeds to permission request +- `deny` triggers fallback/denied flow + +47. `usePushNotificationSync` foreground listener cleanup +- Unmount removes notification listener + +48. `usePushNotificationSync` background registration cleanup +- `notificationListening="always"` unregisters on unmount or mode change + +49. `usePushNotificationSync` missing optional dependency combinations +- Test missing `expo-notifications` +- Test missing `expo-constants` +- Test missing `expo-application` +- Test missing background deps for `'always'` + +50. `pushNotificationSyncTask` invalid persisted config +- Missing required fields +- Corrupt serialized payload +- Fails safely without crash + +51. `executeBackgroundSync` access-token configuration +- Cover `accessToken` path, not only `apiKey` + +52. `executeBackgroundSync` failure before callback registration +- DB still closes +- Callback does not run + +53. `executeBackgroundSync` table/init failure cleanup +- Assert `close()` still runs + +#### Improve existing tests + +- Refactor `usePushNotificationSync.test.ts` to mock fewer internal helpers +- Keep Expo/native boundaries mocked, but prefer real internal control flow + +## Outcome + +This unit-test expansion pass was implemented. The library now has: + +- 33 test suites +- 305 tests +- provider integration coverage with fewer internal mocks +- public export smoke coverage +- coverage reporting with enforced thresholds +- stronger cleanup, auth, push, and reactive-hook edge-case coverage + +## Recommended Implementation Order + +### Phase 1: Highest-value unit tests + +1. Provider defaults integration +2. Provider auth reinit +3. Provider push fallback integration +4. Query stale result + cleanup tests +5. Transaction “no auto-sync after failure” +6. Push permission prompt flow +7. Background sync `accessToken` path + +### Phase 2: Provider and push mock reduction + +8. Refactor `SQLiteSyncProvider.test.tsx` +9. Refactor `usePushNotificationSync.test.ts` +10. Strengthen `useDatabaseInitialization.test.ts` + +### Phase 3: Remaining high-value behavior gaps + +11. Export smoke tests +12. Register-push-token policy edge cases +13. Lifecycle/polling edge cases +14. Sync-init failure matrix +15. Background sync cleanup/failure matrix + +### Phase 4: Coverage measurement and enforcement + +16. Enable `collectCoverage` +17. Establish baseline coverage report +18. Add pragmatic thresholds by category: +- statements +- branches +- functions +- lines + +Suggested approach: + +- Do not add strict thresholds before the new tests are in place +- Start with thresholds that reflect current reality +- Tighten later as E2E coverage is added + +## Mock Reduction Strategy + +### Keep mocking + +- OP-SQLite native module boundaries +- NetInfo +- AppState +- Expo native APIs +- network calls such as `fetch` + +### Reduce mocking for + +- `SQLiteSyncProvider` internal hook graph +- internal push helper modules when testing push hook behavior +- internal database/sync coordination inside provider-level tests + +### Principle + +At the library unit-test level, mock platform/native boundaries, not your own core logic, unless the test is intentionally scoped to one pure unit. + +## Deliverables + +1. Expanded unit-test suite covering the cases listed above +2. Refactored provider and push tests with fewer internal mocks +3. Coverage reporting in Jest +4. Updated `.claude/plans/test-suite.md` after implementation is complete + +## Explicitly Deferred To Next Step + +- Full end-to-end tests on `examples/sync-demo-expo` +- Full end-to-end tests on `examples/sync-demo-bare` +- Real-device push validation +- Build/install/runtime validation in CI on native targets diff --git a/.easignore b/.easignore new file mode 100644 index 0000000..e0581e4 --- /dev/null +++ b/.easignore @@ -0,0 +1,93 @@ +# OSX +# +.DS_Store + +# XDE +.expo/ + +# VSCode +.vscode/ +jsconfig.json + +# Xcode +# +build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.xcuserstate +project.xcworkspace +**/.xcode.env.local + +# Android/IJ +# +.classpath +.cxx +.gradle +.idea +.project +.settings +local.properties +android.iml + +# Cocoapods +# +example/ios/Pods +examples/*/ios/Pods + +# Ruby +example/vendor/ +examples/*/vendor/ + +# node.js +# +node_modules/ +npm-debug.log +yarn-debug.log +yarn-error.log + +# BUCK +buck-out/ +\.buckd/ +android/app/libs +android/keystores/debug.keystore + +# Yarn +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# Expo +.expo/ + +# Turborepo +.turbo/ + +# generated by bob +lib/ + +# React Native Codegen +ios/generated +android/generated + +# NOTE: ios/ is NOT ignored here (unlike .gitignore) so CloudSync.xcframework is uploaded to EAS Build + +# React Native Nitro Modules +nitrogen/ + +# Example app environment files +examples/*/.env diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..7481da4 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,82 @@ +name: Build and Publish + +on: + push: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + outputs: + should_publish: ${{ steps.version_check.outputs.should_publish }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + registry-url: 'https://registry.npmjs.org' + + - name: Enable Corepack + run: corepack enable + + - name: Install dependencies + run: yarn install --immutable + + - name: Typecheck + run: yarn typecheck + + - name: Lint + run: yarn lint + + - name: Build + run: yarn prepare + + - name: Check version + id: version_check + run: | + PACKAGE_NAME=$(node -p "require('./package.json').name") + LOCAL_VERSION=$(node -p "require('./package.json').version") + NPM_VERSION=$(npm view "$PACKAGE_NAME" version 2>/dev/null || echo "0.0.0") + + echo "Local version: $LOCAL_VERSION" + echo "npm version: $NPM_VERSION" + + if [ "$LOCAL_VERSION" != "$NPM_VERSION" ]; then + echo "Version changed, will publish" + echo "should_publish=true" >> $GITHUB_OUTPUT + else + echo "Version unchanged, skipping publish" + echo "should_publish=false" >> $GITHUB_OUTPUT + fi + + publish: + needs: build + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.build.outputs.should_publish == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + registry-url: 'https://registry.npmjs.org' + + - name: Enable Corepack + run: corepack enable + + - name: Install dependencies + run: yarn install --immutable + + - name: Build + run: yarn prepare + + - name: Publish to npm + run: npm publish --access public --provenance diff --git a/.gitignore b/.gitignore index 67f3212..f4e669b 100644 --- a/.gitignore +++ b/.gitignore @@ -44,9 +44,11 @@ android.iml # Cocoapods # example/ios/Pods +examples/*/ios/Pods # Ruby example/vendor/ +examples/*/vendor/ # node.js # @@ -82,5 +84,14 @@ lib/ ios/generated android/generated +# iOS XCFramework +ios/ + # React Native Nitro Modules nitrogen/ + +# Jest coverage +coverage/ + +# Example app environment files +examples/*/.env diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3d0e3ec --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,213 @@ +# CLAUDE.md + +## 1. Think Before Coding + +**Don't assume. Don't hide confusion. Surface tradeoffs.** + +Before implementing: +- State your assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them - don't pick silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop. Name what's confusing. Ask. + +## 2. Simplicity First + +**Minimum code that solves the problem. Nothing speculative.** + +- No features beyond what was asked. +- No abstractions for single-use code. +- No "flexibility" or "configurability" that wasn't requested. +- No error handling for impossible scenarios. +- If you write 200 lines and it could be 50, rewrite it. + +Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +## 3. Surgical Changes + +**Touch only what you must. Clean up only your own mess.** + +When editing existing code: +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- If you notice unrelated dead code, mention it - don't delete it. + +When your changes create orphans: +- Remove imports/variables/functions that YOUR changes made unused. +- Don't remove pre-existing dead code unless asked. + +The test: Every changed line should trace directly to the user's request. + +## 4. Goal-Driven Execution + +**Define success criteria. Loop until verified.** + +Transform tasks into verifiable goals: +- "Add validation" → "Write tests for invalid inputs, then make them pass" +- "Fix the bug" → "Write a test that reproduces it, then make it pass" +- "Refactor X" → "Ensure tests pass before and after" + +For multi-step tasks, state a brief plan: +``` +1. [Step] → verify: [check] +2. [Step] → verify: [check] +3. [Step] → verify: [check] +``` + +Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. + +## 5. Comment Style + +**Consistent, purposeful comments. Not noise.** + +### JSDoc for public APIs +Every exported function, interface, and type gets JSDoc documentation: +```typescript +/** + * Brief description of what it does + * + * @param foo - Description of parameter + * @returns Description of return value + */ +export function myFunction(foo: string): number { ... } +``` + +### JSDoc for types and interfaces +All interfaces and types get a JSDoc comment, and every field gets an inline `/** comment */`: +```typescript +/** Props for {@link MyComponent} */ +interface MyComponentProps { + /** Whether the dialog is visible */ + open: boolean; + /** Called to close the dialog */ + onClose: () => void; +} + +/** Single navigation link in the sidebar */ +type NavItem = { + /** Display text */ + label: string; + /** Route path */ + href: string; +}; +``` + +### Section markers inside functions +Use `/** SECTION NAME */` to mark logical sections within complex functions: +```typescript +function complexFunction() { + /** PARSE OPTIONS */ + const { foo, bar } = options; + + /** VALIDATE INPUT */ + if (!foo) throw new Error('foo required'); + + /** EXECUTE MAIN LOGIC */ + const result = doSomething(foo, bar); + + /** CLEANUP */ + return result; +} +``` + +Common section names: `STATE`, `REFS`, `GUARDS`, `HELPERS`, `CLEANUP`, `EFFECT 1: ...`, `HANDLE ERROR` + +### Inline explanations +Use `// comment` for explaining specific logic: +```typescript +// Only sync if auto-sync is not explicitly disabled +const shouldAutoSync = options?.autoSync !== false; + +// On Android, the native call blocks for ~10-15s if offline +if (Platform.OS === 'android') { ... } +``` + +### What NOT to comment +- Obvious code (`// increment counter` before `count++`) +- Code that's already clear from good naming +- Every single line - only where it adds value + +## 6. Learn From Mistakes + +**When corrected, codify the lesson. Don't repeat the same mistake twice.** + +When the user corrects you or you discover a pattern/convention mid-session: +1. Acknowledge the mistake explicitly. +2. Propose a new rule or update to an existing section of this file that would have prevented it. +3. After user approval, add/update the rule in CLAUDE.md so future sessions benefit. + +Examples of things worth capturing: +- File/folder conventions the user enforces (e.g. "utils go under `src/lib/utils/`") +- Naming patterns (e.g. "one function per file, kebab-case filename") +- Architectural preferences revealed through feedback +- Anti-patterns the user flags + +Do NOT add rules speculatively. Only add rules that come from actual corrections or explicit user preferences expressed during a session. + +## 7. Workflow Orchestration + +### Plan Mode Default + +- Enter plan mode for ANY non-trivial task (3+ steps or architectural decisions) +- If something goes sideways, STOP and re-plan immediately — don't keep pushing +- Use plan mode for verification steps, not just building +- Write detailed specs upfront to reduce ambiguity + +### Subagent Strategy + +- Use subagents liberally to keep main context window clean +- Offload research, exploration, and parallel analysis to subagents +- For complex problems, throw more compute at it via subagents +- One task per subagent for focused execution + +### Self-Improvement Loop + +- After ANY correction from the user: update relevant files in `.claude/rules/` with the pattern +- Write rules for yourself that prevent the same mistake +- Ruthlessly iterate on these lessons until mistake rate drops +- Review lessons at session start for relevant project + +### Verification Before Done + +- Never mark a task complete without proving it works +- Diff behavior between main and your changes when relevant +- Ask yourself: "Would a staff engineer approve this?" +- Run tests, check logs, demonstrate correctness + +### Demand Elegance (Balanced) + +- For non-trivial changes: pause and ask "is there a more elegant way?" +- If a fix feels hacky: "Knowing everything I know now, implement the elegant solution" +- Skip this for simple, obvious fixes — don't over-engineer +- Challenge your own work before presenting it + +### Autonomous Bug Fixing + +- When given a bug report: just fix it. Don't ask for hand-holding +- Point at logs, errors, failing tests — then resolve them +- Zero context switching required from the user +- Go fix failing CI tests without being told how + +## 8. Plan Files + +**Store implementation plans in `.claude/plans/.md`** + +- Plans live in `.claude/plans/` so Claude can reference them across sessions +- Use descriptive kebab-case names: `test-suite-design.md`, `push-notification-refactor.md` +- Include date and approval status at the top +- These are working documents — update them as the plan evolves + +## 9. Task Management + +1. **Plan First**: Write plan to `.claude/todo/.md` with checkable items +2. **Verify Plan**: Check in before starting implementation +3. **Track Progress**: Mark items complete as you go +4. **Explain Changes**: High-level summary at each step +5. **Document Results**: Add review section to `.claude/todo/.md` +6. **Capture Lessons**: Update relevant files in `.claude/rules/` after corrections + +## 10. Core Principles + +- **Simplicity First**: Make every change as simple as possible. Impact minimal code. +- **No Laziness**: Find root causes. No temporary fixes. Senior developer standards. +- **Minimal Impact**: Changes should only touch what's necessary. Avoid introducing bugs. diff --git a/README.md b/README.md index defaa52..6644ad9 100644 --- a/README.md +++ b/README.md @@ -4,59 +4,69 @@ [![npm version](https://img.shields.io/npm/v/@sqliteai/sqlite-sync-react-native.svg)](https://www.npmjs.com/package/@sqliteai/sqlite-sync-react-native) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -Build real-time, collaborative mobile apps that work seamlessly offline and automatically sync when online. Powered by [SQLite Sync](https://github.com/sqliteai/sqlite-sync). +Offline-first React Native sync for SQLite Cloud. This library gives you a local SQLite database on-device, keeps it usable offline, and synchronizes changes with SQLite Cloud when connectivity is available. -## ✨ Features +Powered by [SQLite Sync](https://github.com/sqliteai/sqlite-sync) and [OP-SQLite](https://github.com/OP-Engineering/op-sqlite). -- 🧩 **Offline-First, Automatic Sync** - Wrap your app with `SQLiteSyncProvider` to get a local database with automatic, bi-directional cloud synchronization. Your app works fully offline, and all local changes are synced seamlessly when online. +## Table of Contents -- 🪝 **React Hooks Designed for Sync-Aware Data** - Use hooks like `useSqliteSyncQuery` and `useOnSqliteSync` to automatically refresh your UI when changes are synced from the cloud — keeping your app up-to-date without boilerplate code. +- [Compatibility](#compatibility) +- [Choose Your Auth Model](#choose-your-auth-model) +- [Choose Your Sync Mode](#choose-your-sync-mode) +- [Installation](#installation) +- [Quick Start](#quick-start) +- [Sync Behavior](#sync-behavior) +- [API Reference](#api-reference) +- [Error Handling](#error-handling) +- [Debug Logging](#debug-logging) +- [Known Issues & Improvements](#known-issues--improvements) +- [Examples](#examples) +- [Links](#links) -- 🔧 **Zero-Configuration Extension Loading** - The SQLite Sync extension is automatically loaded and configured for you. - No manual setup required — just access the full [SQLite Sync API](https://github.com/sqliteai/sqlite-sync/blob/main/API.md) directly through the `db` instance. +## Compatibility -- 📱 **Native-Only, Ultra-Fast** - Under the hood, we use OP-SQLite — a low-level, JSI-enabled SQLite engine for React Native With OP-SQLite, database operations run at near-native speed on iOS and Android. +| Requirement | Status / Minimum | +| -------------- | ----------------------------------------------------------------------------------------------------------- | +| React Native | Native projects and Expo development builds | +| iOS | 13.0+ | +| Android | API 26+ | +| Web | Not supported | +| Expo Go | Not supported | +| SQLite engine | [`@op-engineering/op-sqlite`](https://github.com/OP-Engineering/op-sqlite) `^15.1.14` | +| Network status | [`@react-native-community/netinfo`](https://github.com/react-native-netinfo/react-native-netinfo) `^11.0.0` | +| Cloud backend | [SQLite Cloud](https://sqlitecloud.io/) | -## 📚 Table of Contents +Optional Expo dependencies for push mode: -- [Requirements](#-requirements) -- [Installation](#-installation) -- [Quick Start](#-quick-start) -- [API Reference](#-api-reference) - - [SQLiteSyncProvider](#sqlitesyncprovider) - - [Contexts](#contexts) - - [SQLiteDbContext](#sqlitedbcontext) - - [SQLiteSyncStatusContext](#sqlitesyncstatuscontext) - - [SQLiteSyncActionsContext](#sqlitesyncactionscontext) - - [Hooks](#hooks) - - [useSqliteDb](#usesqlitedb) - - [useSyncStatus](#usesyncstatus) - - [useSqliteSync](#usesqlitesync) - - [useTriggerSqliteSync](#usetriggersqlitesync) - - [useOnSqliteSync](#useonsqlitesync) - - [useSqliteSyncQuery](#usesqlitesyncquery) -- [Error Handling](#-error-handling) -- [Debug Logging](#-debug-logging) -- [Examples](#-examples) -- [Links](#-links) +- [`expo-notifications`](https://docs.expo.dev/versions/latest/sdk/notifications/) +- [`expo-constants`](https://docs.expo.dev/versions/latest/sdk/constants/) +- [`expo-application`](https://docs.expo.dev/versions/latest/sdk/application/) +- [`expo-secure-store`](https://docs.expo.dev/versions/latest/sdk/securestore/) +- [`expo-task-manager`](https://docs.expo.dev/versions/latest/sdk/task-manager/) -## 📋 Requirements +Notes: -- iOS **13.0+** -- Android **API 26+** -- [`@op-engineering/op-sqlite`](https://github.com/OP-Engineering/op-sqlite) **^15.0.0** -- [`@react-native-community/netinfo`](https://github.com/react-native-netinfo/react-native-netinfo) **^11.0.0** -- [SQLite Cloud](https://sqlitecloud.io/) account +- `expo-notifications`, `expo-constants`, and `expo-application` are needed for push token registration. +- `expo-task-manager` and `expo-secure-store` are additionally required for `notificationListening="always"`. +- Testing push notifications require a real device. Simulators and emulators are not enough for a full push flow. -> **⚠️ Note:** This library is **native-only** (iOS/Android). +## Choose Your Auth Model -## 📦 Installation +| Auth prop | Use when | Notes | +| ------------- | ---------------------------------------------- | -------------------------------- | +| `apiKey` | Your app uses database-level access | Simpler setup | +| `accessToken` | Your app uses SQLite Cloud access tokens / RLS | Use this for signed-in user auth | -### 1. Install Dependencies +## Choose Your Sync Mode + +| Mode | Best for | Requirements | Tradeoffs | +| --------- | ---------------------------------------------- | ------------------------------- | ---------------------------------------- | +| `polling` | Most apps, easiest setup, predictable behavior | No Expo push packages required | Checks periodically instead of instantly | +| `push` | Apps that need near real-time sync triggers | Expo push setup and permissions | More setup, may fall back to polling | + +## Installation + +### 1. Install Required Dependencies ```bash npm install @sqliteai/sqlite-sync-react-native @op-engineering/op-sqlite @react-native-community/netinfo @@ -64,54 +74,48 @@ npm install @sqliteai/sqlite-sync-react-native @op-engineering/op-sqlite @react- yarn add @sqliteai/sqlite-sync-react-native @op-engineering/op-sqlite @react-native-community/netinfo ``` -### 2. Platform Setup - -#### iOS +Optional Expo packages for push mode: ```bash -cd ios && pod install +npx expo install expo-notifications expo-constants expo-application expo-secure-store expo-task-manager ``` -#### Android - -No additional setup required. Native modules are linked automatically. +### 2. Platform Setup #### Expo -If using Expo, you must use **development builds** (Expo Go is not supported): +If you use Expo, you must use development builds. Expo Go is not supported because this library depends on native modules. -```bash -# Generate native directories -npx expo prebuild +Set Android `minSdkVersion` to `26`: -# Run on iOS/Android -npx expo run:ios -# or -npx expo run:android +```bash +npx expo install expo-build-properties ``` -## 🚀 Quick Start +Add this plugin to `app.json` or `app.config.js`: -### 1. Set Up SQLite Cloud +```json +["expo-build-properties", { "android": { "minSdkVersion": 26 } }] +``` -1. **Create an account** - Sign up at the [SQLite Cloud Dashboard](https://dashboard.sqlitecloud.io/). +## Quick Start -2. **Create a database** - Follow the [database creation guide](https://docs.sqlitecloud.io/docs/create-database). +### 1. Set Up SQLite Cloud - > Ensure your tables have identical schemas in both local and cloud databases. +1. Create an account at the [SQLite Cloud Dashboard](https://dashboard.sqlitecloud.io/). +2. Create a database by following the [database creation guide](https://docs.sqlitecloud.io/docs/create-database). +3. Create your tables in SQLite Cloud. +4. Enable OffSync by following the [OffSync setup guide](https://docs.sqlitecloud.io/docs/offsync#:~:text=in%20the%20cloud.-,Configuring%20OffSync,-You%20can%20enable). +5. Copy your `databaseId` and either your `apiKey` or plan to provide the current signed-in user's `accessToken`. -3. **Enable OffSync** - Configure OffSync by following the [OffSync setup guide](https://docs.sqlitecloud.io/docs/offsync#:~:text=in%20the%20cloud.-,Configuring%20OffSync,-You%20can%20enable). +Schema requirements matter: -4. **Get credentials** - Copy your **connection string** and **API key** from the dashboard. - - Alternatively, you can use [access tokens](https://docs.sqlitecloud.io/docs/access-tokens) for [Row-Level Security](https://docs.sqlitecloud.io/docs/rls). +- Your local table schema must exactly match the cloud table schema. +- For SQLite Sync schema best practices, see [SQLite Sync Best Practices](https://docs.sqlitecloud.io/docs/sqlite-sync-best-practices). ### 2. Wrap Your App -The `SQLiteSyncProvider` needs a `createTableSql` statement for each table you want to sync. This is required because tables must exist before SQLiteSync initialization. +The provider needs a `createTableSql` statement for every table you want synchronized. That SQL is executed locally before sync initialization. ```typescript import { SQLiteSyncProvider } from '@sqliteai/sqlite-sync-react-native'; @@ -119,10 +123,10 @@ import { SQLiteSyncProvider } from '@sqliteai/sqlite-sync-react-native'; export default function App() { return ( ( - 'SELECT * FROM tasks ORDER BY created_at DESC' + } = useSqliteSyncQuery({ + query: 'SELECT * FROM tasks ORDER BY created_at DESC', + arguments: [], + fireOn: [{ table: 'tasks' }], + }); + + if (isLoading) return Loading...; + if (error) return Error: {error.message}; + + return ( + item.id} + renderItem={({ item }) => {item.title}} + /> ); +} +``` - // 2. MANUAL SYNC: Control when to sync (e.g., Pull-to-Refresh) - const { triggerSync, isSyncing } = useTriggerSqliteSync(); +### 4. Write Data With Transactions - // 3. EVENT LISTENING: React to incoming changes (e.g., Show a toast) - useOnSqliteSync(() => { - console.log('✨ New data arrived from the cloud!'); - }); +Use `useSqliteTransaction` for app writes that should trigger reactive queries. - // 4. WRITING DATA: Use the optimized hook (no re-renders on sync) - const { db } = useSqliteDb(); +```typescript +import { useSqliteTransaction } from '@sqliteai/sqlite-sync-react-native'; + +function AddTaskButton() { + const { executeTransaction } = useSqliteTransaction(); - const addTask = useCallback( - async (title: string) => { - if (!db) return; - // Use cloudsync_uuid() for conflict-free IDs - await db.execute( + const addTask = async (title: string) => { + await executeTransaction(async (tx) => { + await tx.execute( 'INSERT INTO tasks (id, title) VALUES (cloudsync_uuid(), ?);', [title] ); - // Optional: Push changes to cloud immediately - triggerSync(); - }, - [db, triggerSync] - ); + }); + }; - if (isLoading) return Loading local DB...; - if (error) return Error: {error.message}; + return