1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-19 09:43:23 +00:00

PM-3585 Improve state migrations (#5009)

* WIP: safer state migrations

Co-authored-by: Justin Baur <justindbaur@users.noreply.github.com>

* Add min version check and remove old migrations

Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>

* Add rollback and version checking

* Add state version move migration

* Expand tests and improve typing for Migrations

* Remove StateMigration Service

* Rewrite version 5 and 6 migrations

* Add all but initial migration to supported migrations

* Handle stateVersion location in migrator update versions

* Move to unique migrations directory

* Disallow imports outside of state-migrations

* Lint and test fixes

* Do not run migrations if we cannot determine state

* Fix desktop background StateService build

* Document Migration builder class

* Add debug logging to migrations

* Comment on migrator overrides

* Use specific property names

* `npm run prettier` 🤖

* Insert new migration

* Set stateVersion when creating new globals object

* PR comments

* Fix migrate imports

* Move migration building into `migrate` function

* Export current version from migration definitions

* Move file version concerns to migrator

* Update migrate spec to reflect new version requirements

* Fix import paths

* Prefer unique state data

* Remove unnecessary async

* Prefer to not use `any`

---------

Co-authored-by: Justin Baur <justindbaur@users.noreply.github.com>
Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>
This commit is contained in:
Matt Gibson
2023-08-30 12:57:20 -05:00
committed by GitHub
parent b444eed0b5
commit 3340af8084
51 changed files with 1538 additions and 980 deletions

View File

@@ -0,0 +1,24 @@
{
"overrides": [
{
"files": ["*"],
"rules": {
"import/no-restricted-paths": [
"error",
{
"basePath": "libs/common/src/state-migrations",
"zones": [
{
"target": "./",
"from": "../",
// Relative to from, not basePath
"except": ["state-migrations"],
"message": "State migrations should rarely import from the greater codebase. If you need to import from another location, take into account the likelihood of change in that code and consider copying to the migration instead."
}
]
}
]
}
}
]
}

View File

@@ -0,0 +1 @@
export { migrate, CURRENT_VERSION } from "./migrate";

View File

@@ -0,0 +1,67 @@
import { mock, MockProxy } from "jest-mock-extended";
// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages
import { LogService } from "../platform/abstractions/log.service";
// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations
import { AbstractStorageService } from "../platform/abstractions/storage.service";
import { CURRENT_VERSION, currentVersion, migrate } from "./migrate";
import { MigrationBuilder } from "./migration-builder";
jest.mock("./migration-builder", () => {
return {
MigrationBuilder: {
create: jest.fn().mockReturnThis(),
},
};
});
describe("migrate", () => {
it("should not run migrations if state is empty", async () => {
const storage = mock<AbstractStorageService>();
const logService = mock<LogService>();
storage.get.mockReturnValueOnce(null);
await migrate(storage, logService);
expect(MigrationBuilder.create).not.toHaveBeenCalled();
});
it("should set to current version if state is empty", async () => {
const storage = mock<AbstractStorageService>();
const logService = mock<LogService>();
storage.get.mockReturnValueOnce(null);
await migrate(storage, logService);
expect(storage.save).toHaveBeenCalledWith("stateVersion", CURRENT_VERSION);
});
});
describe("currentVersion", () => {
let storage: MockProxy<AbstractStorageService>;
let logService: MockProxy<LogService>;
beforeEach(() => {
storage = mock();
logService = mock();
});
it("should return -1 if no version", async () => {
storage.get.mockReturnValueOnce(null);
expect(await currentVersion(storage, logService)).toEqual(-1);
});
it("should return version", async () => {
storage.get.calledWith("stateVersion").mockReturnValueOnce(1 as any);
expect(await currentVersion(storage, logService)).toEqual(1);
});
it("should return version from global", async () => {
storage.get.calledWith("stateVersion").mockReturnValueOnce(null);
storage.get.calledWith("global").mockReturnValueOnce({ stateVersion: 1 } as any);
expect(await currentVersion(storage, logService)).toEqual(1);
});
it("should prefer root version to global", async () => {
storage.get.calledWith("stateVersion").mockReturnValue(1 as any);
storage.get.calledWith("global").mockReturnValue({ stateVersion: 2 } as any);
expect(await currentVersion(storage, logService)).toEqual(1);
});
});

View File

@@ -0,0 +1,60 @@
// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages
import { LogService } from "../platform/abstractions/log.service";
// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations
import { AbstractStorageService } from "../platform/abstractions/storage.service";
import { MigrationBuilder } from "./migration-builder";
import { MigrationHelper } from "./migration-helper";
import { FixPremiumMigrator } from "./migrations/3-fix-premium";
import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked";
import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys";
import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key";
import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account";
import { MoveStateVersionMigrator } from "./migrations/8-move-state-version";
import { MinVersionMigrator } from "./migrations/min-version";
export const MIN_VERSION = 2;
export const CURRENT_VERSION = 8;
export type MinVersion = typeof MIN_VERSION;
export async function migrate(
storageService: AbstractStorageService,
logService: LogService
): Promise<void> {
const migrationHelper = new MigrationHelper(
await currentVersion(storageService, logService),
storageService,
logService
);
if (migrationHelper.currentVersion < 0) {
// Cannot determine state, assuming empty so we don't repeatedly apply a migration.
await storageService.save("stateVersion", CURRENT_VERSION);
return;
}
MigrationBuilder.create()
.with(MinVersionMigrator)
.with(FixPremiumMigrator, 2, 3)
.with(RemoveEverBeenUnlockedMigrator, 3, 4)
.with(AddKeyTypeToOrgKeysMigrator, 4, 5)
.with(RemoveLegacyEtmKeyMigrator, 5, 6)
.with(MoveBiometricAutoPromptToAccount, 6, 7)
.with(MoveStateVersionMigrator, 7, CURRENT_VERSION)
.migrate(migrationHelper);
}
export async function currentVersion(
storageService: AbstractStorageService,
logService: LogService
) {
let state = await storageService.get<number>("stateVersion");
if (state == null) {
// Pre v8
state = (await storageService.get<{ stateVersion: number }>("global"))?.stateVersion;
}
if (state == null) {
logService.info("No state version found, assuming empty state.");
return -1;
}
logService.info(`State version: ${state}`);
return state;
}

View File

@@ -0,0 +1,117 @@
import { mock } from "jest-mock-extended";
import { MigrationBuilder } from "./migration-builder";
import { MigrationHelper } from "./migration-helper";
import { Migrator } from "./migrator";
describe("MigrationBuilder", () => {
class TestMigrator extends Migrator<0, 1> {
async migrate(helper: MigrationHelper): Promise<void> {
await helper.set("test", "test");
return;
}
async rollback(helper: MigrationHelper): Promise<void> {
await helper.set("test", "rollback");
return;
}
}
let sut: MigrationBuilder<number>;
beforeEach(() => {
sut = MigrationBuilder.create();
});
class TestBadMigrator extends Migrator<1, 0> {
async migrate(helper: MigrationHelper): Promise<void> {
await helper.set("test", "test");
}
async rollback(helper: MigrationHelper): Promise<void> {
await helper.set("test", "rollback");
}
}
it("should throw if instantiated incorrectly", () => {
expect(() => MigrationBuilder.create().with(TestMigrator, null, null)).toThrow();
expect(() =>
MigrationBuilder.create().with(TestMigrator, 0, 1).with(TestBadMigrator, 1, 0)
).toThrow();
});
it("should be able to create a new MigrationBuilder", () => {
expect(sut).toBeInstanceOf(MigrationBuilder);
});
it("should be able to add a migrator", () => {
const newBuilder = sut.with(TestMigrator, 0, 1);
const migrations = newBuilder["migrations"];
expect(migrations.length).toBe(1);
expect(migrations[0]).toMatchObject({ migrator: expect.any(TestMigrator), direction: "up" });
});
it("should be able to add a rollback", () => {
const newBuilder = sut.with(TestMigrator, 0, 1).rollback(TestMigrator, 1, 0);
const migrations = newBuilder["migrations"];
expect(migrations.length).toBe(2);
expect(migrations[1]).toMatchObject({ migrator: expect.any(TestMigrator), direction: "down" });
});
describe("migrate", () => {
let migrator: TestMigrator;
let rollback_migrator: TestMigrator;
beforeEach(() => {
sut = sut.with(TestMigrator, 0, 1).rollback(TestMigrator, 1, 0);
migrator = (sut as any).migrations[0].migrator;
rollback_migrator = (sut as any).migrations[1].migrator;
});
it("should migrate", async () => {
const helper = new MigrationHelper(0, mock(), mock());
const spy = jest.spyOn(migrator, "migrate");
await sut.migrate(helper);
expect(spy).toBeCalledWith(helper);
});
it("should rollback", async () => {
const helper = new MigrationHelper(1, mock(), mock());
const spy = jest.spyOn(rollback_migrator, "rollback");
await sut.migrate(helper);
expect(spy).toBeCalledWith(helper);
});
it("should update version on migrate", async () => {
const helper = new MigrationHelper(0, mock(), mock());
const spy = jest.spyOn(migrator, "updateVersion");
await sut.migrate(helper);
expect(spy).toBeCalledWith(helper, "up");
});
it("should update version on rollback", async () => {
const helper = new MigrationHelper(1, mock(), mock());
const spy = jest.spyOn(rollback_migrator, "updateVersion");
await sut.migrate(helper);
expect(spy).toBeCalledWith(helper, "down");
});
it("should not run the migrator if the current version does not match the from version", async () => {
const helper = new MigrationHelper(3, mock(), mock());
const migrate = jest.spyOn(migrator, "migrate");
const rollback = jest.spyOn(rollback_migrator, "rollback");
await sut.migrate(helper);
expect(migrate).not.toBeCalled();
expect(rollback).not.toBeCalled();
});
it("should not update version if the current version does not match the from version", async () => {
const helper = new MigrationHelper(3, mock(), mock());
const migrate = jest.spyOn(migrator, "updateVersion");
const rollback = jest.spyOn(rollback_migrator, "updateVersion");
await sut.migrate(helper);
expect(migrate).not.toBeCalled();
expect(rollback).not.toBeCalled();
});
});
});

View File

@@ -0,0 +1,106 @@
import { MigrationHelper } from "./migration-helper";
import { Direction, Migrator, VersionFrom, VersionTo } from "./migrator";
export class MigrationBuilder<TCurrent extends number = 0> {
/** Create a new MigrationBuilder with an empty buffer of migrations to perform.
*
* Add migrations to the buffer with {@link with} and {@link rollback}.
* @returns A new MigrationBuilder.
*/
static create(): MigrationBuilder<0> {
return new MigrationBuilder([]);
}
private constructor(
private migrations: readonly { migrator: Migrator<number, number>; direction: Direction }[]
) {}
/** Add a migrator to the MigrationBuilder. Types are updated such that the chained MigrationBuilder must currently be
* at state version equal to the from version of the migrator. Return as MigrationBuilder<TTo> where TTo is the to
* version of the migrator, so that the next migrator can be chained.
*
* @param migrate A migrator class or a tuple of a migrator class, the from version, and the to version. A tuple is
* required to instantiate version numbers unless a default constructor is defined.
* @returns A new MigrationBuilder with the to version of the migrator as the current version.
*/
with<
TMigrator extends Migrator<number, number>,
TFrom extends VersionFrom<TMigrator> & TCurrent,
TTo extends VersionTo<TMigrator>
>(
...migrate: [new () => TMigrator] | [new (from: TFrom, to: TTo) => TMigrator, TFrom, TTo]
): MigrationBuilder<TTo> {
return this.addMigrator(migrate, "up");
}
/** Add a migrator to rollback on the MigrationBuilder's list of migrations. As with {@link with}, types of
* MigrationBuilder and Migrator must align. However, this time the migration is reversed so TCurrent of the
* MigrationBuilder must be equal to the to version of the migrator. Return as MigrationBuilder<TFrom> where TFrom
* is the from version of the migrator, so that the next migrator can be chained.
*
* @param migrate A migrator class or a tuple of a migrator class, the from version, and the to version. A tuple is
* required to instantiate version numbers unless a default constructor is defined.
* @returns A new MigrationBuilder with the from version of the migrator as the current version.
*/
rollback<
TMigrator extends Migrator<number, number>,
TFrom extends VersionFrom<TMigrator>,
TTo extends VersionTo<TMigrator> & TCurrent
>(
...migrate: [new () => TMigrator] | [new (from: TFrom, to: TTo) => TMigrator, TTo, TFrom]
): MigrationBuilder<TFrom> {
if (migrate.length === 3) {
migrate = [migrate[0], migrate[2], migrate[1]];
}
return this.addMigrator(migrate, "down");
}
/** Execute the migrations as defined in the MigrationBuilder's migrator buffer */
migrate(helper: MigrationHelper): Promise<void> {
return this.migrations.reduce(
(promise, migrator) =>
promise.then(async () => {
await this.runMigrator(migrator.migrator, helper, migrator.direction);
}),
Promise.resolve()
);
}
private addMigrator<
TMigrator extends Migrator<number, number>,
TFrom extends VersionFrom<TMigrator> & TCurrent,
TTo extends VersionTo<TMigrator>
>(
migrate: [new () => TMigrator] | [new (from: TFrom, to: TTo) => TMigrator, TFrom, TTo],
direction: Direction = "up"
) {
const newMigration =
migrate.length === 1
? { migrator: new migrate[0](), direction }
: { migrator: new migrate[0](migrate[1], migrate[2]), direction };
return new MigrationBuilder<TTo>([...this.migrations, newMigration]);
}
private async runMigrator(
migrator: Migrator<number, number>,
helper: MigrationHelper,
direction: Direction
): Promise<void> {
const shouldMigrate = await migrator.shouldMigrate(helper, direction);
helper.info(
`Migrator ${migrator.constructor.name} (to version ${migrator.toVersion}) should migrate: ${shouldMigrate} - ${direction}`
);
if (shouldMigrate) {
const method = direction === "up" ? migrator.migrate : migrator.rollback;
await method(helper);
helper.info(
`Migrator ${migrator.constructor.name} (to version ${migrator.toVersion}) migrated - ${direction}`
);
await migrator.updateVersion(helper, direction);
helper.info(
`Migrator ${migrator.constructor.name} (to version ${migrator.toVersion}) updated version - ${direction}`
);
}
}
}

View File

@@ -0,0 +1,84 @@
import { MockProxy, mock } from "jest-mock-extended";
// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages
import { LogService } from "../platform/abstractions/log.service";
// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations
import { AbstractStorageService } from "../platform/abstractions/storage.service";
import { MigrationHelper } from "./migration-helper";
const exampleJSON = {
authenticatedAccounts: [
"c493ed01-4e08-4e88-abc7-332f380ca760",
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
],
"c493ed01-4e08-4e88-abc7-332f380ca760": {
otherStuff: "otherStuff1",
},
"23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
otherStuff: "otherStuff2",
},
};
describe("RemoveLegacyEtmKeyMigrator", () => {
let storage: MockProxy<AbstractStorageService>;
let logService: MockProxy<LogService>;
let sut: MigrationHelper;
beforeEach(() => {
logService = mock();
storage = mock();
storage.get.mockImplementation((key) => (exampleJSON as any)[key]);
sut = new MigrationHelper(0, storage, logService);
});
describe("get", () => {
it("should delegate to storage.get", async () => {
await sut.get("key");
expect(storage.get).toHaveBeenCalledWith("key");
});
});
describe("set", () => {
it("should delegate to storage.save", async () => {
await sut.set("key", "value");
expect(storage.save).toHaveBeenCalledWith("key", "value");
});
});
describe("getAccounts", () => {
it("should return all accounts", async () => {
const accounts = await sut.getAccounts();
expect(accounts).toEqual([
{ userId: "c493ed01-4e08-4e88-abc7-332f380ca760", account: { otherStuff: "otherStuff1" } },
{ userId: "23e61a5f-2ece-4f5e-b499-f0bc489482a9", account: { otherStuff: "otherStuff2" } },
]);
});
it("should handle missing authenticatedAccounts", async () => {
storage.get.mockImplementation((key) =>
key === "authenticatedAccounts" ? undefined : (exampleJSON as any)[key]
);
const accounts = await sut.getAccounts();
expect(accounts).toEqual([]);
});
});
});
/** Helper to create well-mocked migration helpers in migration tests */
export function mockMigrationHelper(storageJson: any): MockProxy<MigrationHelper> {
const logService: MockProxy<LogService> = mock();
const storage: MockProxy<AbstractStorageService> = mock();
storage.get.mockImplementation((key) => (storageJson as any)[key]);
storage.save.mockImplementation(async (key, value) => {
(storageJson as any)[key] = value;
});
const helper = new MigrationHelper(0, storage, logService);
const mockHelper = mock<MigrationHelper>();
mockHelper.get.mockImplementation((key) => helper.get(key));
mockHelper.set.mockImplementation((key, value) => helper.set(key, value));
mockHelper.getAccounts.mockImplementation(() => helper.getAccounts());
return mockHelper;
}

