import { Context, Effect, Layer } from "effect"; import { randomUUID } from "node:crypto"; import { DatabaseService } from "../../database/db.js"; import { DatabaseError, Session } from "../../../shared/types.js"; export interface CreateSessionData { userId: string; tokenHash: string; ipAddress: string | null; userAgent: string | null; expiresAt: number; } export class SessionRepository extends Context.Tag("SessionRepository")< SessionRepository, { readonly create: ( data: CreateSessionData ) => Effect.Effect; readonly findById: ( id: string ) => Effect.Effect; readonly findByTokenHash: ( tokenHash: string ) => Effect.Effect; readonly updateTokenHash: ( id: string, tokenHash: string ) => Effect.Effect; readonly revoke: (id: string) => Effect.Effect; readonly revokeAllForUser: ( userId: string ) => Effect.Effect; readonly deleteExpired: () => Effect.Effect; } >() { static Live = Layer.effect( this, Effect.gen(function* (_) { const db = yield* _(DatabaseService); const create = ( data: CreateSessionData ): Effect.Effect => Effect.gen(function* (_) { const id = randomUUID(); const now = Date.now(); yield* _( db.run( `INSERT INTO sessions ( id, user_id, token_hash, ip_address, user_agent, created_at, expires_at, is_revoked, revoked_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ id, data.userId, data.tokenHash, data.ipAddress, data.userAgent, now, data.expiresAt, 0, // is_revoked = false null, // revoked_at = null ] ) ); const session = yield* _(findById(id)); if (!session) { return yield* _( Effect.fail( new DatabaseError("Failed to retrieve created session") ) ); } return session; }); const findById = ( id: string ): Effect.Effect => Effect.gen(function* (_) { const row = yield* _( db.get<{ id: string; user_id: string; token_hash: string; ip_address: string | null; user_agent: string | null; created_at: number; expires_at: number; is_revoked: number; revoked_at: number | null; }>("SELECT * FROM sessions WHERE id = ?", [id]) ); if (!row) { return undefined; } return { id: row.id, userId: row.user_id, tokenHash: row.token_hash, ipAddress: row.ip_address, userAgent: row.user_agent, createdAt: row.created_at, expiresAt: row.expires_at, isRevoked: row.is_revoked === 1, revokedAt: row.revoked_at, }; }); const findByTokenHash = ( tokenHash: string ): Effect.Effect => Effect.gen(function* (_) { const row = yield* _( db.get<{ id: string; user_id: string; token_hash: string; ip_address: string | null; user_agent: string | null; created_at: number; expires_at: number; is_revoked: number; revoked_at: number | null; }>("SELECT * FROM sessions WHERE token_hash = ?", [tokenHash]) ); if (!row) { return undefined; } return { id: row.id, userId: row.user_id, tokenHash: row.token_hash, ipAddress: row.ip_address, userAgent: row.user_agent, createdAt: row.created_at, expiresAt: row.expires_at, isRevoked: row.is_revoked === 1, revokedAt: row.revoked_at, }; }); const updateTokenHash = ( id: string, tokenHash: string ): Effect.Effect => Effect.gen(function* (_) { yield* _( db.run( `UPDATE sessions SET token_hash = ? WHERE id = ?`, [tokenHash, id] ) ); const session = yield* _(findById(id)); if (!session) { return yield* _( Effect.fail( new DatabaseError("Failed to retrieve updated session") ) ); } return session; }); const revoke = (id: string): Effect.Effect => Effect.gen(function* (_) { yield* _( db.run( `UPDATE sessions SET is_revoked = 1, revoked_at = ? WHERE id = ?`, [Date.now(), id] ) ); }); const revokeAllForUser = ( userId: string ): Effect.Effect => Effect.gen(function* (_) { yield* _( db.run( `UPDATE sessions SET is_revoked = 1, revoked_at = ? WHERE user_id = ? AND is_revoked = 0`, [Date.now(), userId] ) ); }); const deleteExpired = (): Effect.Effect => Effect.gen(function* (_) { const now = Date.now(); const result = yield* _( db.run<{ changes: number }>( `DELETE FROM sessions WHERE expires_at < ?`, [now] ) ); return result.changes ?? 0; }); return { create, findById, findByTokenHash, updateTokenHash, revoke, revokeAllForUser, deleteExpired, }; }) ); }