Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ jobs:
- name: make test
run: make test

# 冒烟 e2e:桌面 + 移动两个 project,API 全 mock,不需要 PG/Redis。
# 冒烟 e2e:桌面 + 移动两个 project,API 全 mock,不需要 MySQL/Redis。
e2e:
name: e2e
runs-on: ubuntu-latest
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Agent guidance for `agentre-server`.

AgentRe Server — SaaS backend. Accounts + RFC 8628 Device Flow.

Go 1.26 on the [cago](https://github.com/cago-frame/cago) framework, PostgreSQL 18 + Redis 7,
Go 1.26 on the [cago](https://github.com/cago-frame/cago) framework, MySQL 9.7 + Redis 7,
with a React 19 + Vite + Tailwind + shadcn frontend embedded into the binary via `//go:embed`.
Module path is the bare `agentre-server` (not a GitHub path — deliberate, it is not imported by anything).

Expand Down
10 changes: 7 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,16 @@ test-backend: prepare-web-dist
test-frontend:
cd frontend && pnpm install --frozen-lockfile --silent && pnpm test

# 冒烟 e2e(桌面 + 移动两个 project)。scratch 轨道见 e2e/README.md。
# 冒烟 e2e(桌面 + 移动两个 project)+ runner 自身的单测。scratch 轨道见 e2e/README.md。
# 浏览器由 pnpm smoke 自己装,这里只补 CI 要的系统库(mac 上是空跑)。
#
# runner-test 不开浏览器,只测 run-e2e-web.mjs 里那些纯函数(改写 server 配置、解析
# DSN 与 Redis、诊断启动失败)。它住在 e2e/web/ 下,而 web/ 被冒烟轨道整个排除了,
# 所以必须在这里显式点名——否则它一次都不会跑,等于没有。
test-e2e:
cd e2e && pnpm install --frozen-lockfile --silent && pnpm exec playwright install-deps chromium && pnpm smoke
cd e2e && pnpm install --frozen-lockfile --silent && pnpm exec playwright install-deps chromium && pnpm runner-test && pnpm smoke

# web 全链路 e2e(真浏览器 + 真 server + 真 agentred,开发环境 PG/Redis)。
# web 全链路 e2e(真浏览器 + 真 server + 真 agentred,开发环境 MySQL/Redis)。
# 不进 CI、不进 make test——按需运行,见 e2e/README.md「web 全链路」一节。
test-e2e-web:
cd e2e && pnpm exec playwright install chromium && pnpm web
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ curl http://localhost:8443/v1/healthz

```bash
cp configs/config.example.yaml configs/config.yaml # gitignored runtime 配置
# 把 db.dsn / redis.addr 指向你自己的 PostgreSQL + Redis
# 把 db.dsn / redis.addr 指向你自己的 MySQL + Redis
make dev
```

Expand Down
1 change: 0 additions & 1 deletion cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"github.com/cago-frame/cago"
"github.com/cago-frame/cago/configs"
"github.com/cago-frame/cago/database/db"
_ "github.com/cago-frame/cago/database/db/postgres"
"github.com/cago-frame/cago/pkg/component"
"github.com/cago-frame/cago/pkg/opentelemetry/metric"
Comment on lines 7 to 11
"github.com/cago-frame/cago/pkg/opentelemetry/trace"
Expand Down
2 changes: 1 addition & 1 deletion cmd/synce2e/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// - **seed / cleanup** — the desktop logs in through RFC 8628 Device Flow whose
// terminus is GitHub OAuth, which nobody can click in an e2e. `seed` writes an
// account + its devices + one refresh token per device straight into
// PostgreSQL; every run gets its own account and its own fingerprints, and
// MySQL; every run gets its own account and its own fingerprints, and
// `cleanup` removes exactly the rows that run created (scoped by user id — it
// never truncates and never touches a row it did not write).
// - **peer** — a simulated second desktop that speaks the same `/v1/sync/*`
Expand Down
55 changes: 38 additions & 17 deletions cmd/synce2e/seed.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
"strings"
"time"

"gorm.io/driver/postgres"
"gorm.io/driver/mysql"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
Expand Down Expand Up @@ -43,24 +43,24 @@ func openDB(dsn string) (*gorm.DB, error) {
if strings.TrimSpace(dsn) == "" {
return nil, fmt.Errorf("--dsn is required (the harness reads it from agentre-server/configs/config.yaml)")
}
gdb, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: gormlogger.Discard})
gdb, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: gormlogger.Discard})
if err != nil {
// A dead database must be loud: the suite may not silently skip or pretend to pass.
return nil, fmt.Errorf("connect postgres: %w", err)
return nil, fmt.Errorf("connect mysql: %w", err)
}
sqlDB, err := gdb.DB()
if err != nil {
return nil, err
}
if err := sqlDB.Ping(); err != nil {
return nil, fmt.Errorf("ping postgres: %w", err)
return nil, fmt.Errorf("ping mysql: %w", err)
}
return gdb, nil
}

