Skip to content

Commit a4cfbb4

Browse files
authored
refactor: decouple collections from db/snapshot (@fehmer) (monkeytypegame#7917)
1 parent c5da846 commit a4cfbb4

10 files changed

Lines changed: 118 additions & 95 deletions

File tree

frontend/__tests__/controllers/preset-controller.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ describe("PresetController", () => {
5656

5757
dbGetSnapshotMock.mockReturnValue({} as any);
5858
configApplyMock.mockResolvedValue();
59+
tagsClearMock.mockResolvedValue();
5960
});
6061

6162
it("should apply for full preset", async () => {

frontend/src/ts/ape/user.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { GetUserResponse } from "@monkeytype/contracts/users";
2+
import Ape from ".";
3+
import { createEffectOn } from "../hooks/effects";
4+
import { isAuthenticated } from "../states/core";
5+
import { SnapshotInitError } from "../utils/snapshot-init-error";
6+
7+
type CacheType = GetUserResponse["data"];
8+
9+
let fetchPromise: Promise<void> | null = null;
10+
let cache: CacheType | undefined = undefined;
11+
12+
export async function fetchUserFromApi(): Promise<CacheType | undefined> {
13+
await sync();
14+
return cache;
15+
}
16+
17+
async function sync(): Promise<void> {
18+
if (!isAuthenticated()) {
19+
return;
20+
}
21+
22+
if (cache !== undefined) return;
23+
24+
fetchPromise ??= (async () => {
25+
const response = await Ape.users.get();
26+
27+
if (response.status !== 200) {
28+
throw new SnapshotInitError(
29+
`${response.body.message} (user)`,
30+
response.status,
31+
);
32+
}
33+
34+
cache = response.body.data;
35+
})();
36+
37+
try {
38+
await fetchPromise;
39+
} finally {
40+
fetchPromise = null;
41+
}
42+
}
43+
44+
function reset(): void {
45+
cache = undefined;
46+
fetchPromise = null;
47+
}
48+
49+
// clear cache + reset promise on logout
50+
createEffectOn(isAuthenticated, (isAuthenticated) => {
51+
if (!isAuthenticated) {
52+
reset();
53+
}
54+
});

frontend/src/ts/auth.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from "firebase/auth";
1010

1111
import Ape from "./ape";
12+
import { waitForPresetsReady } from "./collections/presets";
1213
import { updateFromServer as updateConfigFromServer } from "./config/remote";
1314
import * as DB from "./db";
1415
import { authEvent } from "./events/auth";
@@ -30,6 +31,7 @@ import {
3031
showSuccessNotification,
3132
} from "./states/notifications";
3233
import { createErrorMessage } from "./utils/error";
34+
import { SnapshotInitError } from "./utils/snapshot-init-error";
3335

3436
export type AuthResult =
3537
| {
@@ -64,6 +66,8 @@ async function getDataAndInit(): Promise<boolean> {
6466
try {
6567
console.log("getting account data");
6668
const snapshot = await DB.initSnapshot();
69+
//TODO: always load presets for now, remove when __nonReactive is removed from presets collection
70+
await waitForPresetsReady();
6771

6872
if (snapshot === false) {
6973
throw new Error(
@@ -77,7 +81,7 @@ async function getDataAndInit(): Promise<boolean> {
7781
return true;
7882
} catch (error) {
7983
console.error(error);
80-
if (error instanceof DB.SnapshotInitError) {
84+
if (error instanceof SnapshotInitError) {
8185
if (error.responseCode === 429) {
8286
showNoticeNotification(
8387
"Doing so will save you bandwidth, make the next test be ready faster and will not sign you out (which could mean your new personal best would not save to your account).",

frontend/src/ts/collections/presets.ts

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { queryClient } from "../queries";
1010
import { baseKey } from "../queries/utils/keys";
1111
import { ConfigGroupName } from "@monkeytype/schemas/configs";
1212
import { tempId } from "./utils/misc";
13+
import { isAuthenticated } from "../states/core";
14+
import { replaceUnderscoresWithSpaces } from "../utils/strings";
1315

1416
export type PresetItem = Preset;
1517

@@ -29,13 +31,21 @@ export function usePresetsLiveQuery() {
2931
const presetsCollection = createCollection(
3032
queryCollectionOptions({
3133
staleTime: Infinity,
32-
startSync: true,
3334
queryKey: queryKeys.root(),
34-
3535
queryClient,
3636
getKey: (it) => it._id,
3737
queryFn: async () => {
38-
return [] as PresetItem[];
38+
if (!isAuthenticated()) return [];
39+
const response = await Ape.presets.get();
40+
41+
if (response.status !== 200) {
42+
throw new Error("Error fetching presets:" + response.body.message);
43+
}
44+
45+
return response.body.data.map((it) => ({
46+
...it,
47+
name: replaceUnderscoresWithSpaces(it.name),
48+
}));
3949
},
4050
}),
4151
);
@@ -149,6 +159,9 @@ const actions = {
149159
};
150160

151161
// --- Public API ---
162+
export async function waitForPresetsReady(): Promise<void> {
163+
await presetsCollection.stateWhenReady();
164+
}
152165

153166
function getPresets(): PresetItem[] {
154167
return [...presetsCollection.values()].sort((a, b) =>
@@ -160,21 +173,6 @@ function getPreset(id: string): PresetItem | undefined {
160173
return presetsCollection.get(id);
161174
}
162175

163-
export function fillPresetsCollection(presets: Preset[]): void {
164-
const presetItems = presets.map((preset) => ({
165-
_id: preset._id,
166-
name: preset.name.replace(/_/g, " "),
167-
config: preset.config,
168-
settingGroups: preset.settingGroups,
169-
}));
170-
171-
presetsCollection.utils.writeBatch(() => {
172-
presetItems.forEach((item) => {
173-
presetsCollection.utils.writeInsert(item);
174-
});
175-
});
176-
}
177-
178176
export async function addPreset(
179177
params: ActionType["addPreset"],
180178
): Promise<void> {

frontend/src/ts/collections/result-filter-presets.ts

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
replaceUnderscoresWithSpaces,
1414
} from "../utils/strings";
1515
import { tempId } from "./utils/misc";
16+
import { fetchUserFromApi } from "../ape/user";
1617

1718
const queryKeys = {
1819
root: () => [...baseKey("resultFilterPresets", { isUserSpecific: true })],
@@ -27,8 +28,13 @@ const resultFilterPresetsCollection = createCollection(
2728
queryClient,
2829
getKey: (it) => it._id,
2930
queryFn: async () => {
30-
//return emtpy array. We load the user with the snapshot and fill the collection from there
31-
return [] as ResultFilters[];
31+
const userData = await fetchUserFromApi();
32+
if (userData === undefined) return [];
33+
34+
return (userData.resultFilterPresets ?? []).map((it) => ({
35+
...it,
36+
name: replaceUnderscoresWithSpaces(it.name),
37+
}));
3238
},
3339
}),
3440
);
@@ -118,13 +124,3 @@ export async function deleteResultFilterPreset(
118124
const transaction = actions.deleteResultFilterPreset(params);
119125
await transaction.isPersisted.promise;
120126
}
121-
122-
export function fillResultFilterPresetsCollection(
123-
presets: ResultFilters[],
124-
): void {
125-
resultFilterPresetsCollection.utils.writeBatch(() => {
126-
presets
127-
.map((it) => ({ ...it, name: replaceUnderscoresWithSpaces(it.name) }))
128-
.forEach((it) => resultFilterPresetsCollection.utils.writeInsert(it));
129-
});
130-
}

frontend/src/ts/collections/tags.ts

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
import { Difficulty } from "@monkeytype/schemas/configs";
2222
import { Language } from "@monkeytype/schemas/languages";
2323
import { tempId } from "./utils/misc";
24+
import { fetchUserFromApi } from "../ape/user";
2425

2526
export type TagItem = UserTag & { active: boolean };
2627

@@ -31,13 +32,19 @@ const queryKeys = {
3132
const tagsCollection = createCollection(
3233
queryCollectionOptions({
3334
staleTime: Infinity,
34-
startSync: true,
3535
queryKey: queryKeys.root(),
36-
3736
queryClient,
3837
getKey: (it) => it._id,
3938
queryFn: async () => {
40-
return [] as TagItem[];
39+
const activeIds = activeTagsLS.get();
40+
const userData = await fetchUserFromApi();
41+
if (userData === undefined) return [];
42+
43+
return (userData.tags ?? []).map((tag) => ({
44+
...tag,
45+
name: tag.name.replace(/_/g, " "),
46+
active: activeIds.includes(tag._id),
47+
}));
4148
},
4249
}),
4350
);
@@ -205,22 +212,6 @@ function getActiveTags(): TagItem[] {
205212
return getTags().filter((tag) => tag.active);
206213
}
207214

208-
export function fillTagsCollection(userTags: UserTag[]): void {
209-
const activeIds = activeTagsLS.get();
210-
211-
const tagItems = userTags.map((tag) => ({
212-
...tag,
213-
name: tag.name.replace(/_/g, " "),
214-
active: activeIds.includes(tag._id),
215-
}));
216-
217-
tagsCollection.utils.writeBatch(() => {
218-
tagItems.forEach((item) => {
219-
tagsCollection.utils.writeInsert(item);
220-
});
221-
});
222-
}
223-
224215
// --- Active state ---
225216

226217
const activeTagsLS = new LocalStorageWithSchema({

frontend/src/ts/config/remote.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { migrateConfig } from "./utils";
44
import { applyConfig } from "./lifecycle";
55
import { saveFullConfigToLocalStorage } from "./persistence";
66
import Ape from "../ape";
7-
import { SnapshotInitError } from "../db";
7+
import { SnapshotInitError } from "../utils/snapshot-init-error";
88
import { getDefaultConfig } from "../constants/default-config";
99
import { Config } from "./store";
1010

frontend/src/ts/db.ts

Lines changed: 10 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -33,30 +33,21 @@ import {
3333
} from "./ape/server-configuration";
3434
import { Connection } from "@monkeytype/schemas/connections";
3535
import { insertLocalResult } from "./collections/results";
36-
import { fillResultFilterPresetsCollection } from "./collections/result-filter-presets";
3736
import {
3837
setLastResult,
3938
setSnapshot as setSolidSnapshot,
4039
} from "./states/snapshot";
4140
import { XpBreakdown } from "@monkeytype/schemas/results";
4241
import { setXpBarData } from "./states/header";
4342
import { FunboxMetadata } from "@monkeytype/funbox";
44-
import { fillTagsCollection, __nonReactive } from "./collections/tags";
43+
import { __nonReactive } from "./collections/tags";
4544
import { updateTagsInFilterStorage } from "./states/result-filters";
46-
import { fillPresetsCollection } from "./collections/presets";
45+
import { fetchUserFromApi } from "./ape/user";
46+
import { SnapshotInitError } from "./utils/snapshot-init-error";
4747

4848
let dbSnapshot: Snapshot | undefined;
4949
const firstDayOfTheWeek = getFirstDayOfTheWeek();
5050

51-
export class SnapshotInitError extends Error {
52-
public responseCode: number;
53-
constructor(message: string, responseCode: number) {
54-
super(message);
55-
this.name = "SnapshotInitError";
56-
this.responseCode = responseCode;
57-
}
58-
}
59-
6051
export function getSnapshot(): Snapshot | undefined {
6152
return dbSnapshot;
6253
}
@@ -105,39 +96,24 @@ export async function initSnapshot(): Promise<Snapshot | false> {
10596
? Ape.connections.get()
10697
: { status: 200, body: { message: "", data: [] } };
10798

108-
const [userResponse, presetsResponse, connectionsResponse] =
109-
await Promise.all([
110-
Ape.users.get(),
111-
Ape.presets.get(),
112-
connectionsRequest,
113-
]);
99+
const [userData, connectionsResponse] = await Promise.all([
100+
fetchUserFromApi(),
101+
102+
connectionsRequest,
103+
]);
114104

115-
if (userResponse.status !== 200) {
116-
throw new SnapshotInitError(
117-
`${userResponse.body.message} (user)`,
118-
userResponse.status,
119-
);
120-
}
121-
if (presetsResponse.status !== 200) {
122-
throw new SnapshotInitError(
123-
`${presetsResponse.body.message} (presets)`,
124-
presetsResponse.status,
125-
);
126-
}
127105
if (connectionsResponse.status !== 200) {
128106
throw new SnapshotInitError(
129107
`${connectionsResponse.body.message} (connections)`,
130108
connectionsResponse.status,
131109
);
132110
}
133111

134-
const userData = userResponse.body.data;
135-
const presetsData = presetsResponse.body.data;
136112
const connectionsData = connectionsResponse.body.data;
137113

138-
if (userData === null) {
114+
if (userData === null || userData === undefined) {
139115
throw new SnapshotInitError(
140-
`Request was successful but user data is null`,
116+
`Request was successful but user data is null/undefined`,
141117
200,
142118
);
143119
}
@@ -197,14 +173,9 @@ export async function initSnapshot(): Promise<Snapshot | false> {
197173

198174
snap.customThemes = userData.customThemes ?? [];
199175

200-
fillTagsCollection(userData.tags ?? []);
201-
fillPresetsCollection(presetsData ?? []);
202-
203-
fillResultFilterPresetsCollection(userData.resultFilterPresets ?? []);
204176
updateTagsInFilterStorage(userData.tags?.map((it) => it._id) ?? []);
205177

206178
snap.connections = convertConnections(connectionsData);
207-
208179
dbSnapshot = snap;
209180

210181
return dbSnapshot;

frontend/src/ts/queries/index.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import { isAuthenticated } from "../states/core";
44

55
export const queryClient = new QueryClient();
66

7-
createEffectOn(isAuthenticated, (state) => {
8-
if (!state) {
9-
console.debug("QueryClient clear all user related queries.");
10-
void queryClient.resetQueries({ queryKey: ["user"] });
11-
}
7+
createEffectOn(isAuthenticated, () => {
8+
//reset user related queries and collections whenever the state changes.
9+
//for legacy access we initialize some user-bound collections without a user being present (e.g. tags, presets).
10+
//to avoid empty collections after login, reset the queries on every state change not just logout
11+
void queryClient.resetQueries({ queryKey: ["user"] });
1212
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export class SnapshotInitError extends Error {
2+
public responseCode: number;
3+
constructor(message: string, responseCode: number) {
4+
super(message);
5+
this.name = "SnapshotInitError";
6+
this.responseCode = responseCode;
7+
}
8+
}

0 commit comments

Comments
 (0)