Summary#
Renaming an entity or a property is not the same kind of change as adding one. To your source it is a small edit; to your database it is a table full of rows whose name just changed out from under it. A removal is the same problem seen from the other side — the property is gone from your code, and its data is not.
Neither is guessed at. A deploy that removes something holding data is refused until you say what you meant in a
.migration — and then it does the safe thing: a rename keeps every row exactly where it is, and a removal is
recorded, so the column it leaves behind has an owner instead of becoming an anonymous leftover.
Signature#
migration "what changed" {
from "ast:<hash>";
to "ast:<hash>";
rename entity Job -> Assignment;
rename property Job.Notes -> Job.Remarks;
drop property Job.Spare;
drop entity Ghost;
drop enum JobStatus.Parked; // one MEMBER
drop enum JobStatus; // the WHOLE type
drop workflow Fulfilment;
}Description#
What you see if you forget#
Deploy a removal with nothing to authorize it and the deploy stops, having changed nothing:
This deploy removes something that holds data, and nothing says you meant to. Removing an entity or a property is
not a change the deploy will make on your behalf, because the data outlives the source: the rows stay in the
database after the declaration is gone. Offending change(s): entity 'Job' removed.
Say what you meant in a migration and deploy again:
drop entity Job; // or: rename entity Job -> <NewName>;The reason it asks rather than guessing is that a removal and a rename look identical from the outside — both are just "this name is gone" — and they want opposite handling. Guess wrong in either direction and you lose data: treat a rename as a removal and every row is stranded in a table your app can no longer name; treat a removal as a rename and an unrelated table's rows are silently adopted.
You do not have to write this by hand#
Ask for it, and the file is generated from the difference between what is deployed and what you have now:
$ osy compile --generate-migrationThe generated file carries only the changes that need your word — the additive ones are simply applied. Where it can
tell a rename from a genuine removal it writes the rename for you and says so:
rename entity Job -> Assignment; // INFERRED: 75% of its properties match by name and type. Verify — if these are
// genuinely different entities, replace this with `drop entity Job;`It only writes a rename when there is one obvious answer. If two properties of the same type left and two arrived,
or a removed entity resembles several new ones equally, it will not guess — it lists the candidates as a comment and
leaves the drop in place for you to correct. That reticence is deliberate: a rename you did not mean adopts an
unrelated table's rows, which is worse than the removal it replaced.
An entity that now derives from a base declaring the members it used to carry is not losing them, so the file
writes a rebase for it — one line for each such entity — and drops only a member the base does not declare:
rebase entity Saga onto WorkItem; // INFERRED: Saga now derives from WorkItem, which declares the members it no
// longer does — its rows become WorkItem's, ids kept
drop property Saga.Legacy;Review the file, fix anything it guessed wrong, and deploy with it:
$ osy compile --migration migrations/9f00abcd.migration --new-version # local
$ osyrin app compile --migration migrations/9f00abcd.migration --new-version # a deployed appWhat a rename actually does — which is nothing, physically#
Nothing is copied, nothing is moved, and no table is renamed. Your app's description of its data is versioned; the data itself is shared. So the new name simply points at the same table:
- the version you just deployed knows the entity as
Assignment; - a run still finishing on an older version knows it as
Job; - both read and write the same rows, at the same time, correctly.
A physical rename would be the wrong tool for exactly that reason — it would break every run still asking for the old name. Renaming a property works the same way and for the same reason.
This holds through repeated renames. Job → Assignment → Task still reads and writes the rows you created on day
one; each version just calls them something different.
What a rename saves you from#
Without one, the change reads as a removal plus an addition, because that is all there is to see. You would get a new,
empty Assignment and every existing row left behind in a Job your app can no longer name. Nothing is deleted — but
nothing is reachable either, and the app comes up looking like it forgot everything. That is the outcome the refusal
exists to stop.
What a removal actually does#
The property disappears from your app immediately — it is gone from the model, from queries, from screens. Its column stays, because a run still finishing on an older version may go on reading and writing it, and that is the whole point of versioning.
What is new is that the removal is recorded. The version you just deployed keeps a note saying "this property existed and stopped here". That record is what later lets the column be reclaimed safely, and it travels forward: two deploys later, the record still says the property died in the version where it actually died.
The practical effect is that a leftover column is never confused with a mystery column. When space is eventually
reclaimed, only columns with a recorded removal are taken. Anything else that turns up on a table — the residue of a
hand-run ALTER, a half-finished change — is reported to you and left exactly where it is. Deleting a column nobody
can account for is not a decision worth making automatically.
Removing a required property#
Its column stops being required, because it has to: the version you just deployed does not know about the property, so it has nothing to put there, and rows created from now on simply leave it empty. A run still pinned to an older version will therefore find that value missing on rows created after the removal. Finish moving your readers and writers over before anything depends on it being filled in.
Removing an enum member is the sharpest case#
An enum member is not stored by its name. A column holds the member's position, so removing one re-numbers every
member after it — and no existing row is rewritten. { Queued, Parked, Done } minus Parked is { Queued, Done },
and every row that said Parked now says Done.
That is worse than the stranding above, and quieter. A dropped property leaves data unreachable, which you notice. A dropped enum member leaves data perfectly reachable and meaning something else, which you do not.
So it is treated exactly like the other removals: a deploy that removes a member is refused until a migration says you meant it, and the remedy names the member:
migration "retire the parked state" {
drop enum JobStatus.Parked;
// …or, if the member was really renamed:
rename enum JobStatus.Parked -> JobStatus.Held;
}A rename is the interesting one, and it is why the two are separate verbs. Renaming a member keeps its position, so every stored row goes on meaning what it always meant, under the new name. That is almost always what you wanted.
⚠ While you are developing, the local compile does not refuse — it warns. osy compile edits the version in place
rather than deploying a new one, so it applies the change and tells you what it did to your data:
enum member 'JobStatus.Done' changed its stored value from 2 to 1 — usually because an earlier member was removed
and the rest re-indexed. Existing rows are NOT rewritten, so rows holding 1 now read as 'Done', and rows written
when 'Done' meant 2 no longer do.Read it rather than scrolling past it: the rows in your development database now say something you did not write. The warning appears whenever a member's position moves — a removal, or a reorder — and stays silent when you simply add a member at the end, which moves nothing.
Removing a whole enum, or a whole workflow#
Deleting the entire enum JobStatus { … }, or an entire workflow Fulfilment { … }, is a removal like any other —
and each has its own verb, because each leaves a different kind of data behind:
migration "retire the fulfilment flow and its status vocabulary" {
drop workflow Fulfilment; // its runs are the data
drop enum JobStatus; // the WHOLE type — stored ordinals are the data
}Note the two spellings of the enum verb, and that the operand is what tells them apart:
| you write | you retire |
|---|---|
drop enum JobStatus.Parked; | one member — and every later member's stored position shifts, as above |
drop enum JobStatus; | the whole type |
A workflow will not be dropped while any run of it is still going. Its runs pin the exact revision they started under, so deleting the workflow would not cancel them — it would make them unresumable, sitting in the data pointing at something that no longer exists. A running or waiting run therefore refuses the removal outright, whatever your migration says, and names the count:
WORKFLOW_HAS_LIVE_RUNS: refusing to drop workflow 'Fulfilment' — 3 run(s) are still running or waiting and pin
the revisions this would delete, so they could never be resumed. Let them finish, or cancel them
(`osy cancel-runs`), then remove the workflow.That refusal is deliberately not something drop workflow overrides. The verb says "yes, I meant to remove this
declaration"; it does not say "and destroy the work still running through it", which is a different decision and one
the author cannot see in the source they are editing. A completed, failed or cancelled run does not block anything —
nothing will ever resume it, so its pin is history rather than a claim.
And an enum will not be dropped while rows still hold one of its values, for the sharper version of the same reason. A column does not store a member's name, it stores the member's position, and the vocabulary that turns that number back into meaning is the declaration you are deleting. A dropped property leaves rows stranded but still readable under their old name; a dropped member leaves them meaning something else; dropping the whole type leaves them holding a number that nothing anywhere can interpret. So the removal is refused, naming every column that still holds one and how many rows do:
ENUM_HAS_STORED_VALUES: refusing to drop enum 'JobStatus' — 3 row(s) of Job.Status still hold one of its values. A
column stores a member's POSITION, not its name, so removing the type leaves those numbers with nothing left to
interpret them. Retire the column in the same deploy (`drop property Job.Status;`), or clear it, then remove the
enum. (A column this deploy already drops does not block it.)That last sentence is the whole shape of it. Retiring the enum and everything that used it is one ordinary deploy
and is not refused — the rows go with the column that held them, under the drop property that removes it. What is
refused is the deploy where the column survives: you retyped JobStatus Status; to int Status; and deleted the
enum, so every stored number stays exactly where it was with its meaning deleted out from under it. Like the live-run
refusal, this holds in every mode and drop enum JobStatus; does not override it: the verb says the declaration was
meant to go, not that the data pointing into it was.
What a platform release does with one#
drop enum is legal in a platform release's own migration, in its baseline { … } layer — and there the removal
reaches each application only when that application moves to the release (Deploying while workflows are running).
A retirement is recorded as its own kind of change, never as a table or column drop, because that is not what it is. When your application moves to the release, the retirement is applied only if nothing in your database stores the enum's values. If something does, it is refused by name with the count: nothing is retired, every row is kept, and your operators get a critical alert saying which declaration was held back and what is holding it. The move itself succeeds — the rest of the release applies — and a later release can retire it once those rows are gone.
⚠ drop workflow is not legal there. A workflow is retired only in an application's own migration — the flat
form above. What a workflow's removal endangers is its live runs, and a run pins its definition by id inside the
application's model; a release's baseline layer is replayed against each application's database, where a workflow's
name resolves to nothing, so it could never ask whether a run still needs it. The release is refused when it is
published:
[MIGRATE_WORKFLOW_DROP_NOT_APP] `drop workflow Fulfilment;` is not allowed in the baseline layer — a workflow is
retired in the APP layer only. Write `drop workflow Fulfilment;` in the application's own migration (a flat app
migration, no layer block): that compile loads the model, resolves the workflow to its family and refuses the removal
while any run of it can still resume (OsySharpWorkflowRemovalGuard). …Making a property wider, or optional, just deploys#
A change that every value already stored still fits is applied to the column when the version deploys, with no
migration: a longer [MaxLength] (or none at all), a decimal whose [Precision] keeps at least as many digits
before and after the point, int → long, and a required property made optional (string → string?). Existing rows are untouched,
and a value only the new shape allows — a 300-character title where the limit was 100 — writes straight away. A new
[Index] or [Unique] on a property you already had is created the same way.
A run still finishing on the older version goes on using its own declaration, which still carries the old limit — so a value the new version wrote may be longer than that version expects.
Making a property required needs a value for the rows that have none#
string? Note; → string Note; is a different kind of change. Rows written so far may leave Note empty, so the
column cannot simply be made required — and it is not left optional under a model that says it is required either. The
deploy is refused, naming the property, its column, and both shapes:
This deploy makes a member REQUIRED that the version it replaces left optional, and its column is shared with the
data every version reads. …
Doc.Note (column "Doc"."Note"): varchar(50) NULL → varchar(50) NOT NULL
Say what the existing rows hold, in a migration, and deploy again:
backfill Doc.Note = <value>; // or: using row => <expression>;Say it, and the deploy fills every empty Note and makes the column required, in one step:
migration "notes are required now" {
backfill Doc.Note = "none";
}⚠ A run still finishing on the older version treats Note as optional. Once the column is required, a row it saves
without one is refused — so if such runs matter, keep the property optional until they have finished.
Adding or tightening a rule the stored rows break#
An update is judged only on the members it changes ([[entity-constraints#on-update]]), so a row that breaks a rule you
add later would otherwise go on breaking it until somebody changed that member. The deploy is where that is kept true:
a deploy that adds a [Min], [Max] (or [Range]), [MinLength] or [Pattern] — or changes one — to a member whose
table already holds rows counts the rows that break it, and is refused when any do:
This deploy adds or tightens a rule that rows already stored break. An update judges a rule only on the members it
changes, so a stored row breaking one would stay unnoticed until somebody changed that member:
Listing.Stock [Min(1)]: 2 stored row(s) break it
Correct those rows in the migration — a function it runs after the schema change (`run FixThem;`, one that sets a
value only where it breaks the rule, so a second run finds nothing to do) — or loosen the rule, and deploy again.
Nothing of this deploy was applied.The count is taken inside the deploy, after the schema change and the migration's functions, so a function that corrects the rows is all it takes — see Running a function when a version deploys:
entity Listing {
[Required, MaxLength(80)] string Title;
[Min(1)] int Stock;
}
void LiftEmptyStock() {
foreach (var l in Listing.Where(l => l.Stock < 1).ToList()) { l.Stock = 1; }
}migration "stock is at least one" {
run LiftEmptyStock;
}A rule with a when guard and an invariant are not counted: both are judged on every write, so a stored row that
breaks one is refused at its next change, whatever that change is. A rule the deploy leaves as it was is not counted
again. A zone that builds the deploy itself holds the rule the same way: its move runs the migration's functions and
counts the rows after them, in one transaction. Its dry run cannot run them — they write your data — so when the
migration runs none, the dry run counts the stored rows and refuses before any backup is taken; when it runs some, the
count waits for the move.
Narrowing or changing a type needs a retype#
NARROWING the stored type, width or precision of a property that already has data is refused by default: a
shorter [MaxLength], fewer digits in a decimal, long → int, or any other change of type (string → int).
Such a change can reject or lose what is already stored, so the deploy does not make it on your behalf. The refusal
names the property, both shapes, and the line that says you mean it:
This deploy changes the stored type, width or precision of a property that already holds data, and nothing says you
meant to. …
Changed width of Doc.Title: string(500) → string(100)
If you mean it, say so in the app's .migration and deploy again. …
retype Doc.Title : string -> string; // add `using v => <expression>` to convert each value yourselfWrite it, and the deploy converts the column. A statement in your migration overrides the default refusal — you are the one who knows your data:
migration "shorter titles" {
retype Doc.Title : string -> string;
}A change of type usually wants a conversion, written as a lambda over the stored value:
migration "codes are numeric" {
retype Doc.Code : string -> int using c => int.Parse(c);
}The conversion runs inside the deploy, in the same single step as everything else it changes. If any stored value does not fit — a 300-character title under the new limit of 100, a code that is not a number — the database refuses, and the whole deploy is undone: no column changes, no new version, the app exactly as it was. The refusal names the column and the database's own reason, so you can fix those rows (or give the conversion a way to make them fit) and deploy again:
This deploy was rolled back: the database refused to change column "Doc"."Title" (Doc.Title: varchar(500) NULL →
varchar(100) NULL, the `retype` the migration declares) because of what it already holds:
22001: value too long for type character varying(100)
Nothing of this deploy was applied — no column change, no ledger row, no new version: the app is exactly as it was.A value is never cut short to make it fit. Without using, each value is converted as the database would store it
directly, which refuses an over-long string rather than truncating it.
The types a retype names must be the change your source makes. A retype Doc.Title : string -> int; for a title that
stays a string is refused, naming the line to write instead — a mistyped migration is not a decision.
Pointing a reference at another entity#
Changing Person? Owner; to Company? Owner; leaves the column's type as it was — it holds ids either way — but every
id already stored names a Person. The deploy sees that from the column's foreign key, and refuses it like any other
change of type, naming the retype line. A retype for a reference also needs using, because without a conversion
nothing is rewritten and each row would go on pointing at a row of the wrong kind:
migration "documents belong to companies" {
retype Doc.Owner : Person -> Company using r => null;
}The deploy then converts the column and moves its foreign key to the new entity, in the same single step.
Every deploy follows these rules#
They are not only a new version's rules. A deploy onto the version you already have — what osy compile does while you
develop — widens, refuses, converts, backfills and rolls back exactly as a new version does, with the same messages.
Your .migration is sent with every deploy, so the retype you wrote goes on being sent after it has been applied. It
is not applied twice: once the column already has the new shape and the declaration has not changed since the last
deploy, the conversion is skipped, and the deploy log says so.
⚠ A run still finishing on an older version keeps its own declaration. After a narrowing, a value longer than the
new limit that it writes is refused; after a change of type, its reads and writes of that property can fail outright.
You chose this knowingly by writing retype. If such runs matter, change it additively instead — add a new property,
copy the data across, then remove the old one once every reader and writer has moved. See Deploying while workflows are running
for the full sequence and its warning.
Examples#
Renaming an entity and one of its properties in the same deploy:
entity Job {
[Required, MaxLength(200)] string Title;
[MaxLength(400)] string? Notes;
}entity Assignment {
[Required, MaxLength(200)] string Title;
[MaxLength(400)] string? Remarks;
}The migration that says so:
migration "job becomes assignment" {
from "ast:<the deployed hash>";
to "ast:<the new hash>";
rename entity Job -> Assignment;
rename property Job.Notes -> Job.Remarks;
}Deploy it and every Job you ever created is an Assignment, with its notes intact under the new name. Runs that
were already in flight go on calling it a Job until they finish.
See also#
- Running a function when a version deploys —
run, a function of the new version that moves the data itself - Moving an entity onto a base, with its rows —
rebase: an entity becomes a subtype of a base, and its rows the base's - Deploying while workflows are running — why an older version is still reading your data, and how versions are cleaned up
- Stopping runs after a bad deploy — stopping runs that are still executing a version you want rid of
- entity — declaring the entities this is all about
- enum — declaring the enums whose members re-number when one is removed