View File

@@ -0,0 +1,37 @@
// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages
import { LogService } from "../platform/abstractions/log.service";
// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations
import { AbstractStorageService } from "../platform/abstractions/storage.service";
export class MigrationHelper {
constructor(
public currentVersion: number,
private storageService: AbstractStorageService,
public logService: LogService
) {}
get<T>(key: string): Promise<T> {
return this.storageService.get<T>(key);
}
set<T>(key: string, value: T): Promise<void> {
this.logService.info(`Setting ${key}`);
return this.storageService.save(key, value);
}
info(message: string): void {
this.logService.info(message);
}
async getAccounts<ExpectedAccountType>(): Promise<
{ userId: string; account: ExpectedAccountType }[]
> {
const userIds = (await this.get<string[]>("authenticatedAccounts")) ?? [];
return Promise.all(
userIds.map(async (userId) => ({
userId,
account: await this.get<ExpectedAccountType>(userId),
}))
);
}
}

View File

@@ -0,0 +1,111 @@
import { MockProxy } from "jest-mock-extended";
// eslint-disable-next-line import/no-restricted-paths -- Used for testing migration, which requires import
import { TokenService } from "../../auth/services/token.service";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { FixPremiumMigrator } from "./3-fix-premium";
function migrateExampleJSON() {
return {
global: {
stateVersion: 2,
otherStuff: "otherStuff1",
},
authenticatedAccounts: [
"c493ed01-4e08-4e88-abc7-332f380ca760",
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
],
"c493ed01-4e08-4e88-abc7-332f380ca760": {
profile: {
otherStuff: "otherStuff2",
hasPremiumPersonally: null as boolean,
},
tokens: {
otherStuff: "otherStuff3",
accessToken: "accessToken",
},
otherStuff: "otherStuff4",
},
"23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
profile: {
otherStuff: "otherStuff5",
hasPremiumPersonally: true,
},
tokens: {
otherStuff: "otherStuff6",
accessToken: "accessToken",
},
otherStuff: "otherStuff7",
},
otherStuff: "otherStuff8",
};
}
jest.mock("../../auth/services/token.service", () => ({
TokenService: {
decodeToken: jest.fn(),
},
}));
describe("FixPremiumMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: FixPremiumMigrator;
const decodeTokenSpy = TokenService.decodeToken as jest.Mock;
beforeEach(() => {
helper = mockMigrationHelper(migrateExampleJSON());
sut = new FixPremiumMigrator(2, 3);
});
afterEach(() => {
jest.resetAllMocks();
});
describe("migrate", () => {
it("should migrate hasPremiumPersonally", async () => {
decodeTokenSpy.mockResolvedValueOnce({ premium: true });
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", {
profile: {
otherStuff: "otherStuff2",
hasPremiumPersonally: true,
},
tokens: {
otherStuff: "otherStuff3",
accessToken: "accessToken",
},
otherStuff: "otherStuff4",
});
});
it("should not migrate if decode throws", async () => {
decodeTokenSpy.mockRejectedValueOnce(new Error("test"));
await sut.migrate(helper);
expect(helper.set).not.toHaveBeenCalled();
});
it("should not migrate if decode returns null", async () => {
decodeTokenSpy.mockResolvedValueOnce(null);
await sut.migrate(helper);
expect(helper.set).not.toHaveBeenCalled();
});
});
describe("updateVersion", () => {
it("should update version", async () => {
await sut.updateVersion(helper, "up");
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("global", {
stateVersion: 3,
otherStuff: "otherStuff1",
});
});
});
});

