Skip to content

Commit 9c33c94

Browse files
committed
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
1 parent 24c0a0b commit 9c33c94

File tree

5 files changed

+398
-220
lines changed

5 files changed

+398
-220
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@aws-amplify/datastore-storage-adapter': minor
3+
---
4+
5+
fix(datastore-storage-adapter): export ExpoSQLiteAdapter and update to modern expo-sqlite 13.0+ async API
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
// Mock expo-sqlite before importing the module under test
2+
const mockDb = {
3+
execAsync: jest.fn().mockResolvedValue(undefined),
4+
getFirstAsync: jest.fn().mockResolvedValue(undefined),
5+
getAllAsync: jest.fn().mockResolvedValue([]),
6+
runAsync: jest.fn().mockResolvedValue(undefined),
7+
withTransactionAsync: jest.fn(async (cb: () => Promise<void>) => cb()),
8+
closeAsync: jest.fn().mockResolvedValue(undefined),
9+
};
10+
11+
const mockOpenDatabaseAsync = jest.fn().mockResolvedValue(mockDb);
12+
13+
jest.mock('expo-sqlite', () => ({
14+
openDatabaseAsync: mockOpenDatabaseAsync,
15+
}));
16+
17+
jest.mock('expo-file-system', () => ({
18+
documentDirectory: '/mock/documents/',
19+
deleteAsync: jest.fn().mockResolvedValue(undefined),
20+
}));
21+
22+
import ExpoSQLiteDatabase from '../src/ExpoSQLiteAdapter/ExpoSQLiteDatabase';
23+
24+
describe('ExpoSQLiteDatabase', () => {
25+
let db: ExpoSQLiteDatabase;
26+
27+
beforeEach(() => {
28+
jest.clearAllMocks();
29+
// Restore the default mock (in case a test overrode it)
30+
mockOpenDatabaseAsync.mockResolvedValue(mockDb);
31+
db = new ExpoSQLiteDatabase();
32+
});
33+
34+
describe('init', () => {
35+
it('opens database via openDatabaseAsync', async () => {
36+
await db.init();
37+
expect(mockOpenDatabaseAsync).toHaveBeenCalledWith(
38+
'AmplifyDatastore',
39+
);
40+
});
41+
42+
it('applies WAL pragma after opening', async () => {
43+
await db.init();
44+
expect(mockDb.execAsync).toHaveBeenCalledWith(
45+
'PRAGMA journal_mode = WAL;',
46+
);
47+
});
48+
49+
it('only opens once on repeated init calls', async () => {
50+
await db.init();
51+
await db.init();
52+
expect(mockOpenDatabaseAsync).toHaveBeenCalledTimes(1);
53+
});
54+
55+
it('throws when expo-sqlite lacks async API', async () => {
56+
// Temporarily remove openDatabaseAsync to simulate old expo-sqlite
57+
const original = mockOpenDatabaseAsync;
58+
const SQLite = require('expo-sqlite');
59+
delete SQLite.openDatabaseAsync;
60+
61+
const freshDb = new ExpoSQLiteDatabase();
62+
await expect(freshDb.init()).rejects.toThrow('expo-sqlite 13.0+');
63+
64+
// Restore for other tests
65+
SQLite.openDatabaseAsync = original;
66+
});
67+
});
68+
69+
describe('operations before init', () => {
70+
it('get throws if not initialized', async () => {
71+
await expect(db.get('SELECT 1', [])).rejects.toThrow(
72+
'Database not initialized',
73+
);
74+
});
75+
76+
it('getAll throws if not initialized', async () => {
77+
await expect(db.getAll('SELECT 1', [])).rejects.toThrow(
78+
'Database not initialized',
79+
);
80+
});
81+
82+
it('save throws if not initialized', async () => {
83+
await expect(db.save('INSERT', [])).rejects.toThrow(
84+
'Database not initialized',
85+
);
86+
});
87+
});
88+
89+
describe('get', () => {
90+
beforeEach(async () => db.init());
91+
92+
it('delegates to getFirstAsync', async () => {
93+
const row = { id: '1', field1: 'value' };
94+
mockDb.getFirstAsync.mockResolvedValueOnce(row);
95+
const result = await db.get('SELECT * FROM Model WHERE id = ?', [
96+
'1',
97+
]);
98+
expect(mockDb.getFirstAsync).toHaveBeenCalledWith(
99+
'SELECT * FROM Model WHERE id = ?',
100+
['1'],
101+
);
102+
expect(result).toEqual(row);
103+
});
104+
105+
it('returns undefined when no row found', async () => {
106+
mockDb.getFirstAsync.mockResolvedValueOnce(undefined);
107+
const result = await db.get('SELECT * FROM Model WHERE id = ?', [
108+
'missing',
109+
]);
110+
expect(result).toBeUndefined();
111+
});
112+
});
113+
114+
describe('getAll', () => {
115+
beforeEach(async () => db.init());
116+
117+
it('delegates to getAllAsync', async () => {
118+
const rows = [
119+
{ id: '1', field1: 'a' },
120+
{ id: '2', field1: 'b' },
121+
];
122+
mockDb.getAllAsync.mockResolvedValueOnce(rows);
123+
const result = await db.getAll('SELECT * FROM Model', []);
124+
expect(result).toEqual(rows);
125+
});
126+
});
127+
128+
describe('save', () => {
129+
beforeEach(async () => db.init());
130+
131+
it('delegates to runAsync', async () => {
132+
await db.save('INSERT INTO Model (id) VALUES (?)', ['1']);
133+
expect(mockDb.runAsync).toHaveBeenCalledWith(
134+
'INSERT INTO Model (id) VALUES (?)',
135+
['1'],
136+
);
137+
});
138+
});
139+
140+
describe('batchSave', () => {
141+
beforeEach(async () => db.init());
142+
143+
it('executes all statements in a transaction', async () => {
144+
const saves = new Set<[string, (string | number)[]]>([
145+
['INSERT INTO Model (id) VALUES (?)', ['1']],
146+
['INSERT INTO Model (id) VALUES (?)', ['2']],
147+
]);
148+
await db.batchSave(saves);
149+
expect(mockDb.withTransactionAsync).toHaveBeenCalledTimes(1);
150+
expect(mockDb.runAsync).toHaveBeenCalledTimes(2);
151+
});
152+
153+
it('executes deletes after saves', async () => {
154+
const saves = new Set<[string, (string | number)[]]>([
155+
['INSERT INTO Model (id) VALUES (?)', ['1']],
156+
]);
157+
const deletes = new Set<[string, (string | number)[]]>([
158+
['DELETE FROM Model WHERE id = ?', ['old']],
159+
]);
160+
await db.batchSave(saves, deletes);
161+
expect(mockDb.runAsync).toHaveBeenCalledTimes(2);
162+
});
163+
});
164+
165+
describe('batchQuery', () => {
166+
beforeEach(async () => db.init());
167+
168+
it('returns first row from each query', async () => {
169+
mockDb.getAllAsync
170+
.mockResolvedValueOnce([{ id: '1', field1: 'a' }])
171+
.mockResolvedValueOnce([{ id: '2', field1: 'b' }]);
172+
const queries = new Set<[string, (string | number)[]]>([
173+
['SELECT * FROM Model WHERE id = ?', ['1']],
174+
['SELECT * FROM Model WHERE id = ?', ['2']],
175+
]);
176+
const results = await db.batchQuery(queries);
177+
expect(results).toEqual([
178+
{ id: '1', field1: 'a' },
179+
{ id: '2', field1: 'b' },
180+
]);
181+
});
182+
183+
it('skips empty results', async () => {
184+
mockDb.getAllAsync
185+
.mockResolvedValueOnce([])
186+
.mockResolvedValueOnce([{ id: '2' }]);
187+
const queries = new Set<[string, (string | number)[]]>([
188+
['SELECT * FROM Model WHERE id = ?', ['missing']],
189+
['SELECT * FROM Model WHERE id = ?', ['2']],
190+
]);
191+
const results = await db.batchQuery(queries);
192+
expect(results).toEqual([{ id: '2' }]);
193+
});
194+
});
195+
196+
describe('selectAndDelete', () => {
197+
beforeEach(async () => db.init());
198+
199+
it('queries then deletes in a transaction', async () => {
200+
const rows = [{ id: '1' }];
201+
mockDb.getAllAsync.mockResolvedValueOnce(rows);
202+
const result = await db.selectAndDelete(
203+
['SELECT * FROM Model WHERE id = ?', ['1']],
204+
['DELETE FROM Model WHERE id = ?', ['1']],
205+
);
206+
expect(result).toEqual(rows);
207+
expect(mockDb.withTransactionAsync).toHaveBeenCalled();
208+
expect(mockDb.runAsync).toHaveBeenCalledWith(
209+
'DELETE FROM Model WHERE id = ?',
210+
['1'],
211+
);
212+
});
213+
});
214+
215+
describe('createSchema', () => {
216+
beforeEach(async () => db.init());
217+
218+
it('executes all statements in a transaction', async () => {
219+
await db.createSchema([
220+
'CREATE TABLE Model (id TEXT PRIMARY KEY)',
221+
'CREATE TABLE Other (id TEXT PRIMARY KEY)',
222+
]);
223+
expect(mockDb.withTransactionAsync).toHaveBeenCalled();
224+
expect(mockDb.execAsync).toHaveBeenCalledWith(
225+
'CREATE TABLE Model (id TEXT PRIMARY KEY)',
226+
);
227+
expect(mockDb.execAsync).toHaveBeenCalledWith(
228+
'CREATE TABLE Other (id TEXT PRIMARY KEY)',
229+
);
230+
});
231+
});
232+
233+
describe('clear', () => {
234+
beforeEach(async () => db.init());
235+
236+
it('closes db and deletes file', async () => {
237+
await db.clear();
238+
expect(mockDb.closeAsync).toHaveBeenCalled();
239+
const FileSystem = require('expo-file-system');
240+
expect(FileSystem.deleteAsync).toHaveBeenCalledWith(
241+
'/mock/documents/SQLite/AmplifyDatastore',
242+
);
243+
});
244+
});
245+
});

packages/datastore-storage-adapter/package.json

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,21 @@
3333
},
3434
"homepage": "https://aws-amplify.github.io/",
3535
"peerDependencies": {
36-
"@aws-amplify/core": "^6.1.0"
36+
"@aws-amplify/core": "^6.1.0",
37+
"expo-sqlite": ">=13.0.0",
38+
"expo-file-system": ">=13.0.0",
39+
"react-native-sqlite-storage": ">=5.0.0"
40+
},
41+
"peerDependenciesMeta": {
42+
"expo-sqlite": {
43+
"optional": true
44+
},
45+
"expo-file-system": {
46+
"optional": true
47+
},
48+
"react-native-sqlite-storage": {
49+
"optional": true
50+
}
3751
},
3852
"devDependencies": {
3953
"@aws-amplify/core": "6.16.1",

0 commit comments

Comments
 (0)