Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@ workers:
db: primary # database name in the database worker
dir: ./migrations # folder of YYYYMMDDHHMMSS_slug.sql files
auto: false # true = run migrate::up at startup
types_out: ./db.types.ts # codegen output (optional)
types_out: ./db.types.ts # codegen output (optional); when set,
# types regenerate after each migrate::up
# (opt out with codegen_on_up: false)
```

The `config:` block is a first-boot seed; afterwards settings live in the
Expand Down
16 changes: 16 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@

## Unreleased

- `migrate::create` keeps timestamps monotonic with the files already on
disk: when the latest existing migration is dated ahead of the clock (a
hand-written name), the new file is stamped one second after it instead of
sorting before an applied migration.
- Future-dated migration files are surfaced: `migrate::status` returns a new
`future_dated` list and `migrate::up` logs a warning before applying such
a file (5-minute clock-skew tolerance).
- New `codegen_on_up` configuration: after a `migrate::up` that applied at
least one migration, `migrate::codegen` runs automatically so generated
types never drift from the schema. Enabled by default when `types_out` is
set; codegen failures are logged and never fail the run. `migrate::up` now
returns the written path as `types_path`.
- Agent skill rewritten: triggers on any database/schema work, and states
the hard rules (always scaffold with `migrate::create`, one migration per
logical change, never edit applied files).

- Revert the default log filter introduced in 0.1.2 that silenced
`iii-helpers`' OTel connection module. Those transient pre-connection
ERROR logs originate in `iii-helpers` (out of miiigrate's scope) and
Expand Down
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ config change, restart the worker (or the engine).
| `dir` | `./migrations` | Migration folder, resolved against the worker's working directory. Only `migrate::create` creates it. |
| `auto` | `false` | Run `migrate::up` once at startup. If the `database` worker is not registered yet (no ordering guarantee under docker compose or engine-managed boots), the run is retried with capped backoff for up to 2 minutes. Best-effort: any other failure is logged and the worker stays up, so `migrate::status` remains available for diagnosis. |
| `types_out` | — | Output path for `migrate::codegen`. Optional; without it, codegen requires `out` in the payload. |
| `codegen_on_up` | follows `types_out` | Run `migrate::codegen` after a `migrate::up` that applied at least one migration. Defaults to `true` when `types_out` is set, `false` otherwise; set explicitly to override. Codegen failures are logged and never fail the up run. |
| `dialect` | auto | `postgres` or `sqlite`. By default the dialect is detected from the `database` worker's `database::listDatabases` driver field. |

## Example
Expand Down
27 changes: 23 additions & 4 deletions docs/functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ migrate::create ──▶ write your SQL ──▶ migrate::up ──▶ migrate
Apply every pending migration, in file-name order.

- **Payload**: `{}`
- **Returns**: `{ "applied": ["<file names in order>"], "skipped": <n>, "duration_ms": <n> }`
- **Returns**: `{ "applied": ["<file names in order>"], "skipped": <n>, "duration_ms": <n>, "types_path": "…" }`
— `types_path` is present only when the automatic post-run codegen wrote it
(see `codegen_on_up` in the configuration).

Behaviour:

Expand All @@ -31,6 +33,14 @@ Behaviour:
- Statement splitting is dialect-aware: a `;` inside `'strings'`, `E'…'`
escapes, `"identifiers"`, `$tag$…$tag$` bodies (plpgsql functions), or
nested `/* comments */` never splits a statement.
- When `codegen_on_up` is enabled (default whenever `types_out` is set) and
at least one migration was applied, `migrate::codegen` runs afterwards so
the generated types never drift from the schema. A codegen failure is
logged and reported by the absent `types_path`, never rolled into the run:
the migrations stay applied.
- A pending file whose timestamp prefix is ahead of the wall clock (beyond a
5-minute skew tolerance) is applied but logged as a warning: it is almost
certainly a hand-written name — scaffold with `migrate::create` instead.

## `migrate::status`

Expand All @@ -48,7 +58,8 @@ time, including while `migrate::up` is failing.
"applied": [{ "name": "…", "checksum": "…", "applied_at": "…" }],
"pending": [{ "name": "…", "checksum": "…" }],
"mismatched": [{ "name": "…", "applied_checksum": "…", "file_checksum": "…", "applied_at": "…" }],
"missing": [{ "name": "…", "checksum": "…", "applied_at": "…" }]
"missing": [{ "name": "…", "checksum": "…", "applied_at": "…" }],
"future_dated": ["…"]
}
```

