diff --git a/package.json b/package.json index 5b65544..b1d1830 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "type": "module", "dependencies": { "dotenv": "^16.4.5", + "node-cache": "^5.1.2", "postgres": "^3.4.4", "ssh2": "^1.15.0" } diff --git a/src/comp/post.svelte b/src/comp/post.svelte index b3ababa..6f84e3e 100644 --- a/src/comp/post.svelte +++ b/src/comp/post.svelte @@ -6,7 +6,7 @@
-

{post.name}

+ {post.name}

{post.content}

diff --git a/src/hooks.server.js b/src/hooks.server.js index 7e337ad..e7b1ac5 100644 --- a/src/hooks.server.js +++ b/src/hooks.server.js @@ -1,35 +1,6 @@ -// @ts-nocheck -import { env } from '$lib/env'; -import postgres from 'postgres'; -import ssh2 from 'ssh2'; +import { sql } from '$lib/db.server'; export const handle = async ({event, resolve}) => { - const sql = postgres( - // 'postgres://doki8902:ChangeItN8w@pgsql3.mif:5432/studentu', - { - host: env.PG_HOST, - port: parseInt(env.PG_PORT), - database: env.PG_DATABASE, - username: env.PG_USERNAME, - password: env.PG_PASSWORD, - socket: ({ host: [host], port: [port] }) => new Promise((resolve, reject) => { - const ssh = new ssh2.Client(); - ssh - .on('error', reject) - .on('ready', () => - ssh.forwardOut('127.0.0.1', 12345, host, port, - (err, socket) => err ? reject(err) : resolve(socket) - ) - ) - .connect({ - host: env.SSH_HOST, - port: parseInt(env.SSH_PORT), - username: env.SSH_USERNAME, - password: env.SSH_PASSWORD - }) - }) - } - ); // https://github.com/porsager/postgres/issues/762 diff --git a/src/lib/cache.server.js b/src/lib/cache.server.js new file mode 100644 index 0000000..4b6488c --- /dev/null +++ b/src/lib/cache.server.js @@ -0,0 +1,5 @@ +import NodeCache from "node-cache"; + +export const createCache = () => new NodeCache({ + stdTTL: 100 +}); diff --git a/src/lib/db.server.js b/src/lib/db.server.js new file mode 100644 index 0000000..7cb3293 --- /dev/null +++ b/src/lib/db.server.js @@ -0,0 +1,32 @@ +import { env } from '$lib/env'; +import postgres from 'postgres'; +import ssh2 from 'ssh2'; + +// @ts-ignore +export const sql = postgres( + // 'postgres://doki8902:ChangeItN8w@pgsql3.mif:5432/studentu', + { + host: env.PG_HOST, + port: parseInt(env.PG_PORT), + database: env.PG_DATABASE, + username: env.PG_USERNAME, + password: env.PG_PASSWORD, + // @ts-ignore + socket: ({ host: [host], port: [port] }) => new Promise((resolve, reject) => { + const ssh = new ssh2.Client(); + ssh + .on('error', reject) + .on('ready', () => + ssh.forwardOut('127.0.0.1', 12345, host, port, + (err, socket) => err ? reject(err) : resolve(socket) + ) + ) + .connect({ + host: env.SSH_HOST, + port: parseInt(env.SSH_PORT), + username: env.SSH_USERNAME, + password: env.SSH_PASSWORD + }) + }) + } +); diff --git a/src/lib/db/post.js b/src/lib/db/post.js deleted file mode 100644 index b300649..0000000 --- a/src/lib/db/post.js +++ /dev/null @@ -1,41 +0,0 @@ -/** - * @param {import('postgres').Sql} sql - * @param {number | undefined} category - * @param {number} limit - * @param {number} offset - * @returns {Promise} - */ -export async function getPosts(sql, category = undefined, limit = 10, offset = 0) { - let query; - - if (category === undefined) { - query = sql` - SELECT name, latest_content - FROM doki8902.message_post - FETCH FIRST ${limit} ROWS ONLY - OFFSET ${offset};`; - } else { - query = sql` - SELECT name, latest_content - FROM doki8902.message_post - WHERE category_id = ${category} - FETCH FIRST ${limit} ROWS ONLY - OFFSET ${offset};`; - } - - let posts = await query; - - /** - * @type {import('$types/base').Post[]} - */ - let result = []; - - posts.forEach(row => { - result.push({ - name: row['name'], - content: row['latest_content'] - }); - }); - - return result; -} diff --git a/src/lib/db/root.js b/src/lib/db/root.js deleted file mode 100644 index ce29d4f..0000000 --- a/src/lib/db/root.js +++ /dev/null @@ -1,6 +0,0 @@ -// export function runQuery( -// /** @type {import('postgres').Sql} */ sql, -// /** @type {string} */ query, -// /** @type {any[]} */ args = []) { -// sql`${query}` -// } \ No newline at end of file diff --git a/src/lib/env.js b/src/lib/env.js index 1bbb1b7..beaa7ef 100644 --- a/src/lib/env.js +++ b/src/lib/env.js @@ -5,4 +5,5 @@ dotenv.config(); /** * @type {import('$types/env').Env} */ +// @ts-ignore export const env = process.env; diff --git a/src/lib/index.js b/src/lib/index.js deleted file mode 100644 index 856f2b6..0000000 --- a/src/lib/index.js +++ /dev/null @@ -1 +0,0 @@ -// place files you want to import through the `$lib` alias in this folder. diff --git a/src/lib/server/db/category.js b/src/lib/server/db/category.js new file mode 100644 index 0000000..cea443d --- /dev/null +++ b/src/lib/server/db/category.js @@ -0,0 +1,75 @@ +import { createCache } from '$lib/cache.server'; +import { cacheUpdater, cachedMethod } from './root'; + +const cache = createCache(); + +/** + * @template T + * @typedef {import('$types/base').Result} Result + */ + +/** + * @typedef {import('$types/base').Category} Category + */ + +/** + * @param {Result} categories + * @returns {Result} + */ +const updateCategoryCache = cacheUpdater(cache); + +/** + * @param {import('postgres').Sql} sql + * @param {number[]} user_ids + * @returns {Promise>} + */ +export const getCategoriesCached = cachedMethod(cache, getCategories); + +/** + * @param {import('postgres').Sql} sql + * @param {number[]} category_ids + * @returns {Promise>} + */ +export async function getCategories(sql, category_ids) { + if (category_ids.length == 0) return {}; + + const query = sql` + SELECT id, name + FROM doki8902.post_category + WHERE id IN ${ sql(category_ids) };`; + + let categories = await query; + + /** + * @type {Result} + */ + let result = {}; + + categories.forEach(row => { + result[row['id']] = { + id: row['id'], + name: row['name'] + } + }) + + return updateCategoryCache(result); +} + +/** + * + * @param {import('postgres').Sql} sql + * @param {number} category_id + * @returns {Promise} + */ +export async function getCategoryCached(sql, category_id) { + const categories = await getCategoriesCached(sql, [category_id]); + + if (Object.keys(categories).length == 0) { + return { + error: true, + msg: `Could not find Category of ID ${category_id}` + }; + } + + return categories[category_id]; +} \ No newline at end of file diff --git a/src/lib/server/db/post.js b/src/lib/server/db/post.js new file mode 100644 index 0000000..339c16b --- /dev/null +++ b/src/lib/server/db/post.js @@ -0,0 +1,123 @@ +import { getCategoriesCached, getCategoryCached } from './category'; +import { getUser, getUsersCached } from './user'; + +/** + * @typedef {import('$types/base').Post} Post + */ + +/** + * @param {import('postgres').Sql} sql + * @param {import('$types/base').Category | undefined} category + * @param {number} limit + * @param {number} offset + * @returns {Promise} + */ +export async function getPosts(sql, category = undefined, limit = 10, offset = 0) { + let filter; + + if (category === undefined) { + filter = sql``; + } else { + filter = sql`WHERE category_id = ${ category.id }`; + } + + const query = sql` + SELECT id, author_id, name, category_id, latest_content, created_date, likes, dislikes + FROM doki8902.message_post + ${ filter } + FETCH FIRST ${ limit } ROWS ONLY + OFFSET ${ offset };`; + + const posts = await query; + + const users = await getUsersCached(sql, posts.map(row => { + return row['author_id']; + })); + + const categories = await getCategoriesCached(sql, posts.map(row => { + return row['category_id']; + })); + + /** + * @type {Post[]} + */ + return posts.map(row => { + return { + id: row['id'], + author: users[row['author_id']] || null, + name: row['name'], + category: categories[row['category_id']], + content: row['latest_content'], + post_date: row['created_date'], + rating: { + likes: BigInt(row['likes']), + dislikes: BigInt(row['dislikes']), + } + }; + }); +} + +/** + * + * @param {import('postgres').Sql} sql + * @param {number} post_id + * @returns {Promise} + */ +export async function getPost(sql, post_id) { + const query = sql` + SELECT id, author_id, name, category_id, latest_content, created_date, likes, dislikes + FROM doki8902.message_post + WHERE id = ${ post_id };`; + + const post = (await query).at(0); + + if (!post) { + return { + error: true, + msg: `Could not find Post of ID ${ post_id }` + }; + } + + const user_guess = await getUser(sql, post['author_id']); + /** + * @type {import('$types/base').User | null} + */ + const author = function () { + if (Object.hasOwn(user_guess, 'error')) { + return null; + } else { + return /** @type {import('$types/base').User} */ (user_guess); + } + }(); + + const category_guess = await getCategoryCached(sql, post['category_id']); + if (Object.hasOwn(category_guess, 'error')) { + return { + error: true, + msg: `Post of ID ${ post_id } has an invalid Category ID ${ post['category_id'] }` + }; + } + + /** + * @type {import('$types/base').Category} + */ + const category = function () { + return /** @type {import('$types/base').Category} */ (category_guess); + }(); + + /** + * @type {Post} + */ + return { + id: post['id'], + author: author, + name: post['name'], + category: category, + content: post['latest_content'], + post_date: post['created_date'], + rating: { + likes: BigInt(post['likes']), + dislikes: BigInt(post['dislikes']), + } + }; +} diff --git a/src/lib/server/db/root.js b/src/lib/server/db/root.js new file mode 100644 index 0000000..41076b7 --- /dev/null +++ b/src/lib/server/db/root.js @@ -0,0 +1,46 @@ +/** + * @template T + * @param {import('node-cache')} cache + * @returns {function({[id: number]: T})} + */ +export const cacheUpdater = (cache) => { + return function updateUserCache(data) { + Object.keys(data).forEach(id => { + cache.set(parseInt(id), data[parseInt(id)]); + }); + return data; + } +}; + +/** + * @template T + * @param {import('node-cache')} cache + * @param {function(import('postgres').Sql, number[]): Promise<{[id: number]: T}>} method + * @returns {function(import('postgres').Sql, number[]): Promise<{[id: number]: T}>} + */ +export const cachedMethod = (cache, method) => { + return async function(sql, ids) { + /** + * @type {{[id: number]: T}} + */ + let results = {}; + /** + * @type {number[]} + */ + let missing = []; + + ids.forEach(id => { + if (id === null || id === undefined) + return; + let user = cache.get(id); + if (user) + results[id] = user; + else + missing.push(id); + }); + + const remaining = await method(sql, missing); + + return Object.assign({}, results, remaining); + }; +} diff --git a/src/lib/server/db/user.js b/src/lib/server/db/user.js new file mode 100644 index 0000000..c64f724 --- /dev/null +++ b/src/lib/server/db/user.js @@ -0,0 +1,75 @@ +import { createCache } from '$lib/cache.server'; +import { cacheUpdater, cachedMethod } from './root'; + +const cache = createCache(); + +/** + * @template T + * @typedef {import('$types/base').Result} Result + */ + +/** + * @typedef {import('$types/base').User} User + */ + +/** + * @param {Result} users + * @returns {Result} + */ +const updateUserCache = cacheUpdater(cache); + +/** + * @param {import('postgres').Sql} sql + * @param {number[]} user_ids + * @returns {Promise>} + */ +export const getUsersCached = cachedMethod(cache, getUsers); + +/** + * @param {import('postgres').Sql} sql + * @param {number[]} user_ids + * @returns {Promise>} + */ +export async function getUsers(sql, user_ids) { + if (user_ids.length == 0) return {}; + + const query = sql` + SELECT id, username, join_time + FROM doki8902.user + WHERE id IN ${ sql(user_ids) };`; + + let users = await query; + + /** + * @type {Result} + */ + let result = {}; + + users.forEach(row => { + result[row['id']] = { + id: row['id'], + name: row['username'], + join_date: row['join_time'] + } + }) + + return updateUserCache(result); +} + +/** + * @param {import('postgres').Sql} sql + * @param {number} user_id + * @returns {Promise} + */ +export async function getUser(sql, user_id) { + const users = await getUsers(sql, [user_id]); + + if (Object.keys(users).length == 0) { + return { + error: true, + msg: `Could not find user of ID ${user_id}` + }; + } + + return users[user_id]; +} diff --git a/src/routes/(app)/posts/+page.server.js b/src/routes/(app)/posts/+page.server.js index 83104e1..66312b8 100644 --- a/src/routes/(app)/posts/+page.server.js +++ b/src/routes/(app)/posts/+page.server.js @@ -1,12 +1,10 @@ -import { getPosts } from '$lib/db/post'; -import { env } from '$lib/env'; +import { getPosts } from '$lib/server/db/post'; + /** @type {import('./$types').PageServerLoad} */ export async function load({ locals }) { let result = await getPosts(locals.sql); - // console.log(); - console.log(result); return { diff --git a/src/routes/(app)/posts/+page.svelte b/src/routes/(app)/posts/+page.svelte index 9c3220d..8ddc015 100644 --- a/src/routes/(app)/posts/+page.svelte +++ b/src/routes/(app)/posts/+page.svelte @@ -1,5 +1,5 @@ + +

