feat: ship loose-offline dbsync (validate, agent push, LWW audit)
Add UUID/FK channel checks, agent whitelist/push APIs, bindings, super-admin LWW audit with rollback, reconcile rate limits, and sync docs. Default customers stay opt-in; company conflict UI is removed. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
256
platform/internal/dbsync/push.go
Normal file
256
platform/internal/dbsync/push.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package dbsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PushItem 本机 agent → 线上 A 的一条变更(形态 B)。
|
||||
type PushItem struct {
|
||||
Table string `json:"table"`
|
||||
Op string `json:"op"` // insert|update|update_by_id|delete|upsert
|
||||
RowPK string `json:"row_pk"`
|
||||
Row map[string]any `json:"row"` // 单行;与 Rows 二选一
|
||||
Rows []map[string]any `json:"rows"` // 兼容批量 insert
|
||||
Version int64 `json:"version"` // outbox 单调 version;0 则用时间戳
|
||||
ClientOutboxID string `json:"client_outbox_id"`
|
||||
OnlineDBID string `json:"online_db_id"`
|
||||
}
|
||||
|
||||
// PushResult 单条推送结果。
|
||||
type PushResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Applied bool `json:"applied"`
|
||||
Skipped bool `json:"skipped"`
|
||||
Conflict bool `json:"conflict"`
|
||||
AppliedVersion int64 `json:"applied_version"`
|
||||
Message string `json:"message"`
|
||||
ClientOutboxID string `json:"client_outbox_id,omitempty"`
|
||||
}
|
||||
|
||||
// PushToRemote 仅打开 remote,按 LWW/幂等将变更落到线上 A。不连本机 SQLite。
|
||||
func PushToRemote(ctx context.Context, ch *Channel, store *FileStore, item PushItem) (*PushResult, error) {
|
||||
if ch == nil {
|
||||
return nil, fmt.Errorf("channel is nil")
|
||||
}
|
||||
table := strings.TrimSpace(item.Table)
|
||||
if table == "" {
|
||||
return nil, fmt.Errorf("table required")
|
||||
}
|
||||
if !tableInChannel(ch, table) {
|
||||
return nil, fmt.Errorf("table %s 不在通道白名单", table)
|
||||
}
|
||||
pkCol := "id"
|
||||
if ch.PKColumns != nil && strings.TrimSpace(ch.PKColumns[table]) != "" {
|
||||
pkCol = ch.PKColumns[table]
|
||||
}
|
||||
|
||||
op, payload, rowPK, err := normalizePushPayload(item, pkCol)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
version := item.Version
|
||||
if version <= 0 {
|
||||
version = time.Now().UnixNano()
|
||||
}
|
||||
|
||||
db, err := Open(ch.Remote.Driver, ch.Remote.DSN)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open remote: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := EnsureMeta(ctx, db, ch.Remote.Driver); err != nil {
|
||||
return nil, fmt.Errorf("ensure meta: %w", err)
|
||||
}
|
||||
|
||||
res := &PushResult{
|
||||
ClientOutboxID: item.ClientOutboxID,
|
||||
AppliedVersion: version,
|
||||
}
|
||||
|
||||
tgtVer, has, err := GetMetaVersion(ctx, db, ch.Remote.Driver, table, rowPK)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 幂等:同 version 已落地 → 跳过
|
||||
if has && tgtVer == version {
|
||||
res.OK = true
|
||||
res.Skipped = true
|
||||
res.Message = "already applied (same version)"
|
||||
return res, nil
|
||||
}
|
||||
if has && tgtVer > version {
|
||||
policy := ch.ConflictPolicy
|
||||
if policy == "" {
|
||||
policy = PolicyLWWSource // B→A 默认偏源
|
||||
}
|
||||
switch policy {
|
||||
case PolicyLWWTarget:
|
||||
loser := payload
|
||||
winner := SnapshotTargetRow(ctx, db, ch.Remote.Driver, table, pkCol, rowPK)
|
||||
RecordLwwOverride(store, LwwOverride{
|
||||
TenantID: ch.TenantID,
|
||||
ChannelID: ch.ID,
|
||||
Table: table,
|
||||
RowPK: rowPK,
|
||||
Op: op,
|
||||
Entry: EntryAgentPush,
|
||||
Policy: string(PolicyLWWTarget),
|
||||
Outcome: OutcomeKept,
|
||||
LoserPayload: loser,
|
||||
WinnerPayload: winner,
|
||||
TargetVer: tgtVer,
|
||||
SourceVer: version,
|
||||
})
|
||||
res.OK = true
|
||||
res.Skipped = true
|
||||
res.Message = "target newer; kept (lww_target)"
|
||||
return res, nil
|
||||
case PolicyLWWSource:
|
||||
loser := SnapshotTargetRow(ctx, db, ch.Remote.Driver, table, pkCol, rowPK)
|
||||
if err := ApplyChange(ctx, db, ch.Remote.Driver, table, pkCol, op, payload, version); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
RecordLwwOverride(store, LwwOverride{
|
||||
TenantID: ch.TenantID,
|
||||
ChannelID: ch.ID,
|
||||
Table: table,
|
||||
RowPK: rowPK,
|
||||
Op: op,
|
||||
Entry: EntryAgentPush,
|
||||
Policy: string(PolicyLWWSource),
|
||||
Outcome: OutcomeApplied,
|
||||
LoserPayload: loser,
|
||||
WinnerPayload: payload,
|
||||
TargetVer: tgtVer,
|
||||
SourceVer: version,
|
||||
})
|
||||
if store != nil {
|
||||
_ = store.PatchStats(ch.ID, func(c *Channel) { c.Stats.PushedOK++ })
|
||||
}
|
||||
res.OK = true
|
||||
res.Applied = true
|
||||
res.Message = "applied (lww_source override)"
|
||||
return res, nil
|
||||
default:
|
||||
if store != nil {
|
||||
_ = store.AddConflict(Conflict{
|
||||
TenantID: ch.TenantID,
|
||||
ChannelID: ch.ID,
|
||||
Table: table,
|
||||
RowPK: rowPK,
|
||||
Op: op,
|
||||
Source: "agent",
|
||||
Payload: payload,
|
||||
TargetVer: tgtVer,
|
||||
SourceVer: version,
|
||||
Message: "target version newer than agent push",
|
||||
})
|
||||
_ = store.PatchStats(ch.ID, func(c *Channel) { c.Stats.Conflicts++ })
|
||||
}
|
||||
res.OK = true
|
||||
res.Skipped = true
|
||||
res.Conflict = true
|
||||
res.Message = "queued conflict; target newer"
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := ApplyChange(ctx, db, ch.Remote.Driver, table, pkCol, op, payload, version); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if store != nil {
|
||||
_ = store.PatchStats(ch.ID, func(c *Channel) { c.Stats.PushedOK++ })
|
||||
}
|
||||
res.OK = true
|
||||
res.Applied = true
|
||||
res.Message = "applied"
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// PushBatchToRemote 保序批量;遇错即停,已成功条数在返回切片中。
|
||||
func PushBatchToRemote(ctx context.Context, ch *Channel, store *FileStore, items []PushItem) ([]PushResult, error) {
|
||||
out := make([]PushResult, 0, len(items))
|
||||
for i, it := range items {
|
||||
r, err := PushToRemote(ctx, ch, store, it)
|
||||
if err != nil {
|
||||
fail := PushResult{
|
||||
OK: false,
|
||||
Message: err.Error(),
|
||||
ClientOutboxID: it.ClientOutboxID,
|
||||
}
|
||||
out = append(out, fail)
|
||||
return out, fmt.Errorf("item[%d]: %w", i, err)
|
||||
}
|
||||
out = append(out, *r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func tableInChannel(ch *Channel, table string) bool {
|
||||
for _, t := range uniqueTables(ch.Local.Tables, ch.Remote.Tables) {
|
||||
if strings.EqualFold(strings.TrimSpace(t), table) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizePushPayload(item PushItem, pkCol string) (op string, payload string, rowPK string, err error) {
|
||||
rawOp := strings.ToLower(strings.TrimSpace(item.Op))
|
||||
if rawOp == "" {
|
||||
rawOp = "upsert"
|
||||
}
|
||||
switch rawOp {
|
||||
case "delete":
|
||||
op = "delete"
|
||||
case "insert", "update", "update_by_id", "upsert":
|
||||
op = "upsert"
|
||||
default:
|
||||
return "", "", "", fmt.Errorf("unsupported op: %s", item.Op)
|
||||
}
|
||||
|
||||
row := item.Row
|
||||
if row == nil && len(item.Rows) > 0 {
|
||||
row = item.Rows[0]
|
||||
}
|
||||
rowPK = strings.TrimSpace(item.RowPK)
|
||||
if row != nil {
|
||||
if rowPK == "" {
|
||||
rowPK = strings.TrimSpace(fmt.Sprint(row[pkCol]))
|
||||
}
|
||||
if op == "upsert" {
|
||||
// 保证 payload 含主键
|
||||
if _, ok := row[pkCol]; !ok && rowPK != "" {
|
||||
row = copyMap(row)
|
||||
row[pkCol] = rowPK
|
||||
}
|
||||
}
|
||||
}
|
||||
if rowPK == "" || rowPK == "<nil>" {
|
||||
return "", "", "", fmt.Errorf("row_pk required")
|
||||
}
|
||||
if op == "delete" && row == nil {
|
||||
row = map[string]any{pkCol: rowPK}
|
||||
}
|
||||
if row == nil {
|
||||
return "", "", "", fmt.Errorf("row required for %s", op)
|
||||
}
|
||||
b, err := json.Marshal(row)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
return op, string(b), rowPK, nil
|
||||
}
|
||||
|
||||
func copyMap(m map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(m)+1)
|
||||
for k, v := range m {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user