Implement DB interface for Piece CRUD

This commit is contained in:
2024-08-13 23:17:48 +02:00
parent 1bd323bdd6
commit ac091b1dc9
5 changed files with 171 additions and 11 deletions

View File

@@ -1,6 +1,6 @@
import { Schema as S } from "@effect/schema";
import { SessionId } from "common";
import { Login, Logout, Me } from "common/api";
import { CreatePiece, GetPieces, UpdatePiece, DeletePiece, Login, Logout, Me } from "common/api";
import { AccessLog, SessionData } from "common/db";
import { DateTime, Duration, Either as E, Effect, Match, Option as O, pipe } from "effect";
import { constant } from "effect/Function";
@@ -111,7 +111,15 @@ export const app = pipe(
yield* db.createAccessLog(accessLog);
console.log(`${DateTime.toDateUtc(accessLog.timestamp).toISOString()} ${accessLog.requestId} ${accessLog.method} ${accessLog.pathname}?${new URLSearchParams(accessLog.query).toString()} ${accessLog.ip}`);
if (yield* match(Login)) {
if (yield* match(CreatePiece)) {
return new Response(null, { status: 501 });
} else if (yield* match(GetPieces)) {
return new Response(null, { status: 501 });
} else if (yield* match(UpdatePiece)) {
return new Response(null, { status: 501 });
} else if (yield* match(DeletePiece)) {
return new Response(null, { status: 501 });
} else if (yield* match(Login)) {
const body = yield* requestJson(Login.props.request.schema);

View File

@@ -1,8 +1,8 @@
import { Schema as S } from "@effect/schema";
import { Database as SqliteDatabase } from "bun:sqlite";
import { SessionId, UserId } from "common";
import { AccessLog, SessionData, User } from "common/db";
import { Context, Effect, Layer, pipe } from "effect";
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";
@@ -16,6 +16,11 @@ export function generateSessionId(byteLength: number = 12): SessionId {
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>;
@@ -96,6 +101,21 @@ export const DatabaseLive = (filePath: string = "db.sqlite3") => Layer.effect(Da
[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, datetime())");
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]
@@ -119,6 +139,29 @@ export const DatabaseLive = (filePath: string = "db.sqlite3") => Layer.effect(Da
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());

View File

@@ -1,7 +1,48 @@
import { Api } from "make-api";
import { Schema as S } from "@effect/schema";
import { UserId } from "common";
import { PieceId, UserId } from "common";
import { pipe } from "effect";
import { Piece } from "./db";
// --- PIECES ------------------------------------------------------------------
export const CreatePiece = pipe(
Api.make("POST", "piece"),
Api.requestBodyJson(S.Struct({
name: S.NonEmptyString,
composer: S.Union(S.NonEmptyString, S.Null),
lyricist: S.Union(S.NonEmptyString, S.Null),
arranger: S.Union(S.NonEmptyString, S.Null),
})),
Api.responseBodyJson(201, Piece),
Api.responseBodyText(400, S.String),
);
export const GetPieces = pipe(
Api.make("GET", "piece"),
Api.responseBodyJson(200, S.Array(Piece)),
);
export const UpdatePiece = pipe(
Api.make("PUT", "piece", ["pieceId", PieceId]),
Api.requestBodyJson(S.Struct({
name: S.NonEmptyString,
composer: S.Union(S.NonEmptyString, S.Null),
lyricist: S.Union(S.NonEmptyString, S.Null),
arranger: S.Union(S.NonEmptyString, S.Null),
})),
Api.responseBodyJson(200, Piece),
Api.responseBodyText(400, S.String),
);
export const DeletePiece = pipe(
Api.make("DELETE", "piece", ["pieceId", PieceId]),
Api.responseBodyNone(200),
Api.responseBodyText(400, S.String),
Api.responseBodyText(404, S.String),
);
// --- AUTHENTICATION ----------------------------------------------------------
export const Me = pipe(
Api.make("GET", "me"),

View File

@@ -1,10 +1,14 @@
import { style } from "@vanilla-extract/css";
export const container = style({
width: 800,
width: 1000,
maxWidth: "100%",
margin: "0 auto",
display: "flex",
flexDirection: "column",
alignItems: "stretch",
});
export const loading = style({
@@ -13,6 +17,70 @@ export const loading = style({
textAlign: "center",
});
export const content = style({
export const header = style({
padding: 8,
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
});
export const logout = style({
padding: 8,
backgroundColor: "#C0C0C0",
borderWidth: 2,
borderStyle: "solid",
borderTopColor: "#E0E0E0",
borderLeftColor: "#E0E0E0",
borderRightColor: "#404040",
borderBottomColor: "#404040",
borderRadius: 4,
cursor: "pointer",
"selectors": {
"&:focus": {
outlineWidth: 2,
outlineStyle: "solid",
outlineColor: "#8080FF",
"@media": {
"(prefers-color-scheme: dark)": {
outlineColor: "#C0C0FF",
},
},
},
"&:active": {
borderTopColor: "#404040",
borderLeftColor: "#404040",
borderRightColor: "#E0E0E0",
borderBottomColor: "#E0E0E0",
"@media": {
"(prefers-color-scheme: dark)": {
outlineColor: "#C0C0FF",
borderTopColor: "#202020",
borderLeftColor: "#202020",
borderRightColor: "#606060",
borderBottomColor: "#606060",
},
},
},
},
"@media": {
"(prefers-color-scheme: dark)": {
backgroundColor: "#404040",
borderTopColor: "#606060",
borderLeftColor: "#606060",
borderRightColor: "#202020",
borderBottomColor: "#202020",
},
},
});

View File

@@ -71,9 +71,9 @@ export const Home = () => {
return (
<div class={style.container}>
<div class={style.content}>
Użytkownik: {user.username}
<button type="button" onClick={onLogoutClick}>Wyloguj się</button>
<div class={style.header}>
<div>Użytkownik: {user.username}</div>
<div><button class={style.logout} type="button" onClick={onLogoutClick}>Wyloguj się</button></div>
</div>
</div>
);