데이터베이스
웹사이트별 PostgreSQL 데이터베이스와 Prisma Client를 사용합니다.
관리형 데이터 저장소
- Cake20 웹사이트는 관리형 PostgreSQL과 Redis를 사용합니다.
- server/db에 실제 model 또는 view 스키마가 있을 때만 웹사이트 DB와 Prisma Client를 생성하고 연결합니다.
- 빈 DB 폴더, enum·type, SQL 함수, seed와 migration만으로는 DB를 생성하지 않습니다.
- 마지막 model/view를 제거해도 기존 DB 데이터는 자동 삭제하지 않고 사이트 연결만 중단합니다.
- Redis는 웹사이트마다 전용 ACL 계정을 자동 발급하며 자기 웹사이트 키만 접근할 수 있습니다.
- 웹사이트 코드와 Prisma는 실제 주소나 계정 대신 Cake20이 주입한 DATABASE_URL을 사용합니다.
- package.json의 data는 auto, local, server 중 하나입니다. 생략한 경우에도 내부 project.data는 auto로 정규화됩니다.
- auto는 Cake20 서버에서 관리형 PostgreSQL을 사용하고, 로컬 CLI에서는 로컬 파일 영역과 설치된 PostgreSQL을 사용합니다.
- data를 server로 지정하면 로컬 실행도 DATABASE_URL의 PostgreSQL에 연결합니다. package.json에는 data가 생성되지 않습니다.
DB 모델 시작하기
- server/db/note.db.ts에 export const Name = {...} 형식으로 데이터 모델을 작성합니다.
- Cake20이 export한 필드 객체를 내부적으로 z.model()로 감싸므로 사용자가 z.model({...})을 직접 작성할 필요가 없습니다.
- 테이블을 추가할 때는 server/db/user.db.ts, server/db/post.db.ts처럼 테이블마다 별도 *.db.ts 파일을 만드는 것을 기본으로 합니다.
- 파일이 많으면 server/db/catalog/users/user.db.ts처럼 필요한 깊이의 폴더로 정리할 수 있습니다.
- 관계가 있는 모델도 파일을 합치지 않고 z.ref("Model")로 서로 참조할 수 있습니다. 해당 테이블 전용 enum과 view만 같은 파일에 둘 수 있습니다.
- 필드 이름은 객체 key, 필드 타입과 제약은 z 메서드 체인으로 작성합니다.
- z.id()는 BigInt primary key와 autoincrement 기본값을 한 번에 선언합니다.
- Cake20은 저장 즉시 숨겨진 .prisma 파일로 변환하고 Prisma Client를 생성합니다.
- 기존 .prisma만 있는 웹사이트는 같은 이름의 .db.ts를 자동 생성합니다.
- *.db.ts는 실행하지 않고 TypeScript AST를 정적으로 읽으므로 import, 함수 호출, 계산식과 동적 값은 사용할 수 없습니다.
export const State = z.enum(["draft", "published"]);
export const Note = {
id: z.id(),
title: z.string().min(1).max(100),
active: z.boolean().default(true),
state: z.ref("State"),
meta: z.json(),
createdAt: z.date().defaultNow().timestamp()
};*.db.ts가 원본입니다. 생성된 .prisma 파일은 직접 만들거나 수정하지 않습니다.
행 데이터 편집
- boolean 컬럼에는 대소문자와 관계없이 true, false 또는 1, 0만 입력할 수 있으며 실제 boolean 값으로 저장합니다.
- nullable text와 varchar 컬럼은 입력을 완전히 비우면 DB NULL로 저장합니다.
- text와 varchar에 입력한 null은 특별한 값이 아닌 문자열 그대로 저장합니다.
- NULL과 DEFAULT를 직접 선택하려면 셀 오른쪽의 N과 D 버튼을 사용합니다.
에디터·검수·운영이 공유하는 데이터
- 에디터, 디자인 Preview, build/debug 검수와 운영 Release는 웹사이트의 PostgreSQL DB, Redis namespace와 files 저장소 하나를 공유합니다.
- 데이터베이스와 캐시 메모리 편집은 별도 테스트 사본이 아니라 현재 웹사이트 데이터에 즉시 반영됩니다.
- 검수 주소는 코드와 프로세스를 분리하지만 데이터를 격리하지 않습니다. 검수 중 예약 Task는 실행하지 않습니다.
- MCP의 target: test는 이전 클라이언트 호환용 별칭이며 target: production과 같은 DB와 Redis key를 가리킵니다.
- MCP DB 쓰기는 변경 직전 dump를 자동 생성하고 실패하면 그 dump로 복구합니다. 넓은 스키마·데이터 변경은 create_site_backup으로 복원점을 먼저 남깁니다.
검수 환경도 실제 데이터에 연결됩니다. 샘플 화면은 공용 DB를 바꾸지 말고 app/preview의 안전한 JSON을 사용하세요.
DB DUMP 열기와 DUMP 저장
- 왼쪽 Database 패널의 DUMP 열기와 DUMP 저장은 선택한 테이블이 아닌 해당 웹사이트의 현재 DB 전체에 적용됩니다.
- DUMP 열기는 DB가 없으면 web-{웹사이트ID} DB를 만들고 PostgreSQL dump를 복원합니다.
- 기존 DB가 있으면 모든 테이블과 데이터를 먼저 제거한 뒤 dump로 교체하며, 기존 DB는 자동 백업하고 실패하면 복구합니다.
- DUMP 저장은 현재 DB 전체를 PostgreSQL dump 파일로 저장합니다.
- 파일명 시각은 package.json의 timezone을 적용합니다.
- DB 파일명은 YYYYMMDD-HHmmss-{웹사이트ID}.dump 형식입니다.
모델 seed와 초기 데이터
- 각 *.db.ts의 seed는 DB를 직접 호출하지 않고 해당 파일의 row만 반환합니다.
- 모델 하나를 export하면 배열, 여러 모델을 export하면 모델 이름을 key로 사용한 객체를 반환합니다.
- row마다 명시적인 id 또는 unique 필드를 넣습니다. Cake20은 최신 스키마로 먼저 검사하고 파일 checksum이 바뀐 seed만 안전하게 upsert합니다.
- seed에서 row를 제거해도 운영 DB의 기존 데이터는 삭제하지 않습니다.
- 새 *.db.ts에는 빈 export const seed = async () => {};가 자동으로 생성되며 현재 seed 반환 row로 app/preview/<Model>.json을 갱신합니다.
- Cake20은 Preview를 위해 운영 DB를 주기적으로 탐색하거나 5건을 추출하지 않으며 Preview JSON을 웹사이트 DB에 주입하지 않습니다.
- 여러 모델을 조정하는 절차형 초기화만 선택적으로 seed.sql.ts에 작성합니다. 모델 seed 뒤에 실행되고 변경 시 다시 실행되므로 반복 실행에 안전해야 합니다.
- 비밀번호 hash는 z.password().hashFrom("pwdText")처럼 선언합니다. pwdText는 쓰기 전용 평문 입력이며 Cake20이 hash한 뒤 제거합니다.
- 프리뷰 샘플에는 실제 개인정보, 비밀번호, 인증 토큰과 Secret을 넣지 않고 외부 API 없이 재현 가능한 값만 사용합니다.
// server/db/setting.db.ts
export const Setting = {
id: z.string().id().max(30),
locale: z.string().max(10),
signup: z.boolean().default(true)
};
export const seed = async () => [
{ id: "default", locale: "ko", signup: true }
];재사용 DB 함수
- server/db 아래 모든 깊이의 *.sql.ts 함수가 db.sql에 등록됩니다.
- 함수 이름은 폴더와 관계없이 전체 server/db에서 고유해야 합니다.
- API, route, hook, event와 seed.sql.ts에서 import 없이 db.sql.<함수명>()으로 호출합니다.
- DB를 읽거나 쓰는 공통 실행 로직은 shared/utils가 아니라 server/db/*.sql.ts에 둡니다.
- shared/utils/*.ts는 SQL 함수 등록보다 먼저 로드되고 브라우저에서도 사용됩니다. db.sql.*을 참조하면 저장이 안내 오류와 함께 거부됩니다.
- seed.sql.ts는 모델 seed 뒤의 절차형 초기화 전용이며 자동 등록에서 제외됩니다.
- 폴더나 파일이 달라도 같은 함수 이름을 export하면 빌드 오류가 발생합니다.
- config.sample의 func와 args로 에디터에서 SQL 함수를 즉시 테스트합니다.
- args 값은 객체에 작성한 순서대로 함수의 매개변수에 전달됩니다.
- runtime 값은 named 함수와 테스트용 config만 export할 수 있습니다.
- type과 interface export도 허용됩니다.
- 파일 최상위에서 DB 작업을 실행하지 않습니다.
// server/db/user.sql.ts
/**
* 여러 API에서 재사용할 사용자 조회 예제입니다.
* 서버 어디서든 db.sql.findUser(email)로 호출합니다.
*/
export const config = {
sample: {
func: "findUser",
args: { email: "[email protected]" }
}
};
export const findUser = (email: string) => {
return db.user.findFirst({ where: { email } });
};
// server/api/users.post.ts
export default async () => {
return db.sql.findUser("[email protected]");
};전역 db 호출은 외부 transaction에 자동 참여하지 않습니다. transaction이 필요한 함수는 함수 내부에 경계를 명시합니다.
필드 타입
- string, int, bigint, number, decimal, boolean, date, json과 bytes를 지원합니다.
- array()는 PostgreSQL scalar 배열 또는 관계 목록을 만듭니다.
- ref("Name")은 다른 model이나 enum 타입을 참조합니다.
- timestamp()는 PostgreSQL timestamp(0), timestampTz()는 timezone을 포함한 timestamptz(0)를 지정합니다. 1부터 6의 다른 정밀도가 필요할 때만 숫자를 전달합니다.
- 짧은 메서드가 없는 네이티브 타입은 db("Decimal", 12, 2)처럼 기존 db(name, ...args) 문법을 그대로 사용할 수 있습니다.
- 현재 Prisma와 PostgreSQL이 지원하지만 짧은 메서드가 없는 필드 타입은 raw("...")로 보존할 수 있습니다.
export const Product = {
id: z.id(),
name: z.string(),
stock: z.int(),
viewCount: z.bigint(),
rating: z.number(),
price: z.decimal().db("Decimal", 12, 2),
active: z.boolean(),
publishedAt: z.date().timestamp(),
happenedAt: z.date().timestampTz(),
options: z.json(),
data: z.bytes().nullable(),
tags: z.string().array()
};min과 max
- string과 bytes의 min/max는 길이, 숫자 타입은 값의 범위를 검사합니다.
- array().min/max는 배열 항목 개수를 검사하므로 array() 뒤에 작성합니다.
- date의 min/max에는 ISO 날짜 문자열이나 millisecond timestamp를 사용합니다.
- string().max(n)은 숨겨진 Prisma의 @db.VarChar(n)에도 반영됩니다.
- 그 밖의 min/max는 db Client의 create, update와 중첩 쓰기 전에 검사합니다.
- min/max 필드는 increment나 push 같은 상대 갱신 대신 set 또는 직접 값을 사용합니다.
- Raw SQL은 이 검사를 우회하므로 일반 CRUD에는 db Client를 사용합니다.
export const Product = {
name: z.string().min(2).max(100),
stock: z.int().min(0).max(100000),
price: z.decimal().min(0).max(999999.99),
tags: z.string().array().min(1).max(10),
openedAt: z.date().min("2025-01-01T00:00:00+09:00")
};nullable, optional과 기본값
- nullable()은 null을 허용하며 Prisma의 ? 필드로 변환합니다.
- optional()은 Zod처럼 undefined 또는 입력 생략만 뜻하며 DB NULL을 허용하지 않습니다.
- nullish()는 optional과 nullable을 합친 표현이며 DB에서는 ? 필드가 됩니다.
- 입력을 생략했을 때 DB 값을 만들어야 하면 default(), defaultNow(), id() 또는 updated()를 사용합니다.
- defaultRaw("uuid()")는 Prisma가 해석할 원시 기본값을 지정합니다.
- optional() 자체는 DB 기본값을 만들지 않으므로 필수 컬럼에 단독으로 사용하지 않습니다.
export const Account = {
id: z.id(),
nick: z.string().max(40).nullable(),
bio: z.string().max(500).nullish(),
role: z.string().default("member"),
createdAt: z.date().defaultNow(),
updatedAt: z.date().updated()
};enum과 view
- enum은 export const Name = z.enum([...])로 선언하고 z.ref로 참조합니다.
- view는 z.view({...})로 선언하며 실제 view 생성 SQL은 migration에서 관리합니다.
- 문법상 여러 model을 한 파일에 둘 수 있지만, 유지보수를 위해 테이블별 *.db.ts 파일을 기본으로 사용합니다. 여러 모델이 함께 쓰는 enum은 별도 파일로 분리할 수 있습니다.
export const State = z.enum(["draft", "published"]);
export const Post = {
id: z.id(),
state: z.ref("State").defaultRaw("draft")
};
export const PublishedPost = z.view({
id: z.bigint().id(),
state: z.ref("State")
});단일 Index
필드에 index()를 붙이면 숨겨진 Prisma schema의 @@index로 변환합니다.
- index()는 해당 필드 하나로 구성된 index를 생성합니다.
- unique()는 index가 아니라 중복 값을 금지하는 unique 제약입니다.
- 복합·고급 index는 모델 뒤에 attr("@@index([...])")로 선언합니다.
export const User = {
id: z.id(),
email: z.string().index()
};관계
- ref("Model")은 다른 model이나 enum 타입을 참조합니다.
- array()를 붙인 ref는 Prisma의 목록 관계 필드가 됩니다.
- relation("authorId")은 fields: [authorId], references: [id]를 생성합니다.
- 복합 관계는 relation(["localA", "localB"], ["remoteA", "remoteB"])로 작성합니다.
- 관계 이름과 referential action 같은 고급 옵션은 attr("@relation(...)")로 작성합니다.
export const User = {
id: z.id(),
posts: z.ref("Post").array()
};
export const Post = {
id: z.id(),
authorId: z.bigint(),
author: z.ref("User").relation("authorId")
};고급 Prisma 속성
- map("column_name")은 필드의 실제 DB column 이름을 지정합니다.
- attr("@...")은 이름 있는 relation이나 onDelete처럼 짧은 메서드로 표현하지 못한 필드 속성을 그대로 전달합니다.
- 모델 뒤의 attr("@@...")은 복합 unique, 복합 index와 table map을 전달합니다.
- Cake20은 일반 모델 객체를 자동으로 감쌉니다. 모델 자체의 @@ 속성이 필요한 고급 선언에서만 기존 z.model({...}).attr(...) 형식을 사용합니다.
- attr 문자열은 현재 Prisma 문법이어야 하며 저장 시 Prisma 검사를 통과해야 합니다.
export const Member = z.model({
id: z.id(),
tenantId: z.bigint(),
email: z.string().max(200).map("email_address"),
owner: z.ref("User").attr(
'@relation("Owner", fields: [ownerId], references: [id], onDelete: Cascade)'
),
ownerId: z.bigint()
})
.attr("@@unique([tenantId, email])")
.attr("@@index([tenantId, email])")
.attr('@@map("members")');DB 사용
db는 server/api, server/routes, server/hooks, server/tasks와 shared 함수에서 자동으로 사용할 수 있습니다. nullable 입력 정규화 외의 타입과 쿼리 동작은 Prisma와 PostgreSQL 규칙을 따릅니다.
export default async () => {
return db.note.findMany({
orderBy: { id: "desc" }
});
};빈 값과 NULL 처리
- 모든 nullable scalar ? 필드는 최상위 값 "", undefined, null을 구분하지 않고 DB NULL로 저장합니다.
- 필수 Json은 빈 값을 {}로 저장하고, Json?은 다른 nullable 필드처럼 DB NULL로 저장합니다. Json?에 입력한 {}는 NULL과 구분합니다.
- create에서 생략한 nullable 필드는 NULL로 초기화합니다.
- update에서 키를 생략하면 기존 값을 유지하지만, 키를 넣고 undefined를 전달하면 NULL로 변경합니다.
- 검색할 때 null, undefined, 빈 문자열을 OR로 묶지 않습니다. where: { field: null } 하나만 사용합니다.
- Json?의 NULL 검색에도 Prisma.DbNull이나 Prisma.JsonNull 대신 일반 null을 사용합니다. 필수 Json에서 null 조건은 빈 객체 {}를 찾습니다.
- Json 객체 내부의 중첩 값은 변환하지 않으며 Json 필드 자체의 값만 정규화합니다. Raw SQL에는 이 규칙이 적용되지 않습니다.
export const Profile = {
id: z.id(),
nick: z.string().nullable(),
score: z.int().nullable(),
meta: z.json(),
extra: z.json().nullable()
};
// nick, score, extra는 DB NULL, meta는 {}로 저장됩니다.
await db.profile.create({
data: { nick: "", score: undefined, meta: null, extra: "" }
});
// nullable 필드의 빈 값은 null 하나로 검색합니다.
await db.profile.findMany({ where: { nick: null, extra: null } });
// Json?에서 {}는 DB NULL과 구분되는 실제 JSON 값입니다.
await db.profile.findMany({ where: { extra: {} } });필수 String, Int 같은 non-Json 필드에는 빈 값을 전달할 수 없습니다.
Migration과 DB 권한
- 빌드 시 현재 DB와 Prisma model 차이를 SQL로 만들고 내부 cake20.migrations table에 checksum과 적용 이력을 기록합니다.
- 실제 DB 구조와 빌드할 Prisma 구조가 다르거나 적용할 migration이 있을 때만 dump를 만듭니다. 백업에 실패하면 빌드를 중단합니다.
- Prisma model 차이는 데이터 삭제 가능성이 있는 변경도 transaction으로 자동 적용합니다.
- 직접 작성한 DROP SQL은 -- cake20:allow-destructive 표식이 있어야 실행합니다.
- DB·schema 초기화와 TRUNCATE 명령은 migration에서도 허용하지 않습니다.
- server 실행 프로세스는 자기 DB의 CRUD만 허용된 전용 role을 사용합니다. local은 웹사이트별로 분리된 PostgreSQL 인스턴스를 사용합니다.