diff --git a/.docs/_cron.md b/.docs/_cron.md index 760e548..231648e 100644 --- a/.docs/_cron.md +++ b/.docs/_cron.md @@ -3,32 +3,46 @@ You have to allow two extensions: `pg_cron`, `pg_net` for a database to make this work. See [Supabase Documentation](https://supabase.com/docs/guides/cron). -You have to run the following commands in SQL Editor of your Supabase project. +## How cron jobs are deployed -## Generate cron tab +Cron jobs are defined in code and deployed automatically as SQL migrations via +`supabase db push` in CI. No manual SQL editor steps required. -1. Fill out root `.env` file with variables -2. run tests: `deno run tests` -3. cron install queries will be generated into the file `cron_jobs.sql` in the - root -4. visit supabase dashboard and run these queries in SQL editor +The anon key is **never stored in the repository**. It is fetched from the +Supabase Management API at deploy time and stored in Supabase Vault +(`vault.secrets`), where pg_cron reads it at runtime. -## GUI +See [decision record 003](decision-records/003-cron-vault-secrets.md) for full details. -There is actually a UI where you can manage your jobs very easily. +## Adding or changing a cron job -``` -https://supabase.com/dashboard/project/***/integrations/cron/jobs -``` +1. Edit the `CronDefinition` in the relevant Edge Function: + - `supabase/functions/lastfm-user-recent-tracks/sync-tracks.ts` → `recentTracksCron` + - `supabase/functions/lastfm-library-artists/sync-artists.ts` → `libraryArtistsCron` +2. Add or remove users in `supabase/functions/_shared/users.ts` → `LASTFM_USERS` +3. Generate and stage the migration: + ```bash + make cron + ``` +4. Commit and push — CI applies it via `supabase db push` -_Replace your project ID in the URL_. +## Local development -## Remove existing job - -Replace ID by number from your database of already existing cron. +Seed the vault secret once after starting local Supabase: ```bash -select cron.unschedule(3); +# Get the local anon key from: +deno task start # then check output of: supabase status + +psql $DATABASE_URL -c "SELECT vault.create_secret('', 'supabase_anon_key', 'Supabase anon key for cron HTTP requests');" +``` + +## GUI + +Jobs can also be inspected and managed via the Supabase dashboard: + +``` +https://supabase.com/dashboard/project/***/integrations/cron/jobs ``` -Or you can do this via GUI as well. +_Replace your project ID in the URL._ diff --git a/.docs/decision-records/000-TEMPLATE.md b/.docs/decision-records/000-TEMPLATE.md new file mode 100644 index 0000000..44cdffb --- /dev/null +++ b/.docs/decision-records/000-TEMPLATE.md @@ -0,0 +1,23 @@ +# Title + + + +## Status + + + +Proposed + +## Context + + + +## Decision + + + +## Consequences + + diff --git a/.docs/decision-records/003-cron-vault-secrets.md b/.docs/decision-records/003-cron-vault-secrets.md new file mode 100644 index 0000000..4567eba --- /dev/null +++ b/.docs/decision-records/003-cron-vault-secrets.md @@ -0,0 +1,91 @@ +# 003 — Cron Jobs Use Supabase Vault for Secrets (No Hardcoded Keys in Public Repo) + +**Date:** 2026-03-27 +**Status:** Active + +--- + +## Problem + +The `cron_jobs.sql` file contained the Supabase `anon` key hardcoded as a bearer token in the HTTP headers of every `net.http_post` call. This file was committed to a public GitHub repository, exposing the key publicly. + +Additionally, cron jobs were not deployed automatically — `cron_jobs.sql` was a manual artifact that had to be applied by hand, with no CI integration. + +--- + +## Decision + +Store the Supabase `anon` key in **Supabase Vault** (`vault.secrets`) and reference it at cron execution time via `vault.decrypted_secrets`. Cron job definitions live as a standard migration under `supabase/migrations/` and are applied automatically via `supabase db push` in CI. + +The vault secret is upserted by the CI pipeline itself (via `psql` on the self-hosted runner) — before `supabase db push` runs. The anon key is fetched at deploy time from the Supabase Management API using `SUPABASE_ACCESS_TOKEN` (already required for deployment), so no additional GitHub secret is needed. The migration file contains no secret values and is safe to commit. + +--- + +## Architecture + +``` +Supabase Management API + └── projects api-keys (fetched via SUPABASE_ACCESS_TOKEN at deploy time) + │ + ▼ (psql upsert step, before db push) +Supabase Vault + └── vault.secrets { name: 'supabase_anon_key', secret: '' } + │ + ▼ (read at cron execution time by pg_cron) +pg_cron job SQL + └── SELECT decrypted_secret FROM vault.decrypted_secrets WHERE name = 'supabase_anon_key' +``` + +The migration file stores the cron SQL as a string. pg_cron evaluates it at runtime, so the vault lookup happens each time the cron fires — not at migration time. + +--- + +## Why anon key and not service_role key + +Cron jobs call Edge Functions via their public HTTPS endpoint. Edge Functions authenticate callers using the anon key (JWT), not the service_role key. The service_role key bypasses RLS at the DB level and is never needed for HTTP-level invocation. Only the anon key is stored in vault for cron use. + +--- + +## Changing Crons Over Time + +Cron definitions live in `CronDefinition` objects in each Edge Function's sync file. Users are defined in `supabase/functions/_shared/users.ts`. + +After any change, generate and stage a new migration: + +```bash +make cron +``` + +This runs `scripts/generate-crons.ts`, which writes a timestamped migration to `supabase/migrations/` and prints the path. The Makefile stages it with `git add`. Every generated migration unschedules all existing jobs first, then reschedules them — so no stale jobs can accumulate. + +--- + +## Local Development + +When working locally with Supabase, seed the vault secret once: + +```bash +psql $DATABASE_URL -c "SELECT vault.create_secret('your-local-anon-key', 'supabase_anon_key', 'Supabase anon key for cron HTTP requests');" +``` + +The local anon key is found in the output of `supabase status` after `deno task start`. + +--- + +## Security Properties + +| Property | Before | After | +|---------------------------------|------------------------|-----------------------------------| +| Anon key in public repo | Yes (hardcoded in SQL) | No | +| Anon key in CI logs | N/A | No (GitHub masked secret) | +| Anon key at rest | Plaintext in git | Encrypted in vault (pgsodium) | +| Cron deployment | Manual | Automatic via `supabase db push` | +| Self-hosted runner IP whitelist | N/A | DB accessible only from static IP | + +--- + +## Alternatives Considered + +**Template + envsubst in CI** — Replace `${SUPABASE_ANON_KEY}` at CI time and generate the migration file. Rejected: adds fragility (file generation in CI), and the secret is still handled in plaintext in the shell. Vault keeps the secret encrypted at rest in the DB itself. + +**`current_setting()` via `ALTER DATABASE SET`** — Store key as a DB-level setting. Rejected: not encrypted at rest; visible in `pg_settings`. diff --git a/.docs/decision-records/README.md b/.docs/decision-records/README.md new file mode 100644 index 0000000..ddd7380 --- /dev/null +++ b/.docs/decision-records/README.md @@ -0,0 +1,5 @@ +# Decision Records + +- [001](001-artist-upsert-anon-role.md) — Artist upsert performance regression caused by missing service role key (anon role fallback) +- [002](002-reduce-egress.md) — Supabase egress reduction after hitting free-tier bandwidth limit +- [003](003-cron-vault-secrets.md) — Cron jobs use Supabase Vault for secrets; no hardcoded keys in public repo diff --git a/.github/workflows/deploy-supabase.yml b/.github/workflows/deploy-supabase.yml index ede3c0b..bae40d1 100644 --- a/.github/workflows/deploy-supabase.yml +++ b/.github/workflows/deploy-supabase.yml @@ -38,6 +38,29 @@ jobs: npx @sentry/cli releases set-commits ${{ github.sha }} --auto npx @sentry/cli releases deploys ${{ github.sha }} new -e production + - name: "Supabase - Upsert vault secrets" + env: + PROJECT_ID: ${{ secrets.SUPABASE_PROJECT_ID }} + SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} + SUPABASE_DB_PASSWORD: ${{ secrets.SUPABASE_DB_PASSWORD }} + shell: bash + run: | + ANON_KEY=$(supabase projects api-keys --project-ref "$PROJECT_ID" --output json | jq -r '.[] | select(.name == "anon") | .api_key') + DATABASE_URL="postgresql://postgres:${SUPABASE_DB_PASSWORD}@db.${PROJECT_ID}.supabase.co:5432/postgres" + psql "$DATABASE_URL" </dev/null | sort | tail -1); \ - if [ -z "$$last" ]; then next="001"; \ - else next=$$(basename "$$last" | sed 's/-.*//' | awk '{printf "%03d", $$1 + 1}'); fi; \ - file=".docs/decision-records/$${next}-$${slug}.md"; \ - cp .docs/decision-records/000-TEMPLATE.md "$$file"; \ + next=$$(date +%Y%m%d-%H%M); \ + file="$(DOC_DECISIONS_FOLDER)/$${next}-$${slug}.md"; \ + cp $(DOC_DECISIONS_FOLDER)/000-TEMPLATE.md "$$file"; \ basename=$$(basename "$$file"); \ - echo "- [$${next}]($$basename) — $$title" >> .docs/decision-records/README.md; \ - git add "$$file" .docs/decision-records/README.md; \ + echo "- [$${next}]($$basename) — $$title" >> $(DOC_DECISIONS_FOLDER)/README.md; \ + git add "$$file" $(DOC_DECISIONS_FOLDER)/README.md; \ echo "$(GREEN)Created: $$file$(END)"; \ if [ -z "$(TITLE)" ]; then \ case "$$(uname)" in \ @@ -117,11 +121,16 @@ tests: deno task tests # Database -.PHONY: migration migrate update-types-local update-types-linked +.PHONY: migration cron migrate update-types-local update-types-linked migration: deno task migration $(name) +cron: ## Generate cron migration from source (requires PROJECT_ID in .env) + @file=$$(deno run --allow-write=supabase/migrations --allow-env=PROJECT_ID scripts/generate-crons.ts); \ + git add "$$file"; \ + echo "$(GREEN)Created:$(RESET) file://$(CURDIR)/$$file" + migrate: deno task migrate diff --git a/scripts/generate-crons.ts b/scripts/generate-crons.ts new file mode 100644 index 0000000..877707b --- /dev/null +++ b/scripts/generate-crons.ts @@ -0,0 +1,30 @@ +import { buildCron, buildUnschedule } from "../supabase/functions/_shared/cron.ts"; +import { recentTracksCron } from "../supabase/functions/lastfm-user-recent-tracks/sync-tracks.ts"; +import { libraryArtistsCron } from "../supabase/functions/lastfm-library-artists/sync-artists.ts"; +import { LASTFM_USERS } from "../supabase/functions/_shared/users.ts"; + +const PROJECT_ID = Deno.env.get("PROJECT_ID"); +if (!PROJECT_ID) throw new Error("Missing environment variable: PROJECT_ID"); + +const unschedule = LASTFM_USERS.flatMap((user) => [ + buildUnschedule(recentTracksCron, user), + buildUnschedule(libraryArtistsCron, user), +]); + +const schedule = LASTFM_USERS.flatMap((user) => [ + buildCron(recentTracksCron, PROJECT_ID, user), + buildCron(libraryArtistsCron, PROJECT_ID, user), +]); + +const output = [ + "-- unschedule all existing cron jobs before reapplying", + unschedule.join(""), + "-- schedule cron jobs", + schedule.join("\n"), +].join("\n"); + +const timestamp = new Date().toISOString().replace(/\D/g, "").slice(0, 14); +const migrationFile = `supabase/migrations/${timestamp}_cron-jobs.sql`; + +await Deno.writeTextFile(migrationFile, output); +console.log(migrationFile); diff --git a/supabase/functions/_shared/cron.ts b/supabase/functions/_shared/cron.ts index 3888030..b156693 100644 --- a/supabase/functions/_shared/cron.ts +++ b/supabase/functions/_shared/cron.ts @@ -1,50 +1,54 @@ -interface CronProps { - projectId: string; - publishableKey: string; +import type { LastFmUser } from "./users.ts"; + +export interface CronDefinition { + schedule: string; + jobName: (user: LastFmUser) => string; edgeFunctionFolderName: string; - uniqueCronJobName: string; - cronTabTiming: string; - body?: object; - headers?: object; + body: (user: LastFmUser) => object; } /** - * Util to easily build type-safe cron syntax. + * Generates SQL to safely remove a cron job if it exists. + * Uses jobid lookup so it is a no-op when the job is not present. */ -export function buildCron(props: CronProps): string { - const { - projectId, - publishableKey, - edgeFunctionFolderName, - uniqueCronJobName, - cronTabTiming, - body, - headers, - } = props; +export function buildUnschedule( + def: CronDefinition, + user: LastFmUser, +): string { + return `select cron.unschedule(jobid) from cron.job where jobname = '${ + def.jobName(user) + }';\n`; +} - const finalHeaders = { - ...{ - "Content-Type": "application/json", - "Authorization": `Bearer ${publishableKey}`, - }, - ...headers, - }; +/** + * Generates cron schedule SQL for a given definition and user. + * + * The anon key is never inlined — it is read at runtime from Supabase Vault + * (vault.decrypted_secrets WHERE name = 'supabase_anon_key'), so the + * generated SQL is safe to commit to a public repository. + */ +export function buildCron( + def: CronDefinition, + projectId: string, + user: LastFmUser, +): string { const finalBody = { - ...{ - time: "', now(),'", - }, - ...body, + time: "', now(),'", + ...def.body(user), }; return ` select cron.schedule( - '${uniqueCronJobName}', - '${cronTabTiming}', + '${def.jobName(user)}', + '${def.schedule}', $$ select net.http_post( - url:='https://${projectId}.supabase.co/functions/v1/${edgeFunctionFolderName}', - headers:='${JSON.stringify(finalHeaders)}'::jsonb, + url:='https://${projectId}.supabase.co/functions/v1/${def.edgeFunctionFolderName}', + headers:=jsonb_build_object( + 'Content-Type', 'application/json', + 'Authorization', 'Bearer ' || (select decrypted_secret from vault.decrypted_secrets where name = 'supabase_anon_key') + ), body:=concat('${JSON.stringify(finalBody)}')::jsonb ) as request_id; $$ diff --git a/supabase/functions/_shared/users.ts b/supabase/functions/_shared/users.ts new file mode 100644 index 0000000..b32ab75 --- /dev/null +++ b/supabase/functions/_shared/users.ts @@ -0,0 +1,7 @@ +/** + * Last.fm users tracked by this application. + * Used in cron job definitions and anywhere a user list is needed. + */ +export const LASTFM_USERS = ["Insuit", "Weinkaa"] as const; + +export type LastFmUser = (typeof LASTFM_USERS)[number]; diff --git a/supabase/functions/lastfm-library-artists/index.ts b/supabase/functions/lastfm-library-artists/index.ts index 004a51b..0c0113b 100644 --- a/supabase/functions/lastfm-library-artists/index.ts +++ b/supabase/functions/lastfm-library-artists/index.ts @@ -1,7 +1,7 @@ import { SentryErrorHandler } from "../_shared/sentry.ts"; import { env } from "../_shared/env.ts"; import { getLastFmUser } from "../_shared/lastfm/request-fields.ts"; -import { LIBRARY_ARTISTS_CRON_SCHEDULE, syncArtists } from "./sync-artists.ts"; +import { libraryArtistsCron, syncArtists } from "./sync-artists.ts"; const sentryHandler = new SentryErrorHandler(env.DEVELOPER_MODE); sentryHandler.init(); @@ -9,12 +9,10 @@ sentryHandler.init(); Deno.serve(async (req) => { try { const lastFmUser = await getLastFmUser(req, env.LASTFM_USERNAME); - const monitorSlug = `lastfm_library_artists_${lastFmUser.toLowerCase()}`; - const result = await sentryHandler.withCronMonitor( - monitorSlug, + libraryArtistsCron.jobName(lastFmUser), { - schedule: { type: "crontab", value: LIBRARY_ARTISTS_CRON_SCHEDULE }, + schedule: { type: "crontab", value: libraryArtistsCron.schedule }, checkinMargin: 10, maxRuntime: 25, timezone: "UTC", diff --git a/supabase/functions/lastfm-library-artists/sync-artists.ts b/supabase/functions/lastfm-library-artists/sync-artists.ts index efa4c47..f664187 100644 --- a/supabase/functions/lastfm-library-artists/sync-artists.ts +++ b/supabase/functions/lastfm-library-artists/sync-artists.ts @@ -8,7 +8,7 @@ import { ArtistRow, ArtistTable } from "../_shared/db/db.artist.ts"; import { delay, notEmptyOrNull } from "../_shared/utils.ts"; import { HoomanArtistTable } from "../_shared/db/db.hooman_artist.ts"; import { HoomanTable } from "../_shared/db/db.hooman.ts"; -import { buildCron } from "../_shared/cron.ts"; +import type { CronDefinition } from "../_shared/cron.ts"; /** * main function @@ -157,21 +157,9 @@ async function pairArtistWithHooman( } } -export const LIBRARY_ARTISTS_CRON_SCHEDULE = "0 */2 * * *"; - -/** cron job for the edge function */ -export const lastFmLibraryArtistsCron = ( - projectId: string, - publishableKey: string, - lastFmUser: string, -) => - buildCron({ - projectId, - publishableKey, - edgeFunctionFolderName: "lastfm-library-artists", - uniqueCronJobName: `lastfm_library_artists_${lastFmUser.toLowerCase()}`, - cronTabTiming: LIBRARY_ARTISTS_CRON_SCHEDULE, - body: { - lastFmUser: lastFmUser, - }, - }); +export const libraryArtistsCron: CronDefinition = { + schedule: "0 */2 * * *", + jobName: (user) => `lastfm_library_artists_${user.toLowerCase()}`, + edgeFunctionFolderName: "lastfm-library-artists", + body: (user) => ({ lastFmUser: user }), +}; diff --git a/supabase/functions/lastfm-user-recent-tracks/index.ts b/supabase/functions/lastfm-user-recent-tracks/index.ts index 9d01b52..87ca6bb 100644 --- a/supabase/functions/lastfm-user-recent-tracks/index.ts +++ b/supabase/functions/lastfm-user-recent-tracks/index.ts @@ -1,7 +1,7 @@ import { env } from "../_shared/env.ts"; import { SentryErrorHandler } from "../_shared/sentry.ts"; import { getLastFmUser } from "../_shared/lastfm/request-fields.ts"; -import { RECENT_TRACKS_CRON_SCHEDULE, syncTracks } from "./sync-tracks.ts"; +import { recentTracksCron, syncTracks } from "./sync-tracks.ts"; const sentryHandler = new SentryErrorHandler(env.DEVELOPER_MODE); sentryHandler.init(); @@ -9,12 +9,10 @@ sentryHandler.init(); Deno.serve(async (req) => { try { const lastFmUser = await getLastFmUser(req, env.LASTFM_USERNAME); - const monitorSlug = `lastfm_user_recent_tracks_${lastFmUser.toLowerCase()}`; - const result = await sentryHandler.withCronMonitor( - monitorSlug, + recentTracksCron.jobName(lastFmUser), { - schedule: { type: "crontab", value: RECENT_TRACKS_CRON_SCHEDULE }, + schedule: { type: "crontab", value: recentTracksCron.schedule }, checkinMargin: 5, maxRuntime: 4, timezone: "UTC", diff --git a/supabase/functions/lastfm-user-recent-tracks/sync-tracks.ts b/supabase/functions/lastfm-user-recent-tracks/sync-tracks.ts index a7b7e90..3ac35a1 100644 --- a/supabase/functions/lastfm-user-recent-tracks/sync-tracks.ts +++ b/supabase/functions/lastfm-user-recent-tracks/sync-tracks.ts @@ -4,7 +4,7 @@ import { HoomanTable } from "../_shared/db/db.hooman.ts"; import { ListenedRow, ListenedTable } from "../_shared/db/db.listened.ts"; import { getRecentTracks } from "../_shared/lastfm/user-recent-tracks.ts"; import { notEmptyOrNull } from "../_shared/utils.ts"; -import { buildCron } from "../_shared/cron.ts"; +import type { CronDefinition } from "../_shared/cron.ts"; /** * Sync data from Last.fm to Supabase Database. @@ -134,21 +134,9 @@ export async function syncTracks( return "ok"; } -export const RECENT_TRACKS_CRON_SCHEDULE = "*/5 * * * *"; - -/** cron job for the edge function */ -export const lastFmUserRecentTracksCron = ( - projectId: string, - publishableKey: string, - lastFmUser: string, -) => - buildCron({ - projectId, - publishableKey, - edgeFunctionFolderName: "lastfm-user-recent-tracks", - uniqueCronJobName: `lastfm_user_recent_tracks_${lastFmUser.toLowerCase()}`, - cronTabTiming: RECENT_TRACKS_CRON_SCHEDULE, - body: { - lastFmUser: lastFmUser, - }, - }); +export const recentTracksCron: CronDefinition = { + schedule: "*/5 * * * *", + jobName: (user) => `lastfm_user_recent_tracks_${user.toLowerCase()}`, + edgeFunctionFolderName: "lastfm-user-recent-tracks", + body: (user) => ({ lastFmUser: user }), +}; diff --git a/supabase/functions/tests/cron-generate-test.ts b/supabase/functions/tests/cron-generate-test.ts deleted file mode 100644 index 61ac4ce..0000000 --- a/supabase/functions/tests/cron-generate-test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import "@std/dotenv/load"; -import { lastFmUserRecentTracksCron } from "../lastfm-user-recent-tracks/sync-tracks.ts"; -import { lastFmLibraryArtistsCron } from "../lastfm-library-artists/sync-artists.ts"; - -/** - * Use Deno tests to generate us the file. - */ -Deno.test(async function generateCronJobQuery(t) { - const env = { - PROJECT_ID: Deno.env.get("PROJECT_ID")!, - PROJECT_PUBLISHABLE_KEY: Deno.env.get("PROJECT_PUBLISHABLE_KEY")!, - }; - - await t.step("verify env variables exist", () => - Object.keys(env).forEach( - (key) => - Deno.env.get(key) || (() => { - throw new Error(`Missing environment variable: ${key}`); - })(), - )); - - const cronJobs = [ - lastFmUserRecentTracksCron( - env.PROJECT_ID, - env.PROJECT_PUBLISHABLE_KEY, - "Insuit", - ), - lastFmUserRecentTracksCron( - env.PROJECT_ID, - env.PROJECT_PUBLISHABLE_KEY, - "Weinkaa", - ), - lastFmLibraryArtistsCron( - env.PROJECT_ID, - env.PROJECT_PUBLISHABLE_KEY, - "Insuit", - ), - lastFmLibraryArtistsCron( - env.PROJECT_ID, - env.PROJECT_PUBLISHABLE_KEY, - "Weinkaa", - ), - ]; - - const output = "--- Here are your CRON jobs, add them to database:\n" + - cronJobs.join("\n"); - - const outputFile = "cron_jobs.sql"; - - Deno.writeTextFile(outputFile, output).then(() => { - console.log( - `Open file: ${outputFile} and put CRON job queries to your database.`, - ); - }); - - try { - await Deno.stat(outputFile); - } catch (error) { - if (error instanceof Deno.errors.NotFound) { - throw new Error(`${outputFile} does not exist`); - } - throw error; - } -}); diff --git a/supabase/functions/tests/cron-test.ts b/supabase/functions/tests/cron-test.ts index 8cfaced..7c9fc41 100644 --- a/supabase/functions/tests/cron-test.ts +++ b/supabase/functions/tests/cron-test.ts @@ -1,44 +1,51 @@ import { assertEquals } from "@std/assert"; -import { buildCron } from "../_shared/cron.ts"; +import { buildCron, buildUnschedule, CronDefinition } from "../_shared/cron.ts"; -Deno.test("buildCron should generate correct cron schedule SQL", () => { - const props = { - projectId: "myProject", - publishableKey: "abc123", - edgeFunctionFolderName: "myEdgeFunc", - uniqueCronJobName: "jobName", - cronTabTiming: "*/5 * * * *", - body: { lastFmUser: "john" }, - headers: { "X-Custom": "customValue" }, - }; +const testCron: CronDefinition = { + schedule: "*/5 * * * *", + jobName: (user) => `test_job_${user.toLowerCase()}`, + edgeFunctionFolderName: "my-edge-func", + body: (user) => ({ lastFmUser: user }), +}; - const expectedHeaders = { - "Content-Type": "application/json", - "Authorization": "Bearer abc123", - "X-Custom": "customValue", - }; +Deno.test("buildCron generates correct cron schedule SQL", () => { + const result = buildCron(testCron, "myProject", "Insuit"); - const expectedBody = { - time: "', now(),'", - lastFmUser: "john", - }; + const expectedBody = { time: "', now(),'", lastFmUser: "Insuit" }; const expected = ` select cron.schedule( - 'jobName', + 'test_job_insuit', '*/5 * * * *', $$ select net.http_post( - url:='https://myProject.supabase.co/functions/v1/myEdgeFunc', - headers:='${JSON.stringify(expectedHeaders)}'::jsonb, + url:='https://myProject.supabase.co/functions/v1/my-edge-func', + headers:=jsonb_build_object( + 'Content-Type', 'application/json', + 'Authorization', 'Bearer ' || (select decrypted_secret from vault.decrypted_secrets where name = 'supabase_anon_key') + ), body:=concat('${JSON.stringify(expectedBody)}')::jsonb ) as request_id; $$ ); `; - const result = buildCron(props); assertEquals(result, expected); }); + +Deno.test("buildCron lowercases user in job name", () => { + const lower = buildCron(testCron, "myProject", "Insuit"); + const upper = buildCron(testCron, "myProject", "Insuit"); + assertEquals(lower, upper); + assertEquals(lower.includes("test_job_insuit"), true); +}); + +Deno.test("buildUnschedule generates safe no-op SQL", () => { + const result = buildUnschedule(testCron, "Insuit"); + assertEquals( + result, + "select cron.unschedule(jobid) from cron.job where jobname = 'test_job_insuit';\n", + ); +}); diff --git a/supabase/migrations/20260423122237_cron-jobs.sql b/supabase/migrations/20260423122237_cron-jobs.sql new file mode 100644 index 0000000..d79cf7c --- /dev/null +++ b/supabase/migrations/20260423122237_cron-jobs.sql @@ -0,0 +1,78 @@ +-- unschedule all existing cron jobs before reapplying +select cron.unschedule(jobid) from cron.job where jobname = 'lastfm_user_recent_tracks_insuit'; +select cron.unschedule(jobid) from cron.job where jobname = 'lastfm_library_artists_insuit'; +select cron.unschedule(jobid) from cron.job where jobname = 'lastfm_user_recent_tracks_weinkaa'; +select cron.unschedule(jobid) from cron.job where jobname = 'lastfm_library_artists_weinkaa'; + +-- schedule cron jobs + +select + cron.schedule( + 'lastfm_user_recent_tracks_insuit', + '*/5 * * * *', + $$ + select + net.http_post( + url:='https://qwvhazhlyjtgnfukdztm.supabase.co/functions/v1/lastfm-user-recent-tracks', + headers:=jsonb_build_object( + 'Content-Type', 'application/json', + 'Authorization', 'Bearer ' || (select decrypted_secret from vault.decrypted_secrets where name = 'supabase_anon_key') + ), + body:=concat('{"time":"', now(),'","lastFmUser":"Insuit"}')::jsonb + ) as request_id; + $$ + ); + + +select + cron.schedule( + 'lastfm_library_artists_insuit', + '0 */2 * * *', + $$ + select + net.http_post( + url:='https://qwvhazhlyjtgnfukdztm.supabase.co/functions/v1/lastfm-library-artists', + headers:=jsonb_build_object( + 'Content-Type', 'application/json', + 'Authorization', 'Bearer ' || (select decrypted_secret from vault.decrypted_secrets where name = 'supabase_anon_key') + ), + body:=concat('{"time":"', now(),'","lastFmUser":"Insuit"}')::jsonb + ) as request_id; + $$ + ); + + +select + cron.schedule( + 'lastfm_user_recent_tracks_weinkaa', + '*/5 * * * *', + $$ + select + net.http_post( + url:='https://qwvhazhlyjtgnfukdztm.supabase.co/functions/v1/lastfm-user-recent-tracks', + headers:=jsonb_build_object( + 'Content-Type', 'application/json', + 'Authorization', 'Bearer ' || (select decrypted_secret from vault.decrypted_secrets where name = 'supabase_anon_key') + ), + body:=concat('{"time":"', now(),'","lastFmUser":"Weinkaa"}')::jsonb + ) as request_id; + $$ + ); + + +select + cron.schedule( + 'lastfm_library_artists_weinkaa', + '0 */2 * * *', + $$ + select + net.http_post( + url:='https://qwvhazhlyjtgnfukdztm.supabase.co/functions/v1/lastfm-library-artists', + headers:=jsonb_build_object( + 'Content-Type', 'application/json', + 'Authorization', 'Bearer ' || (select decrypted_secret from vault.decrypted_secrets where name = 'supabase_anon_key') + ), + body:=concat('{"time":"', now(),'","lastFmUser":"Weinkaa"}')::jsonb + ) as request_id; + $$ + );