A decentralized music tracking and discovery platform built on AT Protocol 馃幍
rocksky.app
spotify
atproto
lastfm
musicbrainz
scrobbling
listenbrainz
1import SqliteDb from "better-sqlite3";
2import chalk from "chalk";
3import type { Context } from "context";
4import {
5 Kysely,
6 type Migration,
7 type MigrationProvider,
8 Migrator,
9 SqliteDialect,
10} from "kysely";
11import { createAgent } from "lib/agent";
12import { consola } from "consola";
13
14// Types
15
16export type DatabaseSchema = {
17 status: Status;
18 auth_session: AuthSession;
19 auth_state: AuthState;
20};
21
22export type Status = {
23 uri: string;
24 authorDid: string;
25 status: string;
26 createdAt: string;
27 indexedAt: string;
28};
29
30export type AuthSession = {
31 key: string;
32 session: AuthSessionJson;
33 expiresAt?: string | null;
34};
35
36export type AuthState = {
37 key: string;
38 state: AuthStateJson;
39};
40
41type AuthStateJson = string;
42
43type AuthSessionJson = string;
44
45// Migrations
46
47const migrations: Record<string, Migration> = {};
48
49const migrationProvider: MigrationProvider = {
50 async getMigrations() {
51 return migrations;
52 },
53};
54
55migrations["001"] = {
56 async up(db: Kysely<unknown>) {
57 await db.schema
58 .createTable("status")
59 .addColumn("uri", "varchar", (col) => col.primaryKey())
60 .addColumn("authorDid", "varchar", (col) => col.notNull())
61 .addColumn("status", "varchar", (col) => col.notNull())
62 .addColumn("createdAt", "varchar", (col) => col.notNull())
63 .addColumn("indexedAt", "varchar", (col) => col.notNull())
64 .execute();
65 await db.schema
66 .createTable("auth_session")
67 .addColumn("key", "varchar", (col) => col.primaryKey())
68 .addColumn("session", "varchar", (col) => col.notNull())
69 .execute();
70 await db.schema
71 .createTable("auth_state")
72 .addColumn("key", "varchar", (col) => col.primaryKey())
73 .addColumn("state", "varchar", (col) => col.notNull())
74 .execute();
75 },
76 async down(db: Kysely<unknown>) {
77 await db.schema.dropTable("auth_state").execute();
78 await db.schema.dropTable("auth_session").execute();
79 await db.schema.dropTable("status").execute();
80 },
81};
82
83migrations["002"] = {
84 async up(db: Kysely<unknown>) {
85 await db.schema
86 .alterTable("auth_session")
87 .addColumn("expiresAt", "text", (col) => col.defaultTo("NULL"))
88 .execute();
89 },
90 async down(db: Kysely<unknown>) {
91 await db.schema
92 .alterTable("auth_session")
93 .dropColumn("expiresAt")
94 .execute();
95 },
96};
97
98// APIs
99
100export const createDb = (location: string): Database => {
101 return new Kysely<DatabaseSchema>({
102 dialect: new SqliteDialect({
103 database: new SqliteDb(location),
104 }),
105 });
106};
107
108export const migrateToLatest = async (db: Database) => {
109 const migrator = new Migrator({ db, provider: migrationProvider });
110 const { error } = await migrator.migrateToLatest();
111 if (error) throw error;
112};
113
114export const updateExpiresAt = async (db: Database) => {
115 // get all sessions that have expiresAt is null
116 const sessions = await db.selectFrom("auth_session").selectAll().execute();
117 consola.info("Found", sessions.length, "sessions to update");
118 for (const session of sessions) {
119 const data = JSON.parse(session.session) as {
120 tokenSet: { expires_at?: string | null };
121 };
122 consola.info(session.key, data.tokenSet.expires_at);
123 await db
124 .updateTable("auth_session")
125 .set({ expiresAt: data.tokenSet.expires_at })
126 .where("key", "=", session.key)
127 .execute();
128 }
129
130 consola.info(`Updated ${chalk.greenBright(sessions.length)} sessions`);
131};
132
133export const refreshSessionsAboutToExpire = async (
134 db: Database,
135 ctx: Context,
136) => {
137 const now = new Date().toISOString();
138
139 const sessions = await db
140 .selectFrom("auth_session")
141 .selectAll()
142 .where("expiresAt", "is not", "NULL")
143 .where("expiresAt", ">", now)
144 .orderBy("expiresAt", "asc")
145 .execute();
146
147 for (const session of sessions) {
148 consola.info(
149 "Session about to expire:",
150 chalk.cyan(session.key),
151 session.expiresAt,
152 );
153 const agent = await createAgent(ctx.oauthClient, session.key);
154 // Trigger a token refresh by fetching preferences
155 await agent.getPreferences();
156 await new Promise((r) => setTimeout(r, 200));
157 }
158
159 consola.info(
160 `Found ${chalk.yellowBright(sessions.length)} sessions to refresh`,
161 );
162};
163
164export type Database = Kysely<DatabaseSchema>;