Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: analytics into atomic operations #296

Merged
merged 7 commits into from
Jun 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions routes/callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
createUser,
deleteUserBySession,
getUser,
incrementAnalyticsMetricPerDay,
newUserProps,
updateUser,
type User,
Expand Down Expand Up @@ -67,7 +66,6 @@ export const handler: Handlers<any, State> = {
...newUserProps(),
};
await createUser(user);
await incrementAnalyticsMetricPerDay("users_count", new Date());
} else {
await deleteUserBySession(sessionId);
await updateUser({ ...user, sessionId });
Expand Down
4 changes: 2 additions & 2 deletions routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
getAreVotedBySessionId,
getItemsSince,
getManyUsers,
incrementAnalyticsMetricPerDay,
incrVisitsCountByDay,
type Item,
type User,
} from "@/utils/db.ts";
Expand All @@ -31,7 +31,7 @@ function calcTimeAgoFilter(url: URL) {

export const handler: Handlers<HomePageData, State> = {
async GET(req, ctx) {
await incrementAnalyticsMetricPerDay("visits_count", new Date());
await incrVisitsCountByDay(new Date());

const url = new URL(req.url);
const pageNum = calcPageNum(url);
Expand Down
2 changes: 0 additions & 2 deletions routes/submit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import type { State } from "@/routes/_middleware.ts";
import {
createItem,
getUserBySession,
incrementAnalyticsMetricPerDay,
type Item,
newItemProps,
} from "@/utils/db.ts";
Expand Down Expand Up @@ -51,7 +50,6 @@ export const handler: Handlers<State, State> = {
...newItemProps(),
};
await createItem(item);
await incrementAnalyticsMetricPerDay("items_count", new Date());

return redirect(`/item/${item!.id}`);
},
Expand Down
119 changes: 83 additions & 36 deletions utils/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ async function getValues<T>(
return values;
}

/** Converts `Date` to ISO format that is zero UTC offset */
export function formatDate(date: Date) {
return date.toISOString().split("T")[0];
}

// Item
export interface Item {
userId: string;
Expand Down Expand Up @@ -63,13 +68,13 @@ export function newItemProps(): Pick<Item, "id" | "score" | "createdAt"> {
* };
*
* await createItem(item);
* await incrementAnalyticsMetricPerDay("items_count", item.createdAt);
* ```
*/
export async function createItem(item: Item) {
const itemsKey = ["items", item.id];
const itemsByTimeKey = ["items_by_time", item.createdAt.getTime(), item.id];
const itemsByUserKey = ["items_by_user", item.userId, item.id];
const itemsCountKey = ["items_count", formatDate(new Date())];

const res = await kv.atomic()
.check({ key: itemsKey, versionstamp: null })
Expand All @@ -78,6 +83,7 @@ export async function createItem(item: Item) {
.set(itemsKey, item)
.set(itemsByTimeKey, item)
.set(itemsByUserKey, item)
.sum(itemsCountKey, 1n)
.commit();

if (!res.ok) throw new Error(`Failed to create item: ${item}`);
Expand Down Expand Up @@ -203,6 +209,7 @@ export async function createVote(vote: Vote) {
vote.item.id,
vote.user.id,
];
const votesCountKey = ["votes_count", formatDate(new Date())];

const [itemRes, itemsByTimeRes, itemsByUserRes] = await kv.getMany([
itemKey,
Expand All @@ -220,12 +227,11 @@ export async function createVote(vote: Vote) {
.set(itemsByUserKey, vote.item)
.set(votedItemsByUserKey, vote.item)
.set(votedUsersByItemKey, vote.user)
.sum(votesCountKey, 1n)
.commit();

if (!res.ok) throw new Error(`Failed to set vote: ${vote}`);

await incrementAnalyticsMetricPerDay("votes_count", new Date());

return vote;
}

Expand Down Expand Up @@ -305,13 +311,13 @@ export function newUserProps(): Pick<User, "isSubscribed"> {
* ...newUserProps(),
* };
* await createUser(user);
* await incrementAnalyticsMetricPerDay("users_count", new Date());
* ```
*/
export async function createUser(user: User) {
const usersKey = ["users", user.id];
const usersByLoginKey = ["users_by_login", user.login];
const usersBySessionKey = ["users_by_session", user.sessionId];
const usersCountKey = ["users_count", formatDate(new Date())];

const atomicOp = kv.atomic();

Expand All @@ -332,6 +338,7 @@ export async function createUser(user: User) {
.set(usersKey, user)
.set(usersByLoginKey, user)
.set(usersBySessionKey, user)
.sum(usersCountKey, 1n)
.commit();

if (!res.ok) throw new Error(`Failed to create user: ${user}`);
Expand Down Expand Up @@ -411,39 +418,90 @@ export function compareScore(a: Item, b: Item) {
}

// Analytics
export async function incrementAnalyticsMetricPerDay(
metric: string,
date: Date,
) {
// convert to ISO format that is zero UTC offset
const metricKey = [
metric,
`${date.toISOString().split("T")[0]}`,
];
await kv.atomic()
.sum(metricKey, 1n)
.commit();
}

export async function incrementVisitsPerDay(date: Date) {
export async function incrVisitsCountByDay(date: Date) {
// convert to ISO format that is zero UTC offset
const visitsKey = [
"visits",
"visits_count",
`${date.toISOString().split("T")[0]}`,
];
await kv.atomic()
.sum(visitsKey, 1n)
.commit();
}

export async function getVisitsPerDay(date: Date) {
export async function getVisitsCountByDay(date: Date) {
return await getValue<bigint>([
"visits",
`${date.toISOString().split("T")[0]}`,
"visits_count",
formatDate(date),
]);
}

export async function getAnalyticsMetricsPerDay(
export async function getItemsCountByDay(date: Date) {
return await getValue<bigint>([
"items_count",
formatDate(date),
]);
}

export async function getVotesCountByDay(date: Date) {
return await getValue<bigint>([
"votes_count",
formatDate(date),
]);
}

export async function getUsersCountByDay(date: Date) {
return await getValue<bigint>([
"users_count",
formatDate(date),
]);
}

export async function getAllVisitsCountByDay(options?: Deno.KvListOptions) {
const iter = await kv.list<bigint>({ prefix: ["visits_count"] }, options);
const visits = [];
const dates = [];
for await (const res of iter) {
visits.push(Number(res.value));
dates.push(String(res.key[1]));
}
return { visits, dates };
}

export async function getAllItemsCountByDay(options?: Deno.KvListOptions) {
const iter = await kv.list<bigint>({ prefix: ["items_count"] }, options);
const visits = [];
const dates = [];
for await (const res of iter) {
visits.push(Number(res.value));
dates.push(String(res.key[1]));
}
return { visits, dates };
}

export async function getAllVotesCountByDay(options?: Deno.KvListOptions) {
const iter = await kv.list<bigint>({ prefix: ["votes_count"] }, options);
const visits = [];
const dates = [];
for await (const res of iter) {
visits.push(Number(res.value));
dates.push(String(res.key[1]));
}
return { visits, dates };
}

export async function getAllUsersCountByDay(options?: Deno.KvListOptions) {
const iter = await kv.list<bigint>({ prefix: ["users_count"] }, options);
const visits = [];
const dates = [];
for await (const res of iter) {
visits.push(Number(res.value));
dates.push(String(res.key[1]));
}
return { visits, dates };
}

export async function getAnalyticsMetricListPerDay(
metric: string,
options?: Deno.KvListOptions,
) {
Expand All @@ -462,19 +520,8 @@ export async function getManyAnalyticsMetricsPerDay(
options?: Deno.KvListOptions,
) {
const analyticsByDay = await Promise.all(
metrics.map((metric) => getAnalyticsMetricsPerDay(metric, options)),
metrics.map((metric) => getAnalyticsMetricListPerDay(metric, options)),
);

return analyticsByDay;
}

export async function getAllVisitsPerDay(options?: Deno.KvListOptions) {
const iter = await kv.list<bigint>({ prefix: ["visits"] }, options);
const visits = [];
const dates = [];
for await (const res of iter) {
visits.push(Number(res.value));
dates.push(String(res.key[1]));
}
return { visits, dates };
}
32 changes: 26 additions & 6 deletions utils/db_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,22 @@ import {
deleteItem,
deleteUserBySession,
deleteVote,
formatDate,
getAllItems,
getCommentsByItem,
getItem,
getItemsByUser,
getItemsCountByDay,
getItemsSince,
getManyUsers,
getUser,
getUserByLogin,
getUserBySession,
getUserByStripeCustomer,
getVisitsPerDay,
getUsersCountByDay,
getVisitsCountByDay,
getVotedItemsByUser,
incrementVisitsPerDay,
incrVisitsCountByDay,
type Item,
kv,
newCommentProps,
Expand Down Expand Up @@ -92,7 +95,13 @@ Deno.test("[db] (get/create/delete)Item()", async () => {

assertEquals(await getItem(item.id), null);

const itemsCount = (await getItemsCountByDay(new Date()))?.valueOf() ??
0n;
await createItem(item);
assertEquals(
(await getItemsCountByDay(new Date()))!.valueOf(),
itemsCount + 1n,
);
await assertRejects(async () => await createItem(item));
assertEquals(await getItem(item.id), item);

Expand Down Expand Up @@ -133,8 +142,14 @@ Deno.test("[db] user", async () => {
assertEquals(await getUserBySession(user.sessionId), null);
assertEquals(await getUserByStripeCustomer(user.stripeCustomerId!), null);

const usersCount = (await getUsersCountByDay(new Date()))
?.valueOf() ?? 0n;
await createUser(user);
await assertRejects(async () => await createUser(user));
assertEquals(
(await getUsersCountByDay(new Date()))!.valueOf(),
usersCount + 1n,
);
assertEquals(await getUser(user.id), user);
assertEquals(await getUserByLogin(user.login), user);
assertEquals(await getUserBySession(user.sessionId), user);
Expand All @@ -161,14 +176,14 @@ Deno.test("[db] user", async () => {
Deno.test("[db] visit", async () => {
const date = new Date("2023-01-01");
const visitsKey = [
"visits",
"visits_count",
`${date.toISOString().split("T")[0]}`,
];
await incrementVisitsPerDay(date);
await incrVisitsCountByDay(date);
assertEquals((await kv.get(visitsKey)).key[1], "2023-01-01");
assertEquals((await getVisitsPerDay(date))!.valueOf(), 1n);
assertEquals((await getVisitsCountByDay(date))!.valueOf(), 1n);
await kv.delete(visitsKey);
assertEquals(await getVisitsPerDay(date), null);
assertEquals(await getVisitsCountByDay(date), null);
});

Deno.test("[db] newCommentProps()", () => {
Expand Down Expand Up @@ -213,3 +228,8 @@ Deno.test("[db] votes", async () => {
await deleteVote({ item, user });
assertRejects(async () => await deleteVote({ item, user }));
});

Deno.test("[db] formatDate()", () => {
assertEquals(formatDate(new Date("2023-01-01")), "2023-01-01");
assertEquals(formatDate(new Date("2023-01-01T13:59:08.740Z")), "2023-01-01");
});