diff --git a/bun.lockb b/bun.lockb index 0a448f7..0e7db5d 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..3144e97 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + schema: "./src/models/schema.ts", + out: "./drizzle", + driver: "turso", + dialect: "sqlite", + dbCredentials: { + url: process.env.TURSO_URL, + authToken: process.env.TURSO_AUTH_TOKEN, + }, +}); diff --git a/drizzle/0000_left_reavers.sql b/drizzle/0000_left_reavers.sql new file mode 100644 index 0000000..42eb358 --- /dev/null +++ b/drizzle/0000_left_reavers.sql @@ -0,0 +1,14 @@ +CREATE TABLE `token` ( + `device_id` text PRIMARY KEY NOT NULL, + `token` text NOT NULL, + `username` text, + `last_connected_at` text DEFAULT (CURRENT_TIMESTAMP) +); +--> statement-breakpoint +CREATE TABLE `watch_status` ( + `device_id` text NOT NULL, + `title_id` integer NOT NULL, + `watch_status` text NOT NULL, + PRIMARY KEY(`device_id`, `title_id`), + FOREIGN KEY (`device_id`) REFERENCES `token`(`device_id`) ON UPDATE no action ON DELETE no action +); diff --git a/drizzle/0001_purple_franklin_richards.sql b/drizzle/0001_purple_franklin_richards.sql new file mode 100644 index 0000000..d360f2d --- /dev/null +++ b/drizzle/0001_purple_franklin_richards.sql @@ -0,0 +1 @@ +ALTER TABLE `watch_status` DROP COLUMN `watch_status`; \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..134fd37 --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,100 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "4ecf912c-2ffc-4a17-924b-8694ced4d7b6", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "token": { + "name": "token", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_connected_at": { + "name": "last_connected_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "watch_status": { + "name": "watch_status", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title_id": { + "name": "title_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "watch_status": { + "name": "watch_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "watch_status_device_id_token_device_id_fk": { + "name": "watch_status_device_id_token_device_id_fk", + "tableFrom": "watch_status", + "tableTo": "token", + "columnsFrom": ["device_id"], + "columnsTo": ["device_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "watch_status_device_id_title_id_pk": { + "columns": ["device_id", "title_id"], + "name": "watch_status_device_id_title_id_pk" + } + }, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..4460b53 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,93 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "d5b8fe62-fa26-4e9b-94eb-d3d38701f620", + "prevId": "4ecf912c-2ffc-4a17-924b-8694ced4d7b6", + "tables": { + "token": { + "name": "token", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_connected_at": { + "name": "last_connected_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "watch_status": { + "name": "watch_status", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title_id": { + "name": "title_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "watch_status_device_id_token_device_id_fk": { + "name": "watch_status_device_id_token_device_id_fk", + "tableFrom": "watch_status", + "tableTo": "token", + "columnsFrom": ["device_id"], + "columnsTo": ["device_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "watch_status_device_id_title_id_pk": { + "columns": ["device_id", "title_id"], + "name": "watch_status_device_id_title_id_pk" + } + }, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..cc2ceb2 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1718106156111, + "tag": "0000_left_reavers", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1718107695989, + "tag": "0001_purple_franklin_richards", + "breakpoints": true + } + ] +} diff --git a/package.json b/package.json index bb6719e..92380ae 100644 --- a/package.json +++ b/package.json @@ -4,18 +4,23 @@ "description": "API for Aniplay", "main": "src/index.ts", "scripts": { - "dev:cloudflare": "wrangler dev src/index.ts --port 8080", - "dev:server": "bun run --watch src/index.ts", + "dev:cloudflare": "TURSO_URL=http://127.0.0.1:3000 TURSO_AUTH_TOKEN=123 wrangler dev src/index.ts --port 8080", + "dev:server": "TURSO_URL=http://127.0.0.1:3000 TURSO_AUTH_TOKEN=123 bun run --watch src/index.ts", "prod:server": "bun run src/index.ts", "deploy": "wrangler deploy --minify src/index.ts", "env:generate": "bun src/scripts/generateEnv.ts", - "env:verify": "bun src/scripts/verifyEnv.ts" + "env:verify": "bun src/scripts/verifyEnv.ts", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "test": "bun src/testRunner.ts" }, "dependencies": { "@consumet/extensions": "github:consumet/consumet.ts#2bcd9287dc1471ed081bc23333e7629779924e0e", "@haverstack/axios-fetch-adapter": "^0.12.0", "@hono/swagger-ui": "^0.2.2", "@hono/zod-openapi": "^0.12.0", + "@libsql/client": "^0.6.2", + "drizzle-orm": "^0.31.2", "gql.tada": "^1.7.5", "graphql-request": "^7.0.1", "hono": "^4.3.6", @@ -26,11 +31,13 @@ "@cloudflare/workers-types": "^4.20240403.0", "@trivago/prettier-plugin-sort-imports": "^4.3.0", "@types/bun": "^1.1.2", + "drizzle-kit": "^0.22.6", "msw": "^2.3.0", "prettier": "^3.2.5", "prettier-plugin-toml": "^2.0.1", "ts-morph": "^22.0.0", "typescript": "^5.4.5", - "wrangler": "^3.47.0" + "wrangler": "^3.47.0", + "zx": "^8.1.2" } } diff --git a/src/models/db.ts b/src/models/db.ts new file mode 100644 index 0000000..82feac2 --- /dev/null +++ b/src/models/db.ts @@ -0,0 +1,36 @@ +import { createClient } from "@libsql/client"; +import { sql } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/libsql"; + +import type { Env } from "~/types/env"; + +import { tables } from "./schema"; + +type Db = ReturnType; +let db: Db | null = null; + +export function getDb(env: Env): Db { + if (db) { + return db; + } + + db = createDb(env); + return db; +} + +export async function resetDb() { + if (!db) return; + + for (const table of tables) { + await db.delete(table); + } +} + +function createDb(env: Env) { + const client = createClient({ + url: env.TURSO_URL, + authToken: env.TURSO_AUTH_TOKEN, + }); + + return drizzle(client); +} diff --git a/src/models/schema.ts b/src/models/schema.ts new file mode 100644 index 0000000..df8993d --- /dev/null +++ b/src/models/schema.ts @@ -0,0 +1,30 @@ +import { sql } from "drizzle-orm"; +import { + integer, + primaryKey, + sqliteTable, + text, +} from "drizzle-orm/sqlite-core"; + +export const tokenTable = sqliteTable("token", { + deviceId: text("device_id").primaryKey(), + token: text("token").notNull(), + username: text("username"), + /** Used to determine if a device hasn't been used in a while. Should start to ignore tokens where the device hasn't connected in about a month */ + lastConnectedAt: text("last_connected_at").default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const watchStatusTable = sqliteTable( + "watch_status", + { + deviceId: text("device_id") + .notNull() + .references(() => tokenTable.deviceId), + titleId: integer("title_id").notNull(), + }, + (table) => ({ + pk: primaryKey({ columns: [table.deviceId, table.titleId] }), + }), +); + +export const tables = [watchStatusTable, tokenTable]; diff --git a/src/models/token.ts b/src/models/token.ts new file mode 100644 index 0000000..8ee48ad --- /dev/null +++ b/src/models/token.ts @@ -0,0 +1,26 @@ +import { eq, sql } from "drizzle-orm"; + +import type { Env } from "~/types/env"; + +import { getDb } from "./db"; +import { tokenTable } from "./schema"; + +export function saveToken( + env: Env, + deviceId: string, + token: string, + username: string | null, +) { + return getDb(env) + .insert(tokenTable) + .values({ deviceId, token, username }) + .run(); +} + +export function updateDeviceLastConnectedAt(env: Env, deviceId: string) { + return getDb(env) + .update(tokenTable) + .set({ lastConnectedAt: sql`(CURRENT_TIMESTAMP)` }) + .where(eq(tokenTable.deviceId, deviceId)) + .run(); +} diff --git a/src/models/watchStatus.ts b/src/models/watchStatus.ts new file mode 100644 index 0000000..a32ac6f --- /dev/null +++ b/src/models/watchStatus.ts @@ -0,0 +1,59 @@ +import { and, count, eq } from "drizzle-orm"; + +import type { Env } from "~/types/env"; +import { WatchStatusValues } from "~/types/title/watchStatus"; + +import { getDb } from "./db"; +import { watchStatusTable } from "./schema"; + +/** If watch status is "CURRENT", the title will be added to the watch status table. Otherwise, it will be removed. + * + * @returns an object with the following properties: + * - wasAdded: whether the title was set as watching for the first time + * - wasDeleted: whether any users are still watching the title + */ +export function setWatchStatus( + env: Env, + deviceId: string, + titleId: number, + watchStatus: (typeof WatchStatusValues)[number], +) { + let dbAction; + const isSavingTitle = watchStatus === "CURRENT"; + if (isSavingTitle) { + dbAction = saveTitle(env, deviceId, titleId); + } else { + dbAction = removeTitle(env, deviceId, titleId); + } + + return dbAction + .then(() => + getDb(env) + .select({ count: count(watchStatusTable.titleId) }) + .from(watchStatusTable) + .where(eq(watchStatusTable.titleId, titleId)) + .get(), + ) + .then((result) => ({ + wasAdded: isSavingTitle && result?.count === 1, + wasDeleted: !isSavingTitle && result?.count === 0, + })); +} + +function saveTitle(env: Env, deviceId: string, titleId: number) { + return getDb(env) + .insert(watchStatusTable) + .values({ deviceId, titleId }) + .onConflictDoNothing(); +} + +function removeTitle(env: Env, deviceId: string, titleId: number) { + return getDb(env) + .delete(watchStatusTable) + .where( + and( + eq(watchStatusTable.deviceId, deviceId), + eq(watchStatusTable.titleId, titleId), + ), + ); +} diff --git a/wrangler.toml b/wrangler.toml index 28d9499..317e491 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -5,7 +5,7 @@ compatibility_date = "2023-12-01" node_compat = true [vars] -TURSO_URL = "libsql://humble-argent-silverandroid.turso.io" +TURSO_URL = "http://127.0.0.1:8080" QSTASH_URL = "https://qstash.upstash.io/v2/publish" ENABLE_ANIFY = false