# 함수

서버 없이 요청 하나를 받아 답하는 코드예요. 결제사 웹훅 받기, 비밀 키가 필요한 계산, 예약 작업의 본체로 써요.

```
list_functions        배포한 함수와 호출 주소
get_function          지금 코드 (고치기 전에 먼저 읽으세요)
deploy_function       배포 (같은 이름이면 통째로 교체, 다음 호출부터 적용)
set_function_public   공개 여부만 바꾸기
set_function_limits   시간 한도 바꾸기 (호출 하나, SQL 한 문장)
remove_function       삭제
list_function_secrets   함수 시크릿 이름 (값은 돌려주지 않음)
set_function_secrets    함수 시크릿 저장 (같은 이름이면 덮어씀)
copy_schema_from_project      다른 프로젝트의 표와 구조(함수·뷰·정책 포함)를 이 프로젝트에 만들기
copy_data_from_project        다른 프로젝트의 표 행을 이 프로젝트로 복사(없는 행만, 또는 변경분까지)
copy_users_from_project       다른 프로젝트의 로그인 사용자를 복사(해시째, 서버 안에서)
compare_projects_data       두 프로젝트의 표를 행 수·해시로 비교(복사가 빠짐없이 됐는지)
get_project_forward         공개 함수 호출을 다른 프로젝트가 대신 받고 있는지 보기
set_project_forward         공개 함수 호출을 다른 프로젝트가 대신 받게 하기(나눈 뒤 웹훅이 끊기지 않게)
set_project_write_lock      프로젝트를 잠시 읽기 전용으로 잠그기(나누기 마지막 복사 직전, 저절로 풀려요)
get_project_write_lock      쓰기 잠금 보기
copy_functions_from_project  다른 프로젝트의 함수를 코드째 복사(비공개로)
copy_function_secrets   다른 프로젝트의 함수 시크릿을 이름으로 복사
delete_function_secret  함수 시크릿 하나 지우기
```

코드는 파일 하나예요:

```ts
export default async (request, { sql }) => {
  const body = request.json() as { id?: string } | null
  const rows = await sql('select * from orders where id = $1', [body?.id])
  return { order: rows[0] ?? null }
}
```