func runSeed(args []string) error {
fs := flag.NewFlagSet("seed", flag.ExitOnError)
dsn := fs.String("dsn", os.Getenv("SYNCE2E_DSN"), "PostgreSQL DSN")
dsn := fs.String("dsn", os.Getenv("SYNCE2E_DSN"), "MySQL DSN")
runID := fs.String("run-id", "", "unique id for this run")
devices := fs.String("devices", "", "comma separated name:kind:platform triples")
if err := fs.Parse(args); err != nil {
Expand All @@ -81,27 +81,48 @@ func runSeed(args []string) error {
now := time.Now().UnixMilli()
out := &seedResult{RunID: *runID, Email: accountEmail(*runID)}
err = gdb.Transaction(func(tx *gorm.DB) error {
if err := tx.Raw(
`INSERT INTO users (email, email_verified, display_name, avatar_url, status, createtime, updatetime)
VALUES (?, true, ?, '', 1, ?, ?) RETURNING id`,
out.Email, "synce2e "+*runID, now, now,
).Scan(&out.UserID).Error; err != nil {
user := struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
Email string `gorm:"column:email"`
EmailVerified bool `gorm:"column:email_verified"`
DisplayName string `gorm:"column:display_name"`
AvatarURL string `gorm:"column:avatar_url"`
Status int `gorm:"column:status"`
Createtime int64 `gorm:"column:createtime"`
Updatetime int64 `gorm:"column:updatetime"`
}{Email: out.Email, EmailVerified: true, DisplayName: "synce2e " + *runID, Status: 1, Createtime: now, Updatetime: now}
if err := tx.Table("users").Create(&user).Error; err != nil {
return fmt.Errorf("insert user: %w", err)
}
out.UserID = user.ID
for _, spec := range specs {
dev := &seededDevice{
Name: spec.name,
Kind: spec.kind,
Platform: spec.platform,
Fingerprint: fmt.Sprintf("synce2e-%s-%s", *runID, spec.name),
}
if err := tx.Raw(
`INSERT INTO devices (user_id, name, kind, platform, version, fingerprint, last_seen_at, status, createtime, updatetime)
VALUES (?, ?, ?, ?, 'e2e', ?, ?, 1, ?, ?) RETURNING id`,
out.UserID, dev.Name, dev.Kind, dev.Platform, dev.Fingerprint, now, now, now,
).Scan(&dev.DeviceID).Error; err != nil {
row := struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
UserID int64 `gorm:"column:user_id"`
Name string `gorm:"column:name"`
Kind string `gorm:"column:kind"`
Platform string `gorm:"column:platform"`
Version string `gorm:"column:version"`
Fingerprint string `gorm:"column:fingerprint"`
LastSeenAt int64 `gorm:"column:last_seen_at"`
Status int `gorm:"column:status"`
Createtime int64 `gorm:"column:createtime"`
Updatetime int64 `gorm:"column:updatetime"`
}{
UserID: out.UserID, Name: dev.Name, Kind: dev.Kind, Platform: dev.Platform,
Version: "e2e", Fingerprint: dev.Fingerprint, LastSeenAt: now,
Status: 1, Createtime: now, Updatetime: now,
}
if err := tx.Table("devices").Create(&row).Error; err != nil {
return fmt.Errorf("insert device %s: %w", spec.name, err)
}
dev.DeviceID = row.ID
plain, err := randomToken()
if err != nil {
return err
Expand Down Expand Up @@ -169,7 +190,7 @@ type cleanupResult struct {

func runCleanup(args []string) error {
fs := flag.NewFlagSet("cleanup", flag.ExitOnError)
dsn := fs.String("dsn", os.Getenv("SYNCE2E_DSN"), "PostgreSQL DSN")
dsn := fs.String("dsn", os.Getenv("SYNCE2E_DSN"), "MySQL DSN")
runID := fs.String("run-id", "", "run id used at seed time")
if err := fs.Parse(args); err != nil {
return err
Expand Down Expand Up @@ -253,7 +274,7 @@ type localPathRow struct {
// device_local_paths namespace actually holds, per device.
func runLocalPaths(args []string) error {
fs := flag.NewFlagSet("local-paths", flag.ExitOnError)
dsn := fs.String("dsn", os.Getenv("SYNCE2E_DSN"), "PostgreSQL DSN")
dsn := fs.String("dsn", os.Getenv("SYNCE2E_DSN"), "MySQL DSN")
runID := fs.String("run-id", "", "run id used at seed time")
if err := fs.Parse(args); err != nil {
return err
Expand Down
4 changes: 2 additions & 2 deletions configs/config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ http:
- "0.0.0.0:8443"

db:
driver: postgres
dsn: "postgres://server:server@127.0.0.1:5432/server?sslmode=disable"
driver: mysql
dsn: "server:server@tcp(127.0.0.1:3306)/server?charset=utf8mb4&parseTime=True&loc=Local"
debug: false
prepareStmt: false

Expand Down
15 changes: 7 additions & 8 deletions deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
```
deploy/
Dockerfile 镜像:前端和后端都在里面构建,产物是单个静态二进制
docker-compose.yml 单机部署:server + PostgreSQL + Redis
docker-compose.yml 单机部署:server + MySQL + Redis
config.docker.yaml compose 用的配置
helm/ Kubernetes 部署
```
Expand Down Expand Up @@ -58,12 +58,11 @@ curl http://localhost:8443/v1/healthz

`{"db_ping":true,"redis":true}` 就是好了,浏览器打开 <http://localhost:8443> 能看到界面。

数据落在仓库根的 `data/pg` 和 `data/redis`,删掉就等于重置。
数据落在仓库根的 `data/mysql` 和 `data/redis`,删掉就等于重置。

Compose 固定使用 PostgreSQL 18.4。PostgreSQL 不能跨大版本直接读取旧数据目录;如果
`data/pg` 来自 PostgreSQL 16 或 17,先用旧版本导出,再导入全新的 PostgreSQL 18
数据目录(或者按 PostgreSQL 官方流程运行 `pg_upgrade`),不要直接执行 `up -d`
让 18 读取旧目录。
Compose 固定使用 MySQL 9.7.2。升级 MySQL 前先做逻辑备份,并按 MySQL 官方
升级路径检查目标版本是否支持直接读取当前数据目录;不要让不兼容的大版本
直接复用 `data/mysql`。

### 要改配置

Expand Down Expand Up @@ -116,7 +115,7 @@ docker run --rm -p 8443:8443 \

## Kubernetes 部署

`helm/` 下是 chart,只部署服务本身——PostgreSQL、Redis、etcd 都用集群里现成的。
`helm/` 下是 chart,只部署服务本身——MySQL、Redis、etcd 都用集群里现成的。

```bash
helm upgrade --install agentre-server ./deploy/helm \
Expand Down Expand Up @@ -151,7 +150,7 @@ k8s 上只有四个引导键从 ConfigMap 进容器(`env`、`debug`、`source`
| key | 内容 |
| --- | --- |
| `logger` | 日志级别。**`logFile.enable` 必须是 `false`**,容器是只读根文件系统,写不了文件 |
| `db` | PostgreSQL 连接串 |
| `db` | MySQL 连接串 |
| `redis` | Redis 地址 |
| `http` | 监听地址,端口要和 chart 的 `containerPort` 一致 |
| `server` | 域名、会话、JWT 密钥、GitHub OAuth。密钥类的都在这里面 |
Expand Down
4 changes: 2 additions & 2 deletions deploy/config.docker.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ http:
- "0.0.0.0:8443"

db:
driver: postgres
dsn: "postgres://server:server@pg:5432/server?sslmode=disable"
driver: mysql
dsn: "server:server@tcp(mysql:3306)/server?charset=utf8mb4&parseTime=True&loc=Local"
debug: false
prepareStmt: false

Expand Down
18 changes: 9 additions & 9 deletions deploy/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,19 @@ services:
AGENTRE_SERVER_OAUTH_GITHUB_CLIENT_SECRET: "${GH_CLIENT_SECRET:-}"
AGENTRE_SERVER_SESSION_SECRET: "${SESSION_SECRET:-}"
depends_on:
pg: { condition: service_healthy }
mysql: { condition: service_healthy }
redis: { condition: service_started }

pg:
image: postgres:18.4-alpine
mysql:
image: mysql:9.7.2
environment:
POSTGRES_USER: server
POSTGRES_PASSWORD: server
POSTGRES_DB: server
# PostgreSQL 18 按大版本保存数据,挂父目录才能支持后续 pg_upgrade。
volumes: ["../data/pg:/var/lib/postgresql"]
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: server
MYSQL_USER: server
MYSQL_PASSWORD: server
volumes: ["../data/mysql:/var/lib/mysql"]
healthcheck:
test: ["CMD", "pg_isready", "-U", "server"]
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -proot --silent"]
interval: 5s
retries: 10

Expand Down
58 changes: 50 additions & 8 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,32 @@ err := db.Ctx(ctx).Transaction(func(tx *gorm.DB) error {

A repository that reaches for `db.Default()` silently escapes the transaction.

### Column collations are part of the contract

A collation decides what "equal" means, so on any column that a `WHERE`, a `JOIN` or a
unique key compares, it is a behavioural choice, not formatting. Pick it explicitly:

| Kind of value | Collation | Why |
| --- | --- | --- |
| Opaque identifiers, hashes, bearer credentials — `sync_id`, `*_fingerprint`, `session_id`, `device_code`, `refresh_token_hash`, `content_hash`, `provider_uid`, enum-ish `kind` | `utf8mb4_0900_bin` | Two values differing in any byte are two different things. Folding them merges distinct records and widens credential matching. |
| Identifiers a human types — `email`, `user_code` | `utf8mb4_0900_as_ci` | Case must not matter: one mailbox is one account, and a code typed lowercase must still match. |
| Text that is only stored and displayed — `display_name`, `name`, `platform`, `version`, `user_agent`, `ip`, `path`, `content_type` | *(table default `utf8mb4_0900_ai_ci`)* | Never compared, so the choice is inert. Leaving it unset marks it as "not load-bearing". |

Two traps, both of which produced real bugs in the PostgreSQL→MySQL move:

- **Only use the `utf8mb4_0900_*` family.** `utf8mb4_bin` and `utf8mb4_general_ci` are
`PAD SPACE`, so they ignore trailing spaces — `'x'` equals `'x '`, and two `sync_id`s
differing only by a trailing space collide on the unique key. Every `_0900_` collation is
`NO PAD`, which is what PostgreSQL `text` does.
`migrations/collation_test.go` fails the build if a `PAD SPACE` collation appears in DDL.
- **`ai_ci` is not "case-insensitive", it is also accent-insensitive.** As an email
collation it makes `e@x.c` and `é@x.c` the same address. `as_ci` is the case-only tier.

Columns compared against each other must share a collation, or MySQL raises *illegal mix of
collations* at query time: `devices.fingerprint`, `sync_objects.agentred_fingerprint`,
`followed_sessions.device_fingerprint` and `device_flow_codes.client_fingerprint` are one
such group; `users.email` and `user_identities.email` are another.

## Routing and auth shapes

`internal/api/router.go` is the one place the whole route tree is visible, and the
Expand Down Expand Up @@ -171,7 +197,7 @@ The deployment runs multiple replicas by default: `deploy/helm/values.yaml` sets
`autoscaling.enabled: true` with `minReplicas: 2`. "There is only one of me" is never a
safe assumption — it is false from the first install, not just under load.

**Shared vs process-local state.** PostgreSQL and Redis are the only state visible to the
**Shared vs process-local state.** MySQL and Redis are the only state visible to the
whole fleet. Anything else — a `sync.Mutex`, a package-level cache, cago's in-process
`cron.Cron()` schedule — lives in one replica's memory and is invisible to its siblings.
If something must happen exactly once, or must see what every replica has done, it has to
Expand All @@ -194,9 +220,22 @@ the transaction and a "deny" committed in that gap would otherwise still hand th
token.

A write with no conditional `UPDATE` to hang the decision on needs the database to arbitrate
some other way. `device_repo.Upsert` is a single `INSERT … ON CONFLICT ("user_id",
"fingerprint") DO UPDATE`, not a find-then-create, so two exchanges for the same device
converge on one row instead of racing to a `uk_devices_user_fingerprint` duplicate-key 500.
some other way. `device_repo.Upsert` writes with `INSERT … ON DUPLICATE KEY UPDATE` and then
reads the settled row back inside the same transaction (MySQL has no `RETURNING`), rather
than a find-then-create, so two exchanges for the same device converge on one row instead of
racing to a `uk_devices_user_fingerprint` duplicate-key 500.

**`ON DUPLICATE KEY UPDATE` only arbitrates when the table has exactly one unique key.**
MySQL fires it on whichever unique key the row collided with, and does not tell you which —
`clause.OnConflict{Columns: …}` is decorative in the MySQL dialect. `devices`,
`sync_account_seqs`, `sync_device_states`, `sync_avatars` and `followed_sessions` each have a
single unique key, so the clause means what it reads like. `sync_objects` has two
(`uk_sync_objects_identity` and `uk_sync_objects_location`), and there the clause would
quietly rewrite *another account row's* content under its own `sync_id`. `sync_repo.Save`
therefore splits into a version-guarded `UPDATE` plus a plain `INSERT`, and discriminates the
resulting `1062` by index name via `internal/pkg/dberr.IsDuplicateKey` — an identity
collision is a lost version race and is swallowed, a location collision is the R4b backstop
and must surface. Before adding an upsert, count the table's unique keys.

Inside a transaction, put the conditional `UPDATE` **first**, before any write that depends
on winning: `ExchangeToken` marks the flow consumed before it touches `devices`, and
Expand All @@ -215,12 +254,15 @@ replica's lock — TryLock-and-let-expire avoids that. A replica that loses the

**Startup one-shot work.** Work that must run exactly once across a concurrently-starting
fleet — migrations are the current example — needs a distributed lock, not just an
in-process guard. `migrations/migrations.go`'s `RunMigrations` takes a PostgreSQL advisory
in-process guard. `migrations/migrations.go`'s `RunMigrations` takes a MySQL named
lock (`withMigrationLock`) on a connection obtained via `sqlDB.Conn(ctx)` before running
gormigrate, because advisory locks are session-scoped and a `*gorm.DB` call can otherwise
gormigrate, because named locks are session-scoped and a `*gorm.DB` call can otherwise
land on a different pooled connection than the one that acquired the lock. It polls
`pg_try_advisory_lock` rather than blocking, up to a 120s budget, so a replica that can't
get the lock fails loudly instead of hanging past its startup probe.
`GET_LOCK(name, 0)` — the zero-timeout form, which returns immediately — rather than letting
`GET_LOCK(name, <timeout>)` block, up to a 120s budget, so a replica that can't get the lock
fails loudly instead of hanging past its startup probe. `GET_LOCK` has three outcomes, not
two: `1` acquired, `0` held by someone else, and `NULL` when an error occurred; only `1`
counts as acquired, and the other two keep polling until the budget runs out.

## How to add an X

Expand Down
2 changes: 1 addition & 1 deletion docs/develop.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ The repo has **no build tags at all**, so `make test` runs everything there is.
```bash
cp configs/config.example.yaml configs/config.yaml # gitignored runtime config
cp .env.example .env # secrets
# point db.dsn / redis.addr in configs/config.yaml at your own PostgreSQL + Redis
# point db.dsn / redis.addr in configs/config.yaml at your own MySQL + Redis
make dev
```

Expand Down
2 changes: 1 addition & 1 deletion docs/references/verification-report-template.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ One or two sentences. The claim being tested — not a changelog.

## How I verified it

Environment (mocked / real PostgreSQL + Redis / against staging), and the commands run.
Environment (mocked / real MySQL + Redis / against staging), and the commands run.

```bash
go run ./cmd/server
Expand Down
Loading
Loading