Port to elysia, tailwind (no effect)
This commit is contained in:
@@ -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);
|
||||
}),
|
||||
});
|
||||
}),
|
||||
));
|
||||
@@ -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>() { }
|
||||
@@ -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;
|
||||
}),
|
||||
}));
|
||||
Reference in New Issue
Block a user