View File

@@ -0,0 +1,48 @@
// eslint-disable-next-line import/no-restricted-paths -- Used for token decoding, which are valid for days. We want the latest
import { TokenService } from "../../auth/services/token.service";
import { MigrationHelper } from "../migration-helper";
import { Migrator, IRREVERSIBLE, Direction } from "../migrator";
type ExpectedAccountType = {
profile?: { hasPremiumPersonally?: boolean };
tokens?: { accessToken?: string };
};
export class FixPremiumMigrator extends Migrator<2, 3> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function fixPremium(userId: string, account: ExpectedAccountType) {
if (account?.profile?.hasPremiumPersonally === null && account.tokens?.accessToken != null) {
let decodedToken: { premium: boolean };
try {
decodedToken = await TokenService.decodeToken(account.tokens.accessToken);
} catch {
return;
}
if (decodedToken?.premium == null) {
return;
}
account.profile.hasPremiumPersonally = decodedToken?.premium;
return helper.set(userId, account);
}
}
await Promise.all(accounts.map(({ userId, account }) => fixPremium(userId, account)));
}
rollback(helper: MigrationHelper): Promise<void> {
throw IRREVERSIBLE;
}
// Override is necessary because default implementation assumes `stateVersion` at the root, but for this version
// it is nested inside a global object.
override async updateVersion(helper: MigrationHelper, direction: Direction): Promise<void> {
const endVersion = direction === "up" ? this.toVersion : this.fromVersion;
helper.currentVersion = endVersion;
const global: Record<string, unknown> = (await helper.get("global")) || {};
await helper.set("global", { ...global, stateVersion: endVersion });
}
}

