diff --git a/src/network/NetworkFacade.ts b/src/network/NetworkFacade.ts index 2525b03d2..1b0af18c1 100644 --- a/src/network/NetworkFacade.ts +++ b/src/network/NetworkFacade.ts @@ -1,5 +1,4 @@ import * as RNFS from '@dr.pogodin/react-native-fs'; -import { AbortError } from './errors'; import { logger } from '@internxt-mobile/services/common'; import { decryptFile, encryptFile, encryptFileToChunks, joinFiles } from '@internxt/rn-crypto'; import { ALGORITHMS, Network } from '@internxt/sdk/dist/network'; @@ -12,18 +11,24 @@ import { Platform } from 'react-native'; import ReactNativeBlobUtil from 'react-native-blob-util'; import { randomBytes } from 'react-native-crypto'; import uuid from 'react-native-uuid'; +import { AbortError } from './errors'; import drive from '@internxt-mobile/services/drive'; import pLimit, { LimitFunction } from 'p-limit'; import { ripemd160 } from '../@inxt-js/lib/crypto'; -import { generateFileKey } from './crypto'; import appService from '../services/AppService'; import { driveEvents } from '../services/drive/events'; import fileSystemService from '../services/FileSystemService'; import { Abortable } from '../types'; +import { generateFileKey } from './crypto'; import { EncryptedFileDownloadedParams } from './download'; import { getAuthFromCredentials, NetworkCredentials } from './requests'; +const getCaseInsensitiveHeader = (headers: Record, name: string): string | undefined => { + const key = Object.keys(headers).find((key) => key.toLowerCase() === name.toLowerCase()); + return key ? headers[key] : undefined; +}; + interface UploadMultipartOptions { partSize: number; uploadingCallback?: (progress: number) => void; @@ -301,12 +306,28 @@ export class NetworkFacade { ): Promise { const uploadWithRetry = async (path: string, url: string, index: number): Promise => { try { - return await this.uploadPart(url, path, index, uploadState.partsUploadedBytes, fileSize, options.uploadingCallback, abortCtx); + return await this.uploadPart( + url, + path, + index, + uploadState.partsUploadedBytes, + fileSize, + options.uploadingCallback, + abortCtx, + ); } catch (error) { if ((error as Error)?.name === AbortError.errorName) throw error; logger.error(`First attempt failed for part ${index + 1}, retrying...`); try { - return await this.uploadPart(url, path, index, uploadState.partsUploadedBytes, fileSize, options.uploadingCallback, abortCtx); + return await this.uploadPart( + url, + path, + index, + uploadState.partsUploadedBytes, + fileSize, + options.uploadingCallback, + abortCtx, + ); } catch (retryError) { logger.error(`Retry failed for part ${index + 1}`); throw retryError; @@ -348,7 +369,7 @@ export class NetworkFacade { abortCtx?.activeFetchTasks.add(fetchTask); try { const response = await fetchTask; - const etag = Platform.OS === 'android' ? response.info().headers.ETag : response.info().headers.Etag; + const etag = getCaseInsensitiveHeader(response.info().headers, 'etag'); if (!etag) throw new Error('Missing ETag in upload response'); return { diff --git a/src/services/common/network/upload/upload.service.ts b/src/services/common/network/upload/upload.service.ts index e1ce0493d..0f73881dd 100644 --- a/src/services/common/network/upload/upload.service.ts +++ b/src/services/common/network/upload/upload.service.ts @@ -5,6 +5,7 @@ import { CreateThumbnailEntryPayload, DriveFileData, FileEntryByUuid, + ReplaceFile, Thumbnail, } from '@internxt/sdk/dist/drive/storage/types'; import { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings'; @@ -67,6 +68,10 @@ class UploadService { return this.sdk.storageV2.createFileEntryByUuid(entry); } + public async replaceFileEntry(fileUuid: string, payload: ReplaceFile): Promise { + return this.sdk.storageV2.replaceFile(fileUuid, payload); + } + public async createThumbnailEntry(entry: CreateThumbnailEntryPayload): Promise { return this.sdk.storageV2.createThumbnailEntryWithUUID(entry); } diff --git a/src/services/photos/PhotoAssetScanner.ts b/src/services/photos/PhotoAssetScanner.ts index cee42cfff..8f533c071 100644 --- a/src/services/photos/PhotoAssetScanner.ts +++ b/src/services/photos/PhotoAssetScanner.ts @@ -1,21 +1,43 @@ import * as MediaLibrary from 'expo-media-library'; import { logger } from 'src/services/common'; +const PAGE_SIZE = 200; +const MEDIA_TYPES = [MediaLibrary.MediaType.photo, MediaLibrary.MediaType.video]; + +const paginateAssets = async ( + options: Omit, + onAssetFetched: (asset: MediaLibrary.Asset) => boolean, +): Promise => { + let cursor: string | undefined; + do { + const page = await MediaLibrary.getAssetsAsync({ first: PAGE_SIZE, after: cursor, ...options }); + for (const asset of page.assets) { + if (onAssetFetched(asset)) return; + } + cursor = page.hasNextPage ? page.endCursor : undefined; + } while (cursor); +}; + export const PhotoAssetScanner = { + async getAssetsByIds(assetIds: string[]): Promise { + const pendingIdSet = new Set(assetIds); + const resolvedAssets: MediaLibrary.Asset[] = []; + + await paginateAssets({ mediaType: MEDIA_TYPES }, (asset) => { + if (pendingIdSet.has(asset.id)) resolvedAssets.push(asset); + return resolvedAssets.length === assetIds.length; + }); + + return resolvedAssets; + }, + async scanAll(): Promise { const results: MediaLibrary.Asset[] = []; - let cursor: string | undefined; - - do { - const batch = await MediaLibrary.getAssetsAsync({ - first: 200, - after: cursor, - mediaType: [MediaLibrary.MediaType.photo, MediaLibrary.MediaType.video], - sortBy: MediaLibrary.SortBy.modificationTime, - }); - results.push(...batch.assets); - cursor = batch.hasNextPage ? batch.endCursor : undefined; - } while (cursor); + + await paginateAssets({ mediaType: MEDIA_TYPES, sortBy: MediaLibrary.SortBy.modificationTime }, (asset) => { + results.push(asset); + return false; + }); logger.info(`[PhotoAssetScanner] Scan complete — ${results.length} assets`); return results; diff --git a/src/services/photos/PhotoBackupFolders.ts b/src/services/photos/PhotoBackupFolders.ts new file mode 100644 index 000000000..f60b0858c --- /dev/null +++ b/src/services/photos/PhotoBackupFolders.ts @@ -0,0 +1,78 @@ +import asyncStorageService from 'src/services/AsyncStorageService'; +import { logger } from 'src/services/common'; +import { createFolderWithMerge } from 'src/services/drive/folder/folderOrchestration.service'; + +const PHOTOS_BACKUP_ROOT_NAME = 'Photos Backup'; + +class PhotoBackupFolderService { + private photosRootUuid: string | null = null; + private deviceFolderUuid = new Map(); + private yearFolderUuid = new Map(); + private monthFolderUuid = new Map(); + private dayFolderUuid = new Map(); + + async getOrCreateFolderForDate(deviceId: string, date: Date): Promise { + const year = date.getFullYear().toString(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + + const dayKey = `${deviceId}/${year}/${month}/${day}`; + const localDayFolderUuid = this.dayFolderUuid.get(dayKey); + if (localDayFolderUuid) return localDayFolderUuid; + + const photosRootUuid = await this.getOrCreatePhotosRoot(); + const deviceUuid = await this.getOrCreateDeviceFolder(deviceId, photosRootUuid); + const yearUuid = await this.getOrCreateYearFolder(deviceId, year, deviceUuid); + const monthUuid = await this.getOrCreateMonthFolder(deviceId, year, month, yearUuid); + + logger.info(`[PhotoBackupFolders] Creating day folder ${dayKey}`); + const dayUuid = await createFolderWithMerge(monthUuid, day); + this.dayFolderUuid.set(dayKey, dayUuid); + return dayUuid; + } + + clearCache(): void { + this.photosRootUuid = null; + this.deviceFolderUuid.clear(); + this.yearFolderUuid.clear(); + this.monthFolderUuid.clear(); + this.dayFolderUuid.clear(); + } + + private async getOrCreatePhotosRoot(): Promise { + if (this.photosRootUuid) return this.photosRootUuid; + + const user = await asyncStorageService.getUser(); + this.photosRootUuid = await createFolderWithMerge(user.rootFolderId, PHOTOS_BACKUP_ROOT_NAME); + return this.photosRootUuid; + } + + private async getOrCreateFolder( + cache: Map, + key: string, + parentUuid: string, + name: string, + ): Promise { + const cached = cache.get(key); + if (cached) { + return cached; + } + const uuid = await createFolderWithMerge(parentUuid, name); + cache.set(key, uuid); + return uuid; + } + + private getOrCreateDeviceFolder(deviceId: string, photosRootUuid: string): Promise { + return this.getOrCreateFolder(this.deviceFolderUuid, deviceId, photosRootUuid, deviceId); + } + + private getOrCreateYearFolder(deviceId: string, year: string, deviceUuid: string): Promise { + return this.getOrCreateFolder(this.yearFolderUuid, `${deviceId}/${year}`, deviceUuid, year); + } + + private getOrCreateMonthFolder(deviceId: string, year: string, month: string, yearUuid: string): Promise { + return this.getOrCreateFolder(this.monthFolderUuid, `${deviceId}/${year}/${month}`, yearUuid, month); + } +} + +export const photoBackupFolders = new PhotoBackupFolderService(); diff --git a/src/services/photos/PhotoDeduplicator.ts b/src/services/photos/PhotoDeduplicator.ts index 3f523ffb0..0552d8929 100644 --- a/src/services/photos/PhotoDeduplicator.ts +++ b/src/services/photos/PhotoDeduplicator.ts @@ -2,25 +2,37 @@ import * as MediaLibrary from 'expo-media-library'; import { photosLocalDB } from './database/photosLocalDB'; const hasBeenEdited = (asset: MediaLibrary.Asset, syncedModificationTime: number | null): boolean => { - if (syncedModificationTime !== null && asset.modificationTime > syncedModificationTime) { - return true; + if (syncedModificationTime === null) { + return false; } - return false; + const isEditedAsset = asset.modificationTime > syncedModificationTime; + return isEditedAsset; }; +export interface AssetsToSync { + newAssets: MediaLibrary.Asset[]; + editedAssets: MediaLibrary.Asset[]; +} + export const PhotoDeduplicator = { - async getAssetsToSync(assets: MediaLibrary.Asset[]): Promise { - if (assets.length === 0) return []; + async getAssetsToSync(assets: MediaLibrary.Asset[]): Promise { + if (assets.length === 0) { + return { newAssets: [], editedAssets: [] }; + } + const syncedEntries = await photosLocalDB.getSyncedEntries(assets.map((asset) => asset.id)); + const newAssets: MediaLibrary.Asset[] = []; + const editedAssets: MediaLibrary.Asset[] = []; - const uniqueAssets = assets.filter((asset) => { + for (const asset of assets) { const syncedInfo = syncedEntries.get(asset.id); if (!syncedInfo) { - return true; + newAssets.push(asset); + } else if (hasBeenEdited(asset, syncedInfo.modificationTime)) { + editedAssets.push(asset); } - return hasBeenEdited(asset, syncedInfo.modificationTime); - }); + } - return uniqueAssets; + return { newAssets, editedAssets }; }, }; diff --git a/src/services/photos/PhotoDeviceId.ts b/src/services/photos/PhotoDeviceId.ts index 6e14e3386..94dd94d82 100644 --- a/src/services/photos/PhotoDeviceId.ts +++ b/src/services/photos/PhotoDeviceId.ts @@ -1,14 +1,14 @@ import uuid from 'react-native-uuid'; -import asyncStorageService from 'src/services/AsyncStorageService'; +import secureStorageService from 'src/services/SecureStorageService'; import { AsyncStorageKey } from 'src/types'; export const PhotoDeviceId = { async getOrCreate(): Promise { - const existing = await asyncStorageService.getItem(AsyncStorageKey.PhotosDeviceId); + const existing = await secureStorageService.getItem(AsyncStorageKey.PhotosDeviceId); if (existing) return existing; const id = uuid.v4() as string; - await asyncStorageService.saveItem(AsyncStorageKey.PhotosDeviceId, id); + await secureStorageService.setItem(AsyncStorageKey.PhotosDeviceId, id); return id; }, }; diff --git a/src/services/photos/PhotoUploadQueue.ts b/src/services/photos/PhotoUploadQueue.ts new file mode 100644 index 000000000..d852568a9 --- /dev/null +++ b/src/services/photos/PhotoUploadQueue.ts @@ -0,0 +1,48 @@ +import * as MediaLibrary from 'expo-media-library'; +import pLimit from 'p-limit'; +import { PhotoUploadService } from './PhotoUploadService'; + +const UPLOAD_CONCURRENCY = 3; + +export interface AssetUploadJob { + asset: MediaLibrary.Asset; + existingRemoteFileId?: string; +} + +interface UploadQueueCallbacks { + onAssetStart?: (assetId: string) => void; + onAssetProgress?: (assetId: string, ratio: number) => void; + onAssetDone?: (assetId: string, remoteFileId: string, modificationTime: number) => void; + onAssetError?: (assetId: string, error: Error) => void; +} + +export const PhotoUploadQueue = { + async start( + jobs: AssetUploadJob[], + deviceId: string, + callbacks: UploadQueueCallbacks, + ): Promise { + const limit = pLimit(UPLOAD_CONCURRENCY); + + await Promise.all( + jobs.map((job) => + limit(async () => { + const { asset, existingRemoteFileId } = job; + callbacks.onAssetStart?.(asset.id); + try { + const remoteFileId = existingRemoteFileId + ? await PhotoUploadService.replace(asset, existingRemoteFileId, deviceId, (ratio) => + callbacks.onAssetProgress?.(asset.id, ratio), + ) + : await PhotoUploadService.upload(asset, deviceId, (ratio) => + callbacks.onAssetProgress?.(asset.id, ratio), + ); + callbacks.onAssetDone?.(asset.id, remoteFileId, asset.modificationTime); + } catch (uploadError) { + callbacks.onAssetError?.(asset.id, uploadError as Error); + } + }), + ), + ); + }, +}; diff --git a/src/services/photos/PhotoUploadService.ts b/src/services/photos/PhotoUploadService.ts new file mode 100644 index 000000000..d0158a183 --- /dev/null +++ b/src/services/photos/PhotoUploadService.ts @@ -0,0 +1,136 @@ +import * as RNFS from '@dr.pogodin/react-native-fs'; +import { EncryptionVersion } from '@internxt/sdk/dist/drive/storage/types'; +import * as MediaLibrary from 'expo-media-library'; +import { Platform } from 'react-native'; +import { getEnvironmentConfigFromUser } from 'src/lib/network'; +import { uploadFile } from 'src/network/upload'; +import { constants } from 'src/services/AppService'; +import asyncStorageService from 'src/services/AsyncStorageService'; +import { uploadService } from 'src/services/common/network/upload/upload.service'; +import { photoBackupFolders } from './PhotoBackupFolders'; +import { + ANDROID_CONTENT_URI_SCHEME, + ICLOUD_URI_SCHEME, + extractExtensionFromContentUri, + splitFileNameAndExtension, + stripFileScheme, + stripFileSchemeAndFragment, +} from './PhotoUploadService.utils'; + +const TEMP_FILE_PREFIX = 'photo_upload_'; + +interface BucketUploadResult { + bucketFileId: string; + bucketId: string; + fileSize: number; + plainName: string; + fileExtension: string; + modificationIso: string; + creationIso: string; + folderUuid: string; +} + +const resolveLocalPath = async (asset: MediaLibrary.Asset): Promise<{ localPath: string; tempPath?: string }> => { + if (Platform.OS === 'ios') { + const assetInfo = await MediaLibrary.getAssetInfoAsync(asset.id, { shouldDownloadFromNetwork: false }); + const rawUri = assetInfo.localUri ?? asset.uri; + if (!rawUri || rawUri.startsWith(ICLOUD_URI_SCHEME)) { + throw new Error(`Asset ${asset.id} has no local URI — may be stored in iCloud`); + } + + return { localPath: stripFileSchemeAndFragment(rawUri) }; + } + + const uri = asset.uri; + if (uri.startsWith(ANDROID_CONTENT_URI_SCHEME)) { + const ext = extractExtensionFromContentUri(uri); + const tempPath = `${RNFS.CachesDirectoryPath}/${TEMP_FILE_PREFIX}${asset.id}.${ext}`; + await RNFS.copyFile(uri, tempPath); + return { localPath: tempPath, tempPath }; + } + return { localPath: stripFileScheme(uri) }; +}; + +const uploadAssetToBucket = async ( + asset: MediaLibrary.Asset, + deviceId: string, + onProgress?: (ratio: number) => void, +): Promise => { + const { localPath: localFilePath, tempPath } = await resolveLocalPath(asset); + + const createdDate = new Date(asset.creationTime); + const creationIso = createdDate.toISOString(); + const modificationIso = new Date(asset.modificationTime).toISOString(); + const fileName = localFilePath.split('/').pop() ?? asset.filename; + + const [fileStat, user, folderUuid] = await Promise.all([ + RNFS.stat(localFilePath), + asyncStorageService.getUser(), + photoBackupFolders.getOrCreateFolderForDate(deviceId, createdDate), + ]); + const { bucketId, encryptionKey, bridgeUser, bridgePass } = getEnvironmentConfigFromUser(user); + + let bucketFileId: string; + try { + bucketFileId = await uploadFile( + localFilePath, + bucketId, + encryptionKey, + constants.BRIDGE_URL, + { user: bridgeUser, pass: bridgePass }, + { notifyProgress: onProgress }, + ); + } catch (uploadError) { + const msg = uploadError instanceof Error ? uploadError.message : String(uploadError); + throw new Error(`Bucket upload failed for ${fileName}: ${msg}`); + } finally { + if (tempPath) await RNFS.unlink(tempPath).catch(() => null); + } + + const { plainName, fileExtension } = splitFileNameAndExtension(fileName); + + return { + bucketFileId, + bucketId, + fileSize: fileStat.size, + plainName, + fileExtension, + modificationIso, + creationIso, + folderUuid, + }; +}; + +export const PhotoUploadService = { + async upload(asset: MediaLibrary.Asset, deviceId: string, onProgress?: (ratio: number) => void): Promise { + const { bucketFileId, bucketId, fileSize, plainName, fileExtension, modificationIso, creationIso, folderUuid } = + await uploadAssetToBucket(asset, deviceId, onProgress); + + const driveFile = await uploadService.createFileEntry({ + fileId: bucketFileId, + type: fileExtension, + size: fileSize, + plainName, + bucket: bucketId, + folderUuid, + encryptVersion: EncryptionVersion.Aes03, + modificationTime: modificationIso, + creationTime: creationIso, + }); + + return driveFile.uuid; + }, + + async replace( + asset: MediaLibrary.Asset, + existingRemoteFileId: string, + deviceId: string, + onProgress?: (ratio: number) => void, + ): Promise { + const { bucketFileId, fileSize } = await uploadAssetToBucket(asset, deviceId, onProgress); + + await uploadService.replaceFileEntry(existingRemoteFileId, { fileId: bucketFileId, size: fileSize }); + + return existingRemoteFileId; + }, +}; diff --git a/src/services/photos/PhotoUploadService.utils.ts b/src/services/photos/PhotoUploadService.utils.ts new file mode 100644 index 000000000..183f4af24 --- /dev/null +++ b/src/services/photos/PhotoUploadService.utils.ts @@ -0,0 +1,31 @@ +const ICLOUD_URI_SCHEME = 'ph://'; +const FILE_URI_SCHEME = 'file://'; +const ANDROID_CONTENT_URI_SCHEME = 'content://'; +const FALLBACK_EXTENSION = 'tmp'; + +export { ANDROID_CONTENT_URI_SCHEME, ICLOUD_URI_SCHEME }; + +export const stripFileSchemeAndFragment = (uri: string): string => + decodeURIComponent( + uri.startsWith(FILE_URI_SCHEME) ? uri.slice(FILE_URI_SCHEME.length).split('#')[0] : uri.split('#')[0], + ); + +export const stripFileScheme = (uri: string): string => + decodeURIComponent(uri.startsWith(FILE_URI_SCHEME) ? uri.slice(FILE_URI_SCHEME.length) : uri); + +export const extractExtensionFromContentUri = (uri: string): string => { + const segment = uri.split('/').pop()?.split('?')[0] ?? ''; + const dotIndex = segment.lastIndexOf('.'); + if (dotIndex < 0) { + return FALLBACK_EXTENSION; + } + return segment.slice(dotIndex + 1) || FALLBACK_EXTENSION; +}; + +export const splitFileNameAndExtension = (fileName: string): { plainName: string; fileExtension: string } => { + const dotIndex = fileName.lastIndexOf('.'); + if (dotIndex < 0) { + return { plainName: fileName, fileExtension: '' }; + } + return { plainName: fileName.slice(0, dotIndex), fileExtension: fileName.slice(dotIndex + 1) }; +}; diff --git a/src/services/photos/database/photosLocalDB.ts b/src/services/photos/database/photosLocalDB.ts index 3ca772a55..7d579ada2 100644 --- a/src/services/photos/database/photosLocalDB.ts +++ b/src/services/photos/database/photosLocalDB.ts @@ -3,7 +3,7 @@ import assetSyncTable from './tables/asset_sync'; const DB_NAME = 'photos_sync.db'; -export type AssetSyncStatus = 'pending' | 'synced' | 'error'; +export type AssetSyncStatus = 'pending' | 'pending_edit' | 'synced' | 'error'; export interface AssetSyncEntry { assetId: string; @@ -51,8 +51,16 @@ class PhotosLocalDB { await sqliteService.executeSql(DB_NAME, assetSyncTable.statements.markPending, [assetId]); } + async markPendingEdit(assetId: string): Promise { + await sqliteService.executeSql(DB_NAME, assetSyncTable.statements.markPendingEdit, [assetId]); + } + async markSynced(assetId: string, remoteFileId: string, modificationTime: number | null): Promise { - await sqliteService.executeSql(DB_NAME, assetSyncTable.statements.markSynced, [assetId, remoteFileId, modificationTime]); + await sqliteService.executeSql(DB_NAME, assetSyncTable.statements.markSynced, [ + assetId, + remoteFileId, + modificationTime, + ]); } async markError(assetId: string, errorMessage?: string): Promise { @@ -105,6 +113,19 @@ class PhotosLocalDB { }; } + async getPendingAssets(): Promise> { + const pendingAssets = await sqliteService.getAllAsync<{ + asset_id: string; + status: AssetSyncStatus; + remote_file_id: string | null; + }>(DB_NAME, assetSyncTable.statements.getPendingAssets); + return pendingAssets.map((asset) => ({ + assetId: asset.asset_id, + status: asset.status, + remoteFileId: asset.remote_file_id, + })); + } + async reset(): Promise { await sqliteService.executeSql(DB_NAME, assetSyncTable.statements.reset); } diff --git a/src/services/photos/database/tables/asset_sync.ts b/src/services/photos/database/tables/asset_sync.ts index d21fc8ad2..2e259e4eb 100644 --- a/src/services/photos/database/tables/asset_sync.ts +++ b/src/services/photos/database/tables/asset_sync.ts @@ -4,7 +4,7 @@ const statements = { createTable: ` CREATE TABLE IF NOT EXISTS ${TABLE_NAME} ( asset_id TEXT PRIMARY KEY NOT NULL, - status TEXT NOT NULL CHECK (status IN ('pending', 'synced', 'error')), + status TEXT NOT NULL CHECK (status IN ('pending', 'pending_edit', 'synced', 'error')), remote_file_id TEXT, error_message TEXT, attempt_count INTEGER NOT NULL DEFAULT 0, @@ -24,6 +24,13 @@ const statements = { WHERE ${TABLE_NAME}.status != 'synced'; `, + markPendingEdit: ` + INSERT INTO ${TABLE_NAME} (asset_id, status) + VALUES (?, 'pending_edit') + ON CONFLICT(asset_id) DO UPDATE SET status = 'pending_edit' + WHERE ${TABLE_NAME}.status = 'synced'; + `, + markSynced: ` INSERT INTO ${TABLE_NAME} (asset_id, status, remote_file_id, synced_at, last_attempt_at, modification_time) VALUES (?, 'synced', ?, (unixepoch() * 1000), (unixepoch() * 1000), ?) @@ -52,6 +59,7 @@ const statements = { `, getSyncedInList: (placeholders: string) => `SELECT asset_id, modification_time FROM ${TABLE_NAME} WHERE asset_id IN (${placeholders}) AND status = 'synced';`, + getPendingAssets: `SELECT asset_id, status, remote_file_id FROM ${TABLE_NAME} WHERE status != 'synced';`, reset: `DELETE FROM ${TABLE_NAME};`, };