diff --git a/package.json b/package.json index b232d5de..4926225e 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,8 @@ "dependencies": { "@antwika/common": "0.0.17", "mongodb": "^4.6.0", - "mongodb-memory-server": "^8.6.0" + "mongodb-memory-server": "^8.6.0", + "zod": "^3.17.3" }, "files": [ "bin", diff --git a/src/IStore.ts b/src/IStore.ts index f3a10983..0df1a5d4 100644 --- a/src/IStore.ts +++ b/src/IStore.ts @@ -22,4 +22,5 @@ export interface IStore { readAll: () => Promise[]>; update: (data: WithId) => Promise; delete: (id: DataId) => Promise; + replace: (data: WithId) => Promise; } diff --git a/src/MemoryStore.ts b/src/MemoryStore.ts index 6775ef7b..a7323081 100644 --- a/src/MemoryStore.ts +++ b/src/MemoryStore.ts @@ -50,11 +50,15 @@ export class MemoryStore implements IStore { } async update(data: WithId) { - this.database[data.id] = data; + this.database[data.id] = { ...this.database[data.id], ...data }; } async delete(id: DataId) { if (!this.database[id]) throw new Error('Could not delete non-existant data'); delete this.database[id]; } + + async replace(data: WithId) { + this.database[data.id] = data; + } } diff --git a/src/Migrator.ts b/src/Migrator.ts new file mode 100644 index 00000000..940b9aae --- /dev/null +++ b/src/Migrator.ts @@ -0,0 +1,62 @@ +import { IStore } from './IStore'; +import { Migration } from './schema/Migration'; + +type MigrationStatus = { + atTimestamp: Date, +}; + +export class Migrator { + private migrationStatus: MigrationStatus; + + constructor() { + this.migrationStatus = { + atTimestamp: new Date('1970-01-01'), + }; + } + + async upgrade(store: IStore, migrations: Migration[]) { + const sorted = migrations.sort((a, b) => ( + new Date(a.timestamp) > new Date(b.timestamp) ? 1 : -1 + )); + + console.log('sorted migrations:', sorted); + + for (const migration of migrations) { + if (new Date(migration.timestamp) <= this.migrationStatus.atTimestamp) { + console.log(`Already migrated: ${migration.name}`); + // eslint-disable-next-line no-continue + continue; + } + + console.log(`Running migration: ${migration.name}`); + // eslint-disable-next-line no-await-in-loop + await migration.up(store); + + console.log('setting atTimestamp:', new Date(migration.timestamp)); + this.migrationStatus.atTimestamp = new Date(migration.timestamp); + } + } + + async downgrade(store: IStore, migrations: Migration[]) { + const sorted = migrations.sort((a, b) => ( + new Date(a.timestamp) <= new Date(b.timestamp) ? 1 : -1 + )); + + console.log('reverse sorted migrations:', sorted); + + for (const migration of migrations) { + if (new Date(migration.timestamp) > this.migrationStatus.atTimestamp) { + console.log(`Already migrated: ${migration.name}`); + // eslint-disable-next-line no-continue + continue; + } + + console.log(`Running migration (downgrade): ${migration.name}`); + // eslint-disable-next-line no-await-in-loop + await migration.down(store); + + console.log('setting atTimestamp:', new Date(migration.timestamp)); + this.migrationStatus.atTimestamp = new Date(migration.timestamp); + } + } +} diff --git a/src/MongoDbStore.ts b/src/MongoDbStore.ts index 022aea53..c42ee9b7 100644 --- a/src/MongoDbStore.ts +++ b/src/MongoDbStore.ts @@ -182,6 +182,17 @@ export class MongoDbStore implements IStore { } } + async replace(data: WithId) { + const connection = await this.getConnection(); + const database = connection.db(this.database); + const collection = database.collection(this.collection); + await collection.replaceOne( + { _id: new ObjectId(this.ensureHex(data.id, 24)) }, + { ...data, _id: new ObjectId(this.ensureHex(data.id, 24)) }, + { upsert: false }, + ); + } + /** * @deprecated */ diff --git a/src/schema/Migration.ts b/src/schema/Migration.ts new file mode 100644 index 00000000..ecd71d76 --- /dev/null +++ b/src/schema/Migration.ts @@ -0,0 +1,8 @@ +import { IStore } from '../IStore'; + +export type Migration = { + name: string, + timestamp: string, + up: (store: IStore) => Promise; + down: (store: IStore) => Promise; +}; diff --git a/src/schema/User.ts b/src/schema/User.ts new file mode 100644 index 00000000..675884d2 --- /dev/null +++ b/src/schema/User.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +export const UserV1Schema = z.object({ + firstname: z.string(), + lastname: z.string(), +}); + +export type UserV1 = z.infer; + +export const UserV2Schema = z.object({ + firstname: z.string(), + lastname: z.string(), + fullname: z.string(), +}); + +export type UserV2 = z.infer; + +export const UserV3Schema = z.object({ + firstname: z.string(), + lastname: z.string(), + fullname: z.string(), + shortname: z.string(), +}); + +export type UserV3 = z.infer; diff --git a/test/migration.test.ts b/test/migration.test.ts new file mode 100644 index 00000000..28d5ebae --- /dev/null +++ b/test/migration.test.ts @@ -0,0 +1,59 @@ +/* eslint-disable no-lone-blocks */ +import { IStore, WithId } from '../src/IStore'; +import { MemoryStore } from '../src/MemoryStore'; +import { migrationV2 } from './migrations/migrationV2'; +import { migrationV3 } from './migrations/migrationV3'; +import { Migrator } from '../src/Migrator'; +import { UserV1, UserV3 } from '../src/schema/User'; + +describe('migration', () => { + let store: IStore; + + beforeAll(async () => { + store = new MemoryStore(); + await store.connect(); + }); + + afterAll(async () => { + await store.disconnect(); + }); + + // eslint-disable-next-line jest/expect-expect + it('migrates.', async () => { + const userV1: UserV1 = { firstname: 'Anna', lastname: 'Andersson' }; + + const user = await store.createWithoutId(userV1); + + const migrator = new Migrator(); + + await migrator.upgrade(store, [ + migrationV2, + migrationV3, + ]); + + { + const found: WithId = await store.read(user.id); + expect(found).toStrictEqual({ + id: expect.any(String), + firstname: 'Anna', + lastname: 'Andersson', + fullname: 'Anna Andersson', + shortname: 'AnnAnd', + }); + } + + await migrator.downgrade(store, [ + migrationV2, + migrationV3, + ]); + + { + const found: WithId = await store.read(user.id); + expect(found).toStrictEqual({ + id: expect.any(String), + firstname: 'Anna', + lastname: 'Andersson', + }); + } + }); +}); diff --git a/test/migrations/migrationV2.ts b/test/migrations/migrationV2.ts new file mode 100644 index 00000000..7351febc --- /dev/null +++ b/test/migrations/migrationV2.ts @@ -0,0 +1,30 @@ +import { IStore } from '../../src/IStore'; +import { Migration } from '../../src/schema/Migration'; +import { UserV1, UserV2 } from '../../src/schema/User'; + +export const migrationV2: Migration = { + name: 'add "fullname".', + timestamp: new Date('2000-01-01').toISOString(), + up: async (store: IStore) => { + const users = await store.readAll(); + for (const user of users) { + const userV2 = { + ...user, + fullname: `${user.firstname} ${user.lastname}`, + }; + // eslint-disable-next-line no-await-in-loop + await store.update(userV2); + } + }, + down: async (store: IStore) => { + const users = await store.readAll(); + for (const user of users) { + // eslint-disable-next-line no-await-in-loop + await store.replace({ + id: user.id, + firstname: user.firstname, + lastname: user.lastname, + }); + } + }, +}; diff --git a/test/migrations/migrationV3.ts b/test/migrations/migrationV3.ts new file mode 100644 index 00000000..a083dfb8 --- /dev/null +++ b/test/migrations/migrationV3.ts @@ -0,0 +1,31 @@ +import { IStore } from '../../src/IStore'; +import { Migration } from '../../src/schema/Migration'; +import { UserV2, UserV3 } from '../../src/schema/User'; + +export const migrationV3: Migration = { + name: 'add "shortname".', + timestamp: new Date('2000-01-02').toISOString(), + up: async (store: IStore) => { + const users = await store.readAll(); + for (const user of users) { + const userV3 = { + ...user, + shortname: `${user.firstname.substring(0, 3)}${user.lastname.substring(0, 3)}`, + }; + // eslint-disable-next-line no-await-in-loop + await store.update(userV3); + } + }, + down: async (store: IStore) => { + const users = await store.readAll(); + for (const user of users) { + // eslint-disable-next-line no-await-in-loop + await store.replace({ + id: user.id, + firstname: user.firstname, + lastname: user.lastname, + fullname: user.fullname, + }); + } + }, +}; diff --git a/yarn.lock b/yarn.lock index 571d00d4..fe0b0f16 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5413,3 +5413,8 @@ yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +zod@^3.17.3: + version "3.17.3" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.17.3.tgz#86abbc670ff0063a4588d85a4dcc917d6e4af2ba" + integrity sha512-4oKP5zvG6GGbMlqBkI5FESOAweldEhSOZ6LI6cG+JzUT7ofj1ZOC0PJudpQOpT1iqOFpYYtX5Pw0+o403y4bcg==