standards
SDK

Migrations

What happens at boot when your code schema and your database schema disagree — safe ops, destructive drops, and resolving ambiguous changes with .migration().

Every time your service boots, the platform compares the schema declared in your code against what's actually in the database and reconciles the two. Most changes need no ceremony at all; a few are destructive by nature; and some are genuinely ambiguous, so the builder makes you spell out your intent with .migration().

Safe changes: applied automatically

Adding a new object, adding a new attribute to an existing object, and cosmetic changes to an attribute's config (label, description, icon, order, plural label) are all additive or non-destructive. They're detected by diffing your code against the database and applied at boot with no extra step from you.

Removing an attribute: unconditional drop

Deleting an .attribute(...) call from your object builder is unambiguous — attributes declared in code are system, code-controlled entities, so if your code no longer declares one, the platform takes that as a deliberate removal.

Removing an attribute from your object builder drops its column and purges every stored value for it the next time the service boots. This happens unconditionally — there is no flag to opt out and no confirmation step. Back up the data first if you might need it.

Removing a whole object() is different: it's demoted to a runtime (user-owned) object rather than deleted outright, unless STANDARDS_ALLOW_DESTRUCTIVE_SYNC=1 is set in the environment, in which case it's dropped for real. That flag only governs object removal — it has no effect on attribute removal, which is always unconditional.

Ambiguous changes: rename and type change

Renaming an attribute or changing its type looks, from the diff's point of view, exactly like "one attribute disappeared, an unrelated one appeared" — it has no way to tell a rename from a genuine delete-and-add. When it detects this pattern, boot throws a SchemaAmbiguityError and refuses to sync until you resolve it explicitly, by declaring the operation with .migration():

import { object, text, number } from "@stndrds/schema";

object({ name: "contacts", label: "Contact" })
  .labelExpression("{{ first_name }}")
  .migration(2, (m) => m.renameAttribute("firstName", "first_name"))
  .migration(3, (m) => m.changeType("age", "text", "number"))
  .attribute(text({ name: "first_name", label: "First Name" }).required())
  .attribute(number({ name: "age", label: "Age" }))

.migration(version, callback) declares one migration step; version must be >= 2 (version 1 is implicit — a brand-new object needs no migration), and versions must be declared sequentially with no gaps or duplicates. The callback receives a MigrationBuilder:

  • .renameAttribute(from, to) — declares a rename.
  • .changeType(name, from, to, options?) — declares a type change.
  • .removeAttribute(name) — an explicit removal (equivalent to the unconditional drop above, but recorded as part of a versioned migration).
  • .addAttribute(attribute) — an explicit addition.
  • .updateConfig(name, patch) — a partial config patch.

A single migration can't touch the same attribute name twice — declaring .renameAttribute("a", "b") and then .changeType("a", ...) in the same .migration() call throws, since that's an unresolved conflict about what actually happened to "a".

The object's schema_version is derived automatically — it's the highest migration version you've declared (or 1 if you haven't declared any).

Changing a type at runtime is disabled

changeAttributeType is not available through the runtime API — you can't ask a live instance to reinterpret an attribute's type on the fly. Calling it throws ChangeTypeNotSupportedError. The only supported path for a type change is the code-level migration shown above: declare .migration(N, (m) => m.changeType(...)), deploy, and let boot sync apply it.