From 1033bb4a297243a5b75d0a21860cec113c080d5e Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Thu, 29 Aug 2024 01:09:18 +1000 Subject: [PATCH] Implement migration from v0.1.0 -> v0.2.0 --- src/lib/server/auth.ts | 2 +- src/lib/server/data/config.ts | 13 ++ src/lib/server/data/index.ts | 11 +- src/lib/server/data/item.ts | 16 ++- src/lib/server/data/migrations/index.ts | 19 +++ src/lib/server/data/migrations/v0.1.0.ts | 146 +++++++++++++++++++++++ 6 files changed, 200 insertions(+), 7 deletions(-) create mode 100644 src/lib/server/data/migrations/index.ts create mode 100644 src/lib/server/data/migrations/v0.1.0.ts diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 3fa35ad9..f096db88 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -184,7 +184,7 @@ export function hashAndSalt(salt: string, password: string): string { * This is responsible for generating and storing a secure password, thereby * creating the default "admin" account. */ -export async function authSetup(cookies: Cookies): Promise { +export async function authSetup(cookies?: Cookies): Promise { const username = 'admin'; // generate password using 4 random dictionary words diff --git a/src/lib/server/data/config.ts b/src/lib/server/data/config.ts index 4fcca09b..58c21e2d 100644 --- a/src/lib/server/data/config.ts +++ b/src/lib/server/data/config.ts @@ -27,6 +27,19 @@ export const ConfigJsonStruct = object({ /** Main configuration for the portfolio, `config.json` */ export type ConfigJson = Infer; +/** + * Return the version of the configuration. Used to determine if a migration is + * necessary. + * + * This does not validate any of the data. + */ +export async function getConfigVersion(): Promise { + const data = JSON.parse(await readFile(CONFIG_JSON, { encoding: 'utf-8' })); + + // Note: v0.1.0 did not have a version number in the file + return data.version ?? '0.1.0'; +} + /** Return the configuration, stored in `/data/config.json` */ export async function getConfig(): Promise { const data = await readFile(CONFIG_JSON, { encoding: 'utf-8' }); diff --git a/src/lib/server/data/index.ts b/src/lib/server/data/index.ts index 5179c951..007862ca 100644 --- a/src/lib/server/data/index.ts +++ b/src/lib/server/data/index.ts @@ -9,10 +9,12 @@ * of effort for very little gain. */ -import { getConfig, type ConfigJson } from './config'; +import { version } from '$app/environment'; +import { getConfig, getConfigVersion, type ConfigJson } from './config'; import { getGroupData, listGroups, type GroupData } from './group'; import { getItemData, listItems, type ItemData } from './item'; import { invalidateLocalConfigCache } from './localConfig'; +import migrate from './migrations'; import { getReadme } from './readme'; /** Public global data for the portfolio */ @@ -30,6 +32,13 @@ export type PortfolioGlobals = { * references are valid). */ async function loadPortfolioGlobals(): Promise { + // Check if a migration is needed + const dataVersion = await getConfigVersion(); + + if (dataVersion !== version) { + await migrate(dataVersion); + } + const config = await getConfig(); const readme = await getReadme(); diff --git a/src/lib/server/data/item.ts b/src/lib/server/data/item.ts index 094aa5d0..5187fd2b 100644 --- a/src/lib/server/data/item.ts +++ b/src/lib/server/data/item.ts @@ -56,6 +56,7 @@ export const LinksArray = array( object({ groupId: string(), style: LinkStyleStruct, + title: string(), }), array(string()), ]) @@ -71,17 +72,17 @@ export const ItemInfoFullStruct = intersection([ /** URLs associated with the label */ urls: object({ /** URL of the source repository of the label */ - repo: optional(RepoInfoStruct), + repo: nullable(RepoInfoStruct), /** URL of the site demonstrating the label */ - site: optional(string()), + site: nullable(string()), /** URL of the documentation site for the label */ - docs: optional(string()), + docs: nullable(string()), }), /** Information about the package distribution of the label */ - package: optional(PackageInfoStruct), + package: nullable(PackageInfoStruct), }), ]); @@ -173,7 +174,12 @@ export async function createItem(groupId: string, itemId: string, name: string, // TODO: Generate a random color for the new item color: '#aa00aa', links: [], - urls: {}, + urls: { + repo: null, + site: null, + docs: null + }, + package: null, icon: null, banner: null, }); diff --git a/src/lib/server/data/migrations/index.ts b/src/lib/server/data/migrations/index.ts new file mode 100644 index 00000000..794937f9 --- /dev/null +++ b/src/lib/server/data/migrations/index.ts @@ -0,0 +1,19 @@ +import { getDataDir } from '../dataDir'; +import migrateFromV010 from './v0.1.0'; + +/** Perform a migration from the given version */ +export default async function migrate(oldVersion: string) { + console.log(`Data directory uses version ${oldVersion}. Migration needed`); + try { + if (oldVersion === '0.1.0') { + await migrateFromV010(getDataDir()); + } else { + console.log(`Migrate: unrecognised old version ${oldVersion}`); + process.exit(1); + } + } catch (e) { + console.log('!!! Error during migration'); + console.log(e); + process.exit(1); + } +} diff --git a/src/lib/server/data/migrations/v0.1.0.ts b/src/lib/server/data/migrations/v0.1.0.ts new file mode 100644 index 00000000..de2e521f --- /dev/null +++ b/src/lib/server/data/migrations/v0.1.0.ts @@ -0,0 +1,146 @@ +/** + * Migration from v0.1.0 to v0.2.0 + * + * Since this will likely only be used by me, it probably won't be all that + * reliable. + * + * # Instructions + * + * 1. Ensure your data is backed up to a git repo + * 2. Remove the data directory to reset the server + * 3. Set up the server using the git repo URL + * 4. Note the new auth information + * + * # Incompatible data + * + * Some data is lost in this migration (mainly because I couldn't be bothered + * to implement anything more than the minimum). + * + * * `README.md` clobbered by `info.md`. + * * Sorting and listing of items within groups. All items are listed. + * * Ordering of links (formerly associations) in items. Old data format did + * not order them. + * * Link style (can't be bothered to look up data in old format). + * * Filter groups per group. + */ + +import fs from 'fs/promises'; +import { setConfig, type ConfigJson } from '../config'; +import { listGroups, setGroupInfo } from '../group'; +import type { RepoInfo } from '../itemRepo'; +import type { PackageInfo } from '../itemPackage'; +import { LinksArray, listItems, setItemInfo } from '../item'; +import type { Infer } from 'superstruct'; +import { version } from '$app/environment'; + +export default async function migrate(dataDir: string) { + console.log(`Begin data migration v0.1.0 -> ${version}`); + await config(dataDir); + await readme(dataDir); + + // For each group + for (const groupId of await listGroups()) { + // Migrate each item + for (const itemId of await listItems(groupId)) { + await itemInfo(dataDir, groupId, itemId); + } + // Then migrate the group info + await groupInfo(dataDir, groupId); + } + console.log('Data migration complete'); +} + +/** Migrate config.json */ +async function config(dataDir: string) { + console.log(' config.json'); + const configJsonPath = `${dataDir}/config.json`; + const oldConfig: { name: string } = JSON.parse(await fs.readFile(configJsonPath, { encoding: 'utf-8' })); + + const groupsList = await listGroups(); + + const newConfig: ConfigJson = { + siteName: oldConfig.name, + // Assumes that all groups are visible, since there is no visibility + // setting for groups in 0.1.0 + listedGroups: groupsList, + color: '#ffaaff', + version: '0.2.0', + }; + + await setConfig(newConfig); +} + +/** Migrate info.md -> README.md */ +async function readme(dataDir: string) { + console.log(' info.md -> README.md'); + await fs.unlink(`${dataDir}/README.md`); + await fs.rename(`${dataDir}/info.md`, `${dataDir}/README.md`); +} + +async function itemInfo(dataDir: string, groupId: string, itemId: string) { + console.log(` Item: ${groupId}/${itemId}`); + + const itemPath = `${dataDir}/${groupId}/${itemId}`; + const item: { + name: string, + description: string, + color: string, + /** External links (not internal associative links) */ + links?: { + repo?: RepoInfo, + site?: string, + docs?: string, + }, + /** Internal (associative) links */ + associations: Record, + icon?: string, + banner?: string, + package?: PackageInfo, + } = JSON.parse(await fs.readFile(`${itemPath}/info.json`, { encoding: 'utf-8' })); + + const links: Infer = []; + + if (item.links) { + for (const linkedGroup of Object.keys(item.associations)) { + links.push([{ groupId: linkedGroup, title: linkedGroup, style: 'chip' }, item.associations[linkedGroup]]); + } + } + + await setItemInfo(groupId, itemId, { + name: item.name, + description: item.description, + color: item.color, + icon: item.icon ?? null, + banner: item.banner ?? null, + links, + urls: { + repo: item.links?.repo ?? null, + site: item.links?.site ?? null, + docs: item.links?.docs ?? null, + }, + package: item.package ?? null, + }); +} + +async function groupInfo(dataDir: string, groupId: string) { + console.log(` Group: ${groupId}`); + + const groupPath = `${dataDir}/${groupId}`; + const group: { + name: string, + description: string, + color: string, + } = JSON.parse(await fs.readFile(`${groupPath}/info.json`, { encoding: 'utf-8' })); + + await setGroupInfo(groupId, { + name: group.name, + description: group.description, + color: group.color, + icon: null, + banner: null, + // Filter using all groups + filterGroups: await listGroups(), + // List all items + listedItems: await listItems(groupId), + }); +}