Code: A7. Cross-Device Sync — Supabase auth + bidirectional sync #65

Description

@CraigBuckmaster

Summary

Supabase Auth (Apple/Google/Email), bidirectional sync for all user data, offline queue, first-sign-in migration, account deletion. Premium feature.

Sessions: 4–5 (sequential — each builds on the last)

Prerequisite: Craig creates a Supabase project, configures Apple/Google OAuth providers, provides URL + anon key.

Last updated: April 2026 — corrected against current codebase state. See "Drift Corrections" section at bottom.

Session Breakdown

SessionScopeKey Deliverables
1Supabase Auth (Apple/Google/Email sign-in)Native Apple Sign-In, SecureStore JWT, LoginScreen redesign (Facebook → Apple), deps install
2Sync engine + notes/highlights/bookmarks/topicsMigration v15 (cloud_id, sync_queue extension, soft delete), 20 write functions get sync triggers, push/pull with LWW
3Reading progress + study depth + plans + settings syncUnion merge for progress, additive for depth, LWW for prefs, plan sync, sync status UI
4First-sign-in migration + edge cases + account deletionBulk upload with progress UI, queue compaction, Edge Function for deletion

Architecture

  • Offline-first: Local SQLite is always source of truth. Cloud is backup + sync layer. App never blocks on network.
  • Conflict resolution: Last-write-wins for notes/bookmarks/highlights/topics. Union merge for reading progress (never un-completes). Additive for study depth.
  • Soft deletes: All deletable entities get deleted_at column. Both sides honor soft deletes.
  • Queue-based push: Every local write enqueues to sync_queue. Debounced 3s push. Retry with backoff. Drop after 5 failures.

File Impact

  • 10+ new files (services, stores, tests)
  • 12+ modified files (auth, db, settings, navigation)
  • 6 new dependencies
  • ~75 new tests

Decisions (Confirmed)

  • Drop Facebook auth: Yes — heavy SDK, low value for Bible app demographic
  • Sync bookmarked topics: Yes — lightweight, user expects cross-device
  • Sync reading plans/progress: Yes — plan progress should follow user across devices
  • Sync study sessions: No — high-frequency device-local analytics, not user content
  • Sync flagged content: No — moderation, server-bound via separate mechanism (v14 synced column)

Pre-Session Checklist (Craig's Homework)

  • Create Supabase project at supabase.com
  • Copy SUPABASE_URL and SUPABASE_ANON_KEY
  • Add both to .env, app.json extras, and eas.json build profiles
  • Enable Apple provider in Supabase Auth settings
    • Create Apple Services ID in Apple Developer Console
    • Generate a key for Sign in with Apple
    • Paste credentials into Supabase
  • Enable Google provider in Supabase Auth settings
    • Create OAuth 2.0 Client ID in Google Cloud Console
    • Paste Client ID + Secret into Supabase
  • Confirm scheme: "scripture" in app.json (already set ✓)
  • Confirm whether any production users are on migration v14 already (affects sync_queue strategy)

Full Dev Plan

A7. Cross-Device Sync — Dev Plan (Corrected April 2026)

Scope: Supabase Auth (Apple/Google/Email), bidirectional sync for all user data,
offline queue, first-sign-in migration, account deletion.

Sessions: 4–5 (can't be parallelized — each builds on the last)

Prerequisite: Craig creates a Supabase project at supabase.com, provides the
SUPABASE_URL and SUPABASE_ANON_KEY, and configures Apple/Google OAuth providers
in the Supabase dashboard.


Current State (April 2026)

WhatStatus
authStore.ts178 lines, functional, Supabase client via env vars (process.env.SUPABASE_URL)
LoginScreen.tsx336 lines, Google + Facebook + Email — missing Apple Sign-In
SignUpScreen.tsx295 lines, exists and working
ForgotPasswordScreen.tsx184 lines, exists and working
lib/supabase.tsStub using process.env.SUPABASE_URL pattern, CONFIGURED = !!process.env.SUPABASE_URL
lib/oauthHelpers.tsWorking Google/Facebook OAuth via expo-auth-session
db/user.tsBarrel re-exportuserQueries.ts (496 lines) + userMutations.ts (372 lines)
userDatabase.ts14 migrations, no cloud_id, no sync_state, no soft delete. sync_queue exists (v14) but incomplete schema
Cloud schemaNone — Supabase project doesn't exist yet
TestsauthStore.test.ts exists, supabaseMock.js exists
Dependencies installedexpo-auth-session, expo-web-browser, expo-crypto
Dependencies NOT installed@supabase/supabase-js, expo-secure-store, expo-apple-authentication, @react-native-community/netinfo, @react-native-async-storage/async-storage, react-native-url-polyfill

Session 1: Supabase Auth (Apple/Google/Email Sign-In)

Goal

Users can sign in with Apple, Google, or email/password. Session persists across
app restarts. Sign-out clears session. Account section in MoreMenu shows user info.
Facebook auth removed.

Pre-session (Craig)

  1. Create Supabase project → copy URL + anon key
  2. Add SUPABASE_URL and SUPABASE_ANON_KEY to .env, app.json extras, and eas.json build profiles
  3. In Supabase Dashboard → Authentication → Providers:
    • Enable Apple: add Services ID, Team ID, Key ID, private key
    • Enable Google: add Client ID + secret from Google Cloud Console
    • Enable Email: already on by default
  4. In Apple Developer Console:
    • Create a Services ID for "Sign in with Apple"
    • Register the Supabase callback URL as a redirect URI

1A. Dependencies

cd app/
npx expo install @supabase/supabase-js @react-native-async-storage/async-storage \
react-native-url-polyfill expo-secure-store expo-apple-authentication \
@react-native-community/netinfo

Already installed: expo-auth-session, expo-web-browser, expo-crypto.

expo-apple-authentication is needed for native Apple Sign-In on iOS (the App
Store rejects web-based Apple OAuth — it must use the native credential API).

1B. Files to Modify

lib/supabase.ts — Activate the client

// Changes:// 1. Keep env var pattern (already correct): process.env.SUPABASE_URL// 2. Switch auth storage from AsyncStorage to expo-secure-store// (SecureStore is encrypted; AsyncStorage is not)// 3. Replace the placeholder SupabaseClient interface with actual importimport*asSecureStorefrom'expo-secure-store';constExpoSecureStoreAdapter={getItem: (key: string)=>SecureStore.getItemAsync(key),setItem: (key: string,value: string)=>SecureStore.setItemAsync(key,value),removeItem: (key: string)=>SecureStore.deleteItemAsync(key),};// In createClient options:
auth: {storage: ExpoSecureStoreAdapter,autoRefreshToken: true,persistSession: true,detectSessionInUrl: false,flowType: 'pkce',}

Note:expo-secure-store needs to be installed (see 1A above).

lib/oauthHelpers.ts — Add Apple Sign-In

Add signInWithApple() as a separate function (not a new case in signInWithProvider
Apple uses native credential flow via signInWithIdToken, architecturally different from
the browser-redirect flow used by Google):

exportasyncfunctionsignInWithApple(): Promise<{error?: string}>{constAppleAuthentication=require('expo-apple-authentication');constsupabase=getSupabase();if(!supabase)return{error: 'Auth not available'};try{constcredential=awaitAppleAuthentication.signInAsync({requestedScopes: [AppleAuthentication.AppleAuthenticationScope.FULL_NAME,AppleAuthentication.AppleAuthenticationScope.EMAIL,],});if(!credential.identityToken){return{error: 'No identity token from Apple'};}const{ error }=awaitsupabase.auth.signInWithIdToken({provider: 'apple',token: credential.identityToken,});if(error)return{error: error.message};return{};}catch(err: any){if(err.code==='ERR_REQUEST_CANCELED')return{error: 'Sign-in canceled'};return{error: err.message??'Apple sign-in failed'};}}

stores/authStore.ts — Add Apple, drop Facebook

// Changes:// 1. Add signInWithApple method// 2. Remove signInWithFacebook from interface and implementation// 3. Keep signInWithGoogle and signInWithEmail as-issignInWithApple: async()=>{const{ isSupabaseAvailable }=getAuth();if(!isSupabaseAvailable()){return{error: 'Sign-in requires a development build.'};}set({isLoading: true});try{const{ signInWithApple }=require('../lib/oauthHelpers');returnawaitsignInWithApple();}finally{set({isLoading: false});},},

screens/LoginScreen.tsx — Replace Facebook with Apple

Since SignUpScreen.tsx and ForgotPasswordScreen.tsx already exist with working
navigation routes, the redesign is simpler than originally planned:

  1. Replace Facebook button → Apple button (black bg, white text, Apple icon)
  2. Keep existing Google button
  3. Keep email form + existing navigation to SignUp / ForgotPassword
  4. Add "Continue without signing in" footer (navigation.goBack())
  5. Add privacy reassurance text
  6. Remove signInWithFacebook import/usage

Layout (top to bottom):

┌─────────────────────────────────┐
│ ScreenHeader "Sign In" │
│ │
│ "Sign in to sync your study │
│ data across devices." │
│ │
│ ┌─────────────────────────┐ │
│ │ ◉ Continue with Apple │ │ ← Black button, white text, Apple icon
│ └─────────────────────────┘ │
│ ┌─────────────────────────┐ │
│ │ G Continue with Google│ │ ← White button, dark text
│ └─────────────────────────┘ │
│ │
│ ──────── or ──────── │
│ │
│ [ Email ] │
│ [ Password ] │
│ ┌─────────────────────────┐ │
│ │ Sign In │ │ ← Gold button
│ └─────────────────────────┘ │
│ Forgot password? ← navigates │
│ to ForgotPasswordScreen │
│ │
│ Don't have an account? Sign Up │ ← navigates to SignUpScreen
│ │
│ ───────────────────────────── │
│ Your study data stays on your │
│ device even without an │
│ account. Signing in adds │
│ cross-device sync. │
│ │
│ ┌─────────────────────────┐ │
│ │ Continue without │ │ ← Text button, muted
│ │ signing in │ │
│ └─────────────────────────┘ │
└─────────────────────────────────┘

screens/MoreMenuScreen.tsx — Enhanced auth section

Currently shows user email when signed in, "Sign In" when not. Enhance:

┌──────────────────────────────────────┐
│ SIGNED IN STATE: │
│ │
│ [Avatar] Craig Buckmaster │
│ craig@companionstudy.app │
│ ✓ Synced 2 min ago │ ← sync status line (Session 2)
│ │
│ [Sign Out] │
│ │
│ NOT SIGNED IN: │
│ │
│ [Lock icon] Sign In │
│ Sync your study data │
│ across devices │
│ │
└──────────────────────────────────────┘

1C. App Config Changes

app.json — Add Apple auth entitlement

{
"expo": {
"ios": {
"usesAppleSignIn": true
},
"plugins": [
"expo-apple-authentication",
// ... existing plugins (expo-font, expo-screen-orientation, etc.)
]
}
}

scheme: "scripture" is already set ✓

1D. Tests

__tests__/stores/authStore.test.ts — Extend

Add tests for:

  • signInWithApple dispatches correctly when available
  • signInWithApple returns error in Expo Go
  • Facebook methods are removed
  • Hydrate restores session from SecureStore
  • Sign-out clears SecureStore + local profile
  • Error states propagate correctly

__tests__/screens/LoginScreen.test.tsx — New

describe('LoginScreen',()=>{it('renders Apple, Google, and email sign-in options',()=>{ ... });it('does NOT render Facebook button',()=>{ ... });it('navigates to SignUpScreen on "Sign Up" press',()=>{ ... });it('navigates to ForgotPasswordScreen on "Forgot password" press',()=>{ ... });it('shows error message on failed sign-in',async()=>{ ... });it('navigates back on "Continue without signing in"',()=>{ ... });});

__tests__/lib/supabase.test.ts — New

describe('supabase client',()=>{it('returns null when env vars not set',()=>{ ... });it('creates client singleton when configured',()=>{ ... });it('uses SecureStore adapter for auth storage',()=>{ ... });});

1E. Session 1 Deliverables

DeliverableVerification
Apple Sign-In works on deviceSign in → session persists on restart
Google Sign-In worksSign in → session persists
Email sign-in/sign-up worksCreate account → sign in → session persists
Sign-out clears sessionSign out → MoreMenu shows "Sign In"
Facebook removedNo Facebook button in UI, no Facebook in authStore
Forgot password worksRequest reset → email received
Tests passnpm test -- --testPathPattern="auth|Login|supabase"

Session 2: Sync Engine + Notes/Highlights/Bookmarks/Topics

Goal

All user writes (notes, highlights, bookmarks, collections, note links, topic bookmarks)
are queued for sync. On app launch and periodically, queued changes push to Supabase.
Pull merges cloud changes into local SQLite.

2A. Supabase Cloud Schema

Run in Supabase SQL Editor (one-time setup):

-- ══════════════════════════════════════════════════-- TABLES-- ══════════════════════════════════════════════════CREATETABLEuser_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
verse_ref TEXTNOT NULL,
note_text TEXTNOT NULL,
tags_json TEXT DEFAULT '[]',
collection_id UUID,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATETABLEstudy_collections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
name TEXTNOT NULL,
description TEXT DEFAULT '',
color TEXT DEFAULT '#bfa050',
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
deleted_at TIMESTAMPTZ
);
ALTERTABLE user_notes
ADD CONSTRAINT fk_notes_collection
FOREIGN KEY (collection_id) REFERENCES study_collections(id)
ON DELETESETNULL;
CREATETABLEnote_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
from_note_id UUID NOT NULLREFERENCES user_notes(id) ON DELETE CASCADE,
to_note_id UUID NOT NULLREFERENCES user_notes(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATETABLEbookmarks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
verse_ref TEXTNOT NULL,
label TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATETABLEverse_highlights (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
verse_ref TEXTNOT NULL,
color TEXTNOT NULL,
collection_id TEXT,
note TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
deleted_at TIMESTAMPTZ,
UNIQUE(user_id, verse_ref)
);
CREATETABLEhighlight_collections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
name TEXTNOT NULL,
color TEXTNOT NULL,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATETABLEbookmarked_topics (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
topic_id TEXTNOT NULL,
topic_type TEXTNOT NULL DEFAULT 'official',
cached_title TEXT,
cached_summary TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
deleted_at TIMESTAMPTZ,
UNIQUE(user_id, topic_id, topic_type)
);
-- ══════════════════════════════════════════════════-- ROW LEVEL SECURITY (all tables)-- ══════════════════════════════════════════════════
DO $$
DECLARE
tbl TEXT;
BEGIN
FOR tbl INVALUES
('user_notes'), ('study_collections'), ('note_links'),
('bookmarks'), ('verse_highlights'), ('highlight_collections'),
('bookmarked_topics')
LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl);
EXECUTE format(
'CREATE POLICY "Users own their %1$s" ON %1$I FOR ALL USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid())', tbl);
END LOOP;
END $$;
-- ══════════════════════════════════════════════════-- INDEXES-- ══════════════════════════════════════════════════CREATEINDEXidx_notes_userON user_notes(user_id);
CREATEINDEXidx_notes_updatedON user_notes(user_id, updated_at);
CREATEINDEXidx_collections_userON study_collections(user_id);
CREATEINDEXidx_note_links_userON note_links(user_id);
CREATEINDEXidx_bookmarks_userON bookmarks(user_id);
CREATEINDEXidx_bookmarks_updatedON bookmarks(user_id, created_at);
CREATEINDEXidx_highlights_userON verse_highlights(user_id);
CREATEINDEXidx_highlight_collections_userON highlight_collections(user_id);
CREATEINDEXidx_bookmarked_topics_userON bookmarked_topics(user_id);
-- ══════════════════════════════════════════════════-- AUTO-UPDATE updated_at-- ══════════════════════════════════════════════════CREATE OR REPLACEFUNCTIONupdate_updated_at()
RETURNS TRIGGER AS $$
BEGINNEW.updated_at= now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATETRIGGERtrg_notes_updated BEFORE UPDATEON user_notes
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATETRIGGERtrg_collections_updated BEFORE UPDATEON study_collections
FOR EACH ROW EXECUTE FUNCTION update_updated_at();

2B. Local Database Migration

userDatabase.ts — Migration v15: Sync infrastructure

The existing sync_queue table (from v14) has an incomplete schema. v15 extends it
and adds cloud sync columns to all user tables.

-- cloud_id for all synced tablesALTERTABLE user_notes ADD COLUMN cloud_id TEXT;
ALTERTABLE study_collections ADD COLUMN cloud_id TEXT;
ALTERTABLE note_links ADD COLUMN cloud_id TEXT;
ALTERTABLE bookmarks ADD COLUMN cloud_id TEXT;
ALTERTABLE verse_highlights ADD COLUMN cloud_id TEXT;
ALTERTABLE highlight_collections ADD COLUMN cloud_id TEXT;
ALTERTABLE bookmarked_topics ADD COLUMN cloud_id TEXT;
-- Soft delete supportALTERTABLE user_notes ADD COLUMN deleted_at TEXT;
ALTERTABLE study_collections ADD COLUMN deleted_at TEXT;
ALTERTABLE bookmarks ADD COLUMN deleted_at TEXT;
ALTERTABLE verse_highlights ADD COLUMN deleted_at TEXT;
ALTERTABLE bookmarked_topics ADD COLUMN deleted_at TEXT;
-- Extend existing sync_queue (from v14) with routing columnsALTERTABLE sync_queue ADD COLUMN table_name TEXTNOT NULL DEFAULT '';
ALTERTABLE sync_queue ADD COLUMN local_id TEXTNOT NULL DEFAULT '';
-- Sync state table (last_pull_at, migration flags)CREATETABLEIF NOT EXISTS sync_state (
key TEXTPRIMARY KEY,
value TEXTNOT NULL
);
-- LWW support for preferencesALTERTABLE user_preferences ADD COLUMN updated_at TEXT;
-- Indexes for merge lookupsCREATEUNIQUE INDEXIF NOT EXISTS idx_notes_cloud_id ON user_notes(cloud_id);
CREATEUNIQUE INDEXIF NOT EXISTS idx_collections_cloud_id ON study_collections(cloud_id);
CREATEUNIQUE INDEXIF NOT EXISTS idx_note_links_cloud_id ON note_links(cloud_id);
CREATEUNIQUE INDEXIF NOT EXISTS idx_bookmarks_cloud_id ON bookmarks(cloud_id);
CREATEUNIQUE INDEXIF NOT EXISTS idx_highlights_cloud_id ON verse_highlights(cloud_id);
CREATEUNIQUE INDEXIF NOT EXISTS idx_hl_collections_cloud_id ON highlight_collections(cloud_id);
CREATEUNIQUE INDEXIF NOT EXISTS idx_bookmarked_topics_cloud_id ON bookmarked_topics(cloud_id);

2C. New Files

services/syncEngine.ts — Core sync orchestrator (~400 lines)

The sync engine has three responsibilities:

1. Queue writes — After every local write, enqueue a sync operation:

exportasyncfunctionenqueueSync(tableName: SyncableTable,localId: string|number,operation: 'INSERT'|'UPDATE'|'DELETE',payload: Record<string,any>,): Promise<void>{constdb=getUserDb();awaitdb.runAsync(`INSERT INTO sync_queue (table_name, local_id, operation, payload_json) VALUES (?, ?, ?, ?)`,[tableName,String(localId),operation,JSON.stringify(payload)],);schedulePush();}

2. Push — Flush the queue to Supabase:

exportasyncfunctionpushQueue(): Promise<SyncResult>{constsupabase=getSupabase();if(!supabase)return{pushed: 0,failed: 0};constsession=awaitsupabase.auth.getSession();if(!session.data.session)return{pushed: 0,failed: 0};constdb=getUserDb();constpending=awaitdb.getAllAsync<SyncQueueRow>('SELECT * FROM sync_queue ORDER BY created_at ASC LIMIT 100',);letpushed=0,failed=0;for(constitemofpending){try{constpayload=JSON.parse(item.payload_json);awaitpushItem(supabase,item.table_name,item.operation,payload);awaitdb.runAsync('DELETE FROM sync_queue WHERE id = ?',[item.id]);pushed++;}catch(err){constretries=(item.attempts??0)+1;if(retries>=5){awaitdb.runAsync('DELETE FROM sync_queue WHERE id = ?',[item.id]);logger.error('Sync',`Dropping item after 5 retries`,item);}else{awaitdb.runAsync('UPDATE sync_queue SET attempts = ?, last_error = ? WHERE id = ?',[retries,String(err),item.id],);}failed++;}}return{ pushed, failed };}

3. Pull — Fetch changes since last sync and merge:

exportasyncfunctionpullChanges(): Promise<SyncResult>{constsupabase=getSupabase();if(!supabase)return{pulled: 0,conflicts: 0};constdb=getUserDb();constlastSync=awaitgetSyncState('last_pull_at');constsince=lastSync??'1970-01-01T00:00:00Z';letpulled=0,conflicts=0;for(consttableofSYNCABLE_TABLES){const{ data, error }=awaitsupabase.from(table).select('*').gte('updated_at',since).order('updated_at',{ascending: true});if(error){logger.error('Sync',`Pull failed for ${table}`,error);continue;}for(constrowofdata??[]){constresult=awaitmergeRow(db,table,row);if(result==='merged')pulled++;if(result==='conflict')conflicts++;}}awaitsetSyncState('last_pull_at',newDate().toISOString());return{ pulled, conflicts };}

Merge logic — Last Write Wins:

asyncfunctionmergeRow(db,table,cloudRow): Promise<'merged'|'conflict'|'skipped'>{constlocal=awaitdb.getFirstAsync(`SELECT * FROM ${table} WHERE cloud_id = ?`,[cloudRow.id]);if(!local){awaitinsertFromCloud(db,table,cloudRow);return'merged';}if(cloudRow.deleted_at){awaitdb.runAsync(`DELETE FROM ${table} WHERE cloud_id = ?`,[cloudRow.id]);return'merged';}constcloudTime=newDate(cloudRow.updated_at).getTime();constlocalTime=newDate(local.updated_at).getTime();if(cloudTime>localTime){awaitupdateFromCloud(db,table,cloudRow,local.id);return'merged';}returncloudTime===localTime ? 'skipped' : 'conflict';}

services/syncScheduler.ts — Background sync timing (~80 lines)

// Schedules sync operations:// 1. On app foreground (via AppState listener)// 2. Every 5 minutes while app is active// 3. On NetInfo change from offline → online// 4. Debounced 3s after any local write (via enqueueSync)import{AppState}from'react-native';importNetInfofrom'@react-native-community/netinfo';

stores/syncStore.ts — Sync state for UI (~60 lines)

interfaceSyncState{isSyncing: boolean;lastSyncAt: string|null;pendingCount: number;error: string|null;triggerSync: ()=>Promise<void>;refreshPendingCount: ()=>Promise<void>;}

2D. Modifying db/userMutations.ts — Add Sync Triggers

Note: The original issue referenced db/user.ts. That file was split into
userQueries.ts (reads) and userMutations.ts (writes). All sync triggers
go in userMutations.ts.

Every write function gets a sync enqueue call. The pattern:

exportasyncfunctionsaveNote(verseRef: string,text: string): Promise<number>{constcloudId=generateUUID();constresult=awaitgetUserDb().runAsync("INSERT INTO user_notes (verse_ref, note_text, cloud_id) VALUES (?, ?, ?)",[verseRef,text,cloudId]);constnoteId=result.lastInsertRowId;awaitgetUserDb().runAsync("INSERT INTO notes_fts(rowid, note_text) VALUES (?, ?)",[noteId,text]);enqueueSync('user_notes',noteId,'INSERT',{id: cloudId,verse_ref: verseRef,note_text: text,tags_json: '[]',collection_id: null,}).catch(noop);returnnoteId;}

Soft delete pattern:

exportasyncfunctiondeleteNote(id: number): Promise<void>{constrow=awaitgetUserDb().getFirstAsync<{cloud_id: string|null}>('SELECT cloud_id FROM user_notes WHERE id = ?',[id]);awaitgetUserDb().runAsync("UPDATE user_notes SET deleted_at = datetime('now') WHERE id = ?",[id]);awaitgetUserDb().runAsync("DELETE FROM notes_fts WHERE rowid = ?",[id]);if(row?.cloud_id){enqueueSync('user_notes',id,'DELETE',{id: row.cloud_id}).catch(noop);}}

Complete function list (20 functions — corrected from original 14):

#FunctionTableOperationNotes
1saveNoteuser_notesINSERTGenerate cloud_id
2updateNoteuser_notesUPDATE
3deleteNoteuser_notesDELETE (soft)Set deleted_at, remove from FTS
4updateNoteTagsuser_notesUPDATEAdded — missing from original
5setNoteCollectionuser_notesUPDATEAdded — missing from original
6addBookmarkbookmarksINSERTGenerate cloud_id
7removeBookmarkbookmarksDELETE (soft)
8setHighlightverse_highlightsUPSERTGenerate cloud_id on insert
9removeHighlightverse_highlightsDELETE (soft)
10createCollectionstudy_collectionsINSERTGenerate cloud_id
11updateCollectionstudy_collectionsUPDATE
12deleteCollectionstudy_collectionsDELETE (soft)
13createHighlightCollectionhighlight_collectionsINSERTLocal ID is TEXT — use as cloud_id
14deleteHighlightCollectionhighlight_collectionsDELETE (hard→soft)
15linkNotesnote_linksINSERTGenerate cloud_id
16unlinkNotesnote_linksDELETE (hard)
17bookmarkTopicbookmarked_topicsUPSERTAdded — generate cloud_id
18unbookmarkTopicbookmarked_topicsDELETE (soft)Added

NOT synced (local-only):

  • startStudySession, endStudySession, recordSessionEvent — device-local analytics
  • flagContent — moderation, separate sync mechanism (v14 synced column)
  • upsertAuthProfile, clearAuthProfile — auth state, not user content
  • startPlan, completePlanDay, abandonPlan — synced in Session 3
  • resetToNewUser — dev tool

UUID generation: Use expo-crypto (already installed):

import*asCryptofrom'expo-crypto';exportfunctiongenerateUUID(): string{returnCrypto.randomUUID();}

2E. Modifying db/userQueries.ts — Add Soft Delete Filters

All read queries touching soft-deletable tables must add WHERE deleted_at IS NULL:

FunctionTable
getNotesForChapteruser_notes
getNoteCountuser_notes
getAllNotesuser_notes
searchNotesuser_notes
searchNotesFTSuser_notes
getBookmarksbookmarks
isBookmarkedbookmarks
getHighlightsForChapterverse_highlights
getAllHighlightsverse_highlights
getCollectionsstudy_collections
getCollectionstudy_collections
getNotesInCollectionuser_notes
getCollectionNoteCountsuser_notes
getAllTagsuser_notes
getNotesByTaguser_notes
getLinkedNotesnote_links → user_notes
getReferencingNotesnote_links → user_notes
getBookmarkedTopicsbookmarked_topics
isTopicBookmarkedbookmarked_topics

2F. Tests

__tests__/services/syncEngine.test.ts — New (~250 lines)

describe('syncEngine',()=>{describe('enqueueSync',()=>{it('inserts queue entry with table_name and local_id',async()=>{ ... });it('schedules a push after enqueue',async()=>{ ... });});describe('pushQueue',()=>{it('pushes INSERT operations to Supabase upsert',async()=>{ ... });it('pushes DELETE as soft-delete',async()=>{ ... });it('removes queue entries after success',async()=>{ ... });it('increments attempts on failure',async()=>{ ... });it('drops items after 5 failed attempts',async()=>{ ... });it('does nothing when not authenticated',async()=>{ ... });});describe('pullChanges',()=>{it('fetches rows updated since last_pull_at',async()=>{ ... });it('inserts new cloud rows locally with cloud_id',async()=>{ ... });it('LWW: cloud wins on newer timestamp',async()=>{ ... });it('LWW: local wins on newer timestamp',async()=>{ ... });it('soft-deletes locally when cloud has deleted_at',async()=>{ ... });});});

__tests__/db/userMutations.test.ts — New (~150 lines)

Test each write function enqueues sync correctly:

describe('userMutations sync integration',()=>{it('saveNote enqueues INSERT with cloud_id',async()=>{ ... });it('updateNote enqueues UPDATE',async()=>{ ... });it('deleteNote soft-deletes and enqueues DELETE',async()=>{ ... });it('updateNoteTags enqueues UPDATE',async()=>{ ... });it('bookmarkTopic enqueues UPSERT',async()=>{ ... });// ... one test per write function});

__tests__/db/userQueries.test.ts — New (~80 lines)

describe('userQueries soft-delete filtering',()=>{it('getAllNotes excludes soft-deleted rows',async()=>{ ... });it('getBookmarks excludes soft-deleted rows',async()=>{ ... });it('getBookmarkedTopics excludes soft-deleted rows',async()=>{ ... });// ... one test per query with soft-delete filter});

2G. Session 2 Deliverables

DeliverableVerification
Cloud schema deployedTables visible in Supabase dashboard
Migration v15 runs cleanlyApp launches, cloud_id columns exist
Note CRUD syncsCreate note → appears in Supabase → pull on second device
Highlight CRUD syncsSame
Bookmark CRUD syncsSame
Collection CRUD syncsSame
Topic bookmark syncsSame
Soft deletes workDelete note → deleted_at set → synced → hidden on other device
Offline queue persistsAirplane mode → create note → reconnect → note pushes
LWW conflict resolutionEdit same note on 2 devices → newer wins
Tests passnpm test -- --testPathPattern="sync|user"

Session 3: Reading Progress + Study Depth + Plans + Settings Sync

Goal

Sync reading progress (union merge), study depth (additive), reading plan progress,
and user preferences (LWW).

3A. Additional Cloud Tables

CREATETABLEreading_progress (
user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
book_id TEXTNOT NULL,
chapter_num INTEGERNOT NULL,
completed_at TIMESTAMPTZ,
PRIMARY KEY (user_id, book_id, chapter_num)
);
CREATETABLEstudy_depth (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
chapter_id TEXTNOT NULL,
section_id TEXTNOT NULL,
panel_type TEXTNOT NULL,
first_opened_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(user_id, section_id, panel_type)
);
CREATETABLEuser_preferences (
user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
key TEXTNOT NULL,
value TEXTNOT NULL,
updated_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (user_id, key)
);
CREATETABLEreading_streaks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
dateTEXTNOT NULL,
chapters_read INTEGERNOT NULL DEFAULT 0,
books_touched TEXT,
UNIQUE(user_id, date)
);
CREATETABLEplan_progress (
user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
plan_id TEXTNOT NULL,
day_num INTEGERNOT NULL,
completed_at TIMESTAMPTZ,
PRIMARY KEY (user_id, plan_id, day_num)
);
-- RLS for all new tables
DO $$
DECLARE tbl TEXT;
BEGIN
FOR tbl INVALUES
('reading_progress'), ('study_depth'), ('user_preferences'),
('reading_streaks'), ('plan_progress')
LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl);
EXECUTE format(
'CREATE POLICY "Users own their %1$s" ON %1$I FOR ALL USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid())', tbl);
END LOOP;
END $$;

3B. Special Merge Strategies

Reading Progress — Union Merge

Never un-completes. If either side says completed, it stays completed.
Keep the earliest completed_at timestamp.

Study Depth — Additive Only

Append-only. Cloud accumulates all panels ever opened. Merge = INSERT OR IGNORE.

Reading Plans — Union Merge

Plan progress uses same union logic as reading progress — once a day is completed,
it stays completed.

User Preferences — LWW

Simple key-value pairs with updated_at for conflict resolution.

3C. Sync Triggers for Session 3 Functions

recordVisit in userMutations.ts: Enqueue sync for reading_progress.

completePlanDay in userMutations.ts: Enqueue sync for plan_progress.

settingsStore.ts preference setters: Each setPreference call gets a sync enqueue.

3D. UI: Sync Status in Settings

Add a "Sync" section to SettingsScreen:

┌──────────────────────────────────────┐
│ SYNC │
│ │
│ Status ✓ Up to date │
│ Last sync 2 min ago │
│ Pending 0 changes │
│ │
│ [Sync Now] │
│ │
│ Only visible when signed in. │
└──────────────────────────────────────┘

3E. Session 3 Deliverables

DeliverableVerification
Reading progress syncs (union)Read chapter → mark on device A → appears on device B
Never un-completesDelete cloud row → local stays completed
Study depth syncs (additive)Open panel → record appears in Supabase
Plan progress syncs (union)Complete day on A → appears on B
Preferences sync (LWW)Change font size on A → appears on B
Sync status UI in SettingsShows status, last sync time, pending count
Tests passnpm test -- --testPathPattern="sync|settings"

Session 4: First-Sign-In Migration + Edge Cases + Account Deletion

Goal

Handle the critical first-sign-in scenario (user has months of local data),
edge cases (offline→online, large datasets, stale queues), and App Store-compliant
account deletion.

4A. First-Sign-In Migration

services/initialMigration.ts — New (~150 lines)

When a user signs in for the first time, they may have hundreds of notes, bookmarks,
and reading progress entries with no cloud_id. One-time bulk upload:

  1. Check if migration already happened (sync_state.initial_migration_complete)
  2. Check cloud for existing data (returning user on new device?)
  3. Assign cloud_id to all local rows missing them
  4. Upload in batches of 50 (collections first for FK resolution, then notes, etc.)
  5. Mark complete

Migration UI — SyncMigrationSheet

Bottom-sheet showing progress: "Uploading 47 notes, 312 chapters read..." with
progress bar. Auto-dismisses on completion.

4B. Edge Cases

  • Offline → online flush:pushQueue() processes max 100 items per call, chains until empty
  • Queue compaction: When queue > 200 items, compact by keeping only latest per table+local_id
  • Note links with unmapped IDs: Upload notes before note_links; defer links with unresolved refs
  • Interrupted migration:initial_migration_complete only set on full success; idempotent upserts prevent duplicates on retry
  • Stale INSERT+DELETE pairs: Compaction drops both when INSERT never pushed

4C. Account Deletion

services/accountDeletion.ts — New (~50 lines)

  1. Invoke Supabase Edge Function delete-user-data (service_role deletes all user tables + auth account)
  2. Clear local auth state
  3. Stop sync scheduler
  4. Local study data preserved on device

Supabase Edge Function: delete-user-data

Server-side function using service_role key to delete all user rows across all tables
and delete the auth account. Client-side RLS can't cascade across tables.

Deletion UI in SettingsScreen

"Delete Account" in a Danger Zone section (only visible when signed in).
Two-step confirmation: alert → type "DELETE" to confirm.

4D. Session 4 Deliverables

DeliverableVerification
First sign-in migration worksUser with 6 months data → sign in → all data in Supabase
Returning user on new deviceSign in on device B → all data appears
Migration progress UIBottom sheet shows progress during initial upload
Offline queue compaction500 offline edits → compacted → clean push
Account deletion worksDelete → cloud data gone → local data preserved
Two-step delete confirmationCan't accidentally delete
Edge function deployeddelete-user-data in Supabase Functions
Tests passFull suite green

Complete File Inventory (Corrected)

New Files (10+)

FileLines (est.)Session
services/syncEngine.ts~4002
services/syncScheduler.ts~802
services/initialMigration.ts~1504
services/accountDeletion.ts~504
stores/syncStore.ts~602
__tests__/services/syncEngine.test.ts~3502-3
__tests__/services/syncScheduler.test.ts~602
__tests__/db/userMutations.test.ts~1502
__tests__/db/userQueries.test.ts~802
__tests__/services/initialMigration.test.ts~1204
__tests__/services/accountDeletion.test.ts~604
__tests__/screens/LoginScreen.test.tsx~801

Modified Files (12+)

FileChange SummarySession
lib/supabase.tsActivate, SecureStore adapter, real import1
lib/oauthHelpers.tsAdd signInWithApple()1
stores/authStore.tsAdd Apple, remove Facebook1
screens/LoginScreen.tsxReplace Facebook → Apple, add guest option1
screens/MoreMenuScreen.tsxEnhanced auth section + sync status1, 3
screens/SettingsScreen.tsxSync status section + account deletion3, 4
db/userDatabase.tsMigration v15: cloud_id, sync_queue extension, soft delete2
db/userMutations.tsSync triggers on all 18+ write functions + soft delete2, 3
db/userQueries.tsWHERE deleted_at IS NULL on 19+ queries2
stores/settingsStore.tsSync preference changes3
app.jsonApple auth entitlement + plugin1
types/user.tsAdd cloud_id and deleted_at to interfaces2

New Dependencies (6)

PackagePurpose
@supabase/supabase-jsSupabase client
@react-native-async-storage/async-storageRequired by Supabase (session fallback)
react-native-url-polyfillRequired by Supabase (URL parsing)
expo-secure-storeEncrypted JWT storage
expo-apple-authenticationNative Apple Sign-In
@react-native-community/netinfoNetwork state for sync scheduler

Test Summary (~75 tests)

Test FileTests (est.)
syncEngine.test.ts~30
syncScheduler.test.ts~5
userMutations.test.ts~18
userQueries.test.ts~10
initialMigration.test.ts~8
accountDeletion.test.ts~5
LoginScreen.test.tsx~6
authStore.test.ts (extend)~3

Risk Register

RiskImpactMitigation
Apple rejects without native Apple Sign-InApp Store rejectionUsing expo-apple-authentication (native), not web OAuth
Supabase free tier limitsSync stops at 50K MAUUpgrade to Pro ($25/mo) — covered by revenue model
Large initial migration failsUser data doesn't syncIdempotent upserts; retry on next launch; progress UI
LWW loses edits on simultaneous editUser loses a note editAcceptable for v1; realtime sync (v2) reduces window
expo-secure-store 2KB value limitJWT too largeSupabase JWTs are ~1KB; within limit
NetInfo false positivesSync on captive portalsTimeout + retry with backoff
Soft delete bloatDeleted rows accumulatePeriodic cleanup: hard-delete rows > 30 days old
v14 sync_queue schema mismatchALTER may fail on existing datav15 uses ADD COLUMN with defaults; safe for existing rows

Drift Corrections (April 2026)

This issue was originally written against an older codebase. The following corrections
were applied:

  1. db/user.ts split → now userQueries.ts (reads) + userMutations.ts (writes)
  2. Migrations at v14 (not v8) — sync migration is now v15
  3. sync_queue already exists (v14) — v15 extends it with table_name and local_id
  4. @supabase/supabase-js NOT installed — full dep install list corrected
  5. SignUpScreen.tsx and ForgotPasswordScreen.tsx exist — LoginScreen redesign simplified
  6. 20 write functions (not 14) — updateNoteTags, setNoteCollection, bookmarkTopic, unbookmarkTopic added
  7. highlight_collections.id is TEXT — can use local ID as cloud_id directly
  8. user_preferences needs updated_at — added to v15 migration
  9. supabase.ts uses env vars — kept (better than hardcoded)
  10. Decisions confirmed: Drop Facebook ✓, Sync bookmarked topics ✓, Sync reading plans ✓, Don't sync study sessions ✓

Metadata

Metadata

Labels

premiumPremium tier feature

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions

    , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
     blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
    }
    } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
    })();
    (function(){
    try {
    var __m = "github.com";
    var __re = new RegExp('^' + "github\\.com" + '
    
    Skip to content

    Code: A7. Cross-Device Sync — Supabase auth + bidirectional sync #65

    Description

    @CraigBuckmaster

    Summary

    Supabase Auth (Apple/Google/Email), bidirectional sync for all user data, offline queue, first-sign-in migration, account deletion. Premium feature.

    Sessions: 4–5 (sequential — each builds on the last)

    Prerequisite: Craig creates a Supabase project, configures Apple/Google OAuth providers, provides URL + anon key.

    Last updated: April 2026 — corrected against current codebase state. See "Drift Corrections" section at bottom.

    Session Breakdown

    SessionScopeKey Deliverables
    1Supabase Auth (Apple/Google/Email sign-in)Native Apple Sign-In, SecureStore JWT, LoginScreen redesign (Facebook → Apple), deps install
    2Sync engine + notes/highlights/bookmarks/topicsMigration v15 (cloud_id, sync_queue extension, soft delete), 20 write functions get sync triggers, push/pull with LWW
    3Reading progress + study depth + plans + settings syncUnion merge for progress, additive for depth, LWW for prefs, plan sync, sync status UI
    4First-sign-in migration + edge cases + account deletionBulk upload with progress UI, queue compaction, Edge Function for deletion

    Architecture

    • Offline-first: Local SQLite is always source of truth. Cloud is backup + sync layer. App never blocks on network.
    • Conflict resolution: Last-write-wins for notes/bookmarks/highlights/topics. Union merge for reading progress (never un-completes). Additive for study depth.
    • Soft deletes: All deletable entities get deleted_at column. Both sides honor soft deletes.
    • Queue-based push: Every local write enqueues to sync_queue. Debounced 3s push. Retry with backoff. Drop after 5 failures.

    File Impact

    • 10+ new files (services, stores, tests)
    • 12+ modified files (auth, db, settings, navigation)
    • 6 new dependencies
    • ~75 new tests

    Decisions (Confirmed)

    • Drop Facebook auth: Yes — heavy SDK, low value for Bible app demographic
    • Sync bookmarked topics: Yes — lightweight, user expects cross-device
    • Sync reading plans/progress: Yes — plan progress should follow user across devices
    • Sync study sessions: No — high-frequency device-local analytics, not user content
    • Sync flagged content: No — moderation, server-bound via separate mechanism (v14 synced column)

    Pre-Session Checklist (Craig's Homework)

    • Create Supabase project at supabase.com
    • Copy SUPABASE_URL and SUPABASE_ANON_KEY
    • Add both to .env, app.json extras, and eas.json build profiles
    • Enable Apple provider in Supabase Auth settings
      • Create Apple Services ID in Apple Developer Console
      • Generate a key for Sign in with Apple
      • Paste credentials into Supabase
    • Enable Google provider in Supabase Auth settings
      • Create OAuth 2.0 Client ID in Google Cloud Console
      • Paste Client ID + Secret into Supabase
    • Confirm scheme: "scripture" in app.json (already set ✓)
    • Confirm whether any production users are on migration v14 already (affects sync_queue strategy)

    Full Dev Plan

    A7. Cross-Device Sync — Dev Plan (Corrected April 2026)

    Scope: Supabase Auth (Apple/Google/Email), bidirectional sync for all user data,
    offline queue, first-sign-in migration, account deletion.

    Sessions: 4–5 (can't be parallelized — each builds on the last)

    Prerequisite: Craig creates a Supabase project at supabase.com, provides the
    SUPABASE_URL and SUPABASE_ANON_KEY, and configures Apple/Google OAuth providers
    in the Supabase dashboard.


    Current State (April 2026)

    WhatStatus
    authStore.ts178 lines, functional, Supabase client via env vars (process.env.SUPABASE_URL)
    LoginScreen.tsx336 lines, Google + Facebook + Email — missing Apple Sign-In
    SignUpScreen.tsx295 lines, exists and working
    ForgotPasswordScreen.tsx184 lines, exists and working
    lib/supabase.tsStub using process.env.SUPABASE_URL pattern, CONFIGURED = !!process.env.SUPABASE_URL
    lib/oauthHelpers.tsWorking Google/Facebook OAuth via expo-auth-session
    db/user.tsBarrel re-exportuserQueries.ts (496 lines) + userMutations.ts (372 lines)
    userDatabase.ts14 migrations, no cloud_id, no sync_state, no soft delete. sync_queue exists (v14) but incomplete schema
    Cloud schemaNone — Supabase project doesn't exist yet
    TestsauthStore.test.ts exists, supabaseMock.js exists
    Dependencies installedexpo-auth-session, expo-web-browser, expo-crypto
    Dependencies NOT installed@supabase/supabase-js, expo-secure-store, expo-apple-authentication, @react-native-community/netinfo, @react-native-async-storage/async-storage, react-native-url-polyfill

    Session 1: Supabase Auth (Apple/Google/Email Sign-In)

    Goal

    Users can sign in with Apple, Google, or email/password. Session persists across
    app restarts. Sign-out clears session. Account section in MoreMenu shows user info.
    Facebook auth removed.

    Pre-session (Craig)

    1. Create Supabase project → copy URL + anon key
    2. Add SUPABASE_URL and SUPABASE_ANON_KEY to .env, app.json extras, and eas.json build profiles
    3. In Supabase Dashboard → Authentication → Providers:
      • Enable Apple: add Services ID, Team ID, Key ID, private key
      • Enable Google: add Client ID + secret from Google Cloud Console
      • Enable Email: already on by default
    4. In Apple Developer Console:
      • Create a Services ID for "Sign in with Apple"
      • Register the Supabase callback URL as a redirect URI

    1A. Dependencies

    cd app/
    npx expo install @supabase/supabase-js @react-native-async-storage/async-storage \
    react-native-url-polyfill expo-secure-store expo-apple-authentication \
    @react-native-community/netinfo

    Already installed: expo-auth-session, expo-web-browser, expo-crypto.

    expo-apple-authentication is needed for native Apple Sign-In on iOS (the App
    Store rejects web-based Apple OAuth — it must use the native credential API).

    1B. Files to Modify

    lib/supabase.ts — Activate the client

    // Changes:// 1. Keep env var pattern (already correct): process.env.SUPABASE_URL// 2. Switch auth storage from AsyncStorage to expo-secure-store// (SecureStore is encrypted; AsyncStorage is not)// 3. Replace the placeholder SupabaseClient interface with actual importimport*asSecureStorefrom'expo-secure-store';constExpoSecureStoreAdapter={getItem: (key: string)=>SecureStore.getItemAsync(key),setItem: (key: string,value: string)=>SecureStore.setItemAsync(key,value),removeItem: (key: string)=>SecureStore.deleteItemAsync(key),};// In createClient options:
    auth: {storage: ExpoSecureStoreAdapter,autoRefreshToken: true,persistSession: true,detectSessionInUrl: false,flowType: 'pkce',}

    Note:expo-secure-store needs to be installed (see 1A above).

    lib/oauthHelpers.ts — Add Apple Sign-In

    Add signInWithApple() as a separate function (not a new case in signInWithProvider
    Apple uses native credential flow via signInWithIdToken, architecturally different from
    the browser-redirect flow used by Google):

    exportasyncfunctionsignInWithApple(): Promise<{error?: string}>{constAppleAuthentication=require('expo-apple-authentication');constsupabase=getSupabase();if(!supabase)return{error: 'Auth not available'};try{constcredential=awaitAppleAuthentication.signInAsync({requestedScopes: [AppleAuthentication.AppleAuthenticationScope.FULL_NAME,AppleAuthentication.AppleAuthenticationScope.EMAIL,],});if(!credential.identityToken){return{error: 'No identity token from Apple'};}const{ error }=awaitsupabase.auth.signInWithIdToken({provider: 'apple',token: credential.identityToken,});if(error)return{error: error.message};return{};}catch(err: any){if(err.code==='ERR_REQUEST_CANCELED')return{error: 'Sign-in canceled'};return{error: err.message??'Apple sign-in failed'};}}

    stores/authStore.ts — Add Apple, drop Facebook

    // Changes:// 1. Add signInWithApple method// 2. Remove signInWithFacebook from interface and implementation// 3. Keep signInWithGoogle and signInWithEmail as-issignInWithApple: async()=>{const{ isSupabaseAvailable }=getAuth();if(!isSupabaseAvailable()){return{error: 'Sign-in requires a development build.'};}set({isLoading: true});try{const{ signInWithApple }=require('../lib/oauthHelpers');returnawaitsignInWithApple();}finally{set({isLoading: false});},},

    screens/LoginScreen.tsx — Replace Facebook with Apple

    Since SignUpScreen.tsx and ForgotPasswordScreen.tsx already exist with working
    navigation routes, the redesign is simpler than originally planned:

    1. Replace Facebook button → Apple button (black bg, white text, Apple icon)
    2. Keep existing Google button
    3. Keep email form + existing navigation to SignUp / ForgotPassword
    4. Add "Continue without signing in" footer (navigation.goBack())
    5. Add privacy reassurance text
    6. Remove signInWithFacebook import/usage

    Layout (top to bottom):

    ┌─────────────────────────────────┐
    │ ScreenHeader "Sign In" │
    │ │
    │ "Sign in to sync your study │
    │ data across devices." │
    │ │
    │ ┌─────────────────────────┐ │
    │ │ ◉ Continue with Apple │ │ ← Black button, white text, Apple icon
    │ └─────────────────────────┘ │
    │ ┌─────────────────────────┐ │
    │ │ G Continue with Google│ │ ← White button, dark text
    │ └─────────────────────────┘ │
    │ │
    │ ──────── or ──────── │
    │ │
    │ [ Email ] │
    │ [ Password ] │
    │ ┌─────────────────────────┐ │
    │ │ Sign In │ │ ← Gold button
    │ └─────────────────────────┘ │
    │ Forgot password? ← navigates │
    │ to ForgotPasswordScreen │
    │ │
    │ Don't have an account? Sign Up │ ← navigates to SignUpScreen
    │ │
    │ ───────────────────────────── │
    │ Your study data stays on your │
    │ device even without an │
    │ account. Signing in adds │
    │ cross-device sync. │
    │ │
    │ ┌─────────────────────────┐ │
    │ │ Continue without │ │ ← Text button, muted
    │ │ signing in │ │
    │ └─────────────────────────┘ │
    └─────────────────────────────────┘
    

    screens/MoreMenuScreen.tsx — Enhanced auth section

    Currently shows user email when signed in, "Sign In" when not. Enhance:

    ┌──────────────────────────────────────┐
    │ SIGNED IN STATE: │
    │ │
    │ [Avatar] Craig Buckmaster │
    │ craig@companionstudy.app │
    │ ✓ Synced 2 min ago │ ← sync status line (Session 2)
    │ │
    │ [Sign Out] │
    │ │
    │ NOT SIGNED IN: │
    │ │
    │ [Lock icon] Sign In │
    │ Sync your study data │
    │ across devices │
    │ │
    └──────────────────────────────────────┘
    

    1C. App Config Changes

    app.json — Add Apple auth entitlement

    {
    "expo": {
    "ios": {
    "usesAppleSignIn": true
    },
    "plugins": [
    "expo-apple-authentication",
    // ... existing plugins (expo-font, expo-screen-orientation, etc.)
    ]
    }
    }

    scheme: "scripture" is already set ✓

    1D. Tests

    __tests__/stores/authStore.test.ts — Extend

    Add tests for:

    • signInWithApple dispatches correctly when available
    • signInWithApple returns error in Expo Go
    • Facebook methods are removed
    • Hydrate restores session from SecureStore
    • Sign-out clears SecureStore + local profile
    • Error states propagate correctly

    __tests__/screens/LoginScreen.test.tsx — New

    describe('LoginScreen',()=>{it('renders Apple, Google, and email sign-in options',()=>{ ... });it('does NOT render Facebook button',()=>{ ... });it('navigates to SignUpScreen on "Sign Up" press',()=>{ ... });it('navigates to ForgotPasswordScreen on "Forgot password" press',()=>{ ... });it('shows error message on failed sign-in',async()=>{ ... });it('navigates back on "Continue without signing in"',()=>{ ... });});

    __tests__/lib/supabase.test.ts — New

    describe('supabase client',()=>{it('returns null when env vars not set',()=>{ ... });it('creates client singleton when configured',()=>{ ... });it('uses SecureStore adapter for auth storage',()=>{ ... });});

    1E. Session 1 Deliverables

    DeliverableVerification
    Apple Sign-In works on deviceSign in → session persists on restart
    Google Sign-In worksSign in → session persists
    Email sign-in/sign-up worksCreate account → sign in → session persists
    Sign-out clears sessionSign out → MoreMenu shows "Sign In"
    Facebook removedNo Facebook button in UI, no Facebook in authStore
    Forgot password worksRequest reset → email received
    Tests passnpm test -- --testPathPattern="auth|Login|supabase"

    Session 2: Sync Engine + Notes/Highlights/Bookmarks/Topics

    Goal

    All user writes (notes, highlights, bookmarks, collections, note links, topic bookmarks)
    are queued for sync. On app launch and periodically, queued changes push to Supabase.
    Pull merges cloud changes into local SQLite.

    2A. Supabase Cloud Schema

    Run in Supabase SQL Editor (one-time setup):

    -- ══════════════════════════════════════════════════-- TABLES-- ══════════════════════════════════════════════════CREATETABLEuser_notes (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
    verse_ref TEXTNOT NULL,
    note_text TEXTNOT NULL,
    tags_json TEXT DEFAULT '[]',
    collection_id UUID,
    created_at TIMESTAMPTZ DEFAULT now(),
    updated_at TIMESTAMPTZ DEFAULT now(),
    deleted_at TIMESTAMPTZ
    );
    CREATETABLEstudy_collections (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
    name TEXTNOT NULL,
    description TEXT DEFAULT '',
    color TEXT DEFAULT '#bfa050',
    created_at TIMESTAMPTZ DEFAULT now(),
    updated_at TIMESTAMPTZ DEFAULT now(),
    deleted_at TIMESTAMPTZ
    );
    ALTERTABLE user_notes
    ADD CONSTRAINT fk_notes_collection
    FOREIGN KEY (collection_id) REFERENCES study_collections(id)
    ON DELETESETNULL;
    CREATETABLEnote_links (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
    from_note_id UUID NOT NULLREFERENCES user_notes(id) ON DELETE CASCADE,
    to_note_id UUID NOT NULLREFERENCES user_notes(id) ON DELETE CASCADE,
    created_at TIMESTAMPTZ DEFAULT now()
    );
    CREATETABLEbookmarks (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
    verse_ref TEXTNOT NULL,
    label TEXT,
    created_at TIMESTAMPTZ DEFAULT now(),
    deleted_at TIMESTAMPTZ
    );
    CREATETABLEverse_highlights (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
    verse_ref TEXTNOT NULL,
    color TEXTNOT NULL,
    collection_id TEXT,
    note TEXT,
    created_at TIMESTAMPTZ DEFAULT now(),
    deleted_at TIMESTAMPTZ,
    UNIQUE(user_id, verse_ref)
    );
    CREATETABLEhighlight_collections (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
    name TEXTNOT NULL,
    color TEXTNOT NULL,
    sort_order INTEGER DEFAULT 0,
    created_at TIMESTAMPTZ DEFAULT now()
    );
    CREATETABLEbookmarked_topics (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
    topic_id TEXTNOT NULL,
    topic_type TEXTNOT NULL DEFAULT 'official',
    cached_title TEXT,
    cached_summary TEXT,
    created_at TIMESTAMPTZ DEFAULT now(),
    deleted_at TIMESTAMPTZ,
    UNIQUE(user_id, topic_id, topic_type)
    );
    -- ══════════════════════════════════════════════════-- ROW LEVEL SECURITY (all tables)-- ══════════════════════════════════════════════════
    DO $$
    DECLARE
    tbl TEXT;
    BEGIN
    FOR tbl INVALUES
    ('user_notes'), ('study_collections'), ('note_links'),
    ('bookmarks'), ('verse_highlights'), ('highlight_collections'),
    ('bookmarked_topics')
    LOOP
    EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl);
    EXECUTE format(
    'CREATE POLICY "Users own their %1$s" ON %1$I FOR ALL USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid())', tbl);
    END LOOP;
    END $$;
    -- ══════════════════════════════════════════════════-- INDEXES-- ══════════════════════════════════════════════════CREATEINDEXidx_notes_userON user_notes(user_id);
    CREATEINDEXidx_notes_updatedON user_notes(user_id, updated_at);
    CREATEINDEXidx_collections_userON study_collections(user_id);
    CREATEINDEXidx_note_links_userON note_links(user_id);
    CREATEINDEXidx_bookmarks_userON bookmarks(user_id);
    CREATEINDEXidx_bookmarks_updatedON bookmarks(user_id, created_at);
    CREATEINDEXidx_highlights_userON verse_highlights(user_id);
    CREATEINDEXidx_highlight_collections_userON highlight_collections(user_id);
    CREATEINDEXidx_bookmarked_topics_userON bookmarked_topics(user_id);
    -- ══════════════════════════════════════════════════-- AUTO-UPDATE updated_at-- ══════════════════════════════════════════════════CREATE OR REPLACEFUNCTIONupdate_updated_at()
    RETURNS TRIGGER AS $$
    BEGINNEW.updated_at= now();
    RETURN NEW;
    END;
    $$ LANGUAGE plpgsql;
    CREATETRIGGERtrg_notes_updated BEFORE UPDATEON user_notes
    FOR EACH ROW EXECUTE FUNCTION update_updated_at();
    CREATETRIGGERtrg_collections_updated BEFORE UPDATEON study_collections
    FOR EACH ROW EXECUTE FUNCTION update_updated_at();

    2B. Local Database Migration

    userDatabase.ts — Migration v15: Sync infrastructure

    The existing sync_queue table (from v14) has an incomplete schema. v15 extends it
    and adds cloud sync columns to all user tables.

    -- cloud_id for all synced tablesALTERTABLE user_notes ADD COLUMN cloud_id TEXT;
    ALTERTABLE study_collections ADD COLUMN cloud_id TEXT;
    ALTERTABLE note_links ADD COLUMN cloud_id TEXT;
    ALTERTABLE bookmarks ADD COLUMN cloud_id TEXT;
    ALTERTABLE verse_highlights ADD COLUMN cloud_id TEXT;
    ALTERTABLE highlight_collections ADD COLUMN cloud_id TEXT;
    ALTERTABLE bookmarked_topics ADD COLUMN cloud_id TEXT;
    -- Soft delete supportALTERTABLE user_notes ADD COLUMN deleted_at TEXT;
    ALTERTABLE study_collections ADD COLUMN deleted_at TEXT;
    ALTERTABLE bookmarks ADD COLUMN deleted_at TEXT;
    ALTERTABLE verse_highlights ADD COLUMN deleted_at TEXT;
    ALTERTABLE bookmarked_topics ADD COLUMN deleted_at TEXT;
    -- Extend existing sync_queue (from v14) with routing columnsALTERTABLE sync_queue ADD COLUMN table_name TEXTNOT NULL DEFAULT '';
    ALTERTABLE sync_queue ADD COLUMN local_id TEXTNOT NULL DEFAULT '';
    -- Sync state table (last_pull_at, migration flags)CREATETABLEIF NOT EXISTS sync_state (
    key TEXTPRIMARY KEY,
    value TEXTNOT NULL
    );
    -- LWW support for preferencesALTERTABLE user_preferences ADD COLUMN updated_at TEXT;
    -- Indexes for merge lookupsCREATEUNIQUE INDEXIF NOT EXISTS idx_notes_cloud_id ON user_notes(cloud_id);
    CREATEUNIQUE INDEXIF NOT EXISTS idx_collections_cloud_id ON study_collections(cloud_id);
    CREATEUNIQUE INDEXIF NOT EXISTS idx_note_links_cloud_id ON note_links(cloud_id);
    CREATEUNIQUE INDEXIF NOT EXISTS idx_bookmarks_cloud_id ON bookmarks(cloud_id);
    CREATEUNIQUE INDEXIF NOT EXISTS idx_highlights_cloud_id ON verse_highlights(cloud_id);
    CREATEUNIQUE INDEXIF NOT EXISTS idx_hl_collections_cloud_id ON highlight_collections(cloud_id);
    CREATEUNIQUE INDEXIF NOT EXISTS idx_bookmarked_topics_cloud_id ON bookmarked_topics(cloud_id);

    2C. New Files

    services/syncEngine.ts — Core sync orchestrator (~400 lines)

    The sync engine has three responsibilities:

    1. Queue writes — After every local write, enqueue a sync operation:

    exportasyncfunctionenqueueSync(tableName: SyncableTable,localId: string|number,operation: 'INSERT'|'UPDATE'|'DELETE',payload: Record<string,any>,): Promise<void>{constdb=getUserDb();awaitdb.runAsync(`INSERT INTO sync_queue (table_name, local_id, operation, payload_json) VALUES (?, ?, ?, ?)`,[tableName,String(localId),operation,JSON.stringify(payload)],);schedulePush();}

    2. Push — Flush the queue to Supabase:

    exportasyncfunctionpushQueue(): Promise<SyncResult>{constsupabase=getSupabase();if(!supabase)return{pushed: 0,failed: 0};constsession=awaitsupabase.auth.getSession();if(!session.data.session)return{pushed: 0,failed: 0};constdb=getUserDb();constpending=awaitdb.getAllAsync<SyncQueueRow>('SELECT * FROM sync_queue ORDER BY created_at ASC LIMIT 100',);letpushed=0,failed=0;for(constitemofpending){try{constpayload=JSON.parse(item.payload_json);awaitpushItem(supabase,item.table_name,item.operation,payload);awaitdb.runAsync('DELETE FROM sync_queue WHERE id = ?',[item.id]);pushed++;}catch(err){constretries=(item.attempts??0)+1;if(retries>=5){awaitdb.runAsync('DELETE FROM sync_queue WHERE id = ?',[item.id]);logger.error('Sync',`Dropping item after 5 retries`,item);}else{awaitdb.runAsync('UPDATE sync_queue SET attempts = ?, last_error = ? WHERE id = ?',[retries,String(err),item.id],);}failed++;}}return{ pushed, failed };}

    3. Pull — Fetch changes since last sync and merge:

    exportasyncfunctionpullChanges(): Promise<SyncResult>{constsupabase=getSupabase();if(!supabase)return{pulled: 0,conflicts: 0};constdb=getUserDb();constlastSync=awaitgetSyncState('last_pull_at');constsince=lastSync??'1970-01-01T00:00:00Z';letpulled=0,conflicts=0;for(consttableofSYNCABLE_TABLES){const{ data, error }=awaitsupabase.from(table).select('*').gte('updated_at',since).order('updated_at',{ascending: true});if(error){logger.error('Sync',`Pull failed for ${table}`,error);continue;}for(constrowofdata??[]){constresult=awaitmergeRow(db,table,row);if(result==='merged')pulled++;if(result==='conflict')conflicts++;}}awaitsetSyncState('last_pull_at',newDate().toISOString());return{ pulled, conflicts };}

    Merge logic — Last Write Wins:

    asyncfunctionmergeRow(db,table,cloudRow): Promise<'merged'|'conflict'|'skipped'>{constlocal=awaitdb.getFirstAsync(`SELECT * FROM ${table} WHERE cloud_id = ?`,[cloudRow.id]);if(!local){awaitinsertFromCloud(db,table,cloudRow);return'merged';}if(cloudRow.deleted_at){awaitdb.runAsync(`DELETE FROM ${table} WHERE cloud_id = ?`,[cloudRow.id]);return'merged';}constcloudTime=newDate(cloudRow.updated_at).getTime();constlocalTime=newDate(local.updated_at).getTime();if(cloudTime>localTime){awaitupdateFromCloud(db,table,cloudRow,local.id);return'merged';}returncloudTime===localTime ? 'skipped' : 'conflict';}

    services/syncScheduler.ts — Background sync timing (~80 lines)

    // Schedules sync operations:// 1. On app foreground (via AppState listener)// 2. Every 5 minutes while app is active// 3. On NetInfo change from offline → online// 4. Debounced 3s after any local write (via enqueueSync)import{AppState}from'react-native';importNetInfofrom'@react-native-community/netinfo';

    stores/syncStore.ts — Sync state for UI (~60 lines)

    interfaceSyncState{isSyncing: boolean;lastSyncAt: string|null;pendingCount: number;error: string|null;triggerSync: ()=>Promise<void>;refreshPendingCount: ()=>Promise<void>;}

    2D. Modifying db/userMutations.ts — Add Sync Triggers

    Note: The original issue referenced db/user.ts. That file was split into
    userQueries.ts (reads) and userMutations.ts (writes). All sync triggers
    go in userMutations.ts.

    Every write function gets a sync enqueue call. The pattern:

    exportasyncfunctionsaveNote(verseRef: string,text: string): Promise<number>{constcloudId=generateUUID();constresult=awaitgetUserDb().runAsync("INSERT INTO user_notes (verse_ref, note_text, cloud_id) VALUES (?, ?, ?)",[verseRef,text,cloudId]);constnoteId=result.lastInsertRowId;awaitgetUserDb().runAsync("INSERT INTO notes_fts(rowid, note_text) VALUES (?, ?)",[noteId,text]);enqueueSync('user_notes',noteId,'INSERT',{id: cloudId,verse_ref: verseRef,note_text: text,tags_json: '[]',collection_id: null,}).catch(noop);returnnoteId;}

    Soft delete pattern:

    exportasyncfunctiondeleteNote(id: number): Promise<void>{constrow=awaitgetUserDb().getFirstAsync<{cloud_id: string|null}>('SELECT cloud_id FROM user_notes WHERE id = ?',[id]);awaitgetUserDb().runAsync("UPDATE user_notes SET deleted_at = datetime('now') WHERE id = ?",[id]);awaitgetUserDb().runAsync("DELETE FROM notes_fts WHERE rowid = ?",[id]);if(row?.cloud_id){enqueueSync('user_notes',id,'DELETE',{id: row.cloud_id}).catch(noop);}}

    Complete function list (20 functions — corrected from original 14):

    #FunctionTableOperationNotes
    1saveNoteuser_notesINSERTGenerate cloud_id
    2updateNoteuser_notesUPDATE
    3deleteNoteuser_notesDELETE (soft)Set deleted_at, remove from FTS
    4updateNoteTagsuser_notesUPDATEAdded — missing from original
    5setNoteCollectionuser_notesUPDATEAdded — missing from original
    6addBookmarkbookmarksINSERTGenerate cloud_id
    7removeBookmarkbookmarksDELETE (soft)
    8setHighlightverse_highlightsUPSERTGenerate cloud_id on insert
    9removeHighlightverse_highlightsDELETE (soft)
    10createCollectionstudy_collectionsINSERTGenerate cloud_id
    11updateCollectionstudy_collectionsUPDATE
    12deleteCollectionstudy_collectionsDELETE (soft)
    13createHighlightCollectionhighlight_collectionsINSERTLocal ID is TEXT — use as cloud_id
    14deleteHighlightCollectionhighlight_collectionsDELETE (hard→soft)
    15linkNotesnote_linksINSERTGenerate cloud_id
    16unlinkNotesnote_linksDELETE (hard)
    17bookmarkTopicbookmarked_topicsUPSERTAdded — generate cloud_id
    18unbookmarkTopicbookmarked_topicsDELETE (soft)Added

    NOT synced (local-only):

    • startStudySession, endStudySession, recordSessionEvent — device-local analytics
    • flagContent — moderation, separate sync mechanism (v14 synced column)
    • upsertAuthProfile, clearAuthProfile — auth state, not user content
    • startPlan, completePlanDay, abandonPlan — synced in Session 3
    • resetToNewUser — dev tool

    UUID generation: Use expo-crypto (already installed):

    import*asCryptofrom'expo-crypto';exportfunctiongenerateUUID(): string{returnCrypto.randomUUID();}

    2E. Modifying db/userQueries.ts — Add Soft Delete Filters

    All read queries touching soft-deletable tables must add WHERE deleted_at IS NULL:

    FunctionTable
    getNotesForChapteruser_notes
    getNoteCountuser_notes
    getAllNotesuser_notes
    searchNotesuser_notes
    searchNotesFTSuser_notes
    getBookmarksbookmarks
    isBookmarkedbookmarks
    getHighlightsForChapterverse_highlights
    getAllHighlightsverse_highlights
    getCollectionsstudy_collections
    getCollectionstudy_collections
    getNotesInCollectionuser_notes
    getCollectionNoteCountsuser_notes
    getAllTagsuser_notes
    getNotesByTaguser_notes
    getLinkedNotesnote_links → user_notes
    getReferencingNotesnote_links → user_notes
    getBookmarkedTopicsbookmarked_topics
    isTopicBookmarkedbookmarked_topics

    2F. Tests

    __tests__/services/syncEngine.test.ts — New (~250 lines)

    describe('syncEngine',()=>{describe('enqueueSync',()=>{it('inserts queue entry with table_name and local_id',async()=>{ ... });it('schedules a push after enqueue',async()=>{ ... });});describe('pushQueue',()=>{it('pushes INSERT operations to Supabase upsert',async()=>{ ... });it('pushes DELETE as soft-delete',async()=>{ ... });it('removes queue entries after success',async()=>{ ... });it('increments attempts on failure',async()=>{ ... });it('drops items after 5 failed attempts',async()=>{ ... });it('does nothing when not authenticated',async()=>{ ... });});describe('pullChanges',()=>{it('fetches rows updated since last_pull_at',async()=>{ ... });it('inserts new cloud rows locally with cloud_id',async()=>{ ... });it('LWW: cloud wins on newer timestamp',async()=>{ ... });it('LWW: local wins on newer timestamp',async()=>{ ... });it('soft-deletes locally when cloud has deleted_at',async()=>{ ... });});});

    __tests__/db/userMutations.test.ts — New (~150 lines)

    Test each write function enqueues sync correctly:

    describe('userMutations sync integration',()=>{it('saveNote enqueues INSERT with cloud_id',async()=>{ ... });it('updateNote enqueues UPDATE',async()=>{ ... });it('deleteNote soft-deletes and enqueues DELETE',async()=>{ ... });it('updateNoteTags enqueues UPDATE',async()=>{ ... });it('bookmarkTopic enqueues UPSERT',async()=>{ ... });// ... one test per write function});

    __tests__/db/userQueries.test.ts — New (~80 lines)

    describe('userQueries soft-delete filtering',()=>{it('getAllNotes excludes soft-deleted rows',async()=>{ ... });it('getBookmarks excludes soft-deleted rows',async()=>{ ... });it('getBookmarkedTopics excludes soft-deleted rows',async()=>{ ... });// ... one test per query with soft-delete filter});

    2G. Session 2 Deliverables

    DeliverableVerification
    Cloud schema deployedTables visible in Supabase dashboard
    Migration v15 runs cleanlyApp launches, cloud_id columns exist
    Note CRUD syncsCreate note → appears in Supabase → pull on second device
    Highlight CRUD syncsSame
    Bookmark CRUD syncsSame
    Collection CRUD syncsSame
    Topic bookmark syncsSame
    Soft deletes workDelete note → deleted_at set → synced → hidden on other device
    Offline queue persistsAirplane mode → create note → reconnect → note pushes
    LWW conflict resolutionEdit same note on 2 devices → newer wins
    Tests passnpm test -- --testPathPattern="sync|user"

    Session 3: Reading Progress + Study Depth + Plans + Settings Sync

    Goal

    Sync reading progress (union merge), study depth (additive), reading plan progress,
    and user preferences (LWW).

    3A. Additional Cloud Tables

    CREATETABLEreading_progress (
    user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
    book_id TEXTNOT NULL,
    chapter_num INTEGERNOT NULL,
    completed_at TIMESTAMPTZ,
    PRIMARY KEY (user_id, book_id, chapter_num)
    );
    CREATETABLEstudy_depth (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
    chapter_id TEXTNOT NULL,
    section_id TEXTNOT NULL,
    panel_type TEXTNOT NULL,
    first_opened_at TIMESTAMPTZ DEFAULT now(),
    UNIQUE(user_id, section_id, panel_type)
    );
    CREATETABLEuser_preferences (
    user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
    key TEXTNOT NULL,
    value TEXTNOT NULL,
    updated_at TIMESTAMPTZ DEFAULT now(),
    PRIMARY KEY (user_id, key)
    );
    CREATETABLEreading_streaks (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
    dateTEXTNOT NULL,
    chapters_read INTEGERNOT NULL DEFAULT 0,
    books_touched TEXT,
    UNIQUE(user_id, date)
    );
    CREATETABLEplan_progress (
    user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
    plan_id TEXTNOT NULL,
    day_num INTEGERNOT NULL,
    completed_at TIMESTAMPTZ,
    PRIMARY KEY (user_id, plan_id, day_num)
    );
    -- RLS for all new tables
    DO $$
    DECLARE tbl TEXT;
    BEGIN
    FOR tbl INVALUES
    ('reading_progress'), ('study_depth'), ('user_preferences'),
    ('reading_streaks'), ('plan_progress')
    LOOP
    EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl);
    EXECUTE format(
    'CREATE POLICY "Users own their %1$s" ON %1$I FOR ALL USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid())', tbl);
    END LOOP;
    END $$;

    3B. Special Merge Strategies

    Reading Progress — Union Merge

    Never un-completes. If either side says completed, it stays completed.
    Keep the earliest completed_at timestamp.

    Study Depth — Additive Only

    Append-only. Cloud accumulates all panels ever opened. Merge = INSERT OR IGNORE.

    Reading Plans — Union Merge

    Plan progress uses same union logic as reading progress — once a day is completed,
    it stays completed.

    User Preferences — LWW

    Simple key-value pairs with updated_at for conflict resolution.

    3C. Sync Triggers for Session 3 Functions

    recordVisit in userMutations.ts: Enqueue sync for reading_progress.

    completePlanDay in userMutations.ts: Enqueue sync for plan_progress.

    settingsStore.ts preference setters: Each setPreference call gets a sync enqueue.

    3D. UI: Sync Status in Settings

    Add a "Sync" section to SettingsScreen:

    ┌──────────────────────────────────────┐
    │ SYNC │
    │ │
    │ Status ✓ Up to date │
    │ Last sync 2 min ago │
    │ Pending 0 changes │
    │ │
    │ [Sync Now] │
    │ │
    │ Only visible when signed in. │
    └──────────────────────────────────────┘
    

    3E. Session 3 Deliverables

    DeliverableVerification
    Reading progress syncs (union)Read chapter → mark on device A → appears on device B
    Never un-completesDelete cloud row → local stays completed
    Study depth syncs (additive)Open panel → record appears in Supabase
    Plan progress syncs (union)Complete day on A → appears on B
    Preferences sync (LWW)Change font size on A → appears on B
    Sync status UI in SettingsShows status, last sync time, pending count
    Tests passnpm test -- --testPathPattern="sync|settings"

    Session 4: First-Sign-In Migration + Edge Cases + Account Deletion

    Goal

    Handle the critical first-sign-in scenario (user has months of local data),
    edge cases (offline→online, large datasets, stale queues), and App Store-compliant
    account deletion.

    4A. First-Sign-In Migration

    services/initialMigration.ts — New (~150 lines)

    When a user signs in for the first time, they may have hundreds of notes, bookmarks,
    and reading progress entries with no cloud_id. One-time bulk upload:

    1. Check if migration already happened (sync_state.initial_migration_complete)
    2. Check cloud for existing data (returning user on new device?)
    3. Assign cloud_id to all local rows missing them
    4. Upload in batches of 50 (collections first for FK resolution, then notes, etc.)
    5. Mark complete

    Migration UI — SyncMigrationSheet

    Bottom-sheet showing progress: "Uploading 47 notes, 312 chapters read..." with
    progress bar. Auto-dismisses on completion.

    4B. Edge Cases

    • Offline → online flush:pushQueue() processes max 100 items per call, chains until empty
    • Queue compaction: When queue > 200 items, compact by keeping only latest per table+local_id
    • Note links with unmapped IDs: Upload notes before note_links; defer links with unresolved refs
    • Interrupted migration:initial_migration_complete only set on full success; idempotent upserts prevent duplicates on retry
    • Stale INSERT+DELETE pairs: Compaction drops both when INSERT never pushed

    4C. Account Deletion

    services/accountDeletion.ts — New (~50 lines)

    1. Invoke Supabase Edge Function delete-user-data (service_role deletes all user tables + auth account)
    2. Clear local auth state
    3. Stop sync scheduler
    4. Local study data preserved on device

    Supabase Edge Function: delete-user-data

    Server-side function using service_role key to delete all user rows across all tables
    and delete the auth account. Client-side RLS can't cascade across tables.

    Deletion UI in SettingsScreen

    "Delete Account" in a Danger Zone section (only visible when signed in).
    Two-step confirmation: alert → type "DELETE" to confirm.

    4D. Session 4 Deliverables

    DeliverableVerification
    First sign-in migration worksUser with 6 months data → sign in → all data in Supabase
    Returning user on new deviceSign in on device B → all data appears
    Migration progress UIBottom sheet shows progress during initial upload
    Offline queue compaction500 offline edits → compacted → clean push
    Account deletion worksDelete → cloud data gone → local data preserved
    Two-step delete confirmationCan't accidentally delete
    Edge function deployeddelete-user-data in Supabase Functions
    Tests passFull suite green

    Complete File Inventory (Corrected)

    New Files (10+)

    FileLines (est.)Session
    services/syncEngine.ts~4002
    services/syncScheduler.ts~802
    services/initialMigration.ts~1504
    services/accountDeletion.ts~504
    stores/syncStore.ts~602
    __tests__/services/syncEngine.test.ts~3502-3
    __tests__/services/syncScheduler.test.ts~602
    __tests__/db/userMutations.test.ts~1502
    __tests__/db/userQueries.test.ts~802
    __tests__/services/initialMigration.test.ts~1204
    __tests__/services/accountDeletion.test.ts~604
    __tests__/screens/LoginScreen.test.tsx~801

    Modified Files (12+)

    FileChange SummarySession
    lib/supabase.tsActivate, SecureStore adapter, real import1
    lib/oauthHelpers.tsAdd signInWithApple()1
    stores/authStore.tsAdd Apple, remove Facebook1
    screens/LoginScreen.tsxReplace Facebook → Apple, add guest option1
    screens/MoreMenuScreen.tsxEnhanced auth section + sync status1, 3
    screens/SettingsScreen.tsxSync status section + account deletion3, 4
    db/userDatabase.tsMigration v15: cloud_id, sync_queue extension, soft delete2
    db/userMutations.tsSync triggers on all 18+ write functions + soft delete2, 3
    db/userQueries.tsWHERE deleted_at IS NULL on 19+ queries2
    stores/settingsStore.tsSync preference changes3
    app.jsonApple auth entitlement + plugin1
    types/user.tsAdd cloud_id and deleted_at to interfaces2

    New Dependencies (6)

    PackagePurpose
    @supabase/supabase-jsSupabase client
    @react-native-async-storage/async-storageRequired by Supabase (session fallback)
    react-native-url-polyfillRequired by Supabase (URL parsing)
    expo-secure-storeEncrypted JWT storage
    expo-apple-authenticationNative Apple Sign-In
    @react-native-community/netinfoNetwork state for sync scheduler

    Test Summary (~75 tests)

    Test FileTests (est.)
    syncEngine.test.ts~30
    syncScheduler.test.ts~5
    userMutations.test.ts~18
    userQueries.test.ts~10
    initialMigration.test.ts~8
    accountDeletion.test.ts~5
    LoginScreen.test.tsx~6
    authStore.test.ts (extend)~3

    Risk Register

    RiskImpactMitigation
    Apple rejects without native Apple Sign-InApp Store rejectionUsing expo-apple-authentication (native), not web OAuth
    Supabase free tier limitsSync stops at 50K MAUUpgrade to Pro ($25/mo) — covered by revenue model
    Large initial migration failsUser data doesn't syncIdempotent upserts; retry on next launch; progress UI
    LWW loses edits on simultaneous editUser loses a note editAcceptable for v1; realtime sync (v2) reduces window
    expo-secure-store 2KB value limitJWT too largeSupabase JWTs are ~1KB; within limit
    NetInfo false positivesSync on captive portalsTimeout + retry with backoff
    Soft delete bloatDeleted rows accumulatePeriodic cleanup: hard-delete rows > 30 days old
    v14 sync_queue schema mismatchALTER may fail on existing datav15 uses ADD COLUMN with defaults; safe for existing rows

    Drift Corrections (April 2026)

    This issue was originally written against an older codebase. The following corrections
    were applied:

    1. db/user.ts split → now userQueries.ts (reads) + userMutations.ts (writes)
    2. Migrations at v14 (not v8) — sync migration is now v15
    3. sync_queue already exists (v14) — v15 extends it with table_name and local_id
    4. @supabase/supabase-js NOT installed — full dep install list corrected
    5. SignUpScreen.tsx and ForgotPasswordScreen.tsx exist — LoginScreen redesign simplified
    6. 20 write functions (not 14) — updateNoteTags, setNoteCollection, bookmarkTopic, unbookmarkTopic added
    7. highlight_collections.id is TEXT — can use local ID as cloud_id directly
    8. user_preferences needs updated_at — added to v15 migration
    9. supabase.ts uses env vars — kept (better than hardcoded)
    10. Decisions confirmed: Drop Facebook ✓, Sync bookmarked topics ✓, Sync reading plans ✓, Don't sync study sessions ✓

    Metadata

    Metadata

    Labels

    premiumPremium tier feature

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
      Skip to content

      Code: A7. Cross-Device Sync — Supabase auth + bidirectional sync #65

      Description

      @CraigBuckmaster

      Summary

      Supabase Auth (Apple/Google/Email), bidirectional sync for all user data, offline queue, first-sign-in migration, account deletion. Premium feature.

      Sessions: 4–5 (sequential — each builds on the last)

      Prerequisite: Craig creates a Supabase project, configures Apple/Google OAuth providers, provides URL + anon key.

      Last updated: April 2026 — corrected against current codebase state. See "Drift Corrections" section at bottom.

      Session Breakdown

      SessionScopeKey Deliverables
      1Supabase Auth (Apple/Google/Email sign-in)Native Apple Sign-In, SecureStore JWT, LoginScreen redesign (Facebook → Apple), deps install
      2Sync engine + notes/highlights/bookmarks/topicsMigration v15 (cloud_id, sync_queue extension, soft delete), 20 write functions get sync triggers, push/pull with LWW
      3Reading progress + study depth + plans + settings syncUnion merge for progress, additive for depth, LWW for prefs, plan sync, sync status UI
      4First-sign-in migration + edge cases + account deletionBulk upload with progress UI, queue compaction, Edge Function for deletion

      Architecture

      • Offline-first: Local SQLite is always source of truth. Cloud is backup + sync layer. App never blocks on network.
      • Conflict resolution: Last-write-wins for notes/bookmarks/highlights/topics. Union merge for reading progress (never un-completes). Additive for study depth.
      • Soft deletes: All deletable entities get deleted_at column. Both sides honor soft deletes.
      • Queue-based push: Every local write enqueues to sync_queue. Debounced 3s push. Retry with backoff. Drop after 5 failures.

      File Impact

      • 10+ new files (services, stores, tests)
      • 12+ modified files (auth, db, settings, navigation)
      • 6 new dependencies
      • ~75 new tests

      Decisions (Confirmed)

      • Drop Facebook auth: Yes — heavy SDK, low value for Bible app demographic
      • Sync bookmarked topics: Yes — lightweight, user expects cross-device
      • Sync reading plans/progress: Yes — plan progress should follow user across devices
      • Sync study sessions: No — high-frequency device-local analytics, not user content
      • Sync flagged content: No — moderation, server-bound via separate mechanism (v14 synced column)

      Pre-Session Checklist (Craig's Homework)

      • Create Supabase project at supabase.com
      • Copy SUPABASE_URL and SUPABASE_ANON_KEY
      • Add both to .env, app.json extras, and eas.json build profiles
      • Enable Apple provider in Supabase Auth settings
        • Create Apple Services ID in Apple Developer Console
        • Generate a key for Sign in with Apple
        • Paste credentials into Supabase
      • Enable Google provider in Supabase Auth settings
        • Create OAuth 2.0 Client ID in Google Cloud Console
        • Paste Client ID + Secret into Supabase
      • Confirm scheme: "scripture" in app.json (already set ✓)
      • Confirm whether any production users are on migration v14 already (affects sync_queue strategy)

      Full Dev Plan

      A7. Cross-Device Sync — Dev Plan (Corrected April 2026)

      Scope: Supabase Auth (Apple/Google/Email), bidirectional sync for all user data,
      offline queue, first-sign-in migration, account deletion.

      Sessions: 4–5 (can't be parallelized — each builds on the last)

      Prerequisite: Craig creates a Supabase project at supabase.com, provides the
      SUPABASE_URL and SUPABASE_ANON_KEY, and configures Apple/Google OAuth providers
      in the Supabase dashboard.


      Current State (April 2026)

      WhatStatus
      authStore.ts178 lines, functional, Supabase client via env vars (process.env.SUPABASE_URL)
      LoginScreen.tsx336 lines, Google + Facebook + Email — missing Apple Sign-In
      SignUpScreen.tsx295 lines, exists and working
      ForgotPasswordScreen.tsx184 lines, exists and working
      lib/supabase.tsStub using process.env.SUPABASE_URL pattern, CONFIGURED = !!process.env.SUPABASE_URL
      lib/oauthHelpers.tsWorking Google/Facebook OAuth via expo-auth-session
      db/user.tsBarrel re-exportuserQueries.ts (496 lines) + userMutations.ts (372 lines)
      userDatabase.ts14 migrations, no cloud_id, no sync_state, no soft delete. sync_queue exists (v14) but incomplete schema
      Cloud schemaNone — Supabase project doesn't exist yet
      TestsauthStore.test.ts exists, supabaseMock.js exists
      Dependencies installedexpo-auth-session, expo-web-browser, expo-crypto
      Dependencies NOT installed@supabase/supabase-js, expo-secure-store, expo-apple-authentication, @react-native-community/netinfo, @react-native-async-storage/async-storage, react-native-url-polyfill

      Session 1: Supabase Auth (Apple/Google/Email Sign-In)

      Goal

      Users can sign in with Apple, Google, or email/password. Session persists across
      app restarts. Sign-out clears session. Account section in MoreMenu shows user info.
      Facebook auth removed.

      Pre-session (Craig)

      1. Create Supabase project → copy URL + anon key
      2. Add SUPABASE_URL and SUPABASE_ANON_KEY to .env, app.json extras, and eas.json build profiles
      3. In Supabase Dashboard → Authentication → Providers:
        • Enable Apple: add Services ID, Team ID, Key ID, private key
        • Enable Google: add Client ID + secret from Google Cloud Console
        • Enable Email: already on by default
      4. In Apple Developer Console:
        • Create a Services ID for "Sign in with Apple"
        • Register the Supabase callback URL as a redirect URI

      1A. Dependencies

      cd app/
      npx expo install @supabase/supabase-js @react-native-async-storage/async-storage \
      react-native-url-polyfill expo-secure-store expo-apple-authentication \
      @react-native-community/netinfo

      Already installed: expo-auth-session, expo-web-browser, expo-crypto.

      expo-apple-authentication is needed for native Apple Sign-In on iOS (the App
      Store rejects web-based Apple OAuth — it must use the native credential API).

      1B. Files to Modify

      lib/supabase.ts — Activate the client

      // Changes:// 1. Keep env var pattern (already correct): process.env.SUPABASE_URL// 2. Switch auth storage from AsyncStorage to expo-secure-store// (SecureStore is encrypted; AsyncStorage is not)// 3. Replace the placeholder SupabaseClient interface with actual importimport*asSecureStorefrom'expo-secure-store';constExpoSecureStoreAdapter={getItem: (key: string)=>SecureStore.getItemAsync(key),setItem: (key: string,value: string)=>SecureStore.setItemAsync(key,value),removeItem: (key: string)=>SecureStore.deleteItemAsync(key),};// In createClient options:
      auth: {storage: ExpoSecureStoreAdapter,autoRefreshToken: true,persistSession: true,detectSessionInUrl: false,flowType: 'pkce',}

      Note:expo-secure-store needs to be installed (see 1A above).

      lib/oauthHelpers.ts — Add Apple Sign-In

      Add signInWithApple() as a separate function (not a new case in signInWithProvider
      Apple uses native credential flow via signInWithIdToken, architecturally different from
      the browser-redirect flow used by Google):

      exportasyncfunctionsignInWithApple(): Promise<{error?: string}>{constAppleAuthentication=require('expo-apple-authentication');constsupabase=getSupabase();if(!supabase)return{error: 'Auth not available'};try{constcredential=awaitAppleAuthentication.signInAsync({requestedScopes: [AppleAuthentication.AppleAuthenticationScope.FULL_NAME,AppleAuthentication.AppleAuthenticationScope.EMAIL,],});if(!credential.identityToken){return{error: 'No identity token from Apple'};}const{ error }=awaitsupabase.auth.signInWithIdToken({provider: 'apple',token: credential.identityToken,});if(error)return{error: error.message};return{};}catch(err: any){if(err.code==='ERR_REQUEST_CANCELED')return{error: 'Sign-in canceled'};return{error: err.message??'Apple sign-in failed'};}}

      stores/authStore.ts — Add Apple, drop Facebook

      // Changes:// 1. Add signInWithApple method// 2. Remove signInWithFacebook from interface and implementation// 3. Keep signInWithGoogle and signInWithEmail as-issignInWithApple: async()=>{const{ isSupabaseAvailable }=getAuth();if(!isSupabaseAvailable()){return{error: 'Sign-in requires a development build.'};}set({isLoading: true});try{const{ signInWithApple }=require('../lib/oauthHelpers');returnawaitsignInWithApple();}finally{set({isLoading: false});},},

      screens/LoginScreen.tsx — Replace Facebook with Apple

      Since SignUpScreen.tsx and ForgotPasswordScreen.tsx already exist with working
      navigation routes, the redesign is simpler than originally planned:

      1. Replace Facebook button → Apple button (black bg, white text, Apple icon)
      2. Keep existing Google button
      3. Keep email form + existing navigation to SignUp / ForgotPassword
      4. Add "Continue without signing in" footer (navigation.goBack())
      5. Add privacy reassurance text
      6. Remove signInWithFacebook import/usage

      Layout (top to bottom):

      ┌─────────────────────────────────┐
      │ ScreenHeader "Sign In" │
      │ │
      │ "Sign in to sync your study │
      │ data across devices." │
      │ │
      │ ┌─────────────────────────┐ │
      │ │ ◉ Continue with Apple │ │ ← Black button, white text, Apple icon
      │ └─────────────────────────┘ │
      │ ┌─────────────────────────┐ │
      │ │ G Continue with Google│ │ ← White button, dark text
      │ └─────────────────────────┘ │
      │ │
      │ ──────── or ──────── │
      │ │
      │ [ Email ] │
      │ [ Password ] │
      │ ┌─────────────────────────┐ │
      │ │ Sign In │ │ ← Gold button
      │ └─────────────────────────┘ │
      │ Forgot password? ← navigates │
      │ to ForgotPasswordScreen │
      │ │
      │ Don't have an account? Sign Up │ ← navigates to SignUpScreen
      │ │
      │ ───────────────────────────── │
      │ Your study data stays on your │
      │ device even without an │
      │ account. Signing in adds │
      │ cross-device sync. │
      │ │
      │ ┌─────────────────────────┐ │
      │ │ Continue without │ │ ← Text button, muted
      │ │ signing in │ │
      │ └─────────────────────────┘ │
      └─────────────────────────────────┘
      

      screens/MoreMenuScreen.tsx — Enhanced auth section

      Currently shows user email when signed in, "Sign In" when not. Enhance:

      ┌──────────────────────────────────────┐
      │ SIGNED IN STATE: │
      │ │
      │ [Avatar] Craig Buckmaster │
      │ craig@companionstudy.app │
      │ ✓ Synced 2 min ago │ ← sync status line (Session 2)
      │ │
      │ [Sign Out] │
      │ │
      │ NOT SIGNED IN: │
      │ │
      │ [Lock icon] Sign In │
      │ Sync your study data │
      │ across devices │
      │ │
      └──────────────────────────────────────┘
      

      1C. App Config Changes

      app.json — Add Apple auth entitlement

      {
      "expo": {
      "ios": {
      "usesAppleSignIn": true
      },
      "plugins": [
      "expo-apple-authentication",
      // ... existing plugins (expo-font, expo-screen-orientation, etc.)
      ]
      }
      }

      scheme: "scripture" is already set ✓

      1D. Tests

      __tests__/stores/authStore.test.ts — Extend

      Add tests for:

      • signInWithApple dispatches correctly when available
      • signInWithApple returns error in Expo Go
      • Facebook methods are removed
      • Hydrate restores session from SecureStore
      • Sign-out clears SecureStore + local profile
      • Error states propagate correctly

      __tests__/screens/LoginScreen.test.tsx — New

      describe('LoginScreen',()=>{it('renders Apple, Google, and email sign-in options',()=>{ ... });it('does NOT render Facebook button',()=>{ ... });it('navigates to SignUpScreen on "Sign Up" press',()=>{ ... });it('navigates to ForgotPasswordScreen on "Forgot password" press',()=>{ ... });it('shows error message on failed sign-in',async()=>{ ... });it('navigates back on "Continue without signing in"',()=>{ ... });});

      __tests__/lib/supabase.test.ts — New

      describe('supabase client',()=>{it('returns null when env vars not set',()=>{ ... });it('creates client singleton when configured',()=>{ ... });it('uses SecureStore adapter for auth storage',()=>{ ... });});

      1E. Session 1 Deliverables

      DeliverableVerification
      Apple Sign-In works on deviceSign in → session persists on restart
      Google Sign-In worksSign in → session persists
      Email sign-in/sign-up worksCreate account → sign in → session persists
      Sign-out clears sessionSign out → MoreMenu shows "Sign In"
      Facebook removedNo Facebook button in UI, no Facebook in authStore
      Forgot password worksRequest reset → email received
      Tests passnpm test -- --testPathPattern="auth|Login|supabase"

      Session 2: Sync Engine + Notes/Highlights/Bookmarks/Topics

      Goal

      All user writes (notes, highlights, bookmarks, collections, note links, topic bookmarks)
      are queued for sync. On app launch and periodically, queued changes push to Supabase.
      Pull merges cloud changes into local SQLite.

      2A. Supabase Cloud Schema

      Run in Supabase SQL Editor (one-time setup):

      -- ══════════════════════════════════════════════════-- TABLES-- ══════════════════════════════════════════════════CREATETABLEuser_notes (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
      verse_ref TEXTNOT NULL,
      note_text TEXTNOT NULL,
      tags_json TEXT DEFAULT '[]',
      collection_id UUID,
      created_at TIMESTAMPTZ DEFAULT now(),
      updated_at TIMESTAMPTZ DEFAULT now(),
      deleted_at TIMESTAMPTZ
      );
      CREATETABLEstudy_collections (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
      name TEXTNOT NULL,
      description TEXT DEFAULT '',
      color TEXT DEFAULT '#bfa050',
      created_at TIMESTAMPTZ DEFAULT now(),
      updated_at TIMESTAMPTZ DEFAULT now(),
      deleted_at TIMESTAMPTZ
      );
      ALTERTABLE user_notes
      ADD CONSTRAINT fk_notes_collection
      FOREIGN KEY (collection_id) REFERENCES study_collections(id)
      ON DELETESETNULL;
      CREATETABLEnote_links (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
      from_note_id UUID NOT NULLREFERENCES user_notes(id) ON DELETE CASCADE,
      to_note_id UUID NOT NULLREFERENCES user_notes(id) ON DELETE CASCADE,
      created_at TIMESTAMPTZ DEFAULT now()
      );
      CREATETABLEbookmarks (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
      verse_ref TEXTNOT NULL,
      label TEXT,
      created_at TIMESTAMPTZ DEFAULT now(),
      deleted_at TIMESTAMPTZ
      );
      CREATETABLEverse_highlights (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
      verse_ref TEXTNOT NULL,
      color TEXTNOT NULL,
      collection_id TEXT,
      note TEXT,
      created_at TIMESTAMPTZ DEFAULT now(),
      deleted_at TIMESTAMPTZ,
      UNIQUE(user_id, verse_ref)
      );
      CREATETABLEhighlight_collections (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
      name TEXTNOT NULL,
      color TEXTNOT NULL,
      sort_order INTEGER DEFAULT 0,
      created_at TIMESTAMPTZ DEFAULT now()
      );
      CREATETABLEbookmarked_topics (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
      topic_id TEXTNOT NULL,
      topic_type TEXTNOT NULL DEFAULT 'official',
      cached_title TEXT,
      cached_summary TEXT,
      created_at TIMESTAMPTZ DEFAULT now(),
      deleted_at TIMESTAMPTZ,
      UNIQUE(user_id, topic_id, topic_type)
      );
      -- ══════════════════════════════════════════════════-- ROW LEVEL SECURITY (all tables)-- ══════════════════════════════════════════════════
      DO $$
      DECLARE
      tbl TEXT;
      BEGIN
      FOR tbl INVALUES
      ('user_notes'), ('study_collections'), ('note_links'),
      ('bookmarks'), ('verse_highlights'), ('highlight_collections'),
      ('bookmarked_topics')
      LOOP
      EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl);
      EXECUTE format(
      'CREATE POLICY "Users own their %1$s" ON %1$I FOR ALL USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid())', tbl);
      END LOOP;
      END $$;
      -- ══════════════════════════════════════════════════-- INDEXES-- ══════════════════════════════════════════════════CREATEINDEXidx_notes_userON user_notes(user_id);
      CREATEINDEXidx_notes_updatedON user_notes(user_id, updated_at);
      CREATEINDEXidx_collections_userON study_collections(user_id);
      CREATEINDEXidx_note_links_userON note_links(user_id);
      CREATEINDEXidx_bookmarks_userON bookmarks(user_id);
      CREATEINDEXidx_bookmarks_updatedON bookmarks(user_id, created_at);
      CREATEINDEXidx_highlights_userON verse_highlights(user_id);
      CREATEINDEXidx_highlight_collections_userON highlight_collections(user_id);
      CREATEINDEXidx_bookmarked_topics_userON bookmarked_topics(user_id);
      -- ══════════════════════════════════════════════════-- AUTO-UPDATE updated_at-- ══════════════════════════════════════════════════CREATE OR REPLACEFUNCTIONupdate_updated_at()
      RETURNS TRIGGER AS $$
      BEGINNEW.updated_at= now();
      RETURN NEW;
      END;
      $$ LANGUAGE plpgsql;
      CREATETRIGGERtrg_notes_updated BEFORE UPDATEON user_notes
      FOR EACH ROW EXECUTE FUNCTION update_updated_at();
      CREATETRIGGERtrg_collections_updated BEFORE UPDATEON study_collections
      FOR EACH ROW EXECUTE FUNCTION update_updated_at();

      2B. Local Database Migration

      userDatabase.ts — Migration v15: Sync infrastructure

      The existing sync_queue table (from v14) has an incomplete schema. v15 extends it
      and adds cloud sync columns to all user tables.

      -- cloud_id for all synced tablesALTERTABLE user_notes ADD COLUMN cloud_id TEXT;
      ALTERTABLE study_collections ADD COLUMN cloud_id TEXT;
      ALTERTABLE note_links ADD COLUMN cloud_id TEXT;
      ALTERTABLE bookmarks ADD COLUMN cloud_id TEXT;
      ALTERTABLE verse_highlights ADD COLUMN cloud_id TEXT;
      ALTERTABLE highlight_collections ADD COLUMN cloud_id TEXT;
      ALTERTABLE bookmarked_topics ADD COLUMN cloud_id TEXT;
      -- Soft delete supportALTERTABLE user_notes ADD COLUMN deleted_at TEXT;
      ALTERTABLE study_collections ADD COLUMN deleted_at TEXT;
      ALTERTABLE bookmarks ADD COLUMN deleted_at TEXT;
      ALTERTABLE verse_highlights ADD COLUMN deleted_at TEXT;
      ALTERTABLE bookmarked_topics ADD COLUMN deleted_at TEXT;
      -- Extend existing sync_queue (from v14) with routing columnsALTERTABLE sync_queue ADD COLUMN table_name TEXTNOT NULL DEFAULT '';
      ALTERTABLE sync_queue ADD COLUMN local_id TEXTNOT NULL DEFAULT '';
      -- Sync state table (last_pull_at, migration flags)CREATETABLEIF NOT EXISTS sync_state (
      key TEXTPRIMARY KEY,
      value TEXTNOT NULL
      );
      -- LWW support for preferencesALTERTABLE user_preferences ADD COLUMN updated_at TEXT;
      -- Indexes for merge lookupsCREATEUNIQUE INDEXIF NOT EXISTS idx_notes_cloud_id ON user_notes(cloud_id);
      CREATEUNIQUE INDEXIF NOT EXISTS idx_collections_cloud_id ON study_collections(cloud_id);
      CREATEUNIQUE INDEXIF NOT EXISTS idx_note_links_cloud_id ON note_links(cloud_id);
      CREATEUNIQUE INDEXIF NOT EXISTS idx_bookmarks_cloud_id ON bookmarks(cloud_id);
      CREATEUNIQUE INDEXIF NOT EXISTS idx_highlights_cloud_id ON verse_highlights(cloud_id);
      CREATEUNIQUE INDEXIF NOT EXISTS idx_hl_collections_cloud_id ON highlight_collections(cloud_id);
      CREATEUNIQUE INDEXIF NOT EXISTS idx_bookmarked_topics_cloud_id ON bookmarked_topics(cloud_id);

      2C. New Files

      services/syncEngine.ts — Core sync orchestrator (~400 lines)

      The sync engine has three responsibilities:

      1. Queue writes — After every local write, enqueue a sync operation:

      exportasyncfunctionenqueueSync(tableName: SyncableTable,localId: string|number,operation: 'INSERT'|'UPDATE'|'DELETE',payload: Record<string,any>,): Promise<void>{constdb=getUserDb();awaitdb.runAsync(`INSERT INTO sync_queue (table_name, local_id, operation, payload_json) VALUES (?, ?, ?, ?)`,[tableName,String(localId),operation,JSON.stringify(payload)],);schedulePush();}

      2. Push — Flush the queue to Supabase:

      exportasyncfunctionpushQueue(): Promise<SyncResult>{constsupabase=getSupabase();if(!supabase)return{pushed: 0,failed: 0};constsession=awaitsupabase.auth.getSession();if(!session.data.session)return{pushed: 0,failed: 0};constdb=getUserDb();constpending=awaitdb.getAllAsync<SyncQueueRow>('SELECT * FROM sync_queue ORDER BY created_at ASC LIMIT 100',);letpushed=0,failed=0;for(constitemofpending){try{constpayload=JSON.parse(item.payload_json);awaitpushItem(supabase,item.table_name,item.operation,payload);awaitdb.runAsync('DELETE FROM sync_queue WHERE id = ?',[item.id]);pushed++;}catch(err){constretries=(item.attempts??0)+1;if(retries>=5){awaitdb.runAsync('DELETE FROM sync_queue WHERE id = ?',[item.id]);logger.error('Sync',`Dropping item after 5 retries`,item);}else{awaitdb.runAsync('UPDATE sync_queue SET attempts = ?, last_error = ? WHERE id = ?',[retries,String(err),item.id],);}failed++;}}return{ pushed, failed };}

      3. Pull — Fetch changes since last sync and merge:

      exportasyncfunctionpullChanges(): Promise<SyncResult>{constsupabase=getSupabase();if(!supabase)return{pulled: 0,conflicts: 0};constdb=getUserDb();constlastSync=awaitgetSyncState('last_pull_at');constsince=lastSync??'1970-01-01T00:00:00Z';letpulled=0,conflicts=0;for(consttableofSYNCABLE_TABLES){const{ data, error }=awaitsupabase.from(table).select('*').gte('updated_at',since).order('updated_at',{ascending: true});if(error){logger.error('Sync',`Pull failed for ${table}`,error);continue;}for(constrowofdata??[]){constresult=awaitmergeRow(db,table,row);if(result==='merged')pulled++;if(result==='conflict')conflicts++;}}awaitsetSyncState('last_pull_at',newDate().toISOString());return{ pulled, conflicts };}

      Merge logic — Last Write Wins:

      asyncfunctionmergeRow(db,table,cloudRow): Promise<'merged'|'conflict'|'skipped'>{constlocal=awaitdb.getFirstAsync(`SELECT * FROM ${table} WHERE cloud_id = ?`,[cloudRow.id]);if(!local){awaitinsertFromCloud(db,table,cloudRow);return'merged';}if(cloudRow.deleted_at){awaitdb.runAsync(`DELETE FROM ${table} WHERE cloud_id = ?`,[cloudRow.id]);return'merged';}constcloudTime=newDate(cloudRow.updated_at).getTime();constlocalTime=newDate(local.updated_at).getTime();if(cloudTime>localTime){awaitupdateFromCloud(db,table,cloudRow,local.id);return'merged';}returncloudTime===localTime ? 'skipped' : 'conflict';}

      services/syncScheduler.ts — Background sync timing (~80 lines)

      // Schedules sync operations:// 1. On app foreground (via AppState listener)// 2. Every 5 minutes while app is active// 3. On NetInfo change from offline → online// 4. Debounced 3s after any local write (via enqueueSync)import{AppState}from'react-native';importNetInfofrom'@react-native-community/netinfo';

      stores/syncStore.ts — Sync state for UI (~60 lines)

      interfaceSyncState{isSyncing: boolean;lastSyncAt: string|null;pendingCount: number;error: string|null;triggerSync: ()=>Promise<void>;refreshPendingCount: ()=>Promise<void>;}

      2D. Modifying db/userMutations.ts — Add Sync Triggers

      Note: The original issue referenced db/user.ts. That file was split into
      userQueries.ts (reads) and userMutations.ts (writes). All sync triggers
      go in userMutations.ts.

      Every write function gets a sync enqueue call. The pattern:

      exportasyncfunctionsaveNote(verseRef: string,text: string): Promise<number>{constcloudId=generateUUID();constresult=awaitgetUserDb().runAsync("INSERT INTO user_notes (verse_ref, note_text, cloud_id) VALUES (?, ?, ?)",[verseRef,text,cloudId]);constnoteId=result.lastInsertRowId;awaitgetUserDb().runAsync("INSERT INTO notes_fts(rowid, note_text) VALUES (?, ?)",[noteId,text]);enqueueSync('user_notes',noteId,'INSERT',{id: cloudId,verse_ref: verseRef,note_text: text,tags_json: '[]',collection_id: null,}).catch(noop);returnnoteId;}

      Soft delete pattern:

      exportasyncfunctiondeleteNote(id: number): Promise<void>{constrow=awaitgetUserDb().getFirstAsync<{cloud_id: string|null}>('SELECT cloud_id FROM user_notes WHERE id = ?',[id]);awaitgetUserDb().runAsync("UPDATE user_notes SET deleted_at = datetime('now') WHERE id = ?",[id]);awaitgetUserDb().runAsync("DELETE FROM notes_fts WHERE rowid = ?",[id]);if(row?.cloud_id){enqueueSync('user_notes',id,'DELETE',{id: row.cloud_id}).catch(noop);}}

      Complete function list (20 functions — corrected from original 14):

      #FunctionTableOperationNotes
      1saveNoteuser_notesINSERTGenerate cloud_id
      2updateNoteuser_notesUPDATE
      3deleteNoteuser_notesDELETE (soft)Set deleted_at, remove from FTS
      4updateNoteTagsuser_notesUPDATEAdded — missing from original
      5setNoteCollectionuser_notesUPDATEAdded — missing from original
      6addBookmarkbookmarksINSERTGenerate cloud_id
      7removeBookmarkbookmarksDELETE (soft)
      8setHighlightverse_highlightsUPSERTGenerate cloud_id on insert
      9removeHighlightverse_highlightsDELETE (soft)
      10createCollectionstudy_collectionsINSERTGenerate cloud_id
      11updateCollectionstudy_collectionsUPDATE
      12deleteCollectionstudy_collectionsDELETE (soft)
      13createHighlightCollectionhighlight_collectionsINSERTLocal ID is TEXT — use as cloud_id
      14deleteHighlightCollectionhighlight_collectionsDELETE (hard→soft)
      15linkNotesnote_linksINSERTGenerate cloud_id
      16unlinkNotesnote_linksDELETE (hard)
      17bookmarkTopicbookmarked_topicsUPSERTAdded — generate cloud_id
      18unbookmarkTopicbookmarked_topicsDELETE (soft)Added

      NOT synced (local-only):

      • startStudySession, endStudySession, recordSessionEvent — device-local analytics
      • flagContent — moderation, separate sync mechanism (v14 synced column)
      • upsertAuthProfile, clearAuthProfile — auth state, not user content
      • startPlan, completePlanDay, abandonPlan — synced in Session 3
      • resetToNewUser — dev tool

      UUID generation: Use expo-crypto (already installed):

      import*asCryptofrom'expo-crypto';exportfunctiongenerateUUID(): string{returnCrypto.randomUUID();}

      2E. Modifying db/userQueries.ts — Add Soft Delete Filters

      All read queries touching soft-deletable tables must add WHERE deleted_at IS NULL:

      FunctionTable
      getNotesForChapteruser_notes
      getNoteCountuser_notes
      getAllNotesuser_notes
      searchNotesuser_notes
      searchNotesFTSuser_notes
      getBookmarksbookmarks
      isBookmarkedbookmarks
      getHighlightsForChapterverse_highlights
      getAllHighlightsverse_highlights
      getCollectionsstudy_collections
      getCollectionstudy_collections
      getNotesInCollectionuser_notes
      getCollectionNoteCountsuser_notes
      getAllTagsuser_notes
      getNotesByTaguser_notes
      getLinkedNotesnote_links → user_notes
      getReferencingNotesnote_links → user_notes
      getBookmarkedTopicsbookmarked_topics
      isTopicBookmarkedbookmarked_topics

      2F. Tests

      __tests__/services/syncEngine.test.ts — New (~250 lines)

      describe('syncEngine',()=>{describe('enqueueSync',()=>{it('inserts queue entry with table_name and local_id',async()=>{ ... });it('schedules a push after enqueue',async()=>{ ... });});describe('pushQueue',()=>{it('pushes INSERT operations to Supabase upsert',async()=>{ ... });it('pushes DELETE as soft-delete',async()=>{ ... });it('removes queue entries after success',async()=>{ ... });it('increments attempts on failure',async()=>{ ... });it('drops items after 5 failed attempts',async()=>{ ... });it('does nothing when not authenticated',async()=>{ ... });});describe('pullChanges',()=>{it('fetches rows updated since last_pull_at',async()=>{ ... });it('inserts new cloud rows locally with cloud_id',async()=>{ ... });it('LWW: cloud wins on newer timestamp',async()=>{ ... });it('LWW: local wins on newer timestamp',async()=>{ ... });it('soft-deletes locally when cloud has deleted_at',async()=>{ ... });});});

      __tests__/db/userMutations.test.ts — New (~150 lines)

      Test each write function enqueues sync correctly:

      describe('userMutations sync integration',()=>{it('saveNote enqueues INSERT with cloud_id',async()=>{ ... });it('updateNote enqueues UPDATE',async()=>{ ... });it('deleteNote soft-deletes and enqueues DELETE',async()=>{ ... });it('updateNoteTags enqueues UPDATE',async()=>{ ... });it('bookmarkTopic enqueues UPSERT',async()=>{ ... });// ... one test per write function});

      __tests__/db/userQueries.test.ts — New (~80 lines)

      describe('userQueries soft-delete filtering',()=>{it('getAllNotes excludes soft-deleted rows',async()=>{ ... });it('getBookmarks excludes soft-deleted rows',async()=>{ ... });it('getBookmarkedTopics excludes soft-deleted rows',async()=>{ ... });// ... one test per query with soft-delete filter});

      2G. Session 2 Deliverables

      DeliverableVerification
      Cloud schema deployedTables visible in Supabase dashboard
      Migration v15 runs cleanlyApp launches, cloud_id columns exist
      Note CRUD syncsCreate note → appears in Supabase → pull on second device
      Highlight CRUD syncsSame
      Bookmark CRUD syncsSame
      Collection CRUD syncsSame
      Topic bookmark syncsSame
      Soft deletes workDelete note → deleted_at set → synced → hidden on other device
      Offline queue persistsAirplane mode → create note → reconnect → note pushes
      LWW conflict resolutionEdit same note on 2 devices → newer wins
      Tests passnpm test -- --testPathPattern="sync|user"

      Session 3: Reading Progress + Study Depth + Plans + Settings Sync

      Goal

      Sync reading progress (union merge), study depth (additive), reading plan progress,
      and user preferences (LWW).

      3A. Additional Cloud Tables

      CREATETABLEreading_progress (
      user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
      book_id TEXTNOT NULL,
      chapter_num INTEGERNOT NULL,
      completed_at TIMESTAMPTZ,
      PRIMARY KEY (user_id, book_id, chapter_num)
      );
      CREATETABLEstudy_depth (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
      chapter_id TEXTNOT NULL,
      section_id TEXTNOT NULL,
      panel_type TEXTNOT NULL,
      first_opened_at TIMESTAMPTZ DEFAULT now(),
      UNIQUE(user_id, section_id, panel_type)
      );
      CREATETABLEuser_preferences (
      user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
      key TEXTNOT NULL,
      value TEXTNOT NULL,
      updated_at TIMESTAMPTZ DEFAULT now(),
      PRIMARY KEY (user_id, key)
      );
      CREATETABLEreading_streaks (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
      dateTEXTNOT NULL,
      chapters_read INTEGERNOT NULL DEFAULT 0,
      books_touched TEXT,
      UNIQUE(user_id, date)
      );
      CREATETABLEplan_progress (
      user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
      plan_id TEXTNOT NULL,
      day_num INTEGERNOT NULL,
      completed_at TIMESTAMPTZ,
      PRIMARY KEY (user_id, plan_id, day_num)
      );
      -- RLS for all new tables
      DO $$
      DECLARE tbl TEXT;
      BEGIN
      FOR tbl INVALUES
      ('reading_progress'), ('study_depth'), ('user_preferences'),
      ('reading_streaks'), ('plan_progress')
      LOOP
      EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl);
      EXECUTE format(
      'CREATE POLICY "Users own their %1$s" ON %1$I FOR ALL USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid())', tbl);
      END LOOP;
      END $$;

      3B. Special Merge Strategies

      Reading Progress — Union Merge

      Never un-completes. If either side says completed, it stays completed.
      Keep the earliest completed_at timestamp.

      Study Depth — Additive Only

      Append-only. Cloud accumulates all panels ever opened. Merge = INSERT OR IGNORE.

      Reading Plans — Union Merge

      Plan progress uses same union logic as reading progress — once a day is completed,
      it stays completed.

      User Preferences — LWW

      Simple key-value pairs with updated_at for conflict resolution.

      3C. Sync Triggers for Session 3 Functions

      recordVisit in userMutations.ts: Enqueue sync for reading_progress.

      completePlanDay in userMutations.ts: Enqueue sync for plan_progress.

      settingsStore.ts preference setters: Each setPreference call gets a sync enqueue.

      3D. UI: Sync Status in Settings

      Add a "Sync" section to SettingsScreen:

      ┌──────────────────────────────────────┐
      │ SYNC │
      │ │
      │ Status ✓ Up to date │
      │ Last sync 2 min ago │
      │ Pending 0 changes │
      │ │
      │ [Sync Now] │
      │ │
      │ Only visible when signed in. │
      └──────────────────────────────────────┘
      

      3E. Session 3 Deliverables

      DeliverableVerification
      Reading progress syncs (union)Read chapter → mark on device A → appears on device B
      Never un-completesDelete cloud row → local stays completed
      Study depth syncs (additive)Open panel → record appears in Supabase
      Plan progress syncs (union)Complete day on A → appears on B
      Preferences sync (LWW)Change font size on A → appears on B
      Sync status UI in SettingsShows status, last sync time, pending count
      Tests passnpm test -- --testPathPattern="sync|settings"

      Session 4: First-Sign-In Migration + Edge Cases + Account Deletion

      Goal

      Handle the critical first-sign-in scenario (user has months of local data),
      edge cases (offline→online, large datasets, stale queues), and App Store-compliant
      account deletion.

      4A. First-Sign-In Migration

      services/initialMigration.ts — New (~150 lines)

      When a user signs in for the first time, they may have hundreds of notes, bookmarks,
      and reading progress entries with no cloud_id. One-time bulk upload:

      1. Check if migration already happened (sync_state.initial_migration_complete)
      2. Check cloud for existing data (returning user on new device?)
      3. Assign cloud_id to all local rows missing them
      4. Upload in batches of 50 (collections first for FK resolution, then notes, etc.)
      5. Mark complete

      Migration UI — SyncMigrationSheet

      Bottom-sheet showing progress: "Uploading 47 notes, 312 chapters read..." with
      progress bar. Auto-dismisses on completion.

      4B. Edge Cases

      • Offline → online flush:pushQueue() processes max 100 items per call, chains until empty
      • Queue compaction: When queue > 200 items, compact by keeping only latest per table+local_id
      • Note links with unmapped IDs: Upload notes before note_links; defer links with unresolved refs
      • Interrupted migration:initial_migration_complete only set on full success; idempotent upserts prevent duplicates on retry
      • Stale INSERT+DELETE pairs: Compaction drops both when INSERT never pushed

      4C. Account Deletion

      services/accountDeletion.ts — New (~50 lines)

      1. Invoke Supabase Edge Function delete-user-data (service_role deletes all user tables + auth account)
      2. Clear local auth state
      3. Stop sync scheduler
      4. Local study data preserved on device

      Supabase Edge Function: delete-user-data

      Server-side function using service_role key to delete all user rows across all tables
      and delete the auth account. Client-side RLS can't cascade across tables.

      Deletion UI in SettingsScreen

      "Delete Account" in a Danger Zone section (only visible when signed in).
      Two-step confirmation: alert → type "DELETE" to confirm.

      4D. Session 4 Deliverables

      DeliverableVerification
      First sign-in migration worksUser with 6 months data → sign in → all data in Supabase
      Returning user on new deviceSign in on device B → all data appears
      Migration progress UIBottom sheet shows progress during initial upload
      Offline queue compaction500 offline edits → compacted → clean push
      Account deletion worksDelete → cloud data gone → local data preserved
      Two-step delete confirmationCan't accidentally delete
      Edge function deployeddelete-user-data in Supabase Functions
      Tests passFull suite green

      Complete File Inventory (Corrected)

      New Files (10+)

      FileLines (est.)Session
      services/syncEngine.ts~4002
      services/syncScheduler.ts~802
      services/initialMigration.ts~1504
      services/accountDeletion.ts~504
      stores/syncStore.ts~602
      __tests__/services/syncEngine.test.ts~3502-3
      __tests__/services/syncScheduler.test.ts~602
      __tests__/db/userMutations.test.ts~1502
      __tests__/db/userQueries.test.ts~802
      __tests__/services/initialMigration.test.ts~1204
      __tests__/services/accountDeletion.test.ts~604
      __tests__/screens/LoginScreen.test.tsx~801

      Modified Files (12+)

      FileChange SummarySession
      lib/supabase.tsActivate, SecureStore adapter, real import1
      lib/oauthHelpers.tsAdd signInWithApple()1
      stores/authStore.tsAdd Apple, remove Facebook1
      screens/LoginScreen.tsxReplace Facebook → Apple, add guest option1
      screens/MoreMenuScreen.tsxEnhanced auth section + sync status1, 3
      screens/SettingsScreen.tsxSync status section + account deletion3, 4
      db/userDatabase.tsMigration v15: cloud_id, sync_queue extension, soft delete2
      db/userMutations.tsSync triggers on all 18+ write functions + soft delete2, 3
      db/userQueries.tsWHERE deleted_at IS NULL on 19+ queries2
      stores/settingsStore.tsSync preference changes3
      app.jsonApple auth entitlement + plugin1
      types/user.tsAdd cloud_id and deleted_at to interfaces2

      New Dependencies (6)

      PackagePurpose
      @supabase/supabase-jsSupabase client
      @react-native-async-storage/async-storageRequired by Supabase (session fallback)
      react-native-url-polyfillRequired by Supabase (URL parsing)
      expo-secure-storeEncrypted JWT storage
      expo-apple-authenticationNative Apple Sign-In
      @react-native-community/netinfoNetwork state for sync scheduler

      Test Summary (~75 tests)

      Test FileTests (est.)
      syncEngine.test.ts~30
      syncScheduler.test.ts~5
      userMutations.test.ts~18
      userQueries.test.ts~10
      initialMigration.test.ts~8
      accountDeletion.test.ts~5
      LoginScreen.test.tsx~6
      authStore.test.ts (extend)~3

      Risk Register

      RiskImpactMitigation
      Apple rejects without native Apple Sign-InApp Store rejectionUsing expo-apple-authentication (native), not web OAuth
      Supabase free tier limitsSync stops at 50K MAUUpgrade to Pro ($25/mo) — covered by revenue model
      Large initial migration failsUser data doesn't syncIdempotent upserts; retry on next launch; progress UI
      LWW loses edits on simultaneous editUser loses a note editAcceptable for v1; realtime sync (v2) reduces window
      expo-secure-store 2KB value limitJWT too largeSupabase JWTs are ~1KB; within limit
      NetInfo false positivesSync on captive portalsTimeout + retry with backoff
      Soft delete bloatDeleted rows accumulatePeriodic cleanup: hard-delete rows > 30 days old
      v14 sync_queue schema mismatchALTER may fail on existing datav15 uses ADD COLUMN with defaults; safe for existing rows

      Drift Corrections (April 2026)

      This issue was originally written against an older codebase. The following corrections
      were applied:

      1. db/user.ts split → now userQueries.ts (reads) + userMutations.ts (writes)
      2. Migrations at v14 (not v8) — sync migration is now v15
      3. sync_queue already exists (v14) — v15 extends it with table_name and local_id
      4. @supabase/supabase-js NOT installed — full dep install list corrected
      5. SignUpScreen.tsx and ForgotPasswordScreen.tsx exist — LoginScreen redesign simplified
      6. 20 write functions (not 14) — updateNoteTags, setNoteCollection, bookmarkTopic, unbookmarkTopic added
      7. highlight_collections.id is TEXT — can use local ID as cloud_id directly
      8. user_preferences needs updated_at — added to v15 migration
      9. supabase.ts uses env vars — kept (better than hardcoded)
      10. Decisions confirmed: Drop Facebook ✓, Sync bookmarked topics ✓, Sync reading plans ✓, Don't sync study sessions ✓

      Metadata

      Metadata

      Labels

      premiumPremium tier feature

      Projects

      No projects

        Milestone

        No milestone

        Relationships

        None yet

        Development

        No branches or pull requests

        Issue actions

        , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
        Skip to content

        Code: A7. Cross-Device Sync — Supabase auth + bidirectional sync #65

        Description

        @CraigBuckmaster

        Summary

        Supabase Auth (Apple/Google/Email), bidirectional sync for all user data, offline queue, first-sign-in migration, account deletion. Premium feature.

        Sessions: 4–5 (sequential — each builds on the last)

        Prerequisite: Craig creates a Supabase project, configures Apple/Google OAuth providers, provides URL + anon key.

        Last updated: April 2026 — corrected against current codebase state. See "Drift Corrections" section at bottom.

        Session Breakdown

        SessionScopeKey Deliverables
        1Supabase Auth (Apple/Google/Email sign-in)Native Apple Sign-In, SecureStore JWT, LoginScreen redesign (Facebook → Apple), deps install
        2Sync engine + notes/highlights/bookmarks/topicsMigration v15 (cloud_id, sync_queue extension, soft delete), 20 write functions get sync triggers, push/pull with LWW
        3Reading progress + study depth + plans + settings syncUnion merge for progress, additive for depth, LWW for prefs, plan sync, sync status UI
        4First-sign-in migration + edge cases + account deletionBulk upload with progress UI, queue compaction, Edge Function for deletion

        Architecture

        • Offline-first: Local SQLite is always source of truth. Cloud is backup + sync layer. App never blocks on network.
        • Conflict resolution: Last-write-wins for notes/bookmarks/highlights/topics. Union merge for reading progress (never un-completes). Additive for study depth.
        • Soft deletes: All deletable entities get deleted_at column. Both sides honor soft deletes.
        • Queue-based push: Every local write enqueues to sync_queue. Debounced 3s push. Retry with backoff. Drop after 5 failures.

        File Impact

        • 10+ new files (services, stores, tests)
        • 12+ modified files (auth, db, settings, navigation)
        • 6 new dependencies
        • ~75 new tests

        Decisions (Confirmed)

        • Drop Facebook auth: Yes — heavy SDK, low value for Bible app demographic
        • Sync bookmarked topics: Yes — lightweight, user expects cross-device
        • Sync reading plans/progress: Yes — plan progress should follow user across devices
        • Sync study sessions: No — high-frequency device-local analytics, not user content
        • Sync flagged content: No — moderation, server-bound via separate mechanism (v14 synced column)

        Pre-Session Checklist (Craig's Homework)

        • Create Supabase project at supabase.com
        • Copy SUPABASE_URL and SUPABASE_ANON_KEY
        • Add both to .env, app.json extras, and eas.json build profiles
        • Enable Apple provider in Supabase Auth settings
          • Create Apple Services ID in Apple Developer Console
          • Generate a key for Sign in with Apple
          • Paste credentials into Supabase
        • Enable Google provider in Supabase Auth settings
          • Create OAuth 2.0 Client ID in Google Cloud Console
          • Paste Client ID + Secret into Supabase
        • Confirm scheme: "scripture" in app.json (already set ✓)
        • Confirm whether any production users are on migration v14 already (affects sync_queue strategy)

        Full Dev Plan

        A7. Cross-Device Sync — Dev Plan (Corrected April 2026)

        Scope: Supabase Auth (Apple/Google/Email), bidirectional sync for all user data,
        offline queue, first-sign-in migration, account deletion.

        Sessions: 4–5 (can't be parallelized — each builds on the last)

        Prerequisite: Craig creates a Supabase project at supabase.com, provides the
        SUPABASE_URL and SUPABASE_ANON_KEY, and configures Apple/Google OAuth providers
        in the Supabase dashboard.


        Current State (April 2026)

        WhatStatus
        authStore.ts178 lines, functional, Supabase client via env vars (process.env.SUPABASE_URL)
        LoginScreen.tsx336 lines, Google + Facebook + Email — missing Apple Sign-In
        SignUpScreen.tsx295 lines, exists and working
        ForgotPasswordScreen.tsx184 lines, exists and working
        lib/supabase.tsStub using process.env.SUPABASE_URL pattern, CONFIGURED = !!process.env.SUPABASE_URL
        lib/oauthHelpers.tsWorking Google/Facebook OAuth via expo-auth-session
        db/user.tsBarrel re-exportuserQueries.ts (496 lines) + userMutations.ts (372 lines)
        userDatabase.ts14 migrations, no cloud_id, no sync_state, no soft delete. sync_queue exists (v14) but incomplete schema
        Cloud schemaNone — Supabase project doesn't exist yet
        TestsauthStore.test.ts exists, supabaseMock.js exists
        Dependencies installedexpo-auth-session, expo-web-browser, expo-crypto
        Dependencies NOT installed@supabase/supabase-js, expo-secure-store, expo-apple-authentication, @react-native-community/netinfo, @react-native-async-storage/async-storage, react-native-url-polyfill

        Session 1: Supabase Auth (Apple/Google/Email Sign-In)

        Goal

        Users can sign in with Apple, Google, or email/password. Session persists across
        app restarts. Sign-out clears session. Account section in MoreMenu shows user info.
        Facebook auth removed.

        Pre-session (Craig)

        1. Create Supabase project → copy URL + anon key
        2. Add SUPABASE_URL and SUPABASE_ANON_KEY to .env, app.json extras, and eas.json build profiles
        3. In Supabase Dashboard → Authentication → Providers:
          • Enable Apple: add Services ID, Team ID, Key ID, private key
          • Enable Google: add Client ID + secret from Google Cloud Console
          • Enable Email: already on by default
        4. In Apple Developer Console:
          • Create a Services ID for "Sign in with Apple"
          • Register the Supabase callback URL as a redirect URI

        1A. Dependencies

        cd app/
        npx expo install @supabase/supabase-js @react-native-async-storage/async-storage \
        react-native-url-polyfill expo-secure-store expo-apple-authentication \
        @react-native-community/netinfo

        Already installed: expo-auth-session, expo-web-browser, expo-crypto.

        expo-apple-authentication is needed for native Apple Sign-In on iOS (the App
        Store rejects web-based Apple OAuth — it must use the native credential API).

        1B. Files to Modify

        lib/supabase.ts — Activate the client

        // Changes:// 1. Keep env var pattern (already correct): process.env.SUPABASE_URL// 2. Switch auth storage from AsyncStorage to expo-secure-store// (SecureStore is encrypted; AsyncStorage is not)// 3. Replace the placeholder SupabaseClient interface with actual importimport*asSecureStorefrom'expo-secure-store';constExpoSecureStoreAdapter={getItem: (key: string)=>SecureStore.getItemAsync(key),setItem: (key: string,value: string)=>SecureStore.setItemAsync(key,value),removeItem: (key: string)=>SecureStore.deleteItemAsync(key),};// In createClient options:
        auth: {storage: ExpoSecureStoreAdapter,autoRefreshToken: true,persistSession: true,detectSessionInUrl: false,flowType: 'pkce',}

        Note:expo-secure-store needs to be installed (see 1A above).

        lib/oauthHelpers.ts — Add Apple Sign-In

        Add signInWithApple() as a separate function (not a new case in signInWithProvider
        Apple uses native credential flow via signInWithIdToken, architecturally different from
        the browser-redirect flow used by Google):

        exportasyncfunctionsignInWithApple(): Promise<{error?: string}>{constAppleAuthentication=require('expo-apple-authentication');constsupabase=getSupabase();if(!supabase)return{error: 'Auth not available'};try{constcredential=awaitAppleAuthentication.signInAsync({requestedScopes: [AppleAuthentication.AppleAuthenticationScope.FULL_NAME,AppleAuthentication.AppleAuthenticationScope.EMAIL,],});if(!credential.identityToken){return{error: 'No identity token from Apple'};}const{ error }=awaitsupabase.auth.signInWithIdToken({provider: 'apple',token: credential.identityToken,});if(error)return{error: error.message};return{};}catch(err: any){if(err.code==='ERR_REQUEST_CANCELED')return{error: 'Sign-in canceled'};return{error: err.message??'Apple sign-in failed'};}}

        stores/authStore.ts — Add Apple, drop Facebook

        // Changes:// 1. Add signInWithApple method// 2. Remove signInWithFacebook from interface and implementation// 3. Keep signInWithGoogle and signInWithEmail as-issignInWithApple: async()=>{const{ isSupabaseAvailable }=getAuth();if(!isSupabaseAvailable()){return{error: 'Sign-in requires a development build.'};}set({isLoading: true});try{const{ signInWithApple }=require('../lib/oauthHelpers');returnawaitsignInWithApple();}finally{set({isLoading: false});},},

        screens/LoginScreen.tsx — Replace Facebook with Apple

        Since SignUpScreen.tsx and ForgotPasswordScreen.tsx already exist with working
        navigation routes, the redesign is simpler than originally planned:

        1. Replace Facebook button → Apple button (black bg, white text, Apple icon)
        2. Keep existing Google button
        3. Keep email form + existing navigation to SignUp / ForgotPassword
        4. Add "Continue without signing in" footer (navigation.goBack())
        5. Add privacy reassurance text
        6. Remove signInWithFacebook import/usage

        Layout (top to bottom):

        ┌─────────────────────────────────┐
        │ ScreenHeader "Sign In" │
        │ │
        │ "Sign in to sync your study │
        │ data across devices." │
        │ │
        │ ┌─────────────────────────┐ │
        │ │ ◉ Continue with Apple │ │ ← Black button, white text, Apple icon
        │ └─────────────────────────┘ │
        │ ┌─────────────────────────┐ │
        │ │ G Continue with Google│ │ ← White button, dark text
        │ └─────────────────────────┘ │
        │ │
        │ ──────── or ──────── │
        │ │
        │ [ Email ] │
        │ [ Password ] │
        │ ┌─────────────────────────┐ │
        │ │ Sign In │ │ ← Gold button
        │ └─────────────────────────┘ │
        │ Forgot password? ← navigates │
        │ to ForgotPasswordScreen │
        │ │
        │ Don't have an account? Sign Up │ ← navigates to SignUpScreen
        │ │
        │ ───────────────────────────── │
        │ Your study data stays on your │
        │ device even without an │
        │ account. Signing in adds │
        │ cross-device sync. │
        │ │
        │ ┌─────────────────────────┐ │
        │ │ Continue without │ │ ← Text button, muted
        │ │ signing in │ │
        │ └─────────────────────────┘ │
        └─────────────────────────────────┘
        

        screens/MoreMenuScreen.tsx — Enhanced auth section

        Currently shows user email when signed in, "Sign In" when not. Enhance:

        ┌──────────────────────────────────────┐
        │ SIGNED IN STATE: │
        │ │
        │ [Avatar] Craig Buckmaster │
        │ craig@companionstudy.app │
        │ ✓ Synced 2 min ago │ ← sync status line (Session 2)
        │ │
        │ [Sign Out] │
        │ │
        │ NOT SIGNED IN: │
        │ │
        │ [Lock icon] Sign In │
        │ Sync your study data │
        │ across devices │
        │ │
        └──────────────────────────────────────┘
        

        1C. App Config Changes

        app.json — Add Apple auth entitlement

        {
        "expo": {
        "ios": {
        "usesAppleSignIn": true
        },
        "plugins": [
        "expo-apple-authentication",
        // ... existing plugins (expo-font, expo-screen-orientation, etc.)
        ]
        }
        }

        scheme: "scripture" is already set ✓

        1D. Tests

        __tests__/stores/authStore.test.ts — Extend

        Add tests for:

        • signInWithApple dispatches correctly when available
        • signInWithApple returns error in Expo Go
        • Facebook methods are removed
        • Hydrate restores session from SecureStore
        • Sign-out clears SecureStore + local profile
        • Error states propagate correctly

        __tests__/screens/LoginScreen.test.tsx — New

        describe('LoginScreen',()=>{it('renders Apple, Google, and email sign-in options',()=>{ ... });it('does NOT render Facebook button',()=>{ ... });it('navigates to SignUpScreen on "Sign Up" press',()=>{ ... });it('navigates to ForgotPasswordScreen on "Forgot password" press',()=>{ ... });it('shows error message on failed sign-in',async()=>{ ... });it('navigates back on "Continue without signing in"',()=>{ ... });});

        __tests__/lib/supabase.test.ts — New

        describe('supabase client',()=>{it('returns null when env vars not set',()=>{ ... });it('creates client singleton when configured',()=>{ ... });it('uses SecureStore adapter for auth storage',()=>{ ... });});

        1E. Session 1 Deliverables

        DeliverableVerification
        Apple Sign-In works on deviceSign in → session persists on restart
        Google Sign-In worksSign in → session persists
        Email sign-in/sign-up worksCreate account → sign in → session persists
        Sign-out clears sessionSign out → MoreMenu shows "Sign In"
        Facebook removedNo Facebook button in UI, no Facebook in authStore
        Forgot password worksRequest reset → email received
        Tests passnpm test -- --testPathPattern="auth|Login|supabase"

        Session 2: Sync Engine + Notes/Highlights/Bookmarks/Topics

        Goal

        All user writes (notes, highlights, bookmarks, collections, note links, topic bookmarks)
        are queued for sync. On app launch and periodically, queued changes push to Supabase.
        Pull merges cloud changes into local SQLite.

        2A. Supabase Cloud Schema

        Run in Supabase SQL Editor (one-time setup):

        -- ══════════════════════════════════════════════════-- TABLES-- ══════════════════════════════════════════════════CREATETABLEuser_notes (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
        verse_ref TEXTNOT NULL,
        note_text TEXTNOT NULL,
        tags_json TEXT DEFAULT '[]',
        collection_id UUID,
        created_at TIMESTAMPTZ DEFAULT now(),
        updated_at TIMESTAMPTZ DEFAULT now(),
        deleted_at TIMESTAMPTZ
        );
        CREATETABLEstudy_collections (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
        name TEXTNOT NULL,
        description TEXT DEFAULT '',
        color TEXT DEFAULT '#bfa050',
        created_at TIMESTAMPTZ DEFAULT now(),
        updated_at TIMESTAMPTZ DEFAULT now(),
        deleted_at TIMESTAMPTZ
        );
        ALTERTABLE user_notes
        ADD CONSTRAINT fk_notes_collection
        FOREIGN KEY (collection_id) REFERENCES study_collections(id)
        ON DELETESETNULL;
        CREATETABLEnote_links (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
        from_note_id UUID NOT NULLREFERENCES user_notes(id) ON DELETE CASCADE,
        to_note_id UUID NOT NULLREFERENCES user_notes(id) ON DELETE CASCADE,
        created_at TIMESTAMPTZ DEFAULT now()
        );
        CREATETABLEbookmarks (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
        verse_ref TEXTNOT NULL,
        label TEXT,
        created_at TIMESTAMPTZ DEFAULT now(),
        deleted_at TIMESTAMPTZ
        );
        CREATETABLEverse_highlights (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
        verse_ref TEXTNOT NULL,
        color TEXTNOT NULL,
        collection_id TEXT,
        note TEXT,
        created_at TIMESTAMPTZ DEFAULT now(),
        deleted_at TIMESTAMPTZ,
        UNIQUE(user_id, verse_ref)
        );
        CREATETABLEhighlight_collections (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
        name TEXTNOT NULL,
        color TEXTNOT NULL,
        sort_order INTEGER DEFAULT 0,
        created_at TIMESTAMPTZ DEFAULT now()
        );
        CREATETABLEbookmarked_topics (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
        topic_id TEXTNOT NULL,
        topic_type TEXTNOT NULL DEFAULT 'official',
        cached_title TEXT,
        cached_summary TEXT,
        created_at TIMESTAMPTZ DEFAULT now(),
        deleted_at TIMESTAMPTZ,
        UNIQUE(user_id, topic_id, topic_type)
        );
        -- ══════════════════════════════════════════════════-- ROW LEVEL SECURITY (all tables)-- ══════════════════════════════════════════════════
        DO $$
        DECLARE
        tbl TEXT;
        BEGIN
        FOR tbl INVALUES
        ('user_notes'), ('study_collections'), ('note_links'),
        ('bookmarks'), ('verse_highlights'), ('highlight_collections'),
        ('bookmarked_topics')
        LOOP
        EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl);
        EXECUTE format(
        'CREATE POLICY "Users own their %1$s" ON %1$I FOR ALL USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid())', tbl);
        END LOOP;
        END $$;
        -- ══════════════════════════════════════════════════-- INDEXES-- ══════════════════════════════════════════════════CREATEINDEXidx_notes_userON user_notes(user_id);
        CREATEINDEXidx_notes_updatedON user_notes(user_id, updated_at);
        CREATEINDEXidx_collections_userON study_collections(user_id);
        CREATEINDEXidx_note_links_userON note_links(user_id);
        CREATEINDEXidx_bookmarks_userON bookmarks(user_id);
        CREATEINDEXidx_bookmarks_updatedON bookmarks(user_id, created_at);
        CREATEINDEXidx_highlights_userON verse_highlights(user_id);
        CREATEINDEXidx_highlight_collections_userON highlight_collections(user_id);
        CREATEINDEXidx_bookmarked_topics_userON bookmarked_topics(user_id);
        -- ══════════════════════════════════════════════════-- AUTO-UPDATE updated_at-- ══════════════════════════════════════════════════CREATE OR REPLACEFUNCTIONupdate_updated_at()
        RETURNS TRIGGER AS $$
        BEGINNEW.updated_at= now();
        RETURN NEW;
        END;
        $$ LANGUAGE plpgsql;
        CREATETRIGGERtrg_notes_updated BEFORE UPDATEON user_notes
        FOR EACH ROW EXECUTE FUNCTION update_updated_at();
        CREATETRIGGERtrg_collections_updated BEFORE UPDATEON study_collections
        FOR EACH ROW EXECUTE FUNCTION update_updated_at();

        2B. Local Database Migration

        userDatabase.ts — Migration v15: Sync infrastructure

        The existing sync_queue table (from v14) has an incomplete schema. v15 extends it
        and adds cloud sync columns to all user tables.

        -- cloud_id for all synced tablesALTERTABLE user_notes ADD COLUMN cloud_id TEXT;
        ALTERTABLE study_collections ADD COLUMN cloud_id TEXT;
        ALTERTABLE note_links ADD COLUMN cloud_id TEXT;
        ALTERTABLE bookmarks ADD COLUMN cloud_id TEXT;
        ALTERTABLE verse_highlights ADD COLUMN cloud_id TEXT;
        ALTERTABLE highlight_collections ADD COLUMN cloud_id TEXT;
        ALTERTABLE bookmarked_topics ADD COLUMN cloud_id TEXT;
        -- Soft delete supportALTERTABLE user_notes ADD COLUMN deleted_at TEXT;
        ALTERTABLE study_collections ADD COLUMN deleted_at TEXT;
        ALTERTABLE bookmarks ADD COLUMN deleted_at TEXT;
        ALTERTABLE verse_highlights ADD COLUMN deleted_at TEXT;
        ALTERTABLE bookmarked_topics ADD COLUMN deleted_at TEXT;
        -- Extend existing sync_queue (from v14) with routing columnsALTERTABLE sync_queue ADD COLUMN table_name TEXTNOT NULL DEFAULT '';
        ALTERTABLE sync_queue ADD COLUMN local_id TEXTNOT NULL DEFAULT '';
        -- Sync state table (last_pull_at, migration flags)CREATETABLEIF NOT EXISTS sync_state (
        key TEXTPRIMARY KEY,
        value TEXTNOT NULL
        );
        -- LWW support for preferencesALTERTABLE user_preferences ADD COLUMN updated_at TEXT;
        -- Indexes for merge lookupsCREATEUNIQUE INDEXIF NOT EXISTS idx_notes_cloud_id ON user_notes(cloud_id);
        CREATEUNIQUE INDEXIF NOT EXISTS idx_collections_cloud_id ON study_collections(cloud_id);
        CREATEUNIQUE INDEXIF NOT EXISTS idx_note_links_cloud_id ON note_links(cloud_id);
        CREATEUNIQUE INDEXIF NOT EXISTS idx_bookmarks_cloud_id ON bookmarks(cloud_id);
        CREATEUNIQUE INDEXIF NOT EXISTS idx_highlights_cloud_id ON verse_highlights(cloud_id);
        CREATEUNIQUE INDEXIF NOT EXISTS idx_hl_collections_cloud_id ON highlight_collections(cloud_id);
        CREATEUNIQUE INDEXIF NOT EXISTS idx_bookmarked_topics_cloud_id ON bookmarked_topics(cloud_id);

        2C. New Files

        services/syncEngine.ts — Core sync orchestrator (~400 lines)

        The sync engine has three responsibilities:

        1. Queue writes — After every local write, enqueue a sync operation:

        exportasyncfunctionenqueueSync(tableName: SyncableTable,localId: string|number,operation: 'INSERT'|'UPDATE'|'DELETE',payload: Record<string,any>,): Promise<void>{constdb=getUserDb();awaitdb.runAsync(`INSERT INTO sync_queue (table_name, local_id, operation, payload_json) VALUES (?, ?, ?, ?)`,[tableName,String(localId),operation,JSON.stringify(payload)],);schedulePush();}

        2. Push — Flush the queue to Supabase:

        exportasyncfunctionpushQueue(): Promise<SyncResult>{constsupabase=getSupabase();if(!supabase)return{pushed: 0,failed: 0};constsession=awaitsupabase.auth.getSession();if(!session.data.session)return{pushed: 0,failed: 0};constdb=getUserDb();constpending=awaitdb.getAllAsync<SyncQueueRow>('SELECT * FROM sync_queue ORDER BY created_at ASC LIMIT 100',);letpushed=0,failed=0;for(constitemofpending){try{constpayload=JSON.parse(item.payload_json);awaitpushItem(supabase,item.table_name,item.operation,payload);awaitdb.runAsync('DELETE FROM sync_queue WHERE id = ?',[item.id]);pushed++;}catch(err){constretries=(item.attempts??0)+1;if(retries>=5){awaitdb.runAsync('DELETE FROM sync_queue WHERE id = ?',[item.id]);logger.error('Sync',`Dropping item after 5 retries`,item);}else{awaitdb.runAsync('UPDATE sync_queue SET attempts = ?, last_error = ? WHERE id = ?',[retries,String(err),item.id],);}failed++;}}return{ pushed, failed };}

        3. Pull — Fetch changes since last sync and merge:

        exportasyncfunctionpullChanges(): Promise<SyncResult>{constsupabase=getSupabase();if(!supabase)return{pulled: 0,conflicts: 0};constdb=getUserDb();constlastSync=awaitgetSyncState('last_pull_at');constsince=lastSync??'1970-01-01T00:00:00Z';letpulled=0,conflicts=0;for(consttableofSYNCABLE_TABLES){const{ data, error }=awaitsupabase.from(table).select('*').gte('updated_at',since).order('updated_at',{ascending: true});if(error){logger.error('Sync',`Pull failed for ${table}`,error);continue;}for(constrowofdata??[]){constresult=awaitmergeRow(db,table,row);if(result==='merged')pulled++;if(result==='conflict')conflicts++;}}awaitsetSyncState('last_pull_at',newDate().toISOString());return{ pulled, conflicts };}

        Merge logic — Last Write Wins:

        asyncfunctionmergeRow(db,table,cloudRow): Promise<'merged'|'conflict'|'skipped'>{constlocal=awaitdb.getFirstAsync(`SELECT * FROM ${table} WHERE cloud_id = ?`,[cloudRow.id]);if(!local){awaitinsertFromCloud(db,table,cloudRow);return'merged';}if(cloudRow.deleted_at){awaitdb.runAsync(`DELETE FROM ${table} WHERE cloud_id = ?`,[cloudRow.id]);return'merged';}constcloudTime=newDate(cloudRow.updated_at).getTime();constlocalTime=newDate(local.updated_at).getTime();if(cloudTime>localTime){awaitupdateFromCloud(db,table,cloudRow,local.id);return'merged';}returncloudTime===localTime ? 'skipped' : 'conflict';}

        services/syncScheduler.ts — Background sync timing (~80 lines)

        // Schedules sync operations:// 1. On app foreground (via AppState listener)// 2. Every 5 minutes while app is active// 3. On NetInfo change from offline → online// 4. Debounced 3s after any local write (via enqueueSync)import{AppState}from'react-native';importNetInfofrom'@react-native-community/netinfo';

        stores/syncStore.ts — Sync state for UI (~60 lines)

        interfaceSyncState{isSyncing: boolean;lastSyncAt: string|null;pendingCount: number;error: string|null;triggerSync: ()=>Promise<void>;refreshPendingCount: ()=>Promise<void>;}

        2D. Modifying db/userMutations.ts — Add Sync Triggers

        Note: The original issue referenced db/user.ts. That file was split into
        userQueries.ts (reads) and userMutations.ts (writes). All sync triggers
        go in userMutations.ts.

        Every write function gets a sync enqueue call. The pattern:

        exportasyncfunctionsaveNote(verseRef: string,text: string): Promise<number>{constcloudId=generateUUID();constresult=awaitgetUserDb().runAsync("INSERT INTO user_notes (verse_ref, note_text, cloud_id) VALUES (?, ?, ?)",[verseRef,text,cloudId]);constnoteId=result.lastInsertRowId;awaitgetUserDb().runAsync("INSERT INTO notes_fts(rowid, note_text) VALUES (?, ?)",[noteId,text]);enqueueSync('user_notes',noteId,'INSERT',{id: cloudId,verse_ref: verseRef,note_text: text,tags_json: '[]',collection_id: null,}).catch(noop);returnnoteId;}

        Soft delete pattern:

        exportasyncfunctiondeleteNote(id: number): Promise<void>{constrow=awaitgetUserDb().getFirstAsync<{cloud_id: string|null}>('SELECT cloud_id FROM user_notes WHERE id = ?',[id]);awaitgetUserDb().runAsync("UPDATE user_notes SET deleted_at = datetime('now') WHERE id = ?",[id]);awaitgetUserDb().runAsync("DELETE FROM notes_fts WHERE rowid = ?",[id]);if(row?.cloud_id){enqueueSync('user_notes',id,'DELETE',{id: row.cloud_id}).catch(noop);}}

        Complete function list (20 functions — corrected from original 14):

        #FunctionTableOperationNotes
        1saveNoteuser_notesINSERTGenerate cloud_id
        2updateNoteuser_notesUPDATE
        3deleteNoteuser_notesDELETE (soft)Set deleted_at, remove from FTS
        4updateNoteTagsuser_notesUPDATEAdded — missing from original
        5setNoteCollectionuser_notesUPDATEAdded — missing from original
        6addBookmarkbookmarksINSERTGenerate cloud_id
        7removeBookmarkbookmarksDELETE (soft)
        8setHighlightverse_highlightsUPSERTGenerate cloud_id on insert
        9removeHighlightverse_highlightsDELETE (soft)
        10createCollectionstudy_collectionsINSERTGenerate cloud_id
        11updateCollectionstudy_collectionsUPDATE
        12deleteCollectionstudy_collectionsDELETE (soft)
        13createHighlightCollectionhighlight_collectionsINSERTLocal ID is TEXT — use as cloud_id
        14deleteHighlightCollectionhighlight_collectionsDELETE (hard→soft)
        15linkNotesnote_linksINSERTGenerate cloud_id
        16unlinkNotesnote_linksDELETE (hard)
        17bookmarkTopicbookmarked_topicsUPSERTAdded — generate cloud_id
        18unbookmarkTopicbookmarked_topicsDELETE (soft)Added

        NOT synced (local-only):

        • startStudySession, endStudySession, recordSessionEvent — device-local analytics
        • flagContent — moderation, separate sync mechanism (v14 synced column)
        • upsertAuthProfile, clearAuthProfile — auth state, not user content
        • startPlan, completePlanDay, abandonPlan — synced in Session 3
        • resetToNewUser — dev tool

        UUID generation: Use expo-crypto (already installed):

        import*asCryptofrom'expo-crypto';exportfunctiongenerateUUID(): string{returnCrypto.randomUUID();}

        2E. Modifying db/userQueries.ts — Add Soft Delete Filters

        All read queries touching soft-deletable tables must add WHERE deleted_at IS NULL:

        FunctionTable
        getNotesForChapteruser_notes
        getNoteCountuser_notes
        getAllNotesuser_notes
        searchNotesuser_notes
        searchNotesFTSuser_notes
        getBookmarksbookmarks
        isBookmarkedbookmarks
        getHighlightsForChapterverse_highlights
        getAllHighlightsverse_highlights
        getCollectionsstudy_collections
        getCollectionstudy_collections
        getNotesInCollectionuser_notes
        getCollectionNoteCountsuser_notes
        getAllTagsuser_notes
        getNotesByTaguser_notes
        getLinkedNotesnote_links → user_notes
        getReferencingNotesnote_links → user_notes
        getBookmarkedTopicsbookmarked_topics
        isTopicBookmarkedbookmarked_topics

        2F. Tests

        __tests__/services/syncEngine.test.ts — New (~250 lines)

        describe('syncEngine',()=>{describe('enqueueSync',()=>{it('inserts queue entry with table_name and local_id',async()=>{ ... });it('schedules a push after enqueue',async()=>{ ... });});describe('pushQueue',()=>{it('pushes INSERT operations to Supabase upsert',async()=>{ ... });it('pushes DELETE as soft-delete',async()=>{ ... });it('removes queue entries after success',async()=>{ ... });it('increments attempts on failure',async()=>{ ... });it('drops items after 5 failed attempts',async()=>{ ... });it('does nothing when not authenticated',async()=>{ ... });});describe('pullChanges',()=>{it('fetches rows updated since last_pull_at',async()=>{ ... });it('inserts new cloud rows locally with cloud_id',async()=>{ ... });it('LWW: cloud wins on newer timestamp',async()=>{ ... });it('LWW: local wins on newer timestamp',async()=>{ ... });it('soft-deletes locally when cloud has deleted_at',async()=>{ ... });});});

        __tests__/db/userMutations.test.ts — New (~150 lines)

        Test each write function enqueues sync correctly:

        describe('userMutations sync integration',()=>{it('saveNote enqueues INSERT with cloud_id',async()=>{ ... });it('updateNote enqueues UPDATE',async()=>{ ... });it('deleteNote soft-deletes and enqueues DELETE',async()=>{ ... });it('updateNoteTags enqueues UPDATE',async()=>{ ... });it('bookmarkTopic enqueues UPSERT',async()=>{ ... });// ... one test per write function});

        __tests__/db/userQueries.test.ts — New (~80 lines)

        describe('userQueries soft-delete filtering',()=>{it('getAllNotes excludes soft-deleted rows',async()=>{ ... });it('getBookmarks excludes soft-deleted rows',async()=>{ ... });it('getBookmarkedTopics excludes soft-deleted rows',async()=>{ ... });// ... one test per query with soft-delete filter});

        2G. Session 2 Deliverables

        DeliverableVerification
        Cloud schema deployedTables visible in Supabase dashboard
        Migration v15 runs cleanlyApp launches, cloud_id columns exist
        Note CRUD syncsCreate note → appears in Supabase → pull on second device
        Highlight CRUD syncsSame
        Bookmark CRUD syncsSame
        Collection CRUD syncsSame
        Topic bookmark syncsSame
        Soft deletes workDelete note → deleted_at set → synced → hidden on other device
        Offline queue persistsAirplane mode → create note → reconnect → note pushes
        LWW conflict resolutionEdit same note on 2 devices → newer wins
        Tests passnpm test -- --testPathPattern="sync|user"

        Session 3: Reading Progress + Study Depth + Plans + Settings Sync

        Goal

        Sync reading progress (union merge), study depth (additive), reading plan progress,
        and user preferences (LWW).

        3A. Additional Cloud Tables

        CREATETABLEreading_progress (
        user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
        book_id TEXTNOT NULL,
        chapter_num INTEGERNOT NULL,
        completed_at TIMESTAMPTZ,
        PRIMARY KEY (user_id, book_id, chapter_num)
        );
        CREATETABLEstudy_depth (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
        chapter_id TEXTNOT NULL,
        section_id TEXTNOT NULL,
        panel_type TEXTNOT NULL,
        first_opened_at TIMESTAMPTZ DEFAULT now(),
        UNIQUE(user_id, section_id, panel_type)
        );
        CREATETABLEuser_preferences (
        user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
        key TEXTNOT NULL,
        value TEXTNOT NULL,
        updated_at TIMESTAMPTZ DEFAULT now(),
        PRIMARY KEY (user_id, key)
        );
        CREATETABLEreading_streaks (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
        dateTEXTNOT NULL,
        chapters_read INTEGERNOT NULL DEFAULT 0,
        books_touched TEXT,
        UNIQUE(user_id, date)
        );
        CREATETABLEplan_progress (
        user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
        plan_id TEXTNOT NULL,
        day_num INTEGERNOT NULL,
        completed_at TIMESTAMPTZ,
        PRIMARY KEY (user_id, plan_id, day_num)
        );
        -- RLS for all new tables
        DO $$
        DECLARE tbl TEXT;
        BEGIN
        FOR tbl INVALUES
        ('reading_progress'), ('study_depth'), ('user_preferences'),
        ('reading_streaks'), ('plan_progress')
        LOOP
        EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl);
        EXECUTE format(
        'CREATE POLICY "Users own their %1$s" ON %1$I FOR ALL USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid())', tbl);
        END LOOP;
        END $$;

        3B. Special Merge Strategies

        Reading Progress — Union Merge

        Never un-completes. If either side says completed, it stays completed.
        Keep the earliest completed_at timestamp.

        Study Depth — Additive Only

        Append-only. Cloud accumulates all panels ever opened. Merge = INSERT OR IGNORE.

        Reading Plans — Union Merge

        Plan progress uses same union logic as reading progress — once a day is completed,
        it stays completed.

        User Preferences — LWW

        Simple key-value pairs with updated_at for conflict resolution.

        3C. Sync Triggers for Session 3 Functions

        recordVisit in userMutations.ts: Enqueue sync for reading_progress.

        completePlanDay in userMutations.ts: Enqueue sync for plan_progress.

        settingsStore.ts preference setters: Each setPreference call gets a sync enqueue.

        3D. UI: Sync Status in Settings

        Add a "Sync" section to SettingsScreen:

        ┌──────────────────────────────────────┐
        │ SYNC │
        │ │
        │ Status ✓ Up to date │
        │ Last sync 2 min ago │
        │ Pending 0 changes │
        │ │
        │ [Sync Now] │
        │ │
        │ Only visible when signed in. │
        └──────────────────────────────────────┘
        

        3E. Session 3 Deliverables

        DeliverableVerification
        Reading progress syncs (union)Read chapter → mark on device A → appears on device B
        Never un-completesDelete cloud row → local stays completed
        Study depth syncs (additive)Open panel → record appears in Supabase
        Plan progress syncs (union)Complete day on A → appears on B
        Preferences sync (LWW)Change font size on A → appears on B
        Sync status UI in SettingsShows status, last sync time, pending count
        Tests passnpm test -- --testPathPattern="sync|settings"

        Session 4: First-Sign-In Migration + Edge Cases + Account Deletion

        Goal

        Handle the critical first-sign-in scenario (user has months of local data),
        edge cases (offline→online, large datasets, stale queues), and App Store-compliant
        account deletion.

        4A. First-Sign-In Migration

        services/initialMigration.ts — New (~150 lines)

        When a user signs in for the first time, they may have hundreds of notes, bookmarks,
        and reading progress entries with no cloud_id. One-time bulk upload:

        1. Check if migration already happened (sync_state.initial_migration_complete)
        2. Check cloud for existing data (returning user on new device?)
        3. Assign cloud_id to all local rows missing them
        4. Upload in batches of 50 (collections first for FK resolution, then notes, etc.)
        5. Mark complete

        Migration UI — SyncMigrationSheet

        Bottom-sheet showing progress: "Uploading 47 notes, 312 chapters read..." with
        progress bar. Auto-dismisses on completion.

        4B. Edge Cases

        • Offline → online flush:pushQueue() processes max 100 items per call, chains until empty
        • Queue compaction: When queue > 200 items, compact by keeping only latest per table+local_id
        • Note links with unmapped IDs: Upload notes before note_links; defer links with unresolved refs
        • Interrupted migration:initial_migration_complete only set on full success; idempotent upserts prevent duplicates on retry
        • Stale INSERT+DELETE pairs: Compaction drops both when INSERT never pushed

        4C. Account Deletion

        services/accountDeletion.ts — New (~50 lines)

        1. Invoke Supabase Edge Function delete-user-data (service_role deletes all user tables + auth account)
        2. Clear local auth state
        3. Stop sync scheduler
        4. Local study data preserved on device

        Supabase Edge Function: delete-user-data

        Server-side function using service_role key to delete all user rows across all tables
        and delete the auth account. Client-side RLS can't cascade across tables.

        Deletion UI in SettingsScreen

        "Delete Account" in a Danger Zone section (only visible when signed in).
        Two-step confirmation: alert → type "DELETE" to confirm.

        4D. Session 4 Deliverables

        DeliverableVerification
        First sign-in migration worksUser with 6 months data → sign in → all data in Supabase
        Returning user on new deviceSign in on device B → all data appears
        Migration progress UIBottom sheet shows progress during initial upload
        Offline queue compaction500 offline edits → compacted → clean push
        Account deletion worksDelete → cloud data gone → local data preserved
        Two-step delete confirmationCan't accidentally delete
        Edge function deployeddelete-user-data in Supabase Functions
        Tests passFull suite green

        Complete File Inventory (Corrected)

        New Files (10+)

        FileLines (est.)Session
        services/syncEngine.ts~4002
        services/syncScheduler.ts~802
        services/initialMigration.ts~1504
        services/accountDeletion.ts~504
        stores/syncStore.ts~602
        __tests__/services/syncEngine.test.ts~3502-3
        __tests__/services/syncScheduler.test.ts~602
        __tests__/db/userMutations.test.ts~1502
        __tests__/db/userQueries.test.ts~802
        __tests__/services/initialMigration.test.ts~1204
        __tests__/services/accountDeletion.test.ts~604
        __tests__/screens/LoginScreen.test.tsx~801

        Modified Files (12+)

        FileChange SummarySession
        lib/supabase.tsActivate, SecureStore adapter, real import1
        lib/oauthHelpers.tsAdd signInWithApple()1
        stores/authStore.tsAdd Apple, remove Facebook1
        screens/LoginScreen.tsxReplace Facebook → Apple, add guest option1
        screens/MoreMenuScreen.tsxEnhanced auth section + sync status1, 3
        screens/SettingsScreen.tsxSync status section + account deletion3, 4
        db/userDatabase.tsMigration v15: cloud_id, sync_queue extension, soft delete2
        db/userMutations.tsSync triggers on all 18+ write functions + soft delete2, 3
        db/userQueries.tsWHERE deleted_at IS NULL on 19+ queries2
        stores/settingsStore.tsSync preference changes3
        app.jsonApple auth entitlement + plugin1
        types/user.tsAdd cloud_id and deleted_at to interfaces2

        New Dependencies (6)

        PackagePurpose
        @supabase/supabase-jsSupabase client
        @react-native-async-storage/async-storageRequired by Supabase (session fallback)
        react-native-url-polyfillRequired by Supabase (URL parsing)
        expo-secure-storeEncrypted JWT storage
        expo-apple-authenticationNative Apple Sign-In
        @react-native-community/netinfoNetwork state for sync scheduler

        Test Summary (~75 tests)

        Test FileTests (est.)
        syncEngine.test.ts~30
        syncScheduler.test.ts~5
        userMutations.test.ts~18
        userQueries.test.ts~10
        initialMigration.test.ts~8
        accountDeletion.test.ts~5
        LoginScreen.test.tsx~6
        authStore.test.ts (extend)~3

        Risk Register

        RiskImpactMitigation
        Apple rejects without native Apple Sign-InApp Store rejectionUsing expo-apple-authentication (native), not web OAuth
        Supabase free tier limitsSync stops at 50K MAUUpgrade to Pro ($25/mo) — covered by revenue model
        Large initial migration failsUser data doesn't syncIdempotent upserts; retry on next launch; progress UI
        LWW loses edits on simultaneous editUser loses a note editAcceptable for v1; realtime sync (v2) reduces window
        expo-secure-store 2KB value limitJWT too largeSupabase JWTs are ~1KB; within limit
        NetInfo false positivesSync on captive portalsTimeout + retry with backoff
        Soft delete bloatDeleted rows accumulatePeriodic cleanup: hard-delete rows > 30 days old
        v14 sync_queue schema mismatchALTER may fail on existing datav15 uses ADD COLUMN with defaults; safe for existing rows

        Drift Corrections (April 2026)

        This issue was originally written against an older codebase. The following corrections
        were applied:

        1. db/user.ts split → now userQueries.ts (reads) + userMutations.ts (writes)
        2. Migrations at v14 (not v8) — sync migration is now v15
        3. sync_queue already exists (v14) — v15 extends it with table_name and local_id
        4. @supabase/supabase-js NOT installed — full dep install list corrected
        5. SignUpScreen.tsx and ForgotPasswordScreen.tsx exist — LoginScreen redesign simplified
        6. 20 write functions (not 14) — updateNoteTags, setNoteCollection, bookmarkTopic, unbookmarkTopic added
        7. highlight_collections.id is TEXT — can use local ID as cloud_id directly
        8. user_preferences needs updated_at — added to v15 migration
        9. supabase.ts uses env vars — kept (better than hardcoded)
        10. Decisions confirmed: Drop Facebook ✓, Sync bookmarked topics ✓, Sync reading plans ✓, Don't sync study sessions ✓

        Metadata

        Metadata

        Labels

        premiumPremium tier feature

        Projects

        No projects

          Milestone

          No milestone

          Relationships

          None yet

          Development

          No branches or pull requests

          Issue actions

          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
          Skip to content

          Code: A7. Cross-Device Sync — Supabase auth + bidirectional sync #65

          Description

          @CraigBuckmaster

          Summary

          Supabase Auth (Apple/Google/Email), bidirectional sync for all user data, offline queue, first-sign-in migration, account deletion. Premium feature.

          Sessions: 4–5 (sequential — each builds on the last)

          Prerequisite: Craig creates a Supabase project, configures Apple/Google OAuth providers, provides URL + anon key.

          Last updated: April 2026 — corrected against current codebase state. See "Drift Corrections" section at bottom.

          Session Breakdown

          SessionScopeKey Deliverables
          1Supabase Auth (Apple/Google/Email sign-in)Native Apple Sign-In, SecureStore JWT, LoginScreen redesign (Facebook → Apple), deps install
          2Sync engine + notes/highlights/bookmarks/topicsMigration v15 (cloud_id, sync_queue extension, soft delete), 20 write functions get sync triggers, push/pull with LWW
          3Reading progress + study depth + plans + settings syncUnion merge for progress, additive for depth, LWW for prefs, plan sync, sync status UI
          4First-sign-in migration + edge cases + account deletionBulk upload with progress UI, queue compaction, Edge Function for deletion

          Architecture

          • Offline-first: Local SQLite is always source of truth. Cloud is backup + sync layer. App never blocks on network.
          • Conflict resolution: Last-write-wins for notes/bookmarks/highlights/topics. Union merge for reading progress (never un-completes). Additive for study depth.
          • Soft deletes: All deletable entities get deleted_at column. Both sides honor soft deletes.
          • Queue-based push: Every local write enqueues to sync_queue. Debounced 3s push. Retry with backoff. Drop after 5 failures.

          File Impact

          • 10+ new files (services, stores, tests)
          • 12+ modified files (auth, db, settings, navigation)
          • 6 new dependencies
          • ~75 new tests

          Decisions (Confirmed)

          • Drop Facebook auth: Yes — heavy SDK, low value for Bible app demographic
          • Sync bookmarked topics: Yes — lightweight, user expects cross-device
          • Sync reading plans/progress: Yes — plan progress should follow user across devices
          • Sync study sessions: No — high-frequency device-local analytics, not user content
          • Sync flagged content: No — moderation, server-bound via separate mechanism (v14 synced column)

          Pre-Session Checklist (Craig's Homework)

          • Create Supabase project at supabase.com
          • Copy SUPABASE_URL and SUPABASE_ANON_KEY
          • Add both to .env, app.json extras, and eas.json build profiles
          • Enable Apple provider in Supabase Auth settings
            • Create Apple Services ID in Apple Developer Console
            • Generate a key for Sign in with Apple
            • Paste credentials into Supabase
          • Enable Google provider in Supabase Auth settings
            • Create OAuth 2.0 Client ID in Google Cloud Console
            • Paste Client ID + Secret into Supabase
          • Confirm scheme: "scripture" in app.json (already set ✓)
          • Confirm whether any production users are on migration v14 already (affects sync_queue strategy)

          Full Dev Plan

          A7. Cross-Device Sync — Dev Plan (Corrected April 2026)

          Scope: Supabase Auth (Apple/Google/Email), bidirectional sync for all user data,
          offline queue, first-sign-in migration, account deletion.

          Sessions: 4–5 (can't be parallelized — each builds on the last)

          Prerequisite: Craig creates a Supabase project at supabase.com, provides the
          SUPABASE_URL and SUPABASE_ANON_KEY, and configures Apple/Google OAuth providers
          in the Supabase dashboard.


          Current State (April 2026)

          WhatStatus
          authStore.ts178 lines, functional, Supabase client via env vars (process.env.SUPABASE_URL)
          LoginScreen.tsx336 lines, Google + Facebook + Email — missing Apple Sign-In
          SignUpScreen.tsx295 lines, exists and working
          ForgotPasswordScreen.tsx184 lines, exists and working
          lib/supabase.tsStub using process.env.SUPABASE_URL pattern, CONFIGURED = !!process.env.SUPABASE_URL
          lib/oauthHelpers.tsWorking Google/Facebook OAuth via expo-auth-session
          db/user.tsBarrel re-exportuserQueries.ts (496 lines) + userMutations.ts (372 lines)
          userDatabase.ts14 migrations, no cloud_id, no sync_state, no soft delete. sync_queue exists (v14) but incomplete schema
          Cloud schemaNone — Supabase project doesn't exist yet
          TestsauthStore.test.ts exists, supabaseMock.js exists
          Dependencies installedexpo-auth-session, expo-web-browser, expo-crypto
          Dependencies NOT installed@supabase/supabase-js, expo-secure-store, expo-apple-authentication, @react-native-community/netinfo, @react-native-async-storage/async-storage, react-native-url-polyfill

          Session 1: Supabase Auth (Apple/Google/Email Sign-In)

          Goal

          Users can sign in with Apple, Google, or email/password. Session persists across
          app restarts. Sign-out clears session. Account section in MoreMenu shows user info.
          Facebook auth removed.

          Pre-session (Craig)

          1. Create Supabase project → copy URL + anon key
          2. Add SUPABASE_URL and SUPABASE_ANON_KEY to .env, app.json extras, and eas.json build profiles
          3. In Supabase Dashboard → Authentication → Providers:
            • Enable Apple: add Services ID, Team ID, Key ID, private key
            • Enable Google: add Client ID + secret from Google Cloud Console
            • Enable Email: already on by default
          4. In Apple Developer Console:
            • Create a Services ID for "Sign in with Apple"
            • Register the Supabase callback URL as a redirect URI

          1A. Dependencies

          cd app/
          npx expo install @supabase/supabase-js @react-native-async-storage/async-storage \
          react-native-url-polyfill expo-secure-store expo-apple-authentication \
          @react-native-community/netinfo

          Already installed: expo-auth-session, expo-web-browser, expo-crypto.

          expo-apple-authentication is needed for native Apple Sign-In on iOS (the App
          Store rejects web-based Apple OAuth — it must use the native credential API).

          1B. Files to Modify

          lib/supabase.ts — Activate the client

          // Changes:// 1. Keep env var pattern (already correct): process.env.SUPABASE_URL// 2. Switch auth storage from AsyncStorage to expo-secure-store// (SecureStore is encrypted; AsyncStorage is not)// 3. Replace the placeholder SupabaseClient interface with actual importimport*asSecureStorefrom'expo-secure-store';constExpoSecureStoreAdapter={getItem: (key: string)=>SecureStore.getItemAsync(key),setItem: (key: string,value: string)=>SecureStore.setItemAsync(key,value),removeItem: (key: string)=>SecureStore.deleteItemAsync(key),};// In createClient options:
          auth: {storage: ExpoSecureStoreAdapter,autoRefreshToken: true,persistSession: true,detectSessionInUrl: false,flowType: 'pkce',}

          Note:expo-secure-store needs to be installed (see 1A above).

          lib/oauthHelpers.ts — Add Apple Sign-In

          Add signInWithApple() as a separate function (not a new case in signInWithProvider
          Apple uses native credential flow via signInWithIdToken, architecturally different from
          the browser-redirect flow used by Google):

          exportasyncfunctionsignInWithApple(): Promise<{error?: string}>{constAppleAuthentication=require('expo-apple-authentication');constsupabase=getSupabase();if(!supabase)return{error: 'Auth not available'};try{constcredential=awaitAppleAuthentication.signInAsync({requestedScopes: [AppleAuthentication.AppleAuthenticationScope.FULL_NAME,AppleAuthentication.AppleAuthenticationScope.EMAIL,],});if(!credential.identityToken){return{error: 'No identity token from Apple'};}const{ error }=awaitsupabase.auth.signInWithIdToken({provider: 'apple',token: credential.identityToken,});if(error)return{error: error.message};return{};}catch(err: any){if(err.code==='ERR_REQUEST_CANCELED')return{error: 'Sign-in canceled'};return{error: err.message??'Apple sign-in failed'};}}

          stores/authStore.ts — Add Apple, drop Facebook

          // Changes:// 1. Add signInWithApple method// 2. Remove signInWithFacebook from interface and implementation// 3. Keep signInWithGoogle and signInWithEmail as-issignInWithApple: async()=>{const{ isSupabaseAvailable }=getAuth();if(!isSupabaseAvailable()){return{error: 'Sign-in requires a development build.'};}set({isLoading: true});try{const{ signInWithApple }=require('../lib/oauthHelpers');returnawaitsignInWithApple();}finally{set({isLoading: false});},},

          screens/LoginScreen.tsx — Replace Facebook with Apple

          Since SignUpScreen.tsx and ForgotPasswordScreen.tsx already exist with working
          navigation routes, the redesign is simpler than originally planned:

          1. Replace Facebook button → Apple button (black bg, white text, Apple icon)
          2. Keep existing Google button
          3. Keep email form + existing navigation to SignUp / ForgotPassword
          4. Add "Continue without signing in" footer (navigation.goBack())
          5. Add privacy reassurance text
          6. Remove signInWithFacebook import/usage

          Layout (top to bottom):

          ┌─────────────────────────────────┐
          │ ScreenHeader "Sign In" │
          │ │
          │ "Sign in to sync your study │
          │ data across devices." │
          │ │
          │ ┌─────────────────────────┐ │
          │ │ ◉ Continue with Apple │ │ ← Black button, white text, Apple icon
          │ └─────────────────────────┘ │
          │ ┌─────────────────────────┐ │
          │ │ G Continue with Google│ │ ← White button, dark text
          │ └─────────────────────────┘ │
          │ │
          │ ──────── or ──────── │
          │ │
          │ [ Email ] │
          │ [ Password ] │
          │ ┌─────────────────────────┐ │
          │ │ Sign In │ │ ← Gold button
          │ └─────────────────────────┘ │
          │ Forgot password? ← navigates │
          │ to ForgotPasswordScreen │
          │ │
          │ Don't have an account? Sign Up │ ← navigates to SignUpScreen
          │ │
          │ ───────────────────────────── │
          │ Your study data stays on your │
          │ device even without an │
          │ account. Signing in adds │
          │ cross-device sync. │
          │ │
          │ ┌─────────────────────────┐ │
          │ │ Continue without │ │ ← Text button, muted
          │ │ signing in │ │
          │ └─────────────────────────┘ │
          └─────────────────────────────────┘
          

          screens/MoreMenuScreen.tsx — Enhanced auth section

          Currently shows user email when signed in, "Sign In" when not. Enhance:

          ┌──────────────────────────────────────┐
          │ SIGNED IN STATE: │
          │ │
          │ [Avatar] Craig Buckmaster │
          │ craig@companionstudy.app │
          │ ✓ Synced 2 min ago │ ← sync status line (Session 2)
          │ │
          │ [Sign Out] │
          │ │
          │ NOT SIGNED IN: │
          │ │
          │ [Lock icon] Sign In │
          │ Sync your study data │
          │ across devices │
          │ │
          └──────────────────────────────────────┘
          

          1C. App Config Changes

          app.json — Add Apple auth entitlement

          {
          "expo": {
          "ios": {
          "usesAppleSignIn": true
          },
          "plugins": [
          "expo-apple-authentication",
          // ... existing plugins (expo-font, expo-screen-orientation, etc.)
          ]
          }
          }

          scheme: "scripture" is already set ✓

          1D. Tests

          __tests__/stores/authStore.test.ts — Extend

          Add tests for:

          • signInWithApple dispatches correctly when available
          • signInWithApple returns error in Expo Go
          • Facebook methods are removed
          • Hydrate restores session from SecureStore
          • Sign-out clears SecureStore + local profile
          • Error states propagate correctly

          __tests__/screens/LoginScreen.test.tsx — New

          describe('LoginScreen',()=>{it('renders Apple, Google, and email sign-in options',()=>{ ... });it('does NOT render Facebook button',()=>{ ... });it('navigates to SignUpScreen on "Sign Up" press',()=>{ ... });it('navigates to ForgotPasswordScreen on "Forgot password" press',()=>{ ... });it('shows error message on failed sign-in',async()=>{ ... });it('navigates back on "Continue without signing in"',()=>{ ... });});

          __tests__/lib/supabase.test.ts — New

          describe('supabase client',()=>{it('returns null when env vars not set',()=>{ ... });it('creates client singleton when configured',()=>{ ... });it('uses SecureStore adapter for auth storage',()=>{ ... });});

          1E. Session 1 Deliverables

          DeliverableVerification
          Apple Sign-In works on deviceSign in → session persists on restart
          Google Sign-In worksSign in → session persists
          Email sign-in/sign-up worksCreate account → sign in → session persists
          Sign-out clears sessionSign out → MoreMenu shows "Sign In"
          Facebook removedNo Facebook button in UI, no Facebook in authStore
          Forgot password worksRequest reset → email received
          Tests passnpm test -- --testPathPattern="auth|Login|supabase"

          Session 2: Sync Engine + Notes/Highlights/Bookmarks/Topics

          Goal

          All user writes (notes, highlights, bookmarks, collections, note links, topic bookmarks)
          are queued for sync. On app launch and periodically, queued changes push to Supabase.
          Pull merges cloud changes into local SQLite.

          2A. Supabase Cloud Schema

          Run in Supabase SQL Editor (one-time setup):

          -- ══════════════════════════════════════════════════-- TABLES-- ══════════════════════════════════════════════════CREATETABLEuser_notes (
          id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
          user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
          verse_ref TEXTNOT NULL,
          note_text TEXTNOT NULL,
          tags_json TEXT DEFAULT '[]',
          collection_id UUID,
          created_at TIMESTAMPTZ DEFAULT now(),
          updated_at TIMESTAMPTZ DEFAULT now(),
          deleted_at TIMESTAMPTZ
          );
          CREATETABLEstudy_collections (
          id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
          user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
          name TEXTNOT NULL,
          description TEXT DEFAULT '',
          color TEXT DEFAULT '#bfa050',
          created_at TIMESTAMPTZ DEFAULT now(),
          updated_at TIMESTAMPTZ DEFAULT now(),
          deleted_at TIMESTAMPTZ
          );
          ALTERTABLE user_notes
          ADD CONSTRAINT fk_notes_collection
          FOREIGN KEY (collection_id) REFERENCES study_collections(id)
          ON DELETESETNULL;
          CREATETABLEnote_links (
          id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
          user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
          from_note_id UUID NOT NULLREFERENCES user_notes(id) ON DELETE CASCADE,
          to_note_id UUID NOT NULLREFERENCES user_notes(id) ON DELETE CASCADE,
          created_at TIMESTAMPTZ DEFAULT now()
          );
          CREATETABLEbookmarks (
          id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
          user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
          verse_ref TEXTNOT NULL,
          label TEXT,
          created_at TIMESTAMPTZ DEFAULT now(),
          deleted_at TIMESTAMPTZ
          );
          CREATETABLEverse_highlights (
          id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
          user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
          verse_ref TEXTNOT NULL,
          color TEXTNOT NULL,
          collection_id TEXT,
          note TEXT,
          created_at TIMESTAMPTZ DEFAULT now(),
          deleted_at TIMESTAMPTZ,
          UNIQUE(user_id, verse_ref)
          );
          CREATETABLEhighlight_collections (
          id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
          user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
          name TEXTNOT NULL,
          color TEXTNOT NULL,
          sort_order INTEGER DEFAULT 0,
          created_at TIMESTAMPTZ DEFAULT now()
          );
          CREATETABLEbookmarked_topics (
          id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
          user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
          topic_id TEXTNOT NULL,
          topic_type TEXTNOT NULL DEFAULT 'official',
          cached_title TEXT,
          cached_summary TEXT,
          created_at TIMESTAMPTZ DEFAULT now(),
          deleted_at TIMESTAMPTZ,
          UNIQUE(user_id, topic_id, topic_type)
          );
          -- ══════════════════════════════════════════════════-- ROW LEVEL SECURITY (all tables)-- ══════════════════════════════════════════════════
          DO $$
          DECLARE
          tbl TEXT;
          BEGIN
          FOR tbl INVALUES
          ('user_notes'), ('study_collections'), ('note_links'),
          ('bookmarks'), ('verse_highlights'), ('highlight_collections'),
          ('bookmarked_topics')
          LOOP
          EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl);
          EXECUTE format(
          'CREATE POLICY "Users own their %1$s" ON %1$I FOR ALL USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid())', tbl);
          END LOOP;
          END $$;
          -- ══════════════════════════════════════════════════-- INDEXES-- ══════════════════════════════════════════════════CREATEINDEXidx_notes_userON user_notes(user_id);
          CREATEINDEXidx_notes_updatedON user_notes(user_id, updated_at);
          CREATEINDEXidx_collections_userON study_collections(user_id);
          CREATEINDEXidx_note_links_userON note_links(user_id);
          CREATEINDEXidx_bookmarks_userON bookmarks(user_id);
          CREATEINDEXidx_bookmarks_updatedON bookmarks(user_id, created_at);
          CREATEINDEXidx_highlights_userON verse_highlights(user_id);
          CREATEINDEXidx_highlight_collections_userON highlight_collections(user_id);
          CREATEINDEXidx_bookmarked_topics_userON bookmarked_topics(user_id);
          -- ══════════════════════════════════════════════════-- AUTO-UPDATE updated_at-- ══════════════════════════════════════════════════CREATE OR REPLACEFUNCTIONupdate_updated_at()
          RETURNS TRIGGER AS $$
          BEGINNEW.updated_at= now();
          RETURN NEW;
          END;
          $$ LANGUAGE plpgsql;
          CREATETRIGGERtrg_notes_updated BEFORE UPDATEON user_notes
          FOR EACH ROW EXECUTE FUNCTION update_updated_at();
          CREATETRIGGERtrg_collections_updated BEFORE UPDATEON study_collections
          FOR EACH ROW EXECUTE FUNCTION update_updated_at();

          2B. Local Database Migration

          userDatabase.ts — Migration v15: Sync infrastructure

          The existing sync_queue table (from v14) has an incomplete schema. v15 extends it
          and adds cloud sync columns to all user tables.

          -- cloud_id for all synced tablesALTERTABLE user_notes ADD COLUMN cloud_id TEXT;
          ALTERTABLE study_collections ADD COLUMN cloud_id TEXT;
          ALTERTABLE note_links ADD COLUMN cloud_id TEXT;
          ALTERTABLE bookmarks ADD COLUMN cloud_id TEXT;
          ALTERTABLE verse_highlights ADD COLUMN cloud_id TEXT;
          ALTERTABLE highlight_collections ADD COLUMN cloud_id TEXT;
          ALTERTABLE bookmarked_topics ADD COLUMN cloud_id TEXT;
          -- Soft delete supportALTERTABLE user_notes ADD COLUMN deleted_at TEXT;
          ALTERTABLE study_collections ADD COLUMN deleted_at TEXT;
          ALTERTABLE bookmarks ADD COLUMN deleted_at TEXT;
          ALTERTABLE verse_highlights ADD COLUMN deleted_at TEXT;
          ALTERTABLE bookmarked_topics ADD COLUMN deleted_at TEXT;
          -- Extend existing sync_queue (from v14) with routing columnsALTERTABLE sync_queue ADD COLUMN table_name TEXTNOT NULL DEFAULT '';
          ALTERTABLE sync_queue ADD COLUMN local_id TEXTNOT NULL DEFAULT '';
          -- Sync state table (last_pull_at, migration flags)CREATETABLEIF NOT EXISTS sync_state (
          key TEXTPRIMARY KEY,
          value TEXTNOT NULL
          );
          -- LWW support for preferencesALTERTABLE user_preferences ADD COLUMN updated_at TEXT;
          -- Indexes for merge lookupsCREATEUNIQUE INDEXIF NOT EXISTS idx_notes_cloud_id ON user_notes(cloud_id);
          CREATEUNIQUE INDEXIF NOT EXISTS idx_collections_cloud_id ON study_collections(cloud_id);
          CREATEUNIQUE INDEXIF NOT EXISTS idx_note_links_cloud_id ON note_links(cloud_id);
          CREATEUNIQUE INDEXIF NOT EXISTS idx_bookmarks_cloud_id ON bookmarks(cloud_id);
          CREATEUNIQUE INDEXIF NOT EXISTS idx_highlights_cloud_id ON verse_highlights(cloud_id);
          CREATEUNIQUE INDEXIF NOT EXISTS idx_hl_collections_cloud_id ON highlight_collections(cloud_id);
          CREATEUNIQUE INDEXIF NOT EXISTS idx_bookmarked_topics_cloud_id ON bookmarked_topics(cloud_id);

          2C. New Files

          services/syncEngine.ts — Core sync orchestrator (~400 lines)

          The sync engine has three responsibilities:

          1. Queue writes — After every local write, enqueue a sync operation:

          exportasyncfunctionenqueueSync(tableName: SyncableTable,localId: string|number,operation: 'INSERT'|'UPDATE'|'DELETE',payload: Record<string,any>,): Promise<void>{constdb=getUserDb();awaitdb.runAsync(`INSERT INTO sync_queue (table_name, local_id, operation, payload_json) VALUES (?, ?, ?, ?)`,[tableName,String(localId),operation,JSON.stringify(payload)],);schedulePush();}

          2. Push — Flush the queue to Supabase:

          exportasyncfunctionpushQueue(): Promise<SyncResult>{constsupabase=getSupabase();if(!supabase)return{pushed: 0,failed: 0};constsession=awaitsupabase.auth.getSession();if(!session.data.session)return{pushed: 0,failed: 0};constdb=getUserDb();constpending=awaitdb.getAllAsync<SyncQueueRow>('SELECT * FROM sync_queue ORDER BY created_at ASC LIMIT 100',);letpushed=0,failed=0;for(constitemofpending){try{constpayload=JSON.parse(item.payload_json);awaitpushItem(supabase,item.table_name,item.operation,payload);awaitdb.runAsync('DELETE FROM sync_queue WHERE id = ?',[item.id]);pushed++;}catch(err){constretries=(item.attempts??0)+1;if(retries>=5){awaitdb.runAsync('DELETE FROM sync_queue WHERE id = ?',[item.id]);logger.error('Sync',`Dropping item after 5 retries`,item);}else{awaitdb.runAsync('UPDATE sync_queue SET attempts = ?, last_error = ? WHERE id = ?',[retries,String(err),item.id],);}failed++;}}return{ pushed, failed };}

          3. Pull — Fetch changes since last sync and merge:

          exportasyncfunctionpullChanges(): Promise<SyncResult>{constsupabase=getSupabase();if(!supabase)return{pulled: 0,conflicts: 0};constdb=getUserDb();constlastSync=awaitgetSyncState('last_pull_at');constsince=lastSync??'1970-01-01T00:00:00Z';letpulled=0,conflicts=0;for(consttableofSYNCABLE_TABLES){const{ data, error }=awaitsupabase.from(table).select('*').gte('updated_at',since).order('updated_at',{ascending: true});if(error){logger.error('Sync',`Pull failed for ${table}`,error);continue;}for(constrowofdata??[]){constresult=awaitmergeRow(db,table,row);if(result==='merged')pulled++;if(result==='conflict')conflicts++;}}awaitsetSyncState('last_pull_at',newDate().toISOString());return{ pulled, conflicts };}

          Merge logic — Last Write Wins:

          asyncfunctionmergeRow(db,table,cloudRow): Promise<'merged'|'conflict'|'skipped'>{constlocal=awaitdb.getFirstAsync(`SELECT * FROM ${table} WHERE cloud_id = ?`,[cloudRow.id]);if(!local){awaitinsertFromCloud(db,table,cloudRow);return'merged';}if(cloudRow.deleted_at){awaitdb.runAsync(`DELETE FROM ${table} WHERE cloud_id = ?`,[cloudRow.id]);return'merged';}constcloudTime=newDate(cloudRow.updated_at).getTime();constlocalTime=newDate(local.updated_at).getTime();if(cloudTime>localTime){awaitupdateFromCloud(db,table,cloudRow,local.id);return'merged';}returncloudTime===localTime ? 'skipped' : 'conflict';}

          services/syncScheduler.ts — Background sync timing (~80 lines)

          // Schedules sync operations:// 1. On app foreground (via AppState listener)// 2. Every 5 minutes while app is active// 3. On NetInfo change from offline → online// 4. Debounced 3s after any local write (via enqueueSync)import{AppState}from'react-native';importNetInfofrom'@react-native-community/netinfo';

          stores/syncStore.ts — Sync state for UI (~60 lines)

          interfaceSyncState{isSyncing: boolean;lastSyncAt: string|null;pendingCount: number;error: string|null;triggerSync: ()=>Promise<void>;refreshPendingCount: ()=>Promise<void>;}

          2D. Modifying db/userMutations.ts — Add Sync Triggers

          Note: The original issue referenced db/user.ts. That file was split into
          userQueries.ts (reads) and userMutations.ts (writes). All sync triggers
          go in userMutations.ts.

          Every write function gets a sync enqueue call. The pattern:

          exportasyncfunctionsaveNote(verseRef: string,text: string): Promise<number>{constcloudId=generateUUID();constresult=awaitgetUserDb().runAsync("INSERT INTO user_notes (verse_ref, note_text, cloud_id) VALUES (?, ?, ?)",[verseRef,text,cloudId]);constnoteId=result.lastInsertRowId;awaitgetUserDb().runAsync("INSERT INTO notes_fts(rowid, note_text) VALUES (?, ?)",[noteId,text]);enqueueSync('user_notes',noteId,'INSERT',{id: cloudId,verse_ref: verseRef,note_text: text,tags_json: '[]',collection_id: null,}).catch(noop);returnnoteId;}

          Soft delete pattern:

          exportasyncfunctiondeleteNote(id: number): Promise<void>{constrow=awaitgetUserDb().getFirstAsync<{cloud_id: string|null}>('SELECT cloud_id FROM user_notes WHERE id = ?',[id]);awaitgetUserDb().runAsync("UPDATE user_notes SET deleted_at = datetime('now') WHERE id = ?",[id]);awaitgetUserDb().runAsync("DELETE FROM notes_fts WHERE rowid = ?",[id]);if(row?.cloud_id){enqueueSync('user_notes',id,'DELETE',{id: row.cloud_id}).catch(noop);}}

          Complete function list (20 functions — corrected from original 14):

          #FunctionTableOperationNotes
          1saveNoteuser_notesINSERTGenerate cloud_id
          2updateNoteuser_notesUPDATE
          3deleteNoteuser_notesDELETE (soft)Set deleted_at, remove from FTS
          4updateNoteTagsuser_notesUPDATEAdded — missing from original
          5setNoteCollectionuser_notesUPDATEAdded — missing from original
          6addBookmarkbookmarksINSERTGenerate cloud_id
          7removeBookmarkbookmarksDELETE (soft)
          8setHighlightverse_highlightsUPSERTGenerate cloud_id on insert
          9removeHighlightverse_highlightsDELETE (soft)
          10createCollectionstudy_collectionsINSERTGenerate cloud_id
          11updateCollectionstudy_collectionsUPDATE
          12deleteCollectionstudy_collectionsDELETE (soft)
          13createHighlightCollectionhighlight_collectionsINSERTLocal ID is TEXT — use as cloud_id
          14deleteHighlightCollectionhighlight_collectionsDELETE (hard→soft)
          15linkNotesnote_linksINSERTGenerate cloud_id
          16unlinkNotesnote_linksDELETE (hard)
          17bookmarkTopicbookmarked_topicsUPSERTAdded — generate cloud_id
          18unbookmarkTopicbookmarked_topicsDELETE (soft)Added

          NOT synced (local-only):

          • startStudySession, endStudySession, recordSessionEvent — device-local analytics
          • flagContent — moderation, separate sync mechanism (v14 synced column)
          • upsertAuthProfile, clearAuthProfile — auth state, not user content
          • startPlan, completePlanDay, abandonPlan — synced in Session 3
          • resetToNewUser — dev tool

          UUID generation: Use expo-crypto (already installed):

          import*asCryptofrom'expo-crypto';exportfunctiongenerateUUID(): string{returnCrypto.randomUUID();}

          2E. Modifying db/userQueries.ts — Add Soft Delete Filters

          All read queries touching soft-deletable tables must add WHERE deleted_at IS NULL:

          FunctionTable
          getNotesForChapteruser_notes
          getNoteCountuser_notes
          getAllNotesuser_notes
          searchNotesuser_notes
          searchNotesFTSuser_notes
          getBookmarksbookmarks
          isBookmarkedbookmarks
          getHighlightsForChapterverse_highlights
          getAllHighlightsverse_highlights
          getCollectionsstudy_collections
          getCollectionstudy_collections
          getNotesInCollectionuser_notes
          getCollectionNoteCountsuser_notes
          getAllTagsuser_notes
          getNotesByTaguser_notes
          getLinkedNotesnote_links → user_notes
          getReferencingNotesnote_links → user_notes
          getBookmarkedTopicsbookmarked_topics
          isTopicBookmarkedbookmarked_topics

          2F. Tests

          __tests__/services/syncEngine.test.ts — New (~250 lines)

          describe('syncEngine',()=>{describe('enqueueSync',()=>{it('inserts queue entry with table_name and local_id',async()=>{ ... });it('schedules a push after enqueue',async()=>{ ... });});describe('pushQueue',()=>{it('pushes INSERT operations to Supabase upsert',async()=>{ ... });it('pushes DELETE as soft-delete',async()=>{ ... });it('removes queue entries after success',async()=>{ ... });it('increments attempts on failure',async()=>{ ... });it('drops items after 5 failed attempts',async()=>{ ... });it('does nothing when not authenticated',async()=>{ ... });});describe('pullChanges',()=>{it('fetches rows updated since last_pull_at',async()=>{ ... });it('inserts new cloud rows locally with cloud_id',async()=>{ ... });it('LWW: cloud wins on newer timestamp',async()=>{ ... });it('LWW: local wins on newer timestamp',async()=>{ ... });it('soft-deletes locally when cloud has deleted_at',async()=>{ ... });});});

          __tests__/db/userMutations.test.ts — New (~150 lines)

          Test each write function enqueues sync correctly:

          describe('userMutations sync integration',()=>{it('saveNote enqueues INSERT with cloud_id',async()=>{ ... });it('updateNote enqueues UPDATE',async()=>{ ... });it('deleteNote soft-deletes and enqueues DELETE',async()=>{ ... });it('updateNoteTags enqueues UPDATE',async()=>{ ... });it('bookmarkTopic enqueues UPSERT',async()=>{ ... });// ... one test per write function});

          __tests__/db/userQueries.test.ts — New (~80 lines)

          describe('userQueries soft-delete filtering',()=>{it('getAllNotes excludes soft-deleted rows',async()=>{ ... });it('getBookmarks excludes soft-deleted rows',async()=>{ ... });it('getBookmarkedTopics excludes soft-deleted rows',async()=>{ ... });// ... one test per query with soft-delete filter});

          2G. Session 2 Deliverables

          DeliverableVerification
          Cloud schema deployedTables visible in Supabase dashboard
          Migration v15 runs cleanlyApp launches, cloud_id columns exist
          Note CRUD syncsCreate note → appears in Supabase → pull on second device
          Highlight CRUD syncsSame
          Bookmark CRUD syncsSame
          Collection CRUD syncsSame
          Topic bookmark syncsSame
          Soft deletes workDelete note → deleted_at set → synced → hidden on other device
          Offline queue persistsAirplane mode → create note → reconnect → note pushes
          LWW conflict resolutionEdit same note on 2 devices → newer wins
          Tests passnpm test -- --testPathPattern="sync|user"

          Session 3: Reading Progress + Study Depth + Plans + Settings Sync

          Goal

          Sync reading progress (union merge), study depth (additive), reading plan progress,
          and user preferences (LWW).

          3A. Additional Cloud Tables

          CREATETABLEreading_progress (
          user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
          book_id TEXTNOT NULL,
          chapter_num INTEGERNOT NULL,
          completed_at TIMESTAMPTZ,
          PRIMARY KEY (user_id, book_id, chapter_num)
          );
          CREATETABLEstudy_depth (
          id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
          user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
          chapter_id TEXTNOT NULL,
          section_id TEXTNOT NULL,
          panel_type TEXTNOT NULL,
          first_opened_at TIMESTAMPTZ DEFAULT now(),
          UNIQUE(user_id, section_id, panel_type)
          );
          CREATETABLEuser_preferences (
          user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
          key TEXTNOT NULL,
          value TEXTNOT NULL,
          updated_at TIMESTAMPTZ DEFAULT now(),
          PRIMARY KEY (user_id, key)
          );
          CREATETABLEreading_streaks (
          id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
          user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
          dateTEXTNOT NULL,
          chapters_read INTEGERNOT NULL DEFAULT 0,
          books_touched TEXT,
          UNIQUE(user_id, date)
          );
          CREATETABLEplan_progress (
          user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
          plan_id TEXTNOT NULL,
          day_num INTEGERNOT NULL,
          completed_at TIMESTAMPTZ,
          PRIMARY KEY (user_id, plan_id, day_num)
          );
          -- RLS for all new tables
          DO $$
          DECLARE tbl TEXT;
          BEGIN
          FOR tbl INVALUES
          ('reading_progress'), ('study_depth'), ('user_preferences'),
          ('reading_streaks'), ('plan_progress')
          LOOP
          EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl);
          EXECUTE format(
          'CREATE POLICY "Users own their %1$s" ON %1$I FOR ALL USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid())', tbl);
          END LOOP;
          END $$;

          3B. Special Merge Strategies

          Reading Progress — Union Merge

          Never un-completes. If either side says completed, it stays completed.
          Keep the earliest completed_at timestamp.

          Study Depth — Additive Only

          Append-only. Cloud accumulates all panels ever opened. Merge = INSERT OR IGNORE.

          Reading Plans — Union Merge

          Plan progress uses same union logic as reading progress — once a day is completed,
          it stays completed.

          User Preferences — LWW

          Simple key-value pairs with updated_at for conflict resolution.

          3C. Sync Triggers for Session 3 Functions

          recordVisit in userMutations.ts: Enqueue sync for reading_progress.

          completePlanDay in userMutations.ts: Enqueue sync for plan_progress.

          settingsStore.ts preference setters: Each setPreference call gets a sync enqueue.

          3D. UI: Sync Status in Settings

          Add a "Sync" section to SettingsScreen:

          ┌──────────────────────────────────────┐
          │ SYNC │
          │ │
          │ Status ✓ Up to date │
          │ Last sync 2 min ago │
          │ Pending 0 changes │
          │ │
          │ [Sync Now] │
          │ │
          │ Only visible when signed in. │
          └──────────────────────────────────────┘
          

          3E. Session 3 Deliverables

          DeliverableVerification
          Reading progress syncs (union)Read chapter → mark on device A → appears on device B
          Never un-completesDelete cloud row → local stays completed
          Study depth syncs (additive)Open panel → record appears in Supabase
          Plan progress syncs (union)Complete day on A → appears on B
          Preferences sync (LWW)Change font size on A → appears on B
          Sync status UI in SettingsShows status, last sync time, pending count
          Tests passnpm test -- --testPathPattern="sync|settings"

          Session 4: First-Sign-In Migration + Edge Cases + Account Deletion

          Goal

          Handle the critical first-sign-in scenario (user has months of local data),
          edge cases (offline→online, large datasets, stale queues), and App Store-compliant
          account deletion.

          4A. First-Sign-In Migration

          services/initialMigration.ts — New (~150 lines)

          When a user signs in for the first time, they may have hundreds of notes, bookmarks,
          and reading progress entries with no cloud_id. One-time bulk upload:

          1. Check if migration already happened (sync_state.initial_migration_complete)
          2. Check cloud for existing data (returning user on new device?)
          3. Assign cloud_id to all local rows missing them
          4. Upload in batches of 50 (collections first for FK resolution, then notes, etc.)
          5. Mark complete

          Migration UI — SyncMigrationSheet

          Bottom-sheet showing progress: "Uploading 47 notes, 312 chapters read..." with
          progress bar. Auto-dismisses on completion.

          4B. Edge Cases

          • Offline → online flush:pushQueue() processes max 100 items per call, chains until empty
          • Queue compaction: When queue > 200 items, compact by keeping only latest per table+local_id
          • Note links with unmapped IDs: Upload notes before note_links; defer links with unresolved refs
          • Interrupted migration:initial_migration_complete only set on full success; idempotent upserts prevent duplicates on retry
          • Stale INSERT+DELETE pairs: Compaction drops both when INSERT never pushed

          4C. Account Deletion

          services/accountDeletion.ts — New (~50 lines)

          1. Invoke Supabase Edge Function delete-user-data (service_role deletes all user tables + auth account)
          2. Clear local auth state
          3. Stop sync scheduler
          4. Local study data preserved on device

          Supabase Edge Function: delete-user-data

          Server-side function using service_role key to delete all user rows across all tables
          and delete the auth account. Client-side RLS can't cascade across tables.

          Deletion UI in SettingsScreen

          "Delete Account" in a Danger Zone section (only visible when signed in).
          Two-step confirmation: alert → type "DELETE" to confirm.

          4D. Session 4 Deliverables

          DeliverableVerification
          First sign-in migration worksUser with 6 months data → sign in → all data in Supabase
          Returning user on new deviceSign in on device B → all data appears
          Migration progress UIBottom sheet shows progress during initial upload
          Offline queue compaction500 offline edits → compacted → clean push
          Account deletion worksDelete → cloud data gone → local data preserved
          Two-step delete confirmationCan't accidentally delete
          Edge function deployeddelete-user-data in Supabase Functions
          Tests passFull suite green

          Complete File Inventory (Corrected)

          New Files (10+)

          FileLines (est.)Session
          services/syncEngine.ts~4002
          services/syncScheduler.ts~802
          services/initialMigration.ts~1504
          services/accountDeletion.ts~504
          stores/syncStore.ts~602
          __tests__/services/syncEngine.test.ts~3502-3
          __tests__/services/syncScheduler.test.ts~602
          __tests__/db/userMutations.test.ts~1502
          __tests__/db/userQueries.test.ts~802
          __tests__/services/initialMigration.test.ts~1204
          __tests__/services/accountDeletion.test.ts~604
          __tests__/screens/LoginScreen.test.tsx~801

          Modified Files (12+)

          FileChange SummarySession
          lib/supabase.tsActivate, SecureStore adapter, real import1
          lib/oauthHelpers.tsAdd signInWithApple()1
          stores/authStore.tsAdd Apple, remove Facebook1
          screens/LoginScreen.tsxReplace Facebook → Apple, add guest option1
          screens/MoreMenuScreen.tsxEnhanced auth section + sync status1, 3
          screens/SettingsScreen.tsxSync status section + account deletion3, 4
          db/userDatabase.tsMigration v15: cloud_id, sync_queue extension, soft delete2
          db/userMutations.tsSync triggers on all 18+ write functions + soft delete2, 3
          db/userQueries.tsWHERE deleted_at IS NULL on 19+ queries2
          stores/settingsStore.tsSync preference changes3
          app.jsonApple auth entitlement + plugin1
          types/user.tsAdd cloud_id and deleted_at to interfaces2

          New Dependencies (6)

          PackagePurpose
          @supabase/supabase-jsSupabase client
          @react-native-async-storage/async-storageRequired by Supabase (session fallback)
          react-native-url-polyfillRequired by Supabase (URL parsing)
          expo-secure-storeEncrypted JWT storage
          expo-apple-authenticationNative Apple Sign-In
          @react-native-community/netinfoNetwork state for sync scheduler

          Test Summary (~75 tests)

          Test FileTests (est.)
          syncEngine.test.ts~30
          syncScheduler.test.ts~5
          userMutations.test.ts~18
          userQueries.test.ts~10
          initialMigration.test.ts~8
          accountDeletion.test.ts~5
          LoginScreen.test.tsx~6
          authStore.test.ts (extend)~3

          Risk Register

          RiskImpactMitigation
          Apple rejects without native Apple Sign-InApp Store rejectionUsing expo-apple-authentication (native), not web OAuth
          Supabase free tier limitsSync stops at 50K MAUUpgrade to Pro ($25/mo) — covered by revenue model
          Large initial migration failsUser data doesn't syncIdempotent upserts; retry on next launch; progress UI
          LWW loses edits on simultaneous editUser loses a note editAcceptable for v1; realtime sync (v2) reduces window
          expo-secure-store 2KB value limitJWT too largeSupabase JWTs are ~1KB; within limit
          NetInfo false positivesSync on captive portalsTimeout + retry with backoff
          Soft delete bloatDeleted rows accumulatePeriodic cleanup: hard-delete rows > 30 days old
          v14 sync_queue schema mismatchALTER may fail on existing datav15 uses ADD COLUMN with defaults; safe for existing rows

          Drift Corrections (April 2026)

          This issue was originally written against an older codebase. The following corrections
          were applied:

          1. db/user.ts split → now userQueries.ts (reads) + userMutations.ts (writes)
          2. Migrations at v14 (not v8) — sync migration is now v15
          3. sync_queue already exists (v14) — v15 extends it with table_name and local_id
          4. @supabase/supabase-js NOT installed — full dep install list corrected
          5. SignUpScreen.tsx and ForgotPasswordScreen.tsx exist — LoginScreen redesign simplified
          6. 20 write functions (not 14) — updateNoteTags, setNoteCollection, bookmarkTopic, unbookmarkTopic added
          7. highlight_collections.id is TEXT — can use local ID as cloud_id directly
          8. user_preferences needs updated_at — added to v15 migration
          9. supabase.ts uses env vars — kept (better than hardcoded)
          10. Decisions confirmed: Drop Facebook ✓, Sync bookmarked topics ✓, Sync reading plans ✓, Don't sync study sessions ✓

          Metadata

          Metadata

          Labels

          premiumPremium tier feature

          Projects

          No projects

            Milestone

            No milestone

            Relationships

            None yet

            Development

            No branches or pull requests

            Issue actions

            , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
            Skip to content

            Code: A7. Cross-Device Sync — Supabase auth + bidirectional sync #65

            Description

            @CraigBuckmaster

            Summary

            Supabase Auth (Apple/Google/Email), bidirectional sync for all user data, offline queue, first-sign-in migration, account deletion. Premium feature.

            Sessions: 4–5 (sequential — each builds on the last)

            Prerequisite: Craig creates a Supabase project, configures Apple/Google OAuth providers, provides URL + anon key.

            Last updated: April 2026 — corrected against current codebase state. See "Drift Corrections" section at bottom.

            Session Breakdown

            SessionScopeKey Deliverables
            1Supabase Auth (Apple/Google/Email sign-in)Native Apple Sign-In, SecureStore JWT, LoginScreen redesign (Facebook → Apple), deps install
            2Sync engine + notes/highlights/bookmarks/topicsMigration v15 (cloud_id, sync_queue extension, soft delete), 20 write functions get sync triggers, push/pull with LWW
            3Reading progress + study depth + plans + settings syncUnion merge for progress, additive for depth, LWW for prefs, plan sync, sync status UI
            4First-sign-in migration + edge cases + account deletionBulk upload with progress UI, queue compaction, Edge Function for deletion

            Architecture

            • Offline-first: Local SQLite is always source of truth. Cloud is backup + sync layer. App never blocks on network.
            • Conflict resolution: Last-write-wins for notes/bookmarks/highlights/topics. Union merge for reading progress (never un-completes). Additive for study depth.
            • Soft deletes: All deletable entities get deleted_at column. Both sides honor soft deletes.
            • Queue-based push: Every local write enqueues to sync_queue. Debounced 3s push. Retry with backoff. Drop after 5 failures.

            File Impact

            • 10+ new files (services, stores, tests)
            • 12+ modified files (auth, db, settings, navigation)
            • 6 new dependencies
            • ~75 new tests

            Decisions (Confirmed)

            • Drop Facebook auth: Yes — heavy SDK, low value for Bible app demographic
            • Sync bookmarked topics: Yes — lightweight, user expects cross-device
            • Sync reading plans/progress: Yes — plan progress should follow user across devices
            • Sync study sessions: No — high-frequency device-local analytics, not user content
            • Sync flagged content: No — moderation, server-bound via separate mechanism (v14 synced column)

            Pre-Session Checklist (Craig's Homework)

            • Create Supabase project at supabase.com
            • Copy SUPABASE_URL and SUPABASE_ANON_KEY
            • Add both to .env, app.json extras, and eas.json build profiles
            • Enable Apple provider in Supabase Auth settings
              • Create Apple Services ID in Apple Developer Console
              • Generate a key for Sign in with Apple
              • Paste credentials into Supabase
            • Enable Google provider in Supabase Auth settings
              • Create OAuth 2.0 Client ID in Google Cloud Console
              • Paste Client ID + Secret into Supabase
            • Confirm scheme: "scripture" in app.json (already set ✓)
            • Confirm whether any production users are on migration v14 already (affects sync_queue strategy)

            Full Dev Plan

            A7. Cross-Device Sync — Dev Plan (Corrected April 2026)

            Scope: Supabase Auth (Apple/Google/Email), bidirectional sync for all user data,
            offline queue, first-sign-in migration, account deletion.

            Sessions: 4–5 (can't be parallelized — each builds on the last)

            Prerequisite: Craig creates a Supabase project at supabase.com, provides the
            SUPABASE_URL and SUPABASE_ANON_KEY, and configures Apple/Google OAuth providers
            in the Supabase dashboard.


            Current State (April 2026)

            WhatStatus
            authStore.ts178 lines, functional, Supabase client via env vars (process.env.SUPABASE_URL)
            LoginScreen.tsx336 lines, Google + Facebook + Email — missing Apple Sign-In
            SignUpScreen.tsx295 lines, exists and working
            ForgotPasswordScreen.tsx184 lines, exists and working
            lib/supabase.tsStub using process.env.SUPABASE_URL pattern, CONFIGURED = !!process.env.SUPABASE_URL
            lib/oauthHelpers.tsWorking Google/Facebook OAuth via expo-auth-session
            db/user.tsBarrel re-exportuserQueries.ts (496 lines) + userMutations.ts (372 lines)
            userDatabase.ts14 migrations, no cloud_id, no sync_state, no soft delete. sync_queue exists (v14) but incomplete schema
            Cloud schemaNone — Supabase project doesn't exist yet
            TestsauthStore.test.ts exists, supabaseMock.js exists
            Dependencies installedexpo-auth-session, expo-web-browser, expo-crypto
            Dependencies NOT installed@supabase/supabase-js, expo-secure-store, expo-apple-authentication, @react-native-community/netinfo, @react-native-async-storage/async-storage, react-native-url-polyfill

            Session 1: Supabase Auth (Apple/Google/Email Sign-In)

            Goal

            Users can sign in with Apple, Google, or email/password. Session persists across
            app restarts. Sign-out clears session. Account section in MoreMenu shows user info.
            Facebook auth removed.

            Pre-session (Craig)

            1. Create Supabase project → copy URL + anon key
            2. Add SUPABASE_URL and SUPABASE_ANON_KEY to .env, app.json extras, and eas.json build profiles
            3. In Supabase Dashboard → Authentication → Providers:
              • Enable Apple: add Services ID, Team ID, Key ID, private key
              • Enable Google: add Client ID + secret from Google Cloud Console
              • Enable Email: already on by default
            4. In Apple Developer Console:
              • Create a Services ID for "Sign in with Apple"
              • Register the Supabase callback URL as a redirect URI

            1A. Dependencies

            cd app/
            npx expo install @supabase/supabase-js @react-native-async-storage/async-storage \
            react-native-url-polyfill expo-secure-store expo-apple-authentication \
            @react-native-community/netinfo

            Already installed: expo-auth-session, expo-web-browser, expo-crypto.

            expo-apple-authentication is needed for native Apple Sign-In on iOS (the App
            Store rejects web-based Apple OAuth — it must use the native credential API).

            1B. Files to Modify

            lib/supabase.ts — Activate the client

            // Changes:// 1. Keep env var pattern (already correct): process.env.SUPABASE_URL// 2. Switch auth storage from AsyncStorage to expo-secure-store// (SecureStore is encrypted; AsyncStorage is not)// 3. Replace the placeholder SupabaseClient interface with actual importimport*asSecureStorefrom'expo-secure-store';constExpoSecureStoreAdapter={getItem: (key: string)=>SecureStore.getItemAsync(key),setItem: (key: string,value: string)=>SecureStore.setItemAsync(key,value),removeItem: (key: string)=>SecureStore.deleteItemAsync(key),};// In createClient options:
            auth: {storage: ExpoSecureStoreAdapter,autoRefreshToken: true,persistSession: true,detectSessionInUrl: false,flowType: 'pkce',}

            Note:expo-secure-store needs to be installed (see 1A above).

            lib/oauthHelpers.ts — Add Apple Sign-In

            Add signInWithApple() as a separate function (not a new case in signInWithProvider
            Apple uses native credential flow via signInWithIdToken, architecturally different from
            the browser-redirect flow used by Google):

            exportasyncfunctionsignInWithApple(): Promise<{error?: string}>{constAppleAuthentication=require('expo-apple-authentication');constsupabase=getSupabase();if(!supabase)return{error: 'Auth not available'};try{constcredential=awaitAppleAuthentication.signInAsync({requestedScopes: [AppleAuthentication.AppleAuthenticationScope.FULL_NAME,AppleAuthentication.AppleAuthenticationScope.EMAIL,],});if(!credential.identityToken){return{error: 'No identity token from Apple'};}const{ error }=awaitsupabase.auth.signInWithIdToken({provider: 'apple',token: credential.identityToken,});if(error)return{error: error.message};return{};}catch(err: any){if(err.code==='ERR_REQUEST_CANCELED')return{error: 'Sign-in canceled'};return{error: err.message??'Apple sign-in failed'};}}

            stores/authStore.ts — Add Apple, drop Facebook

            // Changes:// 1. Add signInWithApple method// 2. Remove signInWithFacebook from interface and implementation// 3. Keep signInWithGoogle and signInWithEmail as-issignInWithApple: async()=>{const{ isSupabaseAvailable }=getAuth();if(!isSupabaseAvailable()){return{error: 'Sign-in requires a development build.'};}set({isLoading: true});try{const{ signInWithApple }=require('../lib/oauthHelpers');returnawaitsignInWithApple();}finally{set({isLoading: false});},},

            screens/LoginScreen.tsx — Replace Facebook with Apple

            Since SignUpScreen.tsx and ForgotPasswordScreen.tsx already exist with working
            navigation routes, the redesign is simpler than originally planned:

            1. Replace Facebook button → Apple button (black bg, white text, Apple icon)
            2. Keep existing Google button
            3. Keep email form + existing navigation to SignUp / ForgotPassword
            4. Add "Continue without signing in" footer (navigation.goBack())
            5. Add privacy reassurance text
            6. Remove signInWithFacebook import/usage

            Layout (top to bottom):

            ┌─────────────────────────────────┐
            │ ScreenHeader "Sign In" │
            │ │
            │ "Sign in to sync your study │
            │ data across devices." │
            │ │
            │ ┌─────────────────────────┐ │
            │ │ ◉ Continue with Apple │ │ ← Black button, white text, Apple icon
            │ └─────────────────────────┘ │
            │ ┌─────────────────────────┐ │
            │ │ G Continue with Google│ │ ← White button, dark text
            │ └─────────────────────────┘ │
            │ │
            │ ──────── or ──────── │
            │ │
            │ [ Email ] │
            │ [ Password ] │
            │ ┌─────────────────────────┐ │
            │ │ Sign In │ │ ← Gold button
            │ └─────────────────────────┘ │
            │ Forgot password? ← navigates │
            │ to ForgotPasswordScreen │
            │ │
            │ Don't have an account? Sign Up │ ← navigates to SignUpScreen
            │ │
            │ ───────────────────────────── │
            │ Your study data stays on your │
            │ device even without an │
            │ account. Signing in adds │
            │ cross-device sync. │
            │ │
            │ ┌─────────────────────────┐ │
            │ │ Continue without │ │ ← Text button, muted
            │ │ signing in │ │
            │ └─────────────────────────┘ │
            └─────────────────────────────────┘
            

            screens/MoreMenuScreen.tsx — Enhanced auth section

            Currently shows user email when signed in, "Sign In" when not. Enhance:

            ┌──────────────────────────────────────┐
            │ SIGNED IN STATE: │
            │ │
            │ [Avatar] Craig Buckmaster │
            │ craig@companionstudy.app │
            │ ✓ Synced 2 min ago │ ← sync status line (Session 2)
            │ │
            │ [Sign Out] │
            │ │
            │ NOT SIGNED IN: │
            │ │
            │ [Lock icon] Sign In │
            │ Sync your study data │
            │ across devices │
            │ │
            └──────────────────────────────────────┘
            

            1C. App Config Changes

            app.json — Add Apple auth entitlement

            {
            "expo": {
            "ios": {
            "usesAppleSignIn": true
            },
            "plugins": [
            "expo-apple-authentication",
            // ... existing plugins (expo-font, expo-screen-orientation, etc.)
            ]
            }
            }

            scheme: "scripture" is already set ✓

            1D. Tests

            __tests__/stores/authStore.test.ts — Extend

            Add tests for:

            • signInWithApple dispatches correctly when available
            • signInWithApple returns error in Expo Go
            • Facebook methods are removed
            • Hydrate restores session from SecureStore
            • Sign-out clears SecureStore + local profile
            • Error states propagate correctly

            __tests__/screens/LoginScreen.test.tsx — New

            describe('LoginScreen',()=>{it('renders Apple, Google, and email sign-in options',()=>{ ... });it('does NOT render Facebook button',()=>{ ... });it('navigates to SignUpScreen on "Sign Up" press',()=>{ ... });it('navigates to ForgotPasswordScreen on "Forgot password" press',()=>{ ... });it('shows error message on failed sign-in',async()=>{ ... });it('navigates back on "Continue without signing in"',()=>{ ... });});

            __tests__/lib/supabase.test.ts — New

            describe('supabase client',()=>{it('returns null when env vars not set',()=>{ ... });it('creates client singleton when configured',()=>{ ... });it('uses SecureStore adapter for auth storage',()=>{ ... });});

            1E. Session 1 Deliverables

            DeliverableVerification
            Apple Sign-In works on deviceSign in → session persists on restart
            Google Sign-In worksSign in → session persists
            Email sign-in/sign-up worksCreate account → sign in → session persists
            Sign-out clears sessionSign out → MoreMenu shows "Sign In"
            Facebook removedNo Facebook button in UI, no Facebook in authStore
            Forgot password worksRequest reset → email received
            Tests passnpm test -- --testPathPattern="auth|Login|supabase"

            Session 2: Sync Engine + Notes/Highlights/Bookmarks/Topics

            Goal

            All user writes (notes, highlights, bookmarks, collections, note links, topic bookmarks)
            are queued for sync. On app launch and periodically, queued changes push to Supabase.
            Pull merges cloud changes into local SQLite.

            2A. Supabase Cloud Schema

            Run in Supabase SQL Editor (one-time setup):

            -- ══════════════════════════════════════════════════-- TABLES-- ══════════════════════════════════════════════════CREATETABLEuser_notes (
            id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
            user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
            verse_ref TEXTNOT NULL,
            note_text TEXTNOT NULL,
            tags_json TEXT DEFAULT '[]',
            collection_id UUID,
            created_at TIMESTAMPTZ DEFAULT now(),
            updated_at TIMESTAMPTZ DEFAULT now(),
            deleted_at TIMESTAMPTZ
            );
            CREATETABLEstudy_collections (
            id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
            user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
            name TEXTNOT NULL,
            description TEXT DEFAULT '',
            color TEXT DEFAULT '#bfa050',
            created_at TIMESTAMPTZ DEFAULT now(),
            updated_at TIMESTAMPTZ DEFAULT now(),
            deleted_at TIMESTAMPTZ
            );
            ALTERTABLE user_notes
            ADD CONSTRAINT fk_notes_collection
            FOREIGN KEY (collection_id) REFERENCES study_collections(id)
            ON DELETESETNULL;
            CREATETABLEnote_links (
            id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
            user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
            from_note_id UUID NOT NULLREFERENCES user_notes(id) ON DELETE CASCADE,
            to_note_id UUID NOT NULLREFERENCES user_notes(id) ON DELETE CASCADE,
            created_at TIMESTAMPTZ DEFAULT now()
            );
            CREATETABLEbookmarks (
            id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
            user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
            verse_ref TEXTNOT NULL,
            label TEXT,
            created_at TIMESTAMPTZ DEFAULT now(),
            deleted_at TIMESTAMPTZ
            );
            CREATETABLEverse_highlights (
            id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
            user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
            verse_ref TEXTNOT NULL,
            color TEXTNOT NULL,
            collection_id TEXT,
            note TEXT,
            created_at TIMESTAMPTZ DEFAULT now(),
            deleted_at TIMESTAMPTZ,
            UNIQUE(user_id, verse_ref)
            );
            CREATETABLEhighlight_collections (
            id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
            user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
            name TEXTNOT NULL,
            color TEXTNOT NULL,
            sort_order INTEGER DEFAULT 0,
            created_at TIMESTAMPTZ DEFAULT now()
            );
            CREATETABLEbookmarked_topics (
            id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
            user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
            topic_id TEXTNOT NULL,
            topic_type TEXTNOT NULL DEFAULT 'official',
            cached_title TEXT,
            cached_summary TEXT,
            created_at TIMESTAMPTZ DEFAULT now(),
            deleted_at TIMESTAMPTZ,
            UNIQUE(user_id, topic_id, topic_type)
            );
            -- ══════════════════════════════════════════════════-- ROW LEVEL SECURITY (all tables)-- ══════════════════════════════════════════════════
            DO $$
            DECLARE
            tbl TEXT;
            BEGIN
            FOR tbl INVALUES
            ('user_notes'), ('study_collections'), ('note_links'),
            ('bookmarks'), ('verse_highlights'), ('highlight_collections'),
            ('bookmarked_topics')
            LOOP
            EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl);
            EXECUTE format(
            'CREATE POLICY "Users own their %1$s" ON %1$I FOR ALL USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid())', tbl);
            END LOOP;
            END $$;
            -- ══════════════════════════════════════════════════-- INDEXES-- ══════════════════════════════════════════════════CREATEINDEXidx_notes_userON user_notes(user_id);
            CREATEINDEXidx_notes_updatedON user_notes(user_id, updated_at);
            CREATEINDEXidx_collections_userON study_collections(user_id);
            CREATEINDEXidx_note_links_userON note_links(user_id);
            CREATEINDEXidx_bookmarks_userON bookmarks(user_id);
            CREATEINDEXidx_bookmarks_updatedON bookmarks(user_id, created_at);
            CREATEINDEXidx_highlights_userON verse_highlights(user_id);
            CREATEINDEXidx_highlight_collections_userON highlight_collections(user_id);
            CREATEINDEXidx_bookmarked_topics_userON bookmarked_topics(user_id);
            -- ══════════════════════════════════════════════════-- AUTO-UPDATE updated_at-- ══════════════════════════════════════════════════CREATE OR REPLACEFUNCTIONupdate_updated_at()
            RETURNS TRIGGER AS $$
            BEGINNEW.updated_at= now();
            RETURN NEW;
            END;
            $$ LANGUAGE plpgsql;
            CREATETRIGGERtrg_notes_updated BEFORE UPDATEON user_notes
            FOR EACH ROW EXECUTE FUNCTION update_updated_at();
            CREATETRIGGERtrg_collections_updated BEFORE UPDATEON study_collections
            FOR EACH ROW EXECUTE FUNCTION update_updated_at();

            2B. Local Database Migration

            userDatabase.ts — Migration v15: Sync infrastructure

            The existing sync_queue table (from v14) has an incomplete schema. v15 extends it
            and adds cloud sync columns to all user tables.

            -- cloud_id for all synced tablesALTERTABLE user_notes ADD COLUMN cloud_id TEXT;
            ALTERTABLE study_collections ADD COLUMN cloud_id TEXT;
            ALTERTABLE note_links ADD COLUMN cloud_id TEXT;
            ALTERTABLE bookmarks ADD COLUMN cloud_id TEXT;
            ALTERTABLE verse_highlights ADD COLUMN cloud_id TEXT;
            ALTERTABLE highlight_collections ADD COLUMN cloud_id TEXT;
            ALTERTABLE bookmarked_topics ADD COLUMN cloud_id TEXT;
            -- Soft delete supportALTERTABLE user_notes ADD COLUMN deleted_at TEXT;
            ALTERTABLE study_collections ADD COLUMN deleted_at TEXT;
            ALTERTABLE bookmarks ADD COLUMN deleted_at TEXT;
            ALTERTABLE verse_highlights ADD COLUMN deleted_at TEXT;
            ALTERTABLE bookmarked_topics ADD COLUMN deleted_at TEXT;
            -- Extend existing sync_queue (from v14) with routing columnsALTERTABLE sync_queue ADD COLUMN table_name TEXTNOT NULL DEFAULT '';
            ALTERTABLE sync_queue ADD COLUMN local_id TEXTNOT NULL DEFAULT '';
            -- Sync state table (last_pull_at, migration flags)CREATETABLEIF NOT EXISTS sync_state (
            key TEXTPRIMARY KEY,
            value TEXTNOT NULL
            );
            -- LWW support for preferencesALTERTABLE user_preferences ADD COLUMN updated_at TEXT;
            -- Indexes for merge lookupsCREATEUNIQUE INDEXIF NOT EXISTS idx_notes_cloud_id ON user_notes(cloud_id);
            CREATEUNIQUE INDEXIF NOT EXISTS idx_collections_cloud_id ON study_collections(cloud_id);
            CREATEUNIQUE INDEXIF NOT EXISTS idx_note_links_cloud_id ON note_links(cloud_id);
            CREATEUNIQUE INDEXIF NOT EXISTS idx_bookmarks_cloud_id ON bookmarks(cloud_id);
            CREATEUNIQUE INDEXIF NOT EXISTS idx_highlights_cloud_id ON verse_highlights(cloud_id);
            CREATEUNIQUE INDEXIF NOT EXISTS idx_hl_collections_cloud_id ON highlight_collections(cloud_id);
            CREATEUNIQUE INDEXIF NOT EXISTS idx_bookmarked_topics_cloud_id ON bookmarked_topics(cloud_id);

            2C. New Files

            services/syncEngine.ts — Core sync orchestrator (~400 lines)

            The sync engine has three responsibilities:

            1. Queue writes — After every local write, enqueue a sync operation:

            exportasyncfunctionenqueueSync(tableName: SyncableTable,localId: string|number,operation: 'INSERT'|'UPDATE'|'DELETE',payload: Record<string,any>,): Promise<void>{constdb=getUserDb();awaitdb.runAsync(`INSERT INTO sync_queue (table_name, local_id, operation, payload_json) VALUES (?, ?, ?, ?)`,[tableName,String(localId),operation,JSON.stringify(payload)],);schedulePush();}

            2. Push — Flush the queue to Supabase:

            exportasyncfunctionpushQueue(): Promise<SyncResult>{constsupabase=getSupabase();if(!supabase)return{pushed: 0,failed: 0};constsession=awaitsupabase.auth.getSession();if(!session.data.session)return{pushed: 0,failed: 0};constdb=getUserDb();constpending=awaitdb.getAllAsync<SyncQueueRow>('SELECT * FROM sync_queue ORDER BY created_at ASC LIMIT 100',);letpushed=0,failed=0;for(constitemofpending){try{constpayload=JSON.parse(item.payload_json);awaitpushItem(supabase,item.table_name,item.operation,payload);awaitdb.runAsync('DELETE FROM sync_queue WHERE id = ?',[item.id]);pushed++;}catch(err){constretries=(item.attempts??0)+1;if(retries>=5){awaitdb.runAsync('DELETE FROM sync_queue WHERE id = ?',[item.id]);logger.error('Sync',`Dropping item after 5 retries`,item);}else{awaitdb.runAsync('UPDATE sync_queue SET attempts = ?, last_error = ? WHERE id = ?',[retries,String(err),item.id],);}failed++;}}return{ pushed, failed };}

            3. Pull — Fetch changes since last sync and merge:

            exportasyncfunctionpullChanges(): Promise<SyncResult>{constsupabase=getSupabase();if(!supabase)return{pulled: 0,conflicts: 0};constdb=getUserDb();constlastSync=awaitgetSyncState('last_pull_at');constsince=lastSync??'1970-01-01T00:00:00Z';letpulled=0,conflicts=0;for(consttableofSYNCABLE_TABLES){const{ data, error }=awaitsupabase.from(table).select('*').gte('updated_at',since).order('updated_at',{ascending: true});if(error){logger.error('Sync',`Pull failed for ${table}`,error);continue;}for(constrowofdata??[]){constresult=awaitmergeRow(db,table,row);if(result==='merged')pulled++;if(result==='conflict')conflicts++;}}awaitsetSyncState('last_pull_at',newDate().toISOString());return{ pulled, conflicts };}

            Merge logic — Last Write Wins:

            asyncfunctionmergeRow(db,table,cloudRow): Promise<'merged'|'conflict'|'skipped'>{constlocal=awaitdb.getFirstAsync(`SELECT * FROM ${table} WHERE cloud_id = ?`,[cloudRow.id]);if(!local){awaitinsertFromCloud(db,table,cloudRow);return'merged';}if(cloudRow.deleted_at){awaitdb.runAsync(`DELETE FROM ${table} WHERE cloud_id = ?`,[cloudRow.id]);return'merged';}constcloudTime=newDate(cloudRow.updated_at).getTime();constlocalTime=newDate(local.updated_at).getTime();if(cloudTime>localTime){awaitupdateFromCloud(db,table,cloudRow,local.id);return'merged';}returncloudTime===localTime ? 'skipped' : 'conflict';}

            services/syncScheduler.ts — Background sync timing (~80 lines)

            // Schedules sync operations:// 1. On app foreground (via AppState listener)// 2. Every 5 minutes while app is active// 3. On NetInfo change from offline → online// 4. Debounced 3s after any local write (via enqueueSync)import{AppState}from'react-native';importNetInfofrom'@react-native-community/netinfo';

            stores/syncStore.ts — Sync state for UI (~60 lines)

            interfaceSyncState{isSyncing: boolean;lastSyncAt: string|null;pendingCount: number;error: string|null;triggerSync: ()=>Promise<void>;refreshPendingCount: ()=>Promise<void>;}

            2D. Modifying db/userMutations.ts — Add Sync Triggers

            Note: The original issue referenced db/user.ts. That file was split into
            userQueries.ts (reads) and userMutations.ts (writes). All sync triggers
            go in userMutations.ts.

            Every write function gets a sync enqueue call. The pattern:

            exportasyncfunctionsaveNote(verseRef: string,text: string): Promise<number>{constcloudId=generateUUID();constresult=awaitgetUserDb().runAsync("INSERT INTO user_notes (verse_ref, note_text, cloud_id) VALUES (?, ?, ?)",[verseRef,text,cloudId]);constnoteId=result.lastInsertRowId;awaitgetUserDb().runAsync("INSERT INTO notes_fts(rowid, note_text) VALUES (?, ?)",[noteId,text]);enqueueSync('user_notes',noteId,'INSERT',{id: cloudId,verse_ref: verseRef,note_text: text,tags_json: '[]',collection_id: null,}).catch(noop);returnnoteId;}

            Soft delete pattern:

            exportasyncfunctiondeleteNote(id: number): Promise<void>{constrow=awaitgetUserDb().getFirstAsync<{cloud_id: string|null}>('SELECT cloud_id FROM user_notes WHERE id = ?',[id]);awaitgetUserDb().runAsync("UPDATE user_notes SET deleted_at = datetime('now') WHERE id = ?",[id]);awaitgetUserDb().runAsync("DELETE FROM notes_fts WHERE rowid = ?",[id]);if(row?.cloud_id){enqueueSync('user_notes',id,'DELETE',{id: row.cloud_id}).catch(noop);}}

            Complete function list (20 functions — corrected from original 14):

            #FunctionTableOperationNotes
            1saveNoteuser_notesINSERTGenerate cloud_id
            2updateNoteuser_notesUPDATE
            3deleteNoteuser_notesDELETE (soft)Set deleted_at, remove from FTS
            4updateNoteTagsuser_notesUPDATEAdded — missing from original
            5setNoteCollectionuser_notesUPDATEAdded — missing from original
            6addBookmarkbookmarksINSERTGenerate cloud_id
            7removeBookmarkbookmarksDELETE (soft)
            8setHighlightverse_highlightsUPSERTGenerate cloud_id on insert
            9removeHighlightverse_highlightsDELETE (soft)
            10createCollectionstudy_collectionsINSERTGenerate cloud_id
            11updateCollectionstudy_collectionsUPDATE
            12deleteCollectionstudy_collectionsDELETE (soft)
            13createHighlightCollectionhighlight_collectionsINSERTLocal ID is TEXT — use as cloud_id
            14deleteHighlightCollectionhighlight_collectionsDELETE (hard→soft)
            15linkNotesnote_linksINSERTGenerate cloud_id
            16unlinkNotesnote_linksDELETE (hard)
            17bookmarkTopicbookmarked_topicsUPSERTAdded — generate cloud_id
            18unbookmarkTopicbookmarked_topicsDELETE (soft)Added

            NOT synced (local-only):

            • startStudySession, endStudySession, recordSessionEvent — device-local analytics
            • flagContent — moderation, separate sync mechanism (v14 synced column)
            • upsertAuthProfile, clearAuthProfile — auth state, not user content
            • startPlan, completePlanDay, abandonPlan — synced in Session 3
            • resetToNewUser — dev tool

            UUID generation: Use expo-crypto (already installed):

            import*asCryptofrom'expo-crypto';exportfunctiongenerateUUID(): string{returnCrypto.randomUUID();}

            2E. Modifying db/userQueries.ts — Add Soft Delete Filters

            All read queries touching soft-deletable tables must add WHERE deleted_at IS NULL:

            FunctionTable
            getNotesForChapteruser_notes
            getNoteCountuser_notes
            getAllNotesuser_notes
            searchNotesuser_notes
            searchNotesFTSuser_notes
            getBookmarksbookmarks
            isBookmarkedbookmarks
            getHighlightsForChapterverse_highlights
            getAllHighlightsverse_highlights
            getCollectionsstudy_collections
            getCollectionstudy_collections
            getNotesInCollectionuser_notes
            getCollectionNoteCountsuser_notes
            getAllTagsuser_notes
            getNotesByTaguser_notes
            getLinkedNotesnote_links → user_notes
            getReferencingNotesnote_links → user_notes
            getBookmarkedTopicsbookmarked_topics
            isTopicBookmarkedbookmarked_topics

            2F. Tests

            __tests__/services/syncEngine.test.ts — New (~250 lines)

            describe('syncEngine',()=>{describe('enqueueSync',()=>{it('inserts queue entry with table_name and local_id',async()=>{ ... });it('schedules a push after enqueue',async()=>{ ... });});describe('pushQueue',()=>{it('pushes INSERT operations to Supabase upsert',async()=>{ ... });it('pushes DELETE as soft-delete',async()=>{ ... });it('removes queue entries after success',async()=>{ ... });it('increments attempts on failure',async()=>{ ... });it('drops items after 5 failed attempts',async()=>{ ... });it('does nothing when not authenticated',async()=>{ ... });});describe('pullChanges',()=>{it('fetches rows updated since last_pull_at',async()=>{ ... });it('inserts new cloud rows locally with cloud_id',async()=>{ ... });it('LWW: cloud wins on newer timestamp',async()=>{ ... });it('LWW: local wins on newer timestamp',async()=>{ ... });it('soft-deletes locally when cloud has deleted_at',async()=>{ ... });});});

            __tests__/db/userMutations.test.ts — New (~150 lines)

            Test each write function enqueues sync correctly:

            describe('userMutations sync integration',()=>{it('saveNote enqueues INSERT with cloud_id',async()=>{ ... });it('updateNote enqueues UPDATE',async()=>{ ... });it('deleteNote soft-deletes and enqueues DELETE',async()=>{ ... });it('updateNoteTags enqueues UPDATE',async()=>{ ... });it('bookmarkTopic enqueues UPSERT',async()=>{ ... });// ... one test per write function});

            __tests__/db/userQueries.test.ts — New (~80 lines)

            describe('userQueries soft-delete filtering',()=>{it('getAllNotes excludes soft-deleted rows',async()=>{ ... });it('getBookmarks excludes soft-deleted rows',async()=>{ ... });it('getBookmarkedTopics excludes soft-deleted rows',async()=>{ ... });// ... one test per query with soft-delete filter});

            2G. Session 2 Deliverables

            DeliverableVerification
            Cloud schema deployedTables visible in Supabase dashboard
            Migration v15 runs cleanlyApp launches, cloud_id columns exist
            Note CRUD syncsCreate note → appears in Supabase → pull on second device
            Highlight CRUD syncsSame
            Bookmark CRUD syncsSame
            Collection CRUD syncsSame
            Topic bookmark syncsSame
            Soft deletes workDelete note → deleted_at set → synced → hidden on other device
            Offline queue persistsAirplane mode → create note → reconnect → note pushes
            LWW conflict resolutionEdit same note on 2 devices → newer wins
            Tests passnpm test -- --testPathPattern="sync|user"

            Session 3: Reading Progress + Study Depth + Plans + Settings Sync

            Goal

            Sync reading progress (union merge), study depth (additive), reading plan progress,
            and user preferences (LWW).

            3A. Additional Cloud Tables

            CREATETABLEreading_progress (
            user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
            book_id TEXTNOT NULL,
            chapter_num INTEGERNOT NULL,
            completed_at TIMESTAMPTZ,
            PRIMARY KEY (user_id, book_id, chapter_num)
            );
            CREATETABLEstudy_depth (
            id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
            user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
            chapter_id TEXTNOT NULL,
            section_id TEXTNOT NULL,
            panel_type TEXTNOT NULL,
            first_opened_at TIMESTAMPTZ DEFAULT now(),
            UNIQUE(user_id, section_id, panel_type)
            );
            CREATETABLEuser_preferences (
            user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
            key TEXTNOT NULL,
            value TEXTNOT NULL,
            updated_at TIMESTAMPTZ DEFAULT now(),
            PRIMARY KEY (user_id, key)
            );
            CREATETABLEreading_streaks (
            id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
            user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
            dateTEXTNOT NULL,
            chapters_read INTEGERNOT NULL DEFAULT 0,
            books_touched TEXT,
            UNIQUE(user_id, date)
            );
            CREATETABLEplan_progress (
            user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
            plan_id TEXTNOT NULL,
            day_num INTEGERNOT NULL,
            completed_at TIMESTAMPTZ,
            PRIMARY KEY (user_id, plan_id, day_num)
            );
            -- RLS for all new tables
            DO $$
            DECLARE tbl TEXT;
            BEGIN
            FOR tbl INVALUES
            ('reading_progress'), ('study_depth'), ('user_preferences'),
            ('reading_streaks'), ('plan_progress')
            LOOP
            EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl);
            EXECUTE format(
            'CREATE POLICY "Users own their %1$s" ON %1$I FOR ALL USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid())', tbl);
            END LOOP;
            END $$;

            3B. Special Merge Strategies

            Reading Progress — Union Merge

            Never un-completes. If either side says completed, it stays completed.
            Keep the earliest completed_at timestamp.

            Study Depth — Additive Only

            Append-only. Cloud accumulates all panels ever opened. Merge = INSERT OR IGNORE.

            Reading Plans — Union Merge

            Plan progress uses same union logic as reading progress — once a day is completed,
            it stays completed.

            User Preferences — LWW

            Simple key-value pairs with updated_at for conflict resolution.

            3C. Sync Triggers for Session 3 Functions

            recordVisit in userMutations.ts: Enqueue sync for reading_progress.

            completePlanDay in userMutations.ts: Enqueue sync for plan_progress.

            settingsStore.ts preference setters: Each setPreference call gets a sync enqueue.

            3D. UI: Sync Status in Settings

            Add a "Sync" section to SettingsScreen:

            ┌──────────────────────────────────────┐
            │ SYNC │
            │ │
            │ Status ✓ Up to date │
            │ Last sync 2 min ago │
            │ Pending 0 changes │
            │ │
            │ [Sync Now] │
            │ │
            │ Only visible when signed in. │
            └──────────────────────────────────────┘
            

            3E. Session 3 Deliverables

            DeliverableVerification
            Reading progress syncs (union)Read chapter → mark on device A → appears on device B
            Never un-completesDelete cloud row → local stays completed
            Study depth syncs (additive)Open panel → record appears in Supabase
            Plan progress syncs (union)Complete day on A → appears on B
            Preferences sync (LWW)Change font size on A → appears on B
            Sync status UI in SettingsShows status, last sync time, pending count
            Tests passnpm test -- --testPathPattern="sync|settings"

            Session 4: First-Sign-In Migration + Edge Cases + Account Deletion

            Goal

            Handle the critical first-sign-in scenario (user has months of local data),
            edge cases (offline→online, large datasets, stale queues), and App Store-compliant
            account deletion.

            4A. First-Sign-In Migration

            services/initialMigration.ts — New (~150 lines)

            When a user signs in for the first time, they may have hundreds of notes, bookmarks,
            and reading progress entries with no cloud_id. One-time bulk upload:

            1. Check if migration already happened (sync_state.initial_migration_complete)
            2. Check cloud for existing data (returning user on new device?)
            3. Assign cloud_id to all local rows missing them
            4. Upload in batches of 50 (collections first for FK resolution, then notes, etc.)
            5. Mark complete

            Migration UI — SyncMigrationSheet

            Bottom-sheet showing progress: "Uploading 47 notes, 312 chapters read..." with
            progress bar. Auto-dismisses on completion.

            4B. Edge Cases

            • Offline → online flush:pushQueue() processes max 100 items per call, chains until empty
            • Queue compaction: When queue > 200 items, compact by keeping only latest per table+local_id
            • Note links with unmapped IDs: Upload notes before note_links; defer links with unresolved refs
            • Interrupted migration:initial_migration_complete only set on full success; idempotent upserts prevent duplicates on retry
            • Stale INSERT+DELETE pairs: Compaction drops both when INSERT never pushed

            4C. Account Deletion

            services/accountDeletion.ts — New (~50 lines)

            1. Invoke Supabase Edge Function delete-user-data (service_role deletes all user tables + auth account)
            2. Clear local auth state
            3. Stop sync scheduler
            4. Local study data preserved on device

            Supabase Edge Function: delete-user-data

            Server-side function using service_role key to delete all user rows across all tables
            and delete the auth account. Client-side RLS can't cascade across tables.

            Deletion UI in SettingsScreen

            "Delete Account" in a Danger Zone section (only visible when signed in).
            Two-step confirmation: alert → type "DELETE" to confirm.

            4D. Session 4 Deliverables

            DeliverableVerification
            First sign-in migration worksUser with 6 months data → sign in → all data in Supabase
            Returning user on new deviceSign in on device B → all data appears
            Migration progress UIBottom sheet shows progress during initial upload
            Offline queue compaction500 offline edits → compacted → clean push
            Account deletion worksDelete → cloud data gone → local data preserved
            Two-step delete confirmationCan't accidentally delete
            Edge function deployeddelete-user-data in Supabase Functions
            Tests passFull suite green

            Complete File Inventory (Corrected)

            New Files (10+)

            FileLines (est.)Session
            services/syncEngine.ts~4002
            services/syncScheduler.ts~802
            services/initialMigration.ts~1504
            services/accountDeletion.ts~504
            stores/syncStore.ts~602
            __tests__/services/syncEngine.test.ts~3502-3
            __tests__/services/syncScheduler.test.ts~602
            __tests__/db/userMutations.test.ts~1502
            __tests__/db/userQueries.test.ts~802
            __tests__/services/initialMigration.test.ts~1204
            __tests__/services/accountDeletion.test.ts~604
            __tests__/screens/LoginScreen.test.tsx~801

            Modified Files (12+)

            FileChange SummarySession
            lib/supabase.tsActivate, SecureStore adapter, real import1
            lib/oauthHelpers.tsAdd signInWithApple()1
            stores/authStore.tsAdd Apple, remove Facebook1
            screens/LoginScreen.tsxReplace Facebook → Apple, add guest option1
            screens/MoreMenuScreen.tsxEnhanced auth section + sync status1, 3
            screens/SettingsScreen.tsxSync status section + account deletion3, 4
            db/userDatabase.tsMigration v15: cloud_id, sync_queue extension, soft delete2
            db/userMutations.tsSync triggers on all 18+ write functions + soft delete2, 3
            db/userQueries.tsWHERE deleted_at IS NULL on 19+ queries2
            stores/settingsStore.tsSync preference changes3
            app.jsonApple auth entitlement + plugin1
            types/user.tsAdd cloud_id and deleted_at to interfaces2

            New Dependencies (6)

            PackagePurpose
            @supabase/supabase-jsSupabase client
            @react-native-async-storage/async-storageRequired by Supabase (session fallback)
            react-native-url-polyfillRequired by Supabase (URL parsing)
            expo-secure-storeEncrypted JWT storage
            expo-apple-authenticationNative Apple Sign-In
            @react-native-community/netinfoNetwork state for sync scheduler

            Test Summary (~75 tests)

            Test FileTests (est.)
            syncEngine.test.ts~30
            syncScheduler.test.ts~5
            userMutations.test.ts~18
            userQueries.test.ts~10
            initialMigration.test.ts~8
            accountDeletion.test.ts~5
            LoginScreen.test.tsx~6
            authStore.test.ts (extend)~3

            Risk Register

            RiskImpactMitigation
            Apple rejects without native Apple Sign-InApp Store rejectionUsing expo-apple-authentication (native), not web OAuth
            Supabase free tier limitsSync stops at 50K MAUUpgrade to Pro ($25/mo) — covered by revenue model
            Large initial migration failsUser data doesn't syncIdempotent upserts; retry on next launch; progress UI
            LWW loses edits on simultaneous editUser loses a note editAcceptable for v1; realtime sync (v2) reduces window
            expo-secure-store 2KB value limitJWT too largeSupabase JWTs are ~1KB; within limit
            NetInfo false positivesSync on captive portalsTimeout + retry with backoff
            Soft delete bloatDeleted rows accumulatePeriodic cleanup: hard-delete rows > 30 days old
            v14 sync_queue schema mismatchALTER may fail on existing datav15 uses ADD COLUMN with defaults; safe for existing rows

            Drift Corrections (April 2026)

            This issue was originally written against an older codebase. The following corrections
            were applied:

            1. db/user.ts split → now userQueries.ts (reads) + userMutations.ts (writes)
            2. Migrations at v14 (not v8) — sync migration is now v15
            3. sync_queue already exists (v14) — v15 extends it with table_name and local_id
            4. @supabase/supabase-js NOT installed — full dep install list corrected
            5. SignUpScreen.tsx and ForgotPasswordScreen.tsx exist — LoginScreen redesign simplified
            6. 20 write functions (not 14) — updateNoteTags, setNoteCollection, bookmarkTopic, unbookmarkTopic added
            7. highlight_collections.id is TEXT — can use local ID as cloud_id directly
            8. user_preferences needs updated_at — added to v15 migration
            9. supabase.ts uses env vars — kept (better than hardcoded)
            10. Decisions confirmed: Drop Facebook ✓, Sync bookmarked topics ✓, Sync reading plans ✓, Don't sync study sessions ✓

            Metadata

            Metadata

            Labels

            premiumPremium tier feature

            Projects

            No projects

              Milestone

              No milestone

              Relationships

              None yet

              Development

              No branches or pull requests

              Issue actions

              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
              Skip to content

              Code: A7. Cross-Device Sync — Supabase auth + bidirectional sync #65

              Description

              @CraigBuckmaster

              Summary

              Supabase Auth (Apple/Google/Email), bidirectional sync for all user data, offline queue, first-sign-in migration, account deletion. Premium feature.

              Sessions: 4–5 (sequential — each builds on the last)

              Prerequisite: Craig creates a Supabase project, configures Apple/Google OAuth providers, provides URL + anon key.

              Last updated: April 2026 — corrected against current codebase state. See "Drift Corrections" section at bottom.

              Session Breakdown

              SessionScopeKey Deliverables
              1Supabase Auth (Apple/Google/Email sign-in)Native Apple Sign-In, SecureStore JWT, LoginScreen redesign (Facebook → Apple), deps install
              2Sync engine + notes/highlights/bookmarks/topicsMigration v15 (cloud_id, sync_queue extension, soft delete), 20 write functions get sync triggers, push/pull with LWW
              3Reading progress + study depth + plans + settings syncUnion merge for progress, additive for depth, LWW for prefs, plan sync, sync status UI
              4First-sign-in migration + edge cases + account deletionBulk upload with progress UI, queue compaction, Edge Function for deletion

              Architecture

              • Offline-first: Local SQLite is always source of truth. Cloud is backup + sync layer. App never blocks on network.
              • Conflict resolution: Last-write-wins for notes/bookmarks/highlights/topics. Union merge for reading progress (never un-completes). Additive for study depth.
              • Soft deletes: All deletable entities get deleted_at column. Both sides honor soft deletes.
              • Queue-based push: Every local write enqueues to sync_queue. Debounced 3s push. Retry with backoff. Drop after 5 failures.

              File Impact

              • 10+ new files (services, stores, tests)
              • 12+ modified files (auth, db, settings, navigation)
              • 6 new dependencies
              • ~75 new tests

              Decisions (Confirmed)

              • Drop Facebook auth: Yes — heavy SDK, low value for Bible app demographic
              • Sync bookmarked topics: Yes — lightweight, user expects cross-device
              • Sync reading plans/progress: Yes — plan progress should follow user across devices
              • Sync study sessions: No — high-frequency device-local analytics, not user content
              • Sync flagged content: No — moderation, server-bound via separate mechanism (v14 synced column)

              Pre-Session Checklist (Craig's Homework)

              • Create Supabase project at supabase.com
              • Copy SUPABASE_URL and SUPABASE_ANON_KEY
              • Add both to .env, app.json extras, and eas.json build profiles
              • Enable Apple provider in Supabase Auth settings
                • Create Apple Services ID in Apple Developer Console
                • Generate a key for Sign in with Apple
                • Paste credentials into Supabase
              • Enable Google provider in Supabase Auth settings
                • Create OAuth 2.0 Client ID in Google Cloud Console
                • Paste Client ID + Secret into Supabase
              • Confirm scheme: "scripture" in app.json (already set ✓)
              • Confirm whether any production users are on migration v14 already (affects sync_queue strategy)

              Full Dev Plan

              A7. Cross-Device Sync — Dev Plan (Corrected April 2026)

              Scope: Supabase Auth (Apple/Google/Email), bidirectional sync for all user data,
              offline queue, first-sign-in migration, account deletion.

              Sessions: 4–5 (can't be parallelized — each builds on the last)

              Prerequisite: Craig creates a Supabase project at supabase.com, provides the
              SUPABASE_URL and SUPABASE_ANON_KEY, and configures Apple/Google OAuth providers
              in the Supabase dashboard.


              Current State (April 2026)

              WhatStatus
              authStore.ts178 lines, functional, Supabase client via env vars (process.env.SUPABASE_URL)
              LoginScreen.tsx336 lines, Google + Facebook + Email — missing Apple Sign-In
              SignUpScreen.tsx295 lines, exists and working
              ForgotPasswordScreen.tsx184 lines, exists and working
              lib/supabase.tsStub using process.env.SUPABASE_URL pattern, CONFIGURED = !!process.env.SUPABASE_URL
              lib/oauthHelpers.tsWorking Google/Facebook OAuth via expo-auth-session
              db/user.tsBarrel re-exportuserQueries.ts (496 lines) + userMutations.ts (372 lines)
              userDatabase.ts14 migrations, no cloud_id, no sync_state, no soft delete. sync_queue exists (v14) but incomplete schema
              Cloud schemaNone — Supabase project doesn't exist yet
              TestsauthStore.test.ts exists, supabaseMock.js exists
              Dependencies installedexpo-auth-session, expo-web-browser, expo-crypto
              Dependencies NOT installed@supabase/supabase-js, expo-secure-store, expo-apple-authentication, @react-native-community/netinfo, @react-native-async-storage/async-storage, react-native-url-polyfill

              Session 1: Supabase Auth (Apple/Google/Email Sign-In)

              Goal

              Users can sign in with Apple, Google, or email/password. Session persists across
              app restarts. Sign-out clears session. Account section in MoreMenu shows user info.
              Facebook auth removed.

              Pre-session (Craig)

              1. Create Supabase project → copy URL + anon key
              2. Add SUPABASE_URL and SUPABASE_ANON_KEY to .env, app.json extras, and eas.json build profiles
              3. In Supabase Dashboard → Authentication → Providers:
                • Enable Apple: add Services ID, Team ID, Key ID, private key
                • Enable Google: add Client ID + secret from Google Cloud Console
                • Enable Email: already on by default
              4. In Apple Developer Console:
                • Create a Services ID for "Sign in with Apple"
                • Register the Supabase callback URL as a redirect URI

              1A. Dependencies

              cd app/
              npx expo install @supabase/supabase-js @react-native-async-storage/async-storage \
              react-native-url-polyfill expo-secure-store expo-apple-authentication \
              @react-native-community/netinfo

              Already installed: expo-auth-session, expo-web-browser, expo-crypto.

              expo-apple-authentication is needed for native Apple Sign-In on iOS (the App
              Store rejects web-based Apple OAuth — it must use the native credential API).

              1B. Files to Modify

              lib/supabase.ts — Activate the client

              // Changes:// 1. Keep env var pattern (already correct): process.env.SUPABASE_URL// 2. Switch auth storage from AsyncStorage to expo-secure-store// (SecureStore is encrypted; AsyncStorage is not)// 3. Replace the placeholder SupabaseClient interface with actual importimport*asSecureStorefrom'expo-secure-store';constExpoSecureStoreAdapter={getItem: (key: string)=>SecureStore.getItemAsync(key),setItem: (key: string,value: string)=>SecureStore.setItemAsync(key,value),removeItem: (key: string)=>SecureStore.deleteItemAsync(key),};// In createClient options:
              auth: {storage: ExpoSecureStoreAdapter,autoRefreshToken: true,persistSession: true,detectSessionInUrl: false,flowType: 'pkce',}

              Note:expo-secure-store needs to be installed (see 1A above).

              lib/oauthHelpers.ts — Add Apple Sign-In

              Add signInWithApple() as a separate function (not a new case in signInWithProvider
              Apple uses native credential flow via signInWithIdToken, architecturally different from
              the browser-redirect flow used by Google):

              exportasyncfunctionsignInWithApple(): Promise<{error?: string}>{constAppleAuthentication=require('expo-apple-authentication');constsupabase=getSupabase();if(!supabase)return{error: 'Auth not available'};try{constcredential=awaitAppleAuthentication.signInAsync({requestedScopes: [AppleAuthentication.AppleAuthenticationScope.FULL_NAME,AppleAuthentication.AppleAuthenticationScope.EMAIL,],});if(!credential.identityToken){return{error: 'No identity token from Apple'};}const{ error }=awaitsupabase.auth.signInWithIdToken({provider: 'apple',token: credential.identityToken,});if(error)return{error: error.message};return{};}catch(err: any){if(err.code==='ERR_REQUEST_CANCELED')return{error: 'Sign-in canceled'};return{error: err.message??'Apple sign-in failed'};}}

              stores/authStore.ts — Add Apple, drop Facebook

              // Changes:// 1. Add signInWithApple method// 2. Remove signInWithFacebook from interface and implementation// 3. Keep signInWithGoogle and signInWithEmail as-issignInWithApple: async()=>{const{ isSupabaseAvailable }=getAuth();if(!isSupabaseAvailable()){return{error: 'Sign-in requires a development build.'};}set({isLoading: true});try{const{ signInWithApple }=require('../lib/oauthHelpers');returnawaitsignInWithApple();}finally{set({isLoading: false});},},

              screens/LoginScreen.tsx — Replace Facebook with Apple

              Since SignUpScreen.tsx and ForgotPasswordScreen.tsx already exist with working
              navigation routes, the redesign is simpler than originally planned:

              1. Replace Facebook button → Apple button (black bg, white text, Apple icon)
              2. Keep existing Google button
              3. Keep email form + existing navigation to SignUp / ForgotPassword
              4. Add "Continue without signing in" footer (navigation.goBack())
              5. Add privacy reassurance text
              6. Remove signInWithFacebook import/usage

              Layout (top to bottom):

              ┌─────────────────────────────────┐
              │ ScreenHeader "Sign In" │
              │ │
              │ "Sign in to sync your study │
              │ data across devices." │
              │ │
              │ ┌─────────────────────────┐ │
              │ │ ◉ Continue with Apple │ │ ← Black button, white text, Apple icon
              │ └─────────────────────────┘ │
              │ ┌─────────────────────────┐ │
              │ │ G Continue with Google│ │ ← White button, dark text
              │ └─────────────────────────┘ │
              │ │
              │ ──────── or ──────── │
              │ │
              │ [ Email ] │
              │ [ Password ] │
              │ ┌─────────────────────────┐ │
              │ │ Sign In │ │ ← Gold button
              │ └─────────────────────────┘ │
              │ Forgot password? ← navigates │
              │ to ForgotPasswordScreen │
              │ │
              │ Don't have an account? Sign Up │ ← navigates to SignUpScreen
              │ │
              │ ───────────────────────────── │
              │ Your study data stays on your │
              │ device even without an │
              │ account. Signing in adds │
              │ cross-device sync. │
              │ │
              │ ┌─────────────────────────┐ │
              │ │ Continue without │ │ ← Text button, muted
              │ │ signing in │ │
              │ └─────────────────────────┘ │
              └─────────────────────────────────┘
              

              screens/MoreMenuScreen.tsx — Enhanced auth section

              Currently shows user email when signed in, "Sign In" when not. Enhance:

              ┌──────────────────────────────────────┐
              │ SIGNED IN STATE: │
              │ │
              │ [Avatar] Craig Buckmaster │
              │ craig@companionstudy.app │
              │ ✓ Synced 2 min ago │ ← sync status line (Session 2)
              │ │
              │ [Sign Out] │
              │ │
              │ NOT SIGNED IN: │
              │ │
              │ [Lock icon] Sign In │
              │ Sync your study data │
              │ across devices │
              │ │
              └──────────────────────────────────────┘
              

              1C. App Config Changes

              app.json — Add Apple auth entitlement

              {
              "expo": {
              "ios": {
              "usesAppleSignIn": true
              },
              "plugins": [
              "expo-apple-authentication",
              // ... existing plugins (expo-font, expo-screen-orientation, etc.)
              ]
              }
              }

              scheme: "scripture" is already set ✓

              1D. Tests

              __tests__/stores/authStore.test.ts — Extend

              Add tests for:

              • signInWithApple dispatches correctly when available
              • signInWithApple returns error in Expo Go
              • Facebook methods are removed
              • Hydrate restores session from SecureStore
              • Sign-out clears SecureStore + local profile
              • Error states propagate correctly

              __tests__/screens/LoginScreen.test.tsx — New

              describe('LoginScreen',()=>{it('renders Apple, Google, and email sign-in options',()=>{ ... });it('does NOT render Facebook button',()=>{ ... });it('navigates to SignUpScreen on "Sign Up" press',()=>{ ... });it('navigates to ForgotPasswordScreen on "Forgot password" press',()=>{ ... });it('shows error message on failed sign-in',async()=>{ ... });it('navigates back on "Continue without signing in"',()=>{ ... });});

              __tests__/lib/supabase.test.ts — New

              describe('supabase client',()=>{it('returns null when env vars not set',()=>{ ... });it('creates client singleton when configured',()=>{ ... });it('uses SecureStore adapter for auth storage',()=>{ ... });});

              1E. Session 1 Deliverables

              DeliverableVerification
              Apple Sign-In works on deviceSign in → session persists on restart
              Google Sign-In worksSign in → session persists
              Email sign-in/sign-up worksCreate account → sign in → session persists
              Sign-out clears sessionSign out → MoreMenu shows "Sign In"
              Facebook removedNo Facebook button in UI, no Facebook in authStore
              Forgot password worksRequest reset → email received
              Tests passnpm test -- --testPathPattern="auth|Login|supabase"

              Session 2: Sync Engine + Notes/Highlights/Bookmarks/Topics

              Goal

              All user writes (notes, highlights, bookmarks, collections, note links, topic bookmarks)
              are queued for sync. On app launch and periodically, queued changes push to Supabase.
              Pull merges cloud changes into local SQLite.

              2A. Supabase Cloud Schema

              Run in Supabase SQL Editor (one-time setup):

              -- ══════════════════════════════════════════════════-- TABLES-- ══════════════════════════════════════════════════CREATETABLEuser_notes (
              id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
              user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
              verse_ref TEXTNOT NULL,
              note_text TEXTNOT NULL,
              tags_json TEXT DEFAULT '[]',
              collection_id UUID,
              created_at TIMESTAMPTZ DEFAULT now(),
              updated_at TIMESTAMPTZ DEFAULT now(),
              deleted_at TIMESTAMPTZ
              );
              CREATETABLEstudy_collections (
              id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
              user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
              name TEXTNOT NULL,
              description TEXT DEFAULT '',
              color TEXT DEFAULT '#bfa050',
              created_at TIMESTAMPTZ DEFAULT now(),
              updated_at TIMESTAMPTZ DEFAULT now(),
              deleted_at TIMESTAMPTZ
              );
              ALTERTABLE user_notes
              ADD CONSTRAINT fk_notes_collection
              FOREIGN KEY (collection_id) REFERENCES study_collections(id)
              ON DELETESETNULL;
              CREATETABLEnote_links (
              id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
              user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
              from_note_id UUID NOT NULLREFERENCES user_notes(id) ON DELETE CASCADE,
              to_note_id UUID NOT NULLREFERENCES user_notes(id) ON DELETE CASCADE,
              created_at TIMESTAMPTZ DEFAULT now()
              );
              CREATETABLEbookmarks (
              id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
              user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
              verse_ref TEXTNOT NULL,
              label TEXT,
              created_at TIMESTAMPTZ DEFAULT now(),
              deleted_at TIMESTAMPTZ
              );
              CREATETABLEverse_highlights (
              id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
              user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
              verse_ref TEXTNOT NULL,
              color TEXTNOT NULL,
              collection_id TEXT,
              note TEXT,
              created_at TIMESTAMPTZ DEFAULT now(),
              deleted_at TIMESTAMPTZ,
              UNIQUE(user_id, verse_ref)
              );
              CREATETABLEhighlight_collections (
              id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
              user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
              name TEXTNOT NULL,
              color TEXTNOT NULL,
              sort_order INTEGER DEFAULT 0,
              created_at TIMESTAMPTZ DEFAULT now()
              );
              CREATETABLEbookmarked_topics (
              id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
              user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
              topic_id TEXTNOT NULL,
              topic_type TEXTNOT NULL DEFAULT 'official',
              cached_title TEXT,
              cached_summary TEXT,
              created_at TIMESTAMPTZ DEFAULT now(),
              deleted_at TIMESTAMPTZ,
              UNIQUE(user_id, topic_id, topic_type)
              );
              -- ══════════════════════════════════════════════════-- ROW LEVEL SECURITY (all tables)-- ══════════════════════════════════════════════════
              DO $$
              DECLARE
              tbl TEXT;
              BEGIN
              FOR tbl INVALUES
              ('user_notes'), ('study_collections'), ('note_links'),
              ('bookmarks'), ('verse_highlights'), ('highlight_collections'),
              ('bookmarked_topics')
              LOOP
              EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl);
              EXECUTE format(
              'CREATE POLICY "Users own their %1$s" ON %1$I FOR ALL USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid())', tbl);
              END LOOP;
              END $$;
              -- ══════════════════════════════════════════════════-- INDEXES-- ══════════════════════════════════════════════════CREATEINDEXidx_notes_userON user_notes(user_id);
              CREATEINDEXidx_notes_updatedON user_notes(user_id, updated_at);
              CREATEINDEXidx_collections_userON study_collections(user_id);
              CREATEINDEXidx_note_links_userON note_links(user_id);
              CREATEINDEXidx_bookmarks_userON bookmarks(user_id);
              CREATEINDEXidx_bookmarks_updatedON bookmarks(user_id, created_at);
              CREATEINDEXidx_highlights_userON verse_highlights(user_id);
              CREATEINDEXidx_highlight_collections_userON highlight_collections(user_id);
              CREATEINDEXidx_bookmarked_topics_userON bookmarked_topics(user_id);
              -- ══════════════════════════════════════════════════-- AUTO-UPDATE updated_at-- ══════════════════════════════════════════════════CREATE OR REPLACEFUNCTIONupdate_updated_at()
              RETURNS TRIGGER AS $$
              BEGINNEW.updated_at= now();
              RETURN NEW;
              END;
              $$ LANGUAGE plpgsql;
              CREATETRIGGERtrg_notes_updated BEFORE UPDATEON user_notes
              FOR EACH ROW EXECUTE FUNCTION update_updated_at();
              CREATETRIGGERtrg_collections_updated BEFORE UPDATEON study_collections
              FOR EACH ROW EXECUTE FUNCTION update_updated_at();

              2B. Local Database Migration

              userDatabase.ts — Migration v15: Sync infrastructure

              The existing sync_queue table (from v14) has an incomplete schema. v15 extends it
              and adds cloud sync columns to all user tables.

              -- cloud_id for all synced tablesALTERTABLE user_notes ADD COLUMN cloud_id TEXT;
              ALTERTABLE study_collections ADD COLUMN cloud_id TEXT;
              ALTERTABLE note_links ADD COLUMN cloud_id TEXT;
              ALTERTABLE bookmarks ADD COLUMN cloud_id TEXT;
              ALTERTABLE verse_highlights ADD COLUMN cloud_id TEXT;
              ALTERTABLE highlight_collections ADD COLUMN cloud_id TEXT;
              ALTERTABLE bookmarked_topics ADD COLUMN cloud_id TEXT;
              -- Soft delete supportALTERTABLE user_notes ADD COLUMN deleted_at TEXT;
              ALTERTABLE study_collections ADD COLUMN deleted_at TEXT;
              ALTERTABLE bookmarks ADD COLUMN deleted_at TEXT;
              ALTERTABLE verse_highlights ADD COLUMN deleted_at TEXT;
              ALTERTABLE bookmarked_topics ADD COLUMN deleted_at TEXT;
              -- Extend existing sync_queue (from v14) with routing columnsALTERTABLE sync_queue ADD COLUMN table_name TEXTNOT NULL DEFAULT '';
              ALTERTABLE sync_queue ADD COLUMN local_id TEXTNOT NULL DEFAULT '';
              -- Sync state table (last_pull_at, migration flags)CREATETABLEIF NOT EXISTS sync_state (
              key TEXTPRIMARY KEY,
              value TEXTNOT NULL
              );
              -- LWW support for preferencesALTERTABLE user_preferences ADD COLUMN updated_at TEXT;
              -- Indexes for merge lookupsCREATEUNIQUE INDEXIF NOT EXISTS idx_notes_cloud_id ON user_notes(cloud_id);
              CREATEUNIQUE INDEXIF NOT EXISTS idx_collections_cloud_id ON study_collections(cloud_id);
              CREATEUNIQUE INDEXIF NOT EXISTS idx_note_links_cloud_id ON note_links(cloud_id);
              CREATEUNIQUE INDEXIF NOT EXISTS idx_bookmarks_cloud_id ON bookmarks(cloud_id);
              CREATEUNIQUE INDEXIF NOT EXISTS idx_highlights_cloud_id ON verse_highlights(cloud_id);
              CREATEUNIQUE INDEXIF NOT EXISTS idx_hl_collections_cloud_id ON highlight_collections(cloud_id);
              CREATEUNIQUE INDEXIF NOT EXISTS idx_bookmarked_topics_cloud_id ON bookmarked_topics(cloud_id);

              2C. New Files

              services/syncEngine.ts — Core sync orchestrator (~400 lines)

              The sync engine has three responsibilities:

              1. Queue writes — After every local write, enqueue a sync operation:

              exportasyncfunctionenqueueSync(tableName: SyncableTable,localId: string|number,operation: 'INSERT'|'UPDATE'|'DELETE',payload: Record<string,any>,): Promise<void>{constdb=getUserDb();awaitdb.runAsync(`INSERT INTO sync_queue (table_name, local_id, operation, payload_json) VALUES (?, ?, ?, ?)`,[tableName,String(localId),operation,JSON.stringify(payload)],);schedulePush();}

              2. Push — Flush the queue to Supabase:

              exportasyncfunctionpushQueue(): Promise<SyncResult>{constsupabase=getSupabase();if(!supabase)return{pushed: 0,failed: 0};constsession=awaitsupabase.auth.getSession();if(!session.data.session)return{pushed: 0,failed: 0};constdb=getUserDb();constpending=awaitdb.getAllAsync<SyncQueueRow>('SELECT * FROM sync_queue ORDER BY created_at ASC LIMIT 100',);letpushed=0,failed=0;for(constitemofpending){try{constpayload=JSON.parse(item.payload_json);awaitpushItem(supabase,item.table_name,item.operation,payload);awaitdb.runAsync('DELETE FROM sync_queue WHERE id = ?',[item.id]);pushed++;}catch(err){constretries=(item.attempts??0)+1;if(retries>=5){awaitdb.runAsync('DELETE FROM sync_queue WHERE id = ?',[item.id]);logger.error('Sync',`Dropping item after 5 retries`,item);}else{awaitdb.runAsync('UPDATE sync_queue SET attempts = ?, last_error = ? WHERE id = ?',[retries,String(err),item.id],);}failed++;}}return{ pushed, failed };}

              3. Pull — Fetch changes since last sync and merge:

              exportasyncfunctionpullChanges(): Promise<SyncResult>{constsupabase=getSupabase();if(!supabase)return{pulled: 0,conflicts: 0};constdb=getUserDb();constlastSync=awaitgetSyncState('last_pull_at');constsince=lastSync??'1970-01-01T00:00:00Z';letpulled=0,conflicts=0;for(consttableofSYNCABLE_TABLES){const{ data, error }=awaitsupabase.from(table).select('*').gte('updated_at',since).order('updated_at',{ascending: true});if(error){logger.error('Sync',`Pull failed for ${table}`,error);continue;}for(constrowofdata??[]){constresult=awaitmergeRow(db,table,row);if(result==='merged')pulled++;if(result==='conflict')conflicts++;}}awaitsetSyncState('last_pull_at',newDate().toISOString());return{ pulled, conflicts };}

              Merge logic — Last Write Wins:

              asyncfunctionmergeRow(db,table,cloudRow): Promise<'merged'|'conflict'|'skipped'>{constlocal=awaitdb.getFirstAsync(`SELECT * FROM ${table} WHERE cloud_id = ?`,[cloudRow.id]);if(!local){awaitinsertFromCloud(db,table,cloudRow);return'merged';}if(cloudRow.deleted_at){awaitdb.runAsync(`DELETE FROM ${table} WHERE cloud_id = ?`,[cloudRow.id]);return'merged';}constcloudTime=newDate(cloudRow.updated_at).getTime();constlocalTime=newDate(local.updated_at).getTime();if(cloudTime>localTime){awaitupdateFromCloud(db,table,cloudRow,local.id);return'merged';}returncloudTime===localTime ? 'skipped' : 'conflict';}

              services/syncScheduler.ts — Background sync timing (~80 lines)

              // Schedules sync operations:// 1. On app foreground (via AppState listener)// 2. Every 5 minutes while app is active// 3. On NetInfo change from offline → online// 4. Debounced 3s after any local write (via enqueueSync)import{AppState}from'react-native';importNetInfofrom'@react-native-community/netinfo';

              stores/syncStore.ts — Sync state for UI (~60 lines)

              interfaceSyncState{isSyncing: boolean;lastSyncAt: string|null;pendingCount: number;error: string|null;triggerSync: ()=>Promise<void>;refreshPendingCount: ()=>Promise<void>;}

              2D. Modifying db/userMutations.ts — Add Sync Triggers

              Note: The original issue referenced db/user.ts. That file was split into
              userQueries.ts (reads) and userMutations.ts (writes). All sync triggers
              go in userMutations.ts.

              Every write function gets a sync enqueue call. The pattern:

              exportasyncfunctionsaveNote(verseRef: string,text: string): Promise<number>{constcloudId=generateUUID();constresult=awaitgetUserDb().runAsync("INSERT INTO user_notes (verse_ref, note_text, cloud_id) VALUES (?, ?, ?)",[verseRef,text,cloudId]);constnoteId=result.lastInsertRowId;awaitgetUserDb().runAsync("INSERT INTO notes_fts(rowid, note_text) VALUES (?, ?)",[noteId,text]);enqueueSync('user_notes',noteId,'INSERT',{id: cloudId,verse_ref: verseRef,note_text: text,tags_json: '[]',collection_id: null,}).catch(noop);returnnoteId;}

              Soft delete pattern:

              exportasyncfunctiondeleteNote(id: number): Promise<void>{constrow=awaitgetUserDb().getFirstAsync<{cloud_id: string|null}>('SELECT cloud_id FROM user_notes WHERE id = ?',[id]);awaitgetUserDb().runAsync("UPDATE user_notes SET deleted_at = datetime('now') WHERE id = ?",[id]);awaitgetUserDb().runAsync("DELETE FROM notes_fts WHERE rowid = ?",[id]);if(row?.cloud_id){enqueueSync('user_notes',id,'DELETE',{id: row.cloud_id}).catch(noop);}}

              Complete function list (20 functions — corrected from original 14):

              #FunctionTableOperationNotes
              1saveNoteuser_notesINSERTGenerate cloud_id
              2updateNoteuser_notesUPDATE
              3deleteNoteuser_notesDELETE (soft)Set deleted_at, remove from FTS
              4updateNoteTagsuser_notesUPDATEAdded — missing from original
              5setNoteCollectionuser_notesUPDATEAdded — missing from original
              6addBookmarkbookmarksINSERTGenerate cloud_id
              7removeBookmarkbookmarksDELETE (soft)
              8setHighlightverse_highlightsUPSERTGenerate cloud_id on insert
              9removeHighlightverse_highlightsDELETE (soft)
              10createCollectionstudy_collectionsINSERTGenerate cloud_id
              11updateCollectionstudy_collectionsUPDATE
              12deleteCollectionstudy_collectionsDELETE (soft)
              13createHighlightCollectionhighlight_collectionsINSERTLocal ID is TEXT — use as cloud_id
              14deleteHighlightCollectionhighlight_collectionsDELETE (hard→soft)
              15linkNotesnote_linksINSERTGenerate cloud_id
              16unlinkNotesnote_linksDELETE (hard)
              17bookmarkTopicbookmarked_topicsUPSERTAdded — generate cloud_id
              18unbookmarkTopicbookmarked_topicsDELETE (soft)Added

              NOT synced (local-only):

              • startStudySession, endStudySession, recordSessionEvent — device-local analytics
              • flagContent — moderation, separate sync mechanism (v14 synced column)
              • upsertAuthProfile, clearAuthProfile — auth state, not user content
              • startPlan, completePlanDay, abandonPlan — synced in Session 3
              • resetToNewUser — dev tool

              UUID generation: Use expo-crypto (already installed):

              import*asCryptofrom'expo-crypto';exportfunctiongenerateUUID(): string{returnCrypto.randomUUID();}

              2E. Modifying db/userQueries.ts — Add Soft Delete Filters

              All read queries touching soft-deletable tables must add WHERE deleted_at IS NULL:

              FunctionTable
              getNotesForChapteruser_notes
              getNoteCountuser_notes
              getAllNotesuser_notes
              searchNotesuser_notes
              searchNotesFTSuser_notes
              getBookmarksbookmarks
              isBookmarkedbookmarks
              getHighlightsForChapterverse_highlights
              getAllHighlightsverse_highlights
              getCollectionsstudy_collections
              getCollectionstudy_collections
              getNotesInCollectionuser_notes
              getCollectionNoteCountsuser_notes
              getAllTagsuser_notes
              getNotesByTaguser_notes
              getLinkedNotesnote_links → user_notes
              getReferencingNotesnote_links → user_notes
              getBookmarkedTopicsbookmarked_topics
              isTopicBookmarkedbookmarked_topics

              2F. Tests

              __tests__/services/syncEngine.test.ts — New (~250 lines)

              describe('syncEngine',()=>{describe('enqueueSync',()=>{it('inserts queue entry with table_name and local_id',async()=>{ ... });it('schedules a push after enqueue',async()=>{ ... });});describe('pushQueue',()=>{it('pushes INSERT operations to Supabase upsert',async()=>{ ... });it('pushes DELETE as soft-delete',async()=>{ ... });it('removes queue entries after success',async()=>{ ... });it('increments attempts on failure',async()=>{ ... });it('drops items after 5 failed attempts',async()=>{ ... });it('does nothing when not authenticated',async()=>{ ... });});describe('pullChanges',()=>{it('fetches rows updated since last_pull_at',async()=>{ ... });it('inserts new cloud rows locally with cloud_id',async()=>{ ... });it('LWW: cloud wins on newer timestamp',async()=>{ ... });it('LWW: local wins on newer timestamp',async()=>{ ... });it('soft-deletes locally when cloud has deleted_at',async()=>{ ... });});});

              __tests__/db/userMutations.test.ts — New (~150 lines)

              Test each write function enqueues sync correctly:

              describe('userMutations sync integration',()=>{it('saveNote enqueues INSERT with cloud_id',async()=>{ ... });it('updateNote enqueues UPDATE',async()=>{ ... });it('deleteNote soft-deletes and enqueues DELETE',async()=>{ ... });it('updateNoteTags enqueues UPDATE',async()=>{ ... });it('bookmarkTopic enqueues UPSERT',async()=>{ ... });// ... one test per write function});

              __tests__/db/userQueries.test.ts — New (~80 lines)

              describe('userQueries soft-delete filtering',()=>{it('getAllNotes excludes soft-deleted rows',async()=>{ ... });it('getBookmarks excludes soft-deleted rows',async()=>{ ... });it('getBookmarkedTopics excludes soft-deleted rows',async()=>{ ... });// ... one test per query with soft-delete filter});

              2G. Session 2 Deliverables

              DeliverableVerification
              Cloud schema deployedTables visible in Supabase dashboard
              Migration v15 runs cleanlyApp launches, cloud_id columns exist
              Note CRUD syncsCreate note → appears in Supabase → pull on second device
              Highlight CRUD syncsSame
              Bookmark CRUD syncsSame
              Collection CRUD syncsSame
              Topic bookmark syncsSame
              Soft deletes workDelete note → deleted_at set → synced → hidden on other device
              Offline queue persistsAirplane mode → create note → reconnect → note pushes
              LWW conflict resolutionEdit same note on 2 devices → newer wins
              Tests passnpm test -- --testPathPattern="sync|user"

              Session 3: Reading Progress + Study Depth + Plans + Settings Sync

              Goal

              Sync reading progress (union merge), study depth (additive), reading plan progress,
              and user preferences (LWW).

              3A. Additional Cloud Tables

              CREATETABLEreading_progress (
              user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
              book_id TEXTNOT NULL,
              chapter_num INTEGERNOT NULL,
              completed_at TIMESTAMPTZ,
              PRIMARY KEY (user_id, book_id, chapter_num)
              );
              CREATETABLEstudy_depth (
              id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
              user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
              chapter_id TEXTNOT NULL,
              section_id TEXTNOT NULL,
              panel_type TEXTNOT NULL,
              first_opened_at TIMESTAMPTZ DEFAULT now(),
              UNIQUE(user_id, section_id, panel_type)
              );
              CREATETABLEuser_preferences (
              user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
              key TEXTNOT NULL,
              value TEXTNOT NULL,
              updated_at TIMESTAMPTZ DEFAULT now(),
              PRIMARY KEY (user_id, key)
              );
              CREATETABLEreading_streaks (
              id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
              user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
              dateTEXTNOT NULL,
              chapters_read INTEGERNOT NULL DEFAULT 0,
              books_touched TEXT,
              UNIQUE(user_id, date)
              );
              CREATETABLEplan_progress (
              user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
              plan_id TEXTNOT NULL,
              day_num INTEGERNOT NULL,
              completed_at TIMESTAMPTZ,
              PRIMARY KEY (user_id, plan_id, day_num)
              );
              -- RLS for all new tables
              DO $$
              DECLARE tbl TEXT;
              BEGIN
              FOR tbl INVALUES
              ('reading_progress'), ('study_depth'), ('user_preferences'),
              ('reading_streaks'), ('plan_progress')
              LOOP
              EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl);
              EXECUTE format(
              'CREATE POLICY "Users own their %1$s" ON %1$I FOR ALL USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid())', tbl);
              END LOOP;
              END $$;

              3B. Special Merge Strategies

              Reading Progress — Union Merge

              Never un-completes. If either side says completed, it stays completed.
              Keep the earliest completed_at timestamp.

              Study Depth — Additive Only

              Append-only. Cloud accumulates all panels ever opened. Merge = INSERT OR IGNORE.

              Reading Plans — Union Merge

              Plan progress uses same union logic as reading progress — once a day is completed,
              it stays completed.

              User Preferences — LWW

              Simple key-value pairs with updated_at for conflict resolution.

              3C. Sync Triggers for Session 3 Functions

              recordVisit in userMutations.ts: Enqueue sync for reading_progress.

              completePlanDay in userMutations.ts: Enqueue sync for plan_progress.

              settingsStore.ts preference setters: Each setPreference call gets a sync enqueue.

              3D. UI: Sync Status in Settings

              Add a "Sync" section to SettingsScreen:

              ┌──────────────────────────────────────┐
              │ SYNC │
              │ │
              │ Status ✓ Up to date │
              │ Last sync 2 min ago │
              │ Pending 0 changes │
              │ │
              │ [Sync Now] │
              │ │
              │ Only visible when signed in. │
              └──────────────────────────────────────┘
              

              3E. Session 3 Deliverables

              DeliverableVerification
              Reading progress syncs (union)Read chapter → mark on device A → appears on device B
              Never un-completesDelete cloud row → local stays completed
              Study depth syncs (additive)Open panel → record appears in Supabase
              Plan progress syncs (union)Complete day on A → appears on B
              Preferences sync (LWW)Change font size on A → appears on B
              Sync status UI in SettingsShows status, last sync time, pending count
              Tests passnpm test -- --testPathPattern="sync|settings"

              Session 4: First-Sign-In Migration + Edge Cases + Account Deletion

              Goal

              Handle the critical first-sign-in scenario (user has months of local data),
              edge cases (offline→online, large datasets, stale queues), and App Store-compliant
              account deletion.

              4A. First-Sign-In Migration

              services/initialMigration.ts — New (~150 lines)

              When a user signs in for the first time, they may have hundreds of notes, bookmarks,
              and reading progress entries with no cloud_id. One-time bulk upload:

              1. Check if migration already happened (sync_state.initial_migration_complete)
              2. Check cloud for existing data (returning user on new device?)
              3. Assign cloud_id to all local rows missing them
              4. Upload in batches of 50 (collections first for FK resolution, then notes, etc.)
              5. Mark complete

              Migration UI — SyncMigrationSheet

              Bottom-sheet showing progress: "Uploading 47 notes, 312 chapters read..." with
              progress bar. Auto-dismisses on completion.

              4B. Edge Cases

              • Offline → online flush:pushQueue() processes max 100 items per call, chains until empty
              • Queue compaction: When queue > 200 items, compact by keeping only latest per table+local_id
              • Note links with unmapped IDs: Upload notes before note_links; defer links with unresolved refs
              • Interrupted migration:initial_migration_complete only set on full success; idempotent upserts prevent duplicates on retry
              • Stale INSERT+DELETE pairs: Compaction drops both when INSERT never pushed

              4C. Account Deletion

              services/accountDeletion.ts — New (~50 lines)

              1. Invoke Supabase Edge Function delete-user-data (service_role deletes all user tables + auth account)
              2. Clear local auth state
              3. Stop sync scheduler
              4. Local study data preserved on device

              Supabase Edge Function: delete-user-data

              Server-side function using service_role key to delete all user rows across all tables
              and delete the auth account. Client-side RLS can't cascade across tables.

              Deletion UI in SettingsScreen

              "Delete Account" in a Danger Zone section (only visible when signed in).
              Two-step confirmation: alert → type "DELETE" to confirm.

              4D. Session 4 Deliverables

              DeliverableVerification
              First sign-in migration worksUser with 6 months data → sign in → all data in Supabase
              Returning user on new deviceSign in on device B → all data appears
              Migration progress UIBottom sheet shows progress during initial upload
              Offline queue compaction500 offline edits → compacted → clean push
              Account deletion worksDelete → cloud data gone → local data preserved
              Two-step delete confirmationCan't accidentally delete
              Edge function deployeddelete-user-data in Supabase Functions
              Tests passFull suite green

              Complete File Inventory (Corrected)

              New Files (10+)

              FileLines (est.)Session
              services/syncEngine.ts~4002
              services/syncScheduler.ts~802
              services/initialMigration.ts~1504
              services/accountDeletion.ts~504
              stores/syncStore.ts~602
              __tests__/services/syncEngine.test.ts~3502-3
              __tests__/services/syncScheduler.test.ts~602
              __tests__/db/userMutations.test.ts~1502
              __tests__/db/userQueries.test.ts~802
              __tests__/services/initialMigration.test.ts~1204
              __tests__/services/accountDeletion.test.ts~604
              __tests__/screens/LoginScreen.test.tsx~801

              Modified Files (12+)

              FileChange SummarySession
              lib/supabase.tsActivate, SecureStore adapter, real import1
              lib/oauthHelpers.tsAdd signInWithApple()1
              stores/authStore.tsAdd Apple, remove Facebook1
              screens/LoginScreen.tsxReplace Facebook → Apple, add guest option1
              screens/MoreMenuScreen.tsxEnhanced auth section + sync status1, 3
              screens/SettingsScreen.tsxSync status section + account deletion3, 4
              db/userDatabase.tsMigration v15: cloud_id, sync_queue extension, soft delete2
              db/userMutations.tsSync triggers on all 18+ write functions + soft delete2, 3
              db/userQueries.tsWHERE deleted_at IS NULL on 19+ queries2
              stores/settingsStore.tsSync preference changes3
              app.jsonApple auth entitlement + plugin1
              types/user.tsAdd cloud_id and deleted_at to interfaces2

              New Dependencies (6)

              PackagePurpose
              @supabase/supabase-jsSupabase client
              @react-native-async-storage/async-storageRequired by Supabase (session fallback)
              react-native-url-polyfillRequired by Supabase (URL parsing)
              expo-secure-storeEncrypted JWT storage
              expo-apple-authenticationNative Apple Sign-In
              @react-native-community/netinfoNetwork state for sync scheduler

              Test Summary (~75 tests)

              Test FileTests (est.)
              syncEngine.test.ts~30
              syncScheduler.test.ts~5
              userMutations.test.ts~18
              userQueries.test.ts~10
              initialMigration.test.ts~8
              accountDeletion.test.ts~5
              LoginScreen.test.tsx~6
              authStore.test.ts (extend)~3

              Risk Register

              RiskImpactMitigation
              Apple rejects without native Apple Sign-InApp Store rejectionUsing expo-apple-authentication (native), not web OAuth
              Supabase free tier limitsSync stops at 50K MAUUpgrade to Pro ($25/mo) — covered by revenue model
              Large initial migration failsUser data doesn't syncIdempotent upserts; retry on next launch; progress UI
              LWW loses edits on simultaneous editUser loses a note editAcceptable for v1; realtime sync (v2) reduces window
              expo-secure-store 2KB value limitJWT too largeSupabase JWTs are ~1KB; within limit
              NetInfo false positivesSync on captive portalsTimeout + retry with backoff
              Soft delete bloatDeleted rows accumulatePeriodic cleanup: hard-delete rows > 30 days old
              v14 sync_queue schema mismatchALTER may fail on existing datav15 uses ADD COLUMN with defaults; safe for existing rows

              Drift Corrections (April 2026)

              This issue was originally written against an older codebase. The following corrections
              were applied:

              1. db/user.ts split → now userQueries.ts (reads) + userMutations.ts (writes)
              2. Migrations at v14 (not v8) — sync migration is now v15
              3. sync_queue already exists (v14) — v15 extends it with table_name and local_id
              4. @supabase/supabase-js NOT installed — full dep install list corrected
              5. SignUpScreen.tsx and ForgotPasswordScreen.tsx exist — LoginScreen redesign simplified
              6. 20 write functions (not 14) — updateNoteTags, setNoteCollection, bookmarkTopic, unbookmarkTopic added
              7. highlight_collections.id is TEXT — can use local ID as cloud_id directly
              8. user_preferences needs updated_at — added to v15 migration
              9. supabase.ts uses env vars — kept (better than hardcoded)
              10. Decisions confirmed: Drop Facebook ✓, Sync bookmarked topics ✓, Sync reading plans ✓, Don't sync study sessions ✓

              Metadata

              Metadata

              Labels

              premiumPremium tier feature

              Projects

              No projects

                Milestone

                No milestone

                Relationships

                None yet

                Development

                No branches or pull requests

                Issue actions

                , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
                Skip to content

                Code: A7. Cross-Device Sync — Supabase auth + bidirectional sync #65

                Description

                @CraigBuckmaster

                Summary

                Supabase Auth (Apple/Google/Email), bidirectional sync for all user data, offline queue, first-sign-in migration, account deletion. Premium feature.

                Sessions: 4–5 (sequential — each builds on the last)

                Prerequisite: Craig creates a Supabase project, configures Apple/Google OAuth providers, provides URL + anon key.

                Last updated: April 2026 — corrected against current codebase state. See "Drift Corrections" section at bottom.

                Session Breakdown

                SessionScopeKey Deliverables
                1Supabase Auth (Apple/Google/Email sign-in)Native Apple Sign-In, SecureStore JWT, LoginScreen redesign (Facebook → Apple), deps install
                2Sync engine + notes/highlights/bookmarks/topicsMigration v15 (cloud_id, sync_queue extension, soft delete), 20 write functions get sync triggers, push/pull with LWW
                3Reading progress + study depth + plans + settings syncUnion merge for progress, additive for depth, LWW for prefs, plan sync, sync status UI
                4First-sign-in migration + edge cases + account deletionBulk upload with progress UI, queue compaction, Edge Function for deletion

                Architecture

                • Offline-first: Local SQLite is always source of truth. Cloud is backup + sync layer. App never blocks on network.
                • Conflict resolution: Last-write-wins for notes/bookmarks/highlights/topics. Union merge for reading progress (never un-completes). Additive for study depth.
                • Soft deletes: All deletable entities get deleted_at column. Both sides honor soft deletes.
                • Queue-based push: Every local write enqueues to sync_queue. Debounced 3s push. Retry with backoff. Drop after 5 failures.

                File Impact

                • 10+ new files (services, stores, tests)
                • 12+ modified files (auth, db, settings, navigation)
                • 6 new dependencies
                • ~75 new tests

                Decisions (Confirmed)

                • Drop Facebook auth: Yes — heavy SDK, low value for Bible app demographic
                • Sync bookmarked topics: Yes — lightweight, user expects cross-device
                • Sync reading plans/progress: Yes — plan progress should follow user across devices
                • Sync study sessions: No — high-frequency device-local analytics, not user content
                • Sync flagged content: No — moderation, server-bound via separate mechanism (v14 synced column)

                Pre-Session Checklist (Craig's Homework)

                • Create Supabase project at supabase.com
                • Copy SUPABASE_URL and SUPABASE_ANON_KEY
                • Add both to .env, app.json extras, and eas.json build profiles
                • Enable Apple provider in Supabase Auth settings
                  • Create Apple Services ID in Apple Developer Console
                  • Generate a key for Sign in with Apple
                  • Paste credentials into Supabase
                • Enable Google provider in Supabase Auth settings
                  • Create OAuth 2.0 Client ID in Google Cloud Console
                  • Paste Client ID + Secret into Supabase
                • Confirm scheme: "scripture" in app.json (already set ✓)
                • Confirm whether any production users are on migration v14 already (affects sync_queue strategy)

                Full Dev Plan

                A7. Cross-Device Sync — Dev Plan (Corrected April 2026)

                Scope: Supabase Auth (Apple/Google/Email), bidirectional sync for all user data,
                offline queue, first-sign-in migration, account deletion.

                Sessions: 4–5 (can't be parallelized — each builds on the last)

                Prerequisite: Craig creates a Supabase project at supabase.com, provides the
                SUPABASE_URL and SUPABASE_ANON_KEY, and configures Apple/Google OAuth providers
                in the Supabase dashboard.


                Current State (April 2026)

                WhatStatus
                authStore.ts178 lines, functional, Supabase client via env vars (process.env.SUPABASE_URL)
                LoginScreen.tsx336 lines, Google + Facebook + Email — missing Apple Sign-In
                SignUpScreen.tsx295 lines, exists and working
                ForgotPasswordScreen.tsx184 lines, exists and working
                lib/supabase.tsStub using process.env.SUPABASE_URL pattern, CONFIGURED = !!process.env.SUPABASE_URL
                lib/oauthHelpers.tsWorking Google/Facebook OAuth via expo-auth-session
                db/user.tsBarrel re-exportuserQueries.ts (496 lines) + userMutations.ts (372 lines)
                userDatabase.ts14 migrations, no cloud_id, no sync_state, no soft delete. sync_queue exists (v14) but incomplete schema
                Cloud schemaNone — Supabase project doesn't exist yet
                TestsauthStore.test.ts exists, supabaseMock.js exists
                Dependencies installedexpo-auth-session, expo-web-browser, expo-crypto
                Dependencies NOT installed@supabase/supabase-js, expo-secure-store, expo-apple-authentication, @react-native-community/netinfo, @react-native-async-storage/async-storage, react-native-url-polyfill

                Session 1: Supabase Auth (Apple/Google/Email Sign-In)

                Goal

                Users can sign in with Apple, Google, or email/password. Session persists across
                app restarts. Sign-out clears session. Account section in MoreMenu shows user info.
                Facebook auth removed.

                Pre-session (Craig)

                1. Create Supabase project → copy URL + anon key
                2. Add SUPABASE_URL and SUPABASE_ANON_KEY to .env, app.json extras, and eas.json build profiles
                3. In Supabase Dashboard → Authentication → Providers:
                  • Enable Apple: add Services ID, Team ID, Key ID, private key
                  • Enable Google: add Client ID + secret from Google Cloud Console
                  • Enable Email: already on by default
                4. In Apple Developer Console:
                  • Create a Services ID for "Sign in with Apple"
                  • Register the Supabase callback URL as a redirect URI

                1A. Dependencies

                cd app/
                npx expo install @supabase/supabase-js @react-native-async-storage/async-storage \
                react-native-url-polyfill expo-secure-store expo-apple-authentication \
                @react-native-community/netinfo

                Already installed: expo-auth-session, expo-web-browser, expo-crypto.

                expo-apple-authentication is needed for native Apple Sign-In on iOS (the App
                Store rejects web-based Apple OAuth — it must use the native credential API).

                1B. Files to Modify

                lib/supabase.ts — Activate the client

                // Changes:// 1. Keep env var pattern (already correct): process.env.SUPABASE_URL// 2. Switch auth storage from AsyncStorage to expo-secure-store// (SecureStore is encrypted; AsyncStorage is not)// 3. Replace the placeholder SupabaseClient interface with actual importimport*asSecureStorefrom'expo-secure-store';constExpoSecureStoreAdapter={getItem: (key: string)=>SecureStore.getItemAsync(key),setItem: (key: string,value: string)=>SecureStore.setItemAsync(key,value),removeItem: (key: string)=>SecureStore.deleteItemAsync(key),};// In createClient options:
                auth: {storage: ExpoSecureStoreAdapter,autoRefreshToken: true,persistSession: true,detectSessionInUrl: false,flowType: 'pkce',}

                Note:expo-secure-store needs to be installed (see 1A above).

                lib/oauthHelpers.ts — Add Apple Sign-In

                Add signInWithApple() as a separate function (not a new case in signInWithProvider
                Apple uses native credential flow via signInWithIdToken, architecturally different from
                the browser-redirect flow used by Google):

                exportasyncfunctionsignInWithApple(): Promise<{error?: string}>{constAppleAuthentication=require('expo-apple-authentication');constsupabase=getSupabase();if(!supabase)return{error: 'Auth not available'};try{constcredential=awaitAppleAuthentication.signInAsync({requestedScopes: [AppleAuthentication.AppleAuthenticationScope.FULL_NAME,AppleAuthentication.AppleAuthenticationScope.EMAIL,],});if(!credential.identityToken){return{error: 'No identity token from Apple'};}const{ error }=awaitsupabase.auth.signInWithIdToken({provider: 'apple',token: credential.identityToken,});if(error)return{error: error.message};return{};}catch(err: any){if(err.code==='ERR_REQUEST_CANCELED')return{error: 'Sign-in canceled'};return{error: err.message??'Apple sign-in failed'};}}

                stores/authStore.ts — Add Apple, drop Facebook

                // Changes:// 1. Add signInWithApple method// 2. Remove signInWithFacebook from interface and implementation// 3. Keep signInWithGoogle and signInWithEmail as-issignInWithApple: async()=>{const{ isSupabaseAvailable }=getAuth();if(!isSupabaseAvailable()){return{error: 'Sign-in requires a development build.'};}set({isLoading: true});try{const{ signInWithApple }=require('../lib/oauthHelpers');returnawaitsignInWithApple();}finally{set({isLoading: false});},},

                screens/LoginScreen.tsx — Replace Facebook with Apple

                Since SignUpScreen.tsx and ForgotPasswordScreen.tsx already exist with working
                navigation routes, the redesign is simpler than originally planned:

                1. Replace Facebook button → Apple button (black bg, white text, Apple icon)
                2. Keep existing Google button
                3. Keep email form + existing navigation to SignUp / ForgotPassword
                4. Add "Continue without signing in" footer (navigation.goBack())
                5. Add privacy reassurance text
                6. Remove signInWithFacebook import/usage

                Layout (top to bottom):

                ┌─────────────────────────────────┐
                │ ScreenHeader "Sign In" │
                │ │
                │ "Sign in to sync your study │
                │ data across devices." │
                │ │
                │ ┌─────────────────────────┐ │
                │ │ ◉ Continue with Apple │ │ ← Black button, white text, Apple icon
                │ └─────────────────────────┘ │
                │ ┌─────────────────────────┐ │
                │ │ G Continue with Google│ │ ← White button, dark text
                │ └─────────────────────────┘ │
                │ │
                │ ──────── or ──────── │
                │ │
                │ [ Email ] │
                │ [ Password ] │
                │ ┌─────────────────────────┐ │
                │ │ Sign In │ │ ← Gold button
                │ └─────────────────────────┘ │
                │ Forgot password? ← navigates │
                │ to ForgotPasswordScreen │
                │ │
                │ Don't have an account? Sign Up │ ← navigates to SignUpScreen
                │ │
                │ ───────────────────────────── │
                │ Your study data stays on your │
                │ device even without an │
                │ account. Signing in adds │
                │ cross-device sync. │
                │ │
                │ ┌─────────────────────────┐ │
                │ │ Continue without │ │ ← Text button, muted
                │ │ signing in │ │
                │ └─────────────────────────┘ │
                └─────────────────────────────────┘
                

                screens/MoreMenuScreen.tsx — Enhanced auth section

                Currently shows user email when signed in, "Sign In" when not. Enhance:

                ┌──────────────────────────────────────┐
                │ SIGNED IN STATE: │
                │ │
                │ [Avatar] Craig Buckmaster │
                │ craig@companionstudy.app │
                │ ✓ Synced 2 min ago │ ← sync status line (Session 2)
                │ │
                │ [Sign Out] │
                │ │
                │ NOT SIGNED IN: │
                │ │
                │ [Lock icon] Sign In │
                │ Sync your study data │
                │ across devices │
                │ │
                └──────────────────────────────────────┘
                

                1C. App Config Changes

                app.json — Add Apple auth entitlement

                {
                "expo": {
                "ios": {
                "usesAppleSignIn": true
                },
                "plugins": [
                "expo-apple-authentication",
                // ... existing plugins (expo-font, expo-screen-orientation, etc.)
                ]
                }
                }

                scheme: "scripture" is already set ✓

                1D. Tests

                __tests__/stores/authStore.test.ts — Extend

                Add tests for:

                • signInWithApple dispatches correctly when available
                • signInWithApple returns error in Expo Go
                • Facebook methods are removed
                • Hydrate restores session from SecureStore
                • Sign-out clears SecureStore + local profile
                • Error states propagate correctly

                __tests__/screens/LoginScreen.test.tsx — New

                describe('LoginScreen',()=>{it('renders Apple, Google, and email sign-in options',()=>{ ... });it('does NOT render Facebook button',()=>{ ... });it('navigates to SignUpScreen on "Sign Up" press',()=>{ ... });it('navigates to ForgotPasswordScreen on "Forgot password" press',()=>{ ... });it('shows error message on failed sign-in',async()=>{ ... });it('navigates back on "Continue without signing in"',()=>{ ... });});

                __tests__/lib/supabase.test.ts — New

                describe('supabase client',()=>{it('returns null when env vars not set',()=>{ ... });it('creates client singleton when configured',()=>{ ... });it('uses SecureStore adapter for auth storage',()=>{ ... });});

                1E. Session 1 Deliverables

                DeliverableVerification
                Apple Sign-In works on deviceSign in → session persists on restart
                Google Sign-In worksSign in → session persists
                Email sign-in/sign-up worksCreate account → sign in → session persists
                Sign-out clears sessionSign out → MoreMenu shows "Sign In"
                Facebook removedNo Facebook button in UI, no Facebook in authStore
                Forgot password worksRequest reset → email received
                Tests passnpm test -- --testPathPattern="auth|Login|supabase"

                Session 2: Sync Engine + Notes/Highlights/Bookmarks/Topics

                Goal

                All user writes (notes, highlights, bookmarks, collections, note links, topic bookmarks)
                are queued for sync. On app launch and periodically, queued changes push to Supabase.
                Pull merges cloud changes into local SQLite.

                2A. Supabase Cloud Schema

                Run in Supabase SQL Editor (one-time setup):

                -- ══════════════════════════════════════════════════-- TABLES-- ══════════════════════════════════════════════════CREATETABLEuser_notes (
                id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
                user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
                verse_ref TEXTNOT NULL,
                note_text TEXTNOT NULL,
                tags_json TEXT DEFAULT '[]',
                collection_id UUID,
                created_at TIMESTAMPTZ DEFAULT now(),
                updated_at TIMESTAMPTZ DEFAULT now(),
                deleted_at TIMESTAMPTZ
                );
                CREATETABLEstudy_collections (
                id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
                user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
                name TEXTNOT NULL,
                description TEXT DEFAULT '',
                color TEXT DEFAULT '#bfa050',
                created_at TIMESTAMPTZ DEFAULT now(),
                updated_at TIMESTAMPTZ DEFAULT now(),
                deleted_at TIMESTAMPTZ
                );
                ALTERTABLE user_notes
                ADD CONSTRAINT fk_notes_collection
                FOREIGN KEY (collection_id) REFERENCES study_collections(id)
                ON DELETESETNULL;
                CREATETABLEnote_links (
                id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
                user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
                from_note_id UUID NOT NULLREFERENCES user_notes(id) ON DELETE CASCADE,
                to_note_id UUID NOT NULLREFERENCES user_notes(id) ON DELETE CASCADE,
                created_at TIMESTAMPTZ DEFAULT now()
                );
                CREATETABLEbookmarks (
                id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
                user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
                verse_ref TEXTNOT NULL,
                label TEXT,
                created_at TIMESTAMPTZ DEFAULT now(),
                deleted_at TIMESTAMPTZ
                );
                CREATETABLEverse_highlights (
                id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
                user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
                verse_ref TEXTNOT NULL,
                color TEXTNOT NULL,
                collection_id TEXT,
                note TEXT,
                created_at TIMESTAMPTZ DEFAULT now(),
                deleted_at TIMESTAMPTZ,
                UNIQUE(user_id, verse_ref)
                );
                CREATETABLEhighlight_collections (
                id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
                user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
                name TEXTNOT NULL,
                color TEXTNOT NULL,
                sort_order INTEGER DEFAULT 0,
                created_at TIMESTAMPTZ DEFAULT now()
                );
                CREATETABLEbookmarked_topics (
                id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
                user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
                topic_id TEXTNOT NULL,
                topic_type TEXTNOT NULL DEFAULT 'official',
                cached_title TEXT,
                cached_summary TEXT,
                created_at TIMESTAMPTZ DEFAULT now(),
                deleted_at TIMESTAMPTZ,
                UNIQUE(user_id, topic_id, topic_type)
                );
                -- ══════════════════════════════════════════════════-- ROW LEVEL SECURITY (all tables)-- ══════════════════════════════════════════════════
                DO $$
                DECLARE
                tbl TEXT;
                BEGIN
                FOR tbl INVALUES
                ('user_notes'), ('study_collections'), ('note_links'),
                ('bookmarks'), ('verse_highlights'), ('highlight_collections'),
                ('bookmarked_topics')
                LOOP
                EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl);
                EXECUTE format(
                'CREATE POLICY "Users own their %1$s" ON %1$I FOR ALL USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid())', tbl);
                END LOOP;
                END $$;
                -- ══════════════════════════════════════════════════-- INDEXES-- ══════════════════════════════════════════════════CREATEINDEXidx_notes_userON user_notes(user_id);
                CREATEINDEXidx_notes_updatedON user_notes(user_id, updated_at);
                CREATEINDEXidx_collections_userON study_collections(user_id);
                CREATEINDEXidx_note_links_userON note_links(user_id);
                CREATEINDEXidx_bookmarks_userON bookmarks(user_id);
                CREATEINDEXidx_bookmarks_updatedON bookmarks(user_id, created_at);
                CREATEINDEXidx_highlights_userON verse_highlights(user_id);
                CREATEINDEXidx_highlight_collections_userON highlight_collections(user_id);
                CREATEINDEXidx_bookmarked_topics_userON bookmarked_topics(user_id);
                -- ══════════════════════════════════════════════════-- AUTO-UPDATE updated_at-- ══════════════════════════════════════════════════CREATE OR REPLACEFUNCTIONupdate_updated_at()
                RETURNS TRIGGER AS $$
                BEGINNEW.updated_at= now();
                RETURN NEW;
                END;
                $$ LANGUAGE plpgsql;
                CREATETRIGGERtrg_notes_updated BEFORE UPDATEON user_notes
                FOR EACH ROW EXECUTE FUNCTION update_updated_at();
                CREATETRIGGERtrg_collections_updated BEFORE UPDATEON study_collections
                FOR EACH ROW EXECUTE FUNCTION update_updated_at();

                2B. Local Database Migration

                userDatabase.ts — Migration v15: Sync infrastructure

                The existing sync_queue table (from v14) has an incomplete schema. v15 extends it
                and adds cloud sync columns to all user tables.

                -- cloud_id for all synced tablesALTERTABLE user_notes ADD COLUMN cloud_id TEXT;
                ALTERTABLE study_collections ADD COLUMN cloud_id TEXT;
                ALTERTABLE note_links ADD COLUMN cloud_id TEXT;
                ALTERTABLE bookmarks ADD COLUMN cloud_id TEXT;
                ALTERTABLE verse_highlights ADD COLUMN cloud_id TEXT;
                ALTERTABLE highlight_collections ADD COLUMN cloud_id TEXT;
                ALTERTABLE bookmarked_topics ADD COLUMN cloud_id TEXT;
                -- Soft delete supportALTERTABLE user_notes ADD COLUMN deleted_at TEXT;
                ALTERTABLE study_collections ADD COLUMN deleted_at TEXT;
                ALTERTABLE bookmarks ADD COLUMN deleted_at TEXT;
                ALTERTABLE verse_highlights ADD COLUMN deleted_at TEXT;
                ALTERTABLE bookmarked_topics ADD COLUMN deleted_at TEXT;
                -- Extend existing sync_queue (from v14) with routing columnsALTERTABLE sync_queue ADD COLUMN table_name TEXTNOT NULL DEFAULT '';
                ALTERTABLE sync_queue ADD COLUMN local_id TEXTNOT NULL DEFAULT '';
                -- Sync state table (last_pull_at, migration flags)CREATETABLEIF NOT EXISTS sync_state (
                key TEXTPRIMARY KEY,
                value TEXTNOT NULL
                );
                -- LWW support for preferencesALTERTABLE user_preferences ADD COLUMN updated_at TEXT;
                -- Indexes for merge lookupsCREATEUNIQUE INDEXIF NOT EXISTS idx_notes_cloud_id ON user_notes(cloud_id);
                CREATEUNIQUE INDEXIF NOT EXISTS idx_collections_cloud_id ON study_collections(cloud_id);
                CREATEUNIQUE INDEXIF NOT EXISTS idx_note_links_cloud_id ON note_links(cloud_id);
                CREATEUNIQUE INDEXIF NOT EXISTS idx_bookmarks_cloud_id ON bookmarks(cloud_id);
                CREATEUNIQUE INDEXIF NOT EXISTS idx_highlights_cloud_id ON verse_highlights(cloud_id);
                CREATEUNIQUE INDEXIF NOT EXISTS idx_hl_collections_cloud_id ON highlight_collections(cloud_id);
                CREATEUNIQUE INDEXIF NOT EXISTS idx_bookmarked_topics_cloud_id ON bookmarked_topics(cloud_id);

                2C. New Files

                services/syncEngine.ts — Core sync orchestrator (~400 lines)

                The sync engine has three responsibilities:

                1. Queue writes — After every local write, enqueue a sync operation:

                exportasyncfunctionenqueueSync(tableName: SyncableTable,localId: string|number,operation: 'INSERT'|'UPDATE'|'DELETE',payload: Record<string,any>,): Promise<void>{constdb=getUserDb();awaitdb.runAsync(`INSERT INTO sync_queue (table_name, local_id, operation, payload_json) VALUES (?, ?, ?, ?)`,[tableName,String(localId),operation,JSON.stringify(payload)],);schedulePush();}

                2. Push — Flush the queue to Supabase:

                exportasyncfunctionpushQueue(): Promise<SyncResult>{constsupabase=getSupabase();if(!supabase)return{pushed: 0,failed: 0};constsession=awaitsupabase.auth.getSession();if(!session.data.session)return{pushed: 0,failed: 0};constdb=getUserDb();constpending=awaitdb.getAllAsync<SyncQueueRow>('SELECT * FROM sync_queue ORDER BY created_at ASC LIMIT 100',);letpushed=0,failed=0;for(constitemofpending){try{constpayload=JSON.parse(item.payload_json);awaitpushItem(supabase,item.table_name,item.operation,payload);awaitdb.runAsync('DELETE FROM sync_queue WHERE id = ?',[item.id]);pushed++;}catch(err){constretries=(item.attempts??0)+1;if(retries>=5){awaitdb.runAsync('DELETE FROM sync_queue WHERE id = ?',[item.id]);logger.error('Sync',`Dropping item after 5 retries`,item);}else{awaitdb.runAsync('UPDATE sync_queue SET attempts = ?, last_error = ? WHERE id = ?',[retries,String(err),item.id],);}failed++;}}return{ pushed, failed };}

                3. Pull — Fetch changes since last sync and merge:

                exportasyncfunctionpullChanges(): Promise<SyncResult>{constsupabase=getSupabase();if(!supabase)return{pulled: 0,conflicts: 0};constdb=getUserDb();constlastSync=awaitgetSyncState('last_pull_at');constsince=lastSync??'1970-01-01T00:00:00Z';letpulled=0,conflicts=0;for(consttableofSYNCABLE_TABLES){const{ data, error }=awaitsupabase.from(table).select('*').gte('updated_at',since).order('updated_at',{ascending: true});if(error){logger.error('Sync',`Pull failed for ${table}`,error);continue;}for(constrowofdata??[]){constresult=awaitmergeRow(db,table,row);if(result==='merged')pulled++;if(result==='conflict')conflicts++;}}awaitsetSyncState('last_pull_at',newDate().toISOString());return{ pulled, conflicts };}

                Merge logic — Last Write Wins:

                asyncfunctionmergeRow(db,table,cloudRow): Promise<'merged'|'conflict'|'skipped'>{constlocal=awaitdb.getFirstAsync(`SELECT * FROM ${table} WHERE cloud_id = ?`,[cloudRow.id]);if(!local){awaitinsertFromCloud(db,table,cloudRow);return'merged';}if(cloudRow.deleted_at){awaitdb.runAsync(`DELETE FROM ${table} WHERE cloud_id = ?`,[cloudRow.id]);return'merged';}constcloudTime=newDate(cloudRow.updated_at).getTime();constlocalTime=newDate(local.updated_at).getTime();if(cloudTime>localTime){awaitupdateFromCloud(db,table,cloudRow,local.id);return'merged';}returncloudTime===localTime ? 'skipped' : 'conflict';}

                services/syncScheduler.ts — Background sync timing (~80 lines)

                // Schedules sync operations:// 1. On app foreground (via AppState listener)// 2. Every 5 minutes while app is active// 3. On NetInfo change from offline → online// 4. Debounced 3s after any local write (via enqueueSync)import{AppState}from'react-native';importNetInfofrom'@react-native-community/netinfo';

                stores/syncStore.ts — Sync state for UI (~60 lines)

                interfaceSyncState{isSyncing: boolean;lastSyncAt: string|null;pendingCount: number;error: string|null;triggerSync: ()=>Promise<void>;refreshPendingCount: ()=>Promise<void>;}

                2D. Modifying db/userMutations.ts — Add Sync Triggers

                Note: The original issue referenced db/user.ts. That file was split into
                userQueries.ts (reads) and userMutations.ts (writes). All sync triggers
                go in userMutations.ts.

                Every write function gets a sync enqueue call. The pattern:

                exportasyncfunctionsaveNote(verseRef: string,text: string): Promise<number>{constcloudId=generateUUID();constresult=awaitgetUserDb().runAsync("INSERT INTO user_notes (verse_ref, note_text, cloud_id) VALUES (?, ?, ?)",[verseRef,text,cloudId]);constnoteId=result.lastInsertRowId;awaitgetUserDb().runAsync("INSERT INTO notes_fts(rowid, note_text) VALUES (?, ?)",[noteId,text]);enqueueSync('user_notes',noteId,'INSERT',{id: cloudId,verse_ref: verseRef,note_text: text,tags_json: '[]',collection_id: null,}).catch(noop);returnnoteId;}

                Soft delete pattern:

                exportasyncfunctiondeleteNote(id: number): Promise<void>{constrow=awaitgetUserDb().getFirstAsync<{cloud_id: string|null}>('SELECT cloud_id FROM user_notes WHERE id = ?',[id]);awaitgetUserDb().runAsync("UPDATE user_notes SET deleted_at = datetime('now') WHERE id = ?",[id]);awaitgetUserDb().runAsync("DELETE FROM notes_fts WHERE rowid = ?",[id]);if(row?.cloud_id){enqueueSync('user_notes',id,'DELETE',{id: row.cloud_id}).catch(noop);}}

                Complete function list (20 functions — corrected from original 14):

                #FunctionTableOperationNotes
                1saveNoteuser_notesINSERTGenerate cloud_id
                2updateNoteuser_notesUPDATE
                3deleteNoteuser_notesDELETE (soft)Set deleted_at, remove from FTS
                4updateNoteTagsuser_notesUPDATEAdded — missing from original
                5setNoteCollectionuser_notesUPDATEAdded — missing from original
                6addBookmarkbookmarksINSERTGenerate cloud_id
                7removeBookmarkbookmarksDELETE (soft)
                8setHighlightverse_highlightsUPSERTGenerate cloud_id on insert
                9removeHighlightverse_highlightsDELETE (soft)
                10createCollectionstudy_collectionsINSERTGenerate cloud_id
                11updateCollectionstudy_collectionsUPDATE
                12deleteCollectionstudy_collectionsDELETE (soft)
                13createHighlightCollectionhighlight_collectionsINSERTLocal ID is TEXT — use as cloud_id
                14deleteHighlightCollectionhighlight_collectionsDELETE (hard→soft)
                15linkNotesnote_linksINSERTGenerate cloud_id
                16unlinkNotesnote_linksDELETE (hard)
                17bookmarkTopicbookmarked_topicsUPSERTAdded — generate cloud_id
                18unbookmarkTopicbookmarked_topicsDELETE (soft)Added

                NOT synced (local-only):

                • startStudySession, endStudySession, recordSessionEvent — device-local analytics
                • flagContent — moderation, separate sync mechanism (v14 synced column)
                • upsertAuthProfile, clearAuthProfile — auth state, not user content
                • startPlan, completePlanDay, abandonPlan — synced in Session 3
                • resetToNewUser — dev tool

                UUID generation: Use expo-crypto (already installed):

                import*asCryptofrom'expo-crypto';exportfunctiongenerateUUID(): string{returnCrypto.randomUUID();}

                2E. Modifying db/userQueries.ts — Add Soft Delete Filters

                All read queries touching soft-deletable tables must add WHERE deleted_at IS NULL:

                FunctionTable
                getNotesForChapteruser_notes
                getNoteCountuser_notes
                getAllNotesuser_notes
                searchNotesuser_notes
                searchNotesFTSuser_notes
                getBookmarksbookmarks
                isBookmarkedbookmarks
                getHighlightsForChapterverse_highlights
                getAllHighlightsverse_highlights
                getCollectionsstudy_collections
                getCollectionstudy_collections
                getNotesInCollectionuser_notes
                getCollectionNoteCountsuser_notes
                getAllTagsuser_notes
                getNotesByTaguser_notes
                getLinkedNotesnote_links → user_notes
                getReferencingNotesnote_links → user_notes
                getBookmarkedTopicsbookmarked_topics
                isTopicBookmarkedbookmarked_topics

                2F. Tests

                __tests__/services/syncEngine.test.ts — New (~250 lines)

                describe('syncEngine',()=>{describe('enqueueSync',()=>{it('inserts queue entry with table_name and local_id',async()=>{ ... });it('schedules a push after enqueue',async()=>{ ... });});describe('pushQueue',()=>{it('pushes INSERT operations to Supabase upsert',async()=>{ ... });it('pushes DELETE as soft-delete',async()=>{ ... });it('removes queue entries after success',async()=>{ ... });it('increments attempts on failure',async()=>{ ... });it('drops items after 5 failed attempts',async()=>{ ... });it('does nothing when not authenticated',async()=>{ ... });});describe('pullChanges',()=>{it('fetches rows updated since last_pull_at',async()=>{ ... });it('inserts new cloud rows locally with cloud_id',async()=>{ ... });it('LWW: cloud wins on newer timestamp',async()=>{ ... });it('LWW: local wins on newer timestamp',async()=>{ ... });it('soft-deletes locally when cloud has deleted_at',async()=>{ ... });});});

                __tests__/db/userMutations.test.ts — New (~150 lines)

                Test each write function enqueues sync correctly:

                describe('userMutations sync integration',()=>{it('saveNote enqueues INSERT with cloud_id',async()=>{ ... });it('updateNote enqueues UPDATE',async()=>{ ... });it('deleteNote soft-deletes and enqueues DELETE',async()=>{ ... });it('updateNoteTags enqueues UPDATE',async()=>{ ... });it('bookmarkTopic enqueues UPSERT',async()=>{ ... });// ... one test per write function});

                __tests__/db/userQueries.test.ts — New (~80 lines)

                describe('userQueries soft-delete filtering',()=>{it('getAllNotes excludes soft-deleted rows',async()=>{ ... });it('getBookmarks excludes soft-deleted rows',async()=>{ ... });it('getBookmarkedTopics excludes soft-deleted rows',async()=>{ ... });// ... one test per query with soft-delete filter});

                2G. Session 2 Deliverables

                DeliverableVerification
                Cloud schema deployedTables visible in Supabase dashboard
                Migration v15 runs cleanlyApp launches, cloud_id columns exist
                Note CRUD syncsCreate note → appears in Supabase → pull on second device
                Highlight CRUD syncsSame
                Bookmark CRUD syncsSame
                Collection CRUD syncsSame
                Topic bookmark syncsSame
                Soft deletes workDelete note → deleted_at set → synced → hidden on other device
                Offline queue persistsAirplane mode → create note → reconnect → note pushes
                LWW conflict resolutionEdit same note on 2 devices → newer wins
                Tests passnpm test -- --testPathPattern="sync|user"

                Session 3: Reading Progress + Study Depth + Plans + Settings Sync

                Goal

                Sync reading progress (union merge), study depth (additive), reading plan progress,
                and user preferences (LWW).

                3A. Additional Cloud Tables

                CREATETABLEreading_progress (
                user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
                book_id TEXTNOT NULL,
                chapter_num INTEGERNOT NULL,
                completed_at TIMESTAMPTZ,
                PRIMARY KEY (user_id, book_id, chapter_num)
                );
                CREATETABLEstudy_depth (
                id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
                user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
                chapter_id TEXTNOT NULL,
                section_id TEXTNOT NULL,
                panel_type TEXTNOT NULL,
                first_opened_at TIMESTAMPTZ DEFAULT now(),
                UNIQUE(user_id, section_id, panel_type)
                );
                CREATETABLEuser_preferences (
                user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
                key TEXTNOT NULL,
                value TEXTNOT NULL,
                updated_at TIMESTAMPTZ DEFAULT now(),
                PRIMARY KEY (user_id, key)
                );
                CREATETABLEreading_streaks (
                id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
                user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
                dateTEXTNOT NULL,
                chapters_read INTEGERNOT NULL DEFAULT 0,
                books_touched TEXT,
                UNIQUE(user_id, date)
                );
                CREATETABLEplan_progress (
                user_id UUID NOT NULLREFERENCESauth.users(id) ON DELETE CASCADE,
                plan_id TEXTNOT NULL,
                day_num INTEGERNOT NULL,
                completed_at TIMESTAMPTZ,
                PRIMARY KEY (user_id, plan_id, day_num)
                );
                -- RLS for all new tables
                DO $$
                DECLARE tbl TEXT;
                BEGIN
                FOR tbl INVALUES
                ('reading_progress'), ('study_depth'), ('user_preferences'),
                ('reading_streaks'), ('plan_progress')
                LOOP
                EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl);
                EXECUTE format(
                'CREATE POLICY "Users own their %1$s" ON %1$I FOR ALL USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid())', tbl);
                END LOOP;
                END $$;

                3B. Special Merge Strategies

                Reading Progress — Union Merge

                Never un-completes. If either side says completed, it stays completed.
                Keep the earliest completed_at timestamp.

                Study Depth — Additive Only

                Append-only. Cloud accumulates all panels ever opened. Merge = INSERT OR IGNORE.

                Reading Plans — Union Merge

                Plan progress uses same union logic as reading progress — once a day is completed,
                it stays completed.

                User Preferences — LWW

                Simple key-value pairs with updated_at for conflict resolution.

                3C. Sync Triggers for Session 3 Functions

                recordVisit in userMutations.ts: Enqueue sync for reading_progress.

                completePlanDay in userMutations.ts: Enqueue sync for plan_progress.

                settingsStore.ts preference setters: Each setPreference call gets a sync enqueue.

                3D. UI: Sync Status in Settings

                Add a "Sync" section to SettingsScreen:

                ┌──────────────────────────────────────┐
                │ SYNC │
                │ │
                │ Status ✓ Up to date │
                │ Last sync 2 min ago │
                │ Pending 0 changes │
                │ │
                │ [Sync Now] │
                │ │
                │ Only visible when signed in. │
                └──────────────────────────────────────┘
                

                3E. Session 3 Deliverables

                DeliverableVerification
                Reading progress syncs (union)Read chapter → mark on device A → appears on device B
                Never un-completesDelete cloud row → local stays completed
                Study depth syncs (additive)Open panel → record appears in Supabase
                Plan progress syncs (union)Complete day on A → appears on B
                Preferences sync (LWW)Change font size on A → appears on B
                Sync status UI in SettingsShows status, last sync time, pending count
                Tests passnpm test -- --testPathPattern="sync|settings"

                Session 4: First-Sign-In Migration + Edge Cases + Account Deletion

                Goal

                Handle the critical first-sign-in scenario (user has months of local data),
                edge cases (offline→online, large datasets, stale queues), and App Store-compliant
                account deletion.

                4A. First-Sign-In Migration

                services/initialMigration.ts — New (~150 lines)

                When a user signs in for the first time, they may have hundreds of notes, bookmarks,
                and reading progress entries with no cloud_id. One-time bulk upload:

                1. Check if migration already happened (sync_state.initial_migration_complete)
                2. Check cloud for existing data (returning user on new device?)
                3. Assign cloud_id to all local rows missing them
                4. Upload in batches of 50 (collections first for FK resolution, then notes, etc.)
                5. Mark complete

                Migration UI — SyncMigrationSheet

                Bottom-sheet showing progress: "Uploading 47 notes, 312 chapters read..." with
                progress bar. Auto-dismisses on completion.

                4B. Edge Cases

                • Offline → online flush:pushQueue() processes max 100 items per call, chains until empty
                • Queue compaction: When queue > 200 items, compact by keeping only latest per table+local_id
                • Note links with unmapped IDs: Upload notes before note_links; defer links with unresolved refs
                • Interrupted migration:initial_migration_complete only set on full success; idempotent upserts prevent duplicates on retry
                • Stale INSERT+DELETE pairs: Compaction drops both when INSERT never pushed

                4C. Account Deletion

                services/accountDeletion.ts — New (~50 lines)

                1. Invoke Supabase Edge Function delete-user-data (service_role deletes all user tables + auth account)
                2. Clear local auth state
                3. Stop sync scheduler
                4. Local study data preserved on device

                Supabase Edge Function: delete-user-data

                Server-side function using service_role key to delete all user rows across all tables
                and delete the auth account. Client-side RLS can't cascade across tables.

                Deletion UI in SettingsScreen

                "Delete Account" in a Danger Zone section (only visible when signed in).
                Two-step confirmation: alert → type "DELETE" to confirm.

                4D. Session 4 Deliverables

                DeliverableVerification
                First sign-in migration worksUser with 6 months data → sign in → all data in Supabase
                Returning user on new deviceSign in on device B → all data appears
                Migration progress UIBottom sheet shows progress during initial upload
                Offline queue compaction500 offline edits → compacted → clean push
                Account deletion worksDelete → cloud data gone → local data preserved
                Two-step delete confirmationCan't accidentally delete
                Edge function deployeddelete-user-data in Supabase Functions
                Tests passFull suite green

                Complete File Inventory (Corrected)

                New Files (10+)

                FileLines (est.)Session
                services/syncEngine.ts~4002
                services/syncScheduler.ts~802
                services/initialMigration.ts~1504
                services/accountDeletion.ts~504
                stores/syncStore.ts~602
                __tests__/services/syncEngine.test.ts~3502-3
                __tests__/services/syncScheduler.test.ts~602
                __tests__/db/userMutations.test.ts~1502
                __tests__/db/userQueries.test.ts~802
                __tests__/services/initialMigration.test.ts~1204
                __tests__/services/accountDeletion.test.ts~604
                __tests__/screens/LoginScreen.test.tsx~801

                Modified Files (12+)

                FileChange SummarySession
                lib/supabase.tsActivate, SecureStore adapter, real import1
                lib/oauthHelpers.tsAdd signInWithApple()1
                stores/authStore.tsAdd Apple, remove Facebook1
                screens/LoginScreen.tsxReplace Facebook → Apple, add guest option1
                screens/MoreMenuScreen.tsxEnhanced auth section + sync status1, 3
                screens/SettingsScreen.tsxSync status section + account deletion3, 4
                db/userDatabase.tsMigration v15: cloud_id, sync_queue extension, soft delete2
                db/userMutations.tsSync triggers on all 18+ write functions + soft delete2, 3
                db/userQueries.tsWHERE deleted_at IS NULL on 19+ queries2
                stores/settingsStore.tsSync preference changes3
                app.jsonApple auth entitlement + plugin1
                types/user.tsAdd cloud_id and deleted_at to interfaces2

                New Dependencies (6)

                PackagePurpose
                @supabase/supabase-jsSupabase client
                @react-native-async-storage/async-storageRequired by Supabase (session fallback)
                react-native-url-polyfillRequired by Supabase (URL parsing)
                expo-secure-storeEncrypted JWT storage
                expo-apple-authenticationNative Apple Sign-In
                @react-native-community/netinfoNetwork state for sync scheduler

                Test Summary (~75 tests)

                Test FileTests (est.)
                syncEngine.test.ts~30
                syncScheduler.test.ts~5
                userMutations.test.ts~18
                userQueries.test.ts~10
                initialMigration.test.ts~8
                accountDeletion.test.ts~5
                LoginScreen.test.tsx~6
                authStore.test.ts (extend)~3

                Risk Register

                RiskImpactMitigation
                Apple rejects without native Apple Sign-InApp Store rejectionUsing expo-apple-authentication (native), not web OAuth
                Supabase free tier limitsSync stops at 50K MAUUpgrade to Pro ($25/mo) — covered by revenue model
                Large initial migration failsUser data doesn't syncIdempotent upserts; retry on next launch; progress UI
                LWW loses edits on simultaneous editUser loses a note editAcceptable for v1; realtime sync (v2) reduces window
                expo-secure-store 2KB value limitJWT too largeSupabase JWTs are ~1KB; within limit
                NetInfo false positivesSync on captive portalsTimeout + retry with backoff
                Soft delete bloatDeleted rows accumulatePeriodic cleanup: hard-delete rows > 30 days old
                v14 sync_queue schema mismatchALTER may fail on existing datav15 uses ADD COLUMN with defaults; safe for existing rows

                Drift Corrections (April 2026)

                This issue was originally written against an older codebase. The following corrections
                were applied:

                1. db/user.ts split → now userQueries.ts (reads) + userMutations.ts (writes)
                2. Migrations at v14 (not v8) — sync migration is now v15
                3. sync_queue already exists (v14) — v15 extends it with table_name and local_id
                4. @supabase/supabase-js NOT installed — full dep install list corrected
                5. SignUpScreen.tsx and ForgotPasswordScreen.tsx exist — LoginScreen redesign simplified
                6. 20 write functions (not 14) — updateNoteTags, setNoteCollection, bookmarkTopic, unbookmarkTopic added
                7. highlight_collections.id is TEXT — can use local ID as cloud_id directly
                8. user_preferences needs updated_at — added to v15 migration
                9. supabase.ts uses env vars — kept (better than hardcoded)
                10. Decisions confirmed: Drop Facebook ✓, Sync bookmarked topics ✓, Sync reading plans ✓, Don't sync study sessions ✓

                Metadata

                Metadata

                Labels

                premiumPremium tier feature

                Projects

                No projects

                  Milestone

                  No milestone

                  Relationships

                  None yet

                  Development

                  No branches or pull requests

                  Issue actions