Made type-safe versioned store, migrated to it

Co-authored-by: mrjvs <mistrjvs@gmail.com>
This commit is contained in:
Jip Fr 2023-02-12 00:41:55 +01:00
parent dd14b575eb
commit 942a6cc9c0
10 changed files with 408 additions and 485 deletions

View File

@ -9,6 +9,7 @@ import "@/setup/i18n";
import "@/setup/index.css"; import "@/setup/index.css";
import "@/backend"; import "@/backend";
import { initializeChromecast } from "./setup/chromecast"; import { initializeChromecast } from "./setup/chromecast";
import { initializeStores } from "./utils/storage";
// initialize // initialize
const key = const key =
@ -42,12 +43,19 @@ initializeChromecast();
// TODO general todos: // TODO general todos:
// - localize everything (fix loading screen text (series vs movies)) // - localize everything (fix loading screen text (series vs movies))
const LazyLoadedApp = React.lazy(async () => {
await initializeStores();
return {
default: App,
};
});
ReactDOM.render( ReactDOM.render(
<React.StrictMode> <React.StrictMode>
<ErrorBoundary> <ErrorBoundary>
<HashRouter> <HashRouter>
<Suspense fallback=""> <Suspense fallback="">
<App /> <LazyLoadedApp />
</Suspense> </Suspense>
</HashRouter> </HashRouter>
</ErrorBoundary> </ErrorBoundary>

View File

@ -1,17 +1,8 @@
import { MWMediaMeta } from "@/backend/metadata/types"; import { MWMediaMeta } from "@/backend/metadata/types";
import { import { useStore } from "@/utils/storage";
createContext, import { createContext, ReactNode, useContext, useMemo } from "react";
ReactNode,
useCallback,
useContext,
useMemo,
useState,
} from "react";
import { BookmarkStore } from "./store"; import { BookmarkStore } from "./store";
import { BookmarkStoreData } from "./types";
interface BookmarkStoreData {
bookmarks: MWMediaMeta[];
}
interface BookmarkStoreDataWrapper { interface BookmarkStoreDataWrapper {
setItemBookmark(media: MWMediaMeta, bookedmarked: boolean): void; setItemBookmark(media: MWMediaMeta, bookedmarked: boolean): void;
@ -36,25 +27,7 @@ function getBookmarkIndexFromMedia(
} }
export function BookmarkContextProvider(props: { children: ReactNode }) { export function BookmarkContextProvider(props: { children: ReactNode }) {
const bookmarkLocalstorage = BookmarkStore.get(); const [bookmarkStorage, setBookmarked] = useStore(BookmarkStore);
const [bookmarkStorage, setBookmarkStore] = useState<BookmarkStoreData>(
bookmarkLocalstorage as BookmarkStoreData
);
const setBookmarked = useCallback(
(data: any) => {
setBookmarkStore((old) => {
const old2 = JSON.parse(JSON.stringify(old));
let newData = data;
if (data.constructor === Function) {
newData = data(old2);
}
bookmarkLocalstorage.save(newData);
return newData;
});
},
[bookmarkLocalstorage, setBookmarkStore]
);
const contextValue = useMemo( const contextValue = useMemo(
() => ({ () => ({

View File

@ -1,17 +1,18 @@
import { versionedStoreBuilder } from "@/utils/storage"; import { createVersionedStore } from "@/utils/storage";
import { BookmarkStoreData } from "./types";
export const BookmarkStore = versionedStoreBuilder() export const BookmarkStore = createVersionedStore<BookmarkStoreData>()
.setKey("mw-bookmarks") .setKey("mw-bookmarks")
.addVersion({ .addVersion({
version: 0, version: 0,
migrate() {
return {
bookmarks: [], // TODO migrate bookmarks
};
},
}) })
.addVersion({ .addVersion({
version: 1, version: 1,
migrate() {
return {
bookmarks: [],
};
},
create() { create() {
return { return {
bookmarks: [], bookmarks: [],

View File

@ -0,0 +1,5 @@
import { MWMediaMeta } from "@/backend/metadata/types";
export interface BookmarkStoreData {
bookmarks: MWMediaMeta[];
}

View File

@ -1,5 +1,6 @@
import { DetailedMeta } from "@/backend/metadata/getmeta"; import { DetailedMeta } from "@/backend/metadata/getmeta";
import { MWMediaMeta, MWMediaType } from "@/backend/metadata/types"; import { MWMediaType } from "@/backend/metadata/types";
import { useStore } from "@/utils/storage";
import { import {
createContext, createContext,
ReactNode, ReactNode,
@ -7,9 +8,9 @@ import {
useContext, useContext,
useMemo, useMemo,
useRef, useRef,
useState,
} from "react"; } from "react";
import { VideoProgressStore } from "./store"; import { VideoProgressStore } from "./store";
import { StoreMediaItem, WatchedStoreItem, WatchedStoreData } from "./types";
const FIVETEEN_MINUTES = 15 * 60; const FIVETEEN_MINUTES = 15 * 60;
const FIVE_MINUTES = 5 * 60; const FIVE_MINUTES = 5 * 60;
@ -34,29 +35,8 @@ function shouldSave(
return true; return true;
} }
interface MediaItem {
meta: MWMediaMeta;
series?: {
episodeId: string;
seasonId: string;
episode: number;
season: number;
};
}
export interface WatchedStoreItem {
item: MediaItem;
progress: number;
percentage: number;
watchedAt: number;
}
export interface WatchedStoreData {
items: WatchedStoreItem[];
}
interface WatchedStoreDataWrapper { interface WatchedStoreDataWrapper {
updateProgress(media: MediaItem, progress: number, total: number): void; updateProgress(media: StoreMediaItem, progress: number, total: number): void;
getFilteredWatched(): WatchedStoreItem[]; getFilteredWatched(): WatchedStoreItem[];
removeProgress(id: string): void; removeProgress(id: string): void;
watched: WatchedStoreData; watched: WatchedStoreData;
@ -72,7 +52,7 @@ const WatchedContext = createContext<WatchedStoreDataWrapper>({
}); });
WatchedContext.displayName = "WatchedContext"; WatchedContext.displayName = "WatchedContext";
function isSameEpisode(media: MediaItem, v: MediaItem) { function isSameEpisode(media: StoreMediaItem, v: StoreMediaItem) {
return ( return (
media.meta.id === v.meta.id && media.meta.id === v.meta.id &&
(!media.series || (!media.series ||
@ -82,24 +62,7 @@ function isSameEpisode(media: MediaItem, v: MediaItem) {
} }
export function WatchedContextProvider(props: { children: ReactNode }) { export function WatchedContextProvider(props: { children: ReactNode }) {
const watchedLocalstorage = VideoProgressStore.get(); const [watched, setWatched] = useStore(VideoProgressStore);
const [watched, setWatchedReal] = useState<WatchedStoreData>(
watchedLocalstorage as WatchedStoreData
);
const setWatched = useCallback(
(data: any) => {
setWatchedReal((old) => {
let newData = data;
if (data.constructor === Function) {
newData = data(old);
}
watchedLocalstorage.save(newData);
return newData;
});
},
[setWatchedReal, watchedLocalstorage]
);
const contextValue = useMemo( const contextValue = useMemo(
() => ({ () => ({
@ -110,7 +73,11 @@ export function WatchedContextProvider(props: { children: ReactNode }) {
return newData; return newData;
}); });
}, },
updateProgress(media: MediaItem, progress: number, total: number): void { updateProgress(
media: StoreMediaItem,
progress: number,
total: number
): void {
setWatched((data: WatchedStoreData) => { setWatched((data: WatchedStoreData) => {
const newData = { ...data }; const newData = { ...data };
let item = newData.items.find((v) => isSameEpisode(media, v.item)); let item = newData.items.find((v) => isSameEpisode(media, v.item));
@ -176,7 +143,7 @@ export function useWatchedContext() {
} }
function isSameEpisodeMeta( function isSameEpisodeMeta(
media: MediaItem, media: StoreMediaItem,
mediaTwo: DetailedMeta | null, mediaTwo: DetailedMeta | null,
episodeId?: string episodeId?: string
) { ) {

View File

@ -0,0 +1,170 @@
import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta";
import { searchForMedia } from "@/backend/metadata/search";
import { MWMediaMeta, MWMediaType } from "@/backend/metadata/types";
import { WatchedStoreData, WatchedStoreItem } from "../types";
interface OldMediaBase {
mediaId: number;
mediaType: MWMediaType;
percentage: number;
progress: number;
providerId: string;
title: string;
year: number;
}
interface OldMovie extends OldMediaBase {
mediaType: MWMediaType.MOVIE;
}
interface OldSeries extends OldMediaBase {
mediaType: MWMediaType.SERIES;
episodeId: number;
seasonId: number;
}
export interface OldData {
items: (OldMovie | OldSeries)[];
}
export async function migrateV2(old: OldData) {
const oldData = old;
if (!oldData) return;
const uniqueMedias: Record<string, any> = {};
oldData.items.forEach((item: any) => {
if (uniqueMedias[item.mediaId]) return;
uniqueMedias[item.mediaId] = item;
});
const yearsAreClose = (a: number, b: number) => {
return Math.abs(a - b) <= 1;
};
const mediaMetas: Record<string, Record<string, DetailedMeta | null>> = {};
const relevantItems = await Promise.all(
Object.values(uniqueMedias).map(async (item) => {
const year = Number(item.year.toString().split("-")[0]);
const data = await searchForMedia({
searchQuery: `${item.title} ${year}`,
type: item.mediaType,
});
const relevantItem = data.find((res) =>
yearsAreClose(Number(res.year), year)
);
if (!relevantItem) {
console.error("No item");
return;
}
return {
id: item.mediaId,
data: relevantItem,
};
})
);
for (const item of relevantItems.filter(Boolean)) {
if (!item) continue;
let keys: (string | null)[][] = [["0", "0"]];
if (item.data.type === "series") {
// TODO sort episodes by season & episode so it shows the "highest" episode as last
const meta = await getMetaFromId(item.data.type, item.data.id);
if (!meta || !meta?.meta.seasons) return;
const seasonNumbers = [
...new Set(
oldData.items
.filter((watchedEntry: any) => watchedEntry.mediaId === item.id)
.map((watchedEntry: any) => watchedEntry.seasonId)
),
];
const seasons = seasonNumbers.map((num) => ({
num,
season: meta.meta?.seasons?.[(num as number) - 1],
}));
keys = seasons
.map((season) => (season ? [season.num, season?.season?.id] : []))
.filter((entry) => entry.length > 0);
}
if (!mediaMetas[item.id]) mediaMetas[item.id] = {};
await Promise.all(
keys.map(async ([key, id]) => {
if (!key) return;
mediaMetas[item.id][key] = await getMetaFromId(
item.data.type,
item.data.id,
id === "0" || id === null ? undefined : id
);
})
);
}
// We've got all the metadata you can dream of now
// Now let's convert stuff into the new format.
const newData: WatchedStoreData = {
...oldData,
items: [],
};
for (const oldWatched of oldData.items) {
if (oldWatched.mediaType === "movie") {
if (!mediaMetas[oldWatched.mediaId]["0"]?.meta) continue;
const newItem: WatchedStoreItem = {
item: {
meta: mediaMetas[oldWatched.mediaId]["0"]?.meta as MWMediaMeta,
},
progress: oldWatched.progress,
percentage: oldWatched.percentage,
watchedAt: Date.now(), // There was no watchedAt in V2
};
oldData.items = oldData.items.filter(
(item) => JSON.stringify(item) !== JSON.stringify(oldWatched)
);
newData.items.push(newItem);
} else if (oldWatched.mediaType === "series") {
if (!mediaMetas[oldWatched.mediaId][oldWatched.seasonId]?.meta) continue;
const meta = mediaMetas[oldWatched.mediaId][oldWatched.seasonId]
?.meta as MWMediaMeta;
if (meta.type !== "series") return;
const newItem: WatchedStoreItem = {
item: {
meta,
series: {
episode: Number(oldWatched.episodeId),
season: Number(oldWatched.seasonId),
seasonId: meta.seasonData.id,
episodeId:
meta.seasonData.episodes[Number(oldWatched.episodeId) - 1].id,
},
},
progress: oldWatched.progress,
percentage: oldWatched.percentage,
watchedAt: Date.now(), // There was no watchedAt in V2
// Put watchedAt in the future to show last episode as most recently
};
if (
newData.items.find(
(item) =>
item.item.meta.id === newItem.item.meta.id &&
item.item.series?.episodeId === newItem.item.series?.episodeId
)
)
continue;
oldData.items = oldData.items.filter(
(item) => JSON.stringify(item) !== JSON.stringify(oldWatched)
);
newData.items.push(newItem);
}
}
return newData;
}

View File

@ -1,57 +1,25 @@
import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta"; import { createVersionedStore } from "@/utils/storage";
import { searchForMedia } from "@/backend/metadata/search"; import { migrateV2, OldData } from "./migrations/v2";
import { MWMediaMeta, MWMediaType } from "@/backend/metadata/types"; import { WatchedStoreData } from "./types";
import { versionedStoreBuilder } from "@/utils/storage";
import { WatchedStoreData, WatchedStoreItem } from "./context";
interface OldMediaBase { export const VideoProgressStore = createVersionedStore<WatchedStoreData>()
mediaId: number;
mediaType: MWMediaType;
percentage: number;
progress: number;
providerId: string;
title: string;
year: number;
}
interface OldMovie extends OldMediaBase {
mediaType: MWMediaType.MOVIE;
}
interface OldSeries extends OldMediaBase {
mediaType: MWMediaType.SERIES;
episodeId: number;
seasonId: number;
}
interface OldData {
items: (OldMovie | OldSeries)[];
}
export const VideoProgressStore = versionedStoreBuilder()
.setKey("video-progress") .setKey("video-progress")
.addVersion({ .addVersion({
version: 0, version: 0,
migrate() {
return {
items: [], // dont migrate from version 0 to version 1, unmigratable
};
},
}) })
.addVersion({ .addVersion({
version: 1, version: 1,
migrate() { async migrate(old: OldData) {
return { return migrateV2(old);
items: [],
};
}, },
}) })
.addVersion({ .addVersion({
version: 2, version: 2,
migrate(old: OldData) {
requestAnimationFrame(() => {
// eslint-disable-next-line no-use-before-define
migrateV2(old);
});
return {
items: [],
};
},
create() { create() {
return { return {
items: [], items: [],
@ -59,153 +27,3 @@ export const VideoProgressStore = versionedStoreBuilder()
}, },
}) })
.build(); .build();
async function migrateV2(old: OldData) {
const oldData = old;
if (!oldData) return;
const uniqueMedias: Record<string, any> = {};
oldData.items.forEach((item: any) => {
if (uniqueMedias[item.mediaId]) return;
uniqueMedias[item.mediaId] = item;
});
const yearsAreClose = (a: number, b: number) => {
return Math.abs(a - b) <= 1;
};
const mediaMetas: Record<string, Record<string, DetailedMeta | null>> = {};
const relevantItems = await Promise.all(
Object.values(uniqueMedias).map(async (item) => {
const year = Number(item.year.toString().split("-")[0]);
const data = await searchForMedia({
searchQuery: `${item.title} ${year}`,
type: item.mediaType,
});
const relevantItem = data.find((res) =>
yearsAreClose(Number(res.year), year)
);
if (!relevantItem) {
console.error("No item");
return;
}
return {
id: item.mediaId,
data: relevantItem,
};
})
);
for (const item of relevantItems.filter(Boolean)) {
if (!item) continue;
let keys: (string | null)[][] = [["0", "0"]];
if (item.data.type === "series") {
const meta = await getMetaFromId(item.data.type, item.data.id);
if (!meta || !meta?.meta.seasons) return;
const seasonNumbers = [
...new Set(
oldData.items
.filter((watchedEntry: any) => watchedEntry.mediaId === item.id)
.map((watchedEntry: any) => watchedEntry.seasonId)
),
];
const seasons = seasonNumbers
.map((num) => ({
num,
season: meta.meta?.seasons?.[(num as number) - 1],
}))
.filter(Boolean);
keys = seasons
.map((season) => (season ? [season.num, season?.season?.id] : []))
.filter((entry) => entry.length > 0); // Stupid TypeScript
}
if (!mediaMetas[item.id]) mediaMetas[item.id] = {};
await Promise.all(
keys.map(async ([key, id]) => {
if (!key) return;
mediaMetas[item.id][key] = await getMetaFromId(
item.data.type,
item.data.id,
id === "0" || id === null ? undefined : id
);
})
);
}
// We've got all the metadata you can dream of now
// Now let's convert stuff into the new format.
interface WatchedStoreDataWithVersion extends WatchedStoreData {
"--version": number;
}
const newData: WatchedStoreDataWithVersion = {
...oldData,
items: [],
"--version": 2,
};
for (const oldWatched of oldData.items) {
if (oldWatched.mediaType === "movie") {
if (!mediaMetas[oldWatched.mediaId]["0"]?.meta) continue;
const newItem: WatchedStoreItem = {
item: {
meta: mediaMetas[oldWatched.mediaId]["0"]?.meta as MWMediaMeta,
},
progress: oldWatched.progress,
percentage: oldWatched.percentage,
watchedAt: Date.now(), // There was no watchedAt in V2
};
oldData.items = oldData.items.filter(
(item) => JSON.stringify(item) !== JSON.stringify(oldWatched)
);
newData.items.push(newItem);
} else if (oldWatched.mediaType === "series") {
if (!mediaMetas[oldWatched.mediaId][oldWatched.seasonId]?.meta) continue;
const meta = mediaMetas[oldWatched.mediaId][oldWatched.seasonId]
?.meta as MWMediaMeta;
if (meta.type !== "series") return;
const newItem: WatchedStoreItem = {
item: {
meta,
series: {
episode: Number(oldWatched.episodeId),
season: Number(oldWatched.seasonId),
seasonId: meta.seasonData.id,
episodeId:
meta.seasonData.episodes[Number(oldWatched.episodeId) - 1].id,
},
},
progress: oldWatched.progress,
percentage: oldWatched.percentage,
watchedAt: Date.now(), // There was no watchedAt in V2
};
if (
newData.items.find(
(item) =>
item.item.meta.id === newItem.item.meta.id &&
item.item.series?.episodeId === newItem.item.series?.episodeId
)
)
continue;
oldData.items = oldData.items.filter(
(item) => JSON.stringify(item) !== JSON.stringify(oldWatched)
);
newData.items.push(newItem);
}
}
console.log(JSON.stringify(old), JSON.stringify(newData));
if (JSON.stringify(old.items) !== JSON.stringify(newData.items)) {
console.log(newData);
VideoProgressStore.get().save(newData);
}
}

View File

@ -0,0 +1,22 @@
import { MWMediaMeta } from "@/backend/metadata/types";
export interface StoreMediaItem {
meta: MWMediaMeta;
series?: {
episodeId: string;
seasonId: string;
episode: number;
season: number;
};
}
export interface WatchedStoreItem {
item: StoreMediaItem;
progress: number;
percentage: number;
watchedAt: number;
}
export interface WatchedStoreData {
items: WatchedStoreItem[];
}

View File

@ -1,232 +1,188 @@
// TODO make type and react safe!! import { useEffect, useState } from "react";
/*
it needs to be react-ified by having a save function not on the instance itself.
also type safety is important, this is all spaghetti with "any" everywhere
*/
function buildStoreObject(d: any) { interface StoreVersion<A> {
const data: any = { version: number;
versions: d.versions, migrate?(data: A): any;
currentVersion: d.maxVersion, create?: () => A;
id: d.storageString, }
interface StoreRet<T> {
save: (data: T) => void;
get: () => T;
_raw: () => any;
onChange: (cb: (data: T) => void) => {
destroy: () => void;
}; };
}
function update(this: any, obj2: any) { export interface StoreBuilder<T> {
let obj = obj2; setKey: (key: string) => StoreBuilder<T>;
if (!obj) throw new Error("object to update is not an object"); addVersion: <A>(ver: StoreVersion<A>) => StoreBuilder<T>;
build: () => StoreRet<T>;
}
// repeat until object fully updated interface InternalStoreData {
if (obj["--version"] === undefined) obj["--version"] = 0; versions: StoreVersion<any>[];
while (obj["--version"] !== this.currentVersion) { key: string | null;
// get version }
let version: any = obj["--version"] || 0;
if (version.constructor !== Number || version < 0) version = -42;
// invalid on purpose so it will reset
else {
version = ((version as number) + 1).toString();
}
// check if version exists const storeCallbacks: Record<string, ((data: any) => void)[]> = {};
if (!this.versions[version]) { const stores: Record<string, [StoreRet<any>, InternalStoreData]> = {};
console.error(
`Version not found for storage item in store ${this.id}, resetting`
);
obj = null;
break;
}
// update object export async function initializeStores() {
obj = this.versions[version].update(obj); // migrate all stores
for (const [store, internal] of Object.values(stores)) {
const versions = internal.versions.sort((a, b) => a.version - b.version);
const data = store._raw();
const dataVersion =
data["--version"] && typeof data["--version"] === "number"
? data["--version"]
: 0;
// Find which versions need to be used for migrations
const relevantVersions = versions.filter((v) => v.version >= dataVersion);
// Migrate over each version
let mostRecentData = data;
for (const version of relevantVersions) {
if (version.migrate)
mostRecentData = await version.migrate(mostRecentData);
} }
// if resulting obj is null, use latest version as init object store.save(mostRecentData);
if (obj === null) {
console.error(
`Storage item for store ${this.id} has been reset due to faulty updates`
);
return this.versions[this.currentVersion.toString()].init();
}
// updates succesful, return
return obj;
} }
}
function get(this: any) { function buildStorageObject<T>(store: InternalStoreData): StoreRet<T> {
// get from storage api const key = store.key ?? "";
const store = this; const latestVersion = store.versions.sort((a, b) => b.version - a.version)[0];
let gottenData: any = localStorage.getItem(this.id);
// parse json if item exists function onChange(cb: (data: T) => void) {
if (gottenData) { if (!storeCallbacks[key]) storeCallbacks[key] = [];
try { storeCallbacks[key].push(cb);
gottenData = JSON.parse(gottenData); return {
if (!gottenData.constructor) { destroy() {
console.error( // remove function pointer from callbacks
`Storage item for store ${this.id} has not constructor` storeCallbacks[key] = storeCallbacks[key].filter((v) => v === cb);
); },
throw new Error("storage item has no constructor");
}
if (gottenData.constructor !== Object) {
console.error(`Storage item for store ${this.id} is not an object`);
throw new Error("storage item is not an object");
}
} catch (_) {
// if errored, set to null so it generates new one, see below
console.error(`Failed to parse storage item for store ${this.id}`);
gottenData = null;
}
}
// if item doesnt exist, generate from version init
if (!gottenData) {
gottenData = this.versions[this.currentVersion.toString()].init();
}
// update the data if needed
gottenData = this.update(gottenData);
// add a save object to return value
gottenData.save = function save(newData: any) {
const dataToStore = newData || gottenData;
localStorage.setItem(store.id, JSON.stringify(dataToStore));
}; };
// add instance helpers
Object.entries(d.instanceHelpers).forEach(([name, helper]: any) => {
if (gottenData[name] !== undefined)
throw new Error(
`helper name: ${name} on instance of store ${this.id} is reserved`
);
gottenData[name] = helper.bind(gottenData);
});
// return data
return gottenData;
} }
// add functions to store function makeRaw() {
data.get = get.bind(data); const data = latestVersion.create?.() ?? {};
data.update = update.bind(data); data["--version"] = latestVersion.version;
return data;
}
// add static helpers function getRaw() {
Object.entries(d.staticHelpers).forEach(([name, helper]: any) => { const item = localStorage.getItem(key);
if (data[name] !== undefined) if (!item) return makeRaw();
throw new Error(`helper name: ${name} on store ${data.id} is reserved`); try {
data[name] = helper.bind({}); return JSON.parse(item);
} catch (err) {
// we assume user has fucked with the data, give them a fresh store
console.error(`FAILED TO PARSE LOCALSTORAGE FOR KEY ${key}`, err);
return makeRaw();
}
}
function save(data: T) {
const withVersion: any = { ...data };
withVersion["--version"] = latestVersion.version;
localStorage.setItem(key, JSON.stringify(withVersion));
if (!storeCallbacks[key]) storeCallbacks[key] = [];
storeCallbacks[key].forEach((v) => v(structuredClone(data)));
}
return {
get() {
const data = getRaw();
delete data["--version"];
return data as T;
},
_raw() {
return getRaw();
},
onChange,
save,
};
}
function assertStore(store: InternalStoreData) {
const versionListSorted = store.versions.sort(
(a, b) => a.version - b.version
);
versionListSorted.forEach((v, i, arr) => {
if (i === 0) return;
if (v.version !== arr[i - 1].version + 1)
throw new Error("Version list of store is not incremental");
});
versionListSorted.forEach((v) => {
if (v.version < 0) throw new Error("Versions cannot be negative");
}); });
return data; // version zero must exist
if (versionListSorted[0]?.version !== 0)
throw new Error("Version 0 doesn't exist in version list of store");
// max version must have create function
if (!store.versions[store.versions.length - 1].create)
throw new Error(`Missing create function on latest version of store`);
// check storage string
if (!store.key) throw new Error("storage key not set in store");
// check if all parts have migratio
const migrations = [...versionListSorted];
migrations.pop();
migrations.forEach((v) => {
if (!v.migrate)
throw new Error(`Migration missing on version ${v.version}`);
});
} }
/* export function createVersionedStore<T>(): StoreBuilder<T> {
* Builds a versioned store const _data: InternalStoreData = {
* versions: [],
* manages versioning of localstorage items key: null,
*/ };
export function versionedStoreBuilder(): any {
return { return {
_data: { setKey(key) {
versionList: [], _data.key = key;
maxVersion: 0,
versions: {},
storageString: undefined,
instanceHelpers: {},
staticHelpers: {},
},
setKey(str: string) {
this._data.storageString = str;
return this; return this;
}, },
addVersion(ver) {
addVersion({ version, migrate, create }: any) { _data.versions.push(ver);
// input checking
if (version < 0) throw new Error("Cannot add version below 0 in store");
if (version > 0 && !migrate)
throw new Error(
`Missing migration on version ${version} (needed for any version above 0)`
);
// update max version list
if (version > this._data.maxVersion) this._data.maxVersion = version;
// add to version list
this._data.versionList.push(version);
// register version
this._data.versions[version.toString()] = {
version, // version number
update: migrate
? (data: any) => {
// update function, and increment version
const newData = migrate(data);
newData["--version"] = version; // eslint-disable-line no-param-reassign
return newData;
}
: undefined,
init: create
? () => {
// return an initial object
const data = create();
data["--version"] = version;
return data;
}
: undefined,
};
return this; return this;
}, },
registerHelper({ name, helper, type }: any) {
// type
let helperType: string = type;
if (!helperType) helperType = "instance";
// input checking
if (!name || name.constructor !== String) {
throw new Error("helper name is not a string");
}
if (!helper || helper.constructor !== Function) {
throw new Error("helper function is not a function");
}
if (!["instance", "static"].includes(helperType)) {
throw new Error("helper type must be either 'instance' or 'static'");
}
// register helper
if (helperType === "instance")
this._data.instanceHelpers[name as string] = helper;
else if (helperType === "static")
this._data.staticHelpers[name as string] = helper;
return this;
},
build() { build() {
// check if version list doesnt skip versions assertStore(_data);
const versionListSorted = this._data.versionList.sort( const storageObject = buildStorageObject<T>(_data);
(a: number, b: number) => a - b stores[_data.key ?? ""] = [storageObject, _data];
); return storageObject;
versionListSorted.forEach((v: any, i: number, arr: any[]) => {
if (i === 0) return;
if (v !== arr[i - 1] + 1)
throw new Error("Version list of store is not incremental");
});
// version zero must exist
if (versionListSorted[0] !== 0)
throw new Error("Version 0 doesn't exist in version list of store");
// max version must have init function
if (!this._data.versions[this._data.maxVersion.toString()].init)
throw new Error(
`Missing create function on version ${this._data.maxVersion} (needed for latest version of store)`
);
// check storage string
if (!this._data.storageString)
throw new Error("storage key not set in store");
// build versioned store
return buildStoreObject(this._data);
}, },
}; };
} }
export function useStore<T>(
store: StoreRet<T>
): [T, (cb: (old: T) => T) => void] {
const [data, setData] = useState<T>(store.get());
useEffect(() => {
const { destroy } = store.onChange((newData) => {
setData(newData);
});
return () => {
destroy();
};
}, [store]);
function setNewData(cb: (old: T) => T) {
const newData = cb(data);
store.save(newData);
}
return [data, setNewData];
}

View File

@ -1,6 +1,10 @@
import { versionedStoreBuilder } from "@/utils/storage"; import { createVersionedStore } from "@/utils/storage";
export const volumeStore = versionedStoreBuilder() interface VolumeStoreData {
volume: number;
}
export const volumeStore = createVersionedStore<VolumeStoreData>()
.setKey("mw-volume") .setKey("mw-volume")
.addVersion({ .addVersion({
version: 0, version: 0,
@ -18,8 +22,7 @@ export function getStoredVolume(): number {
} }
export function setStoredVolume(volume: number) { export function setStoredVolume(volume: number) {
const store = volumeStore.get(); volumeStore.save({
store.save({
volume, volume,
}); });
} }