We shall fight on the beaches, we shall fight on the landing grounds, we shall fight in the fields and in the streets, we shall fight in the hills; we shall never surrender, Winston Churchill

expo-sqlite works and how it differs from Python’s sqlite3 moduleCREATE TABLE, CREATE INDEX) safely with IF NOT EXISTSuseSQLiteContext and SQLiteProvider work togetherexpo-sqlite API (v14+) and the old APIexpo-sqlite vs Python’s sqlite3Both use the same SQLite engine and the same SQL syntax. The difference is the API layer around it.
PYTHON sqlite3 expo-sqlite (JavaScript)
────────────────────────────────────── ────────────────────────────────────────
import sqlite3 import * as SQLite from "expo-sqlite"
conn = sqlite3.connect("prayers.db") const db = await SQLite.openDatabaseAsync("prayers.db")
conn.row_factory = sqlite3.Row rows returned as objects by default
with conn: await db.execAsync("CREATE TABLE...")
conn.execute("CREATE TABLE...") (auto-commit, no context manager needed)
cursor = conn.execute("SELECT * FROM myprayers")
rows = cursor.fetchall() const rows = await db.getAllAsync("SELECT * FROM myprayers")
conn.execute("INSERT INTO ...", (a,b)) await db.runAsync("INSERT INTO ...", [a, b])
conn.close() db.closeAsync() (rarely needed)
1. JavaScript is asynchronous; Python is synchronous. Python’s sqlite3 is synchronous. When you call conn.execute(…), Python halts until SQLite finishes. This is fine because Python scripts typically run in a background process, not a UI thread.
JavaScript runs on a single UI thread. Blocking the thread freezes the entire app. Therefore, all database operations must be async and return Promises that you await, e.g, const result = await db.getAllAsync("SELECT * FROM prayers");
2. No with conn: context manager — transactions work differently
In Python you use with conn: to auto-commit or rollback.
with conn:
conn.execute("INSERT ...")
This automatically: (i) begins a transaction; (ii) commits on success; (iii) rolls back on exception.
In expo-sqlite, there is no implicit transaction context. Instead you use:
await db.withTransactionAsync(async () => {
await db.runAsync("INSERT ...");
await db.runAsync("UPDATE ...");
});
/* For single statements, you can skip transactions: */
await db.execAsync(...)
Inside the callback: (i) Expo automatically begins a transaction; (22) commits if the callback resolves; (iii) rolls back if it throws.
3. The database file lives in the app’s sandbox, not the filesystem
Python: the path is wherever you put it (~/dotfiles/.../prayers.db). In other words, you choose the path.
expo-sqlite: the file is always stored in the app’s private sandbox, not in the project folder.
/var/mobile/Containers/Data/Application/UUID/Documents/SQLite/prayers.db
Android: /data/data/com.yourname.prayercompanion/databases/prayers.dbYou never open the file directly (so you don’t need to know the exact path) - expo handles creation, migration, and access.
4.Expo SDK 52+ uses a new async API Expo SDK 52 introduced a breaking API change.
const db = await SQLite.openDatabaseAsync("prayers.db");
const rows = await db.getAllAsync("SELECT * FROM prayers");
Benefits: fully async; promise-based; works with React Suspense; cleaner error handling; and consistent across different platforms.
5. JavaScript returns plain JS objects; Python returns tuples.
Python:
rows = cursor.fetchall()
# rows = [(1, "Psalm 23"), (2, "Genesis 1"), ...]
JavaScript:
const rows = await db.getAllAsync("SELECT * FROM prayers");
// rows = [{ id: 1, title: "Psalm 23" }, { id: 2, title: "Genesis 1" }]
// JS gives you objects with column names, which is nicer for UI code.
expo-sqlitecd prayer-companion
npx expo install expo-sqlite
npx expo install (not npm install) is important here. It installs the version of expo-sqlite that is compatible with your exact Expo SDK version. Using npm install directly might install
an incompatible version.
src/db/database.tsThe provided src/db/database.ts file is the heart of Prayer Companion’s local database layer. Below is a thorough, educational walkthrough of every section, why it exists, and the design decisions behind it.
This module owns everything related to the database connection and schema:
The file exports constants and functions that are consumed by the app’s React components via SQLiteProvider and useSQLiteContext.
// src/db/database.ts
//
// SQLite database setup for Prayer Companion.
import * as SQLite from "expo-sqlite";
// ── Database name ─────────────────────────────────────────────────────────────
// DB_NAME is the database filename.
// It’s exported so that the SQLiteProvider in the component tree can reference it without hard‑coding a string.
export const DB_NAME = "prayers.db";
// SCHEMA_VERSION is the current application‑level schema version.
// Every time we change the structure of the database (add a table, add a column, etc.),
// we increment this number. The migration system compares it with the
// PRAGMA user_version stored inside the SQLite file to decide whether migrations need to run.
export const SCHEMA_VERSION = 1;
// ══════════════════════════════════════════════════════════════════════════════
// DDL — DATA DEFINITION LANGUAGE
// The SQL that creates the tables and indexes.
// ══════════════════════════════════════════════════════════════════════════════
const DDL_V1 = `
CREATE TABLE IF NOT EXISTS myprayers (
id_myprayer INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT DEFAULT '',
date TEXT DEFAULT '',
image TEXT
);
CREATE TABLE IF NOT EXISTS pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
myprayerid INTEGER NOT NULL
REFERENCES myprayers(id_myprayer)
ON DELETE CASCADE,
page_number INTEGER NOT NULL DEFAULT 1,
content TEXT DEFAULT '',
image TEXT,
UNIQUE (myprayerid, page_number)
);
CREATE INDEX IF NOT EXISTS idx_prayers_date
ON myprayers (date);
CREATE INDEX IF NOT EXISTS idx_pages_prayer
ON pages (myprayerid, page_number);
`;
/**
* MIGRATIONS — schema version → SQL upgrade script.
*
* This object defines the entire schema‑evolution history of the database.
* Each key is a schema version number (1, 2, 3, …) and each value is the SQL
* required to *reach* that version from the previous one.
*
* Version 1 always represents the initial creation of all tables.
* When you later introduce new tables or columns (e.g., a `tags` table or a
* new column on `pages`), you add a new entry:
*
* 2: `ALTER TABLE ...` or `CREATE TABLE ...`
*
* The migration runner will automatically apply all missing migrations in
* ascending order until the database reaches the target SCHEMA_VERSION.
*
* ---------------------------------------------------------------------------
* HOW TO ADD A NEW MIGRATION
* ---------------------------------------------------------------------------
* 1. Increment SCHEMA_VERSION at the top of this file.
* This declares the new “desired” schema version for the app.
*
* 2. Add a new entry to MIGRATIONS:
* MIGRATIONS[2] = `ALTER TABLE ...`;
* The key must match the new SCHEMA_VERSION.
*
* 3. Write the SQL needed to transform the *existing* schema into the new one.
* - Use ALTER TABLE to add columns.
* - Use CREATE TABLE for new tables.
* - Never remove or drop user data.
*
* 4. Test both installation paths:
* A. Fresh install:
* Delete the app → reinstall → database starts at version 0 → all
* migrations run sequentially.
*
* B. Upgrade path:
* Install an older build → then install the new build → only the new
* migration runs.
*
* ---------------------------------------------------------------------------
* WHY WE DO NOT USE DROP TABLE
* ---------------------------------------------------------------------------
* DROP TABLE destroys all user data.
* Migrations must be *additive* and *non‑destructive* so that existing users
* keep their prayers, pages, tags, etc. Schema evolution should preserve all
* rows and only extend the structure.
*
* SQLite’s PRAGMA user_version gives us a built‑in, persistent integer stored
* directly inside the database file. We treat it as the authoritative schema
* version for migration purposes.
*/
const MIGRATIONS: Record<number, string> = {
1: DDL_V1,
// 2: DDL_V2, ← add future migrations here
};
// ── Migration runner ──────────────────────────────────────────────────────────
export async function runMigrations(
db: SQLite.SQLiteDatabase
): Promise<void> {
// 1. Read the current schema version from the database file.
// PRAGMA user_version is a built‑in SQLite mechanism to store an application‑defined integer directly in the database file.
// We treat it as the actual schema version of the file on disk.
// If the database has never been touched, user_version is 0.
const result = await db.getFirstAsync<{ user_version: number }>(
// This line runs the SQL command: PRAGMA user_version.
// This returns one row with one column: { user_version: 1 } or { user_version: 0 }
// Why the weird <{ user_version: number }> syntax? This is TypeScript,
// it tells the compiler: “The row returned by this query will be an object with a field named user_version that is a number.”
"PRAGMA user_version"
);
// Then, we extract the number safely
// result?.user_version
// if result exists, get the number/ if result is undefined, this becomes undefined
// ?? 0 if the left side is undefined or null, use 0
// To sum up, If the DB has a user_version, use it. Otherwise default to 0.
const currentVersion = result?.user_version ?? 0;
console.log(
`[DB] Schema version: ${currentVersion} → target: ${SCHEMA_VERSION}`
);
// 2. Compute which migrations need to run.
// We sort all migration keys numerically and keep only those strictly greater
// than the current version. This ensures:
// - Fresh installs run all migrations.
// - Upgrades run only the missing ones.
// Suppose: currentVersion = 1; const MIGRATIONS = {
// 1: "...",
// 2: "...",
// 5: "...",
// };
// A. Step 1 — Get the keys, Object.keys(MIGRATIONS) return an array of strings: ["1", "2", "5"]
const pending = Object.keys(MIGRATIONS)
// B. Step 2 — Convert them to numbers: [1, 2, 5]
.map(Number)
// C. Step 3 — Sort them numerically: [1, 2, 5]
.sort((a, b) => a - b)
// D. Step 4 — Filter only the ones we still need: [2, 5]
.filter((v) => v > currentVersion);
if (pending.length === 0) {
console.log("[DB] Schema is up to date.");
return;
}
// 3. Apply each migration inside its own transaction.
// db.withTransactionAsync ensures atomicity:
// - If the SQL succeeds → commit.
// - If anything throws → rollback.
// This prevents partially‑applied migrations and keeps the DB consistent.
for (const version of pending) {
console.log(`[DB] Applying migration v${version}…`);
await db.withTransactionAsync(async () => {
// Apply the schema change(s)
await db.execAsync(MIGRATIONS[version]);
// 4. Update PRAGMA user_version to mark the migration as complete.
// This is crucial: without updating user_version, the migration would run
// again on next launch.
await db.execAsync(`PRAGMA user_version = ${version}`);
});
console.log(`[DB] Migration v${version} complete.`);
}
}
// ── Database initialiser (passed to SQLiteProvider onInit) ────────────────────
// <SQLiteProvider databaseName{DB_NAME} onInit{initDatabase}>
/* rest of the app */
//</SQLiteProvider>
export async function initDatabase(
db: SQLite.SQLiteDatabase
): Promise<void> {
// Why these two PRAGMAs come first and outside a transaction?
// PRAGMA journal_mode = WAL – Write‑Ahead Logging gives better concurrent read performance
// (reads can happen during writes) and is generally recommended for mobile apps.
// It cannot be changed inside a transaction.
await db.execAsync("PRAGMA journal_mode = WAL");
// PRAGMA foreign_keys = ON – SQLite disables foreign key enforcement by default
// for backwards compatibility. We need it enabled to make ON DELETE CASCADE work.
// This setting must be set on each connection (it’s not stored in the database file),
// so we call it every time the database is opened.
await db.execAsync("PRAGMA foreign_keys = ON");
// After these two PRAGMAs are set, we call runMigrations.
// This ensures that any migration scripts (which may rely on foreign key constraints
// or WAL performance) run with the correct configuration
await runMigrations(db);
console.log("[DB] Database initialised successfully.");
}
// ── Diagnostics ───────────────────────────────────────────────────────────────
// A typical setup looks like this: `app/_layout.tsx`
<SQLiteProvider
databaseName={DB_NAME}
onInit={initDatabase}
useSuspense
>
<StatusBar style="light" />
<Stack
screenOptions={{
headerStyle: { backgroundColor: "#1a1f2e" },
headerTintColor: "#dde4f0",
headerTitleStyle: { fontWeight: "bold", color: "#dde4f0" },
contentStyle: { backgroundColor: "#1a1f2e" },
}}
/>
</SQLiteProvider>
// 1. When SQLiteProvider mounts, it calls SQLite.openDatabaseAsync(DB_NAME).
// This is the async operation that opens (or creates) the database file in the app’s private storage.
// 2. Waits for the database connection to be ready.
// 3. Calls the onInit function (initDatabase) with the newly opened db object.
// 4. Provides the db object to the rest of the app via React context. Any child component can then access it with useSQLiteContext().
/**
* Introspects the SQLite database and returns a structured snapshot
* of its current state. This is used in the “Prayers” test/debug screen
* to visualize what tables exist, their columns, row counts, and the
* current schema version.
*
* Returned fields:
* - version: The PRAGMA user_version (integer schema version).
* - tables: All user-created tables (system tables sqlite_* excluded).
* - columns: Map: table → list of column names (via PRAGMA table_info).
* - indexes: All user-created indexes (sqlite_* excluded).
* - counts: Map: table → number of rows in that table.
*
* This function performs only read‑only introspection queries.
*/
export async function getDatabaseInfo(db: SQLite.SQLiteDatabase): Promise<{
version: number;
tables: string[];
columns: Record<string, string[]>;
indexes: string[];
counts: Record<string, number>;
}> {
// Read the schema version
// PRAGMA user_version is SQLite’s built‑in integer used to track schema migrations.
// getFirstAsync returns the first row of the result (or undefined).
// If the pragma returns nothing (shouldn’t happen), we default to 0.
const versionRow = await db.getFirstAsync<{ user_version: number }>(
"PRAGMA user_version"
);
const version = versionRow?.user_version ?? 0;
// List all user‑created tables
// sqlite_master is SQLite’s internal catalog of tables, indexes, triggers, etc.
// Filtering out sqlite_% removes system tables like sqlite_sequence.
// We extract only the table names.
const tableRows = await db.getAllAsync<{ name: string }>(
`SELECT name FROM sqlite_master
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
ORDER BY name`
);
// This SQL query returns rows shaped like:
/* [
{ name: "myprayers" },
{ name: "pages" },
{ name: "tags" },
...
] */
// Each row is an object with a single property: name.
// So tableRows is an array of objects, not an array of strings.
// const tables = tableRows.map((r) => r.name); transforms the previous objects into:
// ["myprayers", "pages", ...]
const tables = tableRows.map((r) => r.name);
// For each table, list its columns
// PRAGMA table_info(table) returns metadata for each column:
// name, type, whether it’s a primary key, default value
// You only extract the column names.
const columns: Record<string, string[]> = {};
for (const table of tables) {
const colRows = await db.getAllAsync<{ name: string }>(
`PRAGMA table_info(${table})`
);
columns[table] = colRows.map((r) => r.name);
}
// List all user‑created indexes
// Excludes internal indexes created by SQLite (name NOT LIKE 'sqlite_%')
const indexRows = await db.getAllAsync<{ name: string }>(
`SELECT name FROM sqlite_master
WHERE type = 'index' AND name NOT LIKE 'sqlite_%'
ORDER BY name`
);
const indexes = indexRows.map((r) => r.name);
// Count rows in each table
// Runs SELECT COUNT(*) on each table.
// Stores the result in a map: { tableName: rowCount }.
const counts: Record<string, number> = {};
for (const table of tables) {
const countRow = await db.getFirstAsync<{ count: number }>(
`SELECT COUNT(*) AS count FROM ${table}`
);
counts[table] = countRow?.count ?? 0;
}
// Return the full database snapshot
return { version, tables, columns, indexes, counts };
}
SQLiteProvider into the root layoutNow that src/db/database.ts holds all the database logic, we need to connect it to the React component tree. That’s where SQLiteProvider comes in. It is a context provider that:
useSQLiteContext()We place SQLiteProvider at the very top of the app – in the root layout – so that every screen and component can access the database without prop drilling.
React’s context system lets you pass data through the component tree without having to pass props down manually at every level. A provider component (SQLiteProvider) stores some value (the database instance) and any consumer (useSQLiteContext()) can read that value, no matter how deeply nested it is.
Open app/_layout.tsx and update it:
// app/_layout.tsx
//
// ROOT LAYOUT
//
// In our app, the tree looks like this:
//
//
// ├── (expo-router root)
// │ ├──
// │ │ ├── — can call useSQLiteContext()
// │ │ ├── — can call useSQLiteContext()
// │ │ └── ...
// │ └──
// └── ...
//
//
// All screens inside the provider share the same database connection,
// opened once and kept alive for the lifetime of the app.
import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { Suspense } from "react";
import { ActivityIndicator, View } from "react-native";
import { SQLiteProvider } from "expo-sqlite";
import { DB_NAME, initDatabase } from "../src/db/database";
export default function RootLayout() {
return (
// Why Suspense is needed?
// SQLiteProvider performs an asynchronous operation: opening the database file.
// While that’s happening, any component that tries to call useSQLiteContext()
// would receive nothing – or worse, throw an error.
// To handle this gracefully, we use React Suspense.
// When SQLiteProvider has the useSuspense prop set to true,
// it throws a promise while the database is being opened.
// Suspense catches the async gap while SQLiteProvider opens the database.
// In other words, before the database is ready
// – the fallback (a simple loading spinner) is rendered.
// Once the database opens and initDatabase finishes
// – SQLiteProvider provides the db object, and the actual screens replace the fallback.
<Suspense
fallback={
<View style={{ flex: 1, backgroundColor: "#1a1f2e",
alignItems: "center", justifyContent: "center" }}>
<ActivityIndicator size="large" color="#c9a84c" />
</View>
}
>
{/*
The SQLiteProvider props explained:
1. databaseName: The name of the SQLite file inside the app’s private storage.
2. onInit: A callback that is called after the database is opened but before any child component can query it.
We pass our initDatabase function, which sets PRAGMA journal_mode = WAL, enables foreign keys, and runs migrations.
This guarantees that the schema is fully up‑to‑date before any screen tries to read or write data.
3. useSuspense: A boolean prop that tells SQLiteProvider to integrate with React Suspense.
When true, the provider throws a Promise during the async opening phase, activating the nearest <Suspense> boundary
(the closest <Suspense> component located directly above our SQLiteProvider in the React component tree.).
*/}
<SQLiteProvider
databaseName={DB_NAME}
onInit={initDatabase}
useSuspense
>
<StatusBar style="light" />
<Stack
screenOptions={{
headerStyle: { backgroundColor: "#1a1f2e" },
headerTintColor: "#dde4f0",
headerTitleStyle: { fontWeight: "bold", color: "#dde4f0" },
contentStyle: { backgroundColor: "#1a1f2e" },
}}
/>
</SQLiteProvider>
</Suspense>
);
}
With this setup, the complete database lifecycle is:
<Stack> navigator (and all screens) render.const db = useSQLiteContext(); and call functions from database.ts (like getDatabaseInfo(db)), using the very same database connection.app/(tabs)/prayers.tsx with the test screenReplace app/(tabs)/prayers.tsx with this test screen. The screen validates that the SQLite database has been correctly set up before building the real prayers UI.
The screen serves as a diagnostic tool that verifies:
If any check fails, the screen displays a clear error report; on success, it shows a “Database ready” status along with schema details and row counts.
// app/(tabs)/prayers.tsx
//
// PRAYERS TAB
//
// Tests that:
// 1. SQLiteProvider opened the database successfully
// 2. Both tables exist with the correct columns
// 3. Both indexes were created
// 4. useSQLiteContext() works inside a tab screen
//
// This will be replaced with the real Prayers UI later on.
// React hooks – useEffect for running the database check once,
// useState for storing results, errors, and loading state.
import React, { useEffect, useState } from "react";
// React Native components – basic layout and loading indicator.
import {
View,
Text,
ScrollView,
StyleSheet,
ActivityIndicator,
} from "react-native";
// useSQLiteContext – Expo SQLite hook that returns the database instance (SQLiteDatabase)
// opened by the nearest SQLiteProvider. It must be called inside a component wrapped by <SQLiteProvider> otherwise it throws an exception.
import { useSQLiteContext } from "expo-sqlite";
// getDatabaseInfo – a custom helper (located at src/db/database.ts) that queries the database schema and statistics.
import { getDatabaseInfo } from "../../src/db/database";
// ── Types ─────────────────────────────────────────────────────────────────────
// Describes the shape of data returned by getDatabaseInfo:
interface DBInfo {
version: number; // – schema version (e.g., from a user_version PRAGMA).
tables: string[]; // – list of table names found.
columns: Record<string, string[]>; // – map from table name to array of column names.
indexes: string[]; // – list of index names.
counts: Record<string, number>; // – row count per table.
}
export default function PrayersScreen() {
// useSQLiteContext() returns the SQLiteDatabase opened by SQLiteProvider.
// It throws if called outside of a SQLiteProvider — that is why we
// wrapped the entire app in in _layout.tsx.
const db = useSQLiteContext(); // This is the active database connection.
// info holds the structured database metadata returned by getDatabaseInfo.
// This single line defines both: info → the current state value; setInfo → the function React gives you to update that state
// This is how the React useState hook works.
const [info, setInfo] = useState<DBInfo | null>(null);
// error stores any thrown error message.
const [error, setError] = useState<string | null>(null);
// loading tracks whether the async load is still in progress.
const [loading, setLoading] = useState(true);
useEffect(() => {
async function load() {
try {
// Calls getDatabaseInfo(db) which performs all the actual SQL queries.
// On success, stores the result; on failure, captures the error message.
const result = await getDatabaseInfo(db);
setInfo(result);
// Because setInfo is the updater function returned by useState.
// Calling it: updates the state, triggers a re-render, and React gives your component the new value in info.
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}
load();
}, [db]); // Runs once after the component mounts.
// The dependency array [db] ensures it re‑runs if the database instance ever changes.
// Conditional Rendering
// ── Render: loading ────────────────────────────────────────────────────────
// Shows a spinner with gold colour (#c9a84c) and a message.
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#c9a84c" />
<Text style={styles.loadingText}>Checking database…</Text>
</View>
);
}
// ── Render: error ──────────────────────────────────────────────────────────
// Error State. Displays the error in red monospace text.
if (error) {
return (
<View style={styles.center}>
<Text style={styles.errorTitle}>❌ Database Error</Text>
<Text style={styles.errorText}>{error}</Text>
</View>
);
}
// ── Render: success ────────────────────────────────────────────────────────
// When info is available, the screen renders:
// Checks that both expected tables and indexes exist.
const allTablesPresent =
info?.tables.includes("myprayers") && info?.tables.includes("pages");
const allIndexesPresent =
info?.indexes.includes("idx_prayers_date") &&
info?.indexes.includes("idx_pages_prayer");
return (
/* This wraps the entire screen in a scrollable container so long lists of tables/columns don’t overflow. */
<ScrollView style={styles.scroll} contentContainerStyle={styles.content}>
{/* Overall status */}
<View style={styles.statusCard}>
/* The statusCard shows “✅ Database ready” if both conditions are true, otherwise a warning. */
<Text style={styles.statusText}>
{allTablesPresent && allIndexesPresent
? "✅ Database ready"
: "⚠️ Database incomplete — check output below"}
</Text>
/* Displays the schema version */
<Text style={styles.statusSub}>
Schema version: {info?.version ?? "?"}
</Tex>
</View>
{/* Tables */}
<View style={styles.card}>
<Text style={styles.cardTitle}>Tables found</Text>
/* Loops over every table name returned by getDatabaseInfo: */
{info?.tables.map((table) => (
<View key={table} style={styles.tableBlock}>
<Text style={styles.tableName}>
/* Shows a checkmark (always “✅” here, because the table is in the list). Shows the table name. */
{info.tables.includes(table) ? "✅" : "❌"} {table}
/* The row count is shown using info.counts[table]. */
<Text style={styles.rowCount}>
/* {" "} is just a literal string containing two spaces.
If info.counts[table] exists → use it. If it is null or undefined → use 0
e.g., ✅ myprayers (12 rows) */
{" "}({info.counts[table] ?? 0} rows)
</Tex>
</Text>
/* The list of columns is shown using info.columns[table]. */
<Text style={styles.columns}>
{info.columns[table]?.join(" · ") ?? ""}
</Text>
</View>
))}
</View>
{/* Indexes */}
<View style={styles.card}>
<Text style={styles.cardTitle}>Indexes found</Text>
{info?.indexes.length === 0 ? (
<Text style={styles.noneText}>No indexes found</Tex>
) : (
info?.indexes.map((idx) => (
<Text key={idx} style={styles.indexName}>✅ {idx}</Text>
))
)}
</View>
{/* Hint */}
<View style={styles.hintCard}>
<Text style={styles.hintText}>
New article complete.{"\n"}
Real Prayers UI coming soon.{"\n\n"}
Debug the database:{"\n"}
{" adb shell"}{" # connect to device shell\n"}
{" run-as com.yourname.prayercompanion\n"}
{" sqlite3 databases/prayers.db\n"}
{" .tables"}
</Text>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
scroll: { flex: 1, backgroundColor: "#1a1f2e" },
content: { padding: 20, paddingBottom: 40 },
center: { flex: 1, backgroundColor: "#1a1f2e",
alignItems: "center", justifyContent: "center", gap: 12 },
loadingText: { color: "#9ca3af", fontSize: 14 },
errorTitle: { fontSize: 18, fontWeight: "bold", color: "#f87171",
marginBottom: 8 },
errorText: { fontSize: 12, color: "#f87171", fontFamily: "monospace",
textAlign: "center", padding: 16 },
statusCard: { backgroundColor: "#1e2d1e", borderRadius: 10, padding: 16,
marginBottom: 16, borderWidth: 1, borderColor: "#2d4a2d",
alignItems: "center" },
statusText: { fontSize: 16, fontWeight: "bold", color: "#86efac",
marginBottom: 4 },
statusSub: { fontSize: 12, color: "#6b7280" },
card: { backgroundColor: "#222840", borderRadius: 10, padding: 16,
marginBottom: 16, borderWidth: 1, borderColor: "#2d3550" },
cardTitle: { fontSize: 14, fontWeight: "bold", color: "#c9a84c",
marginBottom: 12 },
tableBlock: { marginBottom: 12 },
tableName: { fontSize: 14, color: "#dde4f0", fontFamily: "monospace",
marginBottom: 2 },
rowCount: { color: "#6b7280", fontSize: 12 },
columns: { fontSize: 11, color: "#6b7280", fontFamily: "monospace",
lineHeight: 18, paddingLeft: 22 },
indexName: { fontSize: 13, color: "#9ca3af", fontFamily: "monospace",
paddingVertical: 2 },
noneText: { fontSize: 13, color: "#f87171" },
hintCard: { backgroundColor: "#1a1f2e", borderRadius: 8, padding: 14,
borderWidth: 1, borderColor: "#2d3550" },
hintText: { fontSize: 11, color: "#4b5563", fontFamily: "monospace",
lineHeight: 18 },
});
npx expo start --clear
Navigate to the ✝ Prayers tab. You should see:
✅ Database ready
Schema version: 1
Tables found
✅ myprayers (0 rows)
id_myprayer · title · description · date · image
✅ pages (0 rows)
id · myprayerid · page_number · content · image
Indexes found
✅ idx_pages_prayer
✅ idx_prayers_date
When the Prayers screen mounts, getDatabaseInfo() runs automatically and dumps the database state to the console. You’re seeing confirmation that:
adb shell (emulator or USB-connected device)# Install EAS CLI (globally)
mkdir -p ~/.npm-global
# You must configure npm to use a user‑local prefix, not /usr/lib.
npm config set prefix '~/.npm-global'
echo 'export PATH=$HOME/.npm-global/bin:$PATH' >> ~/.zshrc
source ~/.zshrc
npm install -g eas-cli
eas build --platform android --profile development
# EAS CLI is simply asking you to log in to your Expo account.
# This is your Expo account login, the same one you use on: https://expo.dev
adb install your-app.apk
# Connect to the running emulator
adb shell
# Navigate to the app's data directory
# Replace com.yourname.prayercompanion with your actual package name from app.json
run-as com.justtothepoint.prayercompanion
# Open the database
sqlite3 databases/prayers.db
# Inside the SQLite shell:
.tables
.schema myprayers
.schema pages
SELECT * FROM myprayers;
PRAGMA user_version;
.quit