Cron, vault and pg_net
Lesson 2 · about 15 minutes · how Postgres calls our own edge functions on a schedule, and why that path failed silently for a week.
Direction B on the system map. Nothing outside Postgres triggers the price refreshes, the nightly backfills or the Slack alerts. A cron extension fires SQL, the SQL asks an HTTP extension to call an edge function, and the secrets it needs live in an encrypted table. Three extensions, one wrapper function, and one invariant that a test now enforces because a human learned it the hard way.
The three extensions
| Extension | What it gives SQL | Where you meet it |
|---|---|---|
pg_cron | cron.schedule(name, crontab, sql) and cron.unschedule(name). Jobs live in cron.job; runs in cron.job_run_details. | 43 migrations call cron.schedule |
pg_net | net.http_post(url, headers, body). Asynchronous: it queues the request and returns a request id immediately. The response, including a 403, lands later in net._http_response. | private.post_to_edge_function |
| Vault | vault.decrypted_secrets: a view that decrypts on read. Holds supabase_url and supabase_service_role_key, plus per-user SnapTrade secrets by id. | migration 20251117191248, 20260309195344 |
current_setting('app.supabase_url') was the first attempt; it threw "unrecognized configuration parameter" in prod. Migration 20251117191248 unscheduled every job and rebuilt them on vault. Vault is the only secret store SQL can reach. migrations/20251117191248_update_cron_jobs_to_use_vault_secrets.sql:1-6The path, step by step
net.http_post (older jobs) or a bare SELECT some_wrapper(); (newer jobs). Before scheduling, the migration unschedules by name inside DO $$ … EXCEPTION WHEN OTHERS THEN NULL END $$ so a fresh database does not error. migrations/20260612183405_schedule_news_refresh.sql:10-33postgres role. If the SQL returns without raising, cron.job_run_details records succeeded. That word means "the SQL ran", not "the HTTP call worked". migrations/20260714200657:11-14private.post_to_edge_function(path, body). It reads the URL and the service_role key from vault, adds Content-Type and the mandatory Origin: https://api.wallstreetbets.com, and calls net.http_post. It is SECURITY DEFINER so it can read vault, and execute is revoked from public, anon and authenticated because it forwards the service key to any path a caller names. migrations/20260714200640_route_intraday_refresh_through_wrapper_functions.sql:38-76validateServiceRole accepts the bearer. _shared/cors.ts · _shared/jwt/default.tsnet._http_response keyed by the request id. Nobody reads that table unless they go looking. A 403 here and a "succeeded" in step 2 are perfectly compatible.The week the intraday tier went empty
July 2026. Migration 20260707200103 rescheduled the four intraday refresh jobs to fix an interval-type bug. It rewrote the job bodies from scratch and left out the Origin header. Every call 403'd at the CORS gate, before the handler. pg_cron logged success on every run. The same day another migration retired the tier's other writer, so the two-day retention sweep drained the table to nothing and one-day charts were blank for a week. migrations/20260714200640:4-17
Three fixes came out of it, and they are the shape of how this repo hardens things:
| Fix | Mechanism | Where |
|---|---|---|
| Move the header out of the thing a reschedule rewrites | Jobs call a wrapper function; the wrapper calls post_to_edge_function, which owns the header. A reschedule rewrites the cron command, not a function body. | 20260714200640 |
| Make the invariant a test | cron-job-integrity.test.sql scans both cron commands and function bodies for any net.http_post to a CORS-enforcing function that lacks an Origin, and also checks every SELECT foo(); job names a function that exists. | tests/database/cron-job-integrity.test.sql |
| Watch the data, not the transport | check_price_ingestion_health() reports how stale the newest bar is per tier. A runner every 15 minutes records history in private.price_ingestion_health, suppresses intraday alerts outside 04:00–20:00 ET, and rate-limits to one alert per tier per four hours. | 20260714200657 |
The health runner posts to a vault secret named ops_alert_webhook_url. Migration 20260925104500 states that secret was never created, so today the runner records history and raises a WARNING instead of posting. The news draft alert was fixed by routing through the notify-slack edge function, which does have the URL as an env var. If you wire ops alerts, take the same route.
news.notify_draft_created() runs AFTER INSERT on articles. post_to_edge_function raises if supabase_url is missing from vault; without the BEGIN … EXCEPTION block that raise would roll back the article insert. The article is the point, the alert is not. migrations/20260925104500_route_slack_alerts_via_edge_function.sql:21-35Check yourself
Answer from memory. The mechanisms matter more than the names.
Do this in the repo
- List the scheduled jobs on your local stack and spot which ones inline
net.http_postversus call a wrapper:psql postgresql://postgres:postgres@localhost:54322/postgres -c "select jobname, schedule, left(command, 60) from cron.job order by jobname" - Run the guard that would have caught the July outage, then read its two assertions:
cd src && npx supabase test db --debug 2>&1 | grep -A2 cron-job-integrity(or opensrc/supabase/tests/database/cron-job-integrity.test.sql). - Call the health check by hand and predict which tiers will be unhealthy on a fresh local database:
select * from check_price_ingestion_health();
Primary source
Supabase: pg_net, specifically the section on reading responses. Then pg_cron for cron.job_run_details.
Next: Lesson 3, auth tiers: what the service_role bearer in step 4 actually proves, and the two overlapping key systems the stocks function accepts.
Ask your teacher in the terminal: "show me a job body that still inlines net.http_post" or "why is the intraday threshold 30 minutes" are good questions.