chatmu · postgres · dbmate
스키마 지도
워크스페이스 사이클이 끝난 시점의 스키마다. 정본은 db/migrations/ 이고,
이 그림은 거기서 뽑았다 — npm run db:migrate && npm run db:diagram.
표 13 개
마이그레이션 20 개
이번 사이클이 더한 표 4 개
뽑은 날 2026-08-23
관계 그림
생성된 그대로다(graphviz). 넓어서 가로·세로로 밀어 볼 수 있다 —
db/.diagram/schema.dbml 을 dbdiagram.io 에 붙여넣으면 인터랙티브하게 열린다.
⛔ 이 파일들은 커밋하지 않는다: 정본이 둘이 되면 마이그레이션만 고친 날 그림이 조용히 갈린다.
dbml
actors
actors
id
bigint
(!)
display_name
text
(!)
created_at
timestamp
(!)
status_message
text
profile_body
text
profile_body_format
text
bots
bots
actor_id
bigint
(!)
owner_actor_id
bigint
(!)
bot_token_hash
text
(!)
deactivated_at
timestamp
actors:e->bots:w
*
1
direct_rooms
direct_rooms
actor_a_id
bigint
(!)
actor_b_id
bigint
(!)
created_at
timestamp
(!)
room_id
text
(!)
workspace_id
text
(!)
actors:e->direct_rooms:w
*
1
actors:e->direct_rooms:w
*
1
messages
messages
id
bigint
(!)
actor_id
bigint
(!)
body
text
(!)
client_message_id
uuid
created_at
timestamp
(!)
edited_at
timestamp
deleted_at
timestamp
body_format
text
(!)
room_id
text
(!)
actors:e->messages:w
*
1
room_participants
room_participants
actor_id
bigint
(!)
joined_at
timestamp
(!)
last_read_message_id
bigint
room_id
text
(!)
actors:e->room_participants:w
*
1
users
users
login_id
text
(!)
password_hash
text
(!)
created_at
timestamp
(!)
password_changed_at
timestamp
(!)
role
text
(!)
actor_id
bigint
(!)
actors:e->users:w
*
1
workspace_members
workspace_members
workspace_id
text
(!)
actor_id
bigint
(!)
joined_at
timestamp
(!)
actors:e->workspace_members:w
*
1
channel_rooms
channel_rooms
room_id
text
(!)
workspace_id
text
(!)
name
text
(!)
room_id, workspace_id
room_slugs
room_slugs
slug
text
(!)
room_id
text
(!)
retired_at
timestamp
workspace_id
text
(!)
room_id, workspace_id
channel_rooms:e->room_slugs:w
*
1
rooms
rooms
created_at
timestamp
(!)
id
text
(!)
rooms:e->channel_rooms:w
*
1
rooms:e->direct_rooms:w
*
1
rooms:e->messages:w
*
1
rooms:e->room_participants:w
*
1
users:e->bots:w
*
1
workspace_slugs
workspace_slugs
slug
text
(!)
workspace_id
text
(!)
retired_at
timestamp
workspaces
workspaces
id
text
(!)
name
text
(!)
created_at
timestamp
(!)
workspaces:e->channel_rooms:w
*
1
workspaces:e->direct_rooms:w
*
1
workspaces:e->workspace_members:w
*
1
workspaces:e->workspace_slugs:w
*
1
공통 — 모두가 가리키는 것
actors
발화할 수 있는 주체. 사람이든 봇이든 여기 한 줄이다.
pk id · bigint
uniq display_name
status_message · profile_body(+format)
rooms
Message 가 쌓이는 장소. 남은 것은 공통뿐 — 이름도 종류도 여기 없다.
pk id · text (무작위 8자리)
idx (created_at, id) — 목록의 정렬 축
messages
발화. 작성자는 언제나 actor 이고, 정렬·커서의 정본은 id 다.
pk id · bigint
fk room_id → rooms · actor_id → actors
idx (room_id, id) · (room_id, client_message_id)
room_participants
이 방을 읽고 있다 . 볼 자격이던 뜻이 이번에 바뀌었고, 읽음 커서가 여기 산다.
pk (room_id, actor_id)
last_read_message_id · joined_at
확장 표 — 차이만 갖는다
공통 표에 nullable 칸을 섞는 대신 차이를 옆 표로 뺀다. 그러면 그 칸들을
NOT NULL 로 둘 수 있어 어긋난 조합이 표현될 수 없다 . 사람·봇이 먼저 이 길을
갔고(ADR 0011), 이번에 방이 같은 모양이 됐다.
users
사람의 차이 = 로그인. 이 행이 있으면 사람이다.
pk actor_id → actors
uniq login_id
password_hash · password_changed_at · role
bots
봇의 차이 = 주인과 토큰. 울타리에 종속되지 않는다 — 사람처럼 워크스페이스에 불려 든다.
pk actor_id → actors
fk owner_actor_id → users (봇이 봇을 못 가진다)
bot_token_hash · deactivated_at
channel_rooms 이번
채널의 차이 = 울타리와 이름. 둘 다 NOT NULL 인 것이 이 표의 값이다.
pk room_id → rooms
fk workspace_id → workspaces
uniq (workspace_id, room_id) — 슬러그가 이걸 짝으로 건다
direct_rooms 바뀜
이 방은 이 두 사람의 1:1 이다. 울타리 칸이 생겼다 — 같은 두 사람이 워크스페이스마다 다른 대화를 갖는다.
pk room_id → rooms
fk workspace_id · actor_a_id · actor_b_id
uniq (workspace_id, actor_a, actor_b)
chk actor_a_id < actor_b_id
울타리 — 이번 사이클이 세운 층
workspaces 이번
Room 들을 담는 울타리. id 는 방과 같은 무작위 8자리 — 주소가 개수를 안 말한다.
pk id · text
name · created_at
workspace_members 이번
이 표가 인가다. 멤버면 그 안의 채널을 전부 본다. 가리키는 것은 actor 라 봇도 같은 길로 든다.
pk (workspace_id, actor_id)
idx actor_id — 「내가 속한 울타리」
workspace_slugs 이번
울타리의 주소 — /w/soriteam. 전역 유일 이다(그 위에 아무것도 없다).
pk slug
uniq workspace_id where retired_at is null
room_slugs 바뀜
방의 주소. 유일성이 울타리 안으로 좁아졌다 — 다른 팀이 같은 deploy-alerts 를 쓴다.
pk (workspace_id, slug)
fk (workspace_id, room_id) → channel_rooms
uniq room_id where retired_at is null
이 그림에서 읽히는 규칙 다섯
rooms 에 종류 칸이 없다
채널이냐 1:1 이냐는 어느 확장 표에 짝이 있나 로 답한다. 같은 사실을 두 곳에 적지
않으려는 것이고, 그래서 「채널도 DM 도 아닌 방」이 표현 가능하다 — 막지 않기로 했다.
room_slugs → channel_rooms
FK 가 쌍 (workspace_id, room_id)이다. 홑 FK 면 “A 의 채널인데 슬러그는 B 의 것”
이 표현되고, 그때 /w/B/c/이름 이 A 의 방을 연다 — 울타리를 넘는 주소 다.
덤으로 “DM 에는 주소를 못 단다”가 참조 무결성이 됐다.
bots.owner → users
actors 가 아니라 users 를 가리킨다. 봇에는 users 행이 없으므로
“봇이 봇을 소유한다” 가 표현될 수조차 없다 .
messages 에만 CASCADE 가 없다
messages.actor_id 는 지워지지 않는다 — 방을 나가도, 계정을 지워도
지나간 말의 작성자 이름은 남는다 .
direct_rooms 의 CHECK
actor_a < actor_b 가 정렬을 DB 에 박는다. 코드가 순서를 틀리면 통과가 아니라
거절 이고, 그 덕에 쌍의 UNIQUE 가 실제로 중복을 막는다.
정본: backend/db/migrations/ · 뽑는 법: npm run db:migrate && npm run db:diagram
설계의 근거: backend/docs/design/db-schema.md · 결정 기록: docs/adr/0011 · 0012 · 0013 · 0014