Port to elysia, tailwind (no effect)

This commit is contained in:
2024-11-17 23:08:23 +01:00
parent e8aaa328e6
commit 63de1a3b02
53 changed files with 1942 additions and 2363 deletions

View File

@@ -1,197 +0,0 @@
import { Schema as S } from "@effect/schema";
import { Database as SqliteDatabase } from "bun:sqlite";
import { PieceId, SessionId, UserId } from "common";
import { AccessLog, Piece, SessionData, User } from "common/db";
import { Context, Effect, Layer, pipe, Option as O } from "effect";
import { NoSuchElementException } from "effect/Cause";
import { ulid } from "ulid";
export function generateSessionId(byteLength: number = 12): SessionId {
const array = new Uint8Array(byteLength);
crypto.getRandomValues(array);
const string = Buffer.from(array).toString("base64url");
return SessionId.make(string);
}
export interface DatabaseInterface {
readonly createAccessLog: (accessLog: AccessLog) => Effect.Effect<void>;
readonly createPiece: (name: string, composer: O.Option<string>, lyricist: O.Option<string>, arranger: O.Option<string>, user: UserId) => Effect.Effect<PieceId>;
readonly getPieces: Effect.Effect<readonly Piece[]>;
readonly updatePiece: (pieceId: PieceId, name: string, composer: O.Option<string>, lyricist: O.Option<string>, arranger: O.Option<string>, user: UserId) => Effect.Effect<void, NoSuchElementException>;
readonly deletePiece: (pieceId: PieceId) => Effect.Effect<void, NoSuchElementException>;
readonly getUserByUsername: (username: string) => Effect.Effect<User, NoSuchElementException>;
readonly getUserById: (userId: UserId) => Effect.Effect<User, NoSuchElementException>;
readonly createSession: (sessionData: SessionData) => Effect.Effect<SessionId>;
readonly getAndRefreshSessionData: (sessionId: SessionId) => Effect.Effect<SessionData, NoSuchElementException>;
readonly deleteSession: (sessionId: SessionId) => Effect.Effect<void>;
}
export class Database extends Context.Tag("Database")<Database, DatabaseInterface>() { }
export const DatabaseLive = (filePath: string = "db.sqlite3") => Layer.effect(Database, pipe(
Effect.acquireRelease(
Effect.sync(() => new SqliteDatabase(filePath, { create: true, readwrite: true, strict: true })),
(db) => Effect.sync(() => db.close()),
),
Effect.tap((db) => Effect.sync(() => {
const systemInformation = `
createdBy TEXT REFERENCES User (userId) ON DELETE SET NULL ON UPDATE CASCADE,
createdAt TEXT NOT NULL,
modifiedBy TEXT REFERENCES User (userId) ON DELETE SET NULL ON UPDATE CASCADE,
modifiedAt TEXT
`;
db.exec("PRAGMA foreign_keys = ON");
db.exec(`CREATE TABLE IF NOT EXISTS AccessLog (
timestamp TEXT NOT NULL,
requestId TEXT NOT NULL,
method TEXT NOT NULL,
pathname TEXT NOT NULL,
query TEXT NOT NULL,
ip TEXT
)`);
db.exec(`CREATE TABLE IF NOT EXISTS User (
userId TEXT NOT NULL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
admin BOOLEAN NOT NULL
)`);
db.exec(`CREATE TABLE IF NOT EXISTS Piece (
pieceId TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
composer TEXT,
lyricist TEXT,
arranger TEXT,
${systemInformation}
)`);
db.exec(`CREATE TABLE IF NOT EXISTS Session (
sessionId TEXT NOT NULL PRIMARY KEY,
userId TEXT NOT NULL REFERENCES User (userId) ON DELETE CASCADE ON UPDATE CASCADE,
expiresAt TEXT NOT NULL
)`);
db.exec(`CREATE TABLE IF NOT EXISTS Attachment (
attachmentId TEXT NOT NULL PRIMARY KEY,
pieceId TEXT NOT NULL REFERENCES Piece (pieceId) ON DELETE CASCADE ON UPDATE CASCADE,
sha256 BLOB NOT NULL,
filename TEXT NOT NULL,
mediaType TEXT NOT NULL,
${systemInformation}
)`);
const userCount = db.query("SELECT COUNT(*) FROM User").values()[0][0] as number;
if (userCount <= 0) {
const userId = UserId.make(ulid());
const username = "admin";
const password = Bun.password.hashSync("admin");
const admin = true;
db.exec("INSERT INTO User (userId, username, password, admin) VALUES (?, ?, ?, ?)", userId, username, password, admin);
}
})),
Effect.map((db) => {
const createAccessLog = db.prepare<
never,
[timestamp: string, requestId: string, method: string, pathname: string, query: string, ip: string | null]
>("INSERT INTO AccessLog (timestamp, requestId, method, pathname, query, ip) VALUES (?, ?, ?, ?, ?, ?)");
const createPiece = db.prepare<
never,
[pieceId: PieceId, name: string, composer: string | null, lyricist: string | null, arranger: string | null, user: UserId]
>("INSERT INTO Piece (pieceId, name, composer, lyricist, arranger, createdBy, createdAt, modifiedBy, modifiedAt) VALUES (?1, ?2, ?3, ?4, ?5, ?6, datetime(), ?6, NULL)");
const getPieces = db.prepare<
typeof Piece.Encoded,
[]
>("SELECT pieceId, name, composer, lyricist, arranger, createdBy, createdAt, modifiedBy, modifiedAt FROM Piece ORDER BY name, composer, arranger");
const updatePiece = db.prepare<
never,
[pieceId: PieceId, name: string, composer: string | null, lyricist: string | null, arranger: string | null, user: UserId]
>("UPDATE Piece SET name = ?2, composer = ?3, lyricist = ?4, arranger = ?5, modifiedBy = ?6, modifiedAt = datetime() WHERE pieceId = ?1");
const pieceExists = db.prepare<object, [pieceId: PieceId]>("SELECT 1 FROM Piece WHERE PieceId = ?");
const deletePiece = db.prepare<never, [pieceId: PieceId]>("DELETE FROM Piece WHERE pieceId = ?");
const getUserByUsername = db.prepare<
{ userId: string, username: string, password: string, admin: number },
[username: string]
>("SELECT userId, username, password, admin FROM User WHERE username = ?");
const getUserById = db.prepare<
{ userId: string, username: string, password: string, admin: number },
[userId: UserId]
>("SELECT userId, username, password, admin FROM User WHERE userId = ?");
const cleanupSessions = db.prepare<never, []>("DELETE FROM Session WHERE datetime() >= expiresAt");
const createSession = db.prepare<
never,
[sessionId: SessionId, userId: UserId]
>("INSERT INTO Session (sessionId, userId, expiresAt) VALUES (?, ?, datetime('now', '+7 days'))");
const updateSession = db.prepare<typeof SessionData.Encoded, [sessionId: SessionId]>("UPDATE Session SET expiresAt = datetime('now', '+7 days') WHERE sessionId = ? RETURNING userId");
const deleteSession = db.prepare<never, [sessionId: SessionId]>("DELETE FROM Session WHERE sessionId = ?");
return Object.freeze<DatabaseInterface>({
createAccessLog: (accessLog) => Effect.sync(() => {
const { timestamp, requestId, method, pathname, query, ip } = S.encodeSync(AccessLog)(accessLog);
createAccessLog.run(timestamp, requestId, method, pathname, query, ip);
}),
createPiece: (name: string, composer: O.Option<string>, lyricist: O.Option<string>, arranger: O.Option<string>, user: UserId) => Effect.sync(() => {
const pieceId = PieceId.make(ulid());
createPiece.run(pieceId, name, O.getOrNull(composer), O.getOrNull(lyricist), O.getOrNull(arranger), user);
return pieceId;
}),
getPieces: Effect.sync(() => {
const res = getPieces.all();
const decoder = S.decodeSync(Piece);
return Object.freeze(res.map((encoded) => decoder(encoded)));
}),
updatePiece: (pieceId: PieceId, name: string, composer: O.Option<string>, lyricist: O.Option<string>, arranger: O.Option<string>, user: UserId) => Effect.suspend(() => {
updatePiece.run(pieceId, name, O.getOrNull(composer), O.getOrNull(lyricist), O.getOrNull(arranger), user);
const matched = pieceExists.get(pieceId) !== null;
return matched ? Effect.void : Effect.fail(new NoSuchElementException());
}),
deletePiece: (pieceId: PieceId) => Effect.suspend(() => {
const matched = deletePiece.run(pieceId).changes > 0;
return matched ? Effect.void : Effect.fail(new NoSuchElementException());
}),
getUserByUsername: (username) => Effect.suspend(() => {
const res = getUserByUsername.get(username);
if (res === null) return Effect.fail(new NoSuchElementException());
return Effect.succeed(S.decodeSync(User)(res));
}),
getUserById: (userId) => Effect.suspend(() => {
const res = getUserById.get(userId);
if (res === null) return Effect.fail(new NoSuchElementException());
return Effect.succeed(S.decodeSync(User)(res));
}),
createSession: ({ userId }) => Effect.sync(() => {
cleanupSessions.run();
const sessionId = generateSessionId();
createSession.run(sessionId, userId);
return sessionId;
}),
getAndRefreshSessionData: (sessionId) => Effect.suspend(() => {
cleanupSessions.run();
const res = updateSession.get(sessionId);
if (res === null) return Effect.fail(new NoSuchElementException());
return Effect.succeed(S.decodeUnknownSync(SessionData)(res));
}),
deleteSession: (sessionId) => Effect.sync(() => {
cleanupSessions.run();
deleteSession.run(sessionId);
}),
});
}),
));

