1import SqliteDb from "better-sqlite3";
2import { randomBytes } from "crypto";
3import e from "express";
4
5import {
6 Kysely,
7 Migrator,
8 SqliteDialect,
9 Migration,
10 MigrationProvider,
11} from "kysely";
12import { isNumber } from "util";
13
14export type DatabaseSchema = {
15 paste: Paste;
16 comment: Comment;
17 auth_state: AuthState;
18 auth_session: AuthSession;
19};
20
21export type Paste = {
22 uri: string;
23 authorDid: string;
24 shortUrl: string;
25 code: string;
26 lang: string;
27 title: string;
28 createdAt: string;
29 indexedAt: string;
30};
31
32export type AuthSession = {
33 key: string;
34 session: AuthSessionJson;
35};
36
37export type AuthState = {
38 key: string;
39 state: AuthStateJson;
40};
41
42export type Comment = {
43 uri: string;
44 authorDid: string;
45 body: string;
46 createdAt: string;
47 indexedAt: string;
48 pasteUri: string;
49 pasteCid: string;
50};
51
52type AuthSessionJson = string;
53type AuthStateJson = string;
54
55export type Schema = {
56 paste: Paste;
57 auth_session: AuthSession;
58 auth_state: AuthState;
59};
60
61const migrations: Record<string, Migration> = {};
62
63const migrationProvider: MigrationProvider = {
64 async getMigrations() {
65 return migrations;
66 },
67};
68
69migrations["001"] = {
70 async up(db: Kysely<unknown>) {
71 await db.schema
72 .createTable("paste")
73 .addColumn("uri", "varchar", (col) => col.primaryKey())
74 .addColumn("shortUrl", "varchar", (col) => col.notNull().unique())
75 .addColumn("authorDid", "varchar", (col) => col.notNull())
76 .addColumn("code", "varchar", (col) => col.notNull())
77 .addColumn("lang", "varchar")
78 .addColumn("title", "varchar", (col) => col.notNull())
79 .addColumn("createdAt", "varchar", (col) => col.notNull())
80 .addColumn("indexedAt", "varchar", (col) => col.notNull())
81 .execute();
82
83 await db.schema
84 .createTable("auth_session")
85 .addColumn("key", "varchar", (col) => col.primaryKey())
86 .addColumn("session", "varchar", (col) => col.notNull())
87 .execute();
88
89 await db.schema
90 .createTable("auth_state")
91 .addColumn("key", "varchar", (col) => col.primaryKey())
92 .addColumn("state", "varchar", (col) => col.notNull())
93 .execute();
94 },
95 async down(db: Kysely<unknown>) {
96 await db.schema.dropTable("auth_state").execute();
97 await db.schema.dropTable("auth_session").execute();
98 await db.schema.dropTable("paste").execute();
99 },
100};
101
102migrations["002"] = {
103 async up(db: Kysely<unknown>) {
104 await db.schema
105 .createTable("comment")
106 .addColumn("uri", "varchar", (col) => col.primaryKey())
107 .addColumn("authorDid", "varchar", (col) => col.notNull())
108 .addColumn("body", "varchar", (col) => col.notNull())
109 .addColumn("createdAt", "varchar", (col) => col.notNull())
110 .addColumn("indexedAt", "varchar", (col) => col.notNull())
111 .addColumn("pasteUri", "varchar", (col) => col.notNull())
112 .addColumn("pasteCid", "varchar", (col) => col.notNull())
113 .execute();
114 },
115 async down(db: Kysely<unknown>) {
116 await db.schema.dropTable("comments").execute();
117 },
118};
119
120function generateShortString(length: number): string {
121 return randomBytes(length).toString("base64url").substring(0, length);
122}
123
124export async function newShortUrl(
125 db: Database,
126 length: number = 2,
127): Promise<string> {
128 let shortUrl: string;
129
130 while (true) {
131 shortUrl = generateShortString(length);
132
133 try {
134 let exists = await db
135 .selectFrom("paste")
136 .selectAll()
137 .where("shortUrl", "=", shortUrl)
138 .limit(1)
139 .executeTakeFirst();
140 if (!exists) {
141 break; // Break the loop if insertion is successful
142 }
143 } catch (error) {
144 // If we run out of options at the current length, increase the length
145 if (await hasExhaustedShortUrls(db, length)) {
146 length++;
147 }
148 throw error;
149 }
150 }
151
152 return shortUrl;
153}
154
155// Check if all short URLs of the current length have been exhausted
156async function hasExhaustedShortUrls(
157 db: Database,
158 length: number,
159): Promise<boolean> {
160 const totalPossible = Math.pow(64, length);
161 const count = await db
162 .selectFrom("paste")
163 .select((e) => e.fn.count("uri").as("count"))
164 .executeTakeFirst();
165 if (isNumber(count)) {
166 return count >= totalPossible;
167 } else {
168 return true;
169 }
170}
171
172export const createDb = (location: string): Database => {
173 return new Kysely<DatabaseSchema>({
174 dialect: new SqliteDialect({
175 database: new SqliteDb(location),
176 }),
177 });
178};
179
180export const migrateToLatest = async (db: Database) => {
181 const migrator = new Migrator({ db, provider: migrationProvider });
182 const { error } = await migrator.migrateToLatest();
183 if (error) throw error;
184};
185
186export type Database = Kysely<DatabaseSchema>;