View File

@@ -0,0 +1,75 @@
import { MockProxy } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { RemoveEverBeenUnlockedMigrator } from "./4-remove-ever-been-unlocked";
function migrateExampleJSON() {
return {
global: {
stateVersion: 3,
otherStuff: "otherStuff1",
},
authenticatedAccounts: [
"c493ed01-4e08-4e88-abc7-332f380ca760",
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
],
"c493ed01-4e08-4e88-abc7-332f380ca760": {
profile: {
otherStuff: "otherStuff2",
everBeenUnlocked: true,
},
otherStuff: "otherStuff3",
},
"23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
profile: {
otherStuff: "otherStuff4",
everBeenUnlocked: false,
},
otherStuff: "otherStuff5",
},
otherStuff: "otherStuff6",
};
}
describe("RemoveEverBeenUnlockedMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: RemoveEverBeenUnlockedMigrator;
beforeEach(() => {
helper = mockMigrationHelper(migrateExampleJSON());
sut = new RemoveEverBeenUnlockedMigrator(3, 4);
});
describe("migrate", () => {
it("should remove everBeenUnlocked from profile", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledTimes(2);
expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", {
profile: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
expect(helper.set).toHaveBeenCalledWith("23e61a5f-2ece-4f5e-b499-f0bc489482a9", {
profile: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
});
});
});
describe("updateVersion", () => {
it("should update version up", async () => {
await sut.updateVersion(helper, "up");
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("global", {
stateVersion: 4,
otherStuff: "otherStuff1",
});
});
});
});

