Versions and migrations
A stored program outlives its first text. The rows in the store were written by one version of the program, and the next version has to read them. In most stacks that is a migration script written by hand beside the code, and nothing checks that the two agree. In Vishy the program says which version it is, and the compiler carries the store from one version to the next: it derives what it can, and it refuses to guess the rest.
version N; on day one
version 1; at the top of a program numbers the shape of what it stores. A program without the line runs, and is stored as version 0; the first change to a stored row is then refused until a version exists. Here the day-one program had no version line, and the next one adds a field renewed: bool to Loan. vishy check new.vish --from=old.vish checks a program against the one that wrote the store:
book/refusals/migrate/versions-noversion.vish:1:1: error: the new program declares no `version N;` (needed to migrate a store)
row Loan { id: id, holder: id, days: int(1, 28), note: string(32), renewed: bool }
^
The development door refuses the same save with the same sentence. The fix is one line, so the advice is to write it before there is anything to fix: with version 1; from the first day, every later change to a stored shape is a number going up, and the store records which program wrote it.
Version 2
The shelf’s first version stored a loan’s length in days, kept a free-text note, and had a Desk unit:
version 1;
row Loan { id: id, holder: id, days: int(1, 28), note: string(32) }
unit Loans {
caps storage;
state loans: Loan[8];
contract lend(loan: id, holder: id, days: int(1, 28)) => Lent: loans += [Loan { id: loan, holder, days, note: "" }];
}
unit Desk {
caps storage;
state open: bool = false;
contract open_day() => Opened: open := true;
}
flow lend(loan: id, holder: id, days: int(1, 28)) atomic { Loans.lend(loan, holder, days); }
flow open_day() atomic { Desk.open_day(); }
The second counts weeks, drops the note, adds renewed, and has no desk:
version 2;
row Loan { id: id, holder: id, weeks: int(1, 4), renewed: bool }
migrate Loan from 1 { weeks = (old.days + 6) / 7; drop note; }
examples {
{ id: 1, holder: 7, days: 10, note: "blue cover" } => { id: 1, holder: 7, weeks: 2, renewed: false };
{ id: 2, holder: 8, days: 28, note: "" } => { id: 2, holder: 8, weeks: 4, renewed: false };
}
migrate drop Desk;
unit Loans {
caps storage;
state loans: Loan[8];
contract lend(loan: id, holder: id, weeks: int(1, 4)) => Lent: loans += [Loan { id: loan, holder, weeks, renewed: false }];
}
flow lend(loan: id, holder: id, weeks: int(1, 4)) atomic { Loans.lend(loan, holder, weeks); }
migrate Loan from 1 { … } says how a version 1 row becomes a version 2 row. Inside it, old is the row as version 1 declared it, typed against that declaration, so old.days is checked and old.weeks would be an error. weeks = (old.days + 6) / 7; computes the new field. drop note; says out loud that the notes are being thrown away. renewed needs no line: a field added with a default (false, 0, the empty string, none, an enum’s first variant) is derived. So are a widened bound, a new table and a new stored scalar. migrate drop Desk; allows the tables of a unit that no longer exists to be dropped.
The examples give an old row and the row it must become, and they run when the program is checked:
migrate: 3 step(s) from version 1 to 2; 2 example(s) passed; unit Desk dropped
ok: 1 row(s), 0 event(s), 0 fn(s), 1 unit(s), 1 contract(s), 1 flow(s)
The steps are the loans table rebuilt (computing weeks, leaving out note, filling renewed) and the desk’s tables dropped. The verifier does not sample migrations; these examples are their test.
Data loss is never derived
Remove a field and say nothing, and the check stops:
book/refusals/migrate/versions-dataloss.vish:1:1: error: `Loan.note` is gone and its data would be dropped; say so with `migrate Loan from 1 { drop note; }`
version 2;
^
The message names the line that would make the loss a decision. When a field of the same type appears beside the one that vanished, the compiler cannot tell a rename from a loss, and says both lines; the anti-patterns chapter shows that case. Either way nothing is lost that no line of the program asked to lose, which is the second removal applied to the store: nothing changes that the program did not name.
A store carried once
vishy service new.vish out --from=old.vish writes a service whose migrate carries a store at the old version forward exactly once. With the version 1 service and the version 2 service built side by side, on 30 Sept 2026:
$ v1/service shelf.db acme migrate
migrated (fresh store at version 1)
$ v1/service shelf.db acme lend 1 7 10
Lent
$ v1/service shelf.db acme lend 2 8 28
Lent
$ v2/service shelf.db acme migrate
migrated from version 1 to 2
$ v2/service shelf.db acme dump
World { loans: Loans { loans: [Loan { id: 1, holder: 7, weeks: 2, renewed: false }, Loan { id: 2, holder: 8, weeks: 4, renewed: false }] }, … }
$ v2/service shelf.db acme migrate
migrated (store already at version 2)
$ v1/service shelf.db acme migrate
migrate: API misuse: `the store is at version 2, this service is version 1; generate it with `vishy service … --from=<the version 2 program>` to migrate`
The rows came across as the examples promised, running migrate again changed nothing, and the old binary refused the newer store with both versions named. The development door does the same in place, for its one tenant, when a changed program is saved; in the storage chapter’s session, saving the shelf with version 1; and a new field reloaded with the loans carried across and "renewed":false on each.
What a per-row expression cannot say is not covered, by design: tables Vishy did not create, a migration that needs another table or data from outside, a backfill in batches while the service keeps answering. Those are written by hand, as an effect or a script. A recomputed field rebuilds the whole table, so on a very large one run it in a quiet window.
Clients: vishy diff
version N; numbers the store. Callers see a different number: the version in a package’s package.vishy, which the chapter on many files introduces. vishy diff old new compares the two packages’ public surfaces, classifies the change, and refuses a version number that does not cover it. The storage chapter’s shelf as a package at 1.0.0, then with a view added, then with lend narrowed to days: int(1, 14), both at 1.1.0:
$ vishy diff shelf-1.0 shelf-1.1
+ view shelf::loans_of(holder: id)
Minor change: shelf 1.0.0 -> 1.1.0
version covers the change
$ vishy diff shelf-1.0 shelf-1.1-weeks
- flow shelf::lend(loan: id, days: int(1,28)) uses caller
+ flow shelf::lend(loan: id, days: int(1,14)) uses caller
Major change: shelf 1.0.0 -> 1.1.0
error: the interface change is Major but the version went 1.0.0 -> 1.1.0; a major bump is required
A new view breaks no caller. A narrower bound breaks every caller that sends 20, so it is major. Regenerate the clients with vishy client whenever the answer is not a patch.
Try
In versions-shelf.vish, change the first example’s weeks: 2 to weeks: 1 and check it with --from. The check stops with migrate Loan from 1 example 1: expected Loan { id: 1, holder: 7, weeks: 1, renewed: false }, got Loan { id: 1, holder: 7, weeks: 2, renewed: false }: ten days is two weeks by the rule that was written, and the example is what caught the disagreement before any store did.