이슈 #254
진행중8-2. 데이터베이스 스키마 설계 (사용자. 앱. 일정)
설명
PostgreSQL/MongoDB 스키마
CP Chris Park이(가) 7달 전에 변경
- 프로젝트을(를) 14에서 Alphea App(으)로 변경되었습니다.
- 목표버전을(를) 6월 중간 버전(으)로 지정되었습니다.
JL James Lee이(가) 7달 전에 변경
초기 스키마 도입 (accounts, wallets, nodes)¶
사용자 인증(#256) 사전 작업으로 PostgreSQL 초기 스키마를 생성했습니다.
-
Repository:
alphea-ai-edge - Commit: commit:server|2451853
-
Migration:
migrations/0001_initial_schema.sql -
Runtime: PostgreSQL 17 (docker-compose,
postgres:17-alpine) -
Migrator:
sqlx::migrate!매크로 — 서버 기동 시 자동 적용, 버전은_sqlx_migrations테이블이 추적
테이블 1. accounts
¶
사용자 계정. 지갑 서명 인증과 (선택적) 이메일/비밀번호 로그인을 모두 지원.
| Column | Type | Null | Notes |
|---|---|---|---|
id |
BIGSERIAL |
NOT NULL | PK |
wallet_address |
TEXT |
NOT NULL | UNIQUE, 인증 키 |
email |
TEXT |
NULL | UNIQUE, 이메일 복구용 |
username |
TEXT |
NULL | 표시명 |
password_hash |
TEXT |
NULL | Argon2 해시 (향후 단계) |
profile_icon |
TEXT |
NULL | 아이콘 URL |
referral_code |
TEXT |
NOT NULL | UNIQUE, 친구 초대 코드 |
created_at |
TIMESTAMPTZ |
NOT NULL | DEFAULT NOW() |
updated_at |
TIMESTAMPTZ |
NOT NULL | DEFAULT NOW() |
Indexes: idx_accounts_wallet_address, idx_accounts_email, idx_accounts_referral_code
테이블 2. wallets
¶
계정별 AGX 잔액 및 누적 수익. accounts.id 와 1:1.
| Column | Type | Null | Notes |
|---|---|---|---|
id |
BIGSERIAL |
NOT NULL | PK |
account_id |
BIGINT |
NOT NULL | FK → accounts.id ON DELETE CASCADE, UNIQUE |
balance |
DOUBLE PRECISION |
NOT NULL | DEFAULT 0 |
total_earned |
DOUBLE PRECISION |
NOT NULL | DEFAULT 0 |
updated_at |
TIMESTAMPTZ |
NOT NULL | DEFAULT NOW() |
Indexes: idx_wallets_account_id
테이블 3. nodes
¶
엣지 / 모바일 / 가상 컴퓨팅 노드 레지스트리. Verify RPC 에서 device_id + device_meta 가 주어지면 자동으로 모바일 노드 행을 생성할 예정.
| Column | Type | Null | Default | Notes |
|---|---|---|---|---|
id |
BIGSERIAL |
NOT NULL | PK | |
node_id |
TEXT |
NOT NULL | UNIQUE | |
name |
TEXT |
NOT NULL | '' |
|
node_type |
INTEGER |
NOT NULL | 1=EDGE, 2=MOBILE, 3=VIRTUAL | |
owner_wallet_address |
TEXT |
NOT NULL | 소유자 지갑 | |
auth_mode |
INTEGER |
NOT NULL | 2 |
1=INTERNAL, 2=SIGNATURE |
status |
INTEGER |
NOT NULL | 1 |
1=INACTIVE, 2=ACTIVE, 3=BUSY, 4=DRAINING, 5=BANNED |
tier |
INTEGER |
NOT NULL | 1 |
1=LOW, 2=MID, 3=HIGH |
role |
INTEGER |
NOT NULL | 1 |
1=WORKER, 2=COMMITTEE |
max_concurrent_jobs |
INTEGER |
NOT NULL | 5 |
|
throughput_hint |
INTEGER |
NOT NULL | 100 |
|
running_jobs |
INTEGER |
NOT NULL | 0 |
|
contributed_storage_gb |
INTEGER |
NOT NULL | 0 |
|
contributed_cpu_cores |
INTEGER |
NOT NULL | 0 |
|
network_bandwidth_mbps |
INTEGER |
NOT NULL | 0 |
|
score |
INTEGER |
NOT NULL | 50 |
디스패처 점수 |
success_rate_1m |
DOUBLE PRECISION |
NOT NULL | 1.0 |
최근 1분 성공률 |
avg_exec_ms |
DOUBLE PRECISION |
NOT NULL | 0 |
|
p95_exec_ms |
DOUBLE PRECISION |
NOT NULL | 0 |
|
timeouts_1m |
INTEGER |
NOT NULL | 0 |
|
total_jobs_completed |
BIGINT |
NOT NULL | 0 |
|
total_jobs_failed |
BIGINT |
NOT NULL | 0 |
|
device_id |
TEXT |
NULL | 모바일/엣지 디바이스 식별자 | |
device_meta |
JSONB |
NULL |
DeviceMeta proto 직렬화 (platform, model 등) |
|
device_platform |
TEXT |
NULL |
ios / android / edge 등 |
|
pool_group |
TEXT |
NULL | 풀 그룹핑용 | |
last_heartbeat_at |
TIMESTAMPTZ |
NULL | ||
last_assigned_at |
TIMESTAMPTZ |
NULL | ||
created_at |
TIMESTAMPTZ |
NOT NULL | NOW() |
|
updated_at |
TIMESTAMPTZ |
NOT NULL | NOW() |
Indexes: idx_nodes_owner_wallet, idx_nodes_status, idx_nodes_node_type, idx_nodes_device_id
외래 키 관계¶
accounts (id) ──┐
└── wallets.account_id (ON DELETE CASCADE, UNIQUE)
nodes.owner_wallet_address ────> accounts.wallet_address (논리적 참조 — FK 제약은 걸지 않음)
nodes.owner_wallet_address 는 FK 제약을 걸지 않았습니다. 지갑 주소 기반 조회가 빈번하고, 아직 계정이 생성되지 않은 상태에서도 노드 사전 등록이 가능해야 하기 때문입니다.
레거시 프로젝트와의 차이점¶
- 레거시 Go 프로젝트의 수동 마이그레이션 관리(
_meta.schema_version) 대신sqlx표준 마이그레이션 테이블(_sqlx_migrations)을 사용 - enum 값은 레거시와 동일한 숫자 매핑 유지 (proto 값과 1:1 대응)
-
device_meta는 JSONB 로 저장 → 향후 device platform/model/timezone 등 유연한 쿼리 가능
JL James Lee이(가) 7달 전에 변경
Step B 관련 참조 (DB 스키마 변경 없음)¶
- Commit: commit:server|430589e
- 관련 이슈: #256 (사용자 인증 포팅)
이번 커밋은 순수 도메인 로직(src/domain/auth/)만 추가했으며 DB 스키마 변경은 없습니다. 다만 Step A(#254 의 초기 스키마 — commit:server|2451853)의 테이블들과 이번에 추가된 타입 간 매핑을 기록해 둡니다.
스키마 ↔ 도메인 타입 매핑¶
| 테이블 / 컬럼 | 도메인 타입 / 로직 |
|---|---|
accounts.wallet_address |
Keypair::wallet_address — 0x + keccak256(pubkey[1..])[12..] hex |
accounts.password_hash |
crypto::hash_password(password, PASSWORD_SALT) — SHA256(pw+salt) hex |
accounts.email |
derive_keypair_from_credentials(email, password, AUTH_SALT) 의 입력 |
nodes.device_id, nodes.device_meta
|
(Step C 에서 Verify RPC 가 모바일 노드 자동 등록 시 기록) |
다음 변경 예정¶
Step C 에서 AuthService 핸들러를 구현할 때 nodes.device_id + nodes.device_meta 컬럼이 처음으로 쓰기 경로에 연결됩니다. 그 시점에 마이그레이션은 필요 없지만, 실제 JSONB 페이로드 포맷(DeviceMeta proto 직렬화)은 이 이슈에 다시 기록할 예정입니다.
JL James Lee이(가) 7달 전에 변경
Step C 관련 DB 작업 (스키마 변경 없음, 쿼리 코드 추가)¶
- Commit: commit:server|9b7ffb3
- 관련 이슈: #256 (AuthService 구현)
-
마이그레이션 변경: 없음 — Step A 에서 도입한
accounts/wallets/nodes테이블을 그대로 사용
이번 커밋은 Step A 에서 만들어 둔 스키마에 대해 처음으로 쓰기 경로를 연결한 변경입니다. 새로 추가된 쿼리 코드와 각 테이블에 실제로 어떻게 데이터가 들어가는지 정리합니다.
새로 추가된 store 모듈¶
src/store/accounts.rssrc/store/wallets.rssrc/store/nodes.rs-
src/store/mod.rs를 submodule 구조로 재편성, 기존Database래퍼는src/store/database.rs로 이동
전부 sqlx 의 query_as::<_, T>(sql).bind(...) runtime API 를 사용합니다. sqlx::query!/query_as! 매크로는 빌드 타임에 DB 접속을 요구하므로 현재는 쓰지 않으며, #[derive(FromRow)] 만 macros feature 로 활성화했습니다.
각 테이블에 쓰기 경로가 생긴 쿼리¶
1. accounts — src/store/accounts.rs
| 함수 | SQL |
|---|---|
get_by_wallet(pool, wallet) |
SELECT ... FROM accounts WHERE wallet_address = $1 |
create(pool, wallet, email, referral_code) |
INSERT INTO accounts (wallet_address, email, referral_code) VALUES ($1, $2, $3) RETURNING * |
touch_updated_at(pool, wallet) |
UPDATE accounts SET updated_at = NOW() WHERE wallet_address = $1 |
-
referral_code는 UUID v4 앞 8자를 사용 (uuid::Uuid::new_v4().simple().to_string()[..8]) - 새 지갑으로
Verify를 호출하면 자동으로create가 실행되어 계정이 생성됨 -
updated_at은Verify성공 시 fire-and-forget 으로 갱신 (레거시의 "last active" 트래킹)
2. wallets — src/store/wallets.rs
| 함수 | SQL |
|---|---|
ensure(pool, account_id) |
INSERT INTO wallets (account_id, balance, total_earned) VALUES ($1, 0, 0) ON CONFLICT (account_id) DO NOTHING |
get_balance(pool, wallet) |
SELECT w.balance, w.total_earned FROM wallets w JOIN accounts a ON a.id = w.account_id WHERE a.wallet_address = $1 |
-
ensure는 멱등적. 계정 생성 직후 항상 호출됨 -
get_balance는 지갑 행이 없으면WalletBalance::default()(0, 0) 반환
3. nodes — src/store/nodes.rs
| 함수 | 설명 |
|---|---|
node_id_for(wallet, device_id) |
rn_{short}_{device_id} 포맷 (레거시와 동일). short = wallet[2:10]
|
device_meta_to_json(meta) |
프로토 DeviceMeta → serde_json::Value (빈 필드 제외, 모두 비면 None) |
ensure_mobile_node(pool, wallet, device_id, device_meta) |
디바이스가 있을 때만 mobile 노드 upsert. 없으면 빈 문자열 반환 |
ensure_mobile_node 의 upsert SQL:
INSERT INTO nodes (
node_id, name, node_type, owner_wallet_address, auth_mode, status, tier, role,
device_id, device_meta, device_platform
)
VALUES ($1, $2, $3, $4, 1, $5, $6, 1, $7, $8, $9)
ON CONFLICT (node_id) DO UPDATE SET
device_meta = EXCLUDED.device_meta,
device_platform = EXCLUDED.device_platform,
status = $5,
updated_at = NOW()
바인딩 값:
-
$3node_type = NodeType::Mobile as i32= 2 -
$5status = NodeStatus::Active as i32= 2 -
$6tier = NodeTier::Low as i32= 1 -
auth_mode = 1(INTERNAL),role = 1(WORKER) 고정
Rust 의 proto enum 값은 Step A 에서 정의한 integer 컬럼과 1:1 매칭됩니다.
nodes.device_meta JSONB 직렬화 스펙¶
DeviceMeta proto 필드가 비어있지 않은 것만 포함되며, 키 네이밍은 레거시 (camelCase) 와 동일합니다.
| proto 필드 | JSON 키 |
|---|---|
platform |
platform |
model |
model |
screen_resolution |
screenResolution |
app_version |
appVersion |
timezone |
timezone |
모든 필드가 비어 있으면 NULL 이 저장됩니다.
End-to-End 검증으로 확인된 실제 영속화 결과¶
examples/auth_roundtrip.rs 로 Challenge → Verify 전체를 돌린 직후의 DB 상태:
wallet_address | referral_code
----------------------------------------+---------------
0x7a72e61aaadd48917e5845d41f0f7575831c56af | fb9f95c2
node_id | node_type | owner_wallet_address | device_id | device_meta | device_platform
----------------------------+-----------+--------------------------------------------+----------------+----------------------------------------------------------+-----------------
rn_7a72e61a_demo-device-42 | 2 | 0x7a72e61aaadd48917e5845d41f0f7575831c56af | demo-device-42 | {"model":"iPhone 15 Pro","platform":"ios", ...} | ios
-
node_id포맷 레거시 완전 매칭 -
node_type = 2→ MOBILE -
device_metaJSONB 에 5개 필드 모두 직렬화 확인
다음 변경 예정¶
-
SetPassword / Login / EmailRecover 구현 시
accounts.password_hash및accounts.email컬럼 쓰기 경로 활성화 예정 (현재 스키마로 충족) - JWT 인터셉터 구현 시 DB 변경 없음
- 향후 실제 노드 등록 (엣지 노드 직접 등록) 을 구현하면
nodes.auth_mode = 2(SIGNATURE) 사용 경로가 추가됨
이 이슈 (#254) 에는 위 후속 작업에서 스키마가 바뀔 때마다 계속 기록해 나갑니다.
JL James Lee이(가) 7달 전에 변경
Section 17 Step 1 관련 DB 작업 (스키마 변경 없음, 읽기 쿼리 추가)¶
- Commit: commit:server|7828037
- 관련 이슈: #310 (17-1. 노드 목록 조회), #311 (17-2. 노드 상세 정보)
-
마이그레이션 변경: 없음 — Step A 에서 정의한
nodes테이블 그대로 사용
nodes 테이블에 대한 읽기 쿼리 경로를 처음으로 연결한 변경입니다. 모든 컬럼이 한꺼번에 노출되므로, DB 구조와 proto NodeInfo 매핑을 이 이슈에 확정 기록합니다.
새로 추가된 쿼리¶
1. get_by_id(pool, node_id) → Option<NodeRow>
SELECT {모든 컬럼} FROM nodes WHERE node_id = $1
2. list(pool, node_type?, status?, tier?, limit, offset) → Vec<NodeRow>
SELECT {모든 컬럼} FROM nodes
WHERE ($1::int IS NULL OR node_type = $1)
AND ($2::int IS NULL OR status = $2)
AND ($3::int IS NULL OR tier = $3)
ORDER BY created_at DESC
LIMIT $4 OFFSET $5
- 세 필터 모두 optional.
NULL이면 해당 조건 비활성화 - 핸들러에서
0 (UNSPECIFIED)enum 값도None으로 정규화 후 전달
3. count_filtered(pool, node_type?, status?, tier?) → i64
동일한 WHERE 절로 COUNT(*). ListNodes 응답의 PageInfo.total 을 채우기 위함.
NodeRow ↔ nodes 테이블 완전 매핑¶
모든 30개 컬럼이 #[derive(FromRow)] 로 1:1 매핑됩니다.
| 컬럼 | Rust 타입 | 비고 |
|---|---|---|
id |
i64 |
PK |
node_id |
String |
UNIQUE |
name |
String |
|
node_type |
i32 |
proto NodeType enum 값 |
owner_wallet_address |
String |
|
auth_mode |
i32 |
proto AuthMode enum 값 |
status |
i32 |
proto NodeStatus enum 값 |
tier |
i32 |
proto NodeTier enum 값 |
role |
i32 |
proto NodeRole enum 값 |
max_concurrent_jobs |
i32 |
|
throughput_hint |
i32 |
|
running_jobs |
i32 |
|
contributed_storage_gb |
i32 |
|
contributed_cpu_cores |
i32 |
|
network_bandwidth_mbps |
i32 |
|
score |
i32 |
|
success_rate_1m |
f64 |
|
avg_exec_ms |
f64 |
|
p95_exec_ms |
f64 |
|
timeouts_1m |
i32 |
|
total_jobs_completed |
i64 |
|
total_jobs_failed |
i64 |
|
device_id |
Option<String> |
|
device_meta |
Option<serde_json::Value> |
JSONB |
device_platform |
Option<String> |
|
pool_group |
Option<String> |
|
last_heartbeat_at |
Option<DateTime<Utc>> |
TIMESTAMPTZ |
last_assigned_at |
Option<DateTime<Utc>> |
TIMESTAMPTZ |
created_at |
DateTime<Utc> |
TIMESTAMPTZ |
updated_at |
DateTime<Utc> |
TIMESTAMPTZ |
JSONB device_meta ↔ proto DeviceMeta 왕복 검증¶
Step C 에서 쓰기 방향 (device_meta_to_json) 이 구현됐고, 이번에 읽기 방향 (device_meta_from_json) 이 추가되어 왕복이 완성되었습니다.
이전 세션에서 AuthService.Verify 로 저장된 실제 데이터를 ListNodes / GetNode 로 조회했을 때:
"deviceMeta": {
"platform": "ios",
"model": "iPhone 15 Pro",
"screenResolution": "1290x2796",
"appVersion": "0.1.0",
"timezone": "Asia/Seoul"
}
5개 필드 전부 손실 없이 proto 로 복원됨을 확인했습니다.
다음 변경 예정 (Section 17)¶
-
#312 (17-3) —
RegisterNode구현 시nodes테이블 쓰기 경로가 모바일 외 타입 (EDGE / 가상) 으로 확장됨. 스키마 변경 없음 예상 -
#313 (17-4) — 노드 삭제 구현 시 soft-delete 를 위한
archived_at TIMESTAMPTZ컬럼 추가 마이그레이션 도입 예정. 이 시점에 이 이슈에 다시 기록 -
#314 (17-5) — 수익 계산은
job_receipts/aggregated_earnings테이블이 필요. 이 테이블들은 아직 스키마가 없으므로 새 마이그레이션 필요. 이 시점에 이 이슈에 상세 기록
JL James Lee이(가) 7달 전에 변경
Section 17 Step 2 관련 DB 작업 (스키마 변경 없음, upsert 쿼리 추가)¶
- Commit: commit:server|920cd5c
- 관련 이슈: #312 (17-3. 노드 추가 API)
-
마이그레이션 변경: 없음 — Step A 에서 정의한
nodes테이블 그대로 사용
nodes 테이블에 대한 두 번째 쓰기 경로가 추가되었습니다. Step C 의 ensure_mobile_node (AuthService/Verify 자동 생성) 에 이어, 이번에는 owner-signed 명시적 등록용 upsert 가 붙었습니다.
새로 추가된 쿼리¶
upsert_registered(pool, node_id, owner_wallet, node_type, tier, device_meta) → NodeRow
INSERT INTO nodes (
node_id, name, node_type, owner_wallet_address, auth_mode, status, tier, role,
device_meta, device_platform
)
VALUES ($1, $2, $3, $4, 2, $5, $6, 1, $7, $8)
ON CONFLICT (node_id) DO UPDATE SET
device_meta = EXCLUDED.device_meta,
device_platform = EXCLUDED.device_platform,
status = $5,
updated_at = NOW()
RETURNING {SELECT_COLUMNS}
바인딩:
-
$3 node_type= 요청 (EDGE=1, MOBILE=2, VIRTUAL=3) -
$5 status=NodeStatus::Active as i32= 2 -
$6 tier= 요청 (LOW=1, MID=2, HIGH=3) -
auth_mode = 2(SIGNATURE) 고정 — 명시적 등록이므로 mobile 자동 등록과 구분됨 -
role = 1(WORKER) 고정
ensure_mobile_node vs upsert_registered 비교¶
| 항목 |
ensure_mobile_node (Step C) |
upsert_registered (이번 단계) |
|---|---|---|
| 호출 주체 |
AuthService.Verify — 모바일 앱 로그인 플로우 |
NodeService.RegisterNode — 사용자 직접 노드 추가 |
node_id 생성 |
rn_{short_wallet}_{device_id} 자동 생성 |
클라이언트 제공 (자유 문자열) |
node_type |
항상 MOBILE (2)
|
요청에 따라 EDGE (1) / MOBILE (2) / VIRTUAL (3)
|
tier |
항상 LOW (1)
|
요청에 따라 LOW/MID/HIGH
|
auth_mode |
INTERNAL (1) — 앱 토큰으로 노드를 대신 대리 인증 |
SIGNATURE (2) — 지갑 소유자가 직접 서명 |
| 서명 검증 | 없음 (지갑 JWT 로 간접 인증) | 실제 ECDSA recovery 로 직접 검증 |
| 소유권 체크 | 해당 없음 (항상 본인) | 기존 행의 owner_wallet_address 와 요청 비교 |
| 재등록 시 변경 |
device_meta, status, updated_at
|
동일 (node_type/tier 는 건드리지 않음) |
| ON CONFLICT 행위 |
RETURNING 없음 — node_id 만 반환 |
RETURNING * 으로 최신 NodeRow 반환 |
디자인 원칙: 한 nodes.node_id 는 한 가지 진입 경로를 통해서만 생성되어야 합니다. 모바일 자동 등록은 rn_ 접두사가 붙어서 충돌 가능성이 낮고, 사용자 직접 등록은 사용자가 원하는 식별자를 쓸 수 있습니다.
실제 저장 결과 검증¶
Section 17 Step 2 E2E 예제 (examples/node_register.rs) 실행 후:
node_id | node_type | tier | status | auth_mode | owner_wallet_address | device_platform
---------------+-----------+------+--------+-----------+--------------------------------------------+-----------------
edge-9a778fea | 1 | 2 | 2 | 2 | 0x217d4acd078cad11ab7ca93da7a4368b1393383d | edge
-
node_type = 1→ EDGE ✓ -
tier = 2→ MID ✓ -
status = 2→ ACTIVE ✓ -
auth_mode = 2→ SIGNATURE ✓ -
device_platform = 'edge'✓
다음 변경 예정 (Section 17 Step 3, #313)¶
-
Soft delete:
archived_at TIMESTAMPTZ컬럼 추가 마이그레이션 도입 예정. 노드 삭제 시 물리 삭제가 아닌 archived_at 세팅 -
ListNodes / GetNode 쿼리:
archived_at IS NULL조건을 암묵적으로 추가해서 archived 노드 숨김 - 마이그레이션 번호:
0002_nodes_archived_at.sql(예정) - 이 시점에 이 이슈에 상세 기록 예정
JL James Lee이(가) 7달 전에 변경
Section 17 Step 3 — 마이그레이션 0002: nodes.archived_at 추가¶
- Commit: commit:server|d9668e6
- 관련 이슈: #313 (DeleteNode RPC), #310/#311 (read 쿼리 필터), #312 (upsert 업데이트)
-
마이그레이션 파일:
migrations/0002_nodes_archived_at.sql
Step A (0001) 이후 첫 번째 스키마 확장입니다. 노드 soft delete 를 지원하기 위한 변경입니다.
마이그레이션 SQL¶
ALTER TABLE nodes
ADD COLUMN IF NOT EXISTS archived_at TIMESTAMPTZ;
-- Partial index: only the archived rows are indexed, so the index stays
-- tiny while the hot "active nodes" scans use the existing indexes.
CREATE INDEX IF NOT EXISTS idx_nodes_archived_at
ON nodes (archived_at)
WHERE archived_at IS NOT NULL;
결정 포인트
-
Nullable:
archived_at은NULL허용.NULL = 활성,NOT NULL = 삭제됨. 기본값은 명시적으로 설정하지 않음 (ADD COLUMN 시 자동으로 NULL) -
Partial index: 일반 index 가 아닌
WHERE archived_at IS NOT NULL부분 인덱스 사용. 대부분의 행은 활성 상태 (archived_at IS NULL) 이므로 전체 index 는 낭비. archived 행만 색인 → 인덱스 크기 최소화 -
기존 데이터:
ADD COLUMN은 모든 기존 행을NULL로 채움. 기존 노드는 자동으로 "활성" 상태 유지
코드 레벨 변경¶
NodeRow 구조체
#[derive(Debug, Clone, FromRow)]
pub struct NodeRow {
// ... 30 existing columns ...
pub archived_at: Option<DateTime<Utc>>, // NEW
}
SELECT_COLUMNS 상수에도 archived_at 추가 → 30 → 31개 컬럼
읽기 쿼리 필터 추가 (3개 함수 모두)
// get_by_id
WHERE node_id = $1 AND archived_at IS NULL
// list + count_filtered
WHERE archived_at IS NULL
AND ($1::int IS NULL OR node_type = $1)
AND ($2::int IS NULL OR status = $2)
AND ($3::int IS NULL OR tier = $3)
새 함수
/// Retrieve a node row regardless of archived state.
pub async fn get_by_id_including_archived(pool, node_id) -> Option<NodeRow>;
/// Soft-delete a node by setting archived_at = NOW().
pub async fn archive(pool, node_id) -> bool;
upsert_registered 업데이트 — ON CONFLICT DO UPDATE 에 archived_at = NULL 리셋 추가
ON CONFLICT (node_id) DO UPDATE SET
device_meta = EXCLUDED.device_meta,
device_platform = EXCLUDED.device_platform,
status = $5,
archived_at = NULL, -- NEW: un-archive on re-registration
updated_at = NOW()
nodes 테이블 현재 스키마 (31개 컬럼)¶
| 그룹 | 컬럼 |
|---|---|
| 식별 |
id, node_id, name
|
| 분류 (enum) |
node_type, auth_mode, status, tier, role
|
| 소유권 | owner_wallet_address |
| 용량 |
max_concurrent_jobs, throughput_hint, running_jobs
|
| 리소스 |
contributed_storage_gb, contributed_cpu_cores, network_bandwidth_mbps
|
| 성능 |
score, success_rate_1m, avg_exec_ms, p95_exec_ms, timeouts_1m
|
| 누적 |
total_jobs_completed, total_jobs_failed
|
| 디바이스 |
device_id, device_meta (JSONB), device_platform
|
| 기타 | pool_group |
| 타임스탬프 |
last_heartbeat_at, last_assigned_at, created_at, updated_at, archived_at (신규)
|
인덱스 현황¶
| 인덱스 | 타입 | 용도 |
|---|---|---|
nodes_pkey |
PK (btree) | id |
nodes_node_id_key |
UNIQUE (btree) | node_id |
idx_nodes_owner_wallet |
btree | owner 조회 |
idx_nodes_status |
btree | status 필터 |
idx_nodes_node_type |
btree | node_type 필터 |
idx_nodes_device_id |
btree | device_id 조회 |
idx_nodes_archived_at |
partial btree (WHERE archived_at IS NOT NULL) | archived 행 조회 (신규) |
향후 예정¶
- Section 17 Step 4 (#314) — 첫 새 테이블 도입 예정.
WalletService수익 관련 RPC 는job_receipts/aggregated_earnings테이블을 요구하므로 마이그레이션0003_earnings_schema.sql이 추가될 예정. 이 시점에 이 이슈에 상세 기록 - 이후 job 처리 서비스 포팅 시
job_history테이블 추가 예정
JL James Lee이(가) 7달 전에 변경
마이그레이션 0003: job_receipts 테이블 (신규)¶
- Commit: commit:server|c56e3d6
- 관련 이슈: #314 (WalletService 수익 RPC)
-
마이그레이션 파일:
migrations/0003_job_receipts.sql -
Alphea-ai-edge 첫 번째 신규 테이블 (기존 0001/0002 는
accounts/wallets/nodes및 nodes 확장)
마이그레이션 SQL¶
CREATE TABLE IF NOT EXISTS job_receipts (
id BIGSERIAL PRIMARY KEY,
job_id TEXT NOT NULL,
node_id TEXT NOT NULL,
wallet_address TEXT NOT NULL,
job_type INTEGER NOT NULL,
tier INTEGER NOT NULL,
exec_time_ms INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'COMPLETED',
reward_agx DOUBLE PRECISION NOT NULL,
cpu_seconds DOUBLE PRECISION NOT NULL DEFAULT 0,
network_bytes BIGINT NOT NULL DEFAULT 0,
storage_gb DOUBLE PRECISION NOT NULL DEFAULT 0,
completed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_job_receipts_wallet_time
ON job_receipts (wallet_address, completed_at DESC);
CREATE INDEX IF NOT EXISTS idx_job_receipts_node_time
ON job_receipts (node_id, completed_at DESC);
설계 결정¶
1. FK 제약 없음
wallet_address 와 node_id 는 각각 accounts / nodes 를 논리적으로 참조하지만 외래 키 제약은 걸지 않았습니다:
- 수익 영수증은 감사 레코드이므로 계정/노드가 사라져도 과거 수익 기록은 보존되어야 함
- 레거시와 동일한 느슨한 연결
2. 정규화 선택 — wallet_address 직접 저장
nodes → wallet_address 간접 조회 대신 영수증에 지갑 주소를 직접 중복 저장합니다:
-
GetReceipts/GetEarningsSummary의 핫 경로에서 JOIN 제거 - 노드 소유권 이전 가능성 (향후 기능) 시에도 과거 수익 귀속 불변 유지
3. 두 개의 인덱스
| 인덱스 | 용도 |
|---|---|
(wallet_address, completed_at DESC) |
GetReceipts 페이지네이션 + GetEarningsSummary 세 개 파티션(전체/7일/오늘) 스캔 |
(node_id, completed_at DESC) |
향후 노드별 수익 집계 RPC |
두 인덱스 모두 completed_at DESC 접미사를 포함하여 "최근부터" 조회를 B-tree 순방향 스캔으로 커버합니다.
4. aggregated_earnings 테이블은 의도적으로 제외
레거시는 aggregated_earnings 를 별도 테이블로 두고 일간 집계를 캐시했으나, 이번 포팅에서는:
-
job_receipts직접 집계로도 대시보드 부하 커버 가능 - 단일 SQL 문(아래) 으로 3개 파티션 모두 한 번에 계산
- 복잡도 감소 + 동기화 이슈 제거
SELECT
COALESCE(SUM(reward_agx), 0) AS all_time,
COALESCE(SUM(reward_agx) FILTER (WHERE completed_at >= NOW() - INTERVAL '7 days'), 0) AS week,
COALESCE(SUM(reward_agx) FILTER (WHERE completed_at >= DATE_TRUNC('day', NOW())), 0) AS today
FROM job_receipts
WHERE wallet_address = $1
캐시 테이블은 부하 임계치가 실제로 문제가 될 때 별도 이슈로 도입합니다.
store::receipts 모듈¶
src/store/receipts.rs 신설. 주요 함수:
| 함수 | 용도 |
|---|---|
list_by_wallet(pool, wallet, limit, offset) |
페이지네이션 조회 |
count_by_wallet(pool, wallet) |
PageInfo.total |
summary_for_wallet(pool, wallet) |
(all_time, week, today) 한 번에 |
insert(pool, ...) |
쓰기 경로 (현재는 E2E 예제에서 시드용, 향후 스케줄러에서 사용) |
row_to_proto(row) |
ReceiptRow → proto ReceiptRecord
|
현재 전체 스키마 요약¶
| 버전 | 파일 | 내용 |
|---|---|---|
| 0001 | 0001_initial_schema.sql |
accounts, wallets, nodes (30 컬럼) |
| 0002 | 0002_nodes_archived_at.sql |
nodes.archived_at 추가 + partial index |
| 0003 | 0003_job_receipts.sql |
job_receipts 테이블 + 2 인덱스 (신규) |
향후 예정¶
-
job_history테이블: 스케줄러/dispatcher 포팅 시. 현재 진행 중/실패한 작업 추적 -
aggregated_earnings테이블: 부하 이슈 발생 시 (당분간 불필요) -
virtual_node_*테이블군: 가상 노드 포팅 시 (WalletService.ClaimVirtualNodes+VNodeCreditor엔진) -
referral_*/friends테이블:FriendsService포팅 시 (Section 16)
테이블이 새로 추가될 때마다 이 이슈에 기록 예정.
JL James Lee이(가) 6달 전에 변경
스키마 변경 — commit:server|304d9a1 (refs #390)¶
migrations/0004_accounts_last_active_and_friends.sql¶
accounts 테이블 컬럼 추가
ALTER TABLE accounts
ADD COLUMN IF NOT EXISTS last_active_at TIMESTAMPTZ;
-
AuthService.Verify성공 시touch_last_active()로 스탬핑 -
UserService.GetUser의is_active계산에 사용 (24h 이내 → true)
friends 테이블 신규 생성
CREATE TABLE IF NOT EXISTS friends (
id BIGSERIAL PRIMARY KEY,
inviter_id BIGINT NOT NULL REFERENCES accounts (id) ON DELETE CASCADE,
invitee_id BIGINT NOT NULL REFERENCES accounts (id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (inviter_id, invitee_id)
);
CREATE INDEX IF NOT EXISTS idx_friends_inviter ON friends (inviter_id);
CREATE INDEX IF NOT EXISTS idx_friends_invitee ON friends (invitee_id);
-
UserService.GetUser의friend_count/grade계산에 사용 - 향후
FriendsService포팅 시 이 테이블을 기반으로 확장 예정
JL James Lee이(가) 6달 전에 변경
스키마 추가 — commit:server|ef3f2ba¶
migration 0005_job_history_and_aggregated_earnings.sql¶
job_history (신규)
id, job_id (UNIQUE), type, status, assigned_node_id,
actual_exec_ms, final_reward_agx, completed_at, created_at
인덱스: idx_job_history_node_id, idx_job_history_completed_at
aggregated_earnings (신규)
id, wallet_address, period_date, period_type, total_jobs,
completed_jobs, failed_jobs, total_agx, cpu_agx, network_agx,
storage_agx, created_at, updated_at
UNIQUE (wallet_address, period_date, period_type)
인덱스: idx_aggregated_earnings_wallet
JL James Lee이(가) 6달 전에 변경
스키마 변경 — Supabase Auth 전환 (commit:server|8f52241)¶
마이그레이션 파일: migrations/0006_supabase_auth_schema.sql
변경 내용¶
accounts 테이블 재설계
-
id BIGSERIAL→user_id UUID PRIMARY KEY(GoTrue sub) -
username,password_hash,wallet_address등 기존 컬럼 제거 -
email TEXT,display_name TEXT,profile_icon TEXT유지/추가
wallets 테이블 신설
CREATE TABLE wallets (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL REFERENCES accounts(user_id),
wallet_address TEXT NOT NULL,
...
);
사용자와 보상 지갑의 1:N 관계. wallet은 identity가 아닌 reward destination.
nodes 테이블 컬럼 추가
ALTER TABLE nodes ADD COLUMN IF NOT EXISTS owner_user_id UUID REFERENCES accounts(user_id);
friends 테이블 DROP (wallet identity 기반 referral 시스템 → 폐기)
영향¶
- 기존
accounts데이터 전체 DROP 후 재생성 (마이그레이션 포함 DROP TABLE CASCADE) -
nodes.owner_wallet_address는 유지 (노드 서명 검증용),owner_user_id가 소유자 추적에 사용됨
JL James Lee이(가) 6달 전에 변경
DB 스키마 변경 — 0007_agx_account_scope.sql
¶
commit:server|657e7fa
AGX 회계의 키를 wallet_address → user_id (UUID) 로 통일. 데이터 보존 없이 클린 리라이트.
스키마 변경 요약¶
DROP (wallet 기반 테이블 폐기)
job_receiptsaggregated_earnings
ALTER (dead column 제거)
-
wallets:balance_agx,total_earned_agx컬럼 삭제 (코드에서 한 번도 read/write 된 적 없음)
CREATE (user_id 기반 신규 테이블)
CREATE TABLE job_receipts (
id BIGSERIAL PRIMARY KEY,
job_id TEXT NOT NULL,
node_id TEXT NOT NULL,
user_id UUID NOT NULL, -- 신규 키 (이전엔 wallet_address)
job_type INTEGER NOT NULL,
tier INTEGER NOT NULL,
exec_time_ms INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'COMPLETED',
reward_agx DOUBLE PRECISION NOT NULL,
cpu_seconds DOUBLE PRECISION NOT NULL DEFAULT 0,
network_bytes BIGINT NOT NULL DEFAULT 0,
storage_gb DOUBLE PRECISION NOT NULL DEFAULT 0,
completed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_job_receipts_user_time ON job_receipts (user_id, completed_at DESC);
CREATE INDEX idx_job_receipts_node_time ON job_receipts (node_id, completed_at DESC);
CREATE TABLE aggregated_earnings (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL, -- 신규 키
period_date TEXT NOT NULL,
period_type TEXT NOT NULL DEFAULT 'daily',
total_jobs INT NOT NULL DEFAULT 0,
completed_jobs INT NOT NULL DEFAULT 0,
failed_jobs INT NOT NULL DEFAULT 0,
total_agx DOUBLE PRECISION NOT NULL DEFAULT 0,
cpu_agx DOUBLE PRECISION NOT NULL DEFAULT 0,
network_agx DOUBLE PRECISION NOT NULL DEFAULT 0,
storage_agx DOUBLE PRECISION NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, period_date, period_type)
);
CREATE INDEX idx_aggregated_earnings_user ON aggregated_earnings (user_id, period_date DESC);
-- 신규 ledger: redeem code, admin credit/debit, AI 차감 등 receipt 외의 모든 AGX 변동
CREATE TABLE wallet_adjustments (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL,
delta_agx DOUBLE PRECISION NOT NULL, -- 음수 = 차감, 양수 = 적립
reason TEXT NOT NULL, -- 'redeem' | 'admin_credit' | ...
ref_code TEXT, -- redeem_codes.code 등 외부 식별자
note TEXT,
created_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_wallet_adjustments_user_time ON wallet_adjustments (user_id, created_at DESC);
잔액 계산식 변경¶
balance_agx = Σ job_receipts.reward_agx (status='COMPLETED', user_id=$1)
+ Σ wallet_adjustments.delta_agx (user_id=$1)
total_earned_agx = Σ job_receipts.reward_agx (status='COMPLETED', user_id=$1)
영향¶
- 기존
job_receipts/aggregated_earnings데이터 모두 폐기 (마이그레이션 자동 적용 시) -
wallets테이블은 payout destination 목록 용도로만 남음 (1 user → N wallet)
JL James Lee이(가) 6달 전에 변경
DB 스키마 변경 — 0008_redeem_codes.sql
¶
commit:server|a2df7c8
Redeem Code 시스템 (#420) Phase 1 — 사용권 발급/리딤/관리를 위한 3개 테이블 신설.
신규 테이블¶
CREATE TABLE redeem_codes (
code TEXT PRIMARY KEY,
batch_id UUID, -- NULL = 단일 발급
reward_type SMALLINT NOT NULL, -- 1:AGX_POINTS, 2:AI_MONTHLY, 3:AI_YEARLY, 4:CUSTOM
agx_amount DOUBLE PRECISION NOT NULL DEFAULT 0,
ai_service_days INTEGER NOT NULL DEFAULT 0,
note TEXT,
max_redemptions INTEGER NOT NULL DEFAULT 1,
redemption_count INTEGER NOT NULL DEFAULT 0,
expires_at TIMESTAMPTZ, -- NULL = 무기한
created_by UUID, -- 발급한 admin user_id
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
revoked_at TIMESTAMPTZ,
CHECK (max_redemptions >= 1),
CHECK (redemption_count >= 0),
CHECK (agx_amount >= 0),
CHECK (ai_service_days >= 0)
);
CREATE INDEX idx_redeem_codes_batch ON redeem_codes (batch_id) WHERE batch_id IS NOT NULL;
CREATE INDEX idx_redeem_codes_created_at ON redeem_codes (created_at DESC);
CREATE INDEX idx_redeem_codes_reward_type ON redeem_codes (reward_type);
CREATE TABLE redeem_history (
id BIGSERIAL PRIMARY KEY,
code TEXT NOT NULL REFERENCES redeem_codes(code) ON DELETE CASCADE,
user_id UUID NOT NULL,
redeemed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
agx_credited DOUBLE PRECISION NOT NULL DEFAULT 0,
ai_days_added INTEGER NOT NULL DEFAULT 0,
UNIQUE (code, user_id) -- 동일 사용자의 중복 리딤 차단
);
CREATE INDEX idx_redeem_history_user_time ON redeem_history (user_id, redeemed_at DESC);
CREATE INDEX idx_redeem_history_code ON redeem_history (code);
-- AI Service 사용권 — 단일 만료일 누적 연장 방식
CREATE TABLE ai_service_entitlements (
user_id UUID PRIMARY KEY,
expires_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
키 정책 / 외래키¶
-
redeem_history.user_id는 GoTrue UUID —accounts(user_id)FK 는 걸지 않음 (admin 발급은 사용자 가입 전이라도 가능해야 하므로) -
redeem_history.code는redeem_codes(code)ON DELETE CASCADE — 코드 자체가 삭제되면 이력도 삭제 -
redeem_codes.created_by는 admin user_id — FK 없음 (audit 로그성)
AGX 적립 경로¶
redeem() 호출 시 agx_amount > 0 이면 wallet_adjustments 에 reason='redeem', ref_code=<code> 로 ledger 기록 (별도 컬럼 추가 없음).
→ AGX 잔액 조회는 기존 wallets::get_balance(user_id) 가 자동으로 receipts + adjustments 합산 (Phase 0 #421 에서 도입).
JL James Lee이(가) 6달 전에 변경
DB 부트스트랩 — GoTrue 용 auth schema + pgcrypto¶
commit:server|028596e
GoTrue (supabase/gotrue:v2.158.1) 는 깨끗한 Postgres 에서 자체 마이그레이션을 위해 다음 두 가지가 사전에 필요:
CREATE EXTENSION IF NOT EXISTS pgcrypto; -- gen_random_uuid()
CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- 보조 UUID 함수
CREATE SCHEMA IF NOT EXISTS auth; -- GoTrue 의 모든 테이블 위치
GRANT ALL ON SCHEMA auth TO alphea; -- GoTrue 가 alphea 사용자로 접속
이 SQL 을 db/init/01-gotrue-schema.sql 에 두고 docker-compose 의 postgres 서비스에 ./db/init:/docker-entrypoint-initdb.d:ro 로 마운트했다. postgres entrypoint 가 데이터 디렉토리 첫 부팅 시 자동 실행한다.
기존 데이터가 있는 환경에서는 init script 가 트리거되지 않으므로 동일 SQL 을 수동 실행해야 한다 (#424 노트 참고).
영향¶
- alphea 의 기존
public스키마 테이블에는 영향 없음 - GoTrue 는
auth스키마 안에 자체 테이블 (auth.users, auth.refresh_tokens 등) 생성
JL James Lee이(가) 6달 전에 변경
후속 — Supabase 표준 role 세트 추가¶
commit:server|392fb8a
GoTrue v2.140+ RLS 마이그레이션이 표준 Supabase role 5개 (postgres, anon, authenticated, service_role, supabase_auth_admin) 의 존재 + GRANT 가능성을 가정. db/init/01-gotrue-schema.sql 에 NOLOGIN role 5개 생성 + alphea 에게 멤버십 부여 (PostgreSQL 16+ 의 grantor 멤버십 요구사항).
JL James Lee이(가) 6달 전에 변경
DB 스키마 변경 — Redeem USD-only refactor¶
commit:server|1ce5955
migrations/0009_redeem_codes_usd_only.sql — Redeem 정책 변경(#420)에 따른 클린 리라이트.
삭제¶
-
ai_service_entitlements테이블 (AI Service 기능 폐기) -
redeem_codes.agx_amount,redeem_codes.ai_service_days -
redeem_history.agx_credited,redeem_history.ai_days_added
추가/변경¶
CREATE TABLE redeem_codes (
code TEXT PRIMARY KEY,
batch_id UUID,
reward_type SMALLINT NOT NULL, -- 1=USD_200, 2=USD_1000, 3=USD_2000
usd_amount INTEGER NOT NULL, -- 200 / 1000 / 2000
note TEXT,
max_redemptions INTEGER NOT NULL DEFAULT 1,
redemption_count INTEGER NOT NULL DEFAULT 0,
expires_at TIMESTAMPTZ,
created_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
revoked_at TIMESTAMPTZ,
CHECK (reward_type IN (1, 2, 3)),
CHECK (usd_amount > 0),
CHECK (max_redemptions >= 1),
CHECK (redemption_count >= 0)
);
CREATE TABLE redeem_history (
id BIGSERIAL PRIMARY KEY,
code TEXT NOT NULL REFERENCES redeem_codes(code) ON DELETE CASCADE,
user_id UUID NOT NULL,
redeemed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
usd_credited INTEGER NOT NULL DEFAULT 0,
UNIQUE (code, user_id)
);
기존 redeem_* 데이터는 폐기 (Phase 0 / 0007 의 클린 리라이트 패턴 일관 적용).
Redeem 적립 경로 변경¶
- 이전:
redeem()가wallet_adjustments에 AGX 적립 +ai_service_entitlementsupsert - 이후: 둘 다 안 함.
redeem_history.usd_credited에 USD 가치만 기록. -
wallet_adjustments테이블 자체는 유지 (admin manual credit 등 향후 용도).
JL James Lee이(가) 6달 전에 변경
DB 스키마 변경 — redeem_codes.qr_png BYTEA 추가¶
commit:server|3a456f2
migrations/0010_redeem_codes_qr_png.sql — additive 마이그레이션.
ALTER TABLE redeem_codes ADD COLUMN IF NOT EXISTS qr_png BYTEA;
목적¶
발급된 redeem 코드의 QR PNG 이미지를 DB 에 함께 보관. 어드민 UI 가 외부 JS 라이브러리 없이 <img src="/api/.../qr.png"> 로 직접 표시하고, 외부 시스템(인쇄 자동화 등) 도 같은 endpoint 로 가져다 쓸 수 있다.
정책¶
-
NULLABLE— 기존 row 폐기 없이 적용 가능. PostgreSQL TOAST 가 BYTEA 를 자동으로 out-of-line 저장하므로redeem_codes일반 쿼리 성능 영향 없음. - 신규 INSERT 는 store 레이어에서 항상 PNG 채움.
- 기존 row (NULL) 는 admin endpoint 가 첫 호출 시 on-the-fly 생성.
사이즈¶
- 256×256 흑백 PNG, "ALPHEA-XXXX-XXXX-XXXX" 코드 하나당 약 200-400 byte. 1만 row 기준 ~3MB 미만.
JL James Lee이(가) 6달 전에 변경
DB 스키마 변경 — redeem_codes 에 phone + last_sms_sent_at¶
commit:server|b021104
migrations/0011_redeem_codes_phone.sql — additive (기존 데이터 보존).
ALTER TABLE redeem_codes
ADD COLUMN IF NOT EXISTS phone TEXT,
ADD COLUMN IF NOT EXISTS last_sms_sent_at TIMESTAMPTZ;
용도¶
-
phone— redeem 코드 발급 시 수령자 전화번호 (optional). Detail 패널에서 추후 편집 가능. -
last_sms_sent_at— MMS 발송 시각 stamp. UI 의 "최근 발송" 표시 + 중복 발송 방지 판단 근거 (현재는 표시만).
정책¶
- 둘 다 NULLABLE — 기존 row 자동 적용 가능, 신규 row 도 phone 미지정 시 NULL.
-
last_sms_sent_at는redeem::stamp_sms_sent()가 발송 성공 후에만 갱신.
향후 (Phase B real provider 추가 시) 추가 컬럼 가능성¶
-
sms_provider TEXT— 어느 provider 가 발송했는지 -
sms_provider_message_id TEXT— provider 의 외부 ID (delivery 추적용) - 별도
sms_log테이블 — 발송 이력 (성공/실패 상세, 비용 등)
지금 단계에서는 굳이 추가하지 않음 — Phase B 에서 실제 provider 요구에 맞춰 결정.
JL James Lee이(가) 6달 전에 변경
DB 스키마 변경 — redeem_codes 에 email + last_email_sent_at¶
commit:server|49543d4
migrations/0012_redeem_codes_email.sql — additive (기존 데이터 보존).
ALTER TABLE redeem_codes
ADD COLUMN IF NOT EXISTS email TEXT,
ADD COLUMN IF NOT EXISTS last_email_sent_at TIMESTAMPTZ;
용도¶
-
email— redeem 코드의 기본 발송 채널 수령자 주소 (#420/#426) -
last_email_sent_at— 마지막 SES 발송 시각 stamp (UI "최근 발송" 표시)
정책¶
- 둘 다 NULLABLE — 기존 row 자동 적용 가능
-
last_email_sent_at는redeem::stamp_email_sent()가 발송 성공 후 갱신 - email 은 #426 의 default delivery channel. phone (
last_sms_sent_at) 은 secondary.
향후 추가 가능성 (Phase B)¶
-
redeem_code_deliveries별도 감사 테이블 —channel,status,error_message,provider_msg_id,retry_count등 상세 추적 (현재는last_*_sent_atstamp 만)
JL James Lee이(가) 6달 전에 변경
마이그레이션 추가 — 0013_virtual_nodes.sql (Phase 7, 가상 노드 적립)¶
commit:server|d4d49f9
#432 Phase 7 (VirtualNodeCreditor) 와 함께 추가된 새 테이블 3개.
virtual_node_packages¶
구매 가능한 가상 노드 패키지 카탈로그. 정보용이며 assignment 가 패키지 없이도 존재 가능 (admin 발급 / redeem-bound).
CREATE TABLE virtual_node_packages (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
node_count INTEGER NOT NULL,
jobs_per_second INTEGER NOT NULL DEFAULT 10,
daily_job_cap INTEGER NOT NULL DEFAULT 100000,
reward_per_job DOUBLE PRECISION NOT NULL DEFAULT 0.01,
duration_days INTEGER NOT NULL DEFAULT 30,
price_usd DOUBLE PRECISION NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
virtual_node_assignments¶
user 별 가상 노드 grant. status 전이: active → expired (creditor 가 end_time < NOW() 발견) | active → revoked (admin).
CREATE TABLE virtual_node_assignments (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL,
package_id BIGINT REFERENCES virtual_node_packages(id),
node_count INTEGER NOT NULL,
jobs_per_second INTEGER NOT NULL DEFAULT 10,
daily_job_cap INTEGER NOT NULL DEFAULT 100000,
reward_per_job DOUBLE PRECISION NOT NULL DEFAULT 0.01,
status TEXT NOT NULL DEFAULT 'active',
start_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
end_time TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_vna_user ON virtual_node_assignments (user_id);
CREATE INDEX idx_vna_status ON virtual_node_assignments (status);
virtual_node_credits¶
(assignment, period) 단위 적립 기록. 한 row 당 한 tick 기간의 누적 결과. 마지막 row 의 period_end 가 다음 tick 의 cursor.
CREATE TABLE virtual_node_credits (
id BIGSERIAL PRIMARY KEY,
assignment_id BIGINT NOT NULL REFERENCES virtual_node_assignments(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
period_start TIMESTAMPTZ NOT NULL,
period_end TIMESTAMPTZ NOT NULL,
node_count INTEGER NOT NULL,
completed_jobs BIGINT NOT NULL,
agx_earned DOUBLE PRECISION NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_vnc_user ON virtual_node_credits (user_id, created_at DESC);
CREATE INDEX idx_vnc_assignment ON virtual_node_credits (assignment_id, period_end DESC);
잔액 통합¶
store::wallets::get_balance 가 virtual_node_credits.agx_earned 도 합산하도록 동시 수정:
total_earned_agx = SUM(job_receipts.reward_agx WHERE COMPLETED) + SUM(virtual_node_credits.agx_earned)balance_agx = total_earned_agx + SUM(wallet_adjustments.delta_agx)
→ 가상 노드 적립이 별도 회계 경로 없이 사용자 잔액에 자동 반영.
모든 테이블 user_id (UUID) keyed¶
#421 의 account-scope 결정과 정합. wallet_address 키는 사용하지 않음.
JL James Lee이(가) 6달 전에 변경
마이그레이션 추가 — 0014_vfs_nodes.sql (#433, VfsService)¶
commit:server|fed4ea0
#433 (VfsService 포팅) 과 함께 추가된 새 테이블 1개.
vfs_nodes¶
per-account 가상 파일시스템 노드 (file 또는 directory). account-scope (user_id UUID, post-#421). default tree 16 entry 는 첫 접근 시 store::vfs::ensure_init 가 트랜잭션으로 seed.
CREATE TABLE vfs_nodes (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL,
path TEXT NOT NULL,
name TEXT NOT NULL,
node_type TEXT NOT NULL, -- 'file' | 'directory'
content TEXT,
mime_type TEXT,
size BIGINT NOT NULL DEFAULT 0,
readonly BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
modified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, path)
);
CREATE INDEX idx_vfs_user_path ON vfs_nodes (user_id, path);
Schema 결정¶
-
TIMESTAMPTZ (vs legacy BIGINT unix-seconds) — Rust 측 다른 테이블과 통일. proto wire 에서는
.timestamp()로 변환. - (user_id, path) UNIQUE — 동일 사용자의 동일 경로 중복 방지. write 가 ON CONFLICT UPDATE 로 idempotent.
- content TEXT — 작은 텍스트 파일만 지원 (legacy 동등). 큰 binary 는 별도 blob storage 가 필요할 때 추가 컬럼 또는 별도 테이블로 확장.
-
readonly —
/modules,/sys시스템 경로 보호. 핸들러가 prefix 체크로 한 번 더 거름.
post-#421 정합성¶
user_id UUID 키, wallet_address 미사용. proto 의 wallet_address 필드는 wire 호환을 위해 유지하되 핸들러가 무시.
JL James Lee이(가) 6달 전에 변경
DB 스키마 v12 — GoTrue 인증 도입 (Phase 1)¶
commit:server|03c6c64 (이슈 #434)
internal/store/migrations.go 에 v12 추가. clean rewrite 정책에 따라 식별 관련 테이블은 운영 데이터를 보존하지 않는다 (scripts/ec2/reset-db.sh 과 호환).
스키마 변경 내역¶
-- TRUNCATE (clean rewrite)
TRUNCATE wallet_comments, rewards_log, referral_claims, friends, wallets CASCADE;
TRUNCATE accounts RESTART IDENTITY CASCADE;
TRUNCATE nodes RESTART IDENTITY CASCADE;
-- accounts — GoTrue user_id 를 새 식별 컬럼으로
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS user_id UUID;
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS display_name TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_user_id ON accounts(user_id);
ALTER TABLE accounts ALTER COLUMN wallet_address DROP NOT NULL;
ALTER TABLE accounts ALTER COLUMN account_type DROP NOT NULL;
-- nodes — owner_user_id 도입
ALTER TABLE nodes ADD COLUMN IF NOT EXISTS owner_user_id UUID;
CREATE INDEX IF NOT EXISTS idx_nodes_owner_user_id ON nodes(owner_user_id);
ALTER TABLE nodes ALTER COLUMN owner_wallet_address DROP NOT NULL;
GoTrue 자체의 auth 스키마는 Postgres 첫 기동 시 alphea-rpc/db/init/01-gotrue-schema.sql 이 pgcrypto / uuid-ossp 확장 및 Supabase role 5종을 생성한 뒤 GoTrue 마이그레이션이 알아서 채워넣는다.
Phase 2 에서 추가 예정인 스키마 재설계 (별도 이슈)¶
-
wallets→ (user_id, wallet_address, label, created_at, UNIQUE(user_id, wallet_address)) 1:N 구조로 교체 -
job_receipts/aggregated_earnings에user_id UUID추가 + 인덱스 -
wallet_adjustmentsledger 테이블 신규
JL James Lee이(가) 6달 전에 변경
DB 스키마 v13 — AGX 회계 user_id 종속 (Phase 2)¶
commit:server|61f09e9 (이슈 #435)
internal/store/migrations.go 에 v13 추가. clean rewrite 정책에 따라 wallets / job_receipts / aggregated_earnings 의 dev 데이터는 보존하지 않는다.
스키마 변경 내역¶
-- 1) wallets 재설계 — 1:N user→wallet
DROP TABLE IF EXISTS wallets CASCADE;
CREATE TABLE wallets (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL,
wallet_address TEXT NOT NULL,
label TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, wallet_address)
);
CREATE INDEX idx_wallets_user_id ON wallets(user_id);
CREATE INDEX idx_wallets_wallet_address ON wallets(wallet_address);
-- 2) job_receipts.user_id
TRUNCATE job_receipts;
ALTER TABLE job_receipts ADD COLUMN IF NOT EXISTS user_id UUID;
CREATE INDEX IF NOT EXISTS idx_job_receipts_user_id
ON job_receipts(user_id, completed_at DESC);
-- 3) aggregated_earnings.user_id + 고유 인덱스
TRUNCATE aggregated_earnings;
ALTER TABLE aggregated_earnings ADD COLUMN IF NOT EXISTS user_id UUID;
CREATE UNIQUE INDEX IF NOT EXISTS idx_aggregated_earnings_user_period
ON aggregated_earnings(user_id, period_date, period_type);
CREATE INDEX IF NOT EXISTS idx_aggregated_earnings_user_date
ON aggregated_earnings(user_id, period_date);
-- 4) wallet_adjustments — account-scope ledger
CREATE TABLE IF NOT EXISTS wallet_adjustments (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL,
delta_agx DOUBLE PRECISION NOT NULL,
reason TEXT NOT NULL,
ref_code TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_wallet_adjustments_user_id
ON wallet_adjustments(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_wallet_adjustments_ref_code
ON wallet_adjustments(ref_code) WHERE ref_code IS NOT NULL;
잔액 공식¶
balance = Σ job_receipts.reward_agx WHERE user_id=$1 AND status='completed' + Σ wallet_adjustments.delta_agx WHERE user_id=$1
Phase 3 에서 추가 예정¶
-
friends.inviter_id/invitee_id→ user_id -
referral_claims.account_id/rewards_log.account_id→ user_id -
nodes.owner_wallet_address제거 -
virtual_node_*테이블의 user_id 컴럼 추가
JL James Lee이(가) 6달 전에 변경
DB 스키마 v14 — FriendsService user_id 전환 (Phase 4)¶
commit:server|de617cf (이슈 #437)
internal/store/migrations.go 에 v14 추가. clean rewrite 정책에 따라 세 테이블 dev 데이터는 보존하지 않는다.
스키마 변경 내역¶
-- friends 재설계 — (inviter_user_id, invitee_user_id) 1:N
DROP TABLE IF EXISTS friends CASCADE;
CREATE TABLE friends (
id BIGSERIAL PRIMARY KEY,
inviter_user_id UUID NOT NULL,
invitee_user_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (inviter_user_id, invitee_user_id)
);
CREATE INDEX idx_friends_inviter_user_id ON friends(inviter_user_id);
CREATE INDEX idx_friends_invitee_user_id ON friends(invitee_user_id);
-- referral_claims 재설계 — user_id 키
DROP TABLE IF EXISTS referral_claims CASCADE;
CREATE TABLE referral_claims (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL,
reward_type TEXT NOT NULL,
reward_amount DOUBLE PRECISION NOT NULL,
claimed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_referral_claims_user_id
ON referral_claims(user_id, claimed_at DESC);
-- rewards_log 재설계 — user_id 키
DROP TABLE IF EXISTS rewards_log CASCADE;
CREATE TABLE rewards_log (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL,
job_id TEXT NOT NULL,
amount DOUBLE PRECISION NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_rewards_log_user_id
ON rewards_log(user_id, created_at DESC);
유지¶
-
accounts.referral_codeUNIQUE 컴럼 유지 (referral code 발급 주체는 account)
Phase 5+ 에서 추가 예정¶
-
nodes.owner_wallet_address제거 -
virtual_node_assignments/virtual_node_credits/virtual_node_packagesuser_id 직접 키로 전환
JL James Lee이(가) 6달 전에 변경
DB 스키마 v15 — 레거시 wallet_address 제거 (Phase 5)¶
commit:server|dc8ad6e (이슈 #438)
internal/store/migrations.go 에 v15 추가. clean rewrite 정책.
스키마 변경¶
-- 1) nodes 소유권 컬럼 제거
ALTER TABLE nodes DROP COLUMN IF EXISTS owner_wallet_address;
-- 2) virtual_node_* 재설계
DROP TABLE IF EXISTS virtual_node_credits CASCADE;
DROP TABLE IF EXISTS virtual_node_packages CASCADE;
DROP TABLE IF EXISTS virtual_node_assignments CASCADE;
DROP TABLE IF EXISTS virtual_node_claim_events CASCADE;
CREATE TABLE virtual_node_assignments (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL,
node_count INTEGER NOT NULL DEFAULT 1,
jobs_per_second BIGINT NOT NULL DEFAULT 0,
daily_job_cap BIGINT NOT NULL DEFAULT 0,
reward_per_job DOUBLE PRECISION NOT NULL DEFAULT 0,
agx_rate DOUBLE PRECISION NOT NULL DEFAULT 1.0,
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'active',
memo TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_vna_user_status ON virtual_node_assignments(user_id, status);
CREATE TABLE virtual_node_credits (
id BIGSERIAL PRIMARY KEY,
assignment_id BIGINT NOT NULL REFERENCES virtual_node_assignments(id),
user_id UUID NOT NULL,
period_start TIMESTAMPTZ NOT NULL,
period_end TIMESTAMPTZ NOT NULL,
node_count INTEGER NOT NULL,
agx_earned DOUBLE PRECISION NOT NULL,
credited_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_vnc_user_id ON virtual_node_credits(user_id);
CREATE INDEX idx_vnc_assignment ON virtual_node_credits(assignment_id, period_end DESC);
CREATE TABLE virtual_node_packages (
id BIGSERIAL PRIMARY KEY,
claim_code TEXT NOT NULL UNIQUE,
node_count INTEGER NOT NULL CHECK (node_count > 0 AND node_count % 20 = 0),
agx_rate DOUBLE PRECISION NOT NULL DEFAULT 1.0,
valid_years INTEGER NOT NULL DEFAULT 0,
jobs_per_second BIGINT NOT NULL DEFAULT 0,
daily_job_cap BIGINT NOT NULL DEFAULT 0,
reward_per_job DOUBLE PRECISION NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
claimed_user_id UUID,
claimed_assignment_id BIGINT REFERENCES virtual_node_assignments(id),
claimed_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
memo TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_vnp_status ON virtual_node_packages(status, created_at DESC);
CREATE INDEX idx_vnp_claimed_user ON virtual_node_packages(claimed_user_id);
CREATE TABLE virtual_node_claim_events (
id BIGSERIAL PRIMARY KEY,
package_id BIGINT NOT NULL REFERENCES virtual_node_packages(id),
event_type TEXT NOT NULL,
user_id UUID,
details TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_vnce_package ON virtual_node_claim_events(package_id, created_at DESC);
남은 wallet_address 사용자¶
소유권/identity 경로에서는 완전히 사라졌고, 다음 테이블만 텍스트 컬럼으로 유지:
-
wallets— 레거시 registry (1:N user→wallet) -
job_receipts.wallet_address— 원공 receipt metadata -
aggregated_earnings.wallet_address— metadata -
vfs_nodes.wallet_address— VFS 콘테이너 소유 key -
access_logs.wallet_address— audit log metadata -
wallet_comments— admin 메모
Phase 종결¶
Go 원본 GoTrue 백포트가 v15 으로 완결. 이후 스키마 변경은 새 기능/추가 자어 구축 단계에서 진행.
JL James Lee이(가) 6달 전에 변경
Deploy 운영 문서에 v15 / GoTrue init 반영¶
commit:server|ac96cc2
docs/10-deployment.md + docs/11-ec2-deployment-runbook.md 에 v15 스키마까지의 흐름 반영:
- 테이블
_meta.schema_version = v15 -
alphea-rpc/db/init/01-gotrue-schema.sql마운트 (pgcrypto, uuid-ossp,authschema, Supabase role 5종) - v11 이하에서 v15로 올릴 때 reset 이 가장 단순한 경로임을 명시, 데이터 보존 경로도 병기
- post-reset smoke check에
[migrate] done version=15관찰 포함
스키마 변경 없이 운영 관점 보완이므로 이슈 상태는 그대로.
HP Henry Park이(가) 6달 전에 변경
v19 — vfs_nodes drop (Phase 1C 동반)¶
commit:server2|a2f34ff (refs #446)
스키마 변경¶
-
migrateV19DropVfsNodes:DROP TABLE IF EXISTS vfs_nodes - v1 baseline 의
CREATE TABLE vfs_nodes ...는 immutable-migrations 규칙대로 그대로 유지 — fresh DB 는 v1 에서 만들고 v19 에서 즉시 drop. 기존 dev/prod DB 는 v19 마이그레이션이 적용되면서 테이블이 사라짐 - DROP CASCADE 필요 없음 —
vfs_nodes에 다른 테이블의 FK 없음 (독립 테이블)
이유¶
Phase 1C 에서 VfsService 가 완전히 폐기됨 — proto/handler/store/domain 전부 제거. vfs_nodes 의 reader/writer 가 하나도 없으므로 도르만트 상태가 아닌 완전 drop 이 맞음. DfsService (Phase 1B) 가 별도 스키마 (dfs_namespaces / dfs_files / dfs_file_versions, v17/v18) 로 이미 대체 완료
운영 영향¶
- fresh DB: 자동 적용. 기존 운영 절차와 동일
- 기존 dev/qa DB (vfs_nodes 에 데이터 있음): v19 적용 시 테이블 전체 삭제됨. VfsService 가 이미 폐기되었으니 수집할 데이터가 없음 — 별도 백업/마이그레이션 불필요
-
production: 해당 없음 (
next브랜치에 포함되지 않음,feat/dfs-poc에서만 적용). Phase 1 완성 후 monitor 통합 시점에next로 일괄 merge 예정
현재 스키마 버전¶
_meta.schema_version = 19 (runMigrations 가 len(migrations) 로 자동 산출)
HP Henry Park이(가) 6달 전에 변경
v20 — delta_apps (Phase 2 Delta Registry)¶
commit:server2|48b17a0 (refs #446)
스키마 신규¶
CREATE TABLE IF NOT EXISTS delta_apps (
id BIGSERIAL PRIMARY KEY,
namespace_id BIGINT NOT NULL REFERENCES dfs_namespaces(id) ON DELETE CASCADE,
app_id TEXT NOT NULL,
app_path TEXT NOT NULL CHECK (app_path LIKE '/%'),
manifest_path TEXT NOT NULL CHECK (manifest_path LIKE '/%'),
runtime_type TEXT NOT NULL,
entry TEXT,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
current_version TEXT,
digest TEXT,
owner_principal_type TEXT NOT NULL
CHECK (owner_principal_type IN ('user','wallet')),
owner_principal_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (namespace_id, app_id)
);
CREATE INDEX idx_delta_apps_namespace_enabled
ON delta_apps(namespace_id, enabled, app_id);
CREATE INDEX idx_delta_apps_owner_principal
ON delta_apps(owner_principal_type, owner_principal_id);
설계 구현 이유¶
-
namespace_idFK +ON DELETE CASCADE: namespace 가 삭제되면 등록된 앱도 자동 제거. orphan delta_apps 불가능 -
UNIQUE(namespace_id, app_id): 등록 아이델티티. RegisterDeltaApp 는 ON CONFLICT 으로 UPSERT — 같은 앱 재등록 은 새 행 대신 기존 행 update -
owner_principal_typeCHECK in ('user','wallet'): Phase 0.7 Principal 모델 일관. token namespace 경우 wallet 동일은 보류 (token namespace 자체가 owner principal NULL이라 현재 쓰기 흐름 X) -
app_path/manifest_pathCHECKLIKE '/%': Phase 1 dFS path normalization 철학 일관 (NormalizeDFSPath 는 쓰기 경로에서 호출) - 인덱스 2개: namespace 당 enabled 앱 나열 조회 (
idx_delta_apps_namespace_enabled), owner principal 기준 모든 namespace 가로지르는 조회 (idx_delta_apps_owner_principal, Phase 4 이후 admin 대시보드 용)
연관 테이블¶
-
dfs_files / dfs_file_versions: 변경 없음. 다만
RegisterDeltaApp이 단일 트랜잭션 안에서 namespace 의/manifest.json에 새 dfs_file_versions row 추가 (system-managed write path). 파일 API 의미론은 그대로 —/manifest.json은 readonly 여전히 공개 PutFile 로 쓰기 불가
운영 영향¶
- fresh DB: 자동 적용, 운영 절차 없음
- 기존 dev/qa DB: v20 적용 시 테이블 생성 만 — 기존 데이터 영향 없음
- production: 해당 없음 (
next브랜치 미적용,feat/dfs-poc전용)
현재 스키마 버전¶
_meta.schema_version = 20