View File

@@ -0,0 +1,32 @@
import { MigrationHelper } from "../migration-helper";
import { Direction, IRREVERSIBLE, Migrator } from "../migrator";
type ExpectedAccountType = { profile?: { everBeenUnlocked?: boolean } };
export class RemoveEverBeenUnlockedMigrator extends Migrator<3, 4> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function removeEverBeenUnlocked(userId: string, account: ExpectedAccountType) {
if (account?.profile?.everBeenUnlocked != null) {
delete account.profile.everBeenUnlocked;
return helper.set(userId, account);
}
}
Promise.all(accounts.map(({ userId, account }) => removeEverBeenUnlocked(userId, account)));
}
rollback(helper: MigrationHelper): Promise<void> {
throw IRREVERSIBLE;
}
// Override is necessary because default implementation assumes `stateVersion` at the root, but for this version
// it is nested inside a global object.
override async updateVersion(helper: MigrationHelper, direction: Direction): Promise<void> {
const endVersion = direction === "up" ? this.toVersion : this.fromVersion;
helper.currentVersion = endVersion;
const global: { stateVersion: number } = (await helper.get("global")) || ({} as any);
await helper.set("global", { ...global, stateVersion: endVersion });
}
}

View File

@@ -0,0 +1,141 @@
import { MockProxy } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { AddKeyTypeToOrgKeysMigrator } from "./5-add-key-type-to-org-keys";
function migrateExampleJSON() {
return {
global: {
stateVersion: 4,
otherStuff: "otherStuff1",
},
authenticatedAccounts: [
"c493ed01-4e08-4e88-abc7-332f380ca760",
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
],
"c493ed01-4e08-4e88-abc7-332f380ca760": {
keys: {
organizationKeys: {
encrypted: {
orgOneId: "orgOneEncKey",
orgTwoId: "orgTwoEncKey",
},
},
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
};
}
function rollbackExampleJSON() {
return {
global: {
stateVersion: 5,
otherStuff: "otherStuff1",
},
authenticatedAccounts: [
"c493ed01-4e08-4e88-abc7-332f380ca760",
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
],
"c493ed01-4e08-4e88-abc7-332f380ca760": {
keys: {
organizationKeys: {
encrypted: {
orgOneId: {
type: "organization",
key: "orgOneEncKey",
},
orgTwoId: {
type: "organization",
key: "orgTwoEncKey",
},
},
},
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
};
}
describe("AddKeyTypeToOrgKeysMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: AddKeyTypeToOrgKeysMigrator;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(migrateExampleJSON());
sut = new AddKeyTypeToOrgKeysMigrator(4, 5);
});
it("should add organization type to organization keys", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", {
keys: {
organizationKeys: {
encrypted: {
orgOneId: {
type: "organization",
key: "orgOneEncKey",
},
orgTwoId: {
type: "organization",
key: "orgTwoEncKey",
},
},
},
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should update version", async () => {
await sut.updateVersion(helper, "up");
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("global", {
stateVersion: 5,
otherStuff: "otherStuff1",
});
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackExampleJSON());
sut = new AddKeyTypeToOrgKeysMigrator(4, 5);
});
it("should remove type from orgainzation keys", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", {
keys: {
organizationKeys: {
encrypted: {
orgOneId: "orgOneEncKey",
orgTwoId: "orgTwoEncKey",
},
},
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should update version down", async () => {
await sut.updateVersion(helper, "down");
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("global", {
stateVersion: 4,
otherStuff: "otherStuff1",
});
});
});
});

View File

