From 04f639be041a5d10d02969b62c971ade12db5ba4 Mon Sep 17 00:00:00 2001 From: Anivar Aravind Date: Sat, 7 Mar 2026 19:30:21 +0530 Subject: [PATCH] fix(datastore-storage-adapter): export ExpoSQLiteAdapter and update to modern expo-sqlite API ExpoSQLiteAdapter was implemented but never exported from the package. The implementation used deprecated WebSQL APIs (openDatabase, transaction, executeSql) removed in expo-sqlite 13.0+, causing silent fallback to AsyncStorage with ~100x performance degradation. Changes: - Export ExpoSQLiteAdapter from package index - Rewrite ExpoSQLiteDatabase to use expo-sqlite 13.0+ async API - Use require() for optional expo-sqlite/expo-file-system dependencies - Add optional peer dependencies - Enable WAL journal mode for concurrent read/write performance - Add 18 unit tests with mocked expo-sqlite Fixes #14514 #14440 --- .changeset/fix-expo-sqlite-adapter-export.md | 5 + .../__tests__/ExpoSQLiteDatabase.test.ts | 245 ++++++++++++ .../datastore-storage-adapter/package.json | 16 +- .../ExpoSQLiteAdapter/ExpoSQLiteDatabase.ts | 349 +++++++----------- .../datastore-storage-adapter/src/index.ts | 3 +- 5 files changed, 398 insertions(+), 220 deletions(-) create mode 100644 .changeset/fix-expo-sqlite-adapter-export.md create mode 100644 packages/datastore-storage-adapter/__tests__/ExpoSQLiteDatabase.test.ts diff --git a/.changeset/fix-expo-sqlite-adapter-export.md b/.changeset/fix-expo-sqlite-adapter-export.md new file mode 100644 index 00000000000..d514e0af3b0 --- /dev/null +++ b/.changeset/fix-expo-sqlite-adapter-export.md @@ -0,0 +1,5 @@ +--- +'@aws-amplify/datastore-storage-adapter': minor +--- + +fix(datastore-storage-adapter): export ExpoSQLiteAdapter and update to modern expo-sqlite 13.0+ async API diff --git a/packages/datastore-storage-adapter/__tests__/ExpoSQLiteDatabase.test.ts b/packages/datastore-storage-adapter/__tests__/ExpoSQLiteDatabase.test.ts new file mode 100644 index 00000000000..323f4b66cc9 --- /dev/null +++ b/packages/datastore-storage-adapter/__tests__/ExpoSQLiteDatabase.test.ts @@ -0,0 +1,245 @@ +// Mock expo-sqlite before importing the module under test +const mockDb = { + execAsync: jest.fn().mockResolvedValue(undefined), + getFirstAsync: jest.fn().mockResolvedValue(undefined), + getAllAsync: jest.fn().mockResolvedValue([]), + runAsync: jest.fn().mockResolvedValue(undefined), + withTransactionAsync: jest.fn(async (cb: () => Promise) => cb()), + closeAsync: jest.fn().mockResolvedValue(undefined), +}; + +const mockOpenDatabaseAsync = jest.fn().mockResolvedValue(mockDb); + +jest.mock('expo-sqlite', () => ({ + openDatabaseAsync: mockOpenDatabaseAsync, +})); + +jest.mock('expo-file-system', () => ({ + documentDirectory: '/mock/documents/', + deleteAsync: jest.fn().mockResolvedValue(undefined), +})); + +import ExpoSQLiteDatabase from '../src/ExpoSQLiteAdapter/ExpoSQLiteDatabase'; + +describe('ExpoSQLiteDatabase', () => { + let db: ExpoSQLiteDatabase; + + beforeEach(() => { + jest.clearAllMocks(); + // Restore the default mock (in case a test overrode it) + mockOpenDatabaseAsync.mockResolvedValue(mockDb); + db = new ExpoSQLiteDatabase(); + }); + + describe('init', () => { + it('opens database via openDatabaseAsync', async () => { + await db.init(); + expect(mockOpenDatabaseAsync).toHaveBeenCalledWith( + 'AmplifyDatastore', + ); + }); + + it('applies WAL pragma after opening', async () => { + await db.init(); + expect(mockDb.execAsync).toHaveBeenCalledWith( + 'PRAGMA journal_mode = WAL;', + ); + }); + + it('only opens once on repeated init calls', async () => { + await db.init(); + await db.init(); + expect(mockOpenDatabaseAsync).toHaveBeenCalledTimes(1); + }); + + it('throws when expo-sqlite lacks async API', async () => { + // Temporarily remove openDatabaseAsync to simulate old expo-sqlite + const original = mockOpenDatabaseAsync; + const SQLite = require('expo-sqlite'); + delete SQLite.openDatabaseAsync; + + const freshDb = new ExpoSQLiteDatabase(); + await expect(freshDb.init()).rejects.toThrow('expo-sqlite 13.0+'); + + // Restore for other tests + SQLite.openDatabaseAsync = original; + }); + }); + + describe('operations before init', () => { + it('get throws if not initialized', async () => { + await expect(db.get('SELECT 1', [])).rejects.toThrow( + 'Database not initialized', + ); + }); + + it('getAll throws if not initialized', async () => { + await expect(db.getAll('SELECT 1', [])).rejects.toThrow( + 'Database not initialized', + ); + }); + + it('save throws if not initialized', async () => { + await expect(db.save('INSERT', [])).rejects.toThrow( + 'Database not initialized', + ); + }); + }); + + describe('get', () => { + beforeEach(async () => db.init()); + + it('delegates to getFirstAsync', async () => { + const row = { id: '1', field1: 'value' }; + mockDb.getFirstAsync.mockResolvedValueOnce(row); + const result = await db.get('SELECT * FROM Model WHERE id = ?', [ + '1', + ]); + expect(mockDb.getFirstAsync).toHaveBeenCalledWith( + 'SELECT * FROM Model WHERE id = ?', + ['1'], + ); + expect(result).toEqual(row); + }); + + it('returns undefined when no row found', async () => { + mockDb.getFirstAsync.mockResolvedValueOnce(undefined); + const result = await db.get('SELECT * FROM Model WHERE id = ?', [ + 'missing', + ]); + expect(result).toBeUndefined(); + }); + }); + + describe('getAll', () => { + beforeEach(async () => db.init()); + + it('delegates to getAllAsync', async () => { + const rows = [ + { id: '1', field1: 'a' }, + { id: '2', field1: 'b' }, + ]; + mockDb.getAllAsync.mockResolvedValueOnce(rows); + const result = await db.getAll('SELECT * FROM Model', []); + expect(result).toEqual(rows); + }); + }); + + describe('save', () => { + beforeEach(async () => db.init()); + + it('delegates to runAsync', async () => { + await db.save('INSERT INTO Model (id) VALUES (?)', ['1']); + expect(mockDb.runAsync).toHaveBeenCalledWith( + 'INSERT INTO Model (id) VALUES (?)', + ['1'], + ); + }); + }); + + describe('batchSave', () => { + beforeEach(async () => db.init()); + + it('executes all statements in a transaction', async () => { + const saves = new Set<[string, (string | number)[]]>([ + ['INSERT INTO Model (id) VALUES (?)', ['1']], + ['INSERT INTO Model (id) VALUES (?)', ['2']], + ]); + await db.batchSave(saves); + expect(mockDb.withTransactionAsync).toHaveBeenCalledTimes(1); + expect(mockDb.runAsync).toHaveBeenCalledTimes(2); + }); + + it('executes deletes after saves', async () => { + const saves = new Set<[string, (string | number)[]]>([ + ['INSERT INTO Model (id) VALUES (?)', ['1']], + ]); + const deletes = new Set<[string, (string | number)[]]>([ + ['DELETE FROM Model WHERE id = ?', ['old']], + ]); + await db.batchSave(saves, deletes); + expect(mockDb.runAsync).toHaveBeenCalledTimes(2); + }); + }); + + describe('batchQuery', () => { + beforeEach(async () => db.init()); + + it('returns first row from each query', async () => { + mockDb.getAllAsync + .mockResolvedValueOnce([{ id: '1', field1: 'a' }]) + .mockResolvedValueOnce([{ id: '2', field1: 'b' }]); + const queries = new Set<[string, (string | number)[]]>([ + ['SELECT * FROM Model WHERE id = ?', ['1']], + ['SELECT * FROM Model WHERE id = ?', ['2']], + ]); + const results = await db.batchQuery(queries); + expect(results).toEqual([ + { id: '1', field1: 'a' }, + { id: '2', field1: 'b' }, + ]); + }); + + it('skips empty results', async () => { + mockDb.getAllAsync + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: '2' }]); + const queries = new Set<[string, (string | number)[]]>([ + ['SELECT * FROM Model WHERE id = ?', ['missing']], + ['SELECT * FROM Model WHERE id = ?', ['2']], + ]); + const results = await db.batchQuery(queries); + expect(results).toEqual([{ id: '2' }]); + }); + }); + + describe('selectAndDelete', () => { + beforeEach(async () => db.init()); + + it('queries then deletes in a transaction', async () => { + const rows = [{ id: '1' }]; + mockDb.getAllAsync.mockResolvedValueOnce(rows); + const result = await db.selectAndDelete( + ['SELECT * FROM Model WHERE id = ?', ['1']], + ['DELETE FROM Model WHERE id = ?', ['1']], + ); + expect(result).toEqual(rows); + expect(mockDb.withTransactionAsync).toHaveBeenCalled(); + expect(mockDb.runAsync).toHaveBeenCalledWith( + 'DELETE FROM Model WHERE id = ?', + ['1'], + ); + }); + }); + + describe('createSchema', () => { + beforeEach(async () => db.init()); + + it('executes all statements in a transaction', async () => { + await db.createSchema([ + 'CREATE TABLE Model (id TEXT PRIMARY KEY)', + 'CREATE TABLE Other (id TEXT PRIMARY KEY)', + ]); + expect(mockDb.withTransactionAsync).toHaveBeenCalled(); + expect(mockDb.execAsync).toHaveBeenCalledWith( + 'CREATE TABLE Model (id TEXT PRIMARY KEY)', + ); + expect(mockDb.execAsync).toHaveBeenCalledWith( + 'CREATE TABLE Other (id TEXT PRIMARY KEY)', + ); + }); + }); + + describe('clear', () => { + beforeEach(async () => db.init()); + + it('closes db and deletes file', async () => { + await db.clear(); + expect(mockDb.closeAsync).toHaveBeenCalled(); + const FileSystem = require('expo-file-system'); + expect(FileSystem.deleteAsync).toHaveBeenCalledWith( + '/mock/documents/SQLite/AmplifyDatastore', + ); + }); + }); +}); diff --git a/packages/datastore-storage-adapter/package.json b/packages/datastore-storage-adapter/package.json index f9c6cd95cd6..37c6eada06c 100644 --- a/packages/datastore-storage-adapter/package.json +++ b/packages/datastore-storage-adapter/package.json @@ -33,7 +33,21 @@ }, "homepage": "https://aws-amplify.github.io/", "peerDependencies": { - "@aws-amplify/core": "^6.16.2" + "@aws-amplify/core": "^6.16.2", + "expo-sqlite": ">=13.0.0", + "expo-file-system": ">=13.0.0", + "react-native-sqlite-storage": ">=5.0.0" + }, + "peerDependenciesMeta": { + "expo-sqlite": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "react-native-sqlite-storage": { + "optional": true + } }, "devDependencies": { "@aws-amplify/core": "6.16.2", diff --git a/packages/datastore-storage-adapter/src/ExpoSQLiteAdapter/ExpoSQLiteDatabase.ts b/packages/datastore-storage-adapter/src/ExpoSQLiteAdapter/ExpoSQLiteDatabase.ts index 4271f3b4387..6b6f5c03bc1 100644 --- a/packages/datastore-storage-adapter/src/ExpoSQLiteAdapter/ExpoSQLiteDatabase.ts +++ b/packages/datastore-storage-adapter/src/ExpoSQLiteAdapter/ExpoSQLiteDatabase.ts @@ -2,8 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import { ConsoleLogger } from '@aws-amplify/core'; import { PersistentModel } from '@aws-amplify/datastore'; -import { deleteAsync, documentDirectory } from 'expo-file-system'; -import { WebSQLDatabase, openDatabase } from 'expo-sqlite'; import { DB_NAME } from '../common/constants'; import { CommonSQLiteDatabase, ParameterizedStatement } from '../common/types'; @@ -13,23 +11,52 @@ const logger = new ConsoleLogger('ExpoSQLiteDatabase'); /* Note: -ExpoSQLite transaction error callbacks require returning a boolean value to indicate whether the -error was handled or not. Returning a true value indicates the error was handled and does not -rollback the whole transaction. +This adapter requires expo-sqlite 13.0+ with the modern async API. +The legacy WebSQL-style API is not supported to ensure performance +and future compatibility as WebSQL has been deprecated. + +We use require() for optional dependencies to avoid TypeScript +compilation issues when the package is not installed. */ +interface SQLiteDatabase { + execAsync(statement: string): Promise; + getFirstAsync(statement: string, params: (string | number)[]): Promise; + getAllAsync(statement: string, params: (string | number)[]): Promise; + runAsync(statement: string, params: (string | number)[]): Promise; + withTransactionAsync(callback: () => Promise): Promise; + closeAsync(): Promise; +} + class ExpoSQLiteDatabase implements CommonSQLiteDatabase { - private db: WebSQLDatabase; + private db: SQLiteDatabase | undefined; public async init(): Promise { - // only open database once. - if (!this.db) { - // As per expo docs version, description and size arguments are ignored, - // but are accepted by the function for compatibility with the WebSQL specification. - // Hence, we do not need those arguments. - this.db = openDatabase(DB_NAME); + try { + const SQLite = require('expo-sqlite'); + + if (!SQLite.openDatabaseAsync) { + throw new Error( + 'ExpoSQLiteAdapter requires expo-sqlite 13.0+ with async API. ' + + 'Please upgrade expo-sqlite or use the regular SQLiteAdapter instead.', + ); + } + + logger.debug('Initializing expo-sqlite with async API'); + this.db = (await SQLite.openDatabaseAsync(DB_NAME)) as SQLiteDatabase; + + // WAL mode allows concurrent reads during writes + try { + await this.db.execAsync('PRAGMA journal_mode = WAL;'); + } catch (pragmaError) { + logger.debug('Failed to enable WAL mode', pragmaError); + } + } catch (error) { + logger.error('Failed to initialize ExpoSQLiteDatabase', error); + throw error; + } } } @@ -41,258 +68,144 @@ class ExpoSQLiteDatabase implements CommonSQLiteDatabase { try { logger.debug('Clearing database'); await this.closeDB(); - // delete database is not supported by expo-sqlite. - // Database file needs to be deleted using deleteAsync from expo-file-system - await deleteAsync(`${documentDirectory}SQLite/${DB_NAME}`); + + // Delete database file using expo-file-system + // expo-sqlite doesn't provide a deleteDatabase method like react-native-sqlite-storage + const FileSystem = require('expo-file-system'); + let deleteAsync; + + // Try to use the modern deleteAsync from expo-file-system/legacy first + // Fall back to main FileSystem.deleteAsync for older versions + try { + ({ deleteAsync } = require('expo-file-system/legacy')); + } catch { + ({ deleteAsync } = FileSystem); + } + + await deleteAsync(`${FileSystem.documentDirectory}SQLite/${DB_NAME}`); logger.debug('Database cleared'); } catch (error) { logger.warn('Error clearing the database.', error); - // open database if it was closed earlier and this.db was set to undefined. - this.init(); + + if (!this.db) { + await this.init(); + } + + throw error; } } public async get( statement: string, params: (string | number)[], - ): Promise { - const results: T[] = await this.getAll(statement, params); + ): Promise { + if (!this.db) { + throw new Error('Database not initialized'); + } - return results[0]; + const result = await this.db.getFirstAsync(statement, params); + + return result as T | undefined; } - public getAll( + public async getAll( statement: string, params: (string | number)[], ): Promise { - return new Promise((resolve, reject) => { - this.db.readTransaction(transaction => { - transaction.executeSql( - statement, - params, - (_, result) => { - resolve(result.rows._array || []); - }, - (_, error) => { - reject(error); - logger.warn(error); - - return true; - }, - ); - }); - }); + if (!this.db) { + throw new Error('Database not initialized'); + } + + return this.db.getAllAsync(statement, params); } - public save(statement: string, params: (string | number)[]): Promise { - return new Promise((resolve, reject) => { - this.db.transaction(transaction => { - transaction.executeSql( - statement, - params, - () => { - resolve(null); - }, - (_, error) => { - reject(error); - logger.warn(error); - - return true; - }, - ); - }); - }); + public async save( + statement: string, + params: (string | number)[], + ): Promise { + if (!this.db) { + throw new Error('Database not initialized'); + } + + await this.db.runAsync(statement, params); } - public batchQuery( + public async batchQuery( queryParameterizedStatements = new Set(), ): Promise { - return new Promise((resolve, reject) => { - const resolveTransaction = resolve; - const rejectTransaction = reject; - this.db.transaction(async transaction => { - try { - const results: any[] = await Promise.all( - [...queryParameterizedStatements].map( - ([statement, params]) => - new Promise((_resolve, _reject) => { - transaction.executeSql( - statement, - params, - (_, result) => { - _resolve(result.rows._array[0]); - }, - (_, error) => { - _reject(error); - logger.warn(error); - - return true; - }, - ); - }), - ), - ); - resolveTransaction(results); - } catch (error) { - rejectTransaction(error); - logger.warn(error); + if (!this.db) { + throw new Error('Database not initialized'); + } + + const results: T[] = []; + await this.db.withTransactionAsync(async () => { + for (const [statement, params] of queryParameterizedStatements) { + const result = await this.db!.getAllAsync(statement, params); + if (result && result.length > 0) { + results.push(result[0]); } - }); + } }); + + return results; } - public batchSave( + public async batchSave( saveParameterizedStatements = new Set(), deleteParameterizedStatements?: Set, ): Promise { - return new Promise((resolve, reject) => { - const resolveTransaction = resolve; - const rejectTransaction = reject; - this.db.transaction(async transaction => { - try { - // await for all sql statements promises to resolve - await Promise.all( - [...saveParameterizedStatements].map( - ([statement, params]) => - new Promise((_resolve, _reject) => { - transaction.executeSql( - statement, - params, - () => { - _resolve(null); - }, - (_, error) => { - _reject(error); - logger.warn(error); - - return true; - }, - ); - }), - ), - ); - if (deleteParameterizedStatements) { - await Promise.all( - [...deleteParameterizedStatements].map( - ([statement, params]) => - new Promise((_resolve, _reject) => { - transaction.executeSql( - statement, - params, - () => { - _resolve(null); - }, - (_, error) => { - _reject(error); - logger.warn(error); - - return true; - }, - ); - }), - ), - ); - } - resolveTransaction(null); - } catch (error) { - rejectTransaction(error); - logger.warn(error); + if (!this.db) { + throw new Error('Database not initialized'); + } + + await this.db.withTransactionAsync(async () => { + for (const [statement, params] of saveParameterizedStatements) { + await this.db!.runAsync(statement, params); + } + if (deleteParameterizedStatements) { + for (const [statement, params] of deleteParameterizedStatements) { + await this.db!.runAsync(statement, params); } - }); + } }); } - public selectAndDelete( + public async selectAndDelete( queryParameterizedStatement: ParameterizedStatement, deleteParameterizedStatement: ParameterizedStatement, ): Promise { + if (!this.db) { + throw new Error('Database not initialized'); + } + const [queryStatement, queryParams] = queryParameterizedStatement; const [deleteStatement, deleteParams] = deleteParameterizedStatement; - return new Promise((resolve, reject) => { - const resolveTransaction = resolve; - const rejectTransaction = reject; - this.db.transaction(async transaction => { - try { - const result: T[] = await new Promise((_resolve, _reject) => { - transaction.executeSql( - queryStatement, - queryParams, - (_, sqlResult) => { - _resolve(sqlResult.rows._array || []); - }, - (_, error) => { - _reject(error); - logger.warn(error); - - return true; - }, - ); - }); - await new Promise((_resolve, _reject) => { - transaction.executeSql( - deleteStatement, - deleteParams, - () => { - _resolve(null); - }, - (_, error) => { - _reject(error); - logger.warn(error); - - return true; - }, - ); - }); - resolveTransaction(result); - } catch (error) { - rejectTransaction(error); - logger.warn(error); - } - }); + let results: T[] = []; + await this.db.withTransactionAsync(async () => { + results = await this.db!.getAllAsync(queryStatement, queryParams); + await this.db!.runAsync(deleteStatement, deleteParams); }); + + return results; } - private executeStatements(statements: string[]): Promise { - return new Promise((resolve, reject) => { - const resolveTransaction = resolve; - const rejectTransaction = reject; - this.db.transaction(async transaction => { - try { - await Promise.all( - statements.map( - statement => - new Promise((_resolve, _reject) => { - transaction.executeSql( - statement, - [], - () => { - _resolve(null); - }, - (_, error) => { - _reject(error); - - return true; - }, - ); - }), - ), - ); - resolveTransaction(null); - } catch (error) { - rejectTransaction(error); - logger.warn(error); - } - }); + private async executeStatements(statements: string[]): Promise { + if (!this.db) { + throw new Error('Database not initialized'); + } + + await this.db.withTransactionAsync(async () => { + for (const statement of statements) { + await this.db!.execAsync(statement); + } }); } - private async closeDB() { + private async closeDB(): Promise { if (this.db) { logger.debug('Closing Database'); - // closing database is not supported by expo-sqlite. - // Workaround is to access the private db variable and call the close() method. - await (this.db as any)._db.close(); + await this.db.closeAsync(); logger.debug('Database closed'); this.db = undefined; } diff --git a/packages/datastore-storage-adapter/src/index.ts b/packages/datastore-storage-adapter/src/index.ts index 19ba93a509b..3e7b2855d9d 100644 --- a/packages/datastore-storage-adapter/src/index.ts +++ b/packages/datastore-storage-adapter/src/index.ts @@ -1,5 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import SQLiteAdapter from './SQLiteAdapter/SQLiteAdapter'; +import ExpoSQLiteAdapter from './ExpoSQLiteAdapter/ExpoSQLiteAdapter'; -export { SQLiteAdapter }; +export { SQLiteAdapter, ExpoSQLiteAdapter };