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
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
@@ -4,7 +4,6 @@ import {
createUser,
deleteUserBySession,
getUser,
incrementAnalyticsMetricPerDay,
newUserProps,
updateUser,
type User,
@@ -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 });
4 changes: 2 additions & 2 deletions routes/index.tsx
Original file line number Diff line number Diff line change
@@ -12,7 +12,7 @@ import {
getAreVotedBySessionId,
getItemsSince,
getManyUsers,
incrementAnalyticsMetricPerDay,
incrVisitsCountByDay,
type Item,
type User,
} from "@/utils/db.ts";
@@ -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);
2 changes: 0 additions & 2 deletions routes/submit.tsx
Original file line number Diff line number Diff line change
@@ -6,7 +6,6 @@ import type { State } from "@/routes/_middleware.ts";
import {
createItem,
getUserBySession,
incrementAnalyticsMetricPerDay,
type Item,
newItemProps,
} from "@/utils/db.ts";
@@ -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}`);
},
119 changes: 83 additions & 36 deletions utils/db.ts
Original file line number Diff line number Diff line change
@@ -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;
@@ -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 })
@@ -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}`);
@@ -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,
@@ -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;
}

@@ -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();

@@ -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}`);
@@ -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,
) {
@@ -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
@@ -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,
@@ -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);

@@ -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);
@@ -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()", () => {
@@ -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");
});