Database

database(name, migrations) replaces SQLiteProvider and useSQLiteContext - the documented way into expo-sqlite, and both React. The database itself is a plain object with getAllAsync and the rest already on it, so what this rebuilds is the lifecycle: opened once, migrated before anything queries it, closed on request.

Install

npx expo install expo-sqlite
import { database } from '@ng-native/expo/database';

The smallest useful example

A value rather than a service, because a schema belongs to a feature rather than to an injector, and an app usually has exactly one:

import { database } from '@ng-native/expo/database';

export const notes = database('notes.db', [
  { to: 1, up: (db) => db.execAsync('CREATE TABLE note (id INTEGER PRIMARY KEY, body TEXT)') },
]);
import { Component, OnInit, signal } from '@angular/core';
import { Text } from '@ng-native/components';
import { notes } from './notes-db.ts';

@Component({
  selector: 'app-note-list',
  imports: [Text],
  template: `<text>{{ count() }} notes</text>`,
})
export class NoteList implements OnInit {
  protected readonly count = signal(0);

  async ngOnInit(): Promise<void> {
    const db = await notes.ready();
    const rows = await db.getAllAsync('SELECT * FROM note');
    this.count.set(rows.length);
  }
}

count is a signal: assigning a plain property after an asynchronous operation does not itself schedule a render in a zoneless app, so signal(0) and count.set(...) are what get the row count onto the screen once it arrives.

Opening

Opening is deferred until something calls ready(): a database opened at startup is a file handle and a WAL journal for an app that may never read from it. ready() is safe to call from every caller that wants the database - the first one pays for opening it, and everyone who arrives while it is still opening is handed that same promise, which is what stops two connections to one file being made at once.

Migrations

Each migration names the version it produces (to) and a function that gets there (up). They run in order, each inside its own transaction, and the current version is recorded in SQLite's own PRAGMA user_version - not a table this has to create first. A migration whose to is at or below the version already recorded is skipped, so database() can be called with the same migration list on every launch and only the new ones run.

export const notes = database('notes.db', [
  { to: 1, up: (db) => db.execAsync('CREATE TABLE note (id, body)') },
  { to: 2, up: (db) => db.execAsync('ALTER TABLE note ADD COLUMN created_at INTEGER') },
]);

PRAGMA user_version is set inside the same transaction as the migration it records. SQLite treats user_version transactionally, so the schema change and the version bump commit together or roll back together: a migration that fails part-way leaves both the schema and the recorded version as they were, and the next launch runs it again from the start.

When a migration fails

ready() rejects with the migration's own error, and the connection it opened is closed before the rejection arrives, so nothing is left holding the file. The failed open is then forgotten: the next call to ready() opens the database and tries the outstanding migrations again. close() after a failed open resolves without doing anything, since there is no connection left to release.

Closing

close() releases the database connection, and resolves even if opening it had failed. It does not delete stored data. The next call to ready() reopens the database; most apps can leave the connection open. To remove data on sign-out, delete the rows or the database file explicitly - closing the connection is not that.

Without the module installed

ready() rejects with [angular-native] expo-sqlite is not installed. There is no value to fall back to for a query that has not been asked yet, so a database an app relies on fails loudly rather than pretending to hold data it does not have.

Working offline

Database is the cache to reach for a list - a feed, search results, anything queried, filtered or sorted, where Storage's single-value model does not fit. Working offline works through a full example: caching a feed here, refreshing it when Network says there is a connection, and queuing writes made offline in a table of their own.

Angular components as native iOS and Android views, built and shipped with Expo.

An alpha. MIT licensed. Sponsor its development.

An independent project, not affiliated with or endorsed by Google, the Angular team or Expo. Angular is a trademark of Google LLC.