Expand All @@ -57,6 +68,10 @@ time, including while `migrate::up` is failing.
- `mismatched` — applied, but the file content changed since.
`migrate::up` refuses to run while this list is non-empty.
- `missing` — recorded as applied but the file no longer exists on disk.
- `future_dated` — files on disk whose timestamp prefix is ahead of the wall
clock beyond a 5-minute skew tolerance: hand-written names. Harmless once
applied (`migrate::create` keeps new timestamps monotonic with them), but a
pending one should be renamed before apply.

## `migrate::create`

Expand All @@ -66,8 +81,12 @@ migrations directory if it does not exist yet.
- **Payload**: `{ "name": "add_users" }` — slug of `[A-Za-z0-9_-]+`
- **Returns**: `{ "name": "20260719143000_add_users.sql", "path": "./migrations/20260719143000_add_users.sql" }`

The timestamp is the current UTC time. The file starts with a forward-only
header comment; write your SQL below it.
The timestamp is the current UTC time, kept monotonic with the files already
on disk: when the latest existing migration is dated ahead of the clock (a
hand-written name), the new file is stamped one second after it so
lexicographic order — which is the apply order — is preserved. Never write
migration file names by hand; always scaffold here. The file starts with a
forward-only header comment; write your SQL below it.

## `migrate::codegen`

Expand Down
68 changes: 46 additions & 22 deletions skills/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
---
name: miiigrate
description: >-
Apply, track, and scaffold forward-only SQL migrations through the iii
database worker, and generate TypeScript types that match the schema as it
crosses the wire. Reach for it whenever a schema change is needed.
Database schema management for iii projects: forward-only SQL migrations
and generated TypeScript types, driven through the iii database worker.
Use this skill whenever a task touches the database in any way — creating,
altering, or dropping tables, columns, indexes, enums, or constraints;
checking which migrations are applied; or typing rows returned by
database::query. Load it BEFORE writing any SQL or DDL, creating any file
in the migrations directory, or calling the database worker for a schema
change.
---

# miiigrate
Expand All @@ -21,22 +26,39 @@ replicas are safe: batches serialize on `pg_advisory_xact_lock` (Postgres) or
`BEGIN IMMEDIATE` (SQLite), and a replica that loses the race counts the
migration as skipped, not failed.

## Rules

- ALWAYS scaffold with `migrate::create`. NEVER write a migration file name
by hand: the apply order is the lexicographic file-name order, and only
`migrate::create` guarantees unique, monotonic timestamps. A hand-written
timestamp (e.g. a rounded "120000") lands in the future and corrupts the
ordering for every migration that follows.
- One migration per logical change ("add events table", "add user names") —
not one file bundling every change of the session.
- NEVER edit or rename an applied migration file. To undo or amend, create a
new forward migration.
- Apply with `migrate::up`, then check the response: when `types_path` is
present the TypeScript types were already regenerated; when it is absent
and the project has a `types_out`, run `migrate::codegen` yourself.
- Before any schema work, call `migrate::status` and resolve `mismatched`,
`missing`, or `future_dated` entries before adding new migrations.

## When to Use

