@13w/miri
Version:
MongoDB patch manager
289 lines (203 loc) • 9.87 kB
Markdown
# Miri
A MongoDB migration and patch manager with SSH tunneling support. Manages database schema changes through versioned migration scripts, initial setup scripts, and index definitions.
Published as `@13w/miri` on npm.
## Requirements
- Node.js >= 18
## Installation
```bash
npm install -g @13w/miri
```
## Quick Start
1. Create a `.mirirc` file in your project root (see [Configuration](#configuration))
2. Create a `migrations/` directory with your migration scripts (see [Migration Structure](#migration-structure))
3. Run `miri status` to see pending migrations
4. Run `miri sync` to apply all pending migrations
## Configuration
Miri reads a `.mirirc` JSON file from the current working directory. All settings can also be overridden via CLI flags.
### Minimal `.mirirc`
```json
{
"db": "mongodb://127.0.0.1:27017/MyDatabase",
"migrations": "migrations"
}
```
### Full `.mirirc` with environments
```json
{
"db": "mongodb://127.0.0.1:27017/MyDatabase",
"migrations": "migrations",
"sshUser": "ubuntu",
"environments": {
"dev": {
"sshProfile": "my-dev-bastion"
},
"staging": {
"sshProfile": "my-staging-bastion"
},
"production": {
"sshHost": "bastion.example.com",
"sshUser": "deploy",
"sshKey": "~/.ssh/prod_key"
}
}
}
```
### Configuration Options
| Key | CLI Flag | Default | Description |
|-----|----------|---------|-------------|
| `db` | `-d, --db <uri>` | `mongodb://localhost:27017/test` | MongoDB connection URI. The hostname and port are used as the SSH tunnel destination when tunneling is active. |
| `migrations` | `-m, --migrations <folder>` | `./migrations` | Path to the migrations directory, relative to the working directory. |
| `directConnection` | `--no-direct-connection` | `true` | Appends `directConnection=true` to the MongoDB URI. Disable this when connecting to a replica set where you want the driver to discover other members. |
| `sshProfile` | `--ssh-profile <profile>` | — | Name of an SSH host entry in `~/.ssh/config`. When set, miri reads `Hostname`, `Port`, `User`, and `IdentityFile` from the matching SSH config block. Individual SSH fields from CLI flags take precedence over what the profile provides. |
| `sshHost` | `--ssh-host <host>` | — | SSH bastion/jump host address. Setting this (or `sshProfile`) activates SSH tunneling. |
| `sshPort` | `--ssh-port <port>` | `22` | SSH port on the bastion host. |
| `sshUser` | `--ssh-user <user>` | — | SSH username. Can be set at the top level of `.mirirc` as a default for all environments. |
| `sshKey` | `--ssh-key <path>` | — | Path to the SSH private key. Supports `~/` expansion. If a `.pub` file is given, miri will look for the corresponding private key. |
| `sshAskPass` | — | `false` | When `true` and the private key is encrypted, miri prompts for the passphrase interactively. |
### Environment Selection
Use `-e <name>` to select a named environment:
```bash
miri -e dev status # Uses the "dev" environment
miri -e production sync # Uses the "production" environment
```
**Resolution order** (last wins):
1. Top-level `.mirirc` fields (`db`, `migrations`, `sshUser`, etc.)
2. Fields from the selected `environments.<name>` block
3. CLI flags
If no `-e` flag is provided, miri looks for an environment named `"default"`. If that doesn't exist, the top-level settings are used directly.
### SSH Tunneling
When an SSH host is configured (via `sshProfile` or `sshHost`), miri creates a local SSH tunnel to the MongoDB host before connecting. The tunnel forwards a random local port to the `hostname:port` extracted from the `db` URI.
**Authentication priority:**
1. **SSH Agent** — If `SSH_AUTH_SOCK` is set and the key is loaded in the agent, the agent is used automatically.
2. **Private key file** — If a key file is configured and not already in the agent, it is read from disk.
3. **Passphrase prompt** — If the private key is encrypted (passphrase-protected), miri prompts for the passphrase on stdin.
## CLI Reference
```
miri [options] [command]
```
### Global Options
| Flag | Description |
|------|-------------|
| `-V, --version` | Output the version number |
| `-e, --env <environment>` | Environment name from `.mirirc` (default: `"default"`) |
| `-m, --migrations <folder>` | Folder with migrations |
| `-d, --db <mongo-uri>` | MongoDB connection URI |
| `--no-direct-connection` | Disable `directConnection` on the MongoDB URI |
| `--ssh-profile <profile>` | Connect via SSH using an `~/.ssh/config` profile |
| `--ssh-host <host>` | SSH proxy host |
| `--ssh-port <port>` | SSH proxy port |
| `--ssh-user <user>` | SSH proxy user |
| `--ssh-key <path>` | SSH proxy identity key |
### Commands
#### `miri status [--all]`
Displays the status of all versioned patches. Use `--all` to include init patches.
Statuses:
- **Ok** — Applied and unchanged
- **New** — Exists locally but not yet applied
- **Updated** — Applied, but `test` or `down` functions have changed (safe to re-sync)
- **Changed** — Applied, but the `up` function has changed (requires revert + reapply)
- **Degraded** — Applied, but `test()` returns > 0 (the migration's postcondition is no longer met)
- **Removed** — In the database but no longer exists locally
#### `miri sync [--degraded] [--all]`
Applies all pending migrations in order: init scripts, then indexes, then versioned patches.
- `--degraded` — Also re-apply patches whose status is Degraded
- `--all` — Re-apply all patches regardless of status
#### `miri init apply [patch] [--no-exec] [--force]`
Runs init scripts from `migrations/init/`. Optionally target a single patch by name.
- `--no-exec` — Mark the patch as applied without executing it
- `--force` — Re-apply even if already recorded as done
#### `miri init remove <patch> [--no-exec]`
Removes an init patch record from the database.
- `--no-exec` — Remove the record without executing the script
#### `miri init status`
Displays the status of init patches only.
#### `miri indexes status [collection] [-q, --quiet]`
Shows the diff between local index definitions and the indexes currently in MongoDB.
- `--quiet` — Only show changes (hide indexes that are already applied)
#### `miri indexes sync [collection]`
Creates new indexes and drops removed indexes. Optionally target a single collection.
#### `miri patch diff`
Displays the diff between local versioned patches and what's applied in the database.
#### `miri patch sync [--remote] [--degraded] [--all]`
Apply versioned patches.
- `--remote` — Remote only
- `--degraded` — Re-apply degraded patches
- `--all` — Re-apply all patches
#### `miri patch apply <group> <patch> [--no-exec]`
Apply a single specific patch by group and name.
#### `miri patch remove <group> <patch> [--no-exec]`
Revert and remove a single patch. Runs the `down()` function then deletes the record.
## Migration Structure
```
migrations/
├── init/ # One-time setup scripts
│ ├── 01-create-collections.js
│ └── 02-seed-data.js
├── indexes/ # Index definitions (JSON)
│ ├── users.json
│ └── goods.json
├── version-1/ # Versioned patch group
│ ├── 01-02-2023-add-full-name.js
│ └── 04-05-2023-add-user-age.js
└── version-2/ # Another patch group
└── 05-08-2023-add-price.js
```
### Init Scripts
Simple scripts executed in a `mongosh` context. They run once and are tracked by name.
```javascript
db.createCollection('users');
db.createCollection('goods');
```
### Versioned Patches
Each patch must export three functions:
```javascript
// test: returns the count of documents that still need migrating
// When this returns 0, the migration is considered fully applied
export const test = () =>
db.users.countDocuments({ fullName: { $exists: false } });
// up: applies the migration
export const up = () =>
db.users.updateMany(
{ fullName: { $exists: false } },
[{ $set: { fullName: { $concat: ['$firstName', ' ', '$lastName'] } } }]
);
// down: reverts the migration
export const down = () =>
db.users.updateMany({}, { $unset: { fullName: 1 } });
```
**Execution flow during sync:**
1. `test()` is called to check if migration is needed
2. If the patch was previously applied but has changed, `down()` is called first to revert
3. `up()` is called to apply the migration
4. The patch content (hashes of test/up/down) is stored in the `migrations` collection
### Index Definitions
JSON files in `migrations/indexes/`, named after the target collection. Each file contains an array of index specifications:
```json
[
{ "name": 1 },
[{ "email": 1 }, { "unique": true }]
]
```
- A plain object defines the index key (e.g., `{ "name": 1 }`)
- An array of `[keySpec, options]` lets you pass index options like `unique`, `sparse`, etc.
### Environment Variables
Migration scripts can access environment variables prefixed with `MIRI_`. Inside scripts, they're available on the `__env` object with the prefix stripped:
```bash
MIRI_ADMIN_EMAIL=admin@example.com miri sync
```
```javascript
// In a migration script:
export const up = () =>
db.users.updateOne(
{ role: 'admin' },
{ $set: { email: __env.ADMIN_EMAIL } }
);
```
## How Miri Tracks State
Miri stores migration state in a `migrations` collection in the target database. Each applied patch is recorded with:
- `group` — The subdirectory name (e.g., `version-1`, `init`)
- `name` — The filename
- `content` — SHA-256 hashes and base64-encoded bodies of `test`, `up`, and `down` functions
This allows miri to detect when a migration script has been modified since it was last applied and report the appropriate status (Updated, Changed).
## License
MIT