JustToThePoint English Website Version
JustToThePoint en español

SQLite Database Setup with expo-sqlite

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

Basic Linear ODE

What you will learn

Concept: expo-sqlite vs Python’s sqlite3

Both 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)

Key differences

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");

JavaScript yields to the event loop and resumes later.

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.

Python gives you implicit transactional safety. JavaScript requires explicit async transaction blocks.

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.

You 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.

Step 1 — Install expo-sqlite

cd 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.

Step 2 — Create src/db/database.ts

The 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 };
}

Step 3 — Wire SQLiteProvider into the root layout

Now 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:

  1. Opens the database asynchronousl (using SQLite.openDatabaseAsync)
  2. Calls our initDatabase function to set PRAGMAs and run migrations
  3. Makes the db object available to every child component via 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.

Prop drilling is the process of passing data from a top-level component down through multiple layers of nested components to reach a deeply nested child component that needs it.

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>
  );
}

How everything connects

With this setup, the complete database lifecycle is:

  1. App starts → React renders RootLayout.
  2. Suspense mounts, waiting for its children to “suspend”.
  3. SQLiteProvider (with useSuspense) starts opening prayers.db asynchronously.
  4. While opening, the provider suspends – React shows the ActivityIndicator fallback.
  5. Once the database is open, SQLiteProvider calls initDatabase(db):
  6. Sets PRAGMA journal_mode = WAL and foreign_keys = ON
  7. Runs any pending migrations
  8. Suspense finishes, and the <Stack> navigator (and all screens) render.
  9. Any screen can now const db = useSQLiteContext(); and call functions from database.ts (like getDatabaseInfo(db)), using the very same database connection.

Step 4 — Update app/(tabs)/prayers.tsx with the test screen

Replace 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" /&gt;
        <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 },
});

Step 5 — Run and test

npx expo start --clear

Expected Output on Success

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:

How to inspect the database directly (debugging)

Android 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
Bitcoin donation

JustToThePoint Copyright © 2011 - 2026 Anawim. ALL RIGHTS RESERVED. Bilingual e-books, articles, and videos to help your child and your entire family succeed, develop a healthy lifestyle, and have a lot of fun. Social Issues, Join us.

This website uses cookies to improve your navigation experience.
By continuing, you are consenting to our use of cookies, in accordance with our Cookies Policy and Website Terms and Conditions of use.