337 lines
10 KiB
JavaScript
337 lines
10 KiB
JavaScript
// Open the pre-built geodae SQLite database.
|
|
//
|
|
// IMPORTANT: This module must not crash at load time when a native SQLite
|
|
// backend is missing (Hermes: "Cannot find native module 'ExpoSQLite'").
|
|
//
|
|
// Strategy:
|
|
// 1) Prefer @op-engineering/op-sqlite (bare RN) via ./openDbOpSqlite
|
|
// 2) Fallback to expo-sqlite (Expo) ONLY if it can be required
|
|
// 3) If nothing works, callers should use getDbSafe() and handle { db: null }
|
|
|
|
const DB_NAME = "geodae.db";
|
|
|
|
let _dbPromise = null;
|
|
let _backendPromise = null;
|
|
let _selectedBackendName = null;
|
|
|
|
function describeModuleShape(mod) {
|
|
const t = typeof mod;
|
|
const keys =
|
|
mod && (t === "object" || t === "function") ? Object.keys(mod) : [];
|
|
return { type: t, keys };
|
|
}
|
|
|
|
function pickOpener(mod, name) {
|
|
// Deterministic picking to reduce CJS/ESM/Metro export-shape ambiguity.
|
|
// Priority is explicit and matches wrapper contract.
|
|
const opener =
|
|
mod?.openDbOpSqlite ??
|
|
mod?.openDbExpoSqlite ??
|
|
mod?.openDb ??
|
|
mod?.default ??
|
|
mod;
|
|
|
|
if (typeof opener === "function") return opener;
|
|
|
|
const { type, keys } = describeModuleShape(mod);
|
|
throw new TypeError(
|
|
[
|
|
`Backend module did not export a callable opener (backend=${name}).`,
|
|
`module typeof=${type} keys=[${keys.join(", ")}].`,
|
|
`picked typeof=${typeof opener}.`,
|
|
].join(" "),
|
|
);
|
|
}
|
|
|
|
export default function getDb() {
|
|
if (!_dbPromise) {
|
|
_dbPromise = getDbImpl();
|
|
}
|
|
return _dbPromise;
|
|
}
|
|
|
|
/**
|
|
* Non-throwing DB opener.
|
|
*
|
|
* v1 requirement: DB open failures must not crash the app. Downstream UI can
|
|
* display an error/empty state and keep overlays disabled.
|
|
*
|
|
* @returns {Promise<{ db: import('expo-sqlite').SQLiteDatabase | null, error: Error | null }>}
|
|
*/
|
|
export async function getDbSafe() {
|
|
try {
|
|
const db = await getDb();
|
|
return { db, error: null };
|
|
} catch (error) {
|
|
// Actionable runtime logging — include backend attempts + underlying error/stack.
|
|
// Keep behavior unchanged: do not crash, keep returning { db: null, error }.
|
|
const prefix = "[DAE_DB] Failed to open embedded DAE DB";
|
|
|
|
const logErrorDetails = (label, err) => {
|
|
if (!err) {
|
|
console.warn(`${prefix} ${label} <no error object>`);
|
|
return;
|
|
}
|
|
|
|
const msg = err?.message;
|
|
const stack = err?.stack;
|
|
|
|
// Log the raw error object first (best for Hermes / native errors).
|
|
console.warn(`${prefix} ${label} raw:`, err);
|
|
console.warn(`${prefix} ${label} message:`, msg);
|
|
if (stack) console.warn(`${prefix} ${label} stack:\n${stack}`);
|
|
|
|
const cause = err?.cause;
|
|
if (cause) {
|
|
console.warn(`${prefix} ${label} cause raw:`, cause);
|
|
console.warn(`${prefix} ${label} cause message:`, cause?.message);
|
|
if (cause?.stack) {
|
|
console.warn(`${prefix} ${label} cause stack:\n${cause.stack}`);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Primary error thrown by getDb()/selectBackend.
|
|
if (_selectedBackendName) {
|
|
console.warn(`${prefix} selected backend:`, _selectedBackendName);
|
|
}
|
|
logErrorDetails("(primary)", error);
|
|
|
|
// Nested backend selection errors (attached by selectBackend()).
|
|
const backends = error?.backends;
|
|
if (Array.isArray(backends) && backends.length > 0) {
|
|
for (const entry of backends) {
|
|
const backend = entry?.backend ?? "<unknown-backend>";
|
|
console.warn(`${prefix} backend attempted:`, backend);
|
|
logErrorDetails(`(backend=${backend})`, entry?.error);
|
|
}
|
|
}
|
|
|
|
return { db: null, error };
|
|
}
|
|
}
|
|
|
|
async function getDbImpl() {
|
|
const backend = await selectBackend();
|
|
return backend.getDb();
|
|
}
|
|
|
|
async function selectBackend() {
|
|
if (_backendPromise) return _backendPromise;
|
|
|
|
_backendPromise = (async () => {
|
|
const errors = [];
|
|
|
|
// 1) Prefer op-sqlite backend when available.
|
|
try {
|
|
let opBackendModule;
|
|
try {
|
|
console.warn(
|
|
"[DAE_DB] op-sqlite: requiring backend module ./openDbOpSqlite...",
|
|
);
|
|
// eslint-disable-next-line global-require
|
|
opBackendModule = require("./openDbOpSqlite");
|
|
|
|
const opModuleType = typeof opBackendModule;
|
|
const opModuleKeys =
|
|
opBackendModule &&
|
|
(typeof opBackendModule === "object" ||
|
|
typeof opBackendModule === "function")
|
|
? Object.keys(opBackendModule)
|
|
: [];
|
|
console.warn(
|
|
"[DAE_DB] op-sqlite: require ./openDbOpSqlite success",
|
|
`type=${opModuleType} keys=[${opModuleKeys.join(", ")}]`,
|
|
);
|
|
} catch (requireError) {
|
|
console.warn(
|
|
"[DAE_DB] op-sqlite: require ./openDbOpSqlite FAILED:",
|
|
requireError?.message,
|
|
);
|
|
const err = new Error("Failed to require ./openDbOpSqlite");
|
|
// Preserve the underlying Metro/Hermes resolution failure.
|
|
err.cause = requireError;
|
|
throw err;
|
|
}
|
|
|
|
if (opBackendModule == null) {
|
|
throw new TypeError(
|
|
"./openDbOpSqlite required successfully but returned null/undefined",
|
|
);
|
|
}
|
|
|
|
const openDbOp = pickOpener(opBackendModule, "op-sqlite");
|
|
console.warn(
|
|
"[DAE_DB] op-sqlite: picked opener",
|
|
`typeof=${typeof openDbOp}`,
|
|
);
|
|
const db = await openDbOp(); // validates open + schema
|
|
if (!db) throw new Error("op-sqlite backend returned a null DB instance");
|
|
_selectedBackendName = "op-sqlite";
|
|
return { name: "op-sqlite", getDb: () => db };
|
|
} catch (error) {
|
|
errors.push({ backend: "op-sqlite", error });
|
|
}
|
|
|
|
// 2) Fallback to expo-sqlite backend ONLY if it can be required.
|
|
try {
|
|
const expoBackend = createExpoSqliteBackend();
|
|
// Validate open; createExpoSqliteBackend() is already safe to call.
|
|
await expoBackend.getDb();
|
|
_selectedBackendName = expoBackend?.name ?? "expo-sqlite";
|
|
return expoBackend;
|
|
} catch (error) {
|
|
errors.push({ backend: "expo-sqlite", error });
|
|
}
|
|
|
|
const err = new Error(
|
|
"No SQLite backend available (tried: @op-engineering/op-sqlite, expo-sqlite)",
|
|
);
|
|
// Attach details for debugging; callers should treat this as non-fatal.
|
|
// (Avoid AggregateError for broader Hermes compatibility.)
|
|
err.backends = errors;
|
|
throw err;
|
|
})();
|
|
|
|
return _backendPromise;
|
|
}
|
|
|
|
function createExpoSqliteBackend() {
|
|
// All requires are inside the factory so a missing ExpoSQLite native module
|
|
// does not crash at module evaluation time.
|
|
|
|
let openDbExpoSqlite;
|
|
let wrapperModule;
|
|
try {
|
|
// Expo SQLite wrapper uses static imports to make Metro/Hermes interop stable.
|
|
// eslint-disable-next-line global-require
|
|
wrapperModule = require("./openDbExpoSqlite");
|
|
const expoModuleType = typeof wrapperModule;
|
|
const expoModuleKeys =
|
|
wrapperModule &&
|
|
(typeof wrapperModule === "object" || typeof wrapperModule === "function")
|
|
? Object.keys(wrapperModule)
|
|
: [];
|
|
console.warn(
|
|
"[DAE_DB] expo-sqlite: require ./openDbExpoSqlite success",
|
|
`type=${expoModuleType} keys=[${expoModuleKeys.join(", ")}]`,
|
|
);
|
|
openDbExpoSqlite = pickOpener(wrapperModule, "expo-sqlite");
|
|
} catch (requireError) {
|
|
const err = new Error("Failed to require ./openDbExpoSqlite");
|
|
err.cause = requireError;
|
|
throw err;
|
|
}
|
|
|
|
// Log what we actually picked (helps confirm Metro export shapes in the wild).
|
|
if (wrapperModule != null) {
|
|
console.warn(
|
|
"[DAE_DB] expo-sqlite: picked opener",
|
|
`typeof=${typeof openDbExpoSqlite}`,
|
|
);
|
|
}
|
|
|
|
let _expoDbPromise = null;
|
|
|
|
function createLegacyAsyncFacade(legacyDb) {
|
|
const execSqlAsync = (sql, params = []) =>
|
|
new Promise((resolve, reject) => {
|
|
const runner =
|
|
typeof legacyDb.readTransaction === "function"
|
|
? legacyDb.readTransaction.bind(legacyDb)
|
|
: legacyDb.transaction.bind(legacyDb);
|
|
|
|
runner((tx) => {
|
|
tx.executeSql(
|
|
sql,
|
|
params,
|
|
() => resolve(),
|
|
(_tx, err) => {
|
|
reject(err);
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
const queryAllAsync = (sql, params = []) =>
|
|
new Promise((resolve, reject) => {
|
|
const runner =
|
|
typeof legacyDb.readTransaction === "function"
|
|
? legacyDb.readTransaction.bind(legacyDb)
|
|
: legacyDb.transaction.bind(legacyDb);
|
|
|
|
runner((tx) => {
|
|
tx.executeSql(
|
|
sql,
|
|
params,
|
|
(_tx, result) => {
|
|
const rows = [];
|
|
const len = result?.rows?.length ?? 0;
|
|
for (let i = 0; i < len; i++) {
|
|
rows.push(result.rows.item(i));
|
|
}
|
|
resolve(rows);
|
|
},
|
|
(_tx, err) => {
|
|
reject(err);
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
return {
|
|
// Methods used by this repo
|
|
execAsync(sql) {
|
|
return execSqlAsync(sql);
|
|
},
|
|
getAllAsync(sql, params) {
|
|
return queryAllAsync(sql, params);
|
|
},
|
|
async getFirstAsync(sql, params) {
|
|
const rows = await queryAllAsync(sql, params);
|
|
return rows[0] ?? null;
|
|
},
|
|
// Keep a reference to the underlying legacy DB for debugging.
|
|
_legacyDb: legacyDb,
|
|
};
|
|
}
|
|
|
|
async function initDbExpo() {
|
|
// eslint-disable-next-line global-require
|
|
const { ensureEmbeddedDb } = require("./ensureEmbeddedDb");
|
|
// Stage the DB into the Expo SQLite dir before opening.
|
|
await ensureEmbeddedDb({ dbName: DB_NAME });
|
|
|
|
let db;
|
|
// openDbExpoSqlite() can be async (openDatabaseAsync) or sync (openDatabase).
|
|
db = await openDbExpoSqlite(DB_NAME);
|
|
|
|
// Expo Go / older expo-sqlite: provide an async facade compatible with
|
|
// the subset of methods used in this repo (execAsync + getAllAsync).
|
|
if (db && typeof db.execAsync !== "function") {
|
|
db = createLegacyAsyncFacade(db);
|
|
}
|
|
|
|
// Read-only optimizations
|
|
await db.execAsync("PRAGMA journal_mode = WAL");
|
|
await db.execAsync("PRAGMA cache_size = -8000"); // 8 MB
|
|
|
|
// eslint-disable-next-line global-require
|
|
const { assertDbHasTable } = require("./validateDbSchema");
|
|
await assertDbHasTable(db, "defibs");
|
|
|
|
return db;
|
|
}
|
|
|
|
return {
|
|
name: "expo-sqlite",
|
|
getDb() {
|
|
if (!_expoDbPromise) {
|
|
_expoDbPromise = initDbExpo();
|
|
}
|
|
return _expoDbPromise;
|
|
},
|
|
};
|
|
}
|