Chuyển đến nội dung

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/pg chạ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ên users, api_keys và audit_events, và các bộ lọc since, action và target_id trê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_id làm tham số bắt buộc, chỉ được bind từ scope bên trong scoped. Không method được sinh ra nào được gọi với một tenant id từ nơi nào khác.
  • ResolveKey vẫ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 qua store.AuthLookup.
  • audit_events vẫ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 (sqlc out: 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 trong plan.md.
  • sqlc được ghim phiên bản trong Makefile và chạy bằng go run github.com/sqlc-dev/sqlc/cmd/sqlc@<version>. Nó không được thêm vào go.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ạ uuid sang uuid.UUID của stdlib Go 1.27 (*uuid.UUID khi nullable), timestamptz sang time.Time (*time.Time khi nullable), jsonb sang json.RawMessage, và các override cột users.role sang store.Role và tenants.kind sang store.TenantKind. Không kiểu pgtype nào xuất hiện trong một struct được sinh ra.
  • make generate sinh lại; make sqlc-check chạy sqlc diff và sqlc vet và thất bại khi code đã commit bị cũ. Hiện chưa có CI; job CI của phase 16 chạy make sqlc-check.
  • Tooling của roadmap chấp nhận một số phase thập phân: build_plan_html.go và plan-template.html coi 2.1 là 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)
}
  • querier giữ Begin, nên InTx không đổi; nó thỏa mãn DBTX được sinh ra, nên New(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ằng mapErr.
  • ownRowsOnly() trở thành ownUserID() *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ứ predicate sqlc.narg cầ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.go cho 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 đọc queries/audit.sql và audit.sql.go thay vì audit.go.
  • Makefile — các target generate và 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ạy make sqlc-check.

Các bước triển khai

  1. Ghim sqlc trong Makefile (SQLC := go run github.com/sqlc-dev/sqlc/cmd/[email protected], bản release mới nhất) và thêm generate và sqlc-check. Xác nhận nó build được trên máy này (lần đầu nó biên dịch pg_query bằng cgo).
  2. Viết sqlc.yaml với các tùy chọn và override ở trên. Chạy sqlc compile trên migration goose để xác nhận nó đọc phần Up và bỏ qua Down.
  3. 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ại tenant.go thành các mapper. Chạy các test tenant.
  4. Viết queries/users.sql và queries/api_keys.sql, gồm cả guard insert ... select ... from users khi tạo key, join đếm key owner, và revoke với predicate member tùy chọn. Viết lại user.go, apikey.go và scoped.go (ownUserID). Chạy các test tenancy.
  5. Viết queries/audit.sql với append và một list dùng sqlc.narg cho các bộ lọc member, since, action và target_id cùng một limit được bind. Viết lại audit.go và trỏ test chỉ-thêm vào file query và file được sinh ra.
  6. Viết queries/auth.sql cho ResolveKey và viết lại auth.go.
  7. 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.
  8. Chạy make generate hai lần và xác nhận không có diff; chạy make sqlc-check; chạy go build ./... && go vet ./... && go test -race ./... && go test -short ./... && golangci-lint run.
  9. Dạy build_plan_html.go số 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ào plan.md và sinh lại plan.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.
  10. Cập nhật các docs đã liệt kê ở trên, viết learning doc, và thêm make sqlc-check và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 run với phiên bản được ghim thay vì một dependency tool trong go.mod.
  • Tenancy giờ nằm ở đâu: tenant_id là 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ì sao sqlc.narg khô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-check bắt; một query chậm được tìm trong Tempo qua comment -- name: ở đầu db.query.text; một sai lệch kiểu hiện ra lúc sqlc generate, không phải lúc chạy.
  • So sánh với goclaw: database/sql cộ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 users và 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 generate không tạo diff trên một cây sạch, và make sqlc-check pass.
  • Không còn literal chuỗi SQL nào trong internal/store/pg ngoài queries/ 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/server và internal/app pass mà không sửa gì ngoài các đường dẫn file của check chỉ-thêm audit.
  • golangci-lint run báo 0 vấn đề.
  • go list -deps ./cmd/hango không liệt kê package sqlc hay pg_query nào, và go.mod không require sqlc.
  • Không field nào của struct được sinh ra có kiểu pgtype.
  • plan.html sinh 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.md ghi 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.md và plan.md nê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àm toUser. Việc đổi tên trace_llm_content: TraceLLMContent và api_key_id: APIKeyID, cộng với đặt alias key_hash as hash trong ResolveAPIKey, 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ành uuid.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ắc audit_events cùng UPDATE, DELETE hoặc TRUNCATE. Nó cũng kiểm tra các literal thô trong audit.go, nơi một lối thoát sẽ nằm. Nó không đọc audit.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 generate xóa internal/store/pg/*.sql.go trước khi chạy sqlc, vì sqlc không bao giờ xóa đầu ra và sqlc diff không báo một file còn sót từ một file query đã đổi tên.
  • .golangci.yml không cần đổi: các linter vốn đã bỏ qua các file được đánh dấu Code generated ... DO NOT EDIT.
  • plan-template.html cần một thay đổi regex, để các liên kết tới phase-02.1-*.md mở modal của phase.
  • Issue #26 khác spec này ở ba chỗ. Các transaction giữ querier của phase 2, được truyền vào New(s.q), thay vì Queries.WithTx, vì InTx đã hoán đổi một pool thành một transaction và WithTx sẽ 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ạy make 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 generate chạy sqlc compile trướ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 insert unnest cho các batch message.

Đánh giá rủi ro

Rủi roKhả năng x Tác độngDấu hiệuCá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ọnLow x CriticalReview thấy New( nằm ngoài các file sub-storeCode đượ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ơnLow x LowSpan ListAuditEvents chậmList 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 CIMedium x Lowmake generate không build được sqlcCode đượ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 Mediumsqlc generate báo lỗi trên một migrationSử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 planMedium x Lowbuild_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ũ.