# NitromelonDB
> NitromelonDB (npm: `nitromelondb`) is a reactive, lazy-loading SQLite database for React Native, web, Node.js and Electron. It's a maintained fork of WatermelonDB with the same model: schema, `Model` classes with decorators, observable queries, and offline-first sync with your own backend. Native SQLite runs through Nitro Modules on the React Native New Architecture.
Things to get right when writing code with NitromelonDB:
- Import from `nitromelondb` (`nitromelondb/decorators`, `nitromelondb/hooks`, `nitromelondb/adapters/sqlite`), not `@nozbe/watermelondb`. Existing WatermelonDB schemas, models and SQLite files keep working; see Migrating from WatermelonDB.
- React Native New Architecture only (RN 0.83+). Expo is supported through the `nitromelondb` config plugin in development builds and EAS; Expo Go is not.
- Models use legacy decorators: `@babel/plugin-proposal-decorators` with `{ legacy: true }`.
- Every create, update and delete must run inside a writer: `database.write(async () => ...)` or a `@writer` method on a model.
- Don't call `database.write()` / `database.read()` (or a `@writer` / `@reader` method) from inside another writer or reader and wait for it; that deadlocks. Use `writer.callWriter(() => ...)` / `reader.callReader(() => ...)`. Pass `experimentalDetectNestedWriters: __DEV__` to `new Database()` to turn the mistake into an error during development.
- For UI, observe instead of fetching once: `withObservables` or the hooks in `nitromelondb/hooks` (`useQuery`, `useRecord`, `useWriter`).
- For whole jobs, use the agent skills in the repository's `skills/` folder (`npx skills add StasDoskalenko/NitromelonDB`): `nitromelondb-install` (a new install: packages, platform setup, schema, models, Database and a data layer on hooks), `nitromelondb-schema` (design the schema, and every later change with its migration), `nitromelondb-sync` (backend endpoints and the app's sync engine), `nitromelondb-migrate-from-watermelondb`, `nitromelondb-migrate-decorators`, `nitromelondb-audit` (setup gaps and misuse) and `nitromelondb-indexes` (missing indexes, with the migration). See Agent skills. For writing app code by hand, follow Performance: Building a fast app.
- Each link below points to a plain Markdown version of the page. `llms-full.txt` next to this file has the full text of every page except the Optional section.
---
A reactive, offline-first SQLite database for React Native, Expo, the web, Windows and Node.
A maintained fork of WatermelonDB, on the React Native New Architecture.
NitromelonDB keeps your data in SQLite and loads only what a screen asks for, so apps with tens of thousands of records still launch fast. Records are observable: change one, and every component that shows it re-renders. It syncs with your own backend.
It continues [WatermelonDB](https://github.com/Nozbe/WatermelonDB) by [Nozbe](https://github.com/Nozbe), which hasn't kept up with React Native, iOS and Android for a while. Models, queries, writers and sync work the same way, and existing databases keep working. Coming from `@nozbe/watermelondb`? Follow the [migration guide](https://stasdoskalenko.github.io/NitromelonDB/docs/Migrating). (The original WatermelonDB introduction now lives in [LEGACY_README.md](https://github.com/StasDoskalenko/NitromelonDB/blob/master/LEGACY_README.md).)
## Install
**With a coding agent** (Claude Code, Cursor, Codex and others). From the root of your app:
```bash
npx skills add StasDoskalenko/NitromelonDB --skill nitromelondb-install
```
Then ask your agent to add NitromelonDB to the app, or run `/nitromelondb-install`. It checks the requirements, installs the packages, sets up decorators and the Expo plugin for your platform, designs the schema with you, writes the models, the database and a data layer on hooks, and checks the build. Coming from `@nozbe/watermelondb`, use `--skill nitromelondb-migrate-from-watermelondb` instead. There are also skills for schema changes, sync, audits and indexes: [Agent skills](https://stasdoskalenko.github.io/NitromelonDB/docs/AgentSkills).
**By hand:**
```bash
# Expo (development build, EAS Build or EAS Update; not Expo Go)
npx expo install nitromelondb react-native-nitro-modules
# bare React Native
yarn add nitromelondb react-native-nitro-modules
```
Expo apps also add `"nitromelondb"` to `plugins` in `app.json`. Expo needs no Babel setup for decorators; bare React Native needs four Babel plugins. Then rebuild the native app. All steps, per platform: [Installation](https://stasdoskalenko.github.io/NitromelonDB/docs/Installation).
**Requirements:** React Native 0.83+ on the New Architecture, iOS 15.1+, Android minSdk 24, React Native Windows 0.84+, Node.js 22+ (for Node, Electron and tooling).
For anything else, point your agent at [llms.txt](https://stasdoskalenko.github.io/NitromelonDB/llms.txt) (an index of the docs as Markdown) or [llms-full.txt](https://stasdoskalenko.github.io/NitromelonDB/llms-full.txt) (all of it in one file).
## A quick look
Define models for the tables in your schema:
```ts
import { Model, Query } from 'nitromelondb'
import { children, field, text } from 'nitromelondb/decorators'
class Post extends Model {
static table = 'posts'
static associations = {
comments: { type: 'has_many', foreignKey: 'post_id' },
} as const
@text('title') title!: string
@field('is_pinned') isPinned!: boolean
@children('comments') comments!: Query
}
```
Then use them in components. They re-render when the data changes, wherever the change came from:
```tsx
import { useQuery, useRecord, useWriter } from 'nitromelondb/hooks'
function PostScreen({ post }: { post: Post }) {
useRecord(post) // re-render when the post changes
const comments = useQuery(post.comments) // ...or when its comments do
const [togglePin, { isPending }] = useWriter(post, async (p) => {
await p.update((record) => {
record.isPinned = !record.isPinned
})
})
return (
{post.title}
)
}
```
Prefer higher-order components? `withObservables` still works. See [Components](https://stasdoskalenko.github.io/NitromelonDB/docs/Components) and [Hooks](https://stasdoskalenko.github.io/NitromelonDB/docs/Hooks).
## What's new since WatermelonDB
**Platforms**
- **New Architecture only.** Native SQLite on iOS, Android and Windows goes through [Nitro Modules](https://nitro.margelo.com). The old architecture (Paper and the legacy bridge) isn't supported.
- **Expo, first class.** A config plugin for development builds, EAS Build and EAS Update replaces `@morrowdigital/watermelondb-expo-plugin`. The example app runs on Expo SDK 58 and React Native 0.88.
- **Windows.** React Native Windows on the New Architecture (WinAppSDK), with the same Nitro SQLite.
- **The web, on real SQLite.** The same `nitromelondb/adapters/sqlite` import runs [wa-sqlite](https://stasdoskalenko.github.io/NitromelonDB/docs/Implementation/WaSQLiteAdapter) in a worker in the browser, and stores data in IndexedDB. No COOP/COEP headers needed.
- **Node.js and Electron.** SQLite through `better-sqlite3`, and `RemoteAdapter` to run it in Electron's main process.
- **Simpler installs.** SQLite and simdjson are compiled in. Autolinking is all you need: no Podfile, Gradle or `MainApplication` changes, and R8 keep rules ship with the library.
**For developers**
- **Written in TypeScript**, with strict checking, so the published types match the code.
- **Decorators, both kinds.** On Expo, model decorators work with no Babel setup at all. Standard (TC39) decorators with `accessor` fields work too, and development builds tell you when decorators compiled but don't work. See [Decorators](https://stasdoskalenko.github.io/NitromelonDB/docs/Advanced/StandardDecorators).
- **Hooks:** `useQuery`, `useRecord`, `useObservable`, `useWriter`, `useAtomicWriter` and `useDatabaseReady`, next to `withObservables`.
- **Seeding and readiness:** `new Database({ seed })` for initial or demo data tied to schema versions, and `database.readyPromise` / `useDatabaseReady` to gate your UI on setup.
- **Smaller conveniences:** `database.get(Post)` with a model class, `findAndObserveOrNull(id)`, and raw SQL expressions in `Q.sortBy`.
- **Observability:** nested writers, stuck readers and writers, and wrong `callWriter` / `callReader` use fail loudly and say where, instead of hanging. See [Observability](https://stasdoskalenko.github.io/NitromelonDB/docs/Advanced/Observability).
**Performance**
Measured against WatermelonDB 0.28 on the same device, with the same benchmark apps (v0.32.0-beta.0, iPhone 18 Pro simulator, Release builds, React Native 0.88; [method and raw data](https://github.com/StasDoskalenko/NitromelonDB/tree/master/examples/benchmark#latest-results-2026-11-08-v0320-beta0-ios-simulator)):
| | vs WatermelonDB with JSI | vs WatermelonDB without JSI |
| --- | --- | --- |
| Initial sync of a fresh install (80 tables, 300 pulls) | **2.0× faster** | **4.7× faster** |
| Many small syncs into existing data (67 pulls, 12 tables) | **1.4× faster** | **2.3× faster** |
| Sync of 5,000 / 20,000 records (create, update, fetch, push) | **1.2× / 1.2× faster** | **1.9× / 2.1× faster** |
| Sync of 50,000 records (initial and update pulls) | **1.6× faster** | **2.4× faster** |
| Bulk writes and deletes (100,000 records × 10) | **1.4× faster** | **2.1× faster** |
A single query returning ~50,000 records right after syncing them is faster too: 1.2× faster than WatermelonDB with JSI when the app sat idle before the sync, and 4.3× when the sync runs right at launch, where WatermelonDB spends most of that query in garbage collection. One thing is slower: the stress test's query phase, 10% against WatermelonDB without JSI (level with JSI).
What makes it faster:
- Query results skip Nitro's generic `AnyMap` conversion on the way to JS, and the native statement cache is a bounded LRU.
- Sync skips tables with nothing to pull and finds tables with local changes in one query; large batches no longer rebuild every index.
- `destroyAllPermanently()` and `markAllAsDeleted()` no longer slow down with the number of records.
- Caches trim themselves when the OS reports memory pressure, and SQLite releases its own memory too.
The NotesApp's Android CPU, memory and frame rate are also tracked on every commit: [Performance](https://stasdoskalenko.github.io/NitromelonDB/performance).
**Tested on every pull request**
- The shared correctness suite (CRUD, queries, migrations, concurrent writers, batches) runs against the real native SQLite bridge on iOS and Android.
- The [NotesApp](https://github.com/StasDoskalenko/NitromelonDB/tree/master/examples/NotesApp) runs end-to-end tests on every platform: [Maestro](https://docs.maestro.dev) flows on iOS and Android (cold start, create / pin / delete, kill-and-relaunch, pagination, sync), the same scenarios except sync through WinAppDriver on Windows, and Playwright on the web.
- Sync runs end to end against a [reference sync server](https://github.com/StasDoskalenko/NitromelonDB/tree/master/examples/sync-server), on SQLite and LokiJS.
Full history: [changelog](https://stasdoskalenko.github.io/NitromelonDB/docs/CHANGELOG).
## Example apps
- **[NotesApp](https://github.com/StasDoskalenko/NitromelonDB/tree/master/examples/NotesApp):** a notes list with migrations, pagination and sync, on Expo for iOS, Android and the web.
- **[NotesApp_windows](https://github.com/StasDoskalenko/NitromelonDB/tree/master/examples/NotesApp_windows):** the same app on React Native Windows. It's a separate app because RNW 0.85 is on React Native 0.85.
- **[benchmark](https://github.com/StasDoskalenko/NitromelonDB/tree/master/examples/benchmark):** NitromelonDB and WatermelonDB side by side, measuring the same operations.
- **[sync-server](https://github.com/StasDoskalenko/NitromelonDB/tree/master/examples/sync-server):** a small sync backend in TypeScript (Hono and SQLite) to copy from.
Each folder's README has run instructions.
## Roadmap
[SQLCipher](https://github.com/StasDoskalenko/NitromelonDB/issues/60) (including existing databases), [VACUUM](https://github.com/StasDoskalenko/NitromelonDB/issues/59), [macOS](https://github.com/StasDoskalenko/NitromelonDB/issues/49), [paginated sync](https://github.com/StasDoskalenko/NitromelonDB/issues/50), [more reliable migrations](https://github.com/StasDoskalenko/NitromelonDB/issues/57) and [clearer critical-error handling](https://github.com/StasDoskalenko/NitromelonDB/issues/61).
## Contributing
NitromelonDB is open source and needs your help. If something's missing or broken, open an issue to talk it through, and see the [contributing guide](./CONTRIBUTING.md) for setup and testing. [Good first issues](https://github.com/StasDoskalenko/NitromelonDB/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22) are a nice place to start.
Building an app with NitromelonDB? Let us know on [Discord](https://discord.gg/aXqqy3HPb), or add your logo to [LEGACY_README.md](https://github.com/StasDoskalenko/NitromelonDB/blob/master/LEGACY_README.md#who-uses-watermelondb).
## Credits and license
**WatermelonDB** was created by [@Nozbe](https://github.com/Nozbe), and its main author is [Radek Pietruszewski](https://github.com/radex). Huge thanks to them and to the [original contributors](https://github.com/Nozbe/WatermelonDB/graphs/contributors) for designing and building it.
**NitromelonDB** is maintained by [Stas Doskalenko](https://github.com/StasDoskalenko).
MIT licensed. See [LICENSE](https://github.com/StasDoskalenko/NitromelonDB/blob/master/LICENSE).
---
# Migrating from WatermelonDB
NitromelonDB is a maintained fork of [WatermelonDB](https://github.com/Nozbe/WatermelonDB). Models, schema, queries, writers, and sync work the same way. What changes is the package name and how the native code is linked.
Your existing SQLite files, schema version, migrations, and models keep working. You swap a library. Your data isn't migrated or touched.
**Before you start:** the app must be on the React Native **New Architecture**, React Native **0.83+**, iOS **15.1+**, and Android **minSdk 24**. The old architecture is not supported.
## Let a coding agent do it
The steps below are also packaged as an [agent skill](./AgentSkills.md). From the root of your app:
```bash
npx skills add StasDoskalenko/NitromelonDB --skill nitromelondb-migrate-from-watermelondb
```
Then ask your agent (Claude Code, Cursor, Codex, ...) to migrate the app from WatermelonDB to NitromelonDB, or run `/nitromelondb-migrate-from-watermelondb`. It checks the requirements above, does steps 1 to 4, fixes the Babel decorators setup, and lists what it couldn't do. Then it proposes what NitromelonDB adds (hooks, batched writers, nested-writer detection, integer storage, paginated sync), with `file:line`, and applies what you pick. The native rebuild (step 5) is still yours to run.
## 1. Swap the packages
```bash
yarn remove @nozbe/watermelondb @morrowdigital/watermelondb-expo-plugin
yarn add nitromelondb react-native-nitro-modules
```
- Leave out `react-native-nitro-modules` if the app already has it. You need **0.35.2 or newer**.
- If you don't use `@morrowdigital/watermelondb-expo-plugin`, just remove `@nozbe/watermelondb`.
- You don't need to add `rxjs`. NitromelonDB brings it.
## 2. Replace imports
Find and replace `@nozbe/watermelondb` → `nitromelondb` in your **JS/TS source, tests, and Jest mocks**. Every subpath keeps its name:
```js
// before
import { Database, Q } from '@nozbe/watermelondb'
import SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite'
import { withObservables } from '@nozbe/watermelondb/react'
// after
import { Database, Q } from 'nitromelondb'
import SQLiteAdapter from 'nitromelondb/adapters/sqlite'
import { withObservables } from 'nitromelondb/react'
```
Also move `__mocks__/@nozbe/watermelondb` to `__mocks__/nitromelondb`.
:::danger Don't run that replace on native files
In the Podfile, Xcode project, or Gradle files it produces paths that don't exist. Step 3 **deletes** those lines instead.
:::
## 3. Delete the old native setup
Autolinking does all the native linking now. Delete everything you added by hand for WatermelonDB. Don't point it at the new package.
**iOS**
- Podfile: delete `pod 'WatermelonDB'`, `pod 'simdjson'`, and `pod 'FMDB'`. Don't add `pod 'NitromelonDB'`.
- Xcode project: delete `HEADER_SEARCH_PATHS` entries that point into `@nozbe/watermelondb/.../SupportingFiles`.
- Bridging header, if it imports WatermelonDB: change it to `#import `.
**Android**
- `android/settings.gradle`: delete the `:watermelondb-jsi` include and its `projectDir` line.
- `android/app/build.gradle`: delete `implementation project(':watermelondb-jsi')`.
- `MainApplication`: delete `WatermelonDBJSIPackage` and `WatermelonDBPackage`.
- R8 / Proguard: change the keep rule to `-keep class com.nitromelondb.** { *; }`.
**Expo**
In `app.json`, replace `@morrowdigital/watermelondb-expo-plugin` with `"nitromelondb"`:
```json
{
"expo": {
"plugins": ["nitromelondb"]
}
}
```
**Windows**
Remove the old UWP `WatermelonDB.vcxproj` / `WMDatabaseBridge` linking, then follow [Installation — Windows](./Installation.md#windows).
## 4. Update the adapter
Remove `jsi: false` if you pass it. It throws on iOS and Android now. `jsi: true` is fine but no longer does anything.
```js
const adapter = new SQLiteAdapter({
schema,
migrations,
onSetUpError: error => {},
})
```
## 5. Rebuild
Metro reload is not enough. Do a full native build:
```bash
cd ios && pod install && cd ..
npx react-native run-ios
npx react-native run-android
# Expo:
npx expo prebuild --clean
npx expo run:ios
```
## Recommended: store whole numbers as integers
Once the app runs on NitromelonDB, turn on `new SQLiteAdapter({ storeWholeNumbersAsIntegers: true })`: number comparisons and sorting get faster. It's off by default only because an app's raw SQL (`Q.unsafeSqlQuery`, `unsafeExecute`) can see the difference: for example, `sum(total) / count(*)` divides integers as integers. So check your raw SQL first with [Performance](./Advanced/Performance.md), which walks through it step by step. Apps without raw SQL can turn it on right away. Data already on users' devices converts in the background.
## Optional: standard decorators
WatermelonDB models use legacy decorators, which are also NitromelonDB's default, so the steps above are all a migration needs. NitromelonDB also supports standard decorators (TC39 2023-11), the version TypeScript 5+ uses without `experimentalDecorators`. If you'd like to use them, here's the switch:
1. **Babel.** Expo: pass `decorators: { version: '2023-11' }` to `babel-preset-expo`. Bare React Native: use `@babel/plugin-proposal-decorators` with `version: '2023-11'` and `@babel/plugin-transform-class-static-block`. The exact configs are in [Decorators](./Advanced/StandardDecorators.md#standard-decorators).
2. **TypeScript.** Remove `experimentalDecorators` and `emitDecoratorMetadata` from `tsconfig.json`. TypeScript 5.0+ is required.
3. **Models.** Add `accessor` to every decorated field: `@text('title') title!: string` becomes `@text('title') accessor title!: string`. Turn `@lazy` fields into getters: `@lazy get recent() { return ... }`.
4. **Restart the bundler with a cleared cache** (`npx expo start --clear` or `npx react-native start --reset-cache`), then run your type checker and tests.
The decorators setting applies to the whole app, including decorators from other libraries. If you use one that only supports legacy decorators (some versions of MobX, TypeORM or class-validator, for example), stay on [legacy decorators](./Advanced/StandardDecorators.md#the-default-legacy-decorators) until it supports standard ones.
Models you haven't updated yet keep working after step 1, and in development NitromelonDB logs a warning naming each field that still needs `accessor`. So you can do step 3 one model at a time.
To let a coding agent do the switch, see [Decorators: let a coding agent do it](./Advanced/StandardDecorators.md#let-a-coding-agent-do-it).
## Things that may need attention
Build fails with Definitely assigned fields cannot be initialized here
WatermelonDB's setup had you add `@babel/plugin-proposal-decorators` to `babel.config.js`. On Expo SDK 56+ that plugin now runs before `babel-preset-expo`'s own decorator plugins and breaks every field declared with `!`. Remove it: the Expo preset already enables decorators. Bare React Native 0.87+ needs four plugins in a fixed order. Both setups are in [Installation](./Installation.md).
Yarn Classic fails with fatal: not a git repository
`0.30.1-beta.1` and earlier installed `wa-sqlite` from a git URL. Upgrade, or see the [Installation troubleshooting](./Installation.md#troubleshooting) for a `resolutions` workaround.
Flow types and @nozbe/watermelondb/types
The package is written in TypeScript and ships its own `.d.ts` files. Remove `@nozbe/watermelondb` from `.flowconfig`. Don't add `tsconfig` `paths` that point into `node_modules/nitromelondb/src`, because that breaks Metro and Jest.
Type replacements, all from `nitromelondb`:
| Old | New |
| --- | --- |
| `RecordId`, `TableName`, `ColumnName` | same names |
| `RelationId` / `$Call<…>` helpers | `RelationId` (`string`) or `RelationId` (`string \| null`) |
| `RawRecord`, `DirtyRaw` | same names. `Associations` comes from `nitromelondb/Model` |
| `$Diff`, `$Rest`, `$Shape` | `Omit`, `Partial`, `Pick` |
`json()` is generic: `json(column, sanitizer)`. See [Flow support removed](./Advanced/Flow.md).
Assigning record.id
You can set `record.id` only inside `collection.create()` / `prepareCreate()`. Anywhere else it throws. `_raw.id` and `prepareCreateFromDirtyRaw` still work.
Adding seed during the migration
[Database seeding](./Advanced/Seeding.md) records which steps have already run, and WatermelonDB never wrote that record. On a database carried over from WatermelonDB, every seed step **will run**, even if the tables already have user data. Read "Where to be careful in production" on that page first.
Android Studio Database Inspector no longer shows the database
NitromelonDB ships its own SQLite build, and the Inspector only sees databases opened through Android's `android.database.sqlite` API. See [Pro Tips — Database viewer](./Advanced/ProTips.md#database-viewer) for how to open it instead.
## Checklist
- [ ] `@nozbe/watermelondb` removed, `nitromelondb` and `react-native-nitro-modules` (0.35.2+) installed
- [ ] `@nozbe/watermelondb` → `nitromelondb` in JS/TS imports and Jest mocks
- [ ] Old Podfile, Xcode header path, Gradle, and `MainApplication` entries deleted
- [ ] Expo: `"nitromelondb"` plugin instead of `@morrowdigital/watermelondb-expo-plugin`
- [ ] New Architecture on, `jsi: false` removed
- [ ] Babel decorators set up as in [Installation](./Installation.md) (Expo: no decorator plugin; bare RN 0.87+: four plugins in order)
- [ ] Full native rebuild
- [ ] Optional: [standard decorators](#optional-standard-decorators)
Next: [Setup](./Setup.md) covers the `Database` options added since WatermelonDB, such as `seed` and `useDatabaseReady`.
---
# Agent skills
NitromelonDB ships skills for AI coding agents: step-by-step procedures an agent follows in your app's repository. They work with Claude Code, Cursor, Codex and other agents that support the [Agent Skills](https://agentskills.io) format.
Every skill builds on what NitromelonDB adds over WatermelonDB (hooks, batched writers, nested-writer detection, integer storage, paginated sync) and follows [Building a fast app](./Advanced/Performance.md#building-a-fast-app).
## Start here
| Skill | What it does | Changes your code? |
| --- | --- | --- |
| [`nitromelondb-install`](https://github.com/StasDoskalenko/NitromelonDB/tree/master/skills/nitromelondb-install) | Adds NitromelonDB to an app that doesn't use it yet: checks the requirements, installs the packages, sets up decorators, TypeScript and the Expo plugin for the platform (Expo, Expo web, bare React Native, Vite, Webpack, Node, Electron, Windows), creates the schema, migrations, models and `Database` with the recommended options, wires up `DatabaseProvider`, adds a data layer on observing hooks and batched writers, and checks the build and a first write and read. Follows [Installation](./Installation.md) and [Setup](./Setup.md). | Yes |
| [`nitromelondb-migrate-from-watermelondb`](https://github.com/StasDoskalenko/NitromelonDB/tree/master/skills/nitromelondb-migrate-from-watermelondb) | Moves an app from `@nozbe/watermelondb` to `nitromelondb`: packages, imports, Jest mocks, patches, old native linking and config plugins, adapter options, Babel decorators setup. Then proposes what NitromelonDB adds, with `file:line`, and applies what you pick. Follows [Migrating from WatermelonDB](./Migrating.md). | Yes |
## Building the app
| Skill | What it does | Changes your code? |
| --- | --- | --- |
| [`nitromelondb-schema`](https://github.com/StasDoskalenko/NitromelonDB/tree/master/skills/nitromelondb-schema) | Designs the schema from your app's own types, API responses and stored state: tables, columns, one-to-many and many-to-many relations, JSON fields, indexes, what to keep out of the database, and what sync needs. Writes the schema, migrations and typed models. For an app that has shipped, makes every change as a migration with a version bump, and tests an upgrade and a fresh install. Follows [Schema](./Schema.md) and [Migrations](./Advanced/Migrations.md). | After you agree to the proposal |
| [`nitromelondb-sync`](https://github.com/StasDoskalenko/NitromelonDB/tree/master/skills/nitromelondb-sync) | Builds sync on both sides. The backend: pull and push endpoints with change tracking, a monotonic clock, consistent pulls, transactional pushes with conflict detection, paginated pulls, migration syncs and per-user scoping, starting from the [reference server](https://github.com/StasDoskalenko/NitromelonDB/tree/master/examples/sync-server). The app: a sync engine around `synchronize()` (one sync at a time, retry, triggers on launch, foreground and local changes, logout). Tests two devices converging. Follows [Sync](./Sync/Intro.md). | Yes |
| [`nitromelondb-migrate-decorators`](https://github.com/StasDoskalenko/NitromelonDB/tree/master/skills/nitromelondb-migrate-decorators) | Moves models from legacy decorators to [standard decorators](./Advanced/StandardDecorators.md): Babel config, `tsconfig`, `accessor` on fields, `@lazy` getters. Stops first if another library still needs legacy decorators. | Yes |
## Checking the app
| Skill | What it does | Changes your code? |
| --- | --- | --- |
| [`nitromelondb-audit`](https://github.com/StasDoskalenko/NitromelonDB/tree/master/skills/nitromelondb-audit) | Reviews how the app uses NitromelonDB and reports problems by severity, with `file:line`: nested writers that deadlock, slow work inside writers, unbatched writes, missing migrations, startup work that races database readiness, models that drifted from the schema, fetching where the UI should observe, unsafe raw SQL, sync mistakes (including deletes that never sync). | Only what you pick from the report |
| [`nitromelondb-indexes`](https://github.com/StasDoskalenko/NitromelonDB/tree/master/skills/nitromelondb-indexes) | Lists every query, observer and relation, finds columns that should be indexed (and indexes nothing uses), checks them with `EXPLAIN QUERY PLAN` when it can reach a database file, and writes the schema change with its migration. | Only after you approve the list |
## Install
From the root of your app, with the [`skills`](https://github.com/vercel-labs/skills) CLI:
```bash
# pick from a list
npx skills add StasDoskalenko/NitromelonDB
# or one skill
npx skills add StasDoskalenko/NitromelonDB --skill nitromelondb-install
```
It copies the skill into your project, in the folder your agent reads (`.claude/skills`, `.agents/skills` and so on). Commit it if you want your team to have it, or delete the folder when you're done. The skills hand over to each other (install calls schema, schema points to indexes and sync), so adding them all is the simplest.
## Use
Ask for the task in your own words. The agent picks the skill from its description:
- "Add NitromelonDB to this app, with tables for projects and tasks."
- "Design the database schema for this app." / "Add a tags table to our schema."
- "Add sync with our backend."
- "Migrate this app from WatermelonDB to NitromelonDB."
- "Move our models to standard decorators."
- "Audit how we use NitromelonDB."
- "Find missing database indexes and add them."
In agents with slash commands, the skill's name works too: `/nitromelondb-install`.
Without the CLI, point your agent at a skill's file and ask it to follow it:
```text
Follow https://raw.githubusercontent.com/StasDoskalenko/NitromelonDB/master/skills/nitromelondb-install/SKILL.md
for this app.
```
## What to expect
- **The install, the sync and the migrations edit files.** Start from a clean working copy (everything committed), so the diff is only theirs, and review it. The migrations don't touch your data: SQLite files, the schema and migrations stay as they are. The native build is left to you unless you ask the agent to run it.
- **The schema skill proposes before it writes.** It asks about what it can't tell from the code (how large lists get, which ids the backend uses).
- **The audit and the index review report first.** They change nothing until you say which findings to act on. Adding an index, like any schema change on a shipped app, is a migration, so test it like one: an upgrade from the previous build, and a fresh install. See [Migrations](./Advanced/Migrations.md#step-4-test-your-migrations).
- **They read your code, not your users' data.** The index review can read a database file from your own simulator or emulator to count rows and check query plans. It works on a copy.
- A skill is instructions, not a program. Your agent may still get something wrong, so read the report and the diff.
## For everything else
Skills cover specific jobs. For general questions, point your agent at the docs: [llms.txt](https://stasdoskalenko.github.io/NitromelonDB/llms.txt) is an index of every page as Markdown, and [llms-full.txt](https://stasdoskalenko.github.io/NitromelonDB/llms-full.txt) is all of it in one file.
Missing a skill, or found a check the audit should make? [Open an issue](https://github.com/StasDoskalenko/NitromelonDB/issues).
---
# Installation
:::tip Migrating from `@nozbe/watermelondb`?
Use the [migration guide](./Migrating.md) instead. It covers everything on this page.
:::
## With a coding agent (recommended)
This page, [Setup](./Setup.md) and [Schema](./Schema.md) are packaged as an [agent skill](./AgentSkills.md) for Claude Code, Cursor, Codex and other agents. From the root of your app:
```bash
npx skills add StasDoskalenko/NitromelonDB --skill nitromelondb-install
```
Then ask your agent to add NitromelonDB to the app ("Add NitromelonDB to this app, with tables for projects and tasks"), or run `/nitromelondb-install`. It:
- works out your platform (Expo, Expo web, bare React Native, Vite, Webpack, Node, Electron, Windows) and checks the requirements below
- installs the packages and sets up decorators, TypeScript and the Expo plugin the right way for that platform
- works out the schema with you from your app's types and API, and writes the schema, migrations and models (with the [`nitromelondb-schema`](./AgentSkills.md) skill, if you add it too)
- creates the database with the recommended options, `DatabaseProvider`, and a data layer that follows [Building a fast app](./Advanced/Performance.md#building-a-fast-app): observing hooks, batched writers, `seed`
- type-checks, runs your tests, and checks a first write and read
Start from a clean working copy (everything committed) and review the diff. The native build is left to you unless you ask the agent to run it. All skills, including schema changes, sync, audits and indexes: [Agent skills](./AgentSkills.md).
## By hand
Check the requirements, then follow the section for your platform, then [Setup](./Setup.md).
## Requirements
| | Minimum |
| --- | --- |
| React Native | **0.83+, New Architecture** (the old / Paper architecture is not supported) |
| iOS | 15.1 (Xcode 15+) |
| Android | minSdk 24 |
| Windows | RNW 0.84 (New Architecture). See [Windows](#windows) |
| `react-native-nitro-modules` | 0.35.2+ (only needed on iOS, Android, and Windows) |
Expo Go is not supported. Use a development build.
## React Native (bare)
1. Install the package, the Nitro peer, and the Babel plugins for decorators. If the app already depends on `react-native-nitro-modules`, leave it out.
```bash
yarn add nitromelondb react-native-nitro-modules
yarn add --dev @babel/plugin-proposal-decorators@^7 @babel/plugin-transform-class-properties@^7 @babel/plugin-transform-private-methods@^7 @babel/plugin-transform-private-property-in-object@^7
```
Keep the `@^7`: React Native's Babel preset is on Babel 7, and npm's `latest` for these plugins is Babel 8. Without it, npm fails with `ERESOLVE` and Yarn installs plugins that don't match the app's `@babel/core`.
2. Enable decorators in `babel.config.js`. Keep all four plugins, in this order, with these options:
```js
module.exports = {
presets: ['module:@react-native/babel-preset'],
plugins: [
['@babel/plugin-proposal-decorators', { legacy: true }],
['@babel/plugin-transform-class-properties', { loose: true }],
['@babel/plugin-transform-private-methods', { loose: true }],
['@babel/plugin-transform-private-property-in-object', { loose: true }],
],
}
```
Decorators must run before class fields are compiled. With RN 0.87 and newer, the preset no longer compiles class fields early enough on its own, so the decorators plugin alone fails on fields like `@field('amount') amount!: number` with `Definitely assigned fields cannot be initialized here, but only in the constructor`.
Standard (TC39) decorators work too, with two plugins instead of four. See [Decorators](./Advanced/StandardDecorators.md#standard-decorators).
3. Rebuild the native app. Metro reload is not enough.
```bash
npx react-native run-ios # runs pod install for you
npx react-native run-android
```
That's it. Autolinking links SQLite on iOS and Android. You don't add Podfile lines, Gradle lines, or `MainApplication` code, and there is no setup CLI to run. R8 / ProGuard keep rules ship with the library, so minified release builds need no extra rules either.
## Expo
1. Install:
```bash
npx expo install nitromelondb react-native-nitro-modules
```
2. You don't need any Babel setup for decorators, or a `babel.config.js` at all. `babel-preset-expo` turns decorators on by default, together with the class field plugins they need, in the right order. If your app has a `babel.config.js` for other reasons, keep the preset as it is:
```js
module.exports = function (api) {
api.cache(true)
return {
presets: ['babel-preset-expo'],
}
}
```
Don't add `@babel/plugin-proposal-decorators` to `plugins` yourself. Your plugins run before the preset's, which breaks that order (since SDK 56) and fails on fields like `@field('amount') amount!: number` with `Definitely assigned fields cannot be initialized here, but only in the constructor`. If you're upgrading and have it in your config, delete it.
Standard (TC39) decorators work too, with one preset option. See [Decorators](./Advanced/StandardDecorators.md#standard-decorators).
3. Add the config plugin to `app.json`:
```json
{
"expo": {
"plugins": ["nitromelondb"]
}
}
```
4. Build a development client:
```bash
npx expo run:ios
npx expo run:android
# or: eas build --profile development
```
Development builds, EAS Build, and EAS Update all work. See [`examples/NotesApp`](https://github.com/StasDoskalenko/NitromelonDB/tree/master/examples/NotesApp) for a complete app.
**EAS Update:** models, queries, schema, and migrations are JS, so they ship over the air. Bumping `nitromelondb` or `react-native-nitro-modules` needs a new EAS Build.
Expo on Apple Silicon: No such module 'ExpoModulesCore'
Pass `excludeSimArch` to the plugin. It excludes `arm64` from iOS simulator builds:
```json
{
"expo": {
"plugins": [["nitromelondb", { "excludeSimArch": true }]]
}
}
```
### Expo web
The same `nitromelondb/adapters/sqlite` import runs SQLite in the browser through wa-sqlite. It runs in a worker and stores data in IndexedDB. No COOP/COEP headers are needed.
Expo web workers need bundle splitting. Expo Router already sets that up. Without Router, install `@expo/metro-runtime` and load it only on web:
```ts
// webRuntime.web.ts
import '@expo/metro-runtime'
// webRuntime.ts is an empty file. index.ts does: import './webRuntime'
```
If you have a custom `metro.config.js`, start from Expo's defaults and make sure `.wasm` is an asset:
```js
const { getDefaultConfig } = require('expo/metro-config')
const config = getDefaultConfig(__dirname)
if (!config.resolver.assetExts.includes('wasm')) {
config.resolver.assetExts.push('wasm')
}
module.exports = config
```
Before a release, run `npx expo export -p web` and check that the output has a worker bundle and a `.wasm` file.
To host the worker or WASM yourself (custom CDN or CSP), pass `web` options:
```ts
const adapter = new SQLiteAdapter({
dbName: 'app',
schema,
web: {
wasmUrl: 'https://static.example.com/wa-sqlite-async.wasm',
workerFactory: () => new Worker('/nitromelondb-sqlite-worker.js'),
},
})
```
Limitations, multi-tab behavior, and SSR notes are in [wa-sqlite web adapter](./Implementation/WaSQLiteAdapter.md).
## Web without Expo (Webpack, Vite)
:::note Vite 8
Vite 8 (with `@vitejs/plugin-react` 6) has no Babel step, so the Babel setup below doesn't apply. Its compiler reads decorators from your `tsconfig` (`tsconfig.app.json` in Vite's templates): set `"experimentalDecorators": true` and `"useDefineForClassFields": false` there. If your `package.json` has `"type": "module"`, also add `legacy: { inconsistentCjsInterop: true }` to `vite.config`. Without it, default imports such as `import LokiJSAdapter from 'nitromelondb/adapters/lokijs'` fail with `... is not a constructor`.
:::
For Webpack, or Vite 7 and older, install the Babel plugins (`@^7` matches the `@babel/core` most apps use; with Babel 8, drop it):
```bash
yarn add --dev @babel/plugin-proposal-decorators@^7 @babel/plugin-transform-class-properties@^7
```
Use this Babel config. Decorators must come first, and class properties must use `loose` mode:
```json
{
"plugins": [
["@babel/plugin-proposal-decorators", { "legacy": true }],
["@babel/plugin-transform-class-properties", { "loose": true }]
]
}
```
- **Webpack:** put it in `.babelrc` / `babel.config.js`.
- **Vite + React:** pass it to `@vitejs/plugin-react`:
```js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [
react({
babel: {
plugins: [
['@babel/plugin-proposal-decorators', { legacy: true }],
['@babel/plugin-transform-class-properties', { loose: true }],
],
},
}),
],
})
```
Standard (TC39) decorators work too. See [Decorators](./Advanced/StandardDecorators.md#standard-decorators).
Then set up the database with the LokiJS adapter, see [Setup](./Setup.md#web-lokijs). The SQLite adapter's browser build (wa-sqlite) is picked automatically on Expo web only; with Vite or Webpack it needs a hand-made worker and WASM setup, see [wa-sqlite web adapter](./Implementation/WaSQLiteAdapter.md).
## Windows
NitromelonDB runs on [React Native Windows](https://microsoft.github.io/react-native-windows/) **0.85** (New Architecture / WinAppSDK). It uses the same Nitro engine as iOS and Android. The example app is [`examples/NotesApp_windows`](https://github.com/StasDoskalenko/NitromelonDB/tree/master/examples/NotesApp_windows).
You need Node 22.11+, Visual Studio 2022 or 2026 with the C++ and Windows App SDK workloads, and Windows SDK 10.0.26100. If something is missing, run [RNW's dependency script](https://microsoft.github.io/react-native-windows/docs/rnw-dependencies).
1. Install the packages and set up Babel the same way as [React Native (bare)](#react-native-bare).
2. `react-native-nitro-modules` has no Windows project ([nitro#168](https://github.com/mrousavy/nitro/issues/168)), so tell RNW autolinking to skip it. In the app's `react-native.config.js`:
```js
const { windowsAppDependencies } = require('nitromelondb/windows-autolink')
module.exports = {
// ...existing project.windows...
dependencies: windowsAppDependencies(),
}
```
In a monorepo that links NitromelonDB with `file:`, pass `{ root: path.resolve(__dirname, '../path-to-nitromelondb') }`.
3. Build:
```bash
yarn react-native run-windows
```
Only direct debugging works (no Remote JS Debugging). How the Nitro install works on Windows: [`native/windows/README.md`](https://github.com/StasDoskalenko/NitromelonDB/blob/master/native/windows/README.md).
## Node.js and Electron
Install the [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) peer:
```bash
yarn add better-sqlite3
```
On Electron, rebuild it against Electron's Node version. Electron Forge does this for production builds, but not in development:
```bash
npx electron-rebuild -f -w better-sqlite3
```
Electron runs SQLite in the main process. See [Setup — Electron](./Setup.md#electron-sqlite).
## Troubleshooting
Model fields read undefined, or the dev error "decorators aren't working"
In development, NitromelonDB checks the first record of each model class. If a decorated field is also a plain property on the record, that property hides the decorator: reading returns `undefined` and writing doesn't change the database. The error names the fields and the fix:
- **TypeScript or esbuild compiles your models** (`tsc`, `ts-node`, `ts-jest`, `tsx`, Vite without Babel, Bun): with `experimentalDecorators`, set `"useDefineForClassFields": false` in `tsconfig.json`. It's on by default for `ES2022` and newer targets.
- **Babel compiles your models:** use the setup for your platform above. On Expo, don't add a decorators plugin of your own.
Support for the experimental syntax 'decoratorAutoAccessors' isn't currently enabled
A model uses `accessor` (`@text('title') accessor title`), which only standard decorators understand, but the app compiles legacy decorators (the default). Remove `accessor`, or [turn on standard decorators](./Advanced/StandardDecorators.md#standard-decorators).
Yarn or npm fails to install wa-sqlite
Releases after `0.31.0` don't depend on `wa-sqlite` at all: the files the web adapter needs ship inside `nitromelondb`. Upgrading is the simplest fix.
Versions up to `0.30.1-beta.1` pulled `wa-sqlite` from a git URL. Yarn Classic fails on that with `fatal: not a git repository`, and Yarn 4 rejects git dependencies that aren't allowlisted. `0.30.1-beta.2` through `0.31.0` use a tarball URL instead. If you are stuck on an older version, add this to your app's `package.json`:
```json
{
"resolutions": {
"wa-sqlite": "https://codeload.github.com/rhashimoto/wa-sqlite/tar.gz/2bf1c59d89eb6497535a4217bc62fec68a0bb994"
}
}
```
With npm, use `overrides` instead of `resolutions`.
SQLiteAdapter can't create the native database
Check that `react-native-nitro-modules` is installed and that you did a full native rebuild. Also check that the New Architecture is on and that you aren't passing `{ jsi: false }`, which throws on iOS and Android.
use_frameworks! on iOS
Avoid it if you can. If you need it (RN Firebase often does), use `use_frameworks! :linkage => :static`. If the build still fails, [force static libraries](https://github.com/Nozbe/WatermelonDB/issues/1285#issuecomment-1381323060).
Why does Podfile.lock list React-Core, React-jsi, and other React pods under NitromelonDB?
This is normal for any New Architecture native module. Reanimated, Screens, MMKV, and WatermelonDB itself all show the same entries.
The podspec calls React Native's `install_modules_dependencies` helper, which adds the full New Architecture dependency set (`React-Fabric`, `ReactCommon`, `hermes-engine`, and so on). CocoaPods records those links in `Podfile.lock`.
This does **not** mean React is compiled twice (CocoaPods deduplicates pods), and it does not pin a React version (the podspec has no version constraints on `React-*` pods).
## Next steps
➡️ [**Set up the database**](./Setup.md)
---
# Set up your app for WatermelonDB
Make sure you [installed Watermelon](./Installation.md) before proceeding. Coming from `@nozbe/watermelondb`? See [Migrating from WatermelonDB](./Migrating.md).
:::tip With a coding agent
The [`nitromelondb-install`](./AgentSkills.md) agent skill does this page for you, with the recommended options: `npx skills add StasDoskalenko/NitromelonDB --skill nitromelondb-install`, then ask your agent to add NitromelonDB to the app.
:::
## Common
Create `model/schema.js` in your project. You'll need it for [the next step](./Schema.md).
```js
import { appSchema, tableSchema } from 'nitromelondb'
export default appSchema({
version: 1,
tables: [
// We'll add tableSchemas here later
]
})
```
Similarly, create `model/migrations.js`. ([More information about migrations](./Advanced/Migrations.md)):
```js
import { schemaMigrations } from 'nitromelondb/Schema/migrations'
export default schemaMigrations({
migrations: [
// We'll add migration definitions here later
],
})
```
## React Native and Node.js (SQLite)
Now, in your `index.native.js` (React Native) or `index.js` (Node.js):
```js
import { Database } from 'nitromelondb'
import SQLiteAdapter from 'nitromelondb/adapters/sqlite'
import { databaseSeed } from 'nitromelondb/Database/seed'
import schema from './model/schema'
import migrations from './model/migrations'
// import Post from './model/Post' // ⬅️ You'll import your Models here
// First, create the adapter to the underlying database:
const adapter = new SQLiteAdapter({
schema,
// (You might want to comment it out for development purposes -- see Migrations documentation)
migrations,
// (optional database name or file system path)
// dbName: 'myapp',
// Recommended for new apps: faster number comparisons and sorting. Adding it to an existing app?
// Check its raw SQL first -- see Performance
storeWholeNumbersAsIntegers: true,
// (optional, but you should implement this method)
onSetUpError: error => {
// Database failed to load -- offer the user to reload the app or log out.
// To start over, call database.unsafeResetDatabase() (see Migrations: "Database reseting and other edge cases")
}
})
// Then, make a Watermelon database from it!
const database = new Database({
adapter,
modelClasses: [
// Post, // ⬅️ You'll add Models to Watermelon here
],
// Throws if you call a writer from another writer without callWriter() (instead of deadlocking)
// experimentalDetectNestedWriters: __DEV__,
// (optional) seed initial/demo data -- see "Database initialization" below
// seed: databaseSeed({
// steps: [
// {
// schemaVersion: 1,
// run: async (database) => {
// await database.batch(database.get(Post).prepareCreate(post => { post.title = 'Hello' }))
// },
// },
// ],
// }),
})
```
`storeWholeNumbersAsIntegers` stores whole numbers as SQLite `INTEGER` instead of `REAL`, which SQLite compares and sorts faster. Queries built with `Q`, models and sync work the same either way, but raw SQL can see the difference (for example, `/` divides integers as integers). For an existing app, follow [Performance](./Advanced/Performance.md) to check its raw SQL before turning it on; data already stored converts in the background.
## Database initialization
`new Database(...)` returns immediately -- schema setup/migrations (and `seed`, if configured)
finish asynchronously in the background. You don't need to wait for them: every `write()` /
`read()` / `batch()` call, and every direct read (`collection.find()`, `query().fetch()`,
`query().fetchCount()`, `observe*()` / `experimentalSubscribe*()`, etc.) issued on the `database`
or any `Collection` it returns is automatically queued until the database is actually ready, then
runs in the order it was issued. This is what makes the common "just import a `database.js` module
and use it" pattern from older Watermelon apps safe: nothing you do with `database` before it's
ready is lost or run out of order, it just waits its turn.
In development, an access that has to wait logs a warning once per `Database` instance. That's not
an error -- your code still works correctly -- but it usually means something (a module-level
singleton, a reader/writer that fires as soon as the app starts) is touching the database earlier
than intended, which is worth knowing about even when it happens to be harmless.
If you'd rather gate your own UI on readiness explicitly -- e.g. show a splash screen until the
database is usable, instead of letting reads/writes queue silently underneath a screen that
renders as if nothing were pending -- use `database.isReady` / `database.readyPromise`, or, in a
React component, the `useDatabaseReady(database)` hook (`nitromelondb/hooks` or
`nitromelondb/react`):
```jsx
import { useDatabaseReady } from 'nitromelondb/hooks'
function App({ database }) {
const ready = useDatabaseReady(database)
if (!ready) {
return
}
return
}
```
`readyPromise` resolves (and `isReady` becomes `true`) once schema setup/migrations and any
pending `seed` steps have settled. This is optional: as covered above, it's always safe to render
immediately and let queuing do its job -- these exist for when you want a different UX than that.
The `seed` option shown above (built with `databaseSeed()`) populates the database as part of
setup instead of a "seed on first render" pattern in your UI layer -- great for demos, local dev,
and E2E test fixtures, with some real caveats for production use on a database that might already
have data. See [Database seeding](./Advanced/Seeding.md) for the full picture.
## Electron (SQLite)
Electron requires a little extra set up since we have to use IPC between our renderer and main processes to execute queries and return the response. However, if you'd like to use LokiJS instead of SQLite you can skip this section and go to the Web section below.
Let's set things up on the renderer side first.
```js
import RemoteAdapter from 'nitromelondb/adapters/remote'
import { Database } from 'nitromelondb'
import schema from './model/schema'
import migrations from './model/migrations'
const electronAPI = window.electronAPI
const adapter = new RemoteAdapter({
schema,
migrations,
handler: (op, args, callback) => {
electronAPI.handleAdapter({op, args}).then((res) => callback(res[0]))
}
})
const database = new Database({
adapter,
modelClasses: [
// Post, // ⬅️ You'll add Models to Watermelon here
],
})
export default database
```
Whenever Watermelon needs to interact with the database, it will do so through the remote adapter which in turn sends queries to sqlite over the Electron IPC bridge via the handler callback.
Now that our renderer is all set, let's set up the other side in `main.js`:
```js
import SQLiteAdapter from "nitromelondb/adapters/sqlite";
import schema from './model/schema';
import migrations from './model/migrations';
mainWindow.webContents.on('did-finish-load', () => {
const adapter = new SQLiteAdapter({
schema,
migrations
})
async function handleAdapter(_, dispatch) {
return new Promise((res) => {
const { op, args } = dispatch;
adapter[op](...args, (...resp) => res(resp))
})
}
ipcMain.removeHandler('db:handle')
ipcMain.handle('db:handle', handleAdapter)
})
```
Above, we've set up the adapter that will actually interact with our SQLite database. When the renderer sends the `db:handle` event, the handleAdapter callback function will be invoked with the required arguments. It will then return a promise with the data the renderer wants.
Note that we're re-instantiating the adapter inside a `'did-finish-load'` event handler. This ensures the cache maintained in the renderer and main is kept consistent during reloads (manually or due to HMR).
We still need to expose this event to our renderer so in `preload.js` we'll add the following:
```js
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('electronAPI', {
handleAdapter: (...args) => ipcRenderer.invoke('db:handle', ...args)
})
```
## Web (LokiJS)
This set up is suitable for web apps.
```js
import LokiJSAdapter from 'nitromelondb/adapters/lokijs'
const adapter = new LokiJSAdapter({
schema,
// (You might want to comment out migrations for development purposes -- see Migrations documentation)
migrations,
useWebWorker: false,
useIncrementalIndexedDB: true,
// dbName: 'myapp', // optional db name
// --- Optional, but recommended event handlers:
onQuotaExceededError: (error) => {
// Browser ran out of disk space -- offer the user to reload the app or log out
},
onSetUpError: (error) => {
// Database failed to load -- offer the user to reload the app or log out
},
extraIncrementalIDBOptions: {
onDidOverwrite: () => {
// Called when this adapter is forced to overwrite contents of IndexedDB.
// This happens if there's another open tab of the same app that's making changes.
// Try to synchronize the app now, and if user is offline, alert them that if they close this
// tab, some data may be lost
},
onversionchange: () => {
// database was deleted in another browser tab (user logged out), so we must make sure we delete
// it in this tab as well - usually best to just refresh the page
if (checkIfUserIsLoggedIn()) {
window.location.reload()
}
},
}
})
// The rest is the same!
```
* * *
## Next steps
➡️ After Watermelon is installed, [**define your app's schema**](./Schema.md)
---
# Schema
:::tip With a coding agent
The [`nitromelondb-schema`](./AgentSkills.md) agent skill designs the schema from your app's own types, API and stored state, proposes it, and writes the schema, migrations and typed models. It also makes every later change with its migration. `npx skills add StasDoskalenko/NitromelonDB --skill nitromelondb-schema`, then ask your agent to design the database schema, or to add a table.
:::
When using WatermelonDB, you're dealing with **Models** and **Collections**. However, underneath Watermelon sits an **underlying database** (SQLite or LokiJS) which speaks a different language: **tables and columns**. Together, those are called a **database schema** and we must define it first.
## Defining a Schema
Say you want Models `Post`, `Comment` in your app. For each of those Models, you define a table. And for every field of a Model (e.g. name of the blog post, author of the comment) you define a column. For example:
```js
// model/schema.js
import { appSchema, tableSchema } from 'nitromelondb'
export const mySchema = appSchema({
version: 1,
tables: [
tableSchema({
name: 'posts',
columns: [
{ name: 'title', type: 'string' },
{ name: 'subtitle', type: 'string', isOptional: true },
{ name: 'body', type: 'string' },
{ name: 'is_pinned', type: 'boolean' },
]
}),
tableSchema({
name: 'comments',
columns: [
{ name: 'body', type: 'string' },
{ name: 'post_id', type: 'string', isIndexed: true },
]
}),
]
})
```
**Note:** It is database convention to use plural and snake_case names for table names. Column names are also snake_case. So `Post` become `posts` and `createdAt` becomes `created_at`.
### Column types
Columns have one of three types: `string`, `number`, or `boolean`.
Fields of those types will default to `''`, `0`, or `false` respectively, if you create a record with a missing field.
To allow fields to be `null`, mark the column as `isOptional: true`.
### Naming conventions
To add a relation to a table (e.g. `Post` where a `Comment` was published, or author of a comment), add a string column ending with `_id`:
```js
{ name: 'post_id', type: 'string' },
{ name: 'author_id', type: 'string' },
```
Boolean columns should have names starting with `is_`:
```js
{ name: 'is_pinned', type: 'boolean' }
```
Date fields should be `number` (dates are stored as Unix timestamps) and have names ending with `_at`:
```js
{ name: 'last_seen_at', type: 'number', isOptional: true }
```
### Special columns
All tables _automatically_ have a string column `id` (of `string` type) to uniquely identify records -- therefore you cannot declare a column named `id` yourself. (There are also special `_status` and `_changed` columns used for [synchronization](./Sync/Intro.md) - you shouldn't touch them yourself).
You can add special `created_at` / `updated_at` columns to enable [automatic create/update tracking](./Advanced/CreateUpdateTracking.md).
### Modifying Schema
Watermelon cannot automatically detect Schema changes. Therefore, whenever you change the Schema, you must increment its version number (`version:` field).
During early development, this is all you need to do - on app reload, this will cause the database to be cleared completely.
To seamlessly update the schema (without deleting user data), use [Migrations](./Advanced/Migrations.md).
⚠️ Always use Migrations if you already shipped your app.
### Indexing
To enable database indexing, add `isIndexed: true` to a column.
Indexing makes querying by a column faster, at the expense of create/update speed and database size.
For example, if you often query all comments belonging to a post (that is, query comments by its `post_id` column), you should mark the `post_id` column as indexed.
However, if you rarely query all comments by its author, indexing `author_id` is probably not worth it.
In general, most `_id` fields are indexed. Occasionally, `boolean` fields are worth indexing (but it's a "low quality index"). However, you should almost never index date (`_at`) columns or `string` columns. You definitely do not want to index long-form user text.
⚠️ Do not mark all columns as indexed to "make Watermelon faster". Indexing has a real performance cost and should be used only when appropriate.
Adding `isIndexed` to a column that already shipped needs a [migration](./Advanced/Migrations.md) as well, or only fresh installs get the index. The [`nitromelondb-indexes` agent skill](./AgentSkills.md) goes through your queries, proposes the indexes worth having, and writes that migration.
## Advanced
### Unsafe SQL schema
If you want to modify the SQL used to set up the SQLite database, you can pass `unsafeSql` parameter
to `tableSchema` and `appSchema`. This parameter is a function that receives SQL generated by Watermelon,
and you can return whatever you want - so you can append, prepend, replace parts of SQL, or return
your own SQL altogether. When passed to `tableSchema`, it receives SQL generated for just that table,
and when to `appSchema` - the entire schema SQL.
⚠️ Note that SQL generated by WatermelonDB is not considered to be a stable API, so be careful about your transforms as they can break at any time.
```js
appSchema({
...
tables: [
tableSchema({
name: 'tasks',
columns: [...],
unsafeSql: sql => sql.replace(/create table [^)]+\)/, '$& without rowid'),
}),
],
unsafeSql: (sql, kind) => {
// Note that this function is called not just when first setting up the database
// Additionally, when running very large batches, all database indices may be dropped and later
// recreated as an optimization. More kinds may be added in the future.
switch (kind) {
case 'setup':
return `create blabla;${sql}`
case 'create_indices':
case 'drop_indices':
return sql
default:
throw new Error('unexpected unsafeSql kind')
}
},
})
```
* * *
## Next steps
➡️ After you define your schema, go ahead and [**define your Models**](./Model.md)
---
# Model
A **Model** class represents a type of thing in your app. For example, `Post`, `Comment`, `User`.
Before defining a Model, make sure you [defined its schema](./Schema.md).
## Create a Model
Let's define the `Post` model:
```js
// model/Post.js
import { Model } from 'nitromelondb'
export default class Post extends Model {
static table = 'posts'
}
```
Specify the table name for this Model — the same you defined [in the schema](./Schema.md).
Now add the new Model to `Database`:
```js
// index.js
import Post from 'model/Post'
const database = new Database({
// ...
modelClasses: [Post],
})
```
### Associations
Many models relate to one another. A `Post` has many `Comment`s. And every `Comment` belongs to a `Post`. (Every relation is double-sided). Define those associations like so:
```js
class Post extends Model {
static table = 'posts'
static associations = {
comments: { type: 'has_many', foreignKey: 'post_id' },
}
}
class Comment extends Model {
static table = 'comments'
static associations = {
posts: { type: 'belongs_to', key: 'post_id' },
}
}
```
On the "child" side (`comments`) you define a `belongs_to` association, and pass a column name (key) that points to the parent (`post_id` is the ID of the post the comment belongs to).
On the "parent" side (`posts`) you define an equivalent `has_many` association and pass the same column name (⚠️ note that the name here is `foreignKey`).
## Add fields
Next, define the Model's _fields_ (properties). Those correspond to [table columns](./Schema.md) defined earlier in the schema.
```js
import { field, text } from 'nitromelondb/decorators'
class Post extends Model {
static table = 'posts'
static associations = {
comments: { type: 'has_many', foreignKey: 'post_id' },
}
@text('title') title
@text('body') body
@field('is_pinned') isPinned
}
```
Fields are defined using ES6 decorators. Pass **column name** you defined in Schema as the argument to `@field`.
These docs use legacy decorators, the default: on Expo they need no Babel setup. Standard (TC39) decorators work too. With them, declare fields with `accessor` (`@text('title') accessor title`). See [Decorators](./Advanced/StandardDecorators.md).
**Field types**. Fields are guaranteed to be the same type (string/number/boolean) as the column type defined in Schema. If column is marked `isOptional: true`, fields may also be null.
**User text fields**. For fields that contain arbitrary text specified by the user (e.g. names, titles, comment bodies), use `@text` - a simple extension of `@field` that also trims whitespace.
**Note:** Why do I have to type the field/column name twice? The database convention is to use `snake_case` for names, and the JavaScript convention is to use camelCase. So for any multi-word name, the two differ. Also, for resiliency, we believe it's better to be explicit, because over time, you might want to refactor how you name your JavaScript field names, but column names must stay the same for backward compatibility.
### Date fields
For date fields, use `@date` instead of `@field`. This will return a JavaScript `Date` object (instead of Unix timestamp integer).
```js
import { date } from 'nitromelondb/decorators'
class Post extends Model {
// ...
@date('last_event_at') lastEventAt
}
```
### Derived fields
Use ES6 getters to define model properties that can be calculated based on database fields:
```js
import { field, text } from 'nitromelondb/decorators'
class Post extends Model {
static table = 'posts'
@date('archived_at') archivedAt
get isRecentlyArchived() {
// in the last 7 days
return this.archivedAt &&
this.archivedAt.getTime() > Date.now() - 7 * 24 * 3600 * 1000
}
}
```
### To-one relation fields
To point to a related record, e.g. `Post` a `Comment` belongs to, or author (`User`) of a `Comment`, use `@relation` or `@immutableRelation`:
```js
import { relation, immutableRelation } from 'nitromelondb/decorators'
class Comment extends Model {
// ...
@relation('posts', 'post_id') post
@immutableRelation('users', 'author_id') author
}
```
**➡️ Learn more:** [Relation API](./Relation.md)
### Children (to-many relation fields)
To point to a list of records that belong to this Model, e.g. all `Comment`s that belong to a `Post`, you can define a simple `Query` using `@children`:
```js
import { children } from 'nitromelondb/decorators'
class Post extends Model {
static table = 'posts'
static associations = {
comments: { type: 'has_many', foreignKey: 'post_id' },
}
@children('comments') comments
}
```
Pass the _table name_ of the related records as an argument to `@children`. The resulting property will be a `Query` you can fetch, observe, or count.
**Note:** You must define a `has_many` association in `static associations` for this to work
**➡️ Learn more:** [Queries](./Query.md)
### Custom Queries
In addition to `@children`, you can define custom Queries or extend existing ones, for example:
```js
import { children } from 'nitromelondb/decorators'
import { Q } from 'nitromelondb'
class Post extends Model {
static table = 'posts'
static associations = {
comments: { type: 'has_many', foreignKey: 'post_id' },
}
@children('comments') comments
@lazy get verifiedComments() {
return this.comments.extend(Q.where('is_verified', true))
}
}
```
**➡️ Learn more:** [Queries](./Query.md)
### Writer methods
Define **writers** to simplify creating and updating records, for example:
```js
import { writer } from 'nitromelondb/decorators'
class Comment extends Model {
static table = 'comments'
@field('is_spam') isSpam
@writer async markAsSpam() {
await this.update(comment => {
comment.isSpam = true
})
}
}
```
Methods must be marked as `@writer` to be able to modify the database.
**➡️ Learn more:** [Writers](./Writers.md)
For logic that belongs to the whole table rather than one record (creating records, bulk updates,
named queries), you can give the table its own `Collection` subclass.
**➡️ Learn more:** [Custom Collections](./Advanced/CustomCollections.md)
## TypeScript: `!` and keeping fields in sync with the schema
In TypeScript, model fields need `!` (`@text('title') title!: string`). With `strictPropertyInitialization` on, TypeScript requires every field to be set in the constructor, and it can't tell that a decorator provides the value. That's true with both standard and legacy decorators, and decorated `declare` fields aren't allowed. So `!` is expected, not a smell.
`!` only says "something sets this". The type after it is a promise you make, and **nothing checks it against the schema**. When the two drift apart, TypeScript stays quiet and the bug shows up at runtime:
| Drift | Example | What happens |
| --- | --- | --- |
| An optional column typed without `null` | `subtitle` is `isOptional: true`, but the field is `subtitle!: string` | Reads return `null` typed as `string`, so `subtitle.trim()` crashes later, far from the cause. |
| The wrong type | `rank` is a `number` column, but the field is typed `string` | Reads return a number. **Writes of the wrong type are silently stored as the column's empty value** (`''`, `0`, `false`, or `null` if optional), with no error. |
| A misspelled column name | `@field('positon')` instead of `'position'` | Reads return `undefined`. Writes throw `Cannot destructure property 'type' of 'columnSchema' as it is undefined.` |
| A column renamed or removed in a migration, but not in the model | the schema drops `legacy_flag`, and the model still has `@field('legacy_flag')` | Same as a misspelled name. |
| A missing decorator | `title!: string` with no `@text(...)` | The field is always `undefined`, and nothing reads or writes the database. |
Until NitromelonDB can check this for you, keep models and the schema in step by hand:
- **Write optional columns as `T | null`**: `@text('subtitle') subtitle!: string | null`. A column that isn't optional is never `null`. Missing values become `''`, `0` or `false`.
- **Match the decorator's type**: `@field` on a `string`, `number` or `boolean` column, `@text` on `string`, `@date` on a `number` column (typed `Date`, or `Date | null` if optional), `@json` gets its sanitizer's return type, `@relation` / `@immutableRelation` give `Relation`, and `@children` gives `Query`.
- **Update models in the same change as a migration** that renames, removes or retypes a column.
- **Have one test per model that writes and reads back every field.** It catches misspelled columns (the write throws), wrong types (the value comes back as `''`, `0` or `false`) and missing decorators (the value comes back `undefined`).
Checking field types against the schema at compile time is planned: [plans/typed-schema-fields.md](https://github.com/StasDoskalenko/NitromelonDB/blob/master/plans/typed-schema-fields.md).
## Advanced fields
You can also use these decorators:
- `@json` for complex serialized data
- `@readonly` to make the field read-only
- `@nochange` to disallow changes to the field _after the first creation_
And you can make observable compound properties using RxJS...
**➡️ Learn more:** [Advanced fields](./Advanced/AdvancedFields.md)
* * *
## Next steps
➡️ After you define some Models, learn the [**Create / Read / Update / Delete API**](./CRUD.md)
---
# Migrations
**Schema migrations** is the mechanism by which you can add new tables and columns to the database in a backward-compatible way.
Without migrations, if a user of your app upgrades from one version to another, their local database will be cleared at launch, and they will lose all their data.
⚠️ Always use migrations!
## Migrations setup
1. Add a new file for migrations:
```js
// app/model/migrations.js
import { schemaMigrations } from 'nitromelondb/Schema/migrations'
export default schemaMigrations({
migrations: [
// We'll add migration definitions here later
],
})
```
2. Hook up migrations to the Database adapter setup:
```js
// index.js
import migrations from 'model/migrations'
const adapter = new SQLiteAdapter({
schema: mySchema,
migrations,
})
```
## Migrations workflow
When you make schema changes when you use migrations, be sure to do this in this specific order, to minimize the likelihood of making an error.
### Step 1: Add a new migration
First, define the migration - that is, define the **change** that occurs between two versions of schema (such as adding a new table, or a new table column).
Don't change the schema file yet!
```js
// app/model/migrations.js
import { schemaMigrations, createTable } from 'nitromelondb/Schema/migrations'
export default schemaMigrations({
migrations: [
{
// ⚠️ Set this to a number one larger than the current schema version
toVersion: 2,
steps: [
// See "Migrations API" for more details
createTable({
name: 'comments',
columns: [
{ name: 'post_id', type: 'string', isIndexed: true },
{ name: 'body', type: 'string' },
],
}),
],
},
],
})
```
Refresh your simulator/browser. You should see this error:
> Migrations can't be newer than schema. Schema is version 1 and migrations cover range from 1 to 2
If so, good, move to the next step!
But you might also see an error like "Missing table name in schema", which means you made an error in defining migrations. See ["Migrations API" below](#migrations-api) for details.
### Step 2: Make matching changes in schema
Now it's time to make the actual changes to the schema file — add the same tables or columns as in your migration definition
⚠️ Please double and triple check that your changes to schema match exactly the change you defined in the migration. Otherwise you risk that the app will work when the user migrates, but will fail if it's a fresh install — or vice versa.
⚠️ Don't change the schema version yet
```js
// model/schema.js
export default appSchema({
version: 1,
tables: [
// This is our new table!
tableSchema({
name: 'comments',
columns: [
{ name: 'post_id', type: 'string', isIndexed: true },
{ name: 'body', type: 'string' },
],
}),
// ...
]
})
```
Refresh the simulator. You should again see the same "Migrations can't be newer than schema" error. If you see a different error, you made a syntax error.
### Step 3: Bump schema version
Now that we made matching changes in the schema (source of truth about tables and columns) and migrations (the change in tables and columns), it's time to commit the change by bumping the version:
```js
// model/schema.js
export default appSchema({
version: 2,
tables: [
// ...
]
})
```
If you refresh again, your app should show up without issues — but now you can use the new tables/columns
### Step 4: Test your migrations
Before shipping a new version of the app, please check that your database changes are all compatible:
1. Migrations test: Install the previous version of your app, then update to the version you're about to ship, and make sure it still works
2. Fresh schema install test: Remove the app, and then install the _new_ version of the app, and make sure it works
### Why is this order important
It's simply because React Native simulator (and often React web projects) are configured to automatically refresh when you save a file. You don't want the database to accidentally migrate (upgrade) with changes that have a mistake, or changes you haven't yet completed making. By making migrations first, and bumping version last, you can double check you haven't made a mistake.
## Migrations API
Each migration must migrate to a version one above the previous migration, and have multiple _steps_ (such as adding a new table, or new columns). Larger example:
```js
schemaMigrations({
migrations: [
{
toVersion: 3,
steps: [
createTable({
name: 'comments',
columns: [
{ name: 'post_id', type: 'string', isIndexed: true },
{ name: 'body', type: 'string' },
],
}),
addColumns({
table: 'posts',
columns: [
{ name: 'subtitle', type: 'string', isOptional: true },
{ name: 'is_pinned', type: 'boolean' },
],
}),
],
},
{
toVersion: 2,
steps: [
// ...
],
},
],
})
```
### Migration steps:
- `createTable({ name: 'table_name', columns: [ ... ] })` - same API as `tableSchema()`
- `addColumns({ table: 'table_name', columns: [ ... ] })` - you can add one or multiple columns to an existing table. The columns table has the same format as in schema definitions
- `destroyTable({ table: 'table_name' })` - destroys a table and all its records. Remove the table from the schema as well. On SQLite, this is `DROP TABLE IF EXISTS`, so it's safe to use for a table that was already dropped with `unsafeExecuteSql`. On LokiJS, the collection's data is also deleted from IndexedDB, so it's no longer loaded on launch (this works even if the collection is already gone from Loki), and a table with the same name can be created again, as on SQLite
- Other types of migrations (e.g. renaming tables, or deleting and renaming columns) are not yet implemented. See [`migrations/index.ts`](https://github.com/StasDoskalenko/NitromelonDB/blob/master/src/Schema/migrations/index.ts). Please contribute!
## Database reseting and other edge cases
1. When you're **not** using migrations, the database will reset (delete all its contents) whenever you change the schema version.
2. If the migration fails, the database will fail to initialize, and will roll back to previous version. This is unlikely, but could happen if you, for example, create a migration that tries to create the same table twice. The reason why the database will fail instead of reset is to avoid losing user data (also it's less confusing in development). You can notice the problem, fix the migration, and ship it again without data loss.
3. When database in the running app has *newer* database version than the schema version defined in code, the database will reset (clear its contents). This is useful in development.
4. If there's no available migrations path (e.g. user has app with database version 4, but oldest migration is from version 10 to 11), the database will reset.
5. A database with **tables but no schema version** (`PRAGMA user_version` is 0) fails to initialize instead of being reset. Version 0 normally means a new, empty database, so a file that has tables anyway wasn't set up by NitromelonDB, or its header is damaged. Setting it up from scratch would silently delete those tables, so the adapter reports it through `onSetUpError` and leaves the file alone. To delete them and start over, call `database.unsafeResetDatabase()`: after a failed setup, it sets the database up from scratch, and `readyPromise` and `seed` behave as on a fresh install. Until then, every other database call fails with the setup error.
### Rolling back changes
There's no automatic "rollback" feature in Watermelon. If you make a mistake in migrations during development, roll back in this order:
1. Comment out any changes made to schema.js
2. Comment out any changes made to migrations.js
3. Decrement schema version number (bring back the original number)
After refreshing app, the database should reset to previous state. Now you can correct your mistake and apply changes again (please do it in order described in "Migrations workflow").
### Unsafe SQL migrations
Similar to [Schema](../Schema.md), you can add `unsafeSql` parameter to every migration step to modify or replace SQL generated by WatermelonDB to perform the migration. There is also an `unsafeExecuteSql('some sql;')` step you can use to append extra SQL. Those are ignored with LokiJSAdapter and for the purposes of [migration syncs](../Sync/Intro.md).
To destroy a table, use `destroyTable()` instead of SQL, so that it's destroyed on LokiJS as well.
---
# Relations
A `Relation` object represents one record pointing to another — such as the author (`User`) of a `Comment`, or the `Post` the comment belongs to.
### Defining Relations
There's two steps to defining a relation:
1. A [**table column**](./Schema.md) for the related record's ID
```js
tableSchema({
name: 'comments',
columns: [
// ...
{ name: 'author_id', type: 'string' },
]
}),
```
2. A `@relation` field [defined on a `Model`](./Model.md) class:
```js
import { relation } from 'nitromelondb/decorators'
class Comment extends Model {
// ...
@relation('users', 'author_id') author
}
```
The first argument is the _table name_ of the related record, and the second is the _column name_ with an ID for the related record.
### immutableRelation
If you have a relation that cannot change (for example, a comment can't change its author), use `@immutableRelation` for extra protection and performance:
```js
import { immutableRelation } from 'nitromelondb/decorators'
class Comment extends Model {
// ...
@immutableRelation('posts', 'post_id') post
@immutableRelation('users', 'author_id') author
}
```
## Relation API
In the example above, `comment.author` returns a `Relation` object.
> Remember, WatermelonDB is a lazily-loaded database, so you don't get the related `User` record immediately, only when you explicitly fetch it
### Observing
Most of the time, you [connect Relations to Components](./Components.md) by using `observe()` (the same [as with Queries](./Query.md)):
```js
withObservables(['comment'], ({ comment }) => ({
comment,
author: comment.author, // shortcut syntax for `author: comment.author.observe()`
}))
```
The component will now have an `author` prop containing a `User`, and will re-render both when the user changes (e.g. comment's author changes its name), but also when a new author is assigned to the comment (if that was possible).
### Fetching
To simply get the related record, use `fetch`. You might need it [in a Writer](./Writers.md)
```js
const author = await comment.author.fetch()
// Shortcut syntax:
const author = await comment.author
```
**Note**: If the relation column (in this example, `author_id`) is marked as `isOptional: true`, `fetch()` might return `null`.
### ID
If you only need the ID of a related record (e.g. to use in an URL or for the `key=` React prop), use `id`.
```js
const authorId = comment.author.id
```
### Assigning
Use `set()` to assign a new record to the relation
```js
await database.get('comments').create(comment => {
comment.author.set(someUser)
// ...
})
```
**Note**: you can only do this in the `.create()` or `.update()` block.
You can also use `set id` if you only have the ID for the record to assign
```js
await comment.update(() => {
comment.author.id = userId
})
```
## Advanced relations
### Many-To-Many Relation
If for instance, our app `Post`s can be authored by many `User`s and a user can author many `Post`s. We would create such a relation following these steps:-
1. Create a pivot schema and model that both the `User` model and `Post` model has association to; say `PostAuthor`
2. Create has_many association on both `User` and `Post` pointing to `PostAuthor` Model
3. Create belongs_to association on `PostAuthor` pointing to both `User` and `Post`
4. Retrieve all `Posts` for a user by defining a query that uses the pivot `PostAuthor` to infer the `Post`s that were authored by the User.
```js
import { lazy } from 'nitromelondb/decorators'
class Post extends Model {
static table = 'posts'
static associations = {
post_authors: { type: 'has_many', foreignKey: 'post_id' },
}
@lazy get authors() {
return this.collections
.get('users')
.query(Q.on('post_authors', 'post_id', this.id))
}
}
```
```js
import { immutableRelation } from 'nitromelondb/decorators'
class PostAuthor extends Model {
static table = 'post_authors'
static associations = {
posts: { type: 'belongs_to', key: 'post_id' },
users: { type: 'belongs_to', key: 'user_id' },
}
@immutableRelation('posts', 'post_id') post
@immutableRelation('users', 'user_id') user
}
```
```js
import { lazy } from 'nitromelondb/decorators'
class User extends Model {
static table = 'users'
static associations = {
post_authors: { type: 'has_many', foreignKey: 'user_id' },
}
@lazy get posts() {
return this.collections
.get('posts')
.query(Q.on('post_authors', 'user_id', this.id))
}
}
```
```js
withObservables(['post'], ({ post }) => ({
authors: post.authors,
}))
```
* * *
## Next steps
➡️ Now the last step of this guide: [**understand Writers (and Readers)**](./Writers.md)
---
# Create, Read, Update, Delete
When you have your [Schema](./Schema.md) and [Models](./Model.md) defined, learn how to manipulate them!
## Reading
#### Get a collection
The `Collection` object is how you find, query, and create new records of a given type.
```js
const postsCollection = database.get('posts')
```
Pass the [table name](./Schema.md) as the argument — or, for real typing (a table name is just a `string` underneath, so passing one infers nothing, and a typo'd name only fails at runtime, as `null`), pass the Model class instead:
```js
const postsCollection = database.get(Post)
```
Both do the same thing; `database.get(Post)` just gives you back a properly-typed `Collection` instead of `Collection`.
#### Find a record (by ID)
```js
const postId = 'abcdefgh'
const post = await database.get('posts').find(postId)
```
`find()` returns a Promise. If the record cannot be found, the Promise will be rejected.
#### Query records
Find a list of records matching given conditions by making a Query and then fetching it:
```js
const allPosts = await database.get('posts').query().fetch()
const numberOfStarredPosts = await database.get('posts').query(
Q.where('is_starred', true)
).fetchCount()
```
**➡️ Learn more:** [Queries](./Query.md)
## Modifying the database
All modifications to the database (like creating, updating, deleting records) must be done **in a Writer**, either by wrapping your work in `database.write()`:
```js
await database.write(async () => {
const someComment = await database.get('comments').find(commentId)
await someComment.update((comment) => {
comment.isSpam = true
})
})
```
Or by defining a `@writer` method on a Model:
```js
import { writer } from 'nitromelondb/decorators'
class Comment extends Model {
// (...)
@writer async markAsSpam() {
await this.update(comment => {
comment.isSpam = true
})
}
}
```
**➡️ Learn more:** [Writers](./Writers.md)
### Create a new record
```js
const newPost = await database.get('posts').create(post => {
post.title = 'New post'
post.body = 'Lorem ipsum...'
})
```
`.create()` takes a "builder function". In the example above, the builder will get a `Post` object as an argument. Use this object to set values for [fields you defined](./Model.md).
**Note:** Always `await` the Promise returned by `create` before you access the created record.
**Note:** You can only set fields inside `create()` or `update()` builder functions.
### Update a record
```js
await somePost.update(post => {
post.title = 'Updated title'
})
```
Like creating, updating takes a builder function, where you can use field setters.
**Note:** Always `await` the Promise returned by `update` before you access the modified record.
### Delete a record
There are two ways of deleting records: syncable (mark as deleted), and permanent.
If you only use Watermelon as a local database, destroy records permanently, if you [synchronize](./Sync/Intro.md), mark as deleted instead.
```js
await somePost.markAsDeleted() // syncable
await somePost.destroyPermanently() // permanent
```
**Note:** Do not access, update, or observe records after they're deleted.
### Delete all records matching a query
To delete more than one record at once — including clearing a whole table — use `Query.markAllAsDeleted()` / `Query.destroyAllPermanently()` instead of fetching and deleting records one by one, or dropping down to [raw SQL](#advanced-unsafe-raw-execute). It resolves and deletes matching records in a single operation, and — unlike raw SQL — keeps the in-memory record cache and any active observers correctly up to date.
```js
import { Q } from 'nitromelondb'
await database.write(async () => {
// clear a whole table
await database.get('posts').query().destroyAllPermanently()
// delete only what matches a condition
await database.get('posts').query(Q.where('is_draft', true)).destroyAllPermanently()
// delete everything except a few ids
await database.get('posts').query(Q.where('id', Q.notIn(idsToKeep))).destroyAllPermanently()
})
```
Use `markAllAsDeleted()` instead if you [synchronize](./Sync/Intro.md).
## Advanced
- `Model.observe()` - usually you only use this [when connecting records to components](./Components.md), but you can manually observe a record outside of React components. The returned [RxJS](https://github.com/reactivex/rxjs) `Observable` will emit the record immediately upon subscription, and then every time the record is updated. If the record is deleted, the Observable will complete.
- `Query.observe()`, `Relation.observe()` — analagous to the above, but for [Queries](./Query.md) and [Relations](./Relation.md)
- `Query.observeWithColumns()` - used for [sorted lists](./Components.md)
- `Collection.findAndObserve(id)` — same as using `.find(id)` and then calling `record.observe()`
- `Collection.findAndObserveOrNull(id)` — like `findAndObserve`, but emits `null` instead of erroring when the record doesn't exist, picks the record up if it's created later, and emits `null` (instead of completing) when it's deleted
- `Model.prepareUpdate()`, `Collection.prepareCreate`, `Database.batch` — used for [batch updates](./Writers.md)
- `Database.unsafeResetDatabase()` destroys the whole database - [be sure to see this comment before using it](https://github.com/Nozbe/WatermelonDB/blob/22188ee5b6e3af08e48e8af52d14e0d90db72925/src/Database/index.js#L131)
- To override the `record.id` during creation (for example to match a server id), assign `record.id` inside `create()` / `prepareCreate()`. The id must be a non-empty string. Assigning `id` after create throws.
```js
await database.get('posts').create(post => {
post.id = serverId
})
```
`post._raw.id = serverId` and `collection.prepareCreateFromDirtyRaw({ id: serverId, ... })` still work.
### Logging out / switching users
Most apps use a single, long-lived `Database` instance and, on logout, either call `database.unsafeResetDatabase()` or drop down to raw SQL/adapter calls to wipe every table. If you only need to clear specific tables rather than the whole database, prefer [`Query.destroyAllPermanently()`](#delete-all-records-matching-a-query) over raw SQL — it keeps the record cache and observers correct on its own. Either way, **the safe pattern is to make sure nothing is still observing the database when you do this**:
- `unsafeResetDatabase()` clears each `Collection`'s internal record cache, but it does not — and cannot — reach into every `Query`/`Model` observer your app may still be holding onto. Its own doc comment says so explicitly: you must not hold onto records, collections, or other Watermelon objects, and all observers/subscribers should be disposed of first.
- If a subscription genuinely stays alive across the reset (a persistent top-level component, a memoized `Query` held in module scope, a `useQuery`/`useRecord`/`withObservables`-connected component that didn't unmount), NitromelonDB now detects and self-heals it: any `Query` cache (`.observe()`, `.observeWithColumns()`, `.observeCount()`, and their Rx-free `experimentalSubscribe*()` equivalents) that's still actively subscribed when `unsafeResetDatabase()` runs is invalidated and immediately refetched, so the subscriber gets fresh (post-reset) data instead of being frozen on the previous user's data forever. This is a safety net for a real app bug, not a substitute for tearing subscriptions down properly — until that invalidation runs, the still-mounted component *will* briefly render the old user's data.
- **If you wipe tables yourself instead of calling `unsafeResetDatabase()`** (raw SQL, `unsafeExecute`, or anything else that bypasses `database.write()`/`collection.create()`/etc.), that automatic invalidation doesn't run — nothing told NitromelonDB the data changed. Call `database.resetObservablesCache()` (or the narrower `collection.resetObservablesCache()`, for just one table) yourself right after, inside the same writer. Any singleton/long-lived observer watching that data — including ones that are correctly still subscribed across your logout/login — picks up the new (or new user's) data instead of breaking or going stale.
- The cleanest option, if your app's shape allows it, is to give each logged-in session its own `Database` instance (e.g. a fresh SQLite file per user, or just `new Database(...)`) instead of resetting a shared one — there's no cache to invalidate if nothing outlives the session in the first place.
### Advanced: Unsafe raw execute
⚠️ Do not use this if you don't know what you're doing...
There is an escape hatch to drop down from WatermelonDB to underlying database level to execute arbitrary commands. Use as a last resort tool. **Watermelon's Query/Model observers don't see these changes on their own** — follow up with `database.resetObservablesCache()` (see [logging out / switching users](#logging-out--switching-users)) if anything might be observing the tables you touched:
```js
await database.write(() => {
// sqlite:
await database.adapter.unsafeExecute({
sqls: [
// [sql_query, [placeholder arguments, ...]]
['create table temporary_test (id, foo, bar)', []],
['insert into temporary_test (id, foo, bar) values (?, ?, ?)', ['t1', true, 3.14]],
]
})
// lokijs:
await database.adapter.unsafeExecute({
loki: loki => {
loki.addCollection('temporary_test', { unique: ['id'], indices: [], disableMeta: true })
loki.getCollection('temporary_test').insert({ id: 't1', foo: true, bar: 3.14 })
}
})
})
```
* * *
## Next steps
➡️ Now that you can create and update records, [**connect them to React components**](./Components.md)
---
# Connecting Components
After you [define some Models](./Model.md), it's time to connect Watermelon to your app's interface. We're using React in this guide, however WatermelonDB can be used with any UI framework.
**Note:** If you're not familiar with higher-order components, read [React documentation](https://reactjs.org/docs/higher-order-components.html), check out [`recompose`](https://github.com/acdlite/recompose)… or just read the examples below to see it in practice! Prefer hooks over HOCs? See [React Hooks](./Hooks.md) for `useRecord`/`useQuery`/`useObservable` instead.
## Reactive components
Here's a very simple React component rendering a `Comment` record:
```jsx
const Comment = ({ comment }) => (
{comment.body}
)
```
Now we can fetch a comment: `const comment = await commentsCollection.find(id)` and then render it: ``. The only problem is that this is **not reactive**. If the Comment is updated or deleted, the component will not re-render to reflect the changes. (Unless an update is forced manually or the parent component re-renders).
Let's enhance the component to make it _observe_ the `Comment` automatically:
```jsx
import { withObservables } from 'nitromelondb/react'
const enhance = withObservables(['comment'], ({ comment }) => ({
comment // shortcut syntax for `comment: comment.observe()`
}))
const EnhancedComment = enhance(Comment)
export default EnhancedComment
```
Now, if we render ``, it **will** update every time the comment changes.
### Reactive lists
Let's render the whole `Post` with comments:
```jsx
import { withObservables } from 'nitromelondb/react'
import EnhancedComment from 'components/Comment'
const Post = ({ post, comments }) => (
{post.name}
{post.body}
Comments
{comments.map(comment =>
)}
)
const enhance = withObservables(['post'], ({ post }) => ({
post,
comments: post.comments, // Shortcut syntax for `post.comments.observe()`
}))
const EnhancedPost = enhance(Post)
export default EnhancedPost
```
Notice a couple of things:
1. We're starting with a simple non-reactive `Post` component
2. Like before, we enhance it by observing the `Post`. If the post name or body changes, it will re-render.
3. To access comments, we fetch them from the database and observe using `post.comments.observe()` and inject a new prop `comments`. (`post.comments` is a Query created using `@children`).
Note that we can skip `.observe()` and just pass `post.comments` for convenience — `withObservables` will call observe for us
4. By **observing the Query**, the `` component will re-render if a comment is created or deleted
5. However, observing the comments Query will not re-render `` if a comment is _updated_ — we render the `` so that _it_ observes the comment and re-renders if necessary.
### Reactive relations
The `` component we made previously only renders the body of the comment but doesn't say who posted it.
Assume the `Comment` model has a `@relation('users', 'author_id') author` field. Let's render it:
```jsx
const Comment = ({ comment, author }) => (
{comment.body} — by {author.name}
)
const enhance = withObservables(['comment'], ({ comment }) => ({
comment,
author: comment.author, // shortcut syntax for `comment.author.observe()`
}))
const EnhancedComment = enhance(Comment)
```
`comment.author` is a [Relation object](./Relation.md), and we can call `.observe()` on it to fetch the `User` and then observe changes to it. If author's name changes, the component will re-render.
**Note** again that we can also pass `Relation` objects directly for convenience, skipping `.observe()`
### Reactive optional relations
Continuing the above example, if the comment has no author, the `comment.author_id` must be null. If `comment.author_id` has a value, the author record it refers to must be stored in the database, otherwise `withObservables` will throw an error that the record was not found.
### Reactive counters
Let's make a `` component to display on a *list* of Posts, with only a brief summary of the contents and only the number of comments it has:
```jsx
const PostExcerpt = ({ post, commentCount }) => (
{post.name}
{getExcerpt(post.body)}
{commentCount} comments
)
const enhance = withObservables(['post'], ({ post }) => ({
post,
commentCount: post.comments.observeCount()
}))
const EnhancedPostExcerpt = enhance(PostExcerpt)
```
This is very similar to normal ``. We take the `Query` for post's comments, but instead of observing the _list_ of comments, we call `observeCount()`. This is far more efficient. And as always, if a new comment is posted, or one is deleted, the component will re-render with the updated count.
## Hey, what about React Hooks?
We get it — HOCs are so 2017, and Hooks are the future! And we agree — NitromelonDB now ships official hooks: `useRecord`, `useQuery`, and `useObservable`. See **[React Hooks](./Hooks.md)**.
(Upstream WatermelonDB doesn't have these — this was an [open discussion](https://github.com/Nozbe/withObservables/issues/16) there for years, and the community's own attempts at a `useObservable` hook ran into a real gotcha: records are mutated in place, so naively holding one in React state stops re-rendering after the first change, since the reference never differs. `useRecord`/`useQuery` are built directly on this library's Rx-free `experimentalSubscribe*` methods and avoid it properly — no cloning workarounds needed. See [React Hooks](./Hooks.md) for why.)
## Understanding `withObservables`
Let's unpack this:
```js
withObservables(['post'], ({ post }) => ({
post: post.observe(),
commentCount: post.comments.observeCount()
}))
```
1. Starting from the second argument, `({ post })` are the input props for the component. Here, we receive `post` prop with a `Post` object.
2. These:
```js
({
post: post.observe(),
commentCount: post.comments.observeCount()
})
```
are the enhanced props we inject. The keys are props' names, and values are `Observable` objects. Here, we override the `post` prop with an observable version, and create a new `commentCount` prop.
3. The first argument: `['post']` is a list of props that trigger observation restart. So if a different `post` is passed, that new post will be observed. If you pass `[]`, the rendered Post will not change. You can pass multiple prop names if any of them should cause observation to re-start. Think of it the same way as the `deps` argument you pass to `useEffect` hook.
4. **Rule of thumb**: If you want to use a prop in the second arg function, pass its name in the first arg array
## Advanced
1. **findAndObserve**. If you have, say, a post ID from your Router (URL in the browser), you can use:
```js
withObservables(['postId'], ({ postId, database }) => ({
post: database.get('posts').findAndObserve(postId)
}))
```
`findAndObserve` errors if the record doesn't exist, which throws from the component. If the record might be missing (deleted, not synced yet), use `findAndObserveOrNull`. It emits `null` instead, then the record if it's created later, and `null` again if it's deleted:
```js
withObservables(['postId'], ({ postId, database }) => ({
post: database.get('posts').findAndObserveOrNull(postId)
}))
```
1. **RxJS transformations**. The values returned by `Model.observe()`, `Query.observe()`, `Relation.observe()` are [RxJS Observables](https://github.com/ReactiveX/rxjs). You can use standard transforms like mapping, filtering, throttling, startWith to change when and how the component is re-rendered.
1. **Custom Observables**. `withObservables` is a general-purpose HOC for Observables, not just Watermelon. You can create new props from any `Observable`.
### Advanced: observing sorted lists
If you have a list that's dynamically sorted (e.g. sort comments by number of likes), use `Query.observeWithColumns` to ensure the list is re-rendered when its order changes:
```jsx
// This is a function that sorts an array of comments according to its `likes` field
// I'm using `ramda` functions for this example, but you can do sorting however you like
const sortComments = sortWith([
descend(prop('likes'))
])
const CommentList = ({ comments }) => (
{sortComments(comments).map(comment =>
)}
)
const enhance = withObservables(['post'], ({ post }) => ({
comments: post.comments.observeWithColumns(['likes'])
}))
const EnhancedCommentList = enhance(CommentList)
```
If you inject `post.comments.observe()` into the component, the list will not re-render to change its order, only if comments are added or removed. Instead, use `query.observeWithColumns()` with an array of [**column names**](./Schema.md) you use for sorting to re-render whenever a record on the list has any of those fields changed.
### Advanced: observing 2nd level relations
If you have 2nd level relations, like author's `Contact` info, and want to connect it to a component as well, you cannot simply use `post.author.contact.observe()` in `withObservables`. Remember, `post.author` is not a `User` object, but a `Relation` that has to be asynchronously fetched.
Before accessing and observing the `Contact` relation, you need to resolve the `author` itself. Here is the simplest way to do it:
```js
import { compose } from 'nitromelondb/react'
const enhance = compose(
withObservables(['post'], ({ post }) => ({
post,
author: post.author,
})),
withObservables(['author'], ({ author }) => ({
contact: author.contact,
})),
)
const EnhancedPost = enhance(PostComponent);
```
If you're not familiar with function composition, read the `enhance` function from top to bottom:
- first, the PostComponent is enhanced by changing the incoming `post` prop into its observable version, and by adding a new `author` prop that will contain the fetched contents of `post.author`
- then, the enhanced component is enhanced once again, by adding a `contact` prop containing the fetched contents of `author.contact`.
#### Alternative method of observing 2nd level relations
If you are familiar with `rxjs`, another way to achieve the same result is using `switchMap` operator:
```js
import { switchMap } from 'rxjs/operators'
const enhance = withObservables(['post'], ({post}) => ({
post: post,
author: post.author,
contact: post.author.observe().pipe(switchMap(author => author.contact.observe()))
}))
const EnhancedPost = enhance(PostComponent)
```
Now `PostComponent` will have `Post`, `Author` and `Contact` props.
#### 2nd level optional relations
If you have an optional relation between `Post` and `Author`, the enhanced component might receive `null` as `author` prop. As you must always return an observable for the `contact` prop, you can use `rxjs`'s `of` function to create a default or empty `Contact` prop:
```js
import { of as of$ } from 'rxjs'
import { withObservables, compose } from 'nitromelondb/react'
const enhance = compose(
withObservables(['post'], ({ post }) => ({
post,
author: post.author,
})),
withObservables(['author'], ({ author }) => ({
contact: author ? author.contact.observe() : of$(null),
})),
)
```
With the `switchMap` approach, you can do:
```js
const enhance = withObservables(['post'], ({post}) => ({
post: post,
author: post.author,
contact: post.author.observe().pipe(
switchMap(author => author ? author.contact : of$(null))
)
}))
```
## Database Provider
To prevent prop drilling you can use the Database Provider and the `withDatabase` Higher-Order Component.
```jsx
import { DatabaseProvider } from 'nitromelondb/react'
// ...
const database = new Database({
adapter,
modelClasses: [Blog, Post, Comment],
})
render(
, document.getElementById('application')
)
```
To consume the database in your components you just wrap your component like so:
```jsx
import { withDatabase, compose } from 'nitromelondb/react'
// ...
export default compose(
withDatabase,
withObservables([], ({ database }) => ({
blogs: database.get('blogs').query(),
})),
)(BlogList)
```
The database prop in the `withObservables` Higher-Order Component is provided by the database provider.
### `useDatabase`
You can also consume `Database` object using React Hooks syntax:
```js
import { useDatabase } from 'nitromelondb/react'
const Component = () => {
const database = useDatabase()
}
```
* * *
## Next steps
➡️ Next, learn more about [**custom Queries**](./Query.md)
---
# React Hooks
`withObservables` (see [Connecting Components](./Components.md)) is a higher-order component — a good fit if your components are plain functions of their props and you're happy wrapping them. If you'd rather subscribe from inside a hooks-based component, `nitromelondb/hooks` (or `nitromelondb/react`) ships hooks that cover the same ground for reads (`useRecord`, `useQuery`, `useObservable`), `useWriter`/`useAtomicWriter` for writes, and `useDatabaseReady` for gating your own UI on database readiness.
```js
import {
useRecord,
useQuery,
useObservable,
useWriter,
useAtomicWriter,
useDatabaseReady,
} from 'nitromelondb/hooks'
```
## useQuery — a list of records, and useRecord — a single record
These two are usually used together: fetch the list once, then let each row track its own record.
```jsx
import { Text } from 'react-native'
// post: a Post record -- e.g. passed down from a parent that already has
// one (its own useRecord/useQuery, a route param already fetched, ...).
// PostComments doesn't fetch it itself; it's just given one to work with.
function PostComments({ post }) {
const comments = useQuery(post.comments)
return comments.map((comment) => )
}
// comment: one Comment record from that list -- PostComments passes each
// one down via the `comments.map(...)` above.
function Comment({ comment }) {
const liveComment = useRecord(comment)
return {liveComment.body}
}
```
`PostComments` passes a `Query` (or a `Relation`/`Collection` — anything with `.experimentalSubscribe()`) to `useQuery`, and gets back an array of matching records, re-rendering whenever the *set* changes (a comment is added, removed, or no longer matches). It does **not** re-render when an existing comment's fields change — that's `useRecord`'s job.
Each `Comment` passes its one record to `useRecord`; you get the same record back, and *that* component re-renders every time it changes (or is deleted). This is the point of splitting the two: if only `PostComments` observed the data, editing one comment's `body` would do nothing at all (a plain query doesn't watch fields). Passing `columnNames` to `useQuery` instead (see below) would fix that, but re-renders the *entire list* for a one-record edit. Tracking each record individually with `useRecord` re-renders exactly the one `` that changed.
`comment`/`query` may be `null`/`undefined` in either hook (e.g. an optional relation that hasn't loaded, or a query gated on something not ready yet) — it's passed straight through, no subscription is set up.
### useQuery and field-level changes
If you'd rather have the whole list re-render together on a field change — e.g. because the list itself is sorted by that field — pass `columnNames` to `useQuery` instead of using per-record `useRecord`:
```js
const comments = useQuery(post.comments, ['body'])
```
This mirrors `query.observeWithColumns(['body'])` — now `PostComments` also re-renders if an existing comment's `body` changes, not just when comments are added or removed.
Calling `useQuery(query, columnNames)` from several components with the same query and columns (in any order) shares one underlying subscription rather than each component running its own — see [`Query#observeWithColumns`](./Query.md#advanced-observing).
## useWriter — writing, anchored to a record
`useRecord`/`useQuery`/`useObservable` only cover reads. For writes, `useWriter(model, writer)` gives you back a stable callback that runs `writer` inside `model.database.write()` — the hook-friendly equivalent of a `@writer` method:
```jsx
import Comment from '../models/Comment'
function useAddComment(post) {
// useWriter gives back exactly this pair -- a callback to trigger the
// write, and its live status -- we're just handing it straight through.
const [addComment, status] = useWriter(post, async (post, body) => {
await post.database.get(Comment).create((comment) => {
comment.post.set(post)
comment.body = body
})
})
return [addComment, status]
}
```
`database.get(Comment)` — passing the Model class, not a `'comments'` table-name string — is the preferred form: `TableName` is just `string` underneath, so a raw string infers nothing (you'd get back an untyped `Collection`, and a typo'd table name would only surface at runtime, as `null`). The class is a real, checked value, so `database.get(Comment)` gives you `Collection` for real, with no risk of stringly-typed drift.
`writer`'s first argument is `post`, typed as whatever concrete `Model` subclass you passed in (not a generic `Model`) — and it isn't limited to writing `post` itself; it runs inside one Writer, so it can freely touch other records/tables too, as above.
That's the whole point of `useWriter`: it's a building block for your *own* purpose-built hooks like `useAddComment`, not something you're expected to inline directly into a component with the relation-setting/table-name details spelled out at every call site. The component itself only ever sees the small, domain-shaped surface:
```jsx
import { useState } from 'react'
import { TextInput, Button, Text } from 'react-native'
function AddComment({ post }) {
const [body, setBody] = useState('')
const [addComment, { isPending, error }] = useAddComment(post)
return (
<>