- `request`: method, headers, claims(호출한 키의 클레임. `role`이 `anon`·`authenticated`·`service_role`, 키 없이 부른 공개 함수는 null), text(), json()
- `sql(text, values)`: 이 프로젝트의 service_role 권한으로 한 문장 실행. RLS를 거치지 않으니 claims로 직접 확인하세요.
- 응답: 객체(JSON), 문자열, 또는 `{ status, headers, body }`
- **네트워크가 기본으로 꺼져 있어요.** `set_function_egress`(또는 콘솔의 함수 화면에서 `외부 요청`)로 켜면 `fetch()`로 공개 HTTPS 주소를 부를 수 있어요. 서버가 대신 보내고 내부 주소는 막아요. 요금제의 분당 한도(Free 300 · Pro 3,000 · Enterprise 10,000회, `list_functions`에 나와요)까지이고, 모두 함수 로그에 남아요.
- **연결(소켓)**: `Deno.connect`, `Deno.connectTls`, `Deno.startTls`도 되어서 메일 라이브러리(denomailer)와 데이터베이스 드라이버가 그대로 돌아요. 서버가 대신 열고 외부 요청과 같은 규칙을 따라요: 네트워크를 켜야 하고, 연결 하나가 분당 한도에 한 번으로 세어지고, 공개 주소만 돼요. 포트는 1024번 이상과 웹(80·443)·메일(110·143·465·587·993·995)만 열려 있어요(25번 같은 나머지 낮은 포트는 막아요). TLS 인증서는 서버가 이름으로 확인해요. 한 호출에 4개까지, 60초 동안 쓰이지 않으면 닫고, 호출이 끝나면 함께 닫혀요.
- 이 프로젝트의 API(`https://<ref>.<zone>`)는 네트워크를 켜지 않아도 부를 수 있고, 자동으로 들어가는 변수(`SUPABASE_URL`, `SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, blackwall://env)가 들어 있어 Supabase 함수의 `createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!)`가 그대로 동작해요. 이 호출은 분당 한도에 세지 않고, 로그에는 남아요. Supabase의 새 이름 `SUPABASE_SECRET_KEYS`·`SUPABASE_PUBLISHABLE_KEYS`(`{"default": "<키>"}` 모양의 JSON)도 들어가요. `SUPABASE_DB_URL`은 없어요: 함수는 네트워크가 없는 곳에서 돌아 데이터베이스에 직접 연결할 수 없으니 `createClient`나 두 번째 인자의 `sql`을 쓰세요.
- **npm 패키지**: `deploy_function`에 `dependencies`(`{"zod": "^3.23.8"}`) 또는 `package_json`(+ `package_lock`)을 함께 보내면, 배포할 때 설치하고 코드와 함께 ESM 파일 하나로 묶어요(최대 5MB). 설치 스크립트는 실행하지 않고, 네이티브 모듈(.node)은 쓸 수 없어요. 여러 파일은 `files`(`{"lib/util.ts": "..."}`)로 보내고 상대 경로로 import해요. 번들된 함수를 고칠 때는 `get_function`이 돌려준 `package_json`·`files`를 함께 다시 보내세요.
- **설치 시간**: 같은 패키지 묶음(package.json·잠금 파일)은 한 번 설치한 것을 다시 써서 두 번째 배포부터는 몇 초면 끝나요. MCP `deploy_function`은 40초 안에 끝나지 않으면 `status: "building"`으로 바로 답하고 서버에서 이어서 배포해요. `get_function`의 `build.status`가 `deployed`가 되면 새 버전이 호출을 받고, `failed`면 `build.error`에 이유가 있어요.
- **폴더째 배포**: `index.ts`와 다른 모듈(선택으로 `deno.json`)이 든 폴더를 `oneend functions deploy <이름> <폴더>`로, 또는 `deploy_function`의 `files`에 `index.ts`까지 모두 넣어 보내요. Supabase처럼 `deno.json`의 `imports`와 `npm:zod@3` 같은 import를 쓸 수 있고, 배포할 때 npm 패키지로 바꿔 설치해요.
- **jsr:와 URL import**: `jsr:@supabase/supabase-js@2`는 npm의 같은 패키지로, 다른 `jsr:` 패키지는 JSR의 npm 호환 저장소에서 설치해요. `https://deno.land/std@0.177.0/http/server.ts`·`https://esm.sh/...` 같은 URL import는 **배포할 때 한 번** 받아 함께 묶어요(받은 주소·최종 주소·SHA-256이 `get_function`의 `remote_imports`에 남고, `deno.lock`이 있으면 해시가 같아야 배포돼요). 함수가 실행 중에 받는 일은 없어요. 주소에 닿지 못하면 그 주소와 이유를 적고 배포가 실패해요.
- **_shared 폴더**: Supabase처럼 `supabase/functions/<이름>/index.ts`가 `../_shared/cors.ts`를 import하면, `oneend functions deploy <이름>`이 그 폴더를 함께 보내요. MCP에서는 `files`에 `../_shared/cors.ts` 경로 그대로 넣어요.
- **시간 한도**: 호출 하나에 기본 150초(Supabase 엣지 함수와 같음), 함수 안의 SQL 한 문장에 기본 8초예요. `set_function_limits`(또는 `oneend functions limits`, 콘솔의 함수 화면)로 함수마다 바꿀 수 있고 최대는 `get_function`의 `timeout_cap_seconds`이에요. 넘기면 504로 끊겨요.
- 프로덕션 환경 변수(blackwall://env)가 `process.env`로 들어와요. 값을 바꾸면 다음 호출부터 새 값이에요.
- **함수 시크릿**: 웹훅 서명 키나 메일 서비스 API 키처럼 함수만 쓰는 값은 `set_function_secrets { ref, secrets: { "RESEND_API_KEY": "re_…" } }`로 저장해요(콘솔의 함수 시크릿, `oneend secrets set`, REST `POST /v1/projects/<ref>/function-secrets`). 사이트 빌드에는 들어가지 않고, 같은 이름의 환경 변수보다 먼저 `process.env`에 들어가며, 다음 호출부터 쓰여요. 값은 저장만 되고 어떤 도구·API로도 다시 읽을 수 없어요. `list_function_secrets`는 이름과 바꾼 때만, 지우기는 `delete_function_secret`(`DELETE /v1/projects/<ref>/function-secrets/<name>`)이에요. 다른 프로젝트에 이미 넣어 둔 키는 다시 붙여 넣지 말고 `copy_function_secrets { ref, from: "<다른 프로젝트 ref>", names: ["ANTHROPIC_API_KEY"] }`로 복사해요(`oneend secrets copy --from <ref> 이름...`, REST `POST /v1/projects/<ref>/function-secrets/copy`). 값은 서버 안에서만 옮겨지고, 이미 있는 이름은 `overwrite: true`일 때만 바꾸며, 답은 `copied`·`skipped`·`missing` 이름만 줘요. 두 프로젝트 모두에서 소유자·관리자여야 해요. 소유자·관리자만 보고 바꿀 수 있어요.
- 함수는 프로덕션 비밀을 읽을 수 있으니 **소유자·관리자만** 배포할 수 있어요.

호출:

```
POST https://<ref>.<zone>/functions/v1/<name>
Authorization: Bearer <공개 키, 사용자 토큰 또는 비밀 키>
```

누가 부를 수 있나:

| 함수 | 키 없이 | 공개 키 | 사용자 토큰 · 비밀 키 |
|---|---|---|---|
| 비공개(기본) | 401 | 됨 (`claims.role` = `anon`) | 됨 (`authenticated` · `service_role`) |
| 공개(`public: true`) | 됨 (`claims` = null) | 됨 | 됨 |

비공개는 Supabase의 `verify_jwt`와 같아서, 공개 키를 붙인 `fetch`와 `supabase.functions.invoke`가 그대로 돼요.
공개 키는 앱에 들어가 누구나 볼 수 있으니, 로그인한 사용자만 받으려면 함수 안에서 `request.claims.role === 'authenticated'`를 확인하세요.
부른 쪽의 `Authorization`·`apikey` 헤더는 함수에 그대로 가요(Supabase와 같음). 그래서 함수 안에서 `createClient(url, anonKey, { global: { headers: { Authorization: req.headers.get('Authorization') } } })`로 그 사용자 권한으로 조회할 수 있어요. 이 프로젝트의 키·토큰으로 확인된 값만 가고, 확인되지 않은 `apikey`·쿠키·플랫폼의 자격 증명은 가지 않아요.
키 없이 들어오는 결제사 웹훅만 `public: true`로 두고, 그 안에서 서명을 직접 검증하세요. 출력(console.log)은 get_logs의 source "functions"로 봐요.

# 예약 작업

```
save_job          만들기·바꾸기 (이름이 같으면 교체)
list_jobs         목록, 다음 실행, 마지막 결과
run_job           지금 한 번 실행 (1분 안에)
list_job_runs     실행 기록과 오류
set_job_enabled   끄기·켜기
remove_job        삭제
set_job_retention 실행 기록 보관 일수 (1 | 3 | 7 | 14 | 30 | 90, null은 기본값)
```

- 일정은 다섯 칸 cron이고 **UTC**예요. 한국 시간은 UTC + 9시간: 한국 자정은 `0 15 * * *`, 한국 오전 9시는 `0 0 * * *`.
- 하는 일은 둘 중 하나: `kind: "function"`(배포한 함수를 본문 {}의 POST로 부름) 또는 `kind: "sql"`(서비스 역할로 한 문장).
- 외부 URL은 `save_job`으로 부를 수 없어요(SQL에서는 `net.http_post`). 같은 시각의 실행은 서버가 여러 대여도 한 번만 일어나요. SQL 작업은 `timeout_seconds`(기본 120초) 안에 끝나야 해요. 작업의 SQL이 `set statement_timeout to '15min'`처럼 스스로 늘리면 그만큼 기다리고, 어느 쪽이든 요금제의 최대(Free 300초, Pro·Enterprise 3600초)를 넘기면 멈춰요.
- 프로젝트당 작업 수는 요금제마다 달라요(Free 50, Pro 200, Enterprise 500, SQL로 만든 작업 포함). `list_jobs`의 `limit`·`used`로 봐요. 한 프로젝트의 작업이 한꺼번에 몰려도 다른 프로젝트의 작업은 밀리지 않아요(프로젝트마다 동시에 최대 4개).

# Supabase SQL 그대로: 스키마, pg_cron, pg_net, Vault

Supabase에서 쓰던 SQL을 고치지 않고 실행해요. SQL 편집기, `run_sql`, `apply_migrations`, `oneend db push`, SQL 예약 작업이 모두 같은 이름 변환을 거쳐요.

```
create schema if not exists blackquant;          -- app_<ref>_blackquant로 만들어짐
create table blackquant.prices (...);            -- SQL에서는 쓴 이름 그대로
select cron.schedule('collect', '* * * * *', $$ select net.http_post(url := 'https://<ref>.<zone>/functions/v1/collect') $$);
select decrypted_secret from vault.decrypted_secrets where name = 'service_role_key';
```

- **스키마**: `create schema blackquant`는 이 프로젝트만의 `app_<ref>_blackquant`가 돼요. 새 표는 public처럼 접근 규칙(RLS)이 켜진 채로 만들어지고 anon·authenticated·service_role 권한도 public과 같아요. 다른 프로젝트의 스키마는 이름도 만들 수 없고 읽을 수도 없어요.
- **노출 스키마**: 데이터 API는 public만 답해요. `set_exposed_schemas`(콘솔의 데이터베이스 → 스키마, `oneend db schemas expose`)로 노출하면 `Accept-Profile`·`Content-Profile` 헤더, supabase-js의 `db: { schema: 'blackquant' }`로 써요. 목록은 `list_schemas`. 노출하지 않은 스키마는 PGRST106(406)이에요.
- **검색 경로·최대 행 수**: `get_data_api_settings`로 보고 `set_data_api_settings`로 바꿔요(콘솔의 설정 → 데이터 API). `search_path`에 넣은 스키마(`create schema`로 만든 것, extensions)는 요청한 스키마 뒤에 붙어, 함수와 뷰가 스키마 이름 없이 그 안을 찾아요.
- **연결 한도**: `get_database_settings`로 보고 `set_database_settings`로 바꿔요(콘솔의 설정 → 데이터베이스). 예약 작업이나 함수가 `too many connections for role`로 실패하면 API 로그인(`<ref>_authenticator`)이 한도에 막힌 거예요. `enforced.api_connections`가 `limits.api_connections`보다 작으면 `api_connections`를 그 값까지 올리세요. 바로 적용되고 열린 연결은 끊지 않아요. 서버를 혼자 쓰는 프로젝트는 서버의 최대 연결에서 직접 로그인(기본 30)과 예약 10을 뺀 만큼까지 올릴 수 있고, 다른 프로젝트와 같이 쓰는 서버는 서버 최대 연결의 10분의 1(20~80)이에요. 예약 작업은 한도에 닿으면 1분까지 기다렸다가 다시 연결해요.
- **pg_cron**: `cron.schedule(name, schedule, command)`, `cron.unschedule`, `cron.alter_job`, `cron.job`, `cron.job_run_details`(7일 보관)를 써요. One Server의 스케줄러가 서비스 역할로 실행하고 `list_jobs`에 `source: "sql"`로 나와요. 최소 1분 간격(`30 seconds` 같은 초 단위는 거부), `@hourly`·`@daily` 같은 약어는 돼요. `create extension pg_cron`은 오류 없이 지나가요.
- **pg_net**: `net.http_post(url, body, params, headers, timeout_milliseconds)`, `net.http_get`, `net.http_delete`는 요청을 줄에 넣고 id를 돌려줘요. 트랜잭션이 끝난 뒤 몇 초 안에 보내고 응답은 `net._http_response`에 6시간 남아요. https만, 내부 주소는 거부, 시간 제한은 요청한 `timeout_milliseconds`(기본 5초)를 함수 시간 한도의 최대(기본 150초, 운영자가 900초까지)까지 지키고, 응답을 기다리다 끊겨도 부른 함수는 끝까지 돌아요. 함수의 외부 요청과 같은 분당 한도를 함께 써요. 자기 프로젝트 주소(`https://<ref>.<zone>`)는 함수의 외부 요청이 꺼져 있어도 돼요. 다른 주소는 `set_function_egress`로 켜야 해요.
- **http 확장(동기 `http_get`·`http_post`)은 없어요.** 응답을 같은 트랜잭션에서 기다리는 호출은 `net.http_get`으로 보내고 나중에 `net._http_response`에서 읽거나, 함수에서 `fetch()`로 바꾸세요. 응답을 쓰지 않는 호출은 `net.http_post`로 바꾸면 그대로 돼요. 데이터베이스가 직접 외부로 나가면 내부 주소 차단과 분당 한도를 피해 가서 넣지 않았어요. 옮기기 도구가 이 확장을 쓰는 함수와 예약 작업을 이름으로 알려 줘요.
- **Vault**: `vault.create_secret`, `vault.decrypted_secrets`는 서비스 역할(SQL 편집기, `run_sql`, 비밀 키, SQL 예약 작업)에서만 풀려요. 전용 데이터베이스 프로젝트에서 처음 쓸 때 켜져요.

# 데이터베이스 웹훅

표의 줄이 바뀌면 알려요. 함수의 외부 요청을 켜지 않았다면 **외부 서비스(슬랙, 메일 발송 API 등)에 알릴 때 이것을 써요.**

```
create_db_webhook            만들기: 표, 이벤트(insert/update/delete), 보낼 곳(url 또는 function)
list_db_webhooks             목록과 최근 결과(서명 키는 끝 네 글자만)
list_db_webhook_deliveries   전달 기록과 실패 이유
set_db_webhook_enabled       끄기·켜기
remove_db_webhook            삭제
```

- 본문: `{ type: "INSERT"|"UPDATE"|"DELETE", table, schema: "public", record, old_record, hook, delivery, occurred_at }`
- 커밋된 변경만 보내요. 서버가 재시작 중이었어도 나중에 보내요. 순서는 보장하지 않으니 `delivery`로 중복을 거르세요.
- 2xx가 아니거나 10초 안에 응답하지 않으면 실패예요. 10초·1분·5분·30분 뒤 다시 보내고 다섯 번째에 버려요.
- url은 공개된 https 주소만 돼요. 사설·내부 주소와 리다이렉트는 거부해요.
- 받는 쪽 확인: `x-blackwall-signature: t=<초>,v1=<서명>`, 서명 = HMAC-SHA256(signing_secret, "<t>.<본문>")의 16진수.
- signing_secret은 웹훅을 만들 때 한 번만 보여줘요. 목록(`list_db_webhooks`)에는 `signing_secret_set`과 끝 네 글자(`signing_secret_last4`)만 나와요. 잃어버렸으면 웹훅을 지우고 다시 만들면 새 키가 나와요.

## 웹 표준 Response, Deno.serve

```
Deno.serve(async (req) => {
  const { name } = await req.json()
  return new Response(JSON.stringify({ hello: name }), { headers: { "content-type": "application/json" } })
})
```

```
export default {
  async fetch(req, ctx) {
    const hits = await ctx.kv.incr("hits")
    const on = await ctx.flags.isEnabled("new-checkout", req.claims?.sub ?? null)
    return Response.json({ hits, on })
  },
}
```

Supabase처럼 쓴 함수도 그대로 돌아가요. `new Response(...)`(스트림 포함), `Deno.serve(handler)`, `export default { fetch }`, `Deno.env.get()`을 써요. 함수 이름 뒤의 경로(`/functions/v1/<name>/a/b`)는 `req.url`로 받아요. `EdgeRuntime.waitUntil(promise)`에 넘긴 일은 응답을 먼저 보낸 뒤에도 함수의 시간 한도 안에서 끝까지 돌고, 그동안 질의와 외부 요청도 돼요.
스트림으로 만든 Response는 쓰는 대로 보내요(서버 전송 이벤트, 모델 답변). `ctx.kv`와 `ctx.config`, `ctx.flags`로 KV와 엣지 설정을 써요(blackwall://edge).

---

https://end.blackall.ai/docs/functions?lang=ko
