goal: effortless (less-effort) migration between software versions
why you need it: "Move fast, don't break things"
scenarios:
- enforce that you api remain always retrocompatible
- safe data migration
- hotreload code keeping in memory data
when to use it: In cases where you need to expose an API (aka third parties relies on your code interface or REST API).
when to NOT use it: In cases where you do not need retrocompatibility this pattern risks only to add maintenance burden.
First step, we declare the contract our program will have with external world.
src/index.js
exporttypeApi={getPerson(id: string): Person;addPerson(person: Person): void;};exporttypePerson={id: string;name: string;age: number};exportconstapi: Api={getPerson(id: string): Person{return{ id,name: "John Doe",age: 23};},addPerson(person: Person): void{returnvoidperson;}};We gonna respect semver.
So we tag this commit as v1.0.0.
Let's add a new capability to our API.
exporttypeApi={getPerson(id: string): Person;addPerson(person: Person): void;getPeople(): Person[];};Resulting in a MINOR change.
Let's leverage the typechecker to check if our api is retrocompatible.
src/test/compatibility.test.ts
import{ApiasPREVIOUS_Api}from"../../.git/fs/HEAD/worktree/src";import{ApiasCURRENT_Api}from"../../src";test("retro-compatibility",()=>{letcurrent: CURRENT_Api=nullasany;letprevious: PREVIOUS_Api=nullasany;// this means we can safely replace the previous api with the new oneprevious=current;// but we can't downgrade the current api to the previous one// uncomment next line and see why// current = previous});In this example we are using git for versioning,
git-fs for exposing our whole code history,
TypeScript for type-checking,
and a git pre-commit hook that checks for us if we introduce breaking changes before committing.
The src/test/compatibility.test.ts file can be left unchanged till we code PATCH or MINOR changes.
Let's break the api and see how the compiler can be useful detecting the incompatibilities.
exporttypePerson={id: string;name: string;birth: Date};src/test/compatibility.test.ts(10,3): error TS90010: Type 'Api' is not assignable to type 'Api'. Two different types with this name exist, but they are unrelated.
Types of property 'getPerson' are incompatible.
Type '(id: string) => Person' is not assignable to type '(id: string) => Person'. Two different types with this name exist, but they are unrelated.
Type 'Person' is not assignable to type 'Person'. Two different types with this name exist, but they are unrelated.
Property 'age' is missing in type 'Person'.
But let's say we are aware of the fact that we are breaking retro-compatibility,
so let's commit skipping the checks git commit --no-verify
and bump the MAJOR version git tag v2.0.0.
But we need to migrate our data from our old version.
src/index.version.adapter.ts
import{PersonasPerson_V_1_1_0}from"../.git/fs/tags/v1.1.0/worktree/src";import{PersonasPerson_V_2_0_0}from"../.git/fs/tags/v2.0.0/worktree/src";exportconstadapters={from: {Person_V_1_1_0: {to: {Person_V_2_0_0: ({ id, name, age }: Person_V_1_1_0): Person_V_2_0_0=>{constnow=newDate();constbirth=newDate(String(now.getFullYear()-age));return{ id, name, birth };}}}}};import{apiasapi_V_1_1_0}from"../.git/fs/tags/v1.1.0/worktree/src";import{api}from"../src";import{adapt}from"../src/index.version.adapter";test("migration",()=>{api_V_1_1_0.getPeople().map(adapt.from.Person_V_1_1_0.to.Person_V_2_0_0).forEach(api.addPerson);});To achieve code reloading we need an entry point where we can leverage late binding:
src/entry-point.ts
import{migrate}from"./index.version.migration";exportconstentryPoint={api: nullasany,version: ""asstring,asyncupgrade(nextVersion: string){constnextApi=(awaitimport(`../.git/fs/tags/${nextVersion}/worktree/src`)).api;(migrateasany).from[this.version].to[nextVersion](this.api,nextApi);this.version=nextVersion;this.api=nextApi;}};Upon calling upgrade(version) we leverage version control to retreive the desired version
of the code and replace the current one, doing migration meantime.
Here's a sample of migration code:
src/inde.version.migration.ts
import{ApiasApi_V_2_0_0}from"../.git/fs/tags/v2.0.0/worktree/src";import{ApiasApi_V_2_0_1}from"../.git/fs/tags/v2.0.1/worktree/src";import{adapt}from"../src/index.version.adapter";exportconstmigrate={from: {"": {to: {"v2.0.0"(fromApi: null,toApi: Api_V_2_0_0){},"v2.0.1"(fromApi: null,toApi: Api_V_2_0_1){}}},"v2.0.0": {to: {"v2.0.1"(fromApi: Api_V_2_0_0,toApi: Api_V_2_0_1){fromApi.getPeople().map(adapt.from.Person_V_2_0_0.to.Person_V_2_0_1)// try to comment this line.forEach(toApi.addPerson);}}}}};Lets test our hot-reloading:
import{ApiasApi_V_2_0_0}from"../.git/fs/tags/v2.0.0/worktree/src";import{ApiasApi_V_2_0_1}from"../.git/fs/tags/v2.0.1/worktree/src";import{entryPoint}from"../src/entry-point";test("hot reloading code with data migration",async()=>{awaitentryPoint.upgrade("v2.0.0");(entryPoint.apiasApi_V_2_0_0).addPerson({id: "1",name: "John",birth: newDate("1990")});expect((entryPoint.apiasApi_V_2_0_0).getPeople()).toEqual([{id: "1",name: "John",birth: newDate("1990")}]);awaitentryPoint.upgrade("v2.0.1");expect((entryPoint.apiasApi_V_2_0_1).getPeople()).toEqual([{id: "1",name: "John",birth: newDate("1990"),gender: "unknown"}]);(entryPoint.apiasApi_V_2_0_1).addPerson({id: "2",name: "Epoch",birth: newDate(0),gender: "abstract"});expect((entryPoint.apiasApi_V_2_0_1).getPeople()).toEqual([{id: "1",name: "John",birth: newDate("1990"),gender: "unknown"},{id: "2",name: "Epoch",birth: newDate(0),gender: "abstract"}]);});install:
do:
git clone https://github.com/freddi301/version-aware-code.gitcd version-aware-codenpm install- open the folder with Visual Studio Code
- read the
README.md - You can follow the git commits step by step