feat(canary): Phase 1 — Postgres schema + token & event repositories
Schema (3 goose migrations under internal/core/migrations/):
- 0001_create_tokens.sql: tokens table per spec §7.1
Columns: id (varchar 12, PK), manage_id (UUID unique), type, memo,
filename, alert_channel, telegram_bot/chat, webhook_url,
created_at/ip/fp, enabled, trigger_count, last_triggered, metadata (jsonb)
CHECK constraints: chk_token_type (7-token enum), chk_alert_channel (telegram|webhook),
chk_telegram_complete (when channel=telegram, bot+chat required),
chk_webhook_complete (when channel=webhook, url required)
- 0002_create_events.sql: events table
Columns: id (bigserial PK), token_id (FK with ON DELETE CASCADE),
triggered_at, source_ip (inet), user_agent, referer,
geo_country/region/city/asn/asn_org, extra (jsonb),
notify_status, notified_at
CHECK chk_notify_status (pending|sent|failed|deduped)
- 0003_indexes.sql: full index set per spec §7.1
tokens(created_ip), tokens(created_fp), tokens(created_at DESC),
tokens(type), partial idx_tokens_trigger_count WHERE trigger_count > 0,
events(token_id, triggered_at DESC), events(source_ip),
partial idx_events_notify_pending WHERE notify_status = 'pending'
Migration runner (internal/core/migrations.go):
- go:embed migrations/*.sql baked into binary
- core.RunMigrations(*sql.DB) called from main.go after DB connect
- pressly/goose v3.27.1 (research-recommended; no dirty-state bug)
Token domain (internal/token/):
- entity.go: Token struct + typed Type/AlertChannel enums with Valid() guards
- dto.go: CreateRequest with validator/v10 tags (oneof, required_if, url, max),
Response shape with ToResponse(triggerURL, manageURL) helper
- repository.go: Insert (RETURNING created_at + counters), GetByID,
GetByManageID, DeleteByManageID (FK cascade), IncrementTriggerCount,
SetEnabled, ListAll + CountAll. ErrNotFound on sql.ErrNoRows.
Named-parameter binding via sqlx.NamedExecContext.
- repository_test.go (//go:build integration): 11 tests covering insert,
get-by-id, get-by-manage-id, not-found paths, delete with cascade,
trigger count increment, enable toggle, list pagination, type CHECK
Event domain (internal/event/):
- entity.go: Event struct + NotifyStatus enum
- dto.go: GeoView + Response with ToResponse() flattens geo into nested object
- repository.go: Insert, GetByID, ListByToken (cursor pagination via
LIMIT N+1, returns NextCursor + HasMore), CountByToken,
AttachFingerprint (UPDATE most-recent within window using
jsonb || merge), UpdateNotifyStatus, PruneToLimit
(window function row_number() PARTITION BY token_id)
- repository_test.go (//go:build integration): 9 tests covering insert,
cursor-paginated listing, FK cascade from token deletion, fingerprint
merge into existing extra jsonb, notify status update, prune-to-N
preserves newest-first, prune rejects zero limit
Test infrastructure:
- internal/testutil/postgres.go: testcontainers-go helper that spins up
postgres:18-alpine, applies migrations via core.RunMigrations, returns
ready *sql.DB with t.Cleanup-registered teardown. ~5-7s per test;
20 integration tests run in ~13s end-to-end.
Wired into main.go:
- core.RunMigrations(db.DB.DB) called after NewDatabase
- token + event repos instantiated; blank-assigned for now (services
consume them in Phase 9/10)
Verification:
- go build ./... clean
- go vet ./... clean
- go test -tags=integration ./internal/token/... 11 of 11 PASS
- go test -tags=integration ./internal/event/... 9 of 9 PASS