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:
@@ -93,10 +93,24 @@ func (m *Manager) loop(ctx context.Context, id string) {
|
||||
log.Printf("dbsync channel %s: %v", id, err)
|
||||
}
|
||||
ticks++
|
||||
// 约每分钟主键对账一次,补漏(漏投递 / 触发器未装时的存量差)
|
||||
if ticks%120 == 0 && ch.Direction == DirBidirectional {
|
||||
if _, rerr := ReconcileChannel(ctx, ch); rerr != nil {
|
||||
log.Printf("dbsync reconcile %s: %v", id, rerr)
|
||||
// 双向通道自动对账:默认约每 15 分钟一次(限流)
|
||||
autoEvery := 1800 // poll 500ms → ~15min
|
||||
if ch.PollIntervalMS > 0 {
|
||||
autoEvery = int((15 * time.Minute) / (time.Duration(ch.PollIntervalMS) * time.Millisecond))
|
||||
if autoEvery < 60 {
|
||||
autoEvery = 60
|
||||
}
|
||||
}
|
||||
if ticks%autoEvery == 0 && ch.Direction == DirBidirectional {
|
||||
if allow, _ := CanReconcile(ch, 15*time.Minute); allow {
|
||||
if _, rerr := ReconcileChannel(ctx, ch); rerr != nil {
|
||||
log.Printf("dbsync reconcile %s: %v", id, rerr)
|
||||
} else {
|
||||
_ = m.store.PatchStats(id, func(c *Channel) {
|
||||
now := time.Now().UTC()
|
||||
c.LastReconcileAt = &now
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
select {
|
||||
@@ -214,12 +228,63 @@ func (m *Manager) drain(ctx context.Context, ch *Channel, sourceName string, src
|
||||
continue
|
||||
}
|
||||
if has && tgtVer > r.Version {
|
||||
switch ch.ConflictPolicy {
|
||||
policy := ch.ConflictPolicy
|
||||
if policy == "" {
|
||||
policy = PolicyLWWSource
|
||||
}
|
||||
switch policy {
|
||||
case PolicyLWWTarget:
|
||||
loser := payload
|
||||
winner := SnapshotTargetRow(ctx, dst, dstEp.Driver, r.TableName, pkCol, r.RowPK)
|
||||
RecordLwwOverride(m.store, LwwOverride{
|
||||
TenantID: ch.TenantID,
|
||||
ChannelID: ch.ID,
|
||||
Table: r.TableName,
|
||||
RowPK: r.RowPK,
|
||||
Op: r.Op,
|
||||
Entry: EntryDrain,
|
||||
Policy: string(PolicyLWWTarget),
|
||||
Outcome: OutcomeKept,
|
||||
LoserPayload: loser,
|
||||
WinnerPayload: winner,
|
||||
TargetVer: tgtVer,
|
||||
SourceVer: r.Version,
|
||||
})
|
||||
done = append(done, r.ID)
|
||||
continue
|
||||
case PolicyLWWSource:
|
||||
// fallthrough apply
|
||||
loser := SnapshotTargetRow(ctx, dst, dstEp.Driver, r.TableName, pkCol, r.RowPK)
|
||||
if err := ApplyChange(ctx, dst, dstEp.Driver, r.TableName, pkCol, r.Op, payload, r.Version); err != nil {
|
||||
_ = m.store.PatchStats(ch.ID, func(c *Channel) {
|
||||
c.Stats.Retries++
|
||||
c.LastError = err.Error()
|
||||
})
|
||||
continue
|
||||
}
|
||||
RecordLwwOverride(m.store, LwwOverride{
|
||||
TenantID: ch.TenantID,
|
||||
ChannelID: ch.ID,
|
||||
Table: r.TableName,
|
||||
RowPK: r.RowPK,
|
||||
Op: r.Op,
|
||||
Entry: EntryDrain,
|
||||
Policy: string(PolicyLWWSource),
|
||||
Outcome: OutcomeApplied,
|
||||
LoserPayload: loser,
|
||||
WinnerPayload: payload,
|
||||
TargetVer: tgtVer,
|
||||
SourceVer: r.Version,
|
||||
})
|
||||
done = append(done, r.ID)
|
||||
okCount++
|
||||
_ = m.store.PatchStats(ch.ID, func(c *Channel) {
|
||||
if sourceName == "local" {
|
||||
c.Stats.PushedOK++
|
||||
} else {
|
||||
c.Stats.PulledOK++
|
||||
}
|
||||
})
|
||||
continue
|
||||
default:
|
||||
_ = m.store.AddConflict(Conflict{
|
||||
TenantID: ch.TenantID,
|
||||
@@ -264,6 +329,9 @@ func (m *Manager) drain(ctx context.Context, ch *Channel, sourceName string, src
|
||||
|
||||
// PrepareChannel 连接两端、建 outbox/触发器,供「测试/启用」调用。
|
||||
func PrepareChannel(ctx context.Context, ch *Channel) error {
|
||||
if err := ValidateChannelAgainstDB(ctx, ch); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, ep := range []Endpoint{ch.Local, ch.Remote} {
|
||||
db, err := Open(ep.Driver, ep.DSN)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user