From eafab32aa7f2af75d4cbc86fb8812d2a7677afec Mon Sep 17 00:00:00 2001 From: yanu <10122431+ynwd@users.noreply.github.com> Date: Sun, 18 Aug 2024 22:50:56 +0700 Subject: [PATCH] feat: add user module --- deno.json | 2 +- modules/user/user.service.test.ts | 123 ++++++++++++++++++++++++++++++ modules/user/user.service.ts | 77 +++++++++++++++++++ modules/user/user.type.ts | 10 +++ task/db_dump.ts | 13 ++++ task/db_reset.ts | 8 ++ utils/db.ts | 4 + 7 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 modules/user/user.service.test.ts create mode 100644 modules/user/user.service.ts create mode 100644 modules/user/user.type.ts create mode 100644 task/db_dump.ts create mode 100644 task/db_reset.ts diff --git a/deno.json b/deno.json index 89433d563..9382a791e 100644 --- a/deno.json +++ b/deno.json @@ -25,7 +25,7 @@ "start": "ENV=DEVELOPMENT deno run --env --unstable-kv -A --watch modules/app/main.ts", "build": "deno run --env -A --unstable-kv modules/app/main.ts --build ", "prod": "deno run --env --unstable-kv -A modules/app/main.ts", - "test": "rm -rf .hydrate && rm -rf cov && deno test -A --coverage=cov && deno coverage cov", + "test": "rm -rf .hydrate && rm -rf cov && deno test --unstable-kv -A --coverage=cov && deno coverage cov", "coverage": "deno coverage cov --lcov > cov.lcov", "bench": "deno run -A bench/run.ts", "oauth": "deno run --env -A --unstable-kv examples/oauth.ts", diff --git a/modules/user/user.service.test.ts b/modules/user/user.service.test.ts new file mode 100644 index 000000000..e3cb96cca --- /dev/null +++ b/modules/user/user.service.test.ts @@ -0,0 +1,123 @@ +import { assertEquals } from "@app/http/server/deps.ts"; +import { + createUser, + deleteUser, + getUser, + getUserByEmail, + listUsers, + listUsersByEmail, + updateUser, +} from "@app/modules/user/user.service.ts"; +import UserType from "@app/modules/user/user.type.ts"; +import { collectValues, kv } from "@app/utils/db.ts"; + +Deno.test({ + name: "createUser", + async fn() { + const res = await createUser({ + username: "john", + password: "password", + email: "john@email.com", + }); + await createUser({ + username: "john1", + password: "password", + email: "john1@email.com", + }); + await createUser({ + username: "john3", + password: "password", + email: "john3@email.com", + }); + await createUser({ + username: "john4", + password: "password", + email: "john4@email.com", + }); + assertEquals(res.ok, true); + }, +}); + +let user: UserType | null; +Deno.test({ + name: "getUserByEmail", + async fn() { + user = await getUserByEmail("john@email.com"); + assertEquals(user?.email, "john@email.com"); + }, +}); + +Deno.test({ + name: "updateUser", + async fn() { + if (!user) return; + user.email = "john2@email.com"; + if (user.id) { + const res = await updateUser(user?.id, user); + assertEquals(res?.ok, true); + } + }, +}); + +Deno.test({ + name: "getUser", + async fn() { + if (user?.id) { + user = await getUser(user?.id); + } + assertEquals(user?.email, "john2@email.com"); + }, +}); + +Deno.test({ + name: "listUsers", + async fn() { + const res = await collectValues(listUsers()); + assertEquals(res.length, 4); + }, +}); + +Deno.test({ + name: "listUsers", + async fn() { + const iterator = listUsers({ limit: 1 }); + const res = await collectValues(iterator); + assertEquals(res.length, 1); + + const iter2 = listUsers({ limit: 1, cursor: iterator.cursor }); + const res2 = await collectValues(iter2); + assertEquals(res2.length, 1); + + const iter3 = listUsers({ limit: 1, cursor: iterator.cursor }); + const res3 = await collectValues(iter3); + assertEquals(res3.length, 1); + }, +}); + +Deno.test({ + name: "listUsersByEmail", + async fn() { + const res = await collectValues(listUsersByEmail()); + assertEquals(res.length, 4); + }, +}); + +Deno.test({ + name: "deleteUser", + async fn() { + if (user?.id) { + const res = await deleteUser(user.id); + assertEquals(res?.ok, true); + } + }, +}); + +Deno.test({ + name: "reset", + async fn() { + const iter = kv.list({ prefix: [] }); + const promises = []; + for await (const res of iter) promises.push(kv.delete(res.key)); + await Promise.all(promises); + }, +}); diff --git a/modules/user/user.service.ts b/modules/user/user.service.ts new file mode 100644 index 000000000..8a27a0855 --- /dev/null +++ b/modules/user/user.service.ts @@ -0,0 +1,77 @@ +import { ulid } from "jsr:@std/ulid"; +import { kv } from "@app/utils/db.ts"; +import UserType from "@app/modules/user/user.type.ts"; + +export async function getUserByEmail(email: string) { + const res = await kv.get(["users_by_email", email]); + return res.value; +} + +export async function getUser(id: string): Promise { + const res = await kv.get(["users", id]); + return res.value; +} + +export function listUsers( + options?: Deno.KvListOptions, +) { + return kv.list({ prefix: ["users"] }, options); +} + +export function listUsersByEmail( + options?: Deno.KvListOptions, +) { + return kv.list({ prefix: ["users_by_email"] }, options); +} + +export async function createUser(user: UserType) { + user.id = user.id ? user.id : ulid(); + const primaryKey = ["users", user.id]; + const byEmailKey = ["users_by_email", user.email]; + + const res = await kv.atomic() + .check({ key: primaryKey, versionstamp: null }) + .check({ key: byEmailKey, versionstamp: null }) + .set(primaryKey, user) + .set(byEmailKey, user) + .commit(); + + if (!res.ok) { + throw new TypeError("User with ID or email already exists"); + } + + return res; +} + +export async function updateUser(id: string, user: UserType) { + if (!id) return; + const existingUser = await kv.get(["users", id]); + if (!existingUser.value?.email) return; + + const byEmailKey = ["users_by_email", user.email]; + const atomicOp = kv.atomic() + .check(existingUser) + .delete(["users_by_email", existingUser.value?.email]) + .set(["users", id], user) + .check({ key: byEmailKey, versionstamp: null }) + .set(byEmailKey, user); + + const res = await atomicOp.commit(); + if (!res.ok) throw new Error("Failed to update user"); + return res; +} + +export async function deleteUser(id: string) { + let res = { ok: false }; + while (!res.ok) { + const getRes = await kv.get(["users", id]); + if (getRes && getRes.value) { + res = await kv.atomic() + .check(getRes) + .delete(["users", id]) + .delete(["users_by_email", getRes.value.email]) + .commit(); + } + } + return res; +} diff --git a/modules/user/user.type.ts b/modules/user/user.type.ts new file mode 100644 index 000000000..16c5c7c60 --- /dev/null +++ b/modules/user/user.type.ts @@ -0,0 +1,10 @@ +type UserType = { + id?: string; + username: string; + email: string; + password: string; + group?: string[]; + image?: string; +}; + +export default UserType; diff --git a/task/db_dump.ts b/task/db_dump.ts new file mode 100644 index 000000000..726bc0d20 --- /dev/null +++ b/task/db_dump.ts @@ -0,0 +1,13 @@ +import { kv } from "@app/utils/db.ts"; + +function replacer(_key: unknown, value: unknown) { + return typeof value === "bigint" ? value.toString() : value; +} + +const items = await Array.fromAsync( + kv.list({ prefix: [] }), + ({ key, value }) => ({ key, value }), +); +console.log(JSON.stringify(items, replacer, 2)); + +kv.close(); diff --git a/task/db_reset.ts b/task/db_reset.ts new file mode 100644 index 000000000..e1905a6c3 --- /dev/null +++ b/task/db_reset.ts @@ -0,0 +1,8 @@ +import { kv } from "@app/utils/db.ts"; +if (!confirm("WARNING: The database will be reset. Continue?")) Deno.exit(); +const iter = kv.list({ prefix: [] }); +const promises = []; +for await (const res of iter) promises.push(kv.delete(res.key)); +await Promise.all(promises); + +kv.close(); diff --git a/utils/db.ts b/utils/db.ts index af9582dbe..e02e0c871 100644 --- a/utils/db.ts +++ b/utils/db.ts @@ -26,3 +26,7 @@ async function getKvInstance(path?: string): Promise { } export const kv = await getKvInstance(path); + +export async function collectValues(iter: Deno.KvListIterator) { + return await Array.fromAsync(iter, ({ value }) => value); +}