@@ -0,0 +1,67 @@
import { MigrationHelper } from "../migration-helper";
import { Direction, Migrator } from "../migrator";
type ExpectedAccountType = { keys?: { organizationKeys?: { encrypted: Record<string, string> } } };
type NewAccountType = {
keys?: {
organizationKeys?: { encrypted: Record<string, { type: "organization"; key: string }> };
};
};
export class AddKeyTypeToOrgKeysMigrator extends Migrator<4, 5> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts();
async function updateOrgKey(userId: string, account: ExpectedAccountType) {
const encryptedOrgKeys = account?.keys?.organizationKeys?.encrypted;
if (encryptedOrgKeys == null) {
return;
}
const newOrgKeys: Record<string, { type: "organization"; key: string }> = {};
Object.entries(encryptedOrgKeys).forEach(([orgId, encKey]) => {
newOrgKeys[orgId] = {
type: "organization",
key: encKey,
};
});
(account as any).keys.organizationKeys.encrypted = newOrgKeys;
await helper.set(userId, account);
}
Promise.all(accounts.map(({ userId, account }) => updateOrgKey(userId, account)));
}
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts();
async function updateOrgKey(userId: string, account: NewAccountType) {
const encryptedOrgKeys = account?.keys?.organizationKeys?.encrypted;
if (encryptedOrgKeys == null) {
return;
}
const newOrgKeys: Record<string, string> = {};
Object.entries(encryptedOrgKeys).forEach(([orgId, encKey]) => {
newOrgKeys[orgId] = encKey.key;
});
(account as any).keys.organizationKeys.encrypted = newOrgKeys;
await helper.set(userId, account);
}
Promise.all(accounts.map(async ({ userId, account }) => updateOrgKey(userId, account)));
}
// Override is necessary because default implementation assumes `stateVersion` at the root, but for this version
// it is nested inside a global object.
override async updateVersion(helper: MigrationHelper, direction: Direction): Promise<void> {
const endVersion = direction === "up" ? this.toVersion : this.fromVersion;
helper.currentVersion = endVersion;
const global: { stateVersion: number } = (await helper.get("global")) || ({} as any);
await helper.set("global", { ...global, stateVersion: endVersion });
}
}

View File

@@ -0,0 +1,80 @@
import { MockProxy } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { RemoveLegacyEtmKeyMigrator } from "./6-remove-legacy-etm-key";
function exampleJSON() {
return {
global: {
stateVersion: 5,
otherStuff: "otherStuff1",
},
authenticatedAccounts: [
"c493ed01-4e08-4e88-abc7-332f380ca760",
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
"fd005ea6-a16a-45ef-ba4a-a194269bfd73",
],
"c493ed01-4e08-4e88-abc7-332f380ca760": {
keys: {
legacyEtmKey: "legacyEtmKey",
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
keys: {
legacyEtmKey: "legacyEtmKey",
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
describe("RemoveLegacyEtmKeyMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: RemoveLegacyEtmKeyMigrator;
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON());
sut = new RemoveLegacyEtmKeyMigrator(5, 6);
});
describe("migrate", () => {
it("should remove legacyEtmKey from all accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", {
keys: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
expect(helper.set).toHaveBeenCalledWith("23e61a5f-2ece-4f5e-b499-f0bc489482a9", {
keys: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
});
});
});
describe("rollback", () => {
it("should throw", async () => {
await expect(sut.rollback(helper)).rejects.toThrow();
});
});
describe("updateVersion", () => {
it("should update version up", async () => {
await sut.updateVersion(helper, "up");
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("global", {
stateVersion: 6,
otherStuff: "otherStuff1",
});
});
});
});

View File

@@ -0,0 +1,32 @@
import { MigrationHelper } from "../migration-helper";
import { Direction, IRREVERSIBLE, Migrator } from "../migrator";
type ExpectedAccountType = { keys?: { legacyEtmKey?: string } };
export class RemoveLegacyEtmKeyMigrator extends Migrator<5, 6> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function updateAccount(userId: string, account: ExpectedAccountType) {
if (account?.keys?.legacyEtmKey) {
delete account.keys.legacyEtmKey;
await helper.set(userId, account);
}
}
await Promise.all(accounts.map(({ userId, account }) => updateAccount(userId, account)));
}
async rollback(helper: MigrationHelper): Promise<void> {
throw IRREVERSIBLE;
}
// Override is necessary because default implementation assumes `stateVersion` at the root, but for this version
// it is nested inside a global object.
override async updateVersion(helper: MigrationHelper, direction: Direction): Promise<void> {
const endVersion = direction === "up" ? this.toVersion : this.fromVersion;
helper.currentVersion = endVersion;
const global: { stateVersion: number } = (await helper.get("global")) || ({} as any);
await helper.set("global", { ...global, stateVersion: endVersion });
}
}

View File