View File

@@ -1,16 +0,0 @@
import { RequestId } from "common";
import { Context, Effect, Option as O } from "effect";
export interface RequestInterface {
readonly requestId: RequestId;
readonly method: string;
readonly pathname: string;
readonly path: readonly string[];
readonly query: { readonly [_: string]: string };
readonly headers: { readonly [_: string]: string };
readonly ip: O.Option<string>;
readonly json: Effect.Effect<unknown, SyntaxError>;
}
export class Request extends Context.Tag("Request")<Request, RequestInterface>() { }

View File

@@ -1,107 +0,0 @@
import { Sha256 } from "common";
import { Context, Data, Effect, Layer } from "effect";
import { NoSuchElementException } from "effect/Cause";
import { constant, flow } from "effect/Function";
import path from "node:path";
export class StorageError extends Data.TaggedError("StorageError")<{ cause: unknown }> { }
export interface StorageInterface {
/**
* @param sha256 SHA-256 of blob to read
* @returns Blob, if exists
*/
readonly read: (sha256: Sha256) => Effect.Effect<Uint8Array, NoSuchElementException | StorageError>;
/**
* @param data Blob to write
* @param sha256 Precomputed SHA-256, trusted to be accurate if provided
* @returns Precomputed SHA-256, if provided, internally computed SHA-256
* otherwise
*/
readonly write: (data: Uint8Array, sha256?: Sha256) => Effect.Effect<Sha256, StorageError>;
}
export class Storage extends Context.Tag("Storage")<Storage, StorageInterface>() { }
/**
* Storage implementation that uses the filesystem.
*/
export const StorageFilesystem = (baseDir: string = ".") => {
const sha256toFilePath = (sha256: Sha256): string => {
const sha256hex = Buffer.from(sha256).toString("hex");
return path.join(
baseDir,
sha256hex.slice(0, 2),
sha256hex.slice(2, 4),
sha256hex.slice(4),
);
}
return Layer.succeed(Storage, Object.freeze<StorageInterface>({
read: (sha256) => {
const filePath = sha256toFilePath(sha256);
return Effect.gen(function* () {
const arrayBuffer = yield* Effect.tryPromise({
try: () => Bun.file(filePath).arrayBuffer(),
catch: (error) => new StorageError({ cause: error }),
});
const array = new Uint8Array(arrayBuffer);
return array;
});
},
write: (data, sha256) => Effect.gen(function* () {
if (sha256 === undefined) {
sha256 = Sha256.make(new Uint8Array(Bun.SHA256.byteLength));
Bun.SHA256.hash(data, sha256);
}
const filePath = sha256toFilePath(sha256);
yield* Effect.tryPromise({
try: () => Bun.write(filePath, data),
catch: (error) => new StorageError({ cause: error }),
});
return sha256;
}),
}));
};
const sha256ToBase64 = (sha256: Sha256): string => {
return Buffer.from(sha256).toString("base64");
}
/**
* Storage that keeps all data in memory.
*/
export const StorageMemory = Layer.sync(Storage, () => {
const map = new Map<string, Uint8Array>();
return Object.freeze<StorageInterface>({
read: flow(
sha256ToBase64,
_ => map.get(_),
Effect.fromNullable,
),
write: (data, sha256) => Effect.sync(() => {
if (sha256 === undefined) {
sha256 = Sha256.make(new Uint8Array(Bun.SHA256.byteLength));
Bun.SHA256.hash(data, sha256);
}
map.set(sha256ToBase64(sha256), data);
return sha256;
}),
});
});
/**
* Storage that discards any writes silently and always fails to read.
*/
export const StorageEmpty = Layer.succeed(Storage, Object.freeze<StorageInterface>({
read: constant(Effect.fail(new NoSuchElementException())),
write: (data, sha256) => Effect.sync(() => {
if (sha256 === undefined) {
sha256 = Sha256.make(new Uint8Array(Bun.SHA256.byteLength));
Bun.SHA256.hash(data, sha256);
}
return sha256;
}),
}));