- The user asks to add, change, or drop a table, column, or index — scaffold
with `migrate::create`, write the SQL, apply with `migrate::up`.
- You need to know the current schema state: what is applied, pending, or has
drifted (`migrate::status`, read-only, safe anytime).
- TypeScript code reads rows from `database::query` and needs types that match
the JSON wire format (`migrate::codegen`).
- A `migrate::up` run failed and you need the failing file and statement index
(`MIGRATION_FAILED` carries both, plus the database worker's error).
- Any request to add, change, or drop a table, column, index, enum, or
constraint — scaffold with `migrate::create`, write the SQL, apply with
`migrate::up`.
- You need the current schema state: applied, pending, drifted, or
future-dated files (`migrate::status`, read-only, safe anytime).
- TypeScript code reads rows from `database::query` and needs types that
match the JSON wire format (`migrate::codegen`).
- A `migrate::up` run failed and you need the failing file and statement
index (`MIGRATION_FAILED` carries both, plus the database worker's error).

## Boundaries

- Not an ORM or schema DSL — migrations are plain SQL you write yourself.
- No down migrations by design. To undo, write a new forward migration.
- Never edit an applied migration file; always create a new one.
- Postgres and SQLite only; MySQL is rejected with `UNSUPPORTED_DIALECT`.
- Dialect-specific SQL is your responsibility (e.g. SQLite cannot
`ALTER COLUMN TYPE`; use the table-recreate pattern inside one migration —
Expand All @@ -47,18 +69,20 @@ migration as skipped, not failed.
## Functions

- `migrate::up` — apply all pending migrations in order; returns
`{ applied, skipped, duration_ms }`. Refuses to run while any applied file
has drifted on disk.
`{ applied, skipped, duration_ms, types_path? }`. Refuses to run while any
applied file has drifted on disk. With `codegen_on_up` (default when
`types_out` is set), regenerates types after a run that applied anything.
- `migrate::status` — read-only report `{ applied, pending, mismatched,
missing }` with names, checksums, and apply dates.
missing, future_dated }` with names, checksums, and apply dates.
- `migrate::create` — payload `{ name: "add_users" }`; creates
`<dir>/<UTC timestamp>_add_users.sql` and returns `{ name, path }`. Write
the SQL into that file before calling `migrate::up`.
`<dir>/<UTC timestamp>_add_users.sql` with a unique, monotonic timestamp
and returns `{ name, path }`. Write the SQL into that file before calling
`migrate::up`.
- `migrate::codegen` — introspect the live schema through `database::query`
and write TypeScript types to the configured `types_out` (or payload
`out`); returns `{ path, tables, enums }`. Regenerate after every applied
migration.
`out`); returns `{ path, tables, enums }`.

Configuration (`db`, `dir`, `auto`, `types_out`, `dialect`) lives in the
`configuration` worker under id `miiigrate` and is read once at startup. For
payload and response schemas, call `get function info` on the function id.
Configuration (`db`, `dir`, `auto`, `types_out`, `codegen_on_up`, `dialect`)
lives in the `configuration` worker under id `miiigrate` and is read once at
startup. For payload and response schemas, call `get function info` on the
function id.
30 changes: 30 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ pub struct WorkerConfig {
/// SQL dialect override. Default: auto-detect via `database::listDatabases`.
#[serde(default)]
pub dialect: Option<Dialect>,

/// Run `migrate::codegen` automatically after a `migrate::up` that
/// applied at least one migration. Default: enabled when `types_out` is
/// set, disabled otherwise. Codegen failures never fail the up run.
#[serde(default)]
pub codegen_on_up: Option<bool>,
}

fn default_db() -> String {
Expand All @@ -88,11 +94,18 @@ impl Default for WorkerConfig {
auto: false,
types_out: None,
dialect: None,
codegen_on_up: None,
}
}
}

impl WorkerConfig {
/// Effective `codegen_on_up`: explicit value wins, otherwise types are
/// regenerated whenever a `types_out` destination is configured.
pub fn codegen_on_up_enabled(&self) -> bool {
self.codegen_on_up.unwrap_or(self.types_out.is_some())
}

pub fn from_yaml(yaml: &str) -> Result<Self, String> {
serde_yml::from_str(yaml).map_err(|e| format!("yaml parse: {e}"))
}
Expand Down Expand Up @@ -147,6 +160,23 @@ mod tests {
assert!(!cfg.auto);
assert!(cfg.types_out.is_none());
assert!(cfg.dialect.is_none());
assert!(cfg.codegen_on_up.is_none());
}

#[test]
fn codegen_on_up_defaults_follow_types_out() {
let mut cfg = WorkerConfig::default();
assert!(!cfg.codegen_on_up_enabled()); // no types_out, no codegen

cfg.types_out = Some("./db.types.ts".into());
assert!(cfg.codegen_on_up_enabled()); // types_out set: on by default

cfg.codegen_on_up = Some(false); // explicit opt-out wins
assert!(!cfg.codegen_on_up_enabled());

cfg.types_out = None;
cfg.codegen_on_up = Some(true); // explicit opt-in without types_out:
assert!(cfg.codegen_on_up_enabled()); // codegen will fail loudly (no out)
}

#[test]
Expand Down
62 changes: 57 additions & 5 deletions src/handlers/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,23 @@ pub async fn handle(state: &AppState, req: CreateReq) -> Result<CreateResp, Migr
message: format!("creating {}: {e}", dir.display()),
})?;

// UTC timestamp; bump by one second on collision (two creates within the
// same second) so names stay unique and ordered.
// UTC timestamp, kept monotonic with the files already on disk: a
// hand-written future-dated migration must not make new names sort
// before it (lexicographic order is the apply order). Then bump by one
// second on collision (two creates within the same second) so names
// stay unique and ordered.
let mut ts = Utc::now();
if let Some(latest) = latest_migration_timestamp(dir) {
let after_latest = latest + chrono::Duration::seconds(1);
if after_latest > ts {
tracing::warn!(
latest = %latest.format("%Y%m%d%H%M%S"),
"latest migration on disk is ahead of the clock (hand-written \
name?); continuing after it to keep ordering monotonic"
);
ts = after_latest;
}
}
let (name, path): (String, PathBuf) = loop {
let name = format!("{}_{slug}.sql", ts.format("%Y%m%d%H%M%S"));
let path = dir.join(&name);
Expand Down Expand Up @@ -75,12 +89,26 @@ pub async fn handle(state: &AppState, req: CreateReq) -> Result<CreateResp, Migr
})
}

/// Largest timestamp among the well-formed migration files in `dir`.
/// Lenient on purpose: unreadable entries and stray files are ignored here —
/// `migrate::up`/`status` are the loud gate for those — so `create` always
/// stays usable for authoring.
fn latest_migration_timestamp(dir: &Path) -> Option<chrono::DateTime<Utc>> {
let entries = std::fs::read_dir(dir).ok()?;
entries
.filter_map(|e| e.ok())
.filter_map(|e| e.file_name().into_string().ok())
.filter(|name| migrations::validate_name(name).is_ok())
.filter_map(|name| migrations::timestamp_of(&name))
.max()
}

#[cfg(test)]
mod tests {
// The handler needs an AppState (IIIClient) so filesystem behavior is
// covered by the slug validation below plus the playground; slug rules
// are the pure part.
use crate::migrations::validate_name;
// covered by the pure helpers below plus the playground.
use super::latest_migration_timestamp;
use crate::migrations::{timestamp_of, validate_name};

#[test]
fn generated_names_validate() {
Expand All @@ -89,4 +117,28 @@ mod tests {
assert!(validate_name(&name).is_ok(), "{name}");
}
}

#[test]
fn latest_timestamp_picks_max_and_ignores_strays() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("20260101000000_a.sql"), "").unwrap();
std::fs::write(tmp.path().join("20991231235959_future.sql"), "").unwrap();
std::fs::write(tmp.path().join("notes.sql"), "").unwrap(); // stray: ignored
std::fs::write(tmp.path().join("README.md"), "").unwrap();

assert_eq!(
latest_migration_timestamp(tmp.path()),
timestamp_of("20991231235959_future.sql")
);
}

#[test]
fn latest_timestamp_is_none_for_empty_or_missing_dir() {
let tmp = tempfile::tempdir().unwrap();
assert_eq!(latest_migration_timestamp(tmp.path()), None);
assert_eq!(
latest_migration_timestamp(std::path::Path::new("/nonexistent/miiigrate")),
None
);
}
}
19 changes: 19 additions & 0 deletions src/handlers/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ pub struct StatusResp {
pub mismatched: Vec<MismatchedEntry>,
/// Recorded as applied but the file no longer exists on disk.
pub missing: Vec<AppliedRow>,
/// Files on disk whose timestamp prefix is ahead of the wall clock
/// (beyond skew tolerance) — almost certainly hand-written names.
/// Harmless once applied (`migrate::create` stays monotonic), but a
/// pending one should be renamed before apply.
pub future_dated: Vec<String>,
}

pub async fn handle(state: &AppState, _req: StatusReq) -> Result<StatusResp, MigrateError> {
Expand All @@ -62,6 +67,19 @@ pub async fn handle(state: &AppState, _req: StatusReq) -> Result<StatusResp, Mig
let mut pending = Vec::new();
let mut mismatched = Vec::new();

let now = chrono::Utc::now();
let future_dated: Vec<String> = files
.iter()
.filter(|f| migrations::is_future_dated(&f.name, now))
.map(|f| f.name.clone())
.collect();
if !future_dated.is_empty() {
tracing::warn!(
count = future_dated.len(),
"migration files are dated in the future (hand-written names?)"
);
}

for file in files {
match applied_by_name.remove(&file.name) {
None => pending.push(PendingEntry {
Expand Down Expand Up @@ -95,5 +113,6 @@ pub async fn handle(state: &AppState, _req: StatusReq) -> Result<StatusResp, Mig
pending,
mismatched,
missing,
future_dated,
})
}
Loading
Loading