@@ -0,0 +1,102 @@
import { MockProxy, any, matches } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { MoveBiometricAutoPromptToAccount } from "./7-move-biometric-auto-prompt-to-account";
function exampleJSON() {
return {
global: {
stateVersion: 6,
noAutoPromptBiometrics: true,
otherStuff: "otherStuff1",
},
authenticatedAccounts: [
"c493ed01-4e08-4e88-abc7-332f380ca760",
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
"fd005ea6-a16a-45ef-ba4a-a194269bfd73",
],
"c493ed01-4e08-4e88-abc7-332f380ca760": {
settings: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
settings: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
describe("RemoveLegacyEtmKeyMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: MoveBiometricAutoPromptToAccount;
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON());
sut = new MoveBiometricAutoPromptToAccount(6, 7);
});
describe("migrate", () => {
it("should remove noAutoPromptBiometrics from global", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("global", {
otherStuff: "otherStuff1",
stateVersion: 6,
});
});
it("should set disableAutoBiometricsPrompt to true on all accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", {
settings: {
disableAutoBiometricsPrompt: true,
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
expect(helper.set).toHaveBeenCalledWith("23e61a5f-2ece-4f5e-b499-f0bc489482a9", {
settings: {
disableAutoBiometricsPrompt: true,
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
});
});
it("should not set disableAutoBiometricsPrompt to true on accounts if noAutoPromptBiometrics is false", async () => {
const json = exampleJSON();
json.global.noAutoPromptBiometrics = false;
helper = mockMigrationHelper(json);
await sut.migrate(helper);
expect(helper.set).not.toHaveBeenCalledWith(
matches((s) => s != "global"),
any()
);
});
});
describe("rollback", () => {
it("should throw", async () => {
await expect(sut.rollback(helper)).rejects.toThrow();
});
});
describe("updateVersion", () => {
it("should update version up", async () => {
await sut.updateVersion(helper, "up");
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith(
"global",
Object.assign({}, exampleJSON().global, {
stateVersion: 7,
})
);
});
});
});

View File

@@ -0,0 +1,45 @@
import { MigrationHelper } from "../migration-helper";
import { Direction, IRREVERSIBLE, Migrator } from "../migrator";
type ExpectedAccountType = { settings?: { disableAutoBiometricsPrompt?: boolean } };
export class MoveBiometricAutoPromptToAccount extends Migrator<6, 7> {
async migrate(helper: MigrationHelper): Promise<void> {
const global = await helper.get<{ noAutoPromptBiometrics?: boolean }>("global");
const noAutoPromptBiometrics = global?.noAutoPromptBiometrics ?? false;
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function updateAccount(userId: string, account: ExpectedAccountType) {
if (account == null) {
return;
}
if (noAutoPromptBiometrics) {
account.settings = Object.assign(account?.settings ?? {}, {
disableAutoBiometricsPrompt: true,
});
await helper.set(userId, account);
}
}
delete global.noAutoPromptBiometrics;
await Promise.all([
...accounts.map(({ userId, account }) => updateAccount(userId, account)),
helper.set("global", global),
]);
}
async rollback(helper: MigrationHelper): Promise<void> {
throw IRREVERSIBLE;
}
// Override is necessary because default implementation assumes `stateVersion` at the root, but for this version
// it is nested inside a global object.
override async updateVersion(helper: MigrationHelper, direction: Direction): Promise<void> {
const endVersion = direction === "up" ? this.toVersion : this.fromVersion;
helper.currentVersion = endVersion;
const global: { stateVersion: number } = (await helper.get("global")) || ({} as any);
await helper.set("global", { ...global, stateVersion: endVersion });
}
}

View File

@@ -0,0 +1,90 @@
import { MockProxy } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { MoveStateVersionMigrator } from "./8-move-state-version";
function migrateExampleJSON() {
return {
global: {
stateVersion: 6,
otherStuff: "otherStuff1",
},
otherStuff: "otherStuff2",
};
}
function rollbackExampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
stateVersion: 7,
otherStuff: "otherStuff2",
};
}
describe("moveStateVersion", () => {
let helper: MockProxy<MigrationHelper>;
let sut: MoveStateVersionMigrator;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(migrateExampleJSON());
sut = new MoveStateVersionMigrator(7, 8);
});
it("should move state version to root", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("stateVersion", 6);
});
it("should remove state version from global", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("global", {
otherStuff: "otherStuff1",
});
});
it("should throw if state version not found", async () => {
helper.get.mockReturnValue({ otherStuff: "otherStuff1" } as any);
await expect(sut.migrate(helper)).rejects.toThrow(
"Migration failed, state version not found"
);
});
it("should update version up", async () => {
await sut.updateVersion(helper, "up");
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("stateVersion", 8);
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackExampleJSON());
sut = new MoveStateVersionMigrator(7, 8);
});
it("should move state version to global", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("global", {
stateVersion: 7,
otherStuff: "otherStuff1",
});
expect(helper.set).toHaveBeenCalledWith("stateVersion", undefined);
});
it("should update version down", async () => {
await sut.updateVersion(helper, "down");
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("global", {
stateVersion: 7,
otherStuff: "otherStuff1",
});
});
});
});

View File

@@ -0,0 +1,37 @@
import { JsonObject } from "type-fest";
import { MigrationHelper } from "../migration-helper";
import { Direction, Migrator } from "../migrator";
export class MoveStateVersionMigrator extends Migrator<7, 8> {
async migrate(helper: MigrationHelper): Promise<void> {
const global = await helper.get<{ stateVersion: number }>("global");
if (global.stateVersion) {
await helper.set("stateVersion", global.stateVersion);
delete global.stateVersion;
await helper.set("global", global);
} else {
throw new Error("Migration failed, state version not found");
}
}
async rollback(helper: MigrationHelper): Promise<void> {
const version = await helper.get<number>("stateVersion");
const global = await helper.get<JsonObject>("global");
await helper.set("global", { ...global, stateVersion: version });
await helper.set("stateVersion", undefined);
}
// Override is necessary because default implementation assumes `stateVersion` at the root, but this migration moves
// it from a `global` object to root.This makes for unique rollback versioning.
override async updateVersion(helper: MigrationHelper, direction: Direction): Promise<void> {
const endVersion = direction === "up" ? this.toVersion : this.fromVersion;
helper.currentVersion = endVersion;
if (direction === "up") {
await helper.set("stateVersion", endVersion);
} else {
const global: { stateVersion: number } = (await helper.get("global")) || ({} as any);
await helper.set("global", { ...global, stateVersion: endVersion });
}
}
}

