Enable Binding-scoped agent push/pull, empty-table schema ensure, SyncPage inspect/drop-table, default module import, and agent-bound publish docs from the 宇恒联调意见. Co-authored-by: Cursor <cursoragent@cursor.com>
185 lines
5.4 KiB
Go
185 lines
5.4 KiB
Go
package dbsync
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
)
|
||
|
||
// SchemaTableSpec 本机 → 线上:空表/表结构 ensure 请求中的一张表。
|
||
type SchemaTableSpec struct {
|
||
Table string `json:"table"`
|
||
PKColumn string `json:"pk_column,omitempty"`
|
||
Columns []string `json:"columns"`
|
||
OnlineDBID string `json:"online_db_id,omitempty"`
|
||
}
|
||
|
||
// SchemaEnsureRequest agent 批量 ensure 空表到线上 A。
|
||
type SchemaEnsureRequest struct {
|
||
OnlineDBID string `json:"online_db_id"`
|
||
Tables []SchemaTableSpec `json:"tables"`
|
||
}
|
||
|
||
// SchemaEnsureItemResult 单表 ensure 结果。
|
||
type SchemaEnsureItemResult struct {
|
||
Table string `json:"table"`
|
||
OK bool `json:"ok"`
|
||
Created bool `json:"created"` // true=本次新建;false=已存在跳过
|
||
Message string `json:"message,omitempty"`
|
||
}
|
||
|
||
// SchemaEnsureResult 批量 ensure 响应。
|
||
type SchemaEnsureResult struct {
|
||
OK bool `json:"ok"`
|
||
Results []SchemaEnsureItemResult `json:"results"`
|
||
Message string `json:"message,omitempty"`
|
||
}
|
||
|
||
// SchemaDescribeRequest 从线上 A 拉取表结构(供本机建空表)。
|
||
type SchemaDescribeRequest struct {
|
||
OnlineDBID string `json:"online_db_id"`
|
||
Tables []string `json:"tables,omitempty"` // 空=全部业务表
|
||
}
|
||
|
||
// SchemaTableDesc 线上表结构描述。
|
||
type SchemaTableDesc struct {
|
||
Name string `json:"name"`
|
||
PKColumn string `json:"pk_column"`
|
||
Columns []string `json:"columns"`
|
||
RowCount int64 `json:"row_count"`
|
||
}
|
||
|
||
// SchemaDescribeResult 表结构列表。
|
||
type SchemaDescribeResult struct {
|
||
OK bool `json:"ok"`
|
||
Tables []SchemaTableDesc `json:"tables"`
|
||
Message string `json:"message,omitempty"`
|
||
}
|
||
|
||
const MaxSchemaEnsureTables = 100
|
||
|
||
// EnsureSchemasOnRemote 在线上 A 为每张表 CREATE IF NOT EXISTS(空表也建)。
|
||
func EnsureSchemasOnRemote(ctx context.Context, ch *Channel, req SchemaEnsureRequest) (*SchemaEnsureResult, error) {
|
||
if ch == nil {
|
||
return nil, fmt.Errorf("channel is nil")
|
||
}
|
||
if len(req.Tables) == 0 {
|
||
return nil, fmt.Errorf("tables required")
|
||
}
|
||
if len(req.Tables) > MaxSchemaEnsureTables {
|
||
return nil, fmt.Errorf("tables limit %d", MaxSchemaEnsureTables)
|
||
}
|
||
db, err := AcquireRemote(ch.Remote.Driver, ch.Remote.DSN)
|
||
if err != nil {
|
||
return nil, wrapOpenRemote(err)
|
||
}
|
||
if err := EnsureMeta(ctx, db, ch.Remote.Driver); err != nil {
|
||
return nil, Retryablef("ensure meta: %v", err)
|
||
}
|
||
|
||
out := &SchemaEnsureResult{OK: true, Results: make([]SchemaEnsureItemResult, 0, len(req.Tables))}
|
||
for _, spec := range req.Tables {
|
||
table := strings.TrimSpace(spec.Table)
|
||
item := SchemaEnsureItemResult{Table: table}
|
||
if table == "" {
|
||
item.Message = "table required"
|
||
out.OK = false
|
||
out.Results = append(out.Results, item)
|
||
continue
|
||
}
|
||
if !tableInChannel(ch, table) {
|
||
item.Message = "table rejected"
|
||
out.OK = false
|
||
out.Results = append(out.Results, item)
|
||
continue
|
||
}
|
||
pkCol := strings.TrimSpace(spec.PKColumn)
|
||
if pkCol == "" && ch.PKColumns != nil && strings.TrimSpace(ch.PKColumns[table]) != "" {
|
||
pkCol = ch.PKColumns[table]
|
||
}
|
||
if pkCol == "" {
|
||
pkCol = "id"
|
||
}
|
||
existed, err := tableExists(ctx, db, ch.Remote.Driver, table)
|
||
if err != nil {
|
||
return nil, Retryablef("table exists: %v", err)
|
||
}
|
||
if err := EnsureTableFromColumns(ctx, db, ch.Remote.Driver, table, pkCol, spec.Columns); err != nil {
|
||
item.Message = err.Error()
|
||
out.OK = false
|
||
out.Results = append(out.Results, item)
|
||
continue
|
||
}
|
||
item.OK = true
|
||
item.Created = !existed
|
||
if existed {
|
||
item.Message = "already exists"
|
||
} else {
|
||
item.Message = "created"
|
||
}
|
||
out.Results = append(out.Results, item)
|
||
}
|
||
out.Message = "empty tables ensured on online A; client should also create missing local tables from schema describe"
|
||
return out, nil
|
||
}
|
||
|
||
// DescribeSchemasFromRemote 列出线上 A 业务表结构(含空表),供本机 CREATE IF NOT EXISTS。
|
||
func DescribeSchemasFromRemote(ctx context.Context, ch *Channel, req SchemaDescribeRequest) (*SchemaDescribeResult, error) {
|
||
if ch == nil {
|
||
return nil, fmt.Errorf("channel is nil")
|
||
}
|
||
db, err := AcquireRemote(ch.Remote.Driver, ch.Remote.DSN)
|
||
if err != nil {
|
||
return nil, wrapOpenRemote(err)
|
||
}
|
||
if err := EnsureMeta(ctx, db, ch.Remote.Driver); err != nil {
|
||
return nil, Retryablef("ensure meta: %v", err)
|
||
}
|
||
|
||
want := map[string]struct{}{}
|
||
for _, t := range req.Tables {
|
||
t = strings.TrimSpace(t)
|
||
if t != "" {
|
||
want[t] = struct{}{}
|
||
}
|
||
}
|
||
|
||
names, err := ListTables(ctx, db, ch.Remote.Driver)
|
||
if err != nil {
|
||
return nil, Retryablef("list tables: %v", err)
|
||
}
|
||
|
||
out := &SchemaDescribeResult{OK: true, Tables: make([]SchemaTableDesc, 0, len(names))}
|
||
for _, name := range names {
|
||
if len(want) > 0 {
|
||
if _, ok := want[name]; !ok {
|
||
continue
|
||
}
|
||
}
|
||
if !tableInChannel(ch, name) {
|
||
continue
|
||
}
|
||
pkCol := "id"
|
||
if ch.PKColumns != nil && strings.TrimSpace(ch.PKColumns[name]) != "" {
|
||
pkCol = ch.PKColumns[name]
|
||
}
|
||
cols, err := listColumns(ctx, db, ch.Remote.Driver, name)
|
||
if err != nil {
|
||
return nil, Retryablef("columns %s: %v", name, err)
|
||
}
|
||
n, _ := countRows(ctx, db, ch.Remote.Driver, name)
|
||
out.Tables = append(out.Tables, SchemaTableDesc{
|
||
Name: name,
|
||
PKColumn: pkCol,
|
||
Columns: cols,
|
||
RowCount: n,
|
||
})
|
||
}
|
||
if len(out.Tables) == 0 {
|
||
out.Message = "online A has no business tables yet; push or ensure-schema first"
|
||
} else {
|
||
out.Message = "use columns to CREATE TABLE IF NOT EXISTS on local B (including empty tables)"
|
||
}
|
||
return out, nil
|
||
}
|