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>
315 lines
10 KiB
Go
315 lines
10 KiB
Go
package dbsync
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"fmt"
|
||
"strings"
|
||
|
||
_ "github.com/go-sql-driver/mysql"
|
||
_ "github.com/lib/pq"
|
||
_ "modernc.org/sqlite"
|
||
)
|
||
|
||
func Open(driver Driver, dsn string) (*sql.DB, error) {
|
||
var name string
|
||
switch driver {
|
||
case DriverSQLite:
|
||
name = "sqlite"
|
||
if dsn == "" {
|
||
return nil, fmt.Errorf("sqlite dsn required")
|
||
}
|
||
dsn = normalizeSQLiteDSN(dsn)
|
||
case DriverMySQL:
|
||
name = "mysql"
|
||
case DriverPostgres:
|
||
name = "postgres"
|
||
default:
|
||
return nil, fmt.Errorf("unsupported driver: %s", driver)
|
||
}
|
||
db, err := sql.Open(name, dsn)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if driver == DriverSQLite {
|
||
// SQLite 文件锁:单连接更稳,避免 Windows 上并发 open 卡死/EOF
|
||
db.SetMaxOpenConns(1)
|
||
db.SetMaxIdleConns(1)
|
||
} else {
|
||
db.SetMaxOpenConns(5)
|
||
db.SetMaxIdleConns(2)
|
||
}
|
||
if err := db.Ping(); err != nil {
|
||
_ = db.Close()
|
||
return nil, err
|
||
}
|
||
return db, nil
|
||
}
|
||
|
||
// normalizeSQLiteDSN:Windows 反斜杠 → 正斜杠;裸盘符路径补 file:;补 busy_timeout。
|
||
func normalizeSQLiteDSN(dsn string) string {
|
||
dsn = strings.TrimSpace(dsn)
|
||
lower := strings.ToLower(dsn)
|
||
if strings.HasPrefix(lower, "file:") {
|
||
rest := dsn[5:]
|
||
path, query, hasQ := strings.Cut(rest, "?")
|
||
path = strings.ReplaceAll(path, `\`, `/`)
|
||
if hasQ {
|
||
dsn = "file:" + path + "?" + query
|
||
} else {
|
||
dsn = "file:" + path
|
||
}
|
||
} else if strings.Contains(dsn, `\`) || (len(dsn) >= 2 && dsn[1] == ':') {
|
||
path := strings.ReplaceAll(dsn, `\`, `/`)
|
||
dsn = "file:" + path
|
||
}
|
||
if !strings.Contains(strings.ToLower(dsn), "busy_timeout") {
|
||
if strings.Contains(dsn, "?") {
|
||
dsn += "&_pragma=busy_timeout(5000)"
|
||
} else {
|
||
dsn += "?_pragma=busy_timeout(5000)"
|
||
}
|
||
}
|
||
return dsn
|
||
}
|
||
|
||
func quoteIdent(driver Driver, name string) string {
|
||
name = strings.ReplaceAll(name, "`", "")
|
||
name = strings.ReplaceAll(name, `"`, "")
|
||
switch driver {
|
||
case DriverMySQL:
|
||
return "`" + name + "`"
|
||
case DriverPostgres:
|
||
return `"` + name + `"`
|
||
default:
|
||
return `"` + name + `"`
|
||
}
|
||
}
|
||
|
||
func EnsureOutbox(ctx context.Context, db *sql.DB, driver Driver) error {
|
||
var ddl string
|
||
switch driver {
|
||
case DriverSQLite:
|
||
ddl = `CREATE TABLE IF NOT EXISTS ` + OutboxTable + ` (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
table_name TEXT NOT NULL,
|
||
row_pk TEXT NOT NULL,
|
||
op TEXT NOT NULL,
|
||
payload TEXT NOT NULL DEFAULT '',
|
||
version INTEGER NOT NULL,
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||
synced_at TEXT
|
||
);`
|
||
case DriverMySQL:
|
||
ddl = `CREATE TABLE IF NOT EXISTS ` + OutboxTable + ` (
|
||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||
table_name VARCHAR(128) NOT NULL,
|
||
row_pk VARCHAR(255) NOT NULL,
|
||
op VARCHAR(16) NOT NULL,
|
||
payload LONGTEXT NOT NULL,
|
||
version BIGINT NOT NULL,
|
||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||
synced_at DATETIME(3) NULL,
|
||
INDEX idx_outbox_unsynced (synced_at, id)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`
|
||
case DriverPostgres:
|
||
ddl = `CREATE TABLE IF NOT EXISTS ` + OutboxTable + ` (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
table_name TEXT NOT NULL,
|
||
row_pk TEXT NOT NULL,
|
||
op TEXT NOT NULL,
|
||
payload TEXT NOT NULL DEFAULT '',
|
||
version BIGINT NOT NULL,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
synced_at TIMESTAMPTZ
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_ajz_outbox_unsynced ON ` + OutboxTable + ` (synced_at, id);`
|
||
default:
|
||
return fmt.Errorf("unsupported driver")
|
||
}
|
||
_, err := db.ExecContext(ctx, ddl)
|
||
return err
|
||
}
|
||
|
||
func EnsureMeta(ctx context.Context, db *sql.DB, driver Driver) error {
|
||
var ddl string
|
||
switch driver {
|
||
case DriverSQLite:
|
||
ddl = `CREATE TABLE IF NOT EXISTS ` + MetaTable + ` (
|
||
table_name TEXT NOT NULL,
|
||
row_pk TEXT NOT NULL,
|
||
version INTEGER NOT NULL,
|
||
updated_at TEXT NOT NULL,
|
||
PRIMARY KEY (table_name, row_pk)
|
||
);`
|
||
case DriverMySQL:
|
||
ddl = `CREATE TABLE IF NOT EXISTS ` + MetaTable + ` (
|
||
table_name VARCHAR(128) NOT NULL,
|
||
row_pk VARCHAR(255) NOT NULL,
|
||
version BIGINT NOT NULL,
|
||
updated_at DATETIME(3) NOT NULL,
|
||
PRIMARY KEY (table_name, row_pk)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`
|
||
case DriverPostgres:
|
||
ddl = `CREATE TABLE IF NOT EXISTS ` + MetaTable + ` (
|
||
table_name TEXT NOT NULL,
|
||
row_pk TEXT NOT NULL,
|
||
version BIGINT NOT NULL,
|
||
updated_at TIMESTAMPTZ NOT NULL,
|
||
PRIMARY KEY (table_name, row_pk)
|
||
);`
|
||
}
|
||
_, err := db.ExecContext(ctx, ddl)
|
||
return err
|
||
}
|
||
|
||
// InstallTriggers:在业务表上挂触发器,写入 outbox(实时捕获变更)。
|
||
func InstallTriggers(ctx context.Context, db *sql.DB, driver Driver, table, pkCol string) error {
|
||
table = strings.TrimSpace(table)
|
||
pkCol = strings.TrimSpace(pkCol)
|
||
if table == "" || pkCol == "" {
|
||
return fmt.Errorf("table and pk required")
|
||
}
|
||
switch driver {
|
||
case DriverSQLite:
|
||
return installSQLiteTriggers(ctx, db, table, pkCol)
|
||
case DriverMySQL:
|
||
return installMySQLTriggers(ctx, db, table, pkCol)
|
||
case DriverPostgres:
|
||
return installPostgresTriggers(ctx, db, table, pkCol)
|
||
default:
|
||
return fmt.Errorf("unsupported driver")
|
||
}
|
||
}
|
||
|
||
func installSQLiteTriggers(ctx context.Context, db *sql.DB, table, pk string) error {
|
||
if err := EnsureFlagTable(ctx, db, DriverSQLite); err != nil {
|
||
return err
|
||
}
|
||
guard := `(SELECT IFNULL((SELECT v FROM ` + FlagTable + ` WHERE k='applying'),0))=0`
|
||
stmts := []string{
|
||
fmt.Sprintf(`DROP TRIGGER IF EXISTS ajz_sync_ai_%s`, table),
|
||
fmt.Sprintf(`DROP TRIGGER IF EXISTS ajz_sync_au_%s`, table),
|
||
fmt.Sprintf(`DROP TRIGGER IF EXISTS ajz_sync_ad_%s`, table),
|
||
fmt.Sprintf(`CREATE TRIGGER ajz_sync_ai_%s AFTER INSERT ON "%s" WHEN %s BEGIN
|
||
INSERT INTO %s(table_name,row_pk,op,payload,version,created_at)
|
||
VALUES('%s', CAST(NEW."%s" AS TEXT), 'upsert', '',
|
||
CAST(strftime('%%s','now') AS INTEGER)*1000, datetime('now'));
|
||
END;`, table, table, guard, OutboxTable, table, pk),
|
||
fmt.Sprintf(`CREATE TRIGGER ajz_sync_au_%s AFTER UPDATE ON "%s" WHEN %s BEGIN
|
||
INSERT INTO %s(table_name,row_pk,op,payload,version,created_at)
|
||
VALUES('%s', CAST(NEW."%s" AS TEXT), 'upsert', '',
|
||
CAST(strftime('%%s','now') AS INTEGER)*1000, datetime('now'));
|
||
END;`, table, table, guard, OutboxTable, table, pk),
|
||
fmt.Sprintf(`CREATE TRIGGER ajz_sync_ad_%s AFTER DELETE ON "%s" WHEN %s BEGIN
|
||
INSERT INTO %s(table_name,row_pk,op,payload,version,created_at)
|
||
VALUES('%s', CAST(OLD."%s" AS TEXT), 'delete', '',
|
||
CAST(strftime('%%s','now') AS INTEGER)*1000, datetime('now'));
|
||
END;`, table, table, guard, OutboxTable, table, pk),
|
||
}
|
||
for _, s := range stmts {
|
||
if _, err := db.ExecContext(ctx, s); err != nil {
|
||
return fmt.Errorf("sqlite trigger %s: %w", table, err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func installMySQLTriggers(ctx context.Context, db *sql.DB, table, pk string) error {
|
||
drops := []string{
|
||
fmt.Sprintf("DROP TRIGGER IF EXISTS ajz_sync_ai_%s", table),
|
||
fmt.Sprintf("DROP TRIGGER IF EXISTS ajz_sync_au_%s", table),
|
||
fmt.Sprintf("DROP TRIGGER IF EXISTS ajz_sync_ad_%s", table),
|
||
}
|
||
for _, s := range drops {
|
||
_, _ = db.ExecContext(ctx, s)
|
||
}
|
||
creates := []string{
|
||
fmt.Sprintf(`CREATE TRIGGER ajz_sync_ai_%s AFTER INSERT ON `+"`%s`"+` FOR EACH ROW
|
||
BEGIN
|
||
IF IFNULL(@ajz_applying,0)=0 THEN
|
||
INSERT INTO %s(table_name,row_pk,op,payload,version)
|
||
VALUES('%s', CAST(NEW.`+"`%s`"+` AS CHAR), 'upsert', '', UNIX_TIMESTAMP(NOW(3))*1000);
|
||
END IF;
|
||
END`, table, table, OutboxTable, table, pk),
|
||
fmt.Sprintf(`CREATE TRIGGER ajz_sync_au_%s AFTER UPDATE ON `+"`%s`"+` FOR EACH ROW
|
||
BEGIN
|
||
IF IFNULL(@ajz_applying,0)=0 THEN
|
||
INSERT INTO %s(table_name,row_pk,op,payload,version)
|
||
VALUES('%s', CAST(NEW.`+"`%s`"+` AS CHAR), 'upsert', '', UNIX_TIMESTAMP(NOW(3))*1000);
|
||
END IF;
|
||
END`, table, table, OutboxTable, table, pk),
|
||
fmt.Sprintf(`CREATE TRIGGER ajz_sync_ad_%s AFTER DELETE ON `+"`%s`"+` FOR EACH ROW
|
||
BEGIN
|
||
IF IFNULL(@ajz_applying,0)=0 THEN
|
||
INSERT INTO %s(table_name,row_pk,op,payload,version)
|
||
VALUES('%s', CAST(OLD.`+"`%s`"+` AS CHAR), 'delete', '', UNIX_TIMESTAMP(NOW(3))*1000);
|
||
END IF;
|
||
END`, table, table, OutboxTable, table, pk),
|
||
}
|
||
for _, s := range creates {
|
||
if _, err := db.ExecContext(ctx, s); err != nil {
|
||
return fmt.Errorf("mysql trigger %s: %w", table, err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func installPostgresTriggers(ctx context.Context, db *sql.DB, table, pk string) error {
|
||
fn := fmt.Sprintf("ajz_sync_fn_%s", table)
|
||
_, err := db.ExecContext(ctx, fmt.Sprintf(`
|
||
CREATE OR REPLACE FUNCTION %s() RETURNS trigger AS $$
|
||
BEGIN
|
||
IF COALESCE(current_setting('ajz.applying', true), '0') = '1' THEN
|
||
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
|
||
END IF;
|
||
IF TG_OP = 'DELETE' THEN
|
||
INSERT INTO %s(table_name,row_pk,op,payload,version)
|
||
VALUES('%s', OLD.%s::text, 'delete', '', (EXTRACT(EPOCH FROM clock_timestamp())*1000)::bigint);
|
||
RETURN OLD;
|
||
ELSE
|
||
INSERT INTO %s(table_name,row_pk,op,payload,version)
|
||
VALUES('%s', NEW.%s::text, 'upsert', '', (EXTRACT(EPOCH FROM clock_timestamp())*1000)::bigint);
|
||
RETURN NEW;
|
||
END IF;
|
||
END;
|
||
$$ LANGUAGE plpgsql;`, fn, OutboxTable, table, quoteIdent(DriverPostgres, pk), OutboxTable, table, quoteIdent(DriverPostgres, pk)))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
_, _ = db.ExecContext(ctx, fmt.Sprintf(`DROP TRIGGER IF EXISTS ajz_sync_trg_%s ON %s`, table, quoteIdent(DriverPostgres, table)))
|
||
_, err = db.ExecContext(ctx, fmt.Sprintf(`
|
||
CREATE TRIGGER ajz_sync_trg_%s
|
||
AFTER INSERT OR UPDATE OR DELETE ON %s
|
||
FOR EACH ROW EXECUTE PROCEDURE %s();`, table, quoteIdent(DriverPostgres, table), fn))
|
||
return err
|
||
}
|
||
|
||
func ListTables(ctx context.Context, db *sql.DB, driver Driver) ([]string, error) {
|
||
var q string
|
||
switch driver {
|
||
case DriverSQLite:
|
||
q = `SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_ajz_%' ORDER BY name`
|
||
case DriverMySQL:
|
||
q = `SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name NOT LIKE '\_ajz\_%' ORDER BY table_name`
|
||
case DriverPostgres:
|
||
q = `SELECT tablename FROM pg_tables WHERE schemaname='public' AND tablename NOT LIKE '\_ajz\_%' ORDER BY tablename`
|
||
default:
|
||
return nil, fmt.Errorf("unsupported")
|
||
}
|
||
rows, err := db.QueryContext(ctx, q)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
var out []string
|
||
for rows.Next() {
|
||
var n string
|
||
if err := rows.Scan(&n); err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, n)
|
||
}
|
||
return out, rows.Err()
|
||
}
|