Chuyển store sang sqlc
Tổng quan
Thay các query pgx viết tay trong internal/store/pg bằng code do sqlc sinh ra từ các file SQL và các migration goose. sqlc trở thành mặc định cho mọi query; pgx viết tay chỉ còn được phép cho một query sqlc không biểu đạt được, kèm một comment nói rõ vì sao. Không gì phía trên store thay đổi: store.Store, store.Scoped và mọi interface sub-store giữ nguyên chữ ký, và các test của phase 2 được mang sang không sửa như bằng chứng rằng hành vi không đổi. Phase này cố ý đảo ngược quyết định “SQL viết tay, không sqlc” của phase 2, để học sqlc trong một codebase thật khi store còn nhỏ (năm file query) và trước khi phase 4 sao chép pattern của nó. GitHub issue #26.
Yêu cầu
Chức năng
- Mọi query trong
internal/store/pgchạy qua code do sqlc sinh ra, hoặc là một lối thoát có ghi chú. Mọi query của phase 2 đều biểu đạt được, nên phase này dự kiến ra đời mà không có lối thoát nào. - Các predicate tùy chọn dùng
sqlc.narg: bộ lọc chỉ-của-member trênusers,api_keysvàaudit_events, và các bộ lọcsince,actionvàtarget_idtrên audit event. Văn bản câu lệnh là cố định; chỉ có tham số thay đổi. - Quy tắc tenant của phase 2 không đổi và giờ nằm trong các file SQL: mọi câu lệnh trên một bảng thuộc tenant đều nhận
tenant_idlàm tham số bắt buộc, chỉ được bind từ scope bên trongscoped. Không method được sinh ra nào được gọi với một tenant id từ nơi nào khác. ResolveKeyvẫn là query không có tenant duy nhất trên một bảng thuộc tenant, vẫn chỉ với tới được quastore.AuthLookup.audit_eventsvẫn là chỉ-thêm: file query audit và Go được sinh ra từ nó chỉ có một INSERT và một SELECT, không gì khác.
Phi chức năng
- Code được sinh ra nằm ngay trong package
internal/store/pg(sqlcout: internal/store/pg,package: pg). Một package được sinh ra riêng sẽ là package thứ hai mươi mốt và phá giới hạn số package trongplan.md. - sqlc được ghim phiên bản trong
Makefilevà chạy bằnggo run github.com/sqlc-dev/sqlc/cmd/sqlc@<version>. Nó không được thêm vàogo.mod, nên cây dependency của nó không bao giờ lọt vào module graph hay binary. - Kiểu:
sql_package: pgx/v5,emit_pointers_for_null_types: true,emit_db_tags: true, các override ánh xạuuidsanguuid.UUIDcủa stdlib Go 1.27 (*uuid.UUIDkhi nullable),timestamptzsangtime.Time(*time.Timekhi nullable),jsonbsangjson.RawMessage, và các override cộtusers.rolesangstore.Rolevàtenants.kindsangstore.TenantKind. Không kiểupgtypenào xuất hiện trong một struct được sinh ra. make generatesinh lại;make sqlc-checkchạysqlc diffvàsqlc vetvà thất bại khi code đã commit bị cũ. Hiện chưa có CI; job CI của phase 16 chạymake sqlc-check.- Tooling của roadmap chấp nhận một số phase thập phân:
build_plan_html.govàplan-template.htmlcoi2.1là một phase, build order liệt kê nó, và site Hugo hiển thị nó giữa phase 2 và 3.
Kiến trúc
migrations/*.sql ──(schema)──┐
├── sqlc generate ──> internal/store/pg/{db,models,*.sql}.go
internal/store/pg/queries/*.sql ┘ (package pg: Queries, DBTX, row structs)
store.For(ctx) ──> scoped{q, scope} ──> New(s.q).ListUsers(ctx, ListUsersParams{TenantID: s.scope.TenantID, ...})
│
querier (pool or tx) — unchanged-- internal/store/pg/queries/users.sql (shape, not final text)
-- name: ListUsers :many
select id, tenant_id, email, role, status, created_at
from users
where tenant_id = @tenant_id
and (sqlc.narg('only_user')::uuid is null or id = sqlc.narg('only_user'))
order by created_at;// internal/store/pg/user.go (shape)
func (us userStore) List(ctx context.Context) ([]store.User, error) {
rows, err := New(us.s.q).ListUsers(ctx, ListUsersParams{
TenantID: us.s.scope.TenantID,
OnlyUser: us.s.ownUserID(), // nil for owner and admin
})
return mapRows(rows, toUser), mapErr(err)
}queriergiữBegin, nênInTxkhông đổi; nó thỏa mãnDBTXđược sinh ra, nênNew(s.q)hoạt động trên pool và bên trong một transaction như nhau.- Mỗi file viết tay giữ kiểu sub-store của nó và trở thành một mapper mỏng: dựng params từ scope, gọi method được sinh ra, chuyển struct row được sinh ra sang kiểu
store, ánh xạ lỗi bằngmapErr. ownRowsOnly()trở thànhownUserID() *uuid.UUID, trả về user id của scope với một member và nil trong các trường hợp còn lại, đúng thứ predicatesqlc.nargcần.
Instrumentation
Không có span hay metric mới. sqlc đặt -- name: ListUsers :many ở đầu mỗi câu lệnh nó gửi đi, nên thuộc tính db.query.text trên các span otelpgx giờ bắt đầu bằng tên query (tên span vẫn là động từ SQL), giúp tìm được một query chậm trong Tempo theo tên. Learning doc cho thấy điều này.
Các file liên quan
Tạo mới
sqlc.yaml— engine, schema (migrations), query, package đầu ra và các override kiểu.internal/store/pg/queries/tenants.sql,internal/store/pg/queries/users.sql,internal/store/pg/queries/api_keys.sql,internal/store/pg/queries/audit.sql,internal/store/pg/queries/auth.sql.internal/store/pg/db.go,internal/store/pg/models.go, và một*.sql.gocho mỗi file query — được sinh ra, được commit, không bao giờ sửa tay.docs/learning/02.1-sqlc-store.md.
Sửa đổi
internal/store/pg/tenant.go,internal/store/pg/user.go,internal/store/pg/apikey.go,internal/store/pg/audit.go,internal/store/pg/auth.go,internal/store/pg/scoped.go— thay các literal SQL và việc scan bằng các lời gọi được sinh ra và các mapper.internal/store/pg/audit_test.go— check chỉ-thêm đọcqueries/audit.sqlvàaudit.sql.gothay vìaudit.go.Makefile— các targetgeneratevàsqlc-check, ghim phiên bản sqlc..golangci.yml— loại trừ các file được sinh ra nếu một linter báo lỗi.docs/tech-stack.md,docs/roadmap.md,docs/system-architecture.md,docs/learning/README.md,README.md— ghi lại sqlc và lý do; liệt kê bài học mới.plans/260922-0123-hango-agent-harness-roadmap/build_plan_html.go,plans/260922-0123-hango-agent-harness-roadmap/plan-template.html— chấp nhận số phase thập phân.plans/260922-0123-hango-agent-harness-roadmap/phase-16-hardening-rls-ci-release.md— CI chạymake sqlc-check.
Các bước triển khai
- Ghim sqlc trong
Makefile(SQLC := go run github.com/sqlc-dev/sqlc/cmd/[email protected], bản release mới nhất) và thêmgeneratevàsqlc-check. Xác nhận nó build được trên máy này (lần đầu nó biên dịchpg_querybằng cgo). - Viết
sqlc.yamlvới các tùy chọn và override ở trên. Chạysqlc compiletrên migration goose để xác nhận nó đọc phần Up và bỏ qua Down. - Viết
queries/tenants.sql: create, get, get by slug, list, platform, user tenant, lock (for no key update). Sinh code, rồi viết lạitenant.gothành các mapper. Chạy các test tenant. - Viết
queries/users.sqlvàqueries/api_keys.sql, gồm cả guardinsert ... select ... from userskhi tạo key, join đếm key owner, và revoke với predicate member tùy chọn. Viết lạiuser.go,apikey.govàscoped.go(ownUserID). Chạy các test tenancy. - Viết
queries/audit.sqlvới append và một list dùngsqlc.nargcho các bộ lọc member,since,actionvàtarget_idcùng một limit được bind. Viết lạiaudit.govà trỏ test chỉ-thêm vào file query và file được sinh ra. - Viết
queries/auth.sqlchoResolveKeyvà viết lạiauth.go. - Xóa các helper không còn dùng (
scanTenant,collect, các hằng số cột) chỉ khi không còn gì tham chiếu chúng. Nếu một query thật sự không biểu đạt được, giữ nó viết tay kèm một comment nêu giới hạn của sqlc; ghi lại trong learning doc. - Chạy
make generatehai lần và xác nhận không có diff; chạymake sqlc-check; chạygo build ./... && go vet ./... && go test -race ./... && go test -short ./... && golangci-lint run. - Dạy
build_plan_html.gosố phase thập phân (parse\d+(\.\d+)?, lưu dưới dạng float64, in bằng%g) và kiểm tra các lần tra phase của template vẫn khớp. Thêm phase 2.1 vàoplan.mdvà sinh lạiplan.html. Build site Hugo và xác nhận phase 2.1 cùng bài học của nó nằm giữa phase 2 và 3. - Cập nhật các docs đã liệt kê ở trên, viết learning doc, và thêm
make sqlc-checkvào bước CI của phase 16.
Dàn ý tài liệu học
docs/learning/02.1-sqlc-store.md phải có:
- Khái niệm: sqlc đọc gì (schema từ các migration, query từ các file
.sql), nó sinh ra gì, và nó kiểm tra gì ngay lúc sinh code mà SQL viết tay chỉ phát hiện được trong test. - Lựa chọn thiết kế: sqlc là mặc định với một lối thoát hẹp thay vì một mô hình lai chia đôi; code được sinh ra nằm trong package
pgđể tôn trọng giới hạn số package; override kiểu thay vìpgtype;go runvới phiên bản được ghim thay vì một dependency tool tronggo.mod. - Tenancy giờ nằm ở đâu:
tenant_idlà tham số bắt buộc trong SQL, scoped handle vẫn là bên gọi duy nhất, và các test cross-tenant không đổi. - Các giới hạn gặp phải trong thực tế: bộ lọc tùy chọn với
sqlc.narg, vì saosqlc.nargkhông phân biệt được “chưa đặt” với “đặt thành NULL”, một struct row cho mỗi hình dạng query, suy luận kiểu cần một phép ép::type, và một lối thoát sẽ trông thế nào. - Cách debug tầng này: một file được sinh ra bị cũ sẽ bị
make sqlc-checkbắt; một query chậm được tìm trong Tempo qua comment-- name:ở đầudb.query.text; một sai lệch kiểu hiện ra lúcsqlc generate, không phải lúc chạy. - So sánh với goclaw:
database/sqlcộng sqlx với SQL viết tay, được chọn vì goclaw cũng hỗ trợ SQLite; hango chỉ dùng Postgres, nên có thể dùng pgx gốc và sqlc. - Bài tập: thêm một cột vào
usersvà lần theo các lỗi biên dịch; viết list audit với một cột sắp xếp quyết định lúc runtime và xem vì sao nó cần một lối thoát. - Ngân sách thời gian: 6h triển khai + 1h viết learning doc.
Tiêu chí thành công
-
make generatekhông tạo diff trên một cây sạch, vàmake sqlc-checkpass. - Không còn literal chuỗi SQL nào trong
internal/store/pgngoàiqueries/và các file được sinh ra, trừ các lối thoát có ghi chú (dự kiến: không có). -
go build ./... && go vet ./... && go test -race ./...pass khi Docker đang chạy, vàgo test -short ./...pass. - Các test của phase 2 trong
internal/store/pg,internal/servervàinternal/apppass mà không sửa gì ngoài các đường dẫn file của check chỉ-thêm audit. -
golangci-lint runbáo 0 vấn đề. -
go list -deps ./cmd/hangokhông liệt kê package sqlc haypg_querynào, vàgo.modkhông require sqlc. - Không field nào của struct được sinh ra có kiểu
pgtype. -
plan.htmlsinh lại với phase 2.1 trong bảng phase và build order, và site Hugo liệt kê phase 2.1 cùng bài học của nó giữa phase 2 và 3. -
docs/tech-stack.mdghi lại sqlc và lý do, vàdocs/learning/02.1-sqlc-store.mdđã được viết.
Bằng chứng, 2026-10-15: make generate chạy hai lần không để lại diff và make sqlc-check pass. go build ./..., go vet ./... và go test -race -count=1 ./... pass với Docker; cả 12 test trong internal/store/pg đã chạy trên Postgres 17 và pass. go test -short ./... pass và golangci-lint run báo 0 vấn đề. go list -deps ./cmd/hango không có package sqlc hay pg_query, go.mod không có sqlc, và rg pgtype internal/store/pg không tìm thấy gì. Literal backtick duy nhất còn lại trong các file store viết tay là giá trị mặc định {} của audit detail. go run build_plan_html.go liệt kê 21 phase, và bản build Hugo sắp sidebar của plan theo thứ tự 1, 2, 2.1, 3.
Các sai lệch, được quyết định trong lúc triển khai:
- Weight của site trở thành phase × 10 (phase 2 là 20, phase 2.1 là 21) cho mọi file phase và learning doc. Weight của Hugo là số nguyên, và khi phase 2 và 2.1 cùng weight 2, Hugo phá thế hòa theo title và đặt 2.1 lên trước.
docs/docs-site.mdvàplan.mdnêu quy tắc mới. - Các mapper là các phép chuyển struct Go (
store.User(u)) thay vì các hàmtoUser. Việc đổi têntrace_llm_content: TraceLLMContentvàapi_key_id: APIKeyID, cộng với đặt aliaskey_hash as hashtrongResolveAPIKey, làm các struct được sinh ra có field giống hệt các kiểu của store. Audit event giữ một mapper theo từng field vì các tham chiếu NULL trở thànhuuid.Nil(). - Test chỉ-thêm kiểm tra mọi câu lệnh trong
queries/*.sql, đã bỏ comment, và thất bại với câu lệnh nào nhắcaudit_eventscùng UPDATE, DELETE hoặc TRUNCATE. Nó cũng kiểm tra các literal thô trongaudit.go, nơi một lối thoát sẽ nằm. Nó không đọcaudit.sql.gođược sinh ra, vì file đó suy ra từ các file query. Code review chỉ ra rằng một thao tác purge có thể được thêm vào bất kỳ file query nào, nên check bao phủ tất cả chúng; một file thăm dò códelete from audit_eventsđã làm nó thất bại. make generatexóainternal/store/pg/*.sql.gotrước khi chạy sqlc, vì sqlc không bao giờ xóa đầu ra vàsqlc diffkhông báo một file còn sót từ một file query đã đổi tên..golangci.ymlkhông cần đổi: các linter vốn đã bỏ qua các file được đánh dấuCode generated ... DO NOT EDIT.plan-template.htmlcần một thay đổi regex, để các liên kết tớiphase-02.1-*.mdmở modal của phase.- Issue #26 khác spec này ở ba chỗ. Các transaction giữ
queriercủa phase 2, được truyền vàoNew(s.q), thay vìQueries.WithTx, vìInTxđã hoán đổi một pool thành một transaction vàWithTxsẽ thêm một đường thứ hai. Không có allowlist định danh, vì không query nào cần một lối thoát; cái đầu tiên sẽ thêm nó. Check CI được hoãn sang phase 16, phase chưa có CI nào trước nó, và bước của nó giờ chạymake sqlc-check. - Các file query không có comment cấp file, vì sqlc gắn comment đầu file vào doc Go của query đầu tiên. Mỗi ghi chú nằm trên query mà nó mô tả.
make generatechạysqlc compiletrước khi xóa các file được sinh ra cũ, nên một lỗi gõ trong query để nguyên code đã commit.- Các bước store của phase 4 được viết lại cho sqlc: các file query, predicate member
sqlc.narg, và một insertunnestcho các batch message.
Đánh giá rủi ro
| Rủi ro | Khả năng x Tác động | Dấu hiệu | Cách xử lý đã quyết định trước |
|---|---|---|---|
Một method được sinh ra bị gọi ngoài scoped với một tenant id tự chọn | Low x Critical | Review thấy New( nằm ngoài các file sub-store | Code được sinh ra nằm trong package pg, nên chỉ pg mới với tới được một pool để gọi nó; test ranh giới vẫn cấm pgx ở nơi khác |
Các bộ lọc sqlc.narg khiến planner chọn plan tệ hơn | Low x Low | Span ListAuditEvents chậm | List audit được dẫn bởi index tenant_id, occurred_at; xem lại bằng explain nếu một span cho thấy vấn đề |
| Bản build cgo của sqlc thất bại trên một máy hoặc image CI | Medium x Low | make generate không build được sqlc | Code được sinh ra được commit, nên build và test hango không bao giờ cần sqlc; chỉ việc sinh lại mới cần |
| sqlc đọc sai một migration về sau (plpgsql, các policy RLS ở phase 16) | Medium x Medium | sqlc generate báo lỗi trên một migration | Sửa câu lệnh hoặc thêm hình dạng bảng vào một file schema chỉ dành cho sqlc; ghi lại như một giới hạn của sqlc |
| Số phase thập phân làm hỏng trang plan | Medium x Low | build_plan_html.go báo lỗi hoặc trang sai thứ tự | Bước 9 đổi parser và kiểm tra trang trước khi đóng phase |
Rollback
Revert commit của phase. Schema không đổi, nên không migration nào chạy ở cả hai chiều, và store viết tay của phase 2 trở lại đúng như cũ.