JUMBO refactor, still work in progress
This commit is contained in:
397
packages/backend/src/services/Authentication.ts
Normal file
397
packages/backend/src/services/Authentication.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
import { config } from "backend/config";
|
||||
import { BunRequest } from "bun";
|
||||
import { SessionId, UserId } from "common";
|
||||
import { fetch, FetchError } from "common/Fetch";
|
||||
import { Me, NotFound, Other, Role, Unauthenticated } from "common/the_api";
|
||||
import { Context, DateTime, Duration, Effect, HashMap, HashSet, Layer, Option, pipe, Redacted, Schema } from "effect";
|
||||
import { constant } from "effect/Function";
|
||||
import { sql } from "kysely";
|
||||
import * as Database from "./Database";
|
||||
|
||||
export interface AuthenticationInterface {
|
||||
readonly me: Effect.Effect<Me, Unauthenticated>;
|
||||
readonly logout: Effect.Effect<void>;
|
||||
readonly sessionId: SessionId;
|
||||
readonly getUser: (userId: UserId) => Effect.Effect<Other, FetchError | Unauthenticated | NotFound>;
|
||||
}
|
||||
|
||||
export class Authentication extends Context.Tag("Authentication")<Authentication, AuthenticationInterface>() { }
|
||||
|
||||
export const OAUTH_SCOPE = "email openid profile offline_access";
|
||||
export const REDIRECT_URI = config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/login` : "http://localhost:3000/login";
|
||||
|
||||
export const EXPIRATION_BUFFER = Duration.seconds(10);
|
||||
|
||||
export const SESSION_COOKIE_NAME = "sessionId";
|
||||
|
||||
const ALL_ROLES = HashSet.fromIterable(Object.values(Role));
|
||||
|
||||
export const Live = (request: BunRequest) => Layer.effect(Authentication, Effect.gen(function* () {
|
||||
const database = yield* Database.Database;
|
||||
|
||||
yield* database
|
||||
.deleteFrom("Session")
|
||||
.where(sql`datetime()`, ">=", "expiresAt")
|
||||
.$call(Database.execute);
|
||||
|
||||
const sessionId = pipe(
|
||||
request.cookies.get(SESSION_COOKIE_NAME),
|
||||
Option.fromNullable,
|
||||
Option.map(SessionId.make),
|
||||
Option.getOrElse(generateSessionId),
|
||||
);
|
||||
|
||||
request.cookies.set(SESSION_COOKIE_NAME, sessionId, {
|
||||
expires: yield* pipe(
|
||||
DateTime.now,
|
||||
Effect.map(DateTime.addDuration("7 days")),
|
||||
Effect.map(DateTime.toDateUtc),
|
||||
),
|
||||
httpOnly: true,
|
||||
sameSite: "none",
|
||||
secure: true,
|
||||
});
|
||||
|
||||
const returning = [
|
||||
"sessionId",
|
||||
"accessToken",
|
||||
"codeVerifier",
|
||||
"idToken",
|
||||
"refreshToken",
|
||||
"state",
|
||||
] as const;
|
||||
|
||||
const session = yield* database
|
||||
.updateTable("Session")
|
||||
.set({ expiresAt: sql`datetime('now', '+7 days') ` })
|
||||
.where("sessionId", "=", sessionId)
|
||||
.returning(returning)
|
||||
.$call(Database.executeTakeFirst)
|
||||
.pipe(Effect.catchTag("NoSuchElementException", () => database
|
||||
.insertInto("Session")
|
||||
.values({ sessionId, expiresAt: sql`datetime('now', '+7 days')` })
|
||||
.returning(returning)
|
||||
.$call(Database.executeTakeFirstOrDie)
|
||||
));
|
||||
|
||||
const { accessToken, idToken, refreshToken } = yield* revalidateTokens({
|
||||
accessToken: Option.fromNullable(session.accessToken),
|
||||
idToken: Option.fromNullable(session.idToken),
|
||||
refreshToken: Option.fromNullable(session.refreshToken),
|
||||
});
|
||||
|
||||
yield* database
|
||||
.updateTable("Session")
|
||||
.set({
|
||||
accessToken: pipe(
|
||||
accessToken,
|
||||
Option.map((at) => at.token),
|
||||
Option.getOrNull,
|
||||
),
|
||||
idToken: pipe(
|
||||
idToken,
|
||||
Option.map((it) => it.token),
|
||||
Option.getOrNull,
|
||||
),
|
||||
refreshToken: Option.getOrNull(refreshToken),
|
||||
})
|
||||
.$call(Database.execute);
|
||||
|
||||
const state = Object.freeze({
|
||||
sessionId: session.sessionId,
|
||||
accessToken,
|
||||
idToken,
|
||||
refreshToken,
|
||||
codeVerifier: Option.fromNullable(session.codeVerifier),
|
||||
state: Option.fromNullable(session.state),
|
||||
});
|
||||
|
||||
return Object.freeze<AuthenticationInterface>({
|
||||
me: pipe(
|
||||
state.idToken,
|
||||
Option.map(({ payload }) => {
|
||||
const userData: Me = Object.freeze<Me>({
|
||||
userId: payload.sub,
|
||||
displayName: payload.display_name,
|
||||
roles: ALL_ROLES,
|
||||
});
|
||||
return userData;
|
||||
}),
|
||||
Option.match({
|
||||
onNone: () => Effect.fail(Unauthenticated.make()),
|
||||
onSome: Effect.succeed,
|
||||
}),
|
||||
),
|
||||
logout: database
|
||||
.updateTable("Session")
|
||||
.set({
|
||||
accessToken: null,
|
||||
codeVerifier: null,
|
||||
expiresAt: sql`datetime('now', '+7 days')`,
|
||||
idToken: null,
|
||||
refreshToken: null,
|
||||
state: null,
|
||||
})
|
||||
.where("sessionId", "=", state.sessionId)
|
||||
.$call(Database.execute),
|
||||
sessionId: state.sessionId,
|
||||
getUser: (userId) => pipe(
|
||||
getDisplayName(userId),
|
||||
Effect.map((displayName) => Object.freeze<Other>({ userId, displayName })),
|
||||
),
|
||||
});
|
||||
}));
|
||||
|
||||
export const Test = (me: Option.Option<Me>, other: HashMap.HashMap<UserId, Other>) => Layer.sync(Authentication, constant(Object.freeze<AuthenticationInterface>({
|
||||
me: Option.match(me, {
|
||||
onNone: () => Effect.fail(Unauthenticated.make()),
|
||||
onSome: (me) => Effect.succeed(me),
|
||||
}),
|
||||
logout: Effect.void,
|
||||
sessionId: generateSessionId(),
|
||||
getUser: Option.match(me, {
|
||||
onNone: () => constant(Effect.fail(Unauthenticated.make())),
|
||||
onSome: () => (userId) => pipe(
|
||||
other,
|
||||
HashMap.get(userId),
|
||||
Option.match({
|
||||
onNone: () => Effect.fail(NotFound.make()),
|
||||
onSome: Effect.succeed,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
})));
|
||||
|
||||
export const AccessTokenPayload = Schema.Struct({
|
||||
aud: pipe(
|
||||
Schema.String,
|
||||
Schema.HashSet,
|
||||
),
|
||||
exp: Schema.DateTimeUtc,
|
||||
iat: Schema.DateTimeUtc,
|
||||
iss: Schema.String,
|
||||
sub: UserId,
|
||||
});
|
||||
|
||||
export const IdTokenPayload = Schema.Struct({
|
||||
aud: pipe(
|
||||
Schema.String,
|
||||
Schema.HashSet,
|
||||
),
|
||||
exp: Schema.DateTimeUtc,
|
||||
iat: Schema.DateTimeUtc,
|
||||
iss: Schema.String,
|
||||
sub: UserId,
|
||||
|
||||
name: Schema.String,
|
||||
given_name: Schema.String,
|
||||
family_name: Schema.String,
|
||||
display_name: Schema.String,
|
||||
preferred_username: Schema.String,
|
||||
|
||||
email: Schema.String,
|
||||
email_verified: Schema.Boolean,
|
||||
|
||||
picture: Schema.String,
|
||||
});
|
||||
|
||||
export type AccessTokenPayload = typeof AccessTokenPayload.Type;
|
||||
export type IdTokenPayload = typeof IdTokenPayload.Type;
|
||||
|
||||
function generateCodeVerifier(byteLength: number = 32) {
|
||||
const codeVerifierBytes = new Uint8Array(byteLength);
|
||||
crypto.getRandomValues(codeVerifierBytes);
|
||||
const codeVerifier = Buffer.from(codeVerifierBytes).toString("base64url");
|
||||
|
||||
const codeVerifierAsciiBuffer = Buffer.from(codeVerifier, "ascii");
|
||||
const codeVerifierAsciiArray = new Uint8Array(
|
||||
codeVerifierAsciiBuffer.buffer,
|
||||
codeVerifierAsciiBuffer.byteOffset,
|
||||
codeVerifierAsciiBuffer.length,
|
||||
);
|
||||
const codeChallenge = Bun.SHA256.hash(codeVerifierAsciiArray, "base64url");
|
||||
|
||||
return { codeVerifier, codeChallenge };
|
||||
}
|
||||
|
||||
function generateSessionId(byteLength: number = 32): SessionId {
|
||||
const array = new Uint8Array(byteLength);
|
||||
crypto.getRandomValues(array);
|
||||
const string = Buffer.from(array).toString("base64url");
|
||||
return SessionId.make(string);
|
||||
};
|
||||
|
||||
function generateRandomState(byteLength: number = 32): string {
|
||||
const array = new Uint8Array(byteLength);
|
||||
crypto.getRandomValues(array);
|
||||
const state = Buffer.from(array).toString("base64url");
|
||||
return state;
|
||||
}
|
||||
|
||||
const getJwtTokenPayload = <A, I, R>(schema: Schema.Schema<A, I, R>) => {
|
||||
const decoder = Schema.decodeUnknown(schema);
|
||||
return (token: string) => {
|
||||
const json = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf-8"));
|
||||
return pipe(
|
||||
decoder(json),
|
||||
Effect.orDie,
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
const getDisplayName = (userId: UserId) => Effect.gen(function* () {
|
||||
const url = new URL(`/api/users/${userId}`, config.POCKET_ID_API_ORIGIN);
|
||||
const res = yield* fetch(url, {
|
||||
headers: {
|
||||
"X-API-KEY": config.POCKET_ID_API_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return yield* Effect.fail(NotFound.make());
|
||||
}
|
||||
|
||||
const json = yield* Effect.promise(() => res.json());
|
||||
const displayName: string = json.displayName;
|
||||
return displayName;
|
||||
});
|
||||
|
||||
export const makeAuthorizationUrl = (sessionId: SessionId) => Effect.gen(function* () {
|
||||
const database = yield* Database.Database;
|
||||
|
||||
const { codeVerifier, codeChallenge } = generateCodeVerifier();
|
||||
const state = generateRandomState();
|
||||
|
||||
yield* database
|
||||
.updateTable("Session")
|
||||
.set({
|
||||
codeVerifier,
|
||||
state,
|
||||
accessToken: null,
|
||||
idToken: null,
|
||||
refreshToken: null,
|
||||
})
|
||||
.where("sessionId", "=", sessionId)
|
||||
.$call(Database.execute);
|
||||
|
||||
const url = new URL(config.OAUTH_AUTHORIZATION_ENDPOINT);
|
||||
url.searchParams.set("client_id", config.CLIENT_ID);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("redirect_uri", REDIRECT_URI);
|
||||
url.searchParams.set("scope", OAUTH_SCOPE);
|
||||
url.searchParams.set("response_mode", "form_post");
|
||||
url.searchParams.set("state", state);
|
||||
url.searchParams.set("code_challenge", codeChallenge);
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
|
||||
return url.toString();
|
||||
});
|
||||
|
||||
namespace revaildateTokens {
|
||||
export interface Args {
|
||||
readonly accessToken: Option.Option<string>;
|
||||
readonly idToken: Option.Option<string>;
|
||||
readonly refreshToken: Option.Option<string>;
|
||||
}
|
||||
|
||||
export interface Result {
|
||||
readonly accessToken: Option.Option<{
|
||||
readonly token: string,
|
||||
readonly payload: AccessTokenPayload,
|
||||
}>;
|
||||
readonly idToken: Option.Option<{
|
||||
readonly token: string,
|
||||
readonly payload: IdTokenPayload,
|
||||
}>;
|
||||
readonly refreshToken: Option.Option<string>;
|
||||
}
|
||||
}
|
||||
|
||||
const revalidateTokens = ({ accessToken, idToken, refreshToken }: revaildateTokens.Args) => Effect.gen(function* () {
|
||||
|
||||
const accessTokenPayload = yield* pipe(
|
||||
accessToken,
|
||||
Effect.transposeMapOption(getJwtTokenPayload(AccessTokenPayload)),
|
||||
);
|
||||
|
||||
const idTokenPayload = yield* pipe(
|
||||
idToken,
|
||||
Effect.transposeMapOption(getJwtTokenPayload(IdTokenPayload)),
|
||||
);
|
||||
|
||||
const expirationThreshold = DateTime.addDuration(
|
||||
DateTime.unsafeNow(),
|
||||
EXPIRATION_BUFFER,
|
||||
);
|
||||
|
||||
// Token expired or missing
|
||||
if (Option.match(accessTokenPayload, {
|
||||
onNone: constant(false),
|
||||
onSome: (atp) => DateTime.greaterThan(expirationThreshold, atp.exp),
|
||||
}) || Option.match(idTokenPayload, {
|
||||
onNone: constant(false),
|
||||
onSome: (itp) => DateTime.greaterThan(expirationThreshold, itp.exp),
|
||||
})) {
|
||||
|
||||
accessToken = Option.none();
|
||||
idToken = Option.none();
|
||||
|
||||
// try refreshing
|
||||
if (Option.isSome(refreshToken)) {
|
||||
const refreshTokenValue = refreshToken.value;
|
||||
|
||||
const res = yield* fetch(config.OAUTH_TOKEN_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
"client_id": config.CLIENT_ID,
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refreshTokenValue,
|
||||
"client_secret": Redacted.value(config.CLIENT_SECRET),
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
const json: {
|
||||
access_token: string,
|
||||
refresh_token: string,
|
||||
id_token: string,
|
||||
} = yield* Effect.promise(() => res.json());
|
||||
|
||||
accessToken = Option.some(json.access_token);
|
||||
idToken = Option.some(json.id_token);
|
||||
refreshToken = Option.some(json.refresh_token);
|
||||
}
|
||||
}
|
||||
|
||||
const it = yield* pipe(
|
||||
idToken,
|
||||
Option.map((it) => pipe(
|
||||
Effect.Do,
|
||||
Effect.let("token", () => it),
|
||||
Effect.bind("payload", ({ token }) => getJwtTokenPayload(IdTokenPayload)(token)),
|
||||
)),
|
||||
Effect.transposeOption,
|
||||
Effect.map(Option.map((it) => Object.freeze(it))),
|
||||
);
|
||||
|
||||
const at = yield* pipe(
|
||||
accessToken,
|
||||
Option.map((at) => pipe(
|
||||
Effect.Do,
|
||||
Effect.let("token", () => at),
|
||||
Effect.bind("payload", ({ token }) => getJwtTokenPayload(AccessTokenPayload)(token)),
|
||||
)),
|
||||
Effect.transposeOption,
|
||||
Effect.map(Option.map((at) => Object.freeze(at))),
|
||||
);
|
||||
|
||||
const res: revaildateTokens.Result = Object.freeze<revaildateTokens.Result>({
|
||||
accessToken: at,
|
||||
idToken: it,
|
||||
refreshToken,
|
||||
});
|
||||
|
||||
return res;
|
||||
});
|
||||
294
packages/backend/src/services/Database.ts
Normal file
294
packages/backend/src/services/Database.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { Database as BunSqliteDatabase } from "bun:sqlite";
|
||||
import { AttachmentId, PieceId, RepertoireId, RequestId, SessionId, Sha256, UserId } from "common";
|
||||
import { Cause, Context, Effect, Either, HashSet, Layer, pipe, Runtime } from "effect";
|
||||
import { ColumnType, CompiledQuery, CreateTableBuilder, Insertable, Kysely, Selectable, Transaction } from "kysely";
|
||||
import { BunSqliteDialect } from "kysely-bun-sqlite";
|
||||
|
||||
// --- MARK: KYSELY SCHEMA -----------------------------------------------------
|
||||
|
||||
export interface DatabaseSchema {
|
||||
Attachment: AttachmentTable;
|
||||
File: FileTable;
|
||||
Piece: PieceTable;
|
||||
Option: OptionTable;
|
||||
Repertoire: RepertoireTable;
|
||||
RepertoireEntry: RepertoireEntryTable;
|
||||
Session: SessionTable;
|
||||
sqlite_schema: SqliteSchemaTable;
|
||||
}
|
||||
|
||||
export interface SystemInformation {
|
||||
createdBy: ColumnType<UserId | null, UserId, never>;
|
||||
createdAt: ColumnType<string, string, never>;
|
||||
modifiedBy: ColumnType<UserId | null, null, UserId>;
|
||||
modifiedAt: ColumnType<string | null, null, string>;
|
||||
}
|
||||
|
||||
export interface AttachmentData {
|
||||
pieceId: ColumnType<PieceId, PieceId, never>;
|
||||
sha256: Sha256;
|
||||
filename: string;
|
||||
mediaType: string;
|
||||
}
|
||||
|
||||
export interface AttachmentTable extends AttachmentData, SystemInformation {
|
||||
attachmentId: ColumnType<AttachmentId, AttachmentId, never>;
|
||||
}
|
||||
|
||||
export interface FileTable {
|
||||
sha256: ColumnType<Sha256, Sha256, never>;
|
||||
data: ColumnType<Uint8Array, Uint8Array, never>;
|
||||
}
|
||||
|
||||
export interface OptionTable {
|
||||
key: ColumnType<string, string, never>;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface PieceData {
|
||||
name: string;
|
||||
composer: string | null;
|
||||
lyricist: string | null;
|
||||
arranger: string | null;
|
||||
}
|
||||
|
||||
export interface PieceTable extends PieceData, SystemInformation {
|
||||
pieceId: ColumnType<PieceId, PieceId, never>;
|
||||
}
|
||||
|
||||
export interface RepertoireTable extends SystemInformation {
|
||||
repertoireId: ColumnType<RepertoireId, RepertoireId, never>;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface RepertoireEntryData {
|
||||
pieceId: ColumnType<PieceId, PieceId, never>;
|
||||
}
|
||||
|
||||
export interface RepertoireEntryTable extends RepertoireEntryData {
|
||||
repertoireId: ColumnType<RepertoireId, RepertoireId, never>;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface SessionData {
|
||||
state: string | null;
|
||||
codeVerifier: string | null;
|
||||
accessToken: string | null;
|
||||
idToken: string | null;
|
||||
refreshToken: string | null;
|
||||
}
|
||||
|
||||
export interface SessionTable extends SessionData {
|
||||
sessionId: ColumnType<SessionId, SessionId, never>;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface SqliteSchemaTable {
|
||||
type: ColumnType<string, never, never>;
|
||||
name: ColumnType<string, never, never>;
|
||||
tbl_name: ColumnType<string, never, never>;
|
||||
rootpage: ColumnType<number, never, never>;
|
||||
sql: ColumnType<string | null, never, never>;
|
||||
}
|
||||
|
||||
export type Attachment = Selectable<AttachmentTable>;
|
||||
export type File = Selectable<FileTable>;
|
||||
export type Option = Selectable<OptionTable>;
|
||||
export type Piece = Selectable<PieceTable>;
|
||||
export type Repertoire = Selectable<RepertoireTable>;
|
||||
export type RepertoireEntry = Selectable<RepertoireEntryTable>;
|
||||
export type Session = Selectable<SessionTable>;
|
||||
export type SqliteSchema = Selectable<SqliteSchemaTable>;
|
||||
|
||||
// --- MARK: EFFECT LAYERS -----------------------------------------------------
|
||||
|
||||
export type ArrayOrSingle<T> = T | readonly T[];
|
||||
|
||||
export type MockData = {
|
||||
readonly [K in keyof DatabaseSchema]?: ArrayOrSingle<Insertable<DatabaseSchema[K]>>;
|
||||
};
|
||||
|
||||
export interface Executable<O> {
|
||||
execute(): Promise<O>;
|
||||
}
|
||||
|
||||
export interface ExecutableTakeFirst<O> {
|
||||
executeTakeFirst(): Promise<O | undefined>;
|
||||
}
|
||||
|
||||
export class Database extends Context.Tag("Db")<Database, Kysely<DatabaseSchema>>() { }
|
||||
|
||||
export const FromPath = (path: string) => Layer.effect(Database, initDatabase(path));
|
||||
|
||||
export const Mocked = (data: MockData) => Layer.effect(Database, Effect.gen(function* () {
|
||||
const database = yield* initDatabase(":memory:");
|
||||
|
||||
yield* transaction(database, (trx) => Effect.gen(function* () {
|
||||
for (const [table, values] of Object.entries(data)) {
|
||||
yield* trx
|
||||
.insertInto(table as keyof typeof data)
|
||||
.values(values)
|
||||
.$call(execute);
|
||||
}
|
||||
}));
|
||||
|
||||
return database;
|
||||
}));
|
||||
|
||||
export const execute = <O>(executable: Executable<O>) => Effect.promise(() => Object.freeze(executable.execute()));
|
||||
|
||||
export const executeTakeFirst = <O>(executable: ExecutableTakeFirst<O>) => pipe(
|
||||
Effect.promise(() => executable.executeTakeFirst()),
|
||||
Effect.flatMap(Effect.fromNullable),
|
||||
);
|
||||
|
||||
export const executeTakeFirstOrDie = <O>(executable: ExecutableTakeFirst<O>) => pipe(
|
||||
Effect.promise(() => executable.executeTakeFirst()),
|
||||
Effect.flatMap(Effect.fromNullable),
|
||||
Effect.orDie,
|
||||
);
|
||||
|
||||
export const transaction = <Schema, A, E, R>(database: Kysely<Schema>, callback: (trx: Transaction<Schema>) => Effect.Effect<A, E, R>): Effect.Effect<A, E, R> => Effect.gen(function* () {
|
||||
const runtime = yield* Effect.runtime<R>();
|
||||
const promise = database.transaction().execute((trx) => Runtime.runPromise(runtime, callback(trx)));
|
||||
return yield* pipe(
|
||||
Effect.tryPromise(() => promise),
|
||||
Effect.catchAll((error) => {
|
||||
if (!Runtime.isFiberFailure(error.error)) {
|
||||
return Effect.die(error);
|
||||
}
|
||||
|
||||
const cause = error.error[Runtime.FiberFailureCauseId] as Cause.Cause<E>;
|
||||
return Either.match(Cause.failureOrCause(cause), {
|
||||
onLeft: Effect.fail,
|
||||
onRight: Effect.die,
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const initDatabase = (filename: string) => Effect.gen(function* () {
|
||||
|
||||
const systemInformation = <TB extends string, C extends string>(schema: CreateTableBuilder<TB, C>) => schema
|
||||
.addColumn("createdBy", "text")
|
||||
.addColumn("createdAt", "text", (c) => c.notNull())
|
||||
.addColumn("modifiedBy", "text")
|
||||
.addColumn("modifiedAt", "text");
|
||||
|
||||
const database = new BunSqliteDatabase(filename, { create: true, readwrite: true });
|
||||
const dialect = new BunSqliteDialect({ database });
|
||||
const db = new Kysely<DatabaseSchema>({ dialect });
|
||||
|
||||
yield* Effect.promise(() => db.executeQuery(CompiledQuery.raw("PRAGMA foreign_keys = ON")));
|
||||
|
||||
const tables = yield* db
|
||||
.selectFrom("sqlite_schema")
|
||||
.select("name")
|
||||
.where("type", "=", "table")
|
||||
.$call(execute)
|
||||
.pipe(Effect.map((tables) => HashSet.make(...tables.map(({ name }) => name))));
|
||||
|
||||
if (!HashSet.has(tables, "Option")) {
|
||||
yield* db.schema
|
||||
.createTable("File")
|
||||
.ifNotExists()
|
||||
.addColumn("sha256", "blob", (c) => c.notNull().primaryKey())
|
||||
.addColumn("data", "blob", (c) => c.notNull())
|
||||
.$call(execute);
|
||||
|
||||
yield* db.schema
|
||||
.createTable("User")
|
||||
.ifNotExists()
|
||||
.addColumn("userId", "text", (c) => c.notNull().primaryKey())
|
||||
.addColumn("username", "text", (c) => c.notNull().unique())
|
||||
.addColumn("password", "text", (c) => c.notNull())
|
||||
.addColumn("admin", "boolean", (c) => c.notNull())
|
||||
.$call(execute);
|
||||
|
||||
yield* db.schema
|
||||
.createTable("Piece")
|
||||
.ifNotExists()
|
||||
.addColumn("pieceId", "text", (c) => c.notNull().primaryKey())
|
||||
.addColumn("name", "text", (c) => c.notNull())
|
||||
.addColumn("composer", "text")
|
||||
.addColumn("lyricist", "text")
|
||||
.addColumn("arranger", "text")
|
||||
.$call(systemInformation)
|
||||
.$call(execute);
|
||||
|
||||
yield* db.schema
|
||||
.createIndex("Piece_name_composer_arranger")
|
||||
.ifNotExists()
|
||||
.on("Piece")
|
||||
.columns(["name", "composer", "arranger"])
|
||||
.$call(execute);
|
||||
|
||||
yield* db.schema
|
||||
.createTable("Repertoire")
|
||||
.ifNotExists()
|
||||
.addColumn("repertoireId", "text", (c) => c.notNull().primaryKey())
|
||||
.addColumn("name", "text", (c) => c.notNull())
|
||||
.$call(systemInformation)
|
||||
.$call(execute);
|
||||
|
||||
yield* db.schema
|
||||
.createIndex("Repertoire_name")
|
||||
.ifNotExists()
|
||||
.on("Repertoire")
|
||||
.column("name")
|
||||
.$call(execute);
|
||||
|
||||
yield* db.schema
|
||||
.createTable("RepertoireEntry")
|
||||
.ifNotExists()
|
||||
.addColumn("repertoireId", "text", (c) => c.notNull().references("Repertoire.repertoireId").onDelete("cascade").onUpdate("cascade"))
|
||||
.addColumn("order", "integer", (c) => c.notNull())
|
||||
.addColumn("pieceId", "text", (c) => c.notNull().references("Piece.pieceId").onDelete("restrict").onUpdate("restrict"))
|
||||
.addPrimaryKeyConstraint("pk_RepertoireEntry", ["repertoireId", "order"])
|
||||
.$call(execute);
|
||||
|
||||
yield* db.schema
|
||||
.createTable("Session")
|
||||
.ifNotExists()
|
||||
.addColumn("sessionId", "text", (c) => c.notNull().primaryKey())
|
||||
.addColumn("state", "text")
|
||||
.addColumn("codeVerifier", "text")
|
||||
.addColumn("accessToken", "text")
|
||||
.addColumn("idToken", "text")
|
||||
.addColumn("refreshToken", "text")
|
||||
.addColumn("expiresAt", "text", (c) => c.notNull())
|
||||
.$call(execute);
|
||||
|
||||
yield* db.schema
|
||||
.createTable("Attachment")
|
||||
.ifNotExists()
|
||||
.addColumn("attachmentId", "text", (c) => c.notNull().primaryKey())
|
||||
.addColumn("pieceId", "text", (c) => c.notNull().references("Piece.pieceId").onDelete("cascade").onUpdate("cascade"))
|
||||
.addColumn("sha256", "blob", (c) => c.notNull().references("File.sha256").onDelete("restrict").onUpdate("restrict"))
|
||||
.addColumn("filename", "text", (c) => c.notNull())
|
||||
.addColumn("mediaType", "text", (c) => c.notNull())
|
||||
.$call(systemInformation)
|
||||
.$call(execute);
|
||||
|
||||
yield* db.schema
|
||||
.createIndex("Attachment_pieceId_filename")
|
||||
.ifNotExists()
|
||||
.on("Attachment")
|
||||
.columns(["pieceId", "filename"])
|
||||
.$call(execute);
|
||||
|
||||
yield* db.schema
|
||||
.createTable("Option")
|
||||
.ifNotExists()
|
||||
.addColumn("key", "text", (c) => c.notNull().primaryKey())
|
||||
.addColumn("value", "text", (c) => c.notNull())
|
||||
.$call(execute);
|
||||
|
||||
yield* db
|
||||
.insertInto("Option")
|
||||
.values({ key: "database_version", value: "0" })
|
||||
.$call(execute);
|
||||
}
|
||||
|
||||
return db;
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Cause, Context, Effect, Layer, pipe } from "effect";
|
||||
import { Kysely } from "kysely";
|
||||
import { Database } from "../database";
|
||||
|
||||
export interface Executable<O> {
|
||||
execute(): Promise<O[]>;
|
||||
executeTakeFirst(): Promise<O | undefined>;
|
||||
}
|
||||
|
||||
export interface DbInterface {
|
||||
readonly db: Kysely<Database>;
|
||||
readonly execute: <O>(executable: Executable<O>) => Effect.Effect<readonly O[]>;
|
||||
readonly executeTakeFirst: <O>(executable: Executable<O>) => Effect.Effect<O, Cause.NoSuchElementException>;
|
||||
readonly executeTakeFirstOrDefect: <O>(executable: Executable<O>) => Effect.Effect<O>;
|
||||
}
|
||||
|
||||
export class Db extends Context.Tag("Db")<Db, DbInterface>() { }
|
||||
|
||||
export const DbFromInstance = (db: Kysely<Database>) => Layer.succeed(Db, Object.freeze<DbInterface>({
|
||||
db,
|
||||
execute: (executable) => Effect.promise(() => Object.freeze(executable.execute())),
|
||||
executeTakeFirst: (executable) => pipe(
|
||||
Effect.promise(() => executable.executeTakeFirst()),
|
||||
Effect.flatMap(Effect.fromNullable),
|
||||
),
|
||||
executeTakeFirstOrDefect: (executable) => pipe(
|
||||
Effect.promise(() => executable.executeTakeFirst()),
|
||||
Effect.flatMap(Effect.fromNullable),
|
||||
Effect.orDie,
|
||||
),
|
||||
}));
|
||||
@@ -1,6 +0,0 @@
|
||||
import { SessionId } from "common";
|
||||
import { Context, Layer } from "effect";
|
||||
|
||||
export class Session extends Context.Tag("Session")<Session, SessionId>() { }
|
||||
|
||||
export const SessionFromValue = Layer.succeed(Session);
|
||||
Reference in New Issue
Block a user