From 4e73b71c16e6f3ad309bb004b9966fcdc0faec16 Mon Sep 17 00:00:00 2001 From: cbackas Date: Fri, 25 Apr 2025 11:35:32 -0500 Subject: [PATCH 01/12] add litestream and denokv docker/entrypoint stuff --- Dockerfile | 18 +++++++++++++++++- entrypoint.sh | 26 +++++++++++++++++++++----- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4a7cef0..10d3faa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,7 @@ -FROM denoland/deno:debian +FROM litestream/litestream AS litestream +FROM ghcr.io/denoland/denokv:latest AS denokv + +FROM denoland/deno:debian AS build WORKDIR /app RUN apt-get update && \ apt-get install -y --no-install-recommends \ @@ -8,5 +11,18 @@ RUN apt-get update && \ COPY . . RUN deno install -r --allow-scripts=npm:@prisma/client,npm:prisma,npm:@prisma/engines RUN deno task prisma:generate +RUN deno task build + +FROM debian:latest +COPY --from=litestream /usr/local/bin/litestream /usr/local/bin/litestream +COPY --from=denokv /usr/local/bin/denokv /usr/local/bin/denokv +COPY --from=build /app/dist/tvbot /usr/local/bin/tvbot + +COPY litestream.yml /etc/litestream.yml + +ENV DENO_KV_SQLITE_PATH="/data/denokv.sqlite3" + +WORKDIR /app +COPY entrypoint.sh /app/entrypoint.sh ENV TZ="America/Chicago" ENTRYPOINT ["sh", "entrypoint.sh"] diff --git a/entrypoint.sh b/entrypoint.sh index 1738925..9b7f466 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,8 +1,24 @@ #!/bin/bash +set -euo pipefail -set -e -# migrate the database -deno run -A --allow-scripts=npm:prisma,npm:@prisma/engines npm:prisma db push --skip-generate -set +e +# Setup data directory +mkdir -p /data +[[ -f "$DENO_KV_SQLITE_PATH" ]] || litestream restore --if-replica-exists /data/denokv.sqlite3 -deno -A --unstable-cron ./src/app.ts +# Start Litestream replication and apps +litestream replicate & +LITESTREAM_PID=$! + +denokv serve & +DENO_PID=$! + +tvbot & +TVBOT_PID=$! + +# Forward termination signals to all children +trap 'kill -TERM $LITESTREAM_PID $DENO_PID $TVBOT_PID' SIGTERM SIGINT + +# Wait for any process to exit, then cleanup +wait -n $LITESTREAM_PID $DENO_PID $TVBOT_PID +kill $LITESTREAM_PID $DENO_PID $TVBOT_PID 2>/dev/null +exit 1 From 2e12243c7228cf15476a6edfe6dd0f6ed7c08661 Mon Sep 17 00:00:00 2001 From: cbackas Date: Fri, 25 Apr 2025 11:36:05 -0500 Subject: [PATCH 02/12] add the client setup for denokv --- import_map.json | 1 + src/database/client.ts | 8 ++++++++ src/lib/env.ts | 1 + src/schemas.ts | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 42 insertions(+) create mode 100644 src/database/client.ts create mode 100644 src/schemas.ts diff --git a/import_map.json b/import_map.json index ca86444..d137c2e 100644 --- a/import_map.json +++ b/import_map.json @@ -2,6 +2,7 @@ "imports": { "app.ts": "./src/app.ts", "lib/": "./src/lib/", + "database/": "./src/database/", "prisma-client/": "./prisma/client/", "interfaces/": "./src/interfaces/", "commands/": "./src/commands/", diff --git a/src/database/client.ts b/src/database/client.ts new file mode 100644 index 0000000..3727e8d --- /dev/null +++ b/src/database/client.ts @@ -0,0 +1,8 @@ +import { getEnv } from "lib/env.ts" + +const useLocal: boolean = getEnv("DENO_KV_ACCESS_TOKEN") === undefined +const kv = await Deno.openKv( + useLocal ? "./denokv.sqlite3" : "http://localhost:4512", +) + +export default kv diff --git a/src/lib/env.ts b/src/lib/env.ts index f3ba6b1..2ac4900 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -13,6 +13,7 @@ const envKeys = { "NODE_ENV": z.enum(["development", "production"]).optional().default( "development", ), + "DENO_KV_ACCESS_TOKEN": z.string().optional(), } as const export type EnvKey = keyof typeof envKeys diff --git a/src/schemas.ts b/src/schemas.ts new file mode 100644 index 0000000..2228f2c --- /dev/null +++ b/src/schemas.ts @@ -0,0 +1,32 @@ +import * as z from "npm:zod" + +export const zDestination = z.object({ + channelId: z.string(), + forumId: z.string().optional(), +}) +export type Destination = z.infer + +export const zEpisode = z.object({ + season: z.number(), + nuumber: z.number(), + title: z.string(), + airDate: z.iso.datetime(), + messageSent: z.boolean(), +}) +export type Episode = z.infer + +export const zShow = z.object({ + name: z.string(), + imdbId: z.string(), + tvdbId: z.string(), + episodes: z.array(zEpisode), + destinations: z.array(zDestination), +}) +export type Show = z.infer + +// export const zSettings = z.object({ +// defaultForum: z.string().nullable(), +// allEpisodes: z.array(zDestination), +// morningSumarryDestinations: z.array(zDestination), +// }) +// export type Settings = z.infer From dd9ad6ed760e54c42e6778b992c7ebc3e369d838 Mon Sep 17 00:00:00 2001 From: cbackas Date: Fri, 25 Apr 2025 11:36:35 -0500 Subject: [PATCH 03/12] add typesafe settings getter/setter functions using kv --- src/database/settings.ts | 42 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/database/settings.ts diff --git a/src/database/settings.ts b/src/database/settings.ts new file mode 100644 index 0000000..ff02394 --- /dev/null +++ b/src/database/settings.ts @@ -0,0 +1,42 @@ +import * as z from "npm:zod" +import { zDestination } from "../schemas.ts" +import kv from "./client.ts" + +const settingSchema = { + defaultForum: z.string().nullable(), + allEpisodes: z.array(zDestination), + morningSumarryDestinations: z.array(zDestination), +} as const +export type SettingKey = keyof typeof settingSchema + +export async function getSetting( + key: K, +): Promise> { + const kvEntry = await kv.get(["settings", key]) + const parsed: z.infer<(typeof settingSchema)[K]> = parseValue( + key, + kvEntry.value, + ) + return parsed +} + +export async function setSetting( + key: K, + value: z.infer<(typeof settingSchema)[K]>, +): Promise { + const parsedValue = parseValue(key, value) + return await kv.set(["settings", key], parsedValue) +} + +/** + * @throws Error if the setting variable is not valid + */ +function parseValue(key: SettingKey, value: unknown) { + const parsedValue = settingSchema[key].safeParse(value) + if (parsedValue.error != null) { + throw new Error( + `Setting ${key} is not valid: ${parsedValue.error.toString()}`, + ) + } + return parsedValue.data +} From cc1f4ed6dc4c3fa9100c30ce26cf4abd7b447d99 Mon Sep 17 00:00:00 2001 From: cbackas Date: Fri, 25 Apr 2025 12:32:05 -0500 Subject: [PATCH 04/12] delete settings manager and use deno kv for all the settings calls --- import_map.json | 1 + src/app.ts | 3 - src/commands/post.ts | 13 +-- src/commands/setting.ts | 28 +++--- src/cron.ts | 3 - src/database/settings.ts | 24 ++++-- src/handlers.ts | 13 ++- src/lib/episodeNotifier.ts | 6 +- src/lib/morningSummary.ts | 9 +- src/lib/settingsManager.ts | 170 ------------------------------------- 10 files changed, 61 insertions(+), 209 deletions(-) delete mode 100644 src/lib/settingsManager.ts diff --git a/import_map.json b/import_map.json index d137c2e..0d53004 100644 --- a/import_map.json +++ b/import_map.json @@ -5,6 +5,7 @@ "database/": "./src/database/", "prisma-client/": "./prisma/client/", "interfaces/": "./src/interfaces/", + "schemas.ts": "./src/schemas.ts", "commands/": "./src/commands/", "npm:discord.js": "npm:discord.js@^14.18.0", "@prisma/client/runtime/library": "npm:@prisma/client/runtime/library", diff --git a/src/app.ts b/src/app.ts index 9d5b7fa..deaa97f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -3,7 +3,6 @@ import { Client, ClientUser, Events, GatewayIntentBits } from "npm:discord.js" import { CommandManager } from "lib/commandManager.ts" import { checkForAiringEpisodes, pruneUnsubscribedShows } from "lib/shows.ts" import { sendAiringMessages } from "lib/episodeNotifier.ts" -import { Settings } from "lib/settingsManager.ts" import { setRandomShowActivity, setTVDBLoadingActivity, @@ -17,8 +16,6 @@ const token = getEnv("DISCORD_TOKEN") const clientId = getEnv("DISCORD_CLIENT_ID") const guildId = getEnv("DISCORD_GUILD_ID") -await Settings.refresh() - const commandManager = new CommandManager() await commandManager.registerCommands(clientId, token, guildId) diff --git a/src/commands/post.ts b/src/commands/post.ts index ceab622..485a187 100644 --- a/src/commands/post.ts +++ b/src/commands/post.ts @@ -21,7 +21,7 @@ import { buildShowEmbed } from "lib/messages.ts" import { type SeriesExtendedRecord } from "interfaces/tvdb.generated.ts" import { type Destination, type Show } from "prisma-client/client.ts" import { parseIMDBIds } from "lib/util.ts" -import { Settings } from "lib/settingsManager.ts" +import { getSetting } from "database/settings.ts" interface SeriesWrapper { series: SeriesExtendedRecord @@ -77,7 +77,9 @@ export const command: CommandV2 = { // if the user passed in a forum then send the post to that forum const useInputForum = forumInput !== null && isForumChannel(forumInput as Channel) - const tvForum = useInputForum ? forumInput.id : getDefaultTVForumId() + const tvForum = useInputForum + ? forumInput.id + : await getDefaultTVForumId() await progress.sendNextStep() // start step 1 @@ -184,14 +186,13 @@ export const command: CommandV2 = { * @param app main application object instance * @returns ID of the default TV forum */ -function getDefaultTVForumId(): string { - const forumId = Settings.fetch()?.defaultForum +async function getDefaultTVForumId(): Promise { + const forumId = await getSetting("defaultForum") if (forumId == null) { throw new ProgressError( "No TV forum configured, use /settings tv_forum to set the default TV forum", ) } - return forumId } @@ -219,7 +220,7 @@ async function checkForExistingPosts( }, }) - // if the show isnt in the DB then we can just return + // if the show isn't in the DB then we can just return if (show === null) return undefined // if the show is in the DB but has no destinations then we can just return if (show.destinations.length === 0) return undefined diff --git a/src/commands/setting.ts b/src/commands/setting.ts index 1785618..61e4618 100644 --- a/src/commands/setting.ts +++ b/src/commands/setting.ts @@ -11,8 +11,7 @@ import { } from "npm:discord.js" import { type CommandV2 } from "interfaces/command.ts" import { ProgressMessageBuilder } from "lib/progressMessages.ts" -import { Settings } from "lib/settingsManager.ts" -import { type Destination } from "prisma-client/client.ts" +import { getSetting, setSetting, Settings } from "database/settings.ts" export const command: CommandV2 = { slashCommand: { @@ -169,9 +168,7 @@ async function setTVForum( await interaction.editReply(progressMessage.nextStep()) // update the db with the new value - await Settings.update({ - defaultForum: channel.id, - }) + await setSetting("defaultForum", channel.id) await interaction.editReply(progressMessage.nextStep()) } @@ -206,13 +203,18 @@ async function updateGlobalChannels( await progress.sendNextStep() - let destinations: Destination[] = [] + const destinations: Settings["allEpisodes"] = await getSetting("allEpisodes") // add or remove the channel from the list if (mode === "add") { - destinations = await Settings.addGlobalDestination(channel.id) + destinations.push({ + channelId: channel.id, + }) } else if (mode === "remove") { - destinations = await Settings.removeGlobalDestination(channel.id) + const index = destinations.findIndex((d) => d.channelId === channel.id) + if (index >= 0) { + destinations.splice(index, 1) + } } const destinationsString = destinations.map((d) => `<#${d.channelId}>`).join( @@ -225,7 +227,6 @@ async function updateGlobalChannels( * handle the morning_sumarry commands * allows adding and removing channels from the list of channels that receive the morning summary message * todo allow setting the time of the morning summary - * @param settingsManager pass the settingsManager to avoid having to fetch it multiple times * @param interaction the chat interaction that got us here * @returns nothin */ @@ -253,7 +254,9 @@ async function updateMorningSummaryChannels( await progress.sendNextStep() - let channelList = Settings.fetch()?.morningSummaryDestinations ?? [] + let channelList: Settings["morningSumarryDestinations"] = await getSetting( + "morningSumarryDestinations", + ) const hasChannel = channelList.some((d) => d.channelId === channel.id) // add or remove the channel from the list @@ -263,7 +266,6 @@ async function updateMorningSummaryChannels( } channelList.push({ channelId: channel.id, - forumId: null, }) } else if (subCommand === "remove_channel") { if (!hasChannel) { @@ -272,9 +274,7 @@ async function updateMorningSummaryChannels( channelList = channelList.filter((d) => d.channelId !== channel.id) } - await Settings.update({ - morningSummaryDestinations: channelList, - }) + await setSetting("morningSumarryDestinations", channelList) const channelsString = channelList.map((d) => `<#${d.channelId}>`).join("\n") return await progress.sendNextStep(`__New List__:\n${channelsString}`) diff --git a/src/cron.ts b/src/cron.ts index 61fc034..25d6b9c 100644 --- a/src/cron.ts +++ b/src/cron.ts @@ -5,7 +5,6 @@ import { import { getEnv } from "lib/env.ts" import { sendAiringMessages } from "lib/episodeNotifier.ts" import { sendMorningSummary } from "lib/morningSummary.ts" -import { Settings } from "lib/settingsManager.ts" import { checkForAiringEpisodes, pruneUnsubscribedShows } from "lib/shows.ts" export function scheduleCronJobs() { @@ -21,8 +20,6 @@ export function scheduleCronJobs() { }) Deno.cron("Morning Summary", { hour: 8, minute: 0 }, async () => { - const settings = Settings.fetch() - if (settings == null) throw new Error("Settings not found") await sendMorningSummary() }) diff --git a/src/database/settings.ts b/src/database/settings.ts index ff02394..dc8fb9b 100644 --- a/src/database/settings.ts +++ b/src/database/settings.ts @@ -1,13 +1,25 @@ import * as z from "npm:zod" -import { zDestination } from "../schemas.ts" -import kv from "./client.ts" +import { zDestination } from "schemas.ts" +import kv from "database/client.ts" + +const nullToEmptyArray = (val: T[] | null): T[] => { + if (val == null) { + return [] + } + return val +} const settingSchema = { defaultForum: z.string().nullable(), - allEpisodes: z.array(zDestination), - morningSumarryDestinations: z.array(zDestination), -} as const -export type SettingKey = keyof typeof settingSchema + allEpisodes: z.array(zDestination).nullable().transform(nullToEmptyArray), + morningSumarryDestinations: z.array(zDestination).nullable().transform( + nullToEmptyArray, + ), +} +export type Settings = { + [K in keyof typeof settingSchema]: z.infer +} +export type SettingKey = keyof Settings export async function getSetting( key: K, diff --git a/src/handlers.ts b/src/handlers.ts index 5c3bc63..e42732c 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -1,6 +1,6 @@ import { ChannelType, ClientEvents } from "npm:discord.js" -import { Settings } from "lib/settingsManager.ts" import { pruneUnsubscribedShows, removeAllSubscriptions } from "lib/shows.ts" +import { getSetting, setSetting, type Settings } from "database/settings.ts" /** * When a thread (forum post) is deleted, remove all subscriptions for that post @@ -28,6 +28,15 @@ export async function handleChannelDelete( if (channel.type === ChannelType.GuildText) { await removeAllSubscriptions(channel.id, "channelId") await pruneUnsubscribedShows() - await Settings.removeGlobalDestination(channel.id) + + const destinations: Settings["allEpisodes"] = await getSetting( + "allEpisodes", + ) + const filteredDestinations: Settings["allEpisodes"] = destinations.filter(( + d: Settings["allEpisodes"][number], + ) => d.channelId !== channel.id) + if (filteredDestinations.length !== destinations.length) { + await setSetting("allEpisodes", filteredDestinations) + } } } diff --git a/src/lib/episodeNotifier.ts b/src/lib/episodeNotifier.ts index 801c9a0..2f5e402 100644 --- a/src/lib/episodeNotifier.ts +++ b/src/lib/episodeNotifier.ts @@ -10,9 +10,9 @@ import { import moment from "npm:moment-timezone" import { markMessageSent } from "lib/shows.ts" import client from "lib/prisma.ts" -import { Settings, type SettingsType } from "lib/settingsManager.ts" import { addLeadingZeros, toRanges } from "lib/util.ts" import { getClient } from "app.ts" +import { getSetting, type Settings } from "database/settings.ts" export function isTextChannel( channel: Channel, @@ -40,7 +40,7 @@ type PayloadCollection = Collection */ export async function sendAiringMessages(): Promise { const discord = getClient() - const globalDestinations = Settings.fetch()?.allEpisodes ?? [] + const globalDestinations = await getSetting("allEpisodes") ?? [] const payloadCollection = await getShowPayloads() for (const payload of payloadCollection.values()) { @@ -126,7 +126,7 @@ async function getShowPayloads( async function sendNotificationPayload( payload: NotificationPayload, discord: Client, - globalDestinations: SettingsType["allEpisodes"], + globalDestinations: Settings["allEpisodes"], ): Promise { const message = getEpisodeMessage( payload.showName, diff --git a/src/lib/morningSummary.ts b/src/lib/morningSummary.ts index 7aaff0a..e28e145 100644 --- a/src/lib/morningSummary.ts +++ b/src/lib/morningSummary.ts @@ -1,12 +1,18 @@ import { type APIEmbed } from "npm:discord.js" -import { Settings } from "lib/settingsManager.ts" import { getUpcomingEpisodesEmbed } from "lib/upcoming.ts" import client from "lib/prisma.ts" import { type Show } from "prisma-client/client.ts" import { isTextChannel } from "lib/episodeNotifier.ts" import { getClient } from "app.ts" +import { getSetting } from "database/settings.ts" export async function sendMorningSummary(): Promise { + const destinations = await getSetting("morningSumarryDestinations") + if (destinations.length === 0) { + console.info("Morning summary destinations not set, skipping") + return + } + const shows: Show[] = await client.show.findMany({ where: { episodes: { @@ -20,7 +26,6 @@ export async function sendMorningSummary(): Promise { const embed: APIEmbed = getUpcomingEpisodesEmbed(shows, 1) const discordClient = getClient() - const destinations = Settings.fetch()?.morningSummaryDestinations ?? [] for (const dest of destinations) { const channel = await discordClient.channels.fetch(dest.channelId) if (channel == null || !isTextChannel(channel) || !channel.isSendable()) { diff --git a/src/lib/settingsManager.ts b/src/lib/settingsManager.ts deleted file mode 100644 index dde5ce0..0000000 --- a/src/lib/settingsManager.ts +++ /dev/null @@ -1,170 +0,0 @@ -import client from "lib/prisma.ts" -import { - type Destination, - Prisma, - type Settings as DBSettings, -} from "prisma-client/client.ts" - -export type SettingsType = Omit - -/** - * Manager to handle fetching and saving settings in the DB - */ -export class Settings { - private static instance: Settings - - public static getInstance(): Settings { - if (!Settings.instance) { - Settings.instance = new Settings() - } - return Settings.instance - } - - // set the defaults for the settings - private settings?: SettingsType - - /** - * Save initial settings data to the DB - */ - private readonly initData = async (): Promise => { - try { - await client.settings.create({ - data: { - id: 0, - allEpisodes: [], - }, - }) - } catch (error) { - if (!(error instanceof Prisma.PrismaClientKnownRequestError)) throw error - if (error.code !== "P2002") throw error - } - } - - /** - * Fetches the settings from the DB and updates the SettingsManager instance with the latest values - */ - refresh = async (): Promise => { - try { - // fetch the settings from the DB - const settings = await client.settings.findUniqueOrThrow({ - where: { - id: 0, - }, - }) - this.settings = settings - return settings - } catch (error) { - if (!(error instanceof Prisma.PrismaClientKnownRequestError)) throw error - if (error.code === "P2025") await this.initData() - } - } - - public static refresh = async (): Promise => { - const instance = Settings.getInstance() - const settings = await instance.refresh() - return settings - } - - /** - * Update settings in the DB - * @param inputData settings data to update - */ - public static update = async ( - inputData: Partial, - ): Promise => { - const data = Prisma.validator()(inputData) - - await client.settings.update({ - where: { - id: 0, - }, - data, - }) - - await Settings.getInstance().refresh() - } - - /** - * check if a channel is already in settings 'allEpisodes' list global destiations list - * @param channelId channel to check - * @returns true if channel is in list, false if not - */ - private readonly channelIsAlreadyGlobal = async ( - channelId: string, - ): Promise => { - const matchingChannels = await client.settings.count({ - where: { - id: 0, - allEpisodes: { - some: { - channelId, - }, - }, - }, - }) - - return matchingChannels > 0 - } - - public static addGlobalDestination = async ( - channelId: string, - ): Promise => { - const instance = Settings.getInstance() - if (await instance.channelIsAlreadyGlobal(channelId)) { - return instance.settings?.allEpisodes ?? [] - } - - const settings = await client.settings.update({ - where: { - id: 0, - }, - data: { - allEpisodes: { - push: { - channelId, - }, - }, - }, - select: { - allEpisodes: true, - }, - }) - - console.info(`Added ${channelId} to global destinations`) - - await instance.refresh() - return settings.allEpisodes - } - - public static removeGlobalDestination = async ( - channelId: string, - ): Promise => { - const instance = Settings.getInstance() - if (!await instance.channelIsAlreadyGlobal(channelId)) { - return instance.settings?.allEpisodes ?? [] - } - - const settings = await client.settings.update({ - where: { - id: 0, - }, - data: { - allEpisodes: { - deleteMany: { - where: { - channelId, - }, - }, - }, - }, - }) - - console.info(`Removed ${channelId} from global destinations`) - - await instance.refresh() - return settings.allEpisodes - } - - public static fetch = (): SettingsType | undefined => - Settings.getInstance().settings -} From 781cb6ee54b0cc4a5e9c1d3d19af1399b9250892 Mon Sep 17 00:00:00 2001 From: cbackas Date: Fri, 25 Apr 2025 12:37:14 -0500 Subject: [PATCH 05/12] fix typo in all episodes setting response --- src/commands/setting.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/setting.ts b/src/commands/setting.ts index 61e4618..5119c86 100644 --- a/src/commands/setting.ts +++ b/src/commands/setting.ts @@ -220,7 +220,7 @@ async function updateGlobalChannels( const destinationsString = destinations.map((d) => `<#${d.channelId}>`).join( "\n", ) - return await progress.sendNextStep(`__New List__:\nn${destinationsString}`) + return await progress.sendNextStep(`__New List__:\n${destinationsString}`) } /** From 59cc687e51779167fb5e048079ca72399069a334 Mon Sep 17 00:00:00 2001 From: cbackas Date: Fri, 25 Apr 2025 12:37:34 -0500 Subject: [PATCH 06/12] denokv housekeeping things --- .gitignore | 3 +++ deno.jsonc | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index fd89284..f249b8d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ data/ .DS_Store mongodata + +denokv.sqlite3* + diff --git a/deno.jsonc b/deno.jsonc index a0621b1..7346102 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -1,8 +1,8 @@ { "$schema": "https://raw.githubusercontent.com/denoland/deno/refs/heads/main/cli/schemas/config-file.v1.json", "tasks": { - "dev": "deno --watch --no-clear-screen -A --unstable-cron --allow-scripts=npm:prisma,npm:@prisma/client,npm:@prisma/engines ./src/app.ts", - "build": "deno compile --unstable-cron -o ./dist/tvbot ./src/app.ts", + "dev": "deno --watch --no-clear-screen -A --unstable-cron --unstable-kv --allow-scripts=npm:prisma,npm:@prisma/client,npm:@prisma/engines ./src/app.ts", + "build": "deno compile --unstable-cron --unstable-kv -o ./dist/tvbot ./src/app.ts", "prisma": "deno run -A npm:prisma", "prisma:generate": "deno task prisma generate --schema=./prisma/schema.prisma", "db:migrate": "deno run -A npm:prisma migrate dev", From a59b686415219df600726e916cd1e7470800b455 Mon Sep 17 00:00:00 2001 From: cbackas Date: Fri, 25 Apr 2025 12:46:09 -0500 Subject: [PATCH 07/12] add litestream yml --- litestream.yml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 litestream.yml diff --git a/litestream.yml b/litestream.yml new file mode 100644 index 0000000..00891cb --- /dev/null +++ b/litestream.yml @@ -0,0 +1,4 @@ +dbs: + - path: /data/denokv.sqlite3 + replicas: + - url: ${LITESTREAM_S3_REPLICA_URL} From 2ba8867afa27de6ea88358cab98b2c63605b091b Mon Sep 17 00:00:00 2001 From: cbackas Date: Fri, 25 Apr 2025 16:04:20 -0500 Subject: [PATCH 08/12] we don't need to run standalone denokv so that makes everything easier --- Dockerfile | 3 --- entrypoint.sh | 28 +++++++--------------------- src/database/client.ts | 7 +------ src/lib/env.ts | 1 - 4 files changed, 8 insertions(+), 31 deletions(-) diff --git a/Dockerfile b/Dockerfile index 10d3faa..7224bc2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,4 @@ FROM litestream/litestream AS litestream -FROM ghcr.io/denoland/denokv:latest AS denokv - FROM denoland/deno:debian AS build WORKDIR /app RUN apt-get update && \ @@ -15,7 +13,6 @@ RUN deno task build FROM debian:latest COPY --from=litestream /usr/local/bin/litestream /usr/local/bin/litestream -COPY --from=denokv /usr/local/bin/denokv /usr/local/bin/denokv COPY --from=build /app/dist/tvbot /usr/local/bin/tvbot COPY litestream.yml /etc/litestream.yml diff --git a/entrypoint.sh b/entrypoint.sh index 9b7f466..eb57607 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,24 +1,10 @@ #!/bin/bash -set -euo pipefail -# Setup data directory -mkdir -p /data -[[ -f "$DENO_KV_SQLITE_PATH" ]] || litestream restore --if-replica-exists /data/denokv.sqlite3 +if ! test -d /data; then + mkdir /data +fi +if ! test -f "$DENO_KV_SQLITE_PATH"; then + litestream restore --if-replica-exists /data/denokv.sqlite3 +fi -# Start Litestream replication and apps -litestream replicate & -LITESTREAM_PID=$! - -denokv serve & -DENO_PID=$! - -tvbot & -TVBOT_PID=$! - -# Forward termination signals to all children -trap 'kill -TERM $LITESTREAM_PID $DENO_PID $TVBOT_PID' SIGTERM SIGINT - -# Wait for any process to exit, then cleanup -wait -n $LITESTREAM_PID $DENO_PID $TVBOT_PID -kill $LITESTREAM_PID $DENO_PID $TVBOT_PID 2>/dev/null -exit 1 +litestream replicate -exec 'tvbot' diff --git a/src/database/client.ts b/src/database/client.ts index 3727e8d..ac6e13e 100644 --- a/src/database/client.ts +++ b/src/database/client.ts @@ -1,8 +1,3 @@ -import { getEnv } from "lib/env.ts" - -const useLocal: boolean = getEnv("DENO_KV_ACCESS_TOKEN") === undefined -const kv = await Deno.openKv( - useLocal ? "./denokv.sqlite3" : "http://localhost:4512", -) +const kv = await Deno.openKv("./denokv.sqlite3") export default kv diff --git a/src/lib/env.ts b/src/lib/env.ts index 2ac4900..f3ba6b1 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -13,7 +13,6 @@ const envKeys = { "NODE_ENV": z.enum(["development", "production"]).optional().default( "development", ), - "DENO_KV_ACCESS_TOKEN": z.string().optional(), } as const export type EnvKey = keyof typeof envKeys From 4857d09cbc90219e073200821e6f967939ed8d1e Mon Sep 17 00:00:00 2001 From: cbackas Date: Fri, 25 Apr 2025 16:14:04 -0500 Subject: [PATCH 09/12] always use the DENO_KV_SQLITE_PATH env var to set the path to the kv sqlite file --- entrypoint.sh | 2 +- src/database/client.ts | 5 ++++- src/lib/env.ts | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/entrypoint.sh b/entrypoint.sh index eb57607..54323c4 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -4,7 +4,7 @@ if ! test -d /data; then mkdir /data fi if ! test -f "$DENO_KV_SQLITE_PATH"; then - litestream restore --if-replica-exists /data/denokv.sqlite3 + litestream restore --if-replica-exists -o "$DENO_KV_SQLITE_PATH" fi litestream replicate -exec 'tvbot' diff --git a/src/database/client.ts b/src/database/client.ts index ac6e13e..739f9dd 100644 --- a/src/database/client.ts +++ b/src/database/client.ts @@ -1,3 +1,6 @@ -const kv = await Deno.openKv("./denokv.sqlite3") +import { getEnv } from "lib/env.ts" + +const dbLocation = getEnv("DENO_KV_SQLITE_PATH") +const kv = await Deno.openKv(dbLocation) export default kv diff --git a/src/lib/env.ts b/src/lib/env.ts index f3ba6b1..43eb07b 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -13,6 +13,7 @@ const envKeys = { "NODE_ENV": z.enum(["development", "production"]).optional().default( "development", ), + "DENO_KV_SQLITE_PATH": z.string().optional().default("./denokv.sqlite3"), } as const export type EnvKey = keyof typeof envKeys From a4d1da2b4dd76f423d85bbf3e747009ed5bd7daa Mon Sep 17 00:00:00 2001 From: cbackas Date: Fri, 25 Apr 2025 16:14:52 -0500 Subject: [PATCH 10/12] docker improvements --- .dockerignore | 2 ++ Dockerfile | 11 +++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.dockerignore b/.dockerignore index f05b4c3..42432ba 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,3 +12,5 @@ fly.toml .git node_modules/ prisma/client/ +*.sqlite* +*.db* diff --git a/Dockerfile b/Dockerfile index 7224bc2..e2ba9af 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,13 +13,12 @@ RUN deno task build FROM debian:latest COPY --from=litestream /usr/local/bin/litestream /usr/local/bin/litestream -COPY --from=build /app/dist/tvbot /usr/local/bin/tvbot - +COPY entrypoint.sh /usr/local/bin/entrypoint COPY litestream.yml /etc/litestream.yml -ENV DENO_KV_SQLITE_PATH="/data/denokv.sqlite3" +COPY --from=build /app/dist/tvbot /usr/local/bin/tvbot -WORKDIR /app -COPY entrypoint.sh /app/entrypoint.sh +ENV DENO_KV_SQLITE_PATH="/data/denokv.sqlite3" ENV TZ="America/Chicago" -ENTRYPOINT ["sh", "entrypoint.sh"] + +ENTRYPOINT ["entrypoint"] From ab170ac88d8b7bc292942cc92f9860de98295a6c Mon Sep 17 00:00:00 2001 From: cbackas Date: Fri, 25 Apr 2025 16:39:59 -0500 Subject: [PATCH 11/12] make the dockerfile run, maybe it wont work, but it will run --- Dockerfile | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index e2ba9af..d294c4d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,12 +13,14 @@ RUN deno task build FROM debian:latest COPY --from=litestream /usr/local/bin/litestream /usr/local/bin/litestream -COPY entrypoint.sh /usr/local/bin/entrypoint COPY litestream.yml /etc/litestream.yml +COPY entrypoint.sh /app/entrypoint.sh + COPY --from=build /app/dist/tvbot /usr/local/bin/tvbot ENV DENO_KV_SQLITE_PATH="/data/denokv.sqlite3" ENV TZ="America/Chicago" -ENTRYPOINT ["entrypoint"] +WORKDIR /app +ENTRYPOINT ["sh", "entrypoint.sh"] From ddef8b73e5bda0ead260cd73a8d380909f45d52a Mon Sep 17 00:00:00 2001 From: cbackas Date: Fri, 25 Apr 2025 16:59:04 -0500 Subject: [PATCH 12/12] add deno flags to restrict access --- deno.jsonc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deno.jsonc b/deno.jsonc index 7346102..3ab6e43 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -1,8 +1,8 @@ { "$schema": "https://raw.githubusercontent.com/denoland/deno/refs/heads/main/cli/schemas/config-file.v1.json", "tasks": { - "dev": "deno --watch --no-clear-screen -A --unstable-cron --unstable-kv --allow-scripts=npm:prisma,npm:@prisma/client,npm:@prisma/engines ./src/app.ts", - "build": "deno compile --unstable-cron --unstable-kv -o ./dist/tvbot ./src/app.ts", + "dev": "deno --watch --no-clear-screen -ERN --unstable-cron --unstable-kv --allow-scripts=npm:prisma,npm:@prisma/client,npm:@prisma/engines ./src/app.ts", + "build": "deno compile --unstable-cron --unstable-kv -EN -o ./dist/tvbot ./src/app.ts", "prisma": "deno run -A npm:prisma", "prisma:generate": "deno task prisma generate --schema=./prisma/schema.prisma", "db:migrate": "deno run -A npm:prisma migrate dev",