Strata · migrations

A database has no compiler.

In Nisc your screens and queries are data, saved in a database and run from there. Rename a prop, and the compiler flags every file that uses it. The screens saved in the database still say the old name, and nothing flags them. Strata upgrades saved data with a migration, at the moment it is read.

pnpm add @niscorp/strata zod · 0.1.7 · Apache-2.0

The kit renamed a button’s label to text

The saved screen

In your code: the compiler stops you

// the kit, after the release
type ButtonProps = {
  text: string;
  variant?: 'filled' | 'plain';
};

// your file, written in March
const book: ButtonProps = {
  label: 'Book seats',
  tone: 'primary',
};
error TS2353: Object literal may only specify known properties, and 'label' does not exist in type 'ButtonProps'.

In your database: a screen saved in March

{
  "component": "Stack",
  "children": [
    {
      "component": "Card",
      "children": [
        {
          "component": "Text",
          "children": "The Tempest, 19:30"
        },
        {
          "component": "Button",
          "props": {
            "label": "Book seats",
            "tone": "primary"
          }
        }
      ]
    },
    {
      "component": "Button",
      "props": {
        "label": "Other shows",
        "tone": "quiet"
      }
    }
  ]
}
No error. Nothing checks a row.
TonightDecember’s kit

The Tempest, 19:30

Nothing stopped this. The yellow lines are the ones the new kit no longer understands. It looks for text and finds label, so both buttons are drawn with no words. A member finds out when they open the screen.

First steps →
A migration

The fix is data too.

Strata has no language of its own. A migration is a Prism config, the same kind of transform Nisc uses everywhere else. A screen is a tree, so Strata walks it and gives the config one node at a time. The node comes in as $.document, and what the config returns replaces it. This one renames label to text and returns every other node unchanged.

theatre.kit/1 · Button: label is now text
{
  "$case": {
    "branches": [
      {
        "when": {
          "$has": {
            "from": { "$ref": "$.document" },
            "path": ["props", "label"]
          }
        },
        "then": {
          "$update": {
            "from": { "$ref": "$.document" },
            "path": ["props"],
            "value": {
              "$renameKeys": {
                "from": { "$var": "current" },
                "map": { "label": "text" }
              }
            }
          }
        }
      }
    ],
    "else": { "$ref": "$.document" }
  }
}

Every node it was given, and what it returned

Stack         unchanged
  Card        unchanged
    Text      unchanged
    Button    label is now text
  Button      label is now text

Read about Prism →

When it is read

Each screen knows how old it is.

The theatre shipped four releases this year, and three of them came with a migration. A saved screen carries a stamp: how many migrations existed when it was saved. Strata runs the ones it missed when the screen is read, and one call can rewrite a whole table the same way. A screen saved by a newer release than the code is refused.

The screen was saved in

Its stamp

{
  "nisc.nova": 2,
  "nisc.prism": 3,
  "theatre.kit": 0
}

The screen, as saved

{
  "component": "Stack",
  "children": [
    {
      "component": "Card",
      "children": [
        {
          "component": "Text",
          "children": "The Tempest, 19:30"
        },
        {
          "component": "Button",
          "props": {
            "label": "Book seats",
            "tone": "primary"
          }
        }
      ]
    },
    {
      "component": "Button",
      "props": {
        "label": "Other shows",
        "tone": "quiet"
      }
    }
  ]
}

Read by December’s code

Migration that ranWhat it changes
theatre.kit/1Button: label is now text
theatre.kit/2Button: tone is now variant
theatre.kit/3Card is now Tile

The screen, after reading

{
  "component": "Stack",
  "children": [
    {
      "component": "Tile",
      "children": [
        {
          "component": "Text",
          "children": "The Tempest, 19:30"
        },
        {
          "component": "Button",
          "props": {
            "text": "Book seats",
            "variant": "filled"
          }
        }
      ]
    },
    {
      "component": "Button",
      "props": {
        "text": "Other shows",
        "variant": "plain"
      }
    }
  ]
}

Saved before any of the three changes. All three migrations run. Yellow on the left is what goes, yellow on the right is what replaced it.

When an API changes

A release leaves a note for whoever fixes your code.

Some changes are in code, and no migration can rewrite code. The kit’s sell() used to take a show’s title. Now it takes the show’s id. Both are strings, so the compiler accepts the old call. The release ships a migration with no steps, only a description of the change. That still moves the version, so your CI fails until someone has been handed the note.

