package dbsync import ( "context" "database/sql" "encoding/json" "fmt" "strings" "time" ) func PollOutbox(ctx context.Context, db *sql.DB, driver Driver, limit int) ([]OutboxRow, error) { if limit <= 0 { limit = 100 } q := fmt.Sprintf(`SELECT id, table_name, row_pk, op, payload, version, created_at FROM %s WHERE synced_at IS NULL ORDER BY id ASC LIMIT %d`, OutboxTable, limit) rows, err := db.QueryContext(ctx, q) if err != nil { return nil, err } defer rows.Close() var out []OutboxRow for rows.Next() { var r OutboxRow var created any if err := rows.Scan(&r.ID, &r.TableName, &r.RowPK, &r.Op, &r.Payload, &r.Version, &created); err != nil { return nil, err } switch v := created.(type) { case time.Time: r.CreatedAt = v case string: t, _ := time.Parse("2006-01-02 15:04:05", v) r.CreatedAt = t case []byte: t, _ := time.Parse("2006-01-02 15:04:05", string(v)) r.CreatedAt = t } out = append(out, r) } return out, rows.Err() } func MarkSynced(ctx context.Context, db *sql.DB, driver Driver, ids []int64) error { if len(ids) == 0 { return nil } nowExpr := "CURRENT_TIMESTAMP" switch driver { case DriverSQLite: nowExpr = "datetime('now')" case DriverMySQL: nowExpr = "UTC_TIMESTAMP(3)" case DriverPostgres: nowExpr = "now()" } placeholders := make([]string, len(ids)) args := make([]any, len(ids)) for i, id := range ids { placeholders[i] = "?" if driver == DriverPostgres { placeholders[i] = fmt.Sprintf("$%d", i+1) } args[i] = id } q := fmt.Sprintf(`UPDATE %s SET synced_at = %s WHERE id IN (%s)`, OutboxTable, nowExpr, strings.Join(placeholders, ",")) _, err := db.ExecContext(ctx, q, args...) return err } func FetchRowJSON(ctx context.Context, db *sql.DB, driver Driver, table, pkCol, pkVal string) (string, map[string]any, error) { q := fmt.Sprintf(`SELECT * FROM %s WHERE %s = ? LIMIT 1`, quoteIdent(driver, table), quoteIdent(driver, pkCol)) if driver == DriverPostgres { q = fmt.Sprintf(`SELECT * FROM %s WHERE %s = $1 LIMIT 1`, quoteIdent(driver, table), quoteIdent(driver, pkCol)) } rows, err := db.QueryContext(ctx, q, pkVal) if err != nil { return "", nil, err } defer rows.Close() cols, err := rows.Columns() if err != nil { return "", nil, err } if !rows.Next() { return "", nil, sql.ErrNoRows } raw := make([]any, len(cols)) ptrs := make([]any, len(cols)) for i := range raw { ptrs[i] = &raw[i] } if err := rows.Scan(ptrs...); err != nil { return "", nil, err } m := map[string]any{} for i, c := range cols { m[c] = normalizeValue(raw[i]) } b, err := json.Marshal(m) if err != nil { return "", nil, err } return string(b), m, nil } func normalizeValue(v any) any { switch x := v.(type) { case nil: return nil case []byte: return string(x) case time.Time: return x.UTC().Format(time.RFC3339Nano) default: return x } } func GetMetaVersion(ctx context.Context, db *sql.DB, driver Driver, table, pk string) (int64, bool, error) { q := fmt.Sprintf(`SELECT version FROM %s WHERE table_name = ? AND row_pk = ?`, MetaTable) if driver == DriverPostgres { q = fmt.Sprintf(`SELECT version FROM %s WHERE table_name = $1 AND row_pk = $2`, MetaTable) } var ver int64 err := db.QueryRowContext(ctx, q, table, pk).Scan(&ver) if err == sql.ErrNoRows { return 0, false, nil } if err != nil { return 0, false, err } return ver, true, nil } func UpsertMeta(ctx context.Context, db *sql.DB, driver Driver, table, pk string, version int64) error { now := time.Now().UTC().Format(time.RFC3339Nano) switch driver { case DriverSQLite: _, err := db.ExecContext(ctx, ` INSERT INTO `+MetaTable+`(table_name,row_pk,version,updated_at) VALUES(?,?,?,?) ON CONFLICT(table_name,row_pk) DO UPDATE SET version=excluded.version, updated_at=excluded.updated_at`, table, pk, version, now) return err case DriverMySQL: _, err := db.ExecContext(ctx, ` INSERT INTO `+MetaTable+`(table_name,row_pk,version,updated_at) VALUES(?,?,?,?) ON DUPLICATE KEY UPDATE version=VALUES(version), updated_at=VALUES(updated_at)`, table, pk, version, now) return err case DriverPostgres: _, err := db.ExecContext(ctx, ` INSERT INTO `+MetaTable+`(table_name,row_pk,version,updated_at) VALUES($1,$2,$3,$4) ON CONFLICT(table_name,row_pk) DO UPDATE SET version=EXCLUDED.version, updated_at=EXCLUDED.updated_at`, table, pk, version, time.Now().UTC()) return err default: return fmt.Errorf("unsupported") } } func ApplyChange(ctx context.Context, db *sql.DB, driver Driver, table, pkCol, op, payload string, version int64) error { return WithApplying(ctx, db, driver, func() error { return applyChangeInner(ctx, db, driver, table, pkCol, op, payload, version) }) } func applyChangeInner(ctx context.Context, db *sql.DB, driver Driver, table, pkCol, op, payload string, version int64) error { switch op { case "delete": var row map[string]any _ = json.Unmarshal([]byte(payload), &row) pkVal := "" if row != nil { if v, ok := row[pkCol]; ok { pkVal = fmt.Sprint(v) } } if pkVal == "" { return fmt.Errorf("delete missing pk") } q := fmt.Sprintf(`DELETE FROM %s WHERE %s = ?`, quoteIdent(driver, table), quoteIdent(driver, pkCol)) if driver == DriverPostgres { q = fmt.Sprintf(`DELETE FROM %s WHERE %s = $1`, quoteIdent(driver, table), quoteIdent(driver, pkCol)) } if _, err := db.ExecContext(ctx, q, pkVal); err != nil { return err } return UpsertMeta(ctx, db, driver, table, pkVal, version) default: // upsert var row map[string]any if err := json.Unmarshal([]byte(payload), &row); err != nil { return err } pkVal := fmt.Sprint(row[pkCol]) if pkVal == "" || pkVal == "" { return fmt.Errorf("upsert missing pk %s", pkCol) } cols := make([]string, 0, len(row)) vals := make([]any, 0, len(row)) for k, v := range row { cols = append(cols, k) vals = append(vals, v) } return upsertRow(ctx, db, driver, table, pkCol, cols, vals, version) } } func upsertRow(ctx context.Context, db *sql.DB, driver Driver, table, pkCol string, cols []string, vals []any, version int64) error { qcols := make([]string, len(cols)) ph := make([]string, len(cols)) for i, c := range cols { qcols[i] = quoteIdent(driver, c) if driver == DriverPostgres { ph[i] = fmt.Sprintf("$%d", i+1) } else { ph[i] = "?" } } pkVal := "" for i, c := range cols { if c == pkCol { pkVal = fmt.Sprint(vals[i]) break } } switch driver { case DriverSQLite: q := fmt.Sprintf(`INSERT INTO %s (%s) VALUES (%s) ON CONFLICT(%s) DO UPDATE SET %s`, quoteIdent(driver, table), strings.Join(qcols, ","), strings.Join(ph, ","), quoteIdent(driver, pkCol), sqliteSetClause(cols, pkCol, driver), ) if _, err := db.ExecContext(ctx, q, vals...); err != nil { return err } case DriverMySQL: sets := make([]string, 0, len(cols)) for _, c := range cols { if c == pkCol { continue } qi := quoteIdent(driver, c) sets = append(sets, qi+"=VALUES("+qi+")") } if len(sets) == 0 { sets = append(sets, quoteIdent(driver, pkCol)+"="+quoteIdent(driver, pkCol)) } q := fmt.Sprintf(`INSERT INTO %s (%s) VALUES (%s) ON DUPLICATE KEY UPDATE %s`, quoteIdent(driver, table), strings.Join(qcols, ","), strings.Join(ph, ","), strings.Join(sets, ",")) if _, err := db.ExecContext(ctx, q, vals...); err != nil { return err } case DriverPostgres: sets := make([]string, 0, len(cols)) for _, c := range cols { if c == pkCol { continue } qi := quoteIdent(driver, c) sets = append(sets, qi+"=EXCLUDED."+qi) } if len(sets) == 0 { sets = append(sets, quoteIdent(driver, pkCol)+"="+quoteIdent(driver, pkCol)) } q := fmt.Sprintf(`INSERT INTO %s (%s) VALUES (%s) ON CONFLICT (%s) DO UPDATE SET %s`, quoteIdent(driver, table), strings.Join(qcols, ","), strings.Join(ph, ","), quoteIdent(driver, pkCol), strings.Join(sets, ",")) if _, err := db.ExecContext(ctx, q, vals...); err != nil { return err } } return UpsertMeta(ctx, db, driver, table, pkVal, version) } func sqliteSetClause(cols []string, pkCol string, driver Driver) string { parts := make([]string, 0, len(cols)) for _, c := range cols { if c == pkCol { continue } qi := quoteIdent(driver, c) parts = append(parts, qi+"=excluded."+qi) } if len(parts) == 0 { return quoteIdent(driver, pkCol) + "=" + quoteIdent(driver, pkCol) } return strings.Join(parts, ",") }