Skip to content

Commit 0652d8b

Browse files
committed
fix(formulus): Include bundled sqlite package (jsi) to prevent dependency on system sqlite
1 parent d7eaaa1 commit 0652d8b

13 files changed

Lines changed: 469 additions & 27 deletions

File tree

formulus/android/app/src/main/java/org/opendataensemble/formulus/MainApplication.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import com.facebook.react.ReactApplication
77
import com.facebook.react.ReactHost
88
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
99
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
10+
import com.nozbe.watermelondb.jsi.WatermelonDBJSIPackage
1011
import org.opendataensemble.formulus.UserAppPackage
1112

1213
class MainApplication : Application(), ReactApplication {
@@ -17,6 +18,9 @@ class MainApplication : Application(), ReactApplication {
1718
packageList = PackageList(this).packages.apply {
1819
// Packages that cannot be autolinked yet can be added manually here
1920
add(UserAppPackage())
21+
// JSI is a separate Gradle project from Watermelon's autolinked Java
22+
// adapter. Without this, jsi: true silently falls back to system SQLite.
23+
add(WatermelonDBJSIPackage())
2024
},
2125
)
2226
}

formulus/src/contexts/SyncContext.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import React, {
66
ReactNode,
77
} from 'react';
88
import { syncService as syncServiceInstance } from '../services/SyncService';
9+
import { i18n } from '../i18n/instance';
910

1011
export type { SyncProgress, SyncProgressPhase } from '../sync/syncProgress';
1112
export type { SyncProgressReporter } from '../sync/syncProgress';
@@ -69,12 +70,19 @@ export const SyncProvider: React.FC<SyncProviderProps> = ({ children }) => {
6970

7071
const cancelSync = useCallback(() => {
7172
syncServiceInstance.cancelSync();
73+
// Keep isActive until the in-flight syncObservations/updateAppBundle
74+
// finally-block clears isSyncing. Flipping it here re-enabled the Sync
75+
// button while the service was still busy, and the next tap threw
76+
// "Sync already in progress".
7277
setSyncState(prev => ({
7378
...prev,
74-
isActive: false,
7579
canCancel: false,
76-
error: 'Sync cancelled by user',
77-
progress: undefined,
80+
progress: prev.progress
81+
? {
82+
...prev.progress,
83+
details: i18n.t('sync.progress.cancelling'),
84+
}
85+
: prev.progress,
7886
}));
7987
}, []);
8088

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import watermelonLogger from '@nozbe/watermelondb/utils/common/logger';
2+
import {
3+
configureDiagnosticLog,
4+
readRecentEvents,
5+
} from '../../diagnostics/DiagnosticLog';
6+
import { configureLogger, resetLoggerForTests } from '../../diagnostics/logger';
7+
import { createMemoryFs } from '../../diagnostics/memoryFs';
8+
import {
9+
installWatermelonLogBridge,
10+
resetWatermelonLogBridgeForTests,
11+
} from '../installWatermelonLogBridge';
12+
13+
describe('installWatermelonLogBridge', () => {
14+
beforeEach(() => {
15+
resetWatermelonLogBridgeForTests();
16+
resetLoggerForTests();
17+
configureLogger({ persist: true });
18+
configureDiagnosticLog({
19+
fs: createMemoryFs(),
20+
documentDirectoryPath: '/docs',
21+
});
22+
});
23+
24+
afterEach(() => {
25+
resetWatermelonLogBridgeForTests();
26+
});
27+
28+
it('persists Watermelon warn and error into the diagnostic log', async () => {
29+
installWatermelonLogBridge();
30+
watermelonLogger.warn('JSI SQLiteAdapter not available… falling back');
31+
watermelonLogger.error(new Error('Failed to initialize JSI'));
32+
await new Promise(resolve => setTimeout(resolve, 0));
33+
const events = await readRecentEvents(10);
34+
expect(
35+
events.some(
36+
e =>
37+
e.tag === 'watermelon' &&
38+
e.level === 'warn' &&
39+
e.message.includes('JSI SQLiteAdapter not available'),
40+
),
41+
).toBe(true);
42+
expect(
43+
events.some(
44+
e =>
45+
e.tag === 'watermelon' &&
46+
e.level === 'error' &&
47+
e.message.includes('Failed to initialize JSI'),
48+
),
49+
).toBe(true);
50+
});
51+
52+
it('is idempotent', () => {
53+
installWatermelonLogBridge();
54+
installWatermelonLogBridge();
55+
expect(() => watermelonLogger.warn('once')).not.toThrow();
56+
});
57+
});
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import {
2+
describeSqliteEngine,
3+
probeSqliteEngine,
4+
sqliteEngineLogLevel,
5+
} from '../probeSqliteEngine';
6+
7+
function mockDb(options: {
8+
dispatcher?: string;
9+
versionRows?: Record<string, unknown>[];
10+
jsonRows?: Record<string, unknown>[] | Error;
11+
jsiBinding?: boolean;
12+
}) {
13+
let queryCount = 0;
14+
const unsafeFetchRaw = jest.fn(async () => {
15+
queryCount += 1;
16+
if (queryCount === 1) {
17+
return options.versionRows ?? [{ sqlite_version: '3.46.0' }];
18+
}
19+
if (options.jsonRows instanceof Error) {
20+
throw options.jsonRows;
21+
}
22+
return options.jsonRows ?? [{ json_ok: '1' }];
23+
});
24+
const db = {
25+
adapter: {
26+
underlyingAdapter: {
27+
_dispatcherType: options.dispatcher ?? 'jsi',
28+
initializingPromise: Promise.resolve(),
29+
},
30+
},
31+
get: jest.fn(() => ({
32+
query: jest.fn(() => ({ unsafeFetchRaw })),
33+
})),
34+
};
35+
if (options.jsiBinding) {
36+
(
37+
globalThis as { nativeWatermelonCreateAdapter?: () => unknown }
38+
).nativeWatermelonCreateAdapter = () => ({});
39+
} else {
40+
delete (globalThis as { nativeWatermelonCreateAdapter?: () => unknown })
41+
.nativeWatermelonCreateAdapter;
42+
}
43+
return db;
44+
}
45+
46+
describe('probeSqliteEngine', () => {
47+
afterEach(() => {
48+
delete (globalThis as { nativeWatermelonCreateAdapter?: () => unknown })
49+
.nativeWatermelonCreateAdapter;
50+
});
51+
52+
it('describes a healthy bundled engine', () => {
53+
const report = {
54+
dispatcher: 'jsi',
55+
sqliteVersion: '3.46.0',
56+
jsonExtract: true,
57+
jsiBinding: true,
58+
};
59+
expect(describeSqliteEngine(report)).toBe(
60+
'sqlite engine dispatcher=jsi version=3.46.0 json_extract=ok jsiBinding=yes',
61+
);
62+
expect(sqliteEngineLogLevel(report)).toBe('info');
63+
});
64+
65+
it('treats a system-sqlite fallback as a warning even when JSON1 exists', () => {
66+
expect(
67+
sqliteEngineLogLevel({
68+
dispatcher: 'asynchronous',
69+
sqliteVersion: '3.32.2',
70+
jsonExtract: true,
71+
jsiBinding: false,
72+
}),
73+
).toBe('warn');
74+
});
75+
76+
it('treats missing json_extract as an error', () => {
77+
expect(
78+
sqliteEngineLogLevel({
79+
dispatcher: 'asynchronous',
80+
sqliteVersion: '3.22.0',
81+
jsonExtract: false,
82+
jsiBinding: false,
83+
}),
84+
).toBe('error');
85+
expect(
86+
describeSqliteEngine({
87+
dispatcher: 'asynchronous',
88+
sqliteVersion: '3.22.0',
89+
jsonExtract: false,
90+
jsiBinding: false,
91+
}),
92+
).toContain('json_extract=missing');
93+
});
94+
95+
it('probes dispatcher, version, json_extract, and JSI binding', async () => {
96+
const db = mockDb({ dispatcher: 'jsi', jsiBinding: true });
97+
const report = await probeSqliteEngine(db as never);
98+
expect(report).toEqual({
99+
dispatcher: 'jsi',
100+
sqliteVersion: '3.46.0',
101+
jsonExtract: true,
102+
jsiBinding: true,
103+
});
104+
});
105+
106+
it('records json_extract as missing when the probe query fails', async () => {
107+
const db = mockDb({
108+
dispatcher: 'asynchronous',
109+
jsonRows: new Error('no such function: json_extract'),
110+
jsiBinding: false,
111+
});
112+
const report = await probeSqliteEngine(db as never);
113+
expect(report.jsonExtract).toBe(false);
114+
expect(report.dispatcher).toBe('asynchronous');
115+
expect(report.jsiBinding).toBe(false);
116+
});
117+
});

formulus/src/database/database.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ import {
66
schemaMigrations,
77
unsafeExecuteSql,
88
} from '@nozbe/watermelondb/Schema/migrations';
9+
import { logger } from '../diagnostics/logger';
10+
import { installWatermelonLogBridge } from './installWatermelonLogBridge';
11+
import { logSqliteEngine } from './probeSqliteEngine';
12+
13+
// Capture Watermelon's JSI-fallback warn before SQLiteAdapter runs initializeJSI.
14+
installWatermelonLogBridge();
915

1016
// Define migrations
1117
const migrations = schemaMigrations({
@@ -109,11 +115,14 @@ const adapter = new SQLiteAdapter({
109115
dbName: 'formulus',
110116
// Configure migrations
111117
migrations: migrations,
112-
// Optional synchronous mode for development
118+
// Requests the bundled JSI SQLite. Confirm with logSqliteEngine — Android
119+
// still falls back to system SQLite if WatermelonDBJSIPackage is missing.
113120
jsi: true,
114-
// Optional onSetUpError callback
115121
onSetUpError: error => {
116-
console.error('Database setup error:', error);
122+
logger.error(
123+
'db',
124+
error instanceof Error ? error.message : 'Database setup error',
125+
);
117126
},
118127
});
119128

@@ -125,3 +134,10 @@ export const database = new Database({
125134
// Add more models as needed
126135
],
127136
});
137+
138+
void logSqliteEngine(database).catch(error => {
139+
logger.warn(
140+
'db',
141+
error instanceof Error ? error.message : 'sqlite engine probe failed',
142+
);
143+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import watermelonLogger from '@nozbe/watermelondb/utils/common/logger';
2+
import { joinLogArgs } from '../diagnostics/redact';
3+
import { logger } from '../diagnostics/logger';
4+
5+
type WatermelonLogger = {
6+
silent: boolean;
7+
warn: (...messages: unknown[]) => void;
8+
error: (...messages: unknown[]) => void;
9+
};
10+
11+
const wmLogger = watermelonLogger as WatermelonLogger;
12+
13+
let installed = false;
14+
let originalWarn: WatermelonLogger['warn'] | undefined;
15+
let originalError: WatermelonLogger['error'] | undefined;
16+
17+
function formatWatermelonArgs(messages: unknown[]): string {
18+
const normalized = messages.map(message =>
19+
message instanceof Error ? message.message || String(message) : message,
20+
);
21+
return joinLogArgs(normalized);
22+
}
23+
24+
function persist(level: 'warn' | 'error', messages: unknown[]): void {
25+
if (wmLogger.silent) {
26+
return;
27+
}
28+
const text = formatWatermelonArgs(messages);
29+
if (!text) {
30+
return;
31+
}
32+
logger[level]('watermelon', text);
33+
}
34+
35+
/**
36+
* WatermelonDB logs JSI fallback and native errors to console only. Mirror
37+
* warn/error into the Formulus diagnostic log so field exports include them.
38+
*
39+
* Must run before `new SQLiteAdapter({ jsi: true })`, which is when the
40+
* fallback warning is emitted.
41+
*/
42+
export function installWatermelonLogBridge(): void {
43+
if (installed) {
44+
return;
45+
}
46+
originalWarn = wmLogger.warn.bind(wmLogger);
47+
originalError = wmLogger.error.bind(wmLogger);
48+
wmLogger.warn = (...messages: unknown[]) => {
49+
originalWarn?.(...messages);
50+
persist('warn', messages);
51+
};
52+
wmLogger.error = (...messages: unknown[]) => {
53+
originalError?.(...messages);
54+
persist('error', messages);
55+
};
56+
installed = true;
57+
}
58+
59+
export function resetWatermelonLogBridgeForTests(): void {
60+
if (!installed) {
61+
return;
62+
}
63+
if (originalWarn) {
64+
wmLogger.warn = originalWarn;
65+
}
66+
if (originalError) {
67+
wmLogger.error = originalError;
68+
}
69+
originalWarn = undefined;
70+
originalError = undefined;
71+
installed = false;
72+
}

0 commit comments

Comments
 (0)