View File

@@ -0,0 +1,29 @@
import { MockProxy } from "jest-mock-extended";
import { MIN_VERSION } from "../migrate";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { MinVersionMigrator } from "./min-version";
describe("MinVersionMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: MinVersionMigrator;
beforeEach(() => {
helper = mockMigrationHelper(null);
sut = new MinVersionMigrator();
});
describe("shouldMigrate", () => {
it("should return true if current version is less than min version", async () => {
helper.currentVersion = MIN_VERSION - 1;
expect(await sut.shouldMigrate(helper)).toBe(true);
});
it("should return false if current version is greater than min version", async () => {
helper.currentVersion = MIN_VERSION + 1;
expect(await sut.shouldMigrate(helper)).toBe(false);
});
});
});

View File

@@ -0,0 +1,26 @@
import { MinVersion, MIN_VERSION } from "../migrate";
import { MigrationHelper } from "../migration-helper";
import { IRREVERSIBLE, Migrator } from "../migrator";
export function minVersionError(current: number) {
return `Your local data is too old to be migrated. Your current state version is ${current}, but minimum version is ${MIN_VERSION}.`;
}
export class MinVersionMigrator extends Migrator<0, MinVersion> {
constructor() {
super(0, MIN_VERSION);
}
// Overrides the default implementation to catch any version that may be passed in.
override shouldMigrate(helper: MigrationHelper): Promise<boolean> {
return Promise.resolve(helper.currentVersion < MIN_VERSION);
}
async migrate(helper: MigrationHelper): Promise<void> {
if (helper.currentVersion < MIN_VERSION) {
throw new Error(minVersionError(helper.currentVersion));
}
}
async rollback(helper: MigrationHelper): Promise<void> {
throw IRREVERSIBLE;
}
}

View File

@@ -0,0 +1,75 @@
import { mock, MockProxy } from "jest-mock-extended";
// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages
import { LogService } from "../platform/abstractions/log.service";
// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations
import { AbstractStorageService } from "../platform/abstractions/storage.service";
import { MigrationHelper } from "./migration-helper";
import { Migrator } from "./migrator";
describe("migrator default methods", () => {
class TestMigrator extends Migrator<0, 1> {
async migrate(helper: MigrationHelper): Promise<void> {
await helper.set("test", "test");
}
async rollback(helper: MigrationHelper): Promise<void> {
await helper.set("test", "rollback");
}
}
let storage: MockProxy<AbstractStorageService>;
let logService: MockProxy<LogService>;
let helper: MigrationHelper;
let sut: TestMigrator;
beforeEach(() => {
storage = mock();
logService = mock();
helper = new MigrationHelper(0, storage, logService);
sut = new TestMigrator(0, 1);
});
describe("shouldMigrate", () => {
describe("up", () => {
it("should return true if the current version equals the from version", async () => {
expect(await sut.shouldMigrate(helper, "up")).toBe(true);
});
it("should return false if the current version does not equal the from version", async () => {
helper.currentVersion = 1;
expect(await sut.shouldMigrate(helper, "up")).toBe(false);
});
});
describe("down", () => {
it("should return true if the current version equals the to version", async () => {
helper.currentVersion = 1;
expect(await sut.shouldMigrate(helper, "down")).toBe(true);
});
it("should return false if the current version does not equal the to version", async () => {
expect(await sut.shouldMigrate(helper, "down")).toBe(false);
});
});
});
describe("updateVersion", () => {
describe("up", () => {
it("should update the version", async () => {
await sut.updateVersion(helper, "up");
expect(storage.save).toBeCalledWith("stateVersion", 1);
expect(helper.currentVersion).toBe(1);
});
});
describe("down", () => {
it("should update the version", async () => {
helper.currentVersion = 1;
await sut.updateVersion(helper, "down");
expect(storage.save).toBeCalledWith("stateVersion", 0);
expect(helper.currentVersion).toBe(0);
});
});
});
});

View File

@@ -0,0 +1,40 @@
import { NonNegativeInteger } from "type-fest";
import { MigrationHelper } from "./migration-helper";
export const IRREVERSIBLE = new Error("Irreversible migration");
export type VersionFrom<T> = T extends Migrator<infer TFrom, number>
? TFrom extends NonNegativeInteger<TFrom>
? TFrom
: never
: never;
export type VersionTo<T> = T extends Migrator<number, infer TTo>
? TTo extends NonNegativeInteger<TTo>
? TTo
: never
: never;
export type Direction = "up" | "down";
export abstract class Migrator<TFrom extends number, TTo extends number> {
constructor(public fromVersion: TFrom, public toVersion: TTo) {
if (fromVersion == null || toVersion == null) {
throw new Error("Invalid migration");
}
if (fromVersion > toVersion) {
throw new Error("Invalid migration");
}
}
shouldMigrate(helper: MigrationHelper, direction: Direction): Promise<boolean> {
const startVersion = direction === "up" ? this.fromVersion : this.toVersion;
return Promise.resolve(helper.currentVersion === startVersion);
}
abstract migrate(helper: MigrationHelper): Promise<void>;
abstract rollback(helper: MigrationHelper): Promise<void>;
async updateVersion(helper: MigrationHelper, direction: Direction): Promise<void> {
const endVersion = direction === "up" ? this.toVersion : this.fromVersion;
helper.currentVersion = endVersion;
await helper.set("stateVersion", endVersion);
}
}