From be2d0588902b357af610d6478309d37b920f2595 Mon Sep 17 00:00:00 2001 From: Adrian Bruno Date: Thu, 12 Feb 2026 17:43:57 -0500 Subject: [PATCH 1/8] Move legacy sql class into the shared workspace --- .../middleware/legacy/commandqueue.mirror.ts | 2 +- shared/database/package.json | 3 +- shared/database/src/client.ts | 4 +++ shared/database/src/index.ts | 1 + .../database/src}/legacy/sql.mirror.ts | 30 +++++++++++++++---- 5 files changed, 33 insertions(+), 7 deletions(-) rename {apps/backend/middleware => shared/database/src}/legacy/sql.mirror.ts (76%) diff --git a/apps/backend/middleware/legacy/commandqueue.mirror.ts b/apps/backend/middleware/legacy/commandqueue.mirror.ts index f11cbf4..db218cb 100644 --- a/apps/backend/middleware/legacy/commandqueue.mirror.ts +++ b/apps/backend/middleware/legacy/commandqueue.mirror.ts @@ -3,7 +3,7 @@ import { EventData, MirrorEvents, serverEventsMgr } from '../../utils/serverEven import { promises } from 'fs'; import { EventEmitter } from 'node:events'; import path from 'path'; -import { MirrorSQL } from './sql.mirror'; +import { MirrorSQL } from '@wxyc/database'; import { cryptoRandomId, expBackoffMs } from './utilities.mirror'; const CommandQueueEvents = { diff --git a/shared/database/package.json b/shared/database/package.json index b5b2da7..bc81b4d 100644 --- a/shared/database/package.json +++ b/shared/database/package.json @@ -20,7 +20,8 @@ }, "dependencies": { "drizzle-orm": "^0.41.0", - "postgres": "^3.4.4" + "postgres": "^3.4.4", + "node-ssh": "^13.2.1" }, "devDependencies": { "tsup": "^8.5.0", diff --git a/shared/database/src/client.ts b/shared/database/src/client.ts index 4ff4b94..240043f 100644 --- a/shared/database/src/client.ts +++ b/shared/database/src/client.ts @@ -19,3 +19,7 @@ const queryClient = postgres({ }); export const db = drizzle(queryClient, { schema }); + +export function closeDatabaseConnection(): Promise { + return queryClient.end().then(() => console.log('Database connection closed.')); +} diff --git a/shared/database/src/index.ts b/shared/database/src/index.ts index ecc55c8..7250844 100644 --- a/shared/database/src/index.ts +++ b/shared/database/src/index.ts @@ -1,3 +1,4 @@ export * from './client.js'; export * from './schema.js'; export * from './types/index.js'; +export * from './legacy/sql.mirror.js'; diff --git a/apps/backend/middleware/legacy/sql.mirror.ts b/shared/database/src/legacy/sql.mirror.ts similarity index 76% rename from apps/backend/middleware/legacy/sql.mirror.ts rename to shared/database/src/legacy/sql.mirror.ts index 95c91c9..08f3f47 100644 --- a/apps/backend/middleware/legacy/sql.mirror.ts +++ b/shared/database/src/legacy/sql.mirror.ts @@ -3,6 +3,7 @@ import { Config, NodeSSH } from 'node-ssh'; export class MirrorSQL { private static _instance: MirrorSQL | null = null; private static _ssh: NodeSSH | null = null; + private static _disposeTimer: NodeJS.Timeout | null = null; static instance() { if (!this._instance) this._instance = new MirrorSQL(); @@ -24,19 +25,37 @@ export class MirrorSQL { await this._ssh.connect(sshConfig); } - setTimeout( + if (this._disposeTimer) { + clearTimeout(this._disposeTimer); + } + + this._disposeTimer = setTimeout( () => { - if (this._ssh && this._ssh.isConnected()) { - this._ssh.dispose(); - this._ssh = null; - } + MirrorSQL.instance().close(); }, 5 * 60 * 1000 ); // auto-dispose after 5 minutes of inactivity + this._disposeTimer.unref(); return this._ssh; } + close() { + if (MirrorSQL._disposeTimer) { + clearTimeout(MirrorSQL._disposeTimer); + MirrorSQL._disposeTimer = null; + } + + if (MirrorSQL._ssh) { + if (MirrorSQL._ssh.isConnected()) { + MirrorSQL._ssh.dispose(); + } + MirrorSQL._ssh = null; + } + + console.log('[MirrorSQL] Database connection closed.'); + } + private static shSingleQuote = (s: string) => s.replace(/'/g, `'\\''`); private static getRemotePwd = () => process.env.REMOTE_DB_PASSWORD ?? ''; @@ -46,6 +65,7 @@ export class MirrorSQL { mysql -u ${process.env.REMOTE_DB_USER ?? ''} \\ -h ${process.env.REMOTE_DB_HOST ?? ''} \\ -D ${process.env.REMOTE_DB_NAME ?? ''} \\ + -P ${process.env.REMOTE_DB_PORT ?? '3306'} \\ --protocol=TCP \\ --connect-timeout=10 \\ --skip-ssl \\ From bd1e9062075ae0894c73d827515f5934351c66ce Mon Sep 17 00:00:00 2001 From: Adrian Bruno Date: Thu, 12 Feb 2026 17:47:12 -0500 Subject: [PATCH 2/8] implement etl job --- jobs/library-etl/job.ts | 440 ++++++++++++++++++++++++++++++++ jobs/library-etl/package.json | 26 ++ jobs/library-etl/tsconfig.json | 12 + jobs/library-etl/tsup.config.ts | 24 ++ 4 files changed, 502 insertions(+) create mode 100644 jobs/library-etl/job.ts create mode 100644 jobs/library-etl/package.json create mode 100644 jobs/library-etl/tsconfig.json create mode 100644 jobs/library-etl/tsup.config.ts diff --git a/jobs/library-etl/job.ts b/jobs/library-etl/job.ts new file mode 100644 index 0000000..40055ec --- /dev/null +++ b/jobs/library-etl/job.ts @@ -0,0 +1,440 @@ +import { and, eq } from 'drizzle-orm'; +import { isNull } from 'drizzle-orm'; +import { + MirrorSQL, + db, + artists, + format, + genre_artist_crossreference, + genres, + library, + cronjob_runs, + closeDatabaseConnection, +} from '@wxyc/database'; + +const legacyDB = MirrorSQL.instance(); +const JOB_NAME = 'library-etl'; + +type LegacyReleaseRow = { + release_id: number; + release_title: string; + release_last_modified: number | null; + release_time_created: number | null; + release_call_numbers: number | null; + release_call_letters: string | null; + release_alternate_artist_name: string | null; + artist_name: string; + artist_alpha_name: string | null; + artist_call_letters: string | null; + artist_call_numbers: number | null; + genre_ref_name: string | null; + format_ref_name: string | null; +}; + +const VARIOUS_ARTISTS_NAME = 'Various Artists'; +const VARIOUS_ARTISTS_CODE_LETTERS = 'V/A'; +const VARIOUS_ARTISTS_CODE_NUMBER = 0; + +const parseTabRow = (line: string, columnCount: number) => { + const columns = line.split('\t'); + if (columns.length !== columnCount) { + return null; + } + return columns; +}; + +const toNullableString = (value?: string) => { + if (value == null || value.trim().length === 0) return null; + return value.trim(); +}; + +const toNullableNumber = (value?: string) => { + if (value == null || value.trim().length === 0) return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +}; + +const isDbOnlyGenre = (genreRef?: string | null) => { + return genreRef != null && genreRef.trim().toLowerCase() === 'db_only'; +}; + +const normalizeArtistName = (name: string) => { + const trimmed = name.trim(); + if (/^various\s+artists\s*-\s*/i.test(trimmed)) { + return { name: VARIOUS_ARTISTS_NAME, isVarious: true }; + } + return { name: trimmed, isVarious: false }; +}; + +const normalizeCodeLetters = (code: string | null) => { + if (!code) return null; + const trimmed = code.trim(); + if (trimmed.length === 0) return null; + return trimmed.slice(0, 2).toUpperCase(); +}; + +const parseFormatAndDiscs = (formatText: string) => { + const normalized = formatText.toLowerCase().trim(); + + const matchCd = normalized.match(/^cd(?:\s*x\s*(\d+))?(?:\s*box)?$/); + if (matchCd) { + const discQuantity = matchCd[1] ? Number(matchCd[1]) : 1; + return { formatName: 'cd', discQuantity }; + } + + const matchCdr = normalized.match(/^cdr$/); + if (matchCdr) { + return { formatName: 'cdr', discQuantity: 1 }; + } + + if (!normalized.startsWith('vinyl')) { + return null; + } + + const xMatch = normalized.match(/\bx\s*(\d+)\b/); + const discQuantity = xMatch && Number.isFinite(Number(xMatch[1])) ? Number(xMatch[1]) : 1; + + let formatName = 'vinyl'; + if (normalized.includes('7"')) { + formatName = 'vinyl 7"'; + } else if (normalized.includes('10"')) { + formatName = 'vinyl 10"'; + } else if (normalized.includes('12"') || normalized.includes('lp')) { + formatName = 'vinyl 12"'; + } + + return { formatName, discQuantity }; +}; + +const toDateOrUndefined = (value: number | null) => { + if (value == null) return undefined; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return undefined; + return date; +}; + +const toDateOnlyString = (value: number | null) => { + const date = toDateOrUndefined(value); + if (!date) return undefined; + return date.toISOString().slice(0, 10); +}; + +type DbTransaction = Parameters[0]>[0]; +type DbClient = typeof db | DbTransaction; + +const getLastRunTimestamp = async (jobName: string): Promise => { + const response = await db + .select({ lastRun: cronjob_runs.last_run }) + .from(cronjob_runs) + .where(eq(cronjob_runs.job_name, jobName)) + .limit(1); + + const lastRun = response[0]?.lastRun ?? null; + return lastRun ? lastRun.getTime() : null; +}; + +const updateLastRun = async (dbClient: DbClient, jobName: string, lastRun: Date) => { + await dbClient + .insert(cronjob_runs) + .values({ job_name: jobName, last_run: lastRun }) + .onConflictDoUpdate({ + target: cronjob_runs.job_name, + set: { last_run: lastRun }, + }); +}; + +const fetchLegacyReleases = async (lastRunMs: number | null) => { + const lastRunFilter = lastRunMs == null ? '' : `WHERE lr.TIME_LAST_MODIFIED > ${lastRunMs}`; + const sqlQuery = ` + SELECT + lr.ID, + REPLACE(REPLACE(IFNULL(lr.TITLE, ''), '\\t', ' '), '\\n', ' '), + lr.TIME_LAST_MODIFIED, + lr.TIME_CREATED, + lr.CALL_NUMBERS AS release_call_numbers, + lr.CALL_LETTERS AS release_call_letters, + REPLACE(REPLACE(IFNULL(lr.ALTERNATE_ARTIST_NAME, ''), '\\t', ' '), '\\n', ' '), + REPLACE(REPLACE(IFNULL(lc.PRESENTATION_NAME, ''), '\\t', ' '), '\\n', ' '), + REPLACE(REPLACE(IFNULL(lc.ALPHABETICAL_NAME, ''), '\\t', ' '), '\\n', ' '), + lc.CALL_LETTERS AS artist_call_letters, + lc.CALL_NUMBERS AS artist_call_numbers, + g.REFERENCE_NAME, + f.REFERENCE_NAME + FROM LIBRARY_RELEASE lr + JOIN LIBRARY_CODE lc ON lr.LIBRARY_CODE_ID = lc.ID + JOIN GENRE g ON lc.GENRE_ID = g.ID + JOIN FORMAT f ON lr.FORMAT_ID = f.ID + ${lastRunFilter} + ORDER BY lr.TIME_LAST_MODIFIED ASC; + `; + + const raw = await legacyDB.send(sqlQuery); + const rows = raw.trim().length === 0 ? [] : raw.trim().split('\n'); + const columnCount = 13; + const parsed: LegacyReleaseRow[] = []; + + for (const line of rows) { + const columns = parseTabRow(line, columnCount); + if (!columns) { + console.warn('[library-etl] Skipping malformed legacy row:', line); + continue; + } + + parsed.push({ + release_id: Number(columns[0]), + release_title: columns[1], + release_last_modified: toNullableNumber(columns[2]), + release_time_created: toNullableNumber(columns[3]), + release_call_numbers: toNullableNumber(columns[4]), + release_call_letters: toNullableString(columns[5]), + release_alternate_artist_name: toNullableString(columns[6]), + artist_name: columns[7], + artist_alpha_name: toNullableString(columns[8]), + artist_call_letters: toNullableString(columns[9]), + artist_call_numbers: toNullableNumber(columns[10]), + genre_ref_name: toNullableString(columns[11]), + format_ref_name: toNullableString(columns[12]), + }); + } + + return parsed; +}; + +const ensureArtist = async ( + dbClient: DbClient, + artistName: string, + isVarious: boolean, + genreId: number, + codeLetters: string | null, + codeArtistNumber: number | null, + artistCache: Map, + addDate?: string, + lastModified?: Date +) => { + const normalizedLetters = codeLetters ?? '??'; + const normalizedNumber = codeArtistNumber ?? 0; + const artistKey = `${artistName.toLowerCase()}|${normalizedLetters}|${normalizedNumber}`; + const cached = artistCache.get(artistKey); + if (cached) return cached; + + const baseConditions = [ + eq(artists.artist_name, artistName), + eq(artists.code_letters, normalizedLetters), + eq(artists.code_artist_number, normalizedNumber), + ]; + + const query = dbClient + .select({ id: artists.id }) + .from(artists) + .where(isVarious ? and(...baseConditions) : and(...baseConditions, eq(artists.genre_id, genreId))) + .limit(1); + + const existing = await query; + if (existing.length) { + artistCache.set(artistKey, existing[0].id); + return existing[0].id; + } + + const inserted = await dbClient + .insert(artists) + .values({ + artist_name: artistName, + genre_id: genreId, + code_letters: normalizedLetters, + code_artist_number: normalizedNumber, + add_date: addDate, + last_modified: lastModified, + }) + .returning(); + + const id = inserted[0]?.id; + if (!id) { + throw new Error(`[library-etl] Failed to insert artist ${artistName}.`); + } + artistCache.set(artistKey, id); + return id; +}; + +const ensureGenreArtistCrossref = async ( + dbClient: DbClient, + artistId: number, + genreId: number, + artistGenreCode: number +) => { + const existing = await dbClient + .select({ artist_id: genre_artist_crossreference.artist_id }) + .from(genre_artist_crossreference) + .where(and(eq(genre_artist_crossreference.artist_id, artistId), eq(genre_artist_crossreference.genre_id, genreId))) + .limit(1); + + if (existing.length) return; + + await dbClient.insert(genre_artist_crossreference).values({ + artist_id: artistId, + genre_id: genreId, + artist_genre_code: artistGenreCode, + }); +}; + +const albumExists = async ( + dbClient: DbClient, + artistId: number, + genreId: number, + albumTitle: string, + codeNumber: number | null, + codeVolumeLetters: string | null +) => { + const response = await dbClient + .select({ id: library.id }) + .from(library) + .where( + and( + eq(library.artist_id, artistId), + eq(library.genre_id, genreId), + eq(library.album_title, albumTitle), + eq(library.code_number, codeNumber ?? 0), + codeVolumeLetters ? eq(library.code_volume_letters, codeVolumeLetters) : isNull(library.code_volume_letters) + ) + ) + .limit(1); + + return response.length > 0; +}; + +const run = async () => { + try { + const runStartedAt = new Date(); + const lastRunMs = await getLastRunTimestamp(JOB_NAME); + const legacyReleases = await fetchLegacyReleases(lastRunMs); + + if (legacyReleases.length === 0) { + console.log('[library-etl] No new legacy releases found.'); + await updateLastRun(db, JOB_NAME, runStartedAt); + return; + } + + let insertedCount = 0; + let skippedCount = 0; + + await db.transaction(async (tx) => { + const genreRows = await tx.select().from(genres); + const genreMap = new Map(genreRows.map((genre) => [genre.genre_name.toLowerCase(), genre.id])); + + const formatRows = await tx.select().from(format); + const formatMap = new Map(formatRows.map((row) => [row.format_name.toLowerCase(), row.id])); + + const artistCache = new Map(); + + for (const release of legacyReleases) { + if (isDbOnlyGenre(release.genre_ref_name)) { + skippedCount += 1; + continue; + } + + const genreName = release.genre_ref_name ?? ''; + const genreId = genreMap.get(genreName.toLowerCase()); + if (!genreId) { + console.warn(`[library-etl] Missing genre "${genreName}" for release ${release.release_id}.`); + skippedCount += 1; + continue; + } + + const formatText = release.format_ref_name ?? ''; + const formatParsed = parseFormatAndDiscs(formatText); + if (!formatParsed) { + console.warn(`[library-etl] Unsupported format "${formatText}" for release ${release.release_id}.`); + skippedCount += 1; + continue; + } + + const formatId = formatMap.get(formatParsed.formatName.toLowerCase()) ?? null; + if (!formatId) { + console.warn(`[library-etl] Missing format "${formatParsed.formatName}" for release ${release.release_id}.`); + skippedCount += 1; + continue; + } + + const artistInfo = normalizeArtistName(release.artist_name); + if (artistInfo.name.length === 0) { + skippedCount += 1; + continue; + } + const codeLetters = artistInfo.isVarious + ? VARIOUS_ARTISTS_CODE_LETTERS + : normalizeCodeLetters(release.artist_call_letters); + const codeArtistNumber = artistInfo.isVarious + ? VARIOUS_ARTISTS_CODE_NUMBER + : (release.artist_call_numbers ?? 0); + + const artistId = await ensureArtist( + tx, + artistInfo.name, + artistInfo.isVarious, + genreId, + codeLetters, + codeArtistNumber, + artistCache, + toDateOnlyString(release.release_time_created), + toDateOrUndefined(release.release_last_modified) + ); + + await ensureGenreArtistCrossref( + tx, + artistId, + genreId, + artistInfo.isVarious ? VARIOUS_ARTISTS_CODE_NUMBER : (release.artist_call_numbers ?? 0) + ); + + const albumTitle = release.release_title.trim(); + if (albumTitle.length === 0) { + skippedCount += 1; + continue; + } + + const codeVolumeLetters = + release.release_call_letters != null && release.release_call_letters.trim().length > 0 + ? release.release_call_letters.trim() + : null; + const alreadyExists = await albumExists( + tx, + artistId, + genreId, + albumTitle, + release.release_call_numbers, + codeVolumeLetters + ); + if (alreadyExists) { + skippedCount += 1; + continue; + } + + await tx.insert(library).values({ + artist_id: artistId, + genre_id: genreId, + format_id: formatId, + alternate_artist_name: release.release_alternate_artist_name, + album_title: albumTitle, + code_number: release.release_call_numbers ?? 0, + code_volume_letters: codeVolumeLetters, + disc_quantity: formatParsed.discQuantity, + add_date: toDateOrUndefined(release.release_time_created), + last_modified: toDateOrUndefined(release.release_last_modified), + }); + + insertedCount += 1; + } + + await updateLastRun(tx, JOB_NAME, runStartedAt); + }); + + console.log(`[library-etl] Completed. Inserted ${insertedCount}, skipped ${skippedCount}.`); + } finally { + await closeDatabaseConnection(); + legacyDB.close(); + } +}; + +run().catch((error) => { + console.error('[library-etl] Failed:', error); + process.exitCode = 1; +}); diff --git a/jobs/library-etl/package.json b/jobs/library-etl/package.json new file mode 100644 index 0000000..3787506 --- /dev/null +++ b/jobs/library-etl/package.json @@ -0,0 +1,26 @@ +{ + "name": "@wxyc/library-etl", + "cron-schedule": "*/30 * * * *", + "version": "1.0.0", + "description": "WXYC Authentication Service using Better Auth", + "type": "module", + "main": "./dist/job.js", + "scripts": { + "start": "node dist/job.js", + "build": "tsup --minify", + "clean": "rm -rf dist", + "docker:build": "docker build -t wxyc_library_etl:ci -f ../../Dockerfile.library-etl ../../", + "dev": "tsup --watch" + }, + "author": "Adrian Bruno", + "license": "MIT", + "dependencies": { + "@wxyc/database": "^1.0.0" + }, + "peerDependencies": { + "drizzle-orm": "^0.41.0" + }, + "devDependencies": { + "typescript": "^5.6.2" + } +} diff --git a/jobs/library-etl/tsconfig.json b/jobs/library-etl/tsconfig.json new file mode 100644 index 0000000..d5ee8b7 --- /dev/null +++ b/jobs/library-etl/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": ["../../tsconfig.base.json"], + "references": [{ "path": "../../shared/database" }], + "include": ["."], + "compilerOptions": { + "module": "esnext", + "moduleResolution": "bundler", + "paths": { + "@/*": ["./*"] + } + } +} diff --git a/jobs/library-etl/tsup.config.ts b/jobs/library-etl/tsup.config.ts new file mode 100644 index 0000000..6f1d2ba --- /dev/null +++ b/jobs/library-etl/tsup.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'tsup'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +// Get the directory where this config file is located +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +export default defineConfig((options) => ({ + entry: ['job.ts'], + format: ['cjs'], + outDir: 'dist', + clean: true, + onSuccess: options.watch ? 'node ./dist/job.js' : undefined, + minify: !options.watch, + + esbuildOptions(options) { + // Resolve @/ alias to the directory where this config file is located + // This matches TypeScript's behavior (relative to tsconfig.json location) + options.alias = { + '@': resolve(__dirname), + }; + }, +})); From bca243303212211157803c7c70b4a4a82ded5327 Mon Sep 17 00:00:00 2001 From: Adrian Bruno Date: Thu, 12 Feb 2026 17:48:47 -0500 Subject: [PATCH 3/8] update github workflow to handle cronjobs --- .github/workflows/deploy-base.yml | 75 ++++++++++++++++++++++++++++--- package.json | 3 +- 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/.github/workflows/deploy-base.yml b/.github/workflows/deploy-base.yml index 9cf5d3b..cb2faee 100644 --- a/.github/workflows/deploy-base.yml +++ b/.github/workflows/deploy-base.yml @@ -26,8 +26,8 @@ jobs: run: | TARGET_INPUT="${{ inputs.target }}" - if [ ! -d "apps/$TARGET_INPUT" ]; then - echo "Error: The target directory 'apps/$TARGET_INPUT' does not exist." >&2 + if [ ! -d "apps/$TARGET_INPUT" ] && [ ! -d "jobs/$TARGET_INPUT" ]; then + echo "Error: The target directory 'apps/$TARGET_INPUT' or 'jobs/$TARGET_INPUT' does not exist." >&2 exit 1 fi echo "target input '$TARGET_INPUT' is valid." @@ -75,7 +75,7 @@ jobs: TARGETS="${{ inputs.target }}" else echo "Detecting targets from git diff..." - TARGETS=$(git diff --name-only HEAD~1..HEAD | grep '^apps' | cut -d '/' -f 2 | sort -u) + TARGETS=$(git diff --name-only HEAD~1..HEAD | awk -F/ '/^(apps|jobs)\//{print $2}' | sort -u) fi if [ -z "$TARGETS" ] ; then @@ -309,11 +309,24 @@ jobs: id: deploy_vars run: | TARGET_APP=${{ matrix.target }} - PUBLISH_PORT=$(yq .publishPort apps/$TARGET_APP/package.json) + if [ -f "jobs/$TARGET_APP/package.json" ]; then + TARGET_TYPE="job" + CRON_SCHEDULE=$(yq '.["cron-schedule"]' jobs/$TARGET_APP/package.json) + if [ -z "$CRON_SCHEDULE" ] || [ "$CRON_SCHEDULE" = "null" ]; then + echo "Missing cron-schedule in jobs/$TARGET_APP/package.json" >&2 + exit 1 + fi + echo "cron_schedule=$CRON_SCHEDULE" >> $GITHUB_OUTPUT + else + TARGET_TYPE="app" + PUBLISH_PORT=$(yq .publishPort apps/$TARGET_APP/package.json) + echo "publish_port=$PUBLISH_PORT" >> $GITHUB_OUTPUT + fi - echo "publish_port=$PUBLISH_PORT" >> $GITHUB_OUTPUT + echo "target_type=$TARGET_TYPE" >> $GITHUB_OUTPUT - name: Deploy Service + if: steps.deploy_vars.outputs.target_type == 'app' uses: ./.github/actions/deploy-service with: target_app: ${{ matrix.target }} @@ -327,7 +340,39 @@ jobs: aws_region: ${{ secrets.AWS_REGION }} aws_ecr_uri: ${{ secrets.AWS_ECR_URI }} + - name: Deploy Cron Job + if: steps.deploy_vars.outputs.target_type == 'job' + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + set -e + export AWS_ACCESS_KEY_ID=${{ secrets.AWS_ACCESS_KEY_ID }} + export AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY }} + + TARGET_APP=${{ matrix.target }} + DEPLOY_TAG=${{ steps.determine_version.outputs.deploy_version }} + CRON_SCHEDULE='${{ steps.deploy_vars.outputs.cron_schedule }}' + AWS_ECR_URI=${{ secrets.AWS_ECR_URI }} + AWS_REGION=${{ secrets.AWS_REGION }} + + CRON_ID="wxyc_${TARGET_APP}" + echo "Logging into AWS ECR..." + aws ecr get-login-password --region $AWS_REGION | docker login --username AWS --password-stdin $AWS_ECR_URI + + echo "Pulling Docker image for deploy..." + docker pull $AWS_ECR_URI/$TARGET_APP:$DEPLOY_TAG + + CRON_CMD="docker rm -f ${TARGET_APP}-cron >/dev/null 2>&1 || true; docker run --rm --name ${TARGET_APP}-cron --env-file .env $AWS_ECR_URI/$TARGET_APP:$DEPLOY_TAG" + + (crontab -l 2>/dev/null | grep -v "$CRON_ID"; echo "$CRON_SCHEDULE $CRON_CMD # $CRON_ID") | crontab - + + echo "Cron schedule updated." + - name: Confirm server is up + if: steps.deploy_vars.outputs.target_type == 'app' uses: appleboy/ssh-action@v1 with: host: ${{ secrets.EC2_HOST }} @@ -344,3 +389,23 @@ jobs: echo "Server is not running. Deployment failed." >&2 exit 1 fi + + - name: Confirm cron is updated + if: steps.deploy_vars.outputs.target_type == 'job' + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + set -e + TARGET_APP=${{ matrix.target }} + CRON_ID="wxyc_${TARGET_APP}" + + echo "Verifying crontab entry..." + if crontab -l 2>/dev/null | grep -F "$CRON_ID" > /dev/null; then + echo "Cronjob entry verified." + else + echo "Cronjob not updated properly. Deployment failed" >&2 + exit 1 + fi diff --git a/package.json b/package.json index 687e3c3..3836812 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "lint:env": "node scripts/lint-env.js", "format": "prettier --write .", "format:check": "prettier --check .", - "build": "npm run build --workspace=@wxyc/database --workspace=shared/** --workspace=apps/**", + "build": "npm run build --workspace=@wxyc/database --workspace=shared/** --workspace=apps/** --workspace=jobs/**", "dev": "dotenvx run -f .env -- concurrently \"npm:dev:auth\" \"npm:dev:backend\"", "dev:backend": "npm run dev --workspace=@wxyc/backend", "dev:auth": "npm run dev --workspace=@wxyc/auth-service", @@ -44,6 +44,7 @@ "license": "MIT", "workspaces": [ "apps/*", + "jobs/*", "shared/*" ], "devDependencies": { From 511b1fa35083f37ef25df91faef7513de146e9d1 Mon Sep 17 00:00:00 2001 From: Adrian Bruno Date: Thu, 12 Feb 2026 17:49:41 -0500 Subject: [PATCH 4/8] Add cronjob runs tracking table --- .../src/migrations/0026_cron_job_tracking.sql | 36 + .../src/migrations/meta/0026_snapshot.json | 2689 +++++++++++++++++ .../src/migrations/meta/_journal.json | 9 +- shared/database/src/schema.ts | 12 +- 4 files changed, 2743 insertions(+), 3 deletions(-) create mode 100644 shared/database/src/migrations/0026_cron_job_tracking.sql create mode 100644 shared/database/src/migrations/meta/0026_snapshot.json diff --git a/shared/database/src/migrations/0026_cron_job_tracking.sql b/shared/database/src/migrations/0026_cron_job_tracking.sql new file mode 100644 index 0000000..1338abe --- /dev/null +++ b/shared/database/src/migrations/0026_cron_job_tracking.sql @@ -0,0 +1,36 @@ +CREATE TABLE "wxyc_schema"."cronjob_runs" ( + "job_name" varchar(64) PRIMARY KEY NOT NULL, + "last_run" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint + +--> Drop view in order to alter the artists table +DROP VIEW IF EXISTS "wxyc_schema"."library_artist_view"; +--> statement-breakpoint + +ALTER TABLE "wxyc_schema"."artists" ALTER COLUMN "code_letters" SET DATA TYPE varchar(4); +--> statement-breakpoint + +ALTER TABLE "wxyc_schema"."library" ADD COLUMN "code_volume_letters" varchar(4); +--> statement-breakpoint + +--> Recreate view with the altered column in the artists table +CREATE VIEW "wxyc_schema"."library_artist_view" AS +SELECT "library"."id", + "artists"."code_letters", + "artists"."code_artist_number", + "library"."code_number", + "artists"."artist_name", + "library"."album_title", + "library"."label", + "format"."format_name", + "genres"."genre_name", + "rotation"."play_freq", + "library"."add_date" +FROM "wxyc_schema"."library" + INNER JOIN "wxyc_schema"."artists" ON "artists"."id" = "library"."artist_id" + INNER JOIN "wxyc_schema"."genres" ON "genres"."id" = "library"."genre_id" + INNER JOIN "wxyc_schema"."format" ON "format"."id" = "library"."format_id" + LEFT JOIN "wxyc_schema"."rotation" + ON "rotation"."album_id" = "library"."id" AND ("rotation"."kill_date" > CURRENT_DATE OR "rotation"."kill_date" IS NULL); +--> statement-breakpoint \ No newline at end of file diff --git a/shared/database/src/migrations/meta/0026_snapshot.json b/shared/database/src/migrations/meta/0026_snapshot.json new file mode 100644 index 0000000..f4d45ed --- /dev/null +++ b/shared/database/src/migrations/meta/0026_snapshot.json @@ -0,0 +1,2689 @@ +{ + "id": "1bcc0430-ce5d-4cd6-bcc3-9a5bb8823f61", + "prevId": "335ff99b-a9d0-49ec-a275-2bf4e9b2edf7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auth_account": { + "name": "auth_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_account_provider_account_key": { + "name": "auth_account_provider_account_key", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_account_user_id_auth_user_id_fk": { + "name": "auth_account_user_id_auth_user_id_fk", + "tableFrom": "auth_account", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.album_metadata": { + "name": "album_metadata", + "schema": "wxyc_schema", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "album_id": { + "name": "album_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_key": { + "name": "cache_key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "discogs_release_id": { + "name": "discogs_release_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "discogs_url": { + "name": "discogs_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "release_year": { + "name": "release_year", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "artwork_url": { + "name": "artwork_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "spotify_url": { + "name": "spotify_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "apple_music_url": { + "name": "apple_music_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "youtube_music_url": { + "name": "youtube_music_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "bandcamp_url": { + "name": "bandcamp_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "soundcloud_url": { + "name": "soundcloud_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "is_rotation": { + "name": "is_rotation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_accessed": { + "name": "last_accessed", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "album_metadata_album_id_idx": { + "name": "album_metadata_album_id_idx", + "columns": [ + { + "expression": "album_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "album_metadata_cache_key_idx": { + "name": "album_metadata_cache_key_idx", + "columns": [ + { + "expression": "cache_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "album_metadata_last_accessed_idx": { + "name": "album_metadata_last_accessed_idx", + "columns": [ + { + "expression": "last_accessed", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "album_metadata_album_id_library_id_fk": { + "name": "album_metadata_album_id_library_id_fk", + "tableFrom": "album_metadata", + "tableTo": "library", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "album_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "album_metadata_album_id_unique": { + "name": "album_metadata_album_id_unique", + "nullsNotDistinct": false, + "columns": [ + "album_id" + ] + }, + "album_metadata_cache_key_unique": { + "name": "album_metadata_cache_key_unique", + "nullsNotDistinct": false, + "columns": [ + "cache_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.anonymous_devices": { + "name": "anonymous_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "blocked": { + "name": "blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "blocked_at": { + "name": "blocked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "anonymous_devices_device_id_key": { + "name": "anonymous_devices_device_id_key", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "anonymous_devices_device_id_unique": { + "name": "anonymous_devices_device_id_unique", + "nullsNotDistinct": false, + "columns": [ + "device_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.artist_library_crossreference": { + "name": "artist_library_crossreference", + "schema": "wxyc_schema", + "columns": { + "artist_id": { + "name": "artist_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "library_id": { + "name": "library_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "library_id_artist_id": { + "name": "library_id_artist_id", + "columns": [ + { + "expression": "artist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "library_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "artist_library_crossreference_artist_id_artists_id_fk": { + "name": "artist_library_crossreference_artist_id_artists_id_fk", + "tableFrom": "artist_library_crossreference", + "tableTo": "artists", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "artist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "artist_library_crossreference_library_id_library_id_fk": { + "name": "artist_library_crossreference_library_id_library_id_fk", + "tableFrom": "artist_library_crossreference", + "tableTo": "library", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "library_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.artist_metadata": { + "name": "artist_metadata", + "schema": "wxyc_schema", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "artist_id": { + "name": "artist_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_key": { + "name": "cache_key", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "discogs_artist_id": { + "name": "discogs_artist_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wikipedia_url": { + "name": "wikipedia_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "last_accessed": { + "name": "last_accessed", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "artist_metadata_artist_id_idx": { + "name": "artist_metadata_artist_id_idx", + "columns": [ + { + "expression": "artist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "artist_metadata_cache_key_idx": { + "name": "artist_metadata_cache_key_idx", + "columns": [ + { + "expression": "cache_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "artist_metadata_last_accessed_idx": { + "name": "artist_metadata_last_accessed_idx", + "columns": [ + { + "expression": "last_accessed", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "artist_metadata_artist_id_artists_id_fk": { + "name": "artist_metadata_artist_id_artists_id_fk", + "tableFrom": "artist_metadata", + "tableTo": "artists", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "artist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "artist_metadata_artist_id_unique": { + "name": "artist_metadata_artist_id_unique", + "nullsNotDistinct": false, + "columns": [ + "artist_id" + ] + }, + "artist_metadata_cache_key_unique": { + "name": "artist_metadata_cache_key_unique", + "nullsNotDistinct": false, + "columns": [ + "cache_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.artists": { + "name": "artists", + "schema": "wxyc_schema", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "genre_id": { + "name": "genre_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "artist_name": { + "name": "artist_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "code_letters": { + "name": "code_letters", + "type": "varchar(4)", + "primaryKey": false, + "notNull": true + }, + "code_artist_number": { + "name": "code_artist_number", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "add_date": { + "name": "add_date", + "type": "date", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_modified": { + "name": "last_modified", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "artist_name_trgm_idx": { + "name": "artist_name_trgm_idx", + "columns": [ + { + "expression": "\"artist_name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "code_letters_idx": { + "name": "code_letters_idx", + "columns": [ + { + "expression": "code_letters", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "artists_genre_id_genres_id_fk": { + "name": "artists_genre_id_genres_id_fk", + "tableFrom": "artists", + "tableTo": "genres", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "genre_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.bins": { + "name": "bins", + "schema": "wxyc_schema", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "dj_id": { + "name": "dj_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "album_id": { + "name": "album_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "track_title": { + "name": "track_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "bins_dj_id_auth_user_id_fk": { + "name": "bins_dj_id_auth_user_id_fk", + "tableFrom": "bins", + "tableTo": "auth_user", + "columnsFrom": [ + "dj_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bins_album_id_library_id_fk": { + "name": "bins_album_id_library_id_fk", + "tableFrom": "bins", + "tableTo": "library", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "album_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.cronjob_runs": { + "name": "cronjob_runs", + "schema": "wxyc_schema", + "columns": { + "job_name": { + "name": "job_name", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "last_run": { + "name": "last_run", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.dj_stats": { + "name": "dj_stats", + "schema": "wxyc_schema", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "shows_covered": { + "name": "shows_covered", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "dj_stats_user_id_auth_user_id_fk": { + "name": "dj_stats_user_id_auth_user_id_fk", + "tableFrom": "dj_stats", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.flowsheet": { + "name": "flowsheet", + "schema": "wxyc_schema", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "show_id": { + "name": "show_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "album_id": { + "name": "album_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rotation_id": { + "name": "rotation_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "entry_type": { + "name": "entry_type", + "type": "flowsheet_entry_type", + "typeSchema": "wxyc_schema", + "primaryKey": false, + "notNull": true, + "default": "'track'" + }, + "track_title": { + "name": "track_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "album_title": { + "name": "album_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "artist_name": { + "name": "artist_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "record_label": { + "name": "record_label", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "play_order": { + "name": "play_order", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "request_flag": { + "name": "request_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "message": { + "name": "message", + "type": "varchar(250)", + "primaryKey": false, + "notNull": false + }, + "add_time": { + "name": "add_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "flowsheet_show_id_shows_id_fk": { + "name": "flowsheet_show_id_shows_id_fk", + "tableFrom": "flowsheet", + "tableTo": "shows", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "show_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "flowsheet_album_id_library_id_fk": { + "name": "flowsheet_album_id_library_id_fk", + "tableFrom": "flowsheet", + "tableTo": "library", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "album_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "flowsheet_rotation_id_rotation_id_fk": { + "name": "flowsheet_rotation_id_rotation_id_fk", + "tableFrom": "flowsheet", + "tableTo": "rotation", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "rotation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.format": { + "name": "format", + "schema": "wxyc_schema", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "format_name": { + "name": "format_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "add_date": { + "name": "add_date", + "type": "date", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.genre_artist_crossreference": { + "name": "genre_artist_crossreference", + "schema": "wxyc_schema", + "columns": { + "artist_id": { + "name": "artist_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "genre_id": { + "name": "genre_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "artist_genre_code": { + "name": "artist_genre_code", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "artist_genre_key": { + "name": "artist_genre_key", + "columns": [ + { + "expression": "artist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "genre_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "genre_artist_crossreference_artist_id_artists_id_fk": { + "name": "genre_artist_crossreference_artist_id_artists_id_fk", + "tableFrom": "genre_artist_crossreference", + "tableTo": "artists", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "artist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "genre_artist_crossreference_genre_id_genres_id_fk": { + "name": "genre_artist_crossreference_genre_id_genres_id_fk", + "tableFrom": "genre_artist_crossreference", + "tableTo": "genres", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "genre_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.genres": { + "name": "genres", + "schema": "wxyc_schema", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "genre_name": { + "name": "genre_name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plays": { + "name": "plays", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "add_date": { + "name": "add_date", + "type": "date", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_modified": { + "name": "last_modified", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_invitation": { + "name": "auth_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_invitation_email_idx": { + "name": "auth_invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_invitation_organization_id_auth_organization_id_fk": { + "name": "auth_invitation_organization_id_auth_organization_id_fk", + "tableFrom": "auth_invitation", + "tableTo": "auth_organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auth_invitation_inviter_id_auth_user_id_fk": { + "name": "auth_invitation_inviter_id_auth_user_id_fk", + "tableFrom": "auth_invitation", + "tableTo": "auth_user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_jwks": { + "name": "auth_jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.library": { + "name": "library", + "schema": "wxyc_schema", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "artist_id": { + "name": "artist_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "genre_id": { + "name": "genre_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "format_id": { + "name": "format_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "alternate_artist_name": { + "name": "alternate_artist_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "album_title": { + "name": "album_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "code_number": { + "name": "code_number", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "code_volume_letters": { + "name": "code_volume_letters", + "type": "varchar(4)", + "primaryKey": false, + "notNull": false + }, + "disc_quantity": { + "name": "disc_quantity", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "plays": { + "name": "plays", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "add_date": { + "name": "add_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_modified": { + "name": "last_modified", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "title_trgm_idx": { + "name": "title_trgm_idx", + "columns": [ + { + "expression": "\"album_title\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "genre_id_idx": { + "name": "genre_id_idx", + "columns": [ + { + "expression": "genre_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "format_id_idx": { + "name": "format_id_idx", + "columns": [ + { + "expression": "format_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "artist_id_idx": { + "name": "artist_id_idx", + "columns": [ + { + "expression": "artist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "library_artist_id_artists_id_fk": { + "name": "library_artist_id_artists_id_fk", + "tableFrom": "library", + "tableTo": "artists", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "artist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "library_genre_id_genres_id_fk": { + "name": "library_genre_id_genres_id_fk", + "tableFrom": "library", + "tableTo": "genres", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "genre_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "library_format_id_format_id_fk": { + "name": "library_format_id_format_id_fk", + "tableFrom": "library", + "tableTo": "format", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "format_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_member": { + "name": "auth_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_member_org_user_key": { + "name": "auth_member_org_user_key", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_member_organization_id_auth_organization_id_fk": { + "name": "auth_member_organization_id_auth_organization_id_fk", + "tableFrom": "auth_member", + "tableTo": "auth_organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auth_member_user_id_auth_user_id_fk": { + "name": "auth_member_user_id_auth_user_id_fk", + "tableFrom": "auth_member", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_organization": { + "name": "auth_organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auth_organization_slug_key": { + "name": "auth_organization_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.reviews": { + "name": "reviews", + "schema": "wxyc_schema", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "album_id": { + "name": "album_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "review": { + "name": "review", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "add_date": { + "name": "add_date", + "type": "date", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_modified": { + "name": "last_modified", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "author": { + "name": "author", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reviews_album_id_library_id_fk": { + "name": "reviews_album_id_library_id_fk", + "tableFrom": "reviews", + "tableTo": "library", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "album_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "reviews_album_id_unique": { + "name": "reviews_album_id_unique", + "nullsNotDistinct": false, + "columns": [ + "album_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.rotation": { + "name": "rotation", + "schema": "wxyc_schema", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "album_id": { + "name": "album_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "play_freq": { + "name": "play_freq", + "type": "freq_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "add_date": { + "name": "add_date", + "type": "date", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "kill_date": { + "name": "kill_date", + "type": "date", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "album_id_idx": { + "name": "album_id_idx", + "columns": [ + { + "expression": "album_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rotation_album_id_library_id_fk": { + "name": "rotation_album_id_library_id_fk", + "tableFrom": "rotation", + "tableTo": "library", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "album_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.schedule": { + "name": "schedule", + "schema": "wxyc_schema", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "day": { + "name": "day", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "start_time": { + "name": "start_time", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "show_duration": { + "name": "show_duration", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "specialty_id": { + "name": "specialty_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "assigned_dj_id": { + "name": "assigned_dj_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "assigned_dj_id2": { + "name": "assigned_dj_id2", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "schedule_specialty_id_specialty_shows_id_fk": { + "name": "schedule_specialty_id_specialty_shows_id_fk", + "tableFrom": "schedule", + "tableTo": "specialty_shows", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "specialty_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "schedule_assigned_dj_id_auth_user_id_fk": { + "name": "schedule_assigned_dj_id_auth_user_id_fk", + "tableFrom": "schedule", + "tableTo": "auth_user", + "columnsFrom": [ + "assigned_dj_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "schedule_assigned_dj_id2_auth_user_id_fk": { + "name": "schedule_assigned_dj_id2_auth_user_id_fk", + "tableFrom": "schedule", + "tableTo": "auth_user", + "columnsFrom": [ + "assigned_dj_id2" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_session": { + "name": "auth_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auth_session_token_key": { + "name": "auth_session_token_key", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_session_user_id_auth_user_id_fk": { + "name": "auth_session_user_id_auth_user_id_fk", + "tableFrom": "auth_session", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.shift_covers": { + "name": "shift_covers", + "schema": "wxyc_schema", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "shift_timestamp": { + "name": "shift_timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "cover_dj_id": { + "name": "cover_dj_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "covered": { + "name": "covered", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "shift_covers_schedule_id_schedule_id_fk": { + "name": "shift_covers_schedule_id_schedule_id_fk", + "tableFrom": "shift_covers", + "tableTo": "schedule", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "schedule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "shift_covers_cover_dj_id_auth_user_id_fk": { + "name": "shift_covers_cover_dj_id_auth_user_id_fk", + "tableFrom": "shift_covers", + "tableTo": "auth_user", + "columnsFrom": [ + "cover_dj_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.show_djs": { + "name": "show_djs", + "schema": "wxyc_schema", + "columns": { + "show_id": { + "name": "show_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "dj_id": { + "name": "dj_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + } + }, + "indexes": {}, + "foreignKeys": { + "show_djs_show_id_shows_id_fk": { + "name": "show_djs_show_id_shows_id_fk", + "tableFrom": "show_djs", + "tableTo": "shows", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "show_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "show_djs_dj_id_auth_user_id_fk": { + "name": "show_djs_dj_id_auth_user_id_fk", + "tableFrom": "show_djs", + "tableTo": "auth_user", + "columnsFrom": [ + "dj_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.shows": { + "name": "shows", + "schema": "wxyc_schema", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "primary_dj_id": { + "name": "primary_dj_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "specialty_id": { + "name": "specialty_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "show_name": { + "name": "show_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "start_time": { + "name": "start_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "end_time": { + "name": "end_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "shows_primary_dj_id_auth_user_id_fk": { + "name": "shows_primary_dj_id_auth_user_id_fk", + "tableFrom": "shows", + "tableTo": "auth_user", + "columnsFrom": [ + "primary_dj_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "shows_specialty_id_specialty_shows_id_fk": { + "name": "shows_specialty_id_specialty_shows_id_fk", + "tableFrom": "shows", + "tableTo": "specialty_shows", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "specialty_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.specialty_shows": { + "name": "specialty_shows", + "schema": "wxyc_schema", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "specialty_name": { + "name": "specialty_name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "add_date": { + "name": "add_date", + "type": "date", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_modified": { + "name": "last_modified", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_user": { + "name": "auth_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "real_name": { + "name": "real_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "dj_name": { + "name": "dj_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "app_skin": { + "name": "app_skin", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'modern-light'" + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "auth_user_email_key": { + "name": "auth_user_email_key", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_user_username_key": { + "name": "auth_user_username_key", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_activity": { + "name": "user_activity", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_activity_user_id_auth_user_id_fk": { + "name": "user_activity_user_id_auth_user_id_fk", + "tableFrom": "user_activity", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_verification": { + "name": "auth_verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "wxyc_schema.flowsheet_entry_type": { + "name": "flowsheet_entry_type", + "schema": "wxyc_schema", + "values": [ + "track", + "show_start", + "show_end", + "dj_join", + "dj_leave", + "talkset", + "breakpoint", + "message" + ] + }, + "public.freq_enum": { + "name": "freq_enum", + "schema": "public", + "values": [ + "S", + "L", + "M", + "H" + ] + } + }, + "schemas": { + "wxyc_schema": "wxyc_schema" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": { + "wxyc_schema.library_artist_view": { + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "code_letters": { + "name": "code_letters", + "type": "varchar(4)", + "primaryKey": false, + "notNull": true + }, + "code_artist_number": { + "name": "code_artist_number", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "code_number": { + "name": "code_number", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "artist_name": { + "name": "artist_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "album_title": { + "name": "album_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "format_name": { + "name": "format_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "genre_name": { + "name": "genre_name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "play_freq": { + "name": "play_freq", + "type": "freq_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "add_date": { + "name": "add_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "label": { + "name": "label", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + } + }, + "definition": "select \"wxyc_schema\".\"library\".\"id\", \"wxyc_schema\".\"artists\".\"code_letters\", \"wxyc_schema\".\"artists\".\"code_artist_number\", \"wxyc_schema\".\"library\".\"code_number\", \"wxyc_schema\".\"artists\".\"artist_name\", \"wxyc_schema\".\"library\".\"album_title\", \"wxyc_schema\".\"format\".\"format_name\", \"wxyc_schema\".\"genres\".\"genre_name\", \"wxyc_schema\".\"rotation\".\"play_freq\", \"wxyc_schema\".\"library\".\"add_date\", \"wxyc_schema\".\"library\".\"label\" from \"wxyc_schema\".\"library\" inner join \"wxyc_schema\".\"artists\" on \"wxyc_schema\".\"artists\".\"id\" = \"wxyc_schema\".\"library\".\"artist_id\" inner join \"wxyc_schema\".\"format\" on \"wxyc_schema\".\"format\".\"id\" = \"wxyc_schema\".\"library\".\"format_id\" inner join \"wxyc_schema\".\"genres\" on \"wxyc_schema\".\"genres\".\"id\" = \"wxyc_schema\".\"library\".\"genre_id\" left join \"wxyc_schema\".\"rotation\" on \"wxyc_schema\".\"rotation\".\"album_id\" = \"wxyc_schema\".\"library\".\"id\" AND (\"wxyc_schema\".\"rotation\".\"kill_date\" < CURRENT_DATE OR \"wxyc_schema\".\"rotation\".\"kill_date\" IS NULL)", + "name": "library_artist_view", + "schema": "wxyc_schema", + "isExisting": false, + "materialized": false + }, + "wxyc_schema.rotation_library_view": { + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "play_freq": { + "name": "play_freq", + "type": "freq_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "album_title": { + "name": "album_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "artist_name": { + "name": "artist_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "kill_date": { + "name": "kill_date", + "type": "date", + "primaryKey": false, + "notNull": false + } + }, + "definition": "select \"wxyc_schema\".\"library\".\"id\", \"wxyc_schema\".\"rotation\".\"id\", \"wxyc_schema\".\"library\".\"label\", \"wxyc_schema\".\"rotation\".\"play_freq\", \"wxyc_schema\".\"library\".\"album_title\", \"wxyc_schema\".\"artists\".\"artist_name\", \"wxyc_schema\".\"rotation\".\"kill_date\" from \"wxyc_schema\".\"library\" inner join \"wxyc_schema\".\"rotation\" on \"wxyc_schema\".\"library\".\"id\" = \"wxyc_schema\".\"rotation\".\"album_id\" inner join \"wxyc_schema\".\"artists\" on \"wxyc_schema\".\"artists\".\"id\" = \"wxyc_schema\".\"library\".\"artist_id\"", + "name": "rotation_library_view", + "schema": "wxyc_schema", + "isExisting": false, + "materialized": false + } + }, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/shared/database/src/migrations/meta/_journal.json b/shared/database/src/migrations/meta/_journal.json index 05d5c99..a35dbf0 100644 --- a/shared/database/src/migrations/meta/_journal.json +++ b/shared/database/src/migrations/meta/_journal.json @@ -162,6 +162,13 @@ "when": 1768890229444, "tag": "0025_rate_limiting_tables", "breakpoints": true + }, + { + "idx": 26, + "version": "7", + "when": 1770938861646, + "tag": "0026_cron_job_tracking", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/shared/database/src/schema.ts b/shared/database/src/schema.ts index f17f2b3..5825fc1 100644 --- a/shared/database/src/schema.ts +++ b/shared/database/src/schema.ts @@ -194,6 +194,13 @@ export const shift_covers = wxyc_schema.table('shift_covers', { covered: boolean('covered').default(false), }); +export type NewCronjobRun = InferInsertModel; +export type CronjobRun = InferSelectModel; +export const cronjob_runs = wxyc_schema.table('cronjob_runs', { + job_name: varchar('job_name', { length: 64 }).primaryKey(), + last_run: timestamp('last_run', { withTimezone: true }).notNull().defaultNow(), +}); + export type NewArtist = InferInsertModel; export type Artist = InferSelectModel; export const artists = wxyc_schema.table( @@ -204,7 +211,7 @@ export const artists = wxyc_schema.table( .references(() => genres.id) .notNull(), artist_name: varchar('artist_name', { length: 128 }).notNull(), - code_letters: varchar('code_letters', { length: 2 }).notNull(), + code_letters: varchar('code_letters', { length: 4 }).notNull(), code_artist_number: smallint('code_artist_number').notNull(), add_date: date('add_date').defaultNow().notNull(), last_modified: timestamp('last_modified').defaultNow().notNull(), @@ -245,6 +252,7 @@ export const library = wxyc_schema.table( album_title: varchar('album_title', { length: 128 }).notNull(), label: varchar('label', { length: 128 }), code_number: smallint('code_number').notNull(), + code_volume_letters: varchar('code_volume_letters', { length: 4 }), disc_quantity: smallint('disc_quantity').default(1).notNull(), plays: integer('plays').default(0).notNull(), add_date: timestamp('add_date').defaultNow().notNull(), @@ -444,7 +452,7 @@ export const library_artist_view = wxyc_schema.view('library_artist_view').as((q .innerJoin(genres, eq(genres.id, library.genre_id)) .leftJoin( rotation, - sql`${rotation.album_id} = ${library.id} AND (${rotation.kill_date} < CURRENT_DATE OR ${rotation.kill_date} IS NULL)` + sql`${rotation.album_id} = ${library.id} AND (${rotation.kill_date} > CURRENT_DATE OR ${rotation.kill_date} IS NULL)` ); }); From 279f14caebd761074baeb9e5f327cde1e55f15fe Mon Sep 17 00:00:00 2001 From: Adrian Bruno Date: Thu, 12 Feb 2026 18:54:54 -0500 Subject: [PATCH 5/8] Add dockerfile for job --- Dockerfile.library-etl | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 Dockerfile.library-etl diff --git a/Dockerfile.library-etl b/Dockerfile.library-etl new file mode 100644 index 0000000..6d952e9 --- /dev/null +++ b/Dockerfile.library-etl @@ -0,0 +1,27 @@ +#Build stage +FROM node:22-alpine AS builder + +WORKDIR /library-etl-builder + +COPY ./package.json ./ +COPY ./tsconfig.base.json ./ +COPY ./shared/database ./shared/database +COPY ./jobs/library-etl ./jobs/library-etl + +RUN npm install && npm run build --workspace=@wxyc/database --workspace=@wxyc/library-etl + +#Production stage +FROM node:22-alpine AS prod + +WORKDIR /library-etl + +COPY ./package* ./ +COPY ./jobs/library-etl/package* ./jobs/library-etl/ +COPY ./shared/database/package* ./shared/database/ + +RUN npm install --omit=dev + +COPY --from=builder ./library-etl-builder/jobs/library-etl/dist ./jobs/library-etl/dist +COPY --from=builder ./library-etl-builder/shared/database/dist ./shared/database/dist + +CMD ["npm", "start", "--workspace=@wxyc/library-etl"] \ No newline at end of file From 00edd5db60a4982505990f0f49a6e5363da5b207 Mon Sep 17 00:00:00 2001 From: Adrian Bruno Date: Thu, 12 Feb 2026 19:03:17 -0500 Subject: [PATCH 6/8] update package-lock.json --- package-lock.json | 68 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/package-lock.json b/package-lock.json index 9ef6de8..a2f2bdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "workspaces": [ "apps/*", + "jobs/*", "shared/*" ], "dependencies": { @@ -100,6 +101,20 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "jobs/library-etl": { + "name": "@wxyc/library-etl", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@wxyc/database": "^1.0.0" + }, + "devDependencies": { + "typescript": "^5.6.2" + }, + "peerDependencies": { + "drizzle-orm": "^0.41.0" + } + }, "node_modules/@aws-crypto/sha256-browser": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", @@ -1412,6 +1427,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1428,6 +1444,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1444,6 +1461,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1460,6 +1478,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1476,6 +1495,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1492,6 +1512,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1508,6 +1529,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1524,6 +1546,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1540,6 +1563,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1556,6 +1580,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1572,6 +1597,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1588,6 +1614,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1604,6 +1631,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1620,6 +1648,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1636,6 +1665,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1652,6 +1682,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1668,6 +1699,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1684,6 +1716,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1700,6 +1733,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1716,6 +1750,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1732,6 +1767,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1748,6 +1784,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1814,6 +1851,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1830,6 +1868,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1846,6 +1885,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1862,6 +1902,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1878,6 +1919,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1894,6 +1936,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1910,6 +1953,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1926,6 +1970,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1942,6 +1987,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1958,6 +2004,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1974,6 +2021,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1990,6 +2038,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2006,6 +2055,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2022,6 +2072,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2038,6 +2089,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2054,6 +2106,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2070,6 +2123,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2086,6 +2140,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2102,6 +2157,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2118,6 +2174,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2134,6 +2191,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2150,6 +2208,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2166,6 +2225,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2182,6 +2242,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2198,6 +2259,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2214,6 +2276,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5076,6 +5139,10 @@ "resolved": "shared/database", "link": true }, + "node_modules/@wxyc/library-etl": { + "resolved": "jobs/library-etl", + "link": true + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -13136,6 +13203,7 @@ "license": "MIT", "dependencies": { "drizzle-orm": "^0.41.0", + "node-ssh": "^13.2.1", "postgres": "^3.4.4" }, "devDependencies": { From 3e2b999d4b58cef64ddeb7ea17b956a195dbbc09 Mon Sep 17 00:00:00 2001 From: Adrian Bruno Date: Thu, 12 Feb 2026 21:36:07 -0500 Subject: [PATCH 7/8] Use a description that makes sense --- jobs/library-etl/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jobs/library-etl/package.json b/jobs/library-etl/package.json index 3787506..7fc05ee 100644 --- a/jobs/library-etl/package.json +++ b/jobs/library-etl/package.json @@ -2,7 +2,7 @@ "name": "@wxyc/library-etl", "cron-schedule": "*/30 * * * *", "version": "1.0.0", - "description": "WXYC Authentication Service using Better Auth", + "description": "WXYC Legacy Library ETL Job", "type": "module", "main": "./dist/job.js", "scripts": { From bcb14faac3813938eb8d382d8f18b1a66fa8901d Mon Sep 17 00:00:00 2001 From: Adrian Bruno Date: Thu, 12 Feb 2026 21:36:18 -0500 Subject: [PATCH 8/8] Add coverage --- jest.unit.config.ts | 2 +- jobs/library-etl/job.ts | 13 ++ tests/unit/jobs/library-etl.test.ts | 278 ++++++++++++++++++++++++++++ 3 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 tests/unit/jobs/library-etl.test.ts diff --git a/jest.unit.config.ts b/jest.unit.config.ts index 405242e..51d6a41 100644 --- a/jest.unit.config.ts +++ b/jest.unit.config.ts @@ -22,7 +22,7 @@ const config: Config = { // Remove .js extensions from relative imports (ESM compatibility) '^(\\.{1,2}/.*)\\.(js)$': '$1', }, - collectCoverageFrom: ['apps/backend/**/*.ts', '!**/*.d.ts', '!**/dist/**'], + collectCoverageFrom: ['apps/backend/**/*.ts', 'jobs/**/*.ts', '!**/*.d.ts', '!**/dist/**'], clearMocks: true, }; diff --git a/jobs/library-etl/job.ts b/jobs/library-etl/job.ts index 40055ec..efdca68 100644 --- a/jobs/library-etl/job.ts +++ b/jobs/library-etl/job.ts @@ -434,6 +434,19 @@ const run = async () => { } }; +// Exported for unit testing +export { + parseTabRow, + toNullableString, + toNullableNumber, + isDbOnlyGenre, + normalizeArtistName, + normalizeCodeLetters, + parseFormatAndDiscs, + toDateOrUndefined, + toDateOnlyString, +}; + run().catch((error) => { console.error('[library-etl] Failed:', error); process.exitCode = 1; diff --git a/tests/unit/jobs/library-etl.test.ts b/tests/unit/jobs/library-etl.test.ts new file mode 100644 index 0000000..7cb6fe6 --- /dev/null +++ b/tests/unit/jobs/library-etl.test.ts @@ -0,0 +1,278 @@ +/** + * Unit tests for library-etl job helpers + * + * Tests the pure parsing and normalization functions used by the ETL. + * Database and legacy MirrorSQL are mocked so the job module can load. + */ + +const mockSend = jest.fn().mockResolvedValue(''); +const mockClose = jest.fn(); + +jest.mock('@wxyc/database', () => { + const chainResolve = (value: unknown = []) => ({ + select: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + limit: jest.fn().mockResolvedValue(value), + insert: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + onConflictDoUpdate: jest.fn().mockResolvedValue(undefined), + returning: jest.fn().mockResolvedValue([{ id: 1 }]), + }); + const dbChain = chainResolve([]); + return { + db: { + ...dbChain, + select: jest.fn().mockReturnValue({ + from: jest.fn().mockReturnValue({ + where: jest.fn().mockReturnValue({ + limit: jest.fn().mockResolvedValue([]), + }), + }), + }), + insert: jest.fn().mockReturnValue({ + values: jest.fn().mockReturnValue({ + onConflictDoUpdate: jest.fn().mockResolvedValue(undefined), + }), + }), + transaction: jest.fn().mockImplementation(async (cb: (tx: unknown) => Promise) => { + const tx = { + select: jest.fn().mockReturnValue({ from: jest.fn().mockResolvedValue([]) }), + insert: jest.fn().mockReturnValue({ + values: jest.fn().mockReturnValue({ + onConflictDoUpdate: jest.fn().mockResolvedValue(undefined), + returning: jest.fn().mockResolvedValue([{ id: 1 }]), + }), + }), + }; + return cb(tx); + }), + }, + MirrorSQL: { + instance: () => ({ send: mockSend, close: mockClose }), + }, + artists: {}, + genres: {}, + format: {}, + library: {}, + cronjob_runs: {}, + genre_artist_crossreference: {}, + closeDatabaseConnection: jest.fn().mockResolvedValue(undefined), + }; +}); + +jest.mock('drizzle-orm', () => ({ + eq: jest.fn((a: unknown, b: unknown) => ({ eq: [a, b] })), + and: jest.fn((...args: unknown[]) => ({ and: args })), + isNull: jest.fn((col: unknown) => ({ isNull: col })), +})); + +import { + parseTabRow, + toNullableString, + toNullableNumber, + isDbOnlyGenre, + normalizeArtistName, + normalizeCodeLetters, + parseFormatAndDiscs, + toDateOrUndefined, + toDateOnlyString, +} from '../../../jobs/library-etl/job'; + +describe('library-etl job helpers', () => { + describe('parseTabRow', () => { + it('returns array when column count matches', () => { + expect(parseTabRow('a\tb\tc', 3)).toEqual(['a', 'b', 'c']); + expect(parseTabRow('one', 1)).toEqual(['one']); + }); + + it('returns null when column count does not match', () => { + expect(parseTabRow('a\tb', 3)).toBeNull(); + expect(parseTabRow('a\tb\tc', 2)).toBeNull(); + expect(parseTabRow('', 2)).toBeNull(); // '' split gives [''], length 1 + }); + }); + + describe('toNullableString', () => { + it('returns trimmed non-empty string', () => { + expect(toNullableString(' hello ')).toBe('hello'); + expect(toNullableString('x')).toBe('x'); + }); + + it('returns null for empty or whitespace', () => { + expect(toNullableString('')).toBeNull(); + expect(toNullableString(' ')).toBeNull(); + expect(toNullableString('\t')).toBeNull(); + }); + + it('returns null for undefined', () => { + expect(toNullableString(undefined)).toBeNull(); + }); + }); + + describe('toNullableNumber', () => { + it('parses valid numeric strings', () => { + expect(toNullableNumber('42')).toBe(42); + expect(toNullableNumber('0')).toBe(0); + expect(toNullableNumber(' -5 ')).toBe(-5); + }); + + it('returns null for empty or invalid', () => { + expect(toNullableNumber('')).toBeNull(); + expect(toNullableNumber(' ')).toBeNull(); + expect(toNullableNumber('abc')).toBeNull(); + expect(toNullableNumber(undefined)).toBeNull(); + }); + + it('returns null for non-finite parse result', () => { + expect(toNullableNumber('NaN')).toBeNull(); + expect(toNullableNumber('Infinity')).toBeNull(); + }); + }); + + describe('isDbOnlyGenre', () => { + it('returns true for db_only (case insensitive)', () => { + expect(isDbOnlyGenre('db_only')).toBe(true); + expect(isDbOnlyGenre('DB_ONLY')).toBe(true); + expect(isDbOnlyGenre(' Db_Only ')).toBe(true); + }); + + it('returns false for other values', () => { + expect(isDbOnlyGenre('rock')).toBe(false); + expect(isDbOnlyGenre('')).toBe(false); + expect(isDbOnlyGenre(null)).toBe(false); + expect(isDbOnlyGenre(undefined)).toBe(false); + }); + }); + + describe('normalizeArtistName', () => { + it('normalizes "Various Artists - ..." to Various Artists', () => { + const r = normalizeArtistName('Various Artists - latin'); + expect(r.name).toBe('Various Artists'); + expect(r.isVarious).toBe(true); + }); + + it('is case insensitive for various artists prefix', () => { + const r = normalizeArtistName(' VARIOUS ARTISTS - Something '); + expect(r.name).toBe('Various Artists'); + expect(r.isVarious).toBe(true); + }); + + it('returns trimmed name and isVarious false for regular artists', () => { + const r = normalizeArtistName(' FKA twigs '); + expect(r.name).toBe('FKA twigs'); + expect(r.isVarious).toBe(false); + }); + + it('does not match without hyphen after "Various Artists"', () => { + const r = normalizeArtistName('Various Artists'); + expect(r.name).toBe('Various Artists'); + expect(r.isVarious).toBe(false); + }); + }); + + describe('normalizeCodeLetters', () => { + it('returns first two chars uppercased', () => { + expect(normalizeCodeLetters('ab')).toBe('AB'); + expect(normalizeCodeLetters('xyz')).toBe('XY'); + }); + + it('returns null for empty or null', () => { + expect(normalizeCodeLetters(null)).toBeNull(); + expect(normalizeCodeLetters('')).toBeNull(); + expect(normalizeCodeLetters(' ')).toBeNull(); + }); + + it('trims then takes first two', () => { + expect(normalizeCodeLetters(' xy ')).toBe('XY'); + }); + }); + + describe('parseFormatAndDiscs', () => { + describe('CD', () => { + it('parses "cd" as cd, 1 disc', () => { + expect(parseFormatAndDiscs('cd')).toEqual({ formatName: 'cd', discQuantity: 1 }); + expect(parseFormatAndDiscs('CD')).toEqual({ formatName: 'cd', discQuantity: 1 }); + }); + + it('parses "cd x 2" as cd, 2 discs', () => { + expect(parseFormatAndDiscs('cd x 2')).toEqual({ formatName: 'cd', discQuantity: 2 }); + }); + + it('parses "cd box" as cd, 1 disc', () => { + expect(parseFormatAndDiscs('cd box')).toEqual({ formatName: 'cd', discQuantity: 1 }); + }); + }); + + describe('CD-R', () => { + it('parses "cdr" as cdr, 1 disc', () => { + expect(parseFormatAndDiscs('cdr')).toEqual({ formatName: 'cdr', discQuantity: 1 }); + }); + }); + + describe('vinyl', () => { + it('parses "vinyl" as vinyl (no size), 1 disc', () => { + expect(parseFormatAndDiscs('vinyl')).toEqual({ formatName: 'vinyl', discQuantity: 1 }); + }); + + it('parses 7" as vinyl 7"', () => { + expect(parseFormatAndDiscs('vinyl 7"')).toEqual({ formatName: 'vinyl 7"', discQuantity: 1 }); + }); + + it('parses 10" as vinyl 10"', () => { + expect(parseFormatAndDiscs('vinyl 10"')).toEqual({ formatName: 'vinyl 10"', discQuantity: 1 }); + }); + + it('parses 12" or lp as vinyl 12"', () => { + expect(parseFormatAndDiscs('vinyl 12"')).toEqual({ formatName: 'vinyl 12"', discQuantity: 1 }); + expect(parseFormatAndDiscs('vinyl lp')).toEqual({ formatName: 'vinyl 12"', discQuantity: 1 }); + }); + + it('parses vinyl x 2 as 2 discs', () => { + expect(parseFormatAndDiscs('vinyl x 2')).toEqual({ formatName: 'vinyl', discQuantity: 2 }); + }); + + it('parses vinyl 12" x 2 as 2 discs', () => { + expect(parseFormatAndDiscs('vinyl 12" x 2')).toEqual({ formatName: 'vinyl 12"', discQuantity: 2 }); + }); + }); + + it('returns null for unsupported format', () => { + expect(parseFormatAndDiscs('cassette')).toBeNull(); + expect(parseFormatAndDiscs('digital')).toBeNull(); + expect(parseFormatAndDiscs('')).toBeNull(); + }); + }); + + describe('toDateOrUndefined', () => { + it('returns Date for valid timestamp', () => { + const ts = new Date('2024-02-01').getTime(); + const d = toDateOrUndefined(ts); + expect(d).toBeInstanceOf(Date); + expect(d?.getTime()).toBe(ts); + }); + + it('returns undefined for null', () => { + expect(toDateOrUndefined(null)).toBeUndefined(); + }); + + it('returns undefined for invalid timestamp', () => { + expect(toDateOrUndefined(Number.NaN)).toBeUndefined(); + }); + }); + + describe('toDateOnlyString', () => { + it('returns YYYY-MM-DD for valid timestamp', () => { + const ts = new Date('2024-02-01T15:30:00Z').getTime(); + expect(toDateOnlyString(ts)).toBe('2024-02-01'); + }); + + it('returns undefined for null', () => { + expect(toDateOnlyString(null)).toBeUndefined(); + }); + + it('returns undefined for invalid timestamp', () => { + expect(toDateOnlyString(Number.NaN)).toBeUndefined(); + }); + }); +});