# 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