Your app

src/boxoffice.ts, your code

import { sell } from './kit';

export const sellTonight = (seats: string[]): string => {
  return sell('The Tempest', seats);
};
strata.lock.json
{
  "grammar": {
    "nisc.nova": 2,
    "nisc.prism": 3,
    "theatre.kit": 0
  }
}
pnpm strata status --check fails: the source is behind
.strata/upgrade/REPORT.md, written by pnpm strata upgrade
# strata upgrade

The source is written at **nisc.nova 2, nisc.prism 3, theatre.kit 0**; the installed grammars are at **nisc.nova 2, nisc.prism 3, theatre.kit 1**.

## Migrations

- `theatre.kit/1` — sell() takes the show’s id, where it took its title

## Nothing to edit

No artifact changes (1 checked). Run `verify` to move the lock.

The lock file is one behind, so pnpm strata status --check fails. pnpm strata upgrade writes the report on the right. The line under Migrations is the note. "Nothing to edit" means none of your screens changes: the edit is in your code, on the yellow line.

What the note isOne line, the migration’s description. Whoever makes the change writes it for the person or agent who has to act on it.
What Strata doesIt makes the note impossible to skip. CI fails until the upgrade has been run, and the upgrade puts every note since your lock in one report.
What it cannot doCheck your code. Strata never reads it. Whether the call was fixed is for your tests and review.
The notes Nova and Prism have shipped this way
nisc.nova/1
  The `reconcile` step: make a canvas hold exactly the actions a list in the action's data names

nisc.nova/2
  The endpoint `timeoutMs`: how long to wait for the reply before the call fails

nisc.prism/1
  Transform ops ($has, $renameKeys, $update, $assert, $walk); $ref "$" for the whole source; $join parts may be any node that evaluates to an array; an op name is not a template key

nisc.prism/2
  A template key never starts with "$": a `$` name that is not an op is refused by the schema, where it was refused only once evaluated

nisc.prism/3
  $mod and $toNumber; $round takes a mode (nearest, floor, ceil); $replace takes all; a $ref path is keys and indexes only: any other JSONPath form is refused, where it was read as "$"
Before a release

Give saved data a compiler error.

Strata has a check for CI. It fails when a schema changed and no migration was added. It also upgrades screens that were really saved and fails when one no longer fits the schema.

The change to a button
What changed in the schema
- properties.tone
+ properties.variant
Saved buttonIts toneChecked against the schema
Tonight, Book seatsprimaryUnrecognized key: "tone"
Tonight, All showsquietUnrecognized key: "tone"
The door, Let inprimaryUnrecognized key: "tone"
My ticket, Show itnonepasses
A migration is missing

The schema changed and nobody wrote a migration. Three of the four saved buttons still say tone, so they fail. The build stops here, before anything reaches production.

Tables

SQL migrations, by the same rules.

The idea comes from SQL migrations, and Strata runs those too. It runs the ones a database has not run yet, in one transaction, and records each in a ledger table with a checksum. When the app starts, it compares the ledger with the code.

The database is
MigrationDescriptionChecksum in the ledgerChecksum in the code
theatre.app/1Shows83b0b79783b0b797
theatre.app/2Bookings712afad5712afad5
theatre.app/3Season passesde724bccde724bcc
Nothing to do

The ledger and the code match. Nothing runs.

A number is a positionThe third migration is number 3 because it is third. There is no version number to bump.
What ran is historyNever change a migration that has run. To fix one, add another after it.
One ownerA list of migrations covers one thing: a package’s tables, your app’s tables, or one kind of document.
Newer is refusedData written by a newer version is refused with TOO_NEW. Deploy the newer version to read it.
In Nisc

It is why the packages can change.

Without it, a release of Nisc could never change the format of anything you had saved. With it, a release ships its migrations and your saved screens follow.

NovaScreens and actions are documents. Nova says where one is nested in another, so a migration reaches every node.
PrismA config is a document too. A migration is written as a config.
MossRuns every table migration when the server starts: its own tables, the sessions table, the Vex cache.
VexThe table of saved queries has its own list of migrations, nisc.vex.cache.
Your kitRename a prop on a component and add one migration. Every saved screen is upgraded when it is read.
Add-onsA document sent by an add-on arrives with its stamp. An older one is upgraded on arrival, and a newer one is refused.