{data.post.name}

+{data.post.author?.name} +

{data.post.content}

diff --git a/src/stores.js b/src/stores.js new file mode 100644 index 0000000..e69de29 diff --git a/src/types/base.ts b/src/types/base.ts index c87f1e3..b278c70 100644 --- a/src/types/base.ts +++ b/src/types/base.ts @@ -1,21 +1,27 @@ +export type Result = {[id: number]: T}; + export type User = { - name: string + id: number, + name: string, + join_date: Date }; export type Rating = { - likes: number, - dislikes: number + likes: BigInt, + dislikes: BigInt } export type Category = { + id: number, name: string } export type Post = { - // author: User, + id: number, + author: User | null, name: string, - // category: Category, + category: Category, content: string, - // post_date: Date, - // rating: Rating + post_date: Date, + rating: Rating }; diff --git a/src/types/error.ts b/src/types/error.ts new file mode 100644 index 0000000..a644d06 --- /dev/null +++ b/src/types/error.ts @@ -0,0 +1,4 @@ +export type Error = { + error: boolean, + msg: string +}; diff --git a/yarn.lock b/yarn.lock index 9132936..51f2773 100644 --- a/yarn.lock +++ b/yarn.lock @@ -419,6 +419,11 @@ chokidar@^3.4.1: optionalDependencies: fsevents "~2.3.2" +clone@2.x: + version "2.1.2" + resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" + integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w== + code-red@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/code-red/-/code-red-1.0.4.tgz#59ba5c9d1d320a4ef795bc10a28bd42bfebe3e35" @@ -744,6 +749,13 @@ nanoid@^3.3.7: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== +node-cache@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/node-cache/-/node-cache-5.1.2.tgz#f264dc2ccad0a780e76253a694e9fd0ed19c398d" + integrity sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg== + dependencies: + clone "2.x" + normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"