Skip to content

Commit b257a52

Browse files
authored
impr: add fallback and migrate to parsejsonwithschema (@Miodec) (monkeytypegame#6518)
!nuf
1 parent fc2b051 commit b257a52

File tree

10 files changed

+231
-112
lines changed

10 files changed

+231
-112
lines changed

frontend/__tests__/utils/local-storage-with-schema.spec.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,16 @@ describe("local-storage-with-schema.ts", () => {
6868
expect(res).toEqual(defaultObject);
6969
});
7070

71-
it("should revert to the fallback value and remove if localstorage json is malformed", () => {
71+
it("should revert to the fallback value if localstorage json is malformed", () => {
7272
getItemMock.mockReturnValue("badjson");
7373

7474
const res = ls.get();
7575

7676
expect(localStorage.getItem).toHaveBeenCalledWith("config");
77-
expect(localStorage.removeItem).toHaveBeenCalledWith("config");
78-
expect(localStorage.setItem).not.toHaveBeenCalled();
77+
expect(localStorage.setItem).toHaveBeenCalledWith(
78+
"config",
79+
JSON.stringify(defaultObject)
80+
);
7981
expect(res).toEqual(defaultObject);
8082
});
8183

@@ -132,8 +134,7 @@ describe("local-storage-with-schema.ts", () => {
132134
expect(localStorage.getItem).toHaveBeenCalledWith("config");
133135
expect(migrateFnMock).toHaveBeenCalledWith(
134136
existingValue,
135-
expect.any(Array),
136-
defaultObject
137+
expect.any(Array)
137138
);
138139
expect(localStorage.setItem).toHaveBeenCalledWith(
139140
"config",
@@ -167,8 +168,7 @@ describe("local-storage-with-schema.ts", () => {
167168
expect(localStorage.getItem).toHaveBeenCalledWith("config");
168169
expect(migrateFnMock).toHaveBeenCalledWith(
169170
existingValue,
170-
expect.any(Array),
171-
defaultObject
171+
expect.any(Array)
172172
);
173173
expect(localStorage.setItem).toHaveBeenCalledWith(
174174
"config",

frontend/__tests__/utils/url-handler.spec.ts

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -251,16 +251,7 @@ describe("url-handler", () => {
251251

252252
//THEN
253253
expect(addNotificationMock).toHaveBeenCalledWith(
254-
`Failed to load test settings from URL: \"0\" Invalid enum value. Expected 'time' | 'words' | 'quote' | 'custom' | 'zen', received 'invalidMode'
255-
\"1\" Needs to be a number or a number represented as a string e.g. \"10\".
256-
\"2.text\" Expected array, received string
257-
\"2.mode\" Invalid enum value. Expected 'repeat' | 'random' | 'shuffle', received 'invalid'
258-
\"2.limit\" Expected object, received string
259-
\"2.pipeDelimiter\" Expected boolean, received string
260-
\"3\" Expected boolean, received string
261-
\"4\" Expected boolean, received string
262-
\"6\" Invalid enum value. Expected 'normal' | 'expert' | 'master', received 'invalid'
263-
\"7\" Invalid input`,
254+
`Failed to load test settings from URL: JSON does not match schema: \"0\" invalid enum value. expected 'time' | 'words' | 'quote' | 'custom' | 'zen', received 'invalidmode', \"1\" needs to be a number or a number represented as a string e.g. \"10\"., \"2.text\" expected array, received string, \"2.mode\" invalid enum value. expected 'repeat' | 'random' | 'shuffle', received 'invalid', \"2.limit\" expected object, received string, \"2.pipeDelimiter\" expected boolean, received string, \"3\" expected boolean, received string, \"4\" expected boolean, received string, \"6\" invalid enum value. expected 'normal' | 'expert' | 'master', received 'invalid', \"7\" invalid input`,
264255
0
265256
);
266257
});

frontend/src/ts/commandline/lists.ts

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -94,21 +94,17 @@ import * as Misc from "../utils/misc";
9494
import * as JSONData from "../utils/json-data";
9595
import { randomizeTheme } from "../controllers/theme-controller";
9696
import * as CustomTextPopup from "../modals/custom-text";
97-
import * as Settings from "../pages/settings";
9897
import * as Notifications from "../elements/notifications";
9998
import * as VideoAdPopup from "../popups/video-ad-popup";
10099
import * as ShareTestSettingsPopup from "../modals/share-test-settings";
101100
import * as TestStats from "../test/test-stats";
102101
import * as QuoteSearchModal from "../modals/quote-search";
103102
import * as FPSCounter from "../elements/fps-counter";
104-
import { migrateConfig } from "../utils/config";
105103
import {
106104
CustomBackgroundSchema,
107105
CustomLayoutFluid,
108-
PartialConfigSchema,
109106
} from "@monkeytype/contracts/schemas/configs";
110107
import { Command, CommandsSubgroup } from "./types";
111-
import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json";
112108
import * as TestLogic from "../test/test-logic";
113109
import * as ActivePage from "../states/active-page";
114110

@@ -378,21 +374,7 @@ export const commands: CommandsSubgroup = {
378374
input: true,
379375
exec: async ({ input }): Promise<void> => {
380376
if (input === undefined || input === "") return;
381-
try {
382-
const parsedConfig = parseJsonWithSchema(
383-
input,
384-
PartialConfigSchema.strip()
385-
);
386-
await UpdateConfig.apply(migrateConfig(parsedConfig));
387-
UpdateConfig.saveFullConfigToLocalStorage();
388-
void Settings.update();
389-
Notifications.add("Done", 1);
390-
} catch (e) {
391-
Notifications.add(
392-
"An error occured while importing settings: " + e,
393-
-1
394-
);
395-
}
377+
await UpdateConfig.applyFromJson(input);
396378
},
397379
},
398380
{

frontend/src/ts/config.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
canSetFunboxWithConfig,
1515
} from "./test/funbox/funbox-validation";
1616
import {
17+
createErrorMessage,
1718
isDevEnvironment,
1819
isObject,
1920
promiseWithResolvers,
@@ -28,6 +29,7 @@ import { LocalStorageWithSchema } from "./utils/local-storage-with-schema";
2829
import { migrateConfig } from "./utils/config";
2930
import { roundTo1 } from "@monkeytype/util/numbers";
3031
import { getDefaultConfig } from "./constants/default-config";
32+
import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json";
3133

3234
const configLS = new LocalStorageWithSchema({
3335
key: "config",
@@ -2132,6 +2134,30 @@ export function getConfigChanges(): Partial<Config> {
21322134
return configChanges;
21332135
}
21342136

2137+
export async function applyFromJson(json: string): Promise<void> {
2138+
try {
2139+
const parsedConfig = parseJsonWithSchema(
2140+
json,
2141+
ConfigSchemas.PartialConfigSchema.strip(),
2142+
{
2143+
migrate: (value) => {
2144+
if (Array.isArray(value)) {
2145+
throw new Error("Invalid config");
2146+
}
2147+
return migrateConfig(value);
2148+
},
2149+
}
2150+
);
2151+
await apply(parsedConfig);
2152+
saveFullConfigToLocalStorage();
2153+
Notifications.add("Done", 1);
2154+
} catch (e) {
2155+
const msg = createErrorMessage(e, "Failed to import settings");
2156+
console.error(msg);
2157+
Notifications.add(msg, -1);
2158+
}
2159+
}
2160+
21352161
const { promise: loadPromise, resolve: loadDone } = promiseWithResolvers();
21362162

21372163
export { loadPromise };

frontend/src/ts/modals/import-export-settings.ts

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
1-
import { PartialConfigSchema } from "@monkeytype/contracts/schemas/configs";
21
import * as UpdateConfig from "../config";
3-
import * as Notifications from "../elements/notifications";
42
import AnimatedModal from "../utils/animated-modal";
5-
import { migrateConfig } from "../utils/config";
6-
import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json";
73

84
type State = {
95
mode: "import" | "export";
@@ -47,23 +43,7 @@ const modal = new AnimatedModal({
4743
void modal.hide();
4844
return;
4945
}
50-
try {
51-
const parsedConfig = parseJsonWithSchema(
52-
state.value,
53-
PartialConfigSchema.strip()
54-
);
55-
await UpdateConfig.apply(migrateConfig(parsedConfig));
56-
} catch (e) {
57-
Notifications.add(
58-
"Failed to import settings: incorrect data schema",
59-
0
60-
);
61-
console.error(e);
62-
void modal.hide();
63-
return;
64-
}
65-
Notifications.add("Settings imported", 1);
66-
UpdateConfig.saveFullConfigToLocalStorage();
46+
await UpdateConfig.applyFromJson(state.value);
6747
void modal.hide();
6848
});
6949
},

frontend/src/ts/test/custom-text.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
import { LocalStorageWithSchema } from "../utils/local-storage-with-schema";
88
import { z } from "zod";
99
import { CustomTextDataWithTextLen } from "@monkeytype/contracts/schemas/results";
10+
import { deepClone } from "../utils/misc";
1011

1112
const CustomTextObjectSchema = z.record(z.string(), z.string());
1213
type CustomTextObject = z.infer<typeof CustomTextObjectSchema>;
@@ -51,7 +52,9 @@ const customTextSettings = new LocalStorageWithSchema({
5152
key: "customTextSettings",
5253
schema: CustomTextSettingsSchema,
5354
fallback: defaultCustomTextSettings,
54-
migrate: (oldData, _zodIssues, fallback) => {
55+
migrate: (oldData, _zodIssues) => {
56+
const fallback = deepClone(defaultCustomTextSettings);
57+
5558
if (typeof oldData !== "object" || oldData === null) {
5659
return fallback;
5760
}
@@ -60,7 +63,7 @@ const customTextSettings = new LocalStorageWithSchema({
6063
"text" in oldData &&
6164
z.array(z.string()).safeParse(migratedData.text).success
6265
) {
63-
migratedData.text = oldData.text as string[];
66+
migratedData.text = oldData["text"] as string[];
6467
}
6568
return migratedData;
6669
},

frontend/src/ts/utils/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import { getDefaultConfig } from "../constants/default-config";
1414
* @returns
1515
*/
1616
export function migrateConfig(config: PartialConfig | object): Config {
17+
//todo this assumes config is matching all schemas
18+
//i think we should go through each value and validate
1719
return mergeWithDefaultConfig(replaceLegacyValues(config));
1820
}
1921

frontend/src/ts/utils/local-storage-with-schema.ts

Lines changed: 27 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,26 @@
11
import { ZodIssue } from "zod";
2-
import { deepClone } from "./misc";
32
import { isZodError } from "@monkeytype/util/zod";
43
import * as Notifications from "../elements/notifications";
54
import { tryCatchSync } from "@monkeytype/util/trycatch";
5+
import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json";
66

77
export class LocalStorageWithSchema<T> {
88
private key: string;
99
private schema: Zod.Schema<T>;
1010
private fallback: T;
11-
private migrate?: (value: unknown, zodIssues: ZodIssue[], fallback: T) => T;
11+
private migrate?: (
12+
value: Record<string, unknown> | unknown[],
13+
zodIssues?: ZodIssue[]
14+
) => T;
1215

1316
constructor(options: {
1417
key: string;
1518
schema: Zod.Schema<T>;
1619
fallback: T;
17-
migrate?: (value: unknown, zodIssues: ZodIssue[], fallback: T) => T;
20+
migrate?: (
21+
value: Record<string, unknown> | unknown[],
22+
zodIssues?: ZodIssue[]
23+
) => T;
1824
}) {
1925
this.key = options.key;
2026
this.schema = options.schema;
@@ -29,49 +35,31 @@ export class LocalStorageWithSchema<T> {
2935
return this.fallback;
3036
}
3137

32-
const { data: jsonParsed, error } = tryCatchSync(
33-
() => JSON.parse(value) as unknown
38+
let migrated = false;
39+
const { data: parsed, error } = tryCatchSync(() =>
40+
parseJsonWithSchema(value, this.schema, {
41+
fallback: this.fallback,
42+
migrate: (oldData, zodIssues) => {
43+
migrated = true;
44+
if (this.migrate) {
45+
return this.migrate(oldData, zodIssues);
46+
} else {
47+
return this.fallback;
48+
}
49+
},
50+
})
3451
);
52+
3553
if (error) {
36-
console.log(
37-
`Value from localStorage ${this.key} was not a valid JSON, using fallback`,
38-
error
39-
);
40-
window.localStorage.removeItem(this.key);
54+
window.localStorage.setItem(this.key, JSON.stringify(this.fallback));
4155
return this.fallback;
4256
}
4357

44-
const schemaParsed = this.schema.safeParse(jsonParsed);
45-
46-
if (schemaParsed.success) {
47-
return schemaParsed.data;
48-
}
49-
50-
console.log(
51-
`Value from localStorage ${this.key} failed schema validation, migrating`,
52-
schemaParsed.error.issues
53-
);
54-
55-
let newValue = this.fallback;
56-
if (this.migrate) {
57-
const migrated = this.migrate(
58-
jsonParsed,
59-
schemaParsed.error.issues,
60-
deepClone(this.fallback)
61-
);
62-
const parse = this.schema.safeParse(migrated);
63-
if (parse.success) {
64-
newValue = migrated;
65-
} else {
66-
console.error(
67-
`Value from localStorage ${this.key} failed schema validation after migration! This is very bad!`,
68-
parse.error.issues
69-
);
70-
}
58+
if (migrated || parsed === this.fallback) {
59+
window.localStorage.setItem(this.key, JSON.stringify(parsed));
7160
}
7261

73-
window.localStorage.setItem(this.key, JSON.stringify(newValue));
74-
return newValue;
62+
return parsed;
7563
}
7664

7765
public set(data: T): boolean {

0 commit comments

Comments
 (0)