Skip to content

Commit

Permalink
refactor: items logic in KV (#284)
Browse files Browse the repository at this point in the history
* refactor: items in db

* work

* fix

* tweaks

* tweaks

* tweak
  • Loading branch information
iuioiua authored Jun 23, 2023
1 parent f31a96f commit 2df8465
Show file tree
Hide file tree
Showing 8 changed files with 211 additions and 69 deletions.
4 changes: 2 additions & 2 deletions routes/api/vote.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright 2023 the Deno authors. All rights reserved. MIT license.
import type { HandlerContext, Handlers, PageProps } from "$fresh/server.ts";
import type { State } from "@/routes/_middleware.ts";
import { createVote, deleteVote, getItemById } from "@/utils/db.ts";
import { createVote, deleteVote, getItem } from "@/utils/db.ts";
import { getUserBySessionId } from "@/utils/db.ts";

async function sharedHandler(
Expand All @@ -19,7 +19,7 @@ async function sharedHandler(
}

const [item, user] = await Promise.all([
getItemById(itemId),
getItem(itemId),
getUserBySessionId(ctx.state.sessionId),
]);

Expand Down
19 changes: 14 additions & 5 deletions routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ import ItemSummary from "@/components/ItemSummary.tsx";
import PageSelector from "@/components/PageSelector.tsx";
import {
compareScore,
getAllItemsInTimeAgo,
getAllItems,
getAreVotedBySessionId,
getItemsSince,
getManyUsers,
incrementAnalyticsMetricPerDay,
type Item,
type User,
} from "@/utils/db.ts";
import { DAY, WEEK } from "std/datetime/constants.ts";

interface HomePageData extends State {
itemsUsers: User[];
Expand All @@ -24,17 +26,24 @@ interface HomePageData extends State {
}

function calcTimeAgoFilter(url: URL) {
return url.searchParams.get("time-ago") || "";
return url.searchParams.get("time-ago");
}

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

const url = new URL(req.url);
const timeAgo = calcTimeAgoFilter(url);
const pageNum = calcPageNum(url);
const allItems = await getAllItemsInTimeAgo(timeAgo);
const timeAgo = calcTimeAgoFilter(url);
let allItems: Item[];
if (timeAgo === "week") {
allItems = await getItemsSince(WEEK);
} else if (timeAgo === "month") {
allItems = await getItemsSince(30 * DAY);
} else {
allItems = await getAllItems();
}

const items = allItems
.toSorted(compareScore)
Expand Down Expand Up @@ -80,7 +89,7 @@ export default function HomePage(props: PageProps<HomePageData>) {
<PageSelector
currentPage={calcPageNum(props.url)}
lastPage={props.data.lastPage}
timeSelector={calcTimeAgoFilter(props.url)}
timeSelector={calcTimeAgoFilter(props.url) ?? undefined}
/>
)}
</div>
Expand Down
4 changes: 2 additions & 2 deletions routes/item/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
createComment,
getAreVotedBySessionId,
getCommentsByItem,
getItemById,
getItem,
getManyUsers,
getUserById,
getUserBySessionId,
Expand All @@ -42,7 +42,7 @@ export const handler: Handlers<ItemPageData, State> = {
const url = new URL(req.url);
const pageNum = calcPageNum(url);

const item = await getItemById(id);
const item = await getItem(id);
if (item === null) {
return ctx.renderNotFound();
}
Expand Down
15 changes: 12 additions & 3 deletions routes/submit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import type { Handlers, PageProps } from "$fresh/server.ts";
import Head from "@/components/Head.tsx";
import { BUTTON_STYLES, INPUT_STYLES } from "@/utils/constants.ts";
import type { State } from "@/routes/_middleware.ts";
import { createItem, getUserBySessionId } from "@/utils/db.ts";
import {
createItem,
getUserBySessionId,
incrementAnalyticsMetricPerDay,
type Item,
newItemProps,
} from "@/utils/db.ts";
import { redirect } from "@/utils/redirect.ts";
import { redirectToLogin } from "@/utils/redirect.ts";

Expand Down Expand Up @@ -38,11 +44,14 @@ export const handler: Handlers<State, State> = {

if (!user) return new Response(null, { status: 400 });

const item = await createItem({
const item: Item = {
userId: user.id,
title,
url,
});
...newItemProps(),
};
await createItem(item);
await incrementAnalyticsMetricPerDay("items_count", new Date());

return redirect(`/item/${item!.id}`);
},
Expand Down
4 changes: 2 additions & 2 deletions routes/user/[username].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import ItemSummary from "@/components/ItemSummary.tsx";
import {
compareScore,
getAreVotedBySessionId,
getItemsByUserId,
getItemsByUser,
getUserByLogin,
type Item,
type User,
Expand All @@ -30,7 +30,7 @@ export const handler: Handlers<UserData, State> = {
return ctx.renderNotFound();
}

const items = await getItemsByUserId(user.id);
const items = await getItemsByUser(user.id);
items.sort(compareScore);
const areVoted = await getAreVotedBySessionId(
items,
Expand Down
27 changes: 16 additions & 11 deletions tools/seed_submissions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
// Copyright 2023 the Deno authors. All rights reserved. MIT license.
// Description: Seeds the kv db with Hacker News stories
import { createItem, createUser, type Item, kv } from "@/utils/db.ts";
import {
createItem,
createUser,
incrementAnalyticsMetricPerDay,
type Item,
newItemProps,
} from "@/utils/db.ts";

// Reference: https://github.com/HackerNews/API
const API_BASE_URL = `https://hacker-news.firebaseio.com/v0`;
Expand Down Expand Up @@ -54,18 +60,10 @@ async function fetchTopStories(limit = 10) {
return stories;
}

async function createItemWithScore(item: Item) {
const res = await createItem(item);
return await kv.set(["items", res!.id], {
...res,
score: item.score,
createdAt: item.createdAt,
});
}

async function seedSubmissions(stories: Story[]) {
const items = stories.map(({ by: userId, title, url, score, time }) => {
return {
...newItemProps(),
userId,
title,
url,
Expand All @@ -74,7 +72,14 @@ async function seedSubmissions(stories: Story[]) {
} as Item;
}).filter(({ url }) => url);
for (const batch of batchify(items)) {
await Promise.all(batch.map((item) => createItemWithScore(item)));
await Promise.all(
batch.map((item) =>
Promise.all([
createItem(item),
incrementAnalyticsMetricPerDay("items_count", new Date()),
])
),
);
}
return items;
}
Expand Down
105 changes: 62 additions & 43 deletions utils/db.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
// Copyright 2023 the Deno authors. All rights reserved. MIT license.
import { DAY, WEEK } from "std/datetime/constants.ts";

const KV_PATH_KEY = "KV_PATH";
let path = undefined;
Expand Down Expand Up @@ -31,75 +30,95 @@ async function getValues<T>(
}

// Item
interface InitItem {
export interface Item {
userId: string;
title: string;
url: string;
}

export interface Item extends InitItem {
// The below properties can be automatically generated upon item creation
id: string;
createdAt: Date;
score: number;
}

export async function createItem(initItem: InitItem) {
const item: Item = {
export function newItemProps(): Omit<Item, "userId" | "title" | "url"> {
return {
id: crypto.randomUUID(),
score: 0,
createdAt: new Date(),
...initItem,
};
}

const itemKey = ["items", item.id];
const itemByTime = ["items_by_time", item.createdAt.getTime(), item.id];
/**
* Creates a new item in KV. Throws if the item already exists in one of the indexes.
*
* @example New item creation
* ```ts
* import { newItemProps, createItem, incrementAnalyticsMetricPerDay } from "@/utils/db.ts";
*
* const item: Item = {
* userId: "example-user-id",
* title: "example-title",
* url: "https://example.com"
* ..newItemProps(),
* };
*
* 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 res = await kv.atomic()
.check({ key: itemKey, versionstamp: null })
.check({ key: itemByTime, versionstamp: null })
.check({ key: itemsKey, versionstamp: null })
.check({ key: itemsByTimeKey, versionstamp: null })
.check({ key: itemsByUserKey, versionstamp: null })
.set(itemKey, item)
.set(itemByTime, item)
.set(itemsKey, item)
.set(itemsByTimeKey, item)
.set(itemsByUserKey, item)
.commit();

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

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

return item;
}

export async function getAllItemsInTimeAgo(timeAgo: string) {
switch (timeAgo) {
case "month":
return await getValues<Item>({
prefix: ["items_by_time"],
start: ["items_by_time", Date.now() - DAY * 30],
});
case "all":
return await getValues<Item>({
prefix: ["items_by_time"],
});
default:
return await getValues<Item>({
prefix: ["items_by_time"],
start: ["items_by_time", Date.now() - WEEK],
});
}
if (!res.ok) throw new Error(`Failed to create item: ${item}`);
}

export async function getItemById(id: string) {
export async function getItem(id: string) {
return await getValue<Item>(["items", id]);
}

export async function getItemByUser(userId: string, itemId: string) {
return await getValue<Item>(["items_by_user", userId, itemId]);
export async function getItemsByUser(userId: string) {
return await getValues<Item>({ prefix: ["items_by_user", userId] });
}

export async function getItemsByUserId(userId: string) {
return await getValues<Item>({ prefix: ["items_by_user", userId] });
export async function getAllItems() {
return await getValues<Item>({ prefix: ["items"] });
}

/**
* Gets all items since a given number of milliseconds ago from KV.
*
* @example Since a week ago
* ```ts
* import { WEEK } from "std/datetime/constants.ts";
* import { getItemsSince } from "@/utils/db.ts";
*
* const itemsSinceAllTime = await getItemsSince(WEEK);
* ```
*
* @example Since a month ago
* ```ts
* import { DAY } from "std/datetime/constants.ts";
* import { getItemsSince } from "@/utils/db.ts";
*
* const itemsSinceAllTime = await getItemsSince(DAY * 30);
* ```
*/
export async function getItemsSince(msAgo: number) {
return await getValues<Item>({
prefix: ["items_by_time"],
start: ["items_by_time", Date.now() - msAgo],
});
}

// Comment
Expand Down
Loading

0 comments on commit 2df8